import { scopedStorageKey } from "@/lib/user-scope"; import type { DirectorScene } from "@/types/director"; export type DirectorSaveStatus = "dirty" | "saving" | "saved" | "error"; export type DirectorCloseDecision = "close" | "offer-draft-exit" | "stay"; export type DirectorDraftEnvelope = { schemaVersion: 1; sceneId: string; baseUpdatedAt: string; scene: DirectorScene; revision: number; savedAt: number; }; export type DirectorFlushRequest = { scene: DirectorScene; revision: number; baseUpdatedAt: string; }; export type StorageLike = { getItem: (key: string) => string | null; setItem: (key: string, value: string) => void; removeItem: (key: string) => void; }; export type FlushFunction = (request: Readonly) => Promise; export type ScheduleFunction = (callback: () => void, delay: number) => unknown; export type CancelScheduleFunction = (handle: unknown) => void; export type DirectorSaveSnapshot = { scene: DirectorScene; revision: number; confirmedRevision: number; status: DirectorSaveStatus; draftStored: boolean; }; export type DirectorSaveCoordinatorOptions = { initialScene: DirectorScene; scope: string; storage: StorageLike; flush: FlushFunction; now?: () => number; debounceMs?: number; schedule?: ScheduleFunction; cancelSchedule?: CancelScheduleFunction; }; export interface DirectorSaveCoordinator { getSnapshot: () => DirectorSaveSnapshot; edit: (scene: DirectorScene) => boolean; flushLatest: () => Promise; retry: () => Promise; restoreCandidate: () => DirectorDraftEnvelope | null; discardDraft: () => boolean; explicitRestore: (candidate: unknown) => boolean; prepareClose: () => Promise; handlePageHide: () => Promise; onStatusChange: (handler: (status: DirectorSaveStatus) => void) => () => void; dispose: () => void; } const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const cloneScene = (scene: DirectorScene): DirectorScene => structuredClone(scene); const isDirectorScene = (value: unknown, expectedSceneId: string): value is DirectorScene => { if (!isRecord(value)) return false; return ( value.id === expectedSceneId && value.version === 1 && typeof value.title === "string" && typeof value.background === "string" && typeof value.environmentIntensity === "number" && Number.isFinite(value.environmentIntensity) && typeof value.gridVisible === "boolean" && Array.isArray(value.objects) && Array.isArray(value.cameras) && Array.isArray(value.lights) && Array.isArray(value.shots) && typeof value.activeShotId === "string" && typeof value.createdAt === "string" && typeof value.updatedAt === "string" ); }; const validateDraftEnvelope = (candidate: unknown, expectedSceneId: string): DirectorDraftEnvelope | null => { if (!isRecord(candidate)) return null; if ( candidate.schemaVersion !== 1 || candidate.sceneId !== expectedSceneId || typeof candidate.baseUpdatedAt !== "string" || typeof candidate.revision !== "number" || !Number.isSafeInteger(candidate.revision) || candidate.revision < 0 || typeof candidate.savedAt !== "number" || !Number.isFinite(candidate.savedAt) || candidate.savedAt < 0 || !isDirectorScene(candidate.scene, expectedSceneId) ) { return null; } return { schemaVersion: 1, sceneId: expectedSceneId, baseUpdatedAt: candidate.baseUpdatedAt, scene: cloneScene(candidate.scene), revision: candidate.revision, savedAt: candidate.savedAt, }; }; const parseDraftEnvelope = (raw: string, expectedSceneId: string): DirectorDraftEnvelope | null => { try { const candidate: unknown = JSON.parse(raw); return validateDraftEnvelope(candidate, expectedSceneId); } catch { return null; } }; export function createDirectorSaveCoordinator(options: DirectorSaveCoordinatorOptions): DirectorSaveCoordinator { const { initialScene, scope, storage, flush, now = Date.now, debounceMs = 300 } = options; const schedule: ScheduleFunction = options.schedule ?? ((callback, delay) => globalThis.setTimeout(callback, delay)); const cancelSchedule: CancelScheduleFunction = options.cancelSchedule ?? ((handle) => globalThis.clearTimeout(handle as ReturnType)); const fixedSceneId = initialScene.id; const storageKey = scopedStorageKey("director-scene-draft:" + fixedSceneId, scope); let currentScene = cloneScene(initialScene); let currentRevision = 0; let confirmedRevision = 0; let baseUpdatedAt = initialScene.updatedAt; let status: DirectorSaveStatus = "saved"; let localDraft: DirectorDraftEnvelope | null = null; let draftStored = false; let debounceHandle: unknown | null = null; let inFlightFlush: Promise | null = null; let closePromise: Promise | null = null; let pageHidePromise: Promise | null = null; let disposed = false; const subscribers = new Set<(status: DirectorSaveStatus) => void>(); const setStatus = (nextStatus: DirectorSaveStatus) => { if (disposed || status === nextStatus) return; status = nextStatus; for (const subscriber of subscribers) { try { subscriber(nextStatus); } catch { // A subscriber must not break persistence. } } }; const getSnapshot = (): DirectorSaveSnapshot => ({ scene: cloneScene(currentScene), revision: currentRevision, confirmedRevision, status, draftStored, }); const cancelDebounce = () => { if (debounceHandle === null) return; cancelSchedule(debounceHandle); debounceHandle = null; }; const storeCurrentDraft = () => { const envelope: DirectorDraftEnvelope = { schemaVersion: 1, sceneId: fixedSceneId, baseUpdatedAt, scene: cloneScene(currentScene), revision: currentRevision, savedAt: now(), }; localDraft = envelope; try { storage.setItem(storageKey, JSON.stringify(envelope)); draftStored = true; setStatus("dirty"); } catch { draftStored = false; setStatus("error"); } }; const scheduleFlush = () => { cancelDebounce(); debounceHandle = schedule(() => { debounceHandle = null; void flushLatest(); }, debounceMs); }; const edit = (scene: DirectorScene): boolean => { if (disposed || scene.id !== fixedSceneId) return false; currentScene = cloneScene(scene); currentRevision += 1; storeCurrentDraft(); scheduleFlush(); return true; }; const clearStoredDraft = (revision: number) => { if (localDraft?.revision !== revision) return; try { storage.removeItem(storageKey); localDraft = null; draftStored = false; } catch { draftStored = true; } }; const refreshStoredDraftBase = () => { if (!localDraft || localDraft.revision <= confirmedRevision) return; localDraft = { ...localDraft, baseUpdatedAt, }; try { storage.setItem(storageKey, JSON.stringify(localDraft)); draftStored = true; } catch { draftStored = false; } }; const runDrain = async (): Promise => { try { while (!disposed && confirmedRevision < currentRevision) { const targetRevision = currentRevision; const targetScene = cloneScene(currentScene); const targetBaseUpdatedAt = baseUpdatedAt; setStatus("saving"); try { await flush({ scene: targetScene, revision: targetRevision, baseUpdatedAt: targetBaseUpdatedAt, }); } catch { if (!disposed) setStatus("error"); return false; } if (disposed) return false; confirmedRevision = Math.max(confirmedRevision, targetRevision); baseUpdatedAt = targetScene.updatedAt; if (confirmedRevision === currentRevision) { clearStoredDraft(targetRevision); setStatus("saved"); return true; } refreshStoredDraftBase(); } if (disposed) return false; setStatus("saved"); return confirmedRevision === currentRevision; } catch { if (!disposed) setStatus("error"); return false; } }; function flushLatest(): Promise { cancelDebounce(); if (disposed) return Promise.resolve(false); if (inFlightFlush) return inFlightFlush; if (confirmedRevision === currentRevision) { setStatus("saved"); return Promise.resolve(true); } const pending = runDrain(); inFlightFlush = pending; void pending.then( () => { if (inFlightFlush === pending) inFlightFlush = null; }, () => { if (inFlightFlush === pending) inFlightFlush = null; }, ); return pending; } const retry = (): Promise => flushLatest(); const restoreCandidate = (): DirectorDraftEnvelope | null => { try { const raw = storage.getItem(storageKey); if (raw === null) return null; return parseDraftEnvelope(raw, fixedSceneId); } catch { return null; } }; /** * 显式放弃本地草稿:真正删除该 scoped key,避免同一旧草稿每次重开都提示。 * 删除失败返回 false,调用方必须告知用户草稿仍在,不能假装已放弃。 */ const discardDraft = (): boolean => { try { storage.removeItem(storageKey); } catch { return false; } localDraft = null; draftStored = false; return true; }; const explicitRestore = (candidate: unknown): boolean => { if (disposed) return false; const envelope = validateDraftEnvelope(candidate, fixedSceneId); if (!envelope || envelope.revision <= currentRevision) return false; cancelDebounce(); currentScene = cloneScene(envelope.scene); currentRevision = envelope.revision; baseUpdatedAt = envelope.baseUpdatedAt; localDraft = envelope; try { storage.setItem(storageKey, JSON.stringify(envelope)); draftStored = true; setStatus("dirty"); } catch { draftStored = false; setStatus("error"); } return true; }; const prepareClose = (): Promise => { if (disposed) return Promise.resolve("stay"); if (confirmedRevision === currentRevision) { return Promise.resolve("close"); } if (closePromise) return closePromise; const pending = flushLatest().then((success) => { if (success) return "close"; return draftStored ? "offer-draft-exit" : "stay"; }); closePromise = pending; void pending.then( () => { if (closePromise === pending) closePromise = null; }, () => { if (closePromise === pending) closePromise = null; }, ); return pending; }; const handlePageHide = (): Promise => { if (disposed) return Promise.resolve(false); if (pageHidePromise) return pageHidePromise; const pending = flushLatest(); pageHidePromise = pending; void pending.then( () => { if (pageHidePromise === pending) pageHidePromise = null; }, () => { if (pageHidePromise === pending) pageHidePromise = null; }, ); return pending; }; const onStatusChange = (handler: (status: DirectorSaveStatus) => void): (() => void) => { if (disposed) return () => undefined; subscribers.add(handler); return () => { subscribers.delete(handler); }; }; const dispose = () => { if (disposed) return; disposed = true; cancelDebounce(); subscribers.clear(); closePromise = null; pageHidePromise = null; }; return { getSnapshot, edit, flushLatest, retry, restoreCandidate, discardDraft, explicitRestore, prepareClose, handlePageHide, onStatusChange, dispose, }; }