feat(editor): add debounced model autosave composable
This commit is contained in:
83
src/composables/useAutosave.ts
Normal file
83
src/composables/useAutosave.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Autosave (F7) — the binding between the Model store (C5) and the repository
|
||||
* (C9). It restores the last document on mount, then persists every change with
|
||||
* a short debounce so a burst of edits costs one write, well inside the ≤500 ms
|
||||
* budget. The debounce window is the only data-loss risk, so pending edits are
|
||||
* also flushed when the tab is hidden or closed.
|
||||
*
|
||||
* It lives in a composable, not the store: persistence needs component lifecycle
|
||||
* (mount/unmount) and browser globals, which keep the store pure and testable.
|
||||
* The repository is injected (defaulting to IndexedDB) so it stays swappable.
|
||||
*/
|
||||
import { nextTick, onBeforeUnmount, onMounted, watch } from "vue"
|
||||
import { createRepository, type ModelRepository } from "@/model/repository"
|
||||
import { useModelStore } from "@/store/model"
|
||||
|
||||
/** Coalesce a burst of edits into one write; comfortably under the 500 ms budget. */
|
||||
const DEBOUNCE_MS = 400
|
||||
|
||||
interface AutosaveOptions {
|
||||
repository?: ModelRepository
|
||||
/** Called once after a saved Model is restored (e.g. to fit the view). */
|
||||
onRestore?: () => void
|
||||
}
|
||||
|
||||
export function useAutosave(options: AutosaveOptions = {}): { flush: () => void } {
|
||||
const repository = options.repository ?? createRepository()
|
||||
const store = useModelStore()
|
||||
// Armed only after the initial load, so restoring a document doesn't echo back
|
||||
// a redundant save of what we just read.
|
||||
let ready = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
if (!ready) return
|
||||
// Plain, proxy-free clone: idb structured-clones on put and would choke on
|
||||
// the Vue reactive proxy (the same reason undo snapshots use JSON).
|
||||
await repository.save(JSON.parse(JSON.stringify(store.model)) as typeof store.model)
|
||||
}
|
||||
|
||||
function schedule(): void {
|
||||
if (timer !== null) clearTimeout(timer)
|
||||
timer = setTimeout(() => void save(), DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (timer !== null) void save()
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
if (document.visibilityState === "hidden") flush()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.model,
|
||||
() => {
|
||||
if (ready) schedule()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = await repository.load()
|
||||
if (saved) store.setModel(saved)
|
||||
// Let the watch tick from setModel pass before arming, so it isn't re-saved.
|
||||
await nextTick()
|
||||
if (saved) options.onRestore?.()
|
||||
ready = true
|
||||
document.addEventListener("visibilitychange", onVisibilityChange)
|
||||
window.addEventListener("beforeunload", flush)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
window.removeEventListener("beforeunload", flush)
|
||||
flush()
|
||||
})
|
||||
|
||||
return { flush }
|
||||
}
|
||||
Reference in New Issue
Block a user