diff --git a/src/serialization/editor-autosave.ts b/src/serialization/editor-autosave.ts new file mode 100644 index 00000000..b3e1f7fa --- /dev/null +++ b/src/serialization/editor-autosave.ts @@ -0,0 +1,128 @@ +import type { SaveSceneDocumentDraftResult } from "./local-draft-storage"; + +export type EditorAutosaveScope = "document" | "viewport" | "all"; + +export interface EditorAutosaveRequest { + document: boolean; + viewportLayout: boolean; +} + +export interface EditorAutosaveControllerOptions { + debounceMs?: number; + onComplete?: (result: SaveSceneDocumentDraftResult) => void; + saveDraft: ( + request: EditorAutosaveRequest + ) => Promise | SaveSceneDocumentDraftResult; +} + +const UP_TO_DATE_RESULT: SaveSceneDocumentDraftResult = { + status: "saved", + message: "Autosave is already up to date." +}; + +function createEmptyRequest(): EditorAutosaveRequest { + return { + document: false, + viewportLayout: false + }; +} + +function hasPendingWork(request: EditorAutosaveRequest): boolean { + return request.document || request.viewportLayout; +} + +function mergeScopeIntoRequest( + request: EditorAutosaveRequest, + scope: EditorAutosaveScope +) { + if (scope === "document" || scope === "all") { + request.document = true; + } + + if (scope === "viewport" || scope === "all") { + request.viewportLayout = true; + } +} + +export class EditorAutosaveController { + private readonly debounceMs: number; + private readonly onComplete: + | ((result: SaveSceneDocumentDraftResult) => void) + | undefined; + private readonly saveDraft: ( + request: EditorAutosaveRequest + ) => Promise | SaveSceneDocumentDraftResult; + private timeoutId: number | null = null; + private pendingRequest = createEmptyRequest(); + private inFlight = false; + private drainPromise: Promise | null = null; + + constructor(options: EditorAutosaveControllerOptions) { + this.debounceMs = options.debounceMs ?? 200; + this.onComplete = options.onComplete; + this.saveDraft = options.saveDraft; + } + + schedule(scope: EditorAutosaveScope = "all") { + mergeScopeIntoRequest(this.pendingRequest, scope); + this.clearPendingTimeout(); + this.timeoutId = window.setTimeout(() => { + this.timeoutId = null; + void this.drain(); + }, this.debounceMs); + } + + flush(scope?: EditorAutosaveScope): Promise { + if (scope !== undefined) { + mergeScopeIntoRequest(this.pendingRequest, scope); + } + + this.clearPendingTimeout(); + return this.drain(); + } + + dispose() { + this.clearPendingTimeout(); + } + + private clearPendingTimeout() { + if (this.timeoutId === null) { + return; + } + + window.clearTimeout(this.timeoutId); + this.timeoutId = null; + } + + private drain(): Promise { + if (this.inFlight) { + return this.drainPromise ?? Promise.resolve(UP_TO_DATE_RESULT); + } + + this.drainPromise = this.runPendingSaves(); + return this.drainPromise; + } + + private async runPendingSaves(): Promise { + this.inFlight = true; + let lastResult = UP_TO_DATE_RESULT; + + try { + while (hasPendingWork(this.pendingRequest)) { + const request = this.pendingRequest; + this.pendingRequest = createEmptyRequest(); + lastResult = await this.saveDraft({ ...request }); + this.onComplete?.(lastResult); + } + + return lastResult; + } finally { + this.inFlight = false; + this.drainPromise = null; + + if (hasPendingWork(this.pendingRequest)) { + void this.drain(); + } + } + } +}