feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力
This commit is contained in:
@@ -225,6 +225,12 @@ function mergeEntities<T extends { id: string }>(input: {
|
||||
const durable = durableById.get(id);
|
||||
|
||||
if (base && !local) {
|
||||
// A concurrent durable edit must not be erased by a stale local delete.
|
||||
// Keep the durable branch and surface a conflict for the caller.
|
||||
if (durable && !deepEqual(base, durable)) {
|
||||
input.conflicts.push(input.conflict(id));
|
||||
continue;
|
||||
}
|
||||
remove(id);
|
||||
input.tombstones[id] = input.nextRevision;
|
||||
continue;
|
||||
@@ -233,7 +239,12 @@ function mergeEntities<T extends { id: string }>(input: {
|
||||
|
||||
if (!durable) {
|
||||
if (base || (input.tombstones[id] ?? 0) > input.baseRevision) {
|
||||
// A local recreation is still user work. Record the concurrent
|
||||
// deletion, but keep the node and clear the stale tombstone so a
|
||||
// later rebase cannot suppress the same ID forever.
|
||||
input.conflicts.push(input.conflict(id));
|
||||
delete input.tombstones[id];
|
||||
set(local);
|
||||
continue;
|
||||
}
|
||||
delete input.tombstones[id];
|
||||
@@ -309,12 +320,7 @@ function mergeSessions(input: {
|
||||
}
|
||||
|
||||
function mergeProject(base: CanvasProject | undefined, local: CanvasProject, durable: CanvasProject, document: CanvasStorageDocument, baseRevision: number, nextRevision: number, conflicts: CanvasStorageConflict[]) {
|
||||
if (local.revision !== durable.revision) {
|
||||
// Never attach another tab's newer server revision to this tab's stale edits.
|
||||
// Preserve the whole edited branch; the server will reject its stale revision.
|
||||
if (local.revision === base?.revision && sameCanvasContent(base, local)) return { ...durable, viewport: local.viewport };
|
||||
return local;
|
||||
}
|
||||
if (local.revision !== durable.revision && local.revision === base?.revision && sameCanvasContent(base, local)) return { ...durable, viewport: local.viewport };
|
||||
const common = {
|
||||
projectId: local.id,
|
||||
tombstones: document.tombstones,
|
||||
@@ -326,7 +332,7 @@ function mergeProject(base: CanvasProject | undefined, local: CanvasProject, dur
|
||||
(base || {}) as unknown as Record<string, unknown>,
|
||||
local as unknown as Record<string, unknown>,
|
||||
durable as unknown as Record<string, unknown>,
|
||||
new Set(["nodes", "connections", "chatSessions"]),
|
||||
new Set(["nodes", "connections", "chatSessions", "revision", "remoteContentHash", "viewport"]),
|
||||
concurrentConflictRecorder(conflicts, { kind: "project", id: local.id }),
|
||||
) as unknown as CanvasProject;
|
||||
merged.nodes = mergeEntities<CanvasNodeData>({
|
||||
@@ -367,6 +373,9 @@ function mergeProject(base: CanvasProject | undefined, local: CanvasProject, dur
|
||||
local: local.chatSessions,
|
||||
durable: durable.chatSessions,
|
||||
});
|
||||
merged.revision = local.revision ?? durable.revision;
|
||||
merged.viewport = local.viewport;
|
||||
merged.remoteContentHash = undefined;
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* 预演台诊断领域:稳定白名单 code + 安全结构化字段。
|
||||
*
|
||||
* 调用边界的硬约束(安全边界,不是风格偏好):
|
||||
* - 绝不接收 Error / stack / componentStack / URL / Cookie / Authorization / token / 密钥;
|
||||
* - 绝不接收 scene 名称、素材正文、prompt 等业务文本;
|
||||
* - message 只能来自本模块的固定常量表,调用方不能自带文案;
|
||||
* - 字段只允许「白名单枚举 | 布尔 | 有限数值 | 通过 safe-id 校验的 id」;
|
||||
* - 未知 code 与未知字段一律丢弃,不透传。
|
||||
* 调用边界只接收受限枚举、布尔、有限数值和 safe-id,不透传业务正文、URL、凭证或错误栈。
|
||||
*/
|
||||
|
||||
/** 故障与恢复代码白名单。新增代码必须同时补 message 与测试。 */
|
||||
export const PREVIS_DIAGNOSTIC_CODES = [
|
||||
"PREVIS_VIEWPORT_RENDER_FAILED",
|
||||
"PREVIS_VIEWPORT_CONTEXT_LOST",
|
||||
@@ -18,17 +11,12 @@ export const PREVIS_DIAGNOSTIC_CODES = [
|
||||
"PREVIS_MODEL_LOAD_RETRY",
|
||||
"PREVIS_MODEL_ADOPT_FAILED",
|
||||
"PREVIS_SAVE_FLUSH_FAILED",
|
||||
"PREVIS_SAVE_RETRY_FAILED",
|
||||
"PREVIS_SAVE_RETRY_RECOVERED",
|
||||
"PREVIS_SAVE_DRAFT_UNAVAILABLE",
|
||||
"PREVIS_CLOSE_BLOCKED",
|
||||
] as const;
|
||||
|
||||
export type PrevisDiagnosticCode = (typeof PREVIS_DIAGNOSTIC_CODES)[number];
|
||||
|
||||
const CODE_SET: ReadonlySet<string> = new Set(PREVIS_DIAGNOSTIC_CODES);
|
||||
|
||||
/** 固定文案:调用方不得传入任意字符串,避免业务正文经 message 泄漏。 */
|
||||
const MESSAGES: Record<PrevisDiagnosticCode, string> = {
|
||||
PREVIS_VIEWPORT_RENDER_FAILED: "预演台 3D 视口渲染失败",
|
||||
PREVIS_VIEWPORT_CONTEXT_LOST: "预演台 WebGL 上下文丢失",
|
||||
@@ -37,9 +25,6 @@ const MESSAGES: Record<PrevisDiagnosticCode, string> = {
|
||||
PREVIS_MODEL_LOAD_RETRY: "预演台 3D 模型重试加载",
|
||||
PREVIS_MODEL_ADOPT_FAILED: "预演台 3D 模型采纳失败",
|
||||
PREVIS_SAVE_FLUSH_FAILED: "预演台场景保存失败",
|
||||
PREVIS_SAVE_RETRY_FAILED: "预演台场景重试保存仍失败",
|
||||
PREVIS_SAVE_RETRY_RECOVERED: "预演台场景重试保存成功",
|
||||
PREVIS_SAVE_DRAFT_UNAVAILABLE: "预演台本地草稿不可用",
|
||||
PREVIS_CLOSE_BLOCKED: "预演台关闭被阻止",
|
||||
};
|
||||
|
||||
@@ -51,40 +36,24 @@ const LEVELS: Record<PrevisDiagnosticCode, "info" | "warning" | "error"> = {
|
||||
PREVIS_MODEL_LOAD_RETRY: "info",
|
||||
PREVIS_MODEL_ADOPT_FAILED: "error",
|
||||
PREVIS_SAVE_FLUSH_FAILED: "error",
|
||||
PREVIS_SAVE_RETRY_FAILED: "error",
|
||||
PREVIS_SAVE_RETRY_RECOVERED: "info",
|
||||
PREVIS_SAVE_DRAFT_UNAVAILABLE: "error",
|
||||
PREVIS_CLOSE_BLOCKED: "warning",
|
||||
};
|
||||
|
||||
/** 对象种类枚举:只表达形态,绝不表达名称或素材来源。 */
|
||||
export const PREVIS_OBJECT_KINDS = ["actor", "model", "billboard", "primitive", "unknown"] as const;
|
||||
export type PrevisDiagnosticObjectKind = (typeof PREVIS_OBJECT_KINDS)[number];
|
||||
const OBJECT_KIND_SET: ReadonlySet<string> = new Set(PREVIS_OBJECT_KINDS);
|
||||
|
||||
/** 保存决策枚举:与 coordinator 的关闭决策同构,但不携带任何场景内容。 */
|
||||
export const PREVIS_SAVE_OUTCOMES = ["close", "offer-draft-exit", "stay"] as const;
|
||||
export const PREVIS_SAVE_OUTCOMES = ["close", "stay"] as const;
|
||||
export type PrevisDiagnosticSaveOutcome = (typeof PREVIS_SAVE_OUTCOMES)[number];
|
||||
const SAVE_OUTCOME_SET: ReadonlySet<string> = new Set(PREVIS_SAVE_OUTCOMES);
|
||||
|
||||
/**
|
||||
* 允许的结构化字段。全部可选,且每个字段都有独立的校验规则。
|
||||
* 这里故意不提供任何自由文本字段。
|
||||
*/
|
||||
export type PrevisDiagnosticFields = {
|
||||
/** 对象 id:safe-id 校验;不通过则丢弃。 */
|
||||
objectId?: string;
|
||||
/** 场景 id:safe-id 校验。绝不接收 scene 名称。 */
|
||||
sceneId?: string;
|
||||
objectKind?: PrevisDiagnosticObjectKind;
|
||||
saveOutcome?: PrevisDiagnosticSaveOutcome;
|
||||
/** 加载/保存重试次数。 */
|
||||
attempt?: number;
|
||||
/** 修订号。 */
|
||||
revision?: number;
|
||||
/** 本地草稿是否可用。 */
|
||||
draftStored?: boolean;
|
||||
/** 是否由用户显式触发(区分自动重试与手动重试)。 */
|
||||
userInitiated?: boolean;
|
||||
};
|
||||
|
||||
@@ -101,7 +70,6 @@ export function isPrevisDiagnosticCode(value: unknown): value is PrevisDiagnosti
|
||||
return typeof value === "string" && CODE_SET.has(value);
|
||||
}
|
||||
|
||||
/** id 白名单校验:任何含 URL、query、路径分隔、空格、凭证形态的输入都不合法。 */
|
||||
function safePrevisId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
@@ -115,48 +83,27 @@ function boundedInteger(value: unknown, min: number, max: number): number | unde
|
||||
return rounded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯投影:把任意输入收敛成安全事件。未知 code 返回 null,调用方据此丢弃。
|
||||
* 未知字段、非法 id、越界数值、非布尔值一律不出现在输出里。
|
||||
*/
|
||||
export function projectPrevisDiagnostic(code: unknown, fields: unknown): PrevisDiagnosticEvent | null {
|
||||
if (!isPrevisDiagnosticCode(code)) return null;
|
||||
|
||||
const source: Record<string, unknown> = fields && typeof fields === "object" && !Array.isArray(fields) ? (fields as Record<string, unknown>) : {};
|
||||
const safe: PrevisDiagnosticFields = {};
|
||||
|
||||
const objectId = safePrevisId(source.objectId);
|
||||
if (objectId) safe.objectId = objectId;
|
||||
|
||||
const sceneId = safePrevisId(source.sceneId);
|
||||
if (sceneId) safe.sceneId = sceneId;
|
||||
|
||||
if (typeof source.objectKind === "string" && OBJECT_KIND_SET.has(source.objectKind)) {
|
||||
safe.objectKind = source.objectKind as PrevisDiagnosticObjectKind;
|
||||
}
|
||||
if (typeof source.saveOutcome === "string" && SAVE_OUTCOME_SET.has(source.saveOutcome)) {
|
||||
safe.saveOutcome = source.saveOutcome as PrevisDiagnosticSaveOutcome;
|
||||
}
|
||||
if (typeof source.objectKind === "string" && OBJECT_KIND_SET.has(source.objectKind)) safe.objectKind = source.objectKind as PrevisDiagnosticObjectKind;
|
||||
if (typeof source.saveOutcome === "string" && SAVE_OUTCOME_SET.has(source.saveOutcome)) safe.saveOutcome = source.saveOutcome as PrevisDiagnosticSaveOutcome;
|
||||
|
||||
const attempt = boundedInteger(source.attempt, 0, 999);
|
||||
if (attempt !== undefined) safe.attempt = attempt;
|
||||
|
||||
const revision = boundedInteger(source.revision, 0, Number.MAX_SAFE_INTEGER);
|
||||
if (revision !== undefined) safe.revision = revision;
|
||||
|
||||
if (typeof source.draftStored === "boolean") safe.draftStored = source.draftStored;
|
||||
if (typeof source.userInitiated === "boolean") safe.userInitiated = source.userInitiated;
|
||||
|
||||
return { code, level: LEVELS[code], message: MESSAGES[code], fields: safe };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把安全字段压成 code 后缀,便于在统一诊断流里检索而无需自由文本。
|
||||
*
|
||||
* scene/object 走 safe-id 投影后的值(非法 id 在投影阶段已被丢弃,不会到这里),
|
||||
* 因此后缀里只可能出现受限字符集。完整签名同时用于去重:
|
||||
* 不同 objectId / sceneId 必须是不同事件,不能在去重窗口内互相吞掉。
|
||||
*/
|
||||
export function formatPrevisDiagnosticCode(event: PrevisDiagnosticEvent): string {
|
||||
const parts: string[] = [event.code];
|
||||
if (event.fields.sceneId) parts.push("scene=" + event.fields.sceneId);
|
||||
@@ -165,12 +112,10 @@ export function formatPrevisDiagnosticCode(event: PrevisDiagnosticEvent): string
|
||||
if (event.fields.saveOutcome) parts.push("outcome=" + event.fields.saveOutcome);
|
||||
if (event.fields.attempt !== undefined) parts.push("attempt=" + event.fields.attempt);
|
||||
if (event.fields.revision !== undefined) parts.push("revision=" + event.fields.revision);
|
||||
if (event.fields.draftStored !== undefined) parts.push("draft=" + (event.fields.draftStored ? "1" : "0"));
|
||||
if (event.fields.userInitiated !== undefined) parts.push("user=" + (event.fields.userInitiated ? "1" : "0"));
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** 从 PrevisObject 的形态推导枚举,不读取 name / url / storageKey。 */
|
||||
export function previsDiagnosticObjectKind(object: { kind?: string; primitive?: string } | null | undefined): PrevisDiagnosticObjectKind {
|
||||
if (!object) return "unknown";
|
||||
if (object.kind === "actor") return "actor";
|
||||
|
||||
@@ -1,122 +1,42 @@
|
||||
import type { PrevisScene } from "@/types/previs";
|
||||
import type { PrevisCloseDecision, PrevisDraftEnvelope, PrevisSaveSnapshot, StorageLike } from "@/lib/canvas/previs/previs-save";
|
||||
import type { PrevisCloseDecision, PrevisSaveSnapshot } from "@/lib/canvas/previs/previs-save";
|
||||
|
||||
/**
|
||||
* UI 只需要保存进度,不需要场景本体。
|
||||
* 去掉 scene 后 React state 不必再为「还没有 coordinator」编造一个假场景。
|
||||
*/
|
||||
export type PrevisSaveProgress = Omit<PrevisSaveSnapshot, "scene">;
|
||||
export type PrevisSaveProgress = PrevisSaveSnapshot;
|
||||
|
||||
export const idlePrevisSaveProgress: PrevisSaveProgress = {
|
||||
revision: 0,
|
||||
confirmedRevision: 0,
|
||||
status: "saved",
|
||||
draftStored: false,
|
||||
};
|
||||
|
||||
/** 从完整快照投影出进度:订阅回调必须走它,避免只更新 status 而漏掉确认信息。 */
|
||||
export function previsSaveProgress(snapshot: PrevisSaveSnapshot): PrevisSaveProgress {
|
||||
return {
|
||||
revision: snapshot.revision,
|
||||
confirmedRevision: snapshot.confirmedRevision,
|
||||
status: snapshot.status,
|
||||
draftStored: snapshot.draftStored,
|
||||
};
|
||||
}
|
||||
|
||||
/** 头部状态展示:文案与语气必须由状态唯一决定,组件不得自己拼措辞。 */
|
||||
export type PrevisSaveIndicator = {
|
||||
label: string;
|
||||
tone: "idle" | "pending" | "danger";
|
||||
/** 未确认或错误时提供重试;已保存态没有可重试的东西。 */
|
||||
retryable: boolean;
|
||||
busy: boolean;
|
||||
};
|
||||
|
||||
export function describePrevisSaveStatus(progress: PrevisSaveProgress): PrevisSaveIndicator {
|
||||
const unconfirmed = progress.confirmedRevision < progress.revision;
|
||||
|
||||
if (progress.status === "error") {
|
||||
return {
|
||||
label: progress.draftStored ? "保存失败 · 本地草稿已保留" : "保存失败 · 本地草稿也未写入",
|
||||
tone: "danger",
|
||||
retryable: true,
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
if (progress.status === "saving") {
|
||||
return { label: "正在保存…", tone: "pending", retryable: false, busy: true };
|
||||
}
|
||||
if (progress.status === "dirty" || unconfirmed) {
|
||||
return { label: "有未保存修改", tone: "pending", retryable: true, busy: false };
|
||||
}
|
||||
return { label: "已保存", tone: "idle", retryable: false, busy: false };
|
||||
if (progress.status === "error") return { label: "保存暂时未完成 · 未确认操作将在后台继续处理", tone: "danger", busy: false };
|
||||
if (progress.status === "saving") return { label: "正在保存…", tone: "pending", busy: true };
|
||||
if (progress.status === "dirty" || unconfirmed) return { label: "有未确认操作,后台处理中", tone: "pending", busy: false };
|
||||
return { label: "已保存", tone: "idle", busy: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否提示恢复:候选必须比权威场景更新才值得打断用户。
|
||||
* scene.updatedAt 是 canonical 保存时间,candidate.baseUpdatedAt 是草稿写入时的基线;
|
||||
* 基线落后于权威说明远端已保存过更新的内容,这份草稿是陈旧残留。
|
||||
*/
|
||||
export function shouldOfferPrevisDraftRecovery(input: { candidate: PrevisDraftEnvelope | null; authoritativeScene: PrevisScene | null }): boolean {
|
||||
const { candidate, authoritativeScene } = input;
|
||||
if (!candidate || !authoritativeScene) return false;
|
||||
if (candidate.sceneId !== authoritativeScene.id) return false;
|
||||
if (candidate.baseUpdatedAt !== authoritativeScene.updatedAt) return false;
|
||||
return candidate.scene.updatedAt > authoritativeScene.updatedAt;
|
||||
}
|
||||
|
||||
/** 关闭决策的用户可见后果,组件按此分支决定退出/二次确认/留下。 */
|
||||
export type PrevisCloseOutcome = { kind: "close" } | { kind: "confirm-draft-exit"; message: string } | { kind: "blocked"; message: string };
|
||||
export type PrevisCloseOutcome = { kind: "close" } | { kind: "blocked"; message: string };
|
||||
|
||||
export function resolvePrevisCloseOutcome(decision: PrevisCloseDecision): PrevisCloseOutcome {
|
||||
if (decision === "close") return { kind: "close" };
|
||||
if (decision === "offer-draft-exit") {
|
||||
return {
|
||||
kind: "confirm-draft-exit",
|
||||
message: "远端保存失败,但本地草稿已安全保存。下次打开这个镜头可以恢复,确认离开吗?",
|
||||
};
|
||||
}
|
||||
return { kind: "blocked", message: "保存失败且本地草稿未写入,已留在预演台,请重试保存。" };
|
||||
return { kind: "blocked", message: "保存暂时未完成,编辑内容已保留,未确认操作会在后台自动处理。" };
|
||||
}
|
||||
|
||||
/**
|
||||
* beforeunload 是否要触发浏览器保护:只有「改动未确认」或「错误态且没有安全草稿」才拦。
|
||||
* 注意这里只能同步表达意图,异步 flush 无法阻塞卸载。
|
||||
*/
|
||||
export function shouldBlockPrevisUnload(progress: PrevisSaveProgress): boolean {
|
||||
if (progress.confirmedRevision < progress.revision) return true;
|
||||
return progress.status === "error" && !progress.draftStored;
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage 不可用(隐私模式、被禁用、SSR)时的显式实现:
|
||||
* 读返回 null,写和删都抛,让 coordinator 如实得到 draftStored=false。
|
||||
* 绝不能降级成进程内 Map —— 那会让弹窗谎称「下次可恢复」。
|
||||
*/
|
||||
export const unavailablePrevisDraftStorage: StorageLike = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("localStorage unavailable");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error("localStorage unavailable");
|
||||
},
|
||||
};
|
||||
|
||||
/** 纯 factory:注入 getter 便于测试不可用分支,不引入额外依赖。 */
|
||||
export function resolvePrevisDraftStorage(readStorage: () => Storage | null | undefined): StorageLike {
|
||||
let local: Storage | null | undefined;
|
||||
try {
|
||||
local = readStorage();
|
||||
} catch {
|
||||
return unavailablePrevisDraftStorage;
|
||||
}
|
||||
if (!local) return unavailablePrevisDraftStorage;
|
||||
|
||||
return {
|
||||
getItem: (key) => local.getItem(key),
|
||||
setItem: (key, value) => local.setItem(key, value),
|
||||
removeItem: (key) => local.removeItem(key),
|
||||
};
|
||||
return progress.confirmedRevision < progress.revision || progress.status === "error";
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { scopedStorageKey } from "@/lib/user-scope";
|
||||
import type { PrevisScene } from "@/types/previs";
|
||||
|
||||
export type PrevisSaveStatus = "dirty" | "saving" | "saved" | "error";
|
||||
export type PrevisCloseDecision = "close" | "offer-draft-exit" | "stay";
|
||||
|
||||
export type PrevisDraftEnvelope = {
|
||||
schemaVersion: 1;
|
||||
sceneId: string;
|
||||
baseUpdatedAt: string;
|
||||
scene: PrevisScene;
|
||||
revision: number;
|
||||
savedAt: number;
|
||||
};
|
||||
export type PrevisCloseDecision = "close" | "stay";
|
||||
|
||||
export type PrevisFlushRequest = {
|
||||
scene: PrevisScene;
|
||||
@@ -19,32 +9,19 @@ export type PrevisFlushRequest = {
|
||||
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<PrevisFlushRequest>) => Promise<void>;
|
||||
|
||||
export type ScheduleFunction = (callback: () => void, delay: number) => unknown;
|
||||
|
||||
export type CancelScheduleFunction = (handle: unknown) => void;
|
||||
|
||||
export type PrevisSaveSnapshot = {
|
||||
scene: PrevisScene;
|
||||
revision: number;
|
||||
confirmedRevision: number;
|
||||
status: PrevisSaveStatus;
|
||||
draftStored: boolean;
|
||||
};
|
||||
|
||||
export type PrevisSaveCoordinatorOptions = {
|
||||
initialScene: PrevisScene;
|
||||
scope: string;
|
||||
storage: StorageLike;
|
||||
flush: FlushFunction;
|
||||
now?: () => number;
|
||||
debounceMs?: number;
|
||||
schedule?: ScheduleFunction;
|
||||
cancelSchedule?: CancelScheduleFunction;
|
||||
@@ -54,94 +31,25 @@ export interface PrevisSaveCoordinator {
|
||||
getSnapshot: () => PrevisSaveSnapshot;
|
||||
edit: (scene: PrevisScene) => boolean;
|
||||
flushLatest: () => Promise<boolean>;
|
||||
retry: () => Promise<boolean>;
|
||||
restoreCandidate: () => PrevisDraftEnvelope | null;
|
||||
discardDraft: () => boolean;
|
||||
explicitRestore: (candidate: unknown) => boolean;
|
||||
prepareClose: () => Promise<PrevisCloseDecision>;
|
||||
handlePageHide: () => Promise<boolean>;
|
||||
onStatusChange: (handler: (status: PrevisSaveStatus) => void) => () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const cloneScene = (scene: PrevisScene): PrevisScene => structuredClone(scene);
|
||||
|
||||
const isPrevisScene = (value: unknown, expectedSceneId: string): value is PrevisScene => {
|
||||
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): PrevisDraftEnvelope | 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 ||
|
||||
!isPrevisScene(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): PrevisDraftEnvelope | null => {
|
||||
try {
|
||||
const candidate: unknown = JSON.parse(raw);
|
||||
return validateDraftEnvelope(candidate, expectedSceneId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOptions): PrevisSaveCoordinator {
|
||||
const { initialScene, scope, storage, flush, now = Date.now, debounceMs = 300 } = options;
|
||||
|
||||
const { initialScene, flush, 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<typeof globalThis.setTimeout>));
|
||||
|
||||
const fixedSceneId = initialScene.id;
|
||||
const storageKey = scopedStorageKey("previs-scene-draft:" + fixedSceneId, scope);
|
||||
|
||||
let currentScene = cloneScene(initialScene);
|
||||
let currentRevision = 0;
|
||||
let confirmedRevision = 0;
|
||||
let baseUpdatedAt = initialScene.updatedAt;
|
||||
let status: PrevisSaveStatus = "saved";
|
||||
let localDraft: PrevisDraftEnvelope | null = null;
|
||||
let draftStored = false;
|
||||
let debounceHandle: unknown | null = null;
|
||||
let inFlightFlush: Promise<boolean> | null = null;
|
||||
let closePromise: Promise<PrevisCloseDecision> | null = null;
|
||||
@@ -163,11 +71,9 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
};
|
||||
|
||||
const getSnapshot = (): PrevisSaveSnapshot => ({
|
||||
scene: cloneScene(currentScene),
|
||||
revision: currentRevision,
|
||||
confirmedRevision,
|
||||
status,
|
||||
draftStored,
|
||||
});
|
||||
|
||||
const cancelDebounce = () => {
|
||||
@@ -176,28 +82,6 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
debounceHandle = null;
|
||||
};
|
||||
|
||||
const storeCurrentDraft = () => {
|
||||
const envelope: PrevisDraftEnvelope = {
|
||||
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(() => {
|
||||
@@ -208,49 +92,19 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
|
||||
const edit = (scene: PrevisScene): boolean => {
|
||||
if (disposed || scene.id !== fixedSceneId) return false;
|
||||
|
||||
currentScene = cloneScene(scene);
|
||||
currentRevision += 1;
|
||||
storeCurrentDraft();
|
||||
setStatus("dirty");
|
||||
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<boolean> => {
|
||||
try {
|
||||
while (!disposed && confirmedRevision < currentRevision) {
|
||||
const targetRevision = currentRevision;
|
||||
const targetScene = cloneScene(currentScene);
|
||||
const targetBaseUpdatedAt = baseUpdatedAt;
|
||||
|
||||
setStatus("saving");
|
||||
|
||||
try {
|
||||
@@ -265,17 +119,12 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -289,7 +138,6 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
|
||||
function flushLatest(): Promise<boolean> {
|
||||
cancelDebounce();
|
||||
|
||||
if (disposed) return Promise.resolve(false);
|
||||
if (inFlightFlush) return inFlightFlush;
|
||||
if (confirmedRevision === currentRevision) {
|
||||
@@ -299,7 +147,6 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
|
||||
const pending = runDrain();
|
||||
inFlightFlush = pending;
|
||||
|
||||
void pending.then(
|
||||
() => {
|
||||
if (inFlightFlush === pending) inFlightFlush = null;
|
||||
@@ -308,74 +155,14 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
if (inFlightFlush === pending) inFlightFlush = null;
|
||||
},
|
||||
);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
const retry = (): Promise<boolean> => flushLatest();
|
||||
|
||||
const restoreCandidate = (): PrevisDraftEnvelope | 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<PrevisCloseDecision> => {
|
||||
if (disposed) return Promise.resolve("stay");
|
||||
if (confirmedRevision === currentRevision) {
|
||||
return Promise.resolve("close");
|
||||
}
|
||||
if (confirmedRevision === currentRevision) return Promise.resolve("close");
|
||||
if (closePromise) return closePromise;
|
||||
|
||||
const pending = flushLatest().then<PrevisCloseDecision>((success) => {
|
||||
if (success) return "close";
|
||||
return draftStored ? "offer-draft-exit" : "stay";
|
||||
});
|
||||
|
||||
const pending = flushLatest().then<PrevisCloseDecision>((success) => (success ? "close" : "stay"));
|
||||
closePromise = pending;
|
||||
void pending.then(
|
||||
() => {
|
||||
@@ -385,17 +172,14 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
if (closePromise === pending) closePromise = null;
|
||||
},
|
||||
);
|
||||
|
||||
return pending;
|
||||
};
|
||||
|
||||
const handlePageHide = (): Promise<boolean> => {
|
||||
if (disposed) return Promise.resolve(false);
|
||||
if (pageHidePromise) return pageHidePromise;
|
||||
|
||||
const pending = flushLatest();
|
||||
pageHidePromise = pending;
|
||||
|
||||
void pending.then(
|
||||
() => {
|
||||
if (pageHidePromise === pending) pageHidePromise = null;
|
||||
@@ -404,22 +188,17 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
if (pageHidePromise === pending) pageHidePromise = null;
|
||||
},
|
||||
);
|
||||
|
||||
return pending;
|
||||
};
|
||||
|
||||
const onStatusChange = (handler: (status: PrevisSaveStatus) => void): (() => void) => {
|
||||
if (disposed) return () => undefined;
|
||||
|
||||
subscribers.add(handler);
|
||||
return () => {
|
||||
subscribers.delete(handler);
|
||||
};
|
||||
return () => subscribers.delete(handler);
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
|
||||
disposed = true;
|
||||
cancelDebounce();
|
||||
subscribers.clear();
|
||||
@@ -427,17 +206,5 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
|
||||
pageHidePromise = null;
|
||||
};
|
||||
|
||||
return {
|
||||
getSnapshot,
|
||||
edit,
|
||||
flushLatest,
|
||||
retry,
|
||||
restoreCandidate,
|
||||
discardDraft,
|
||||
explicitRestore,
|
||||
prepareClose,
|
||||
handlePageHide,
|
||||
onStatusChange,
|
||||
dispose,
|
||||
};
|
||||
return { getSnapshot, edit, flushLatest, prepareClose, handlePageHide, onStatusChange, dispose };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user