170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
|
|
import {
|
|
CanvasRevisionConflict,
|
|
VideoCanvasSaveQueue,
|
|
type VideoCanvasSaveStatus,
|
|
} from "@/lib/video-canvas/save-queue";
|
|
|
|
export function useVideoCanvasPersistence(
|
|
projectId: string,
|
|
loaded: { id: string; revision: string } | null,
|
|
snapshot: string,
|
|
applySnapshot?: (snapshot: string) => void,
|
|
) {
|
|
const queueRef = useRef<VideoCanvasSaveQueue | null>(null);
|
|
const latestSnapshot = useRef(snapshot);
|
|
const observedSnapshot = useRef(snapshot);
|
|
latestSnapshot.current = snapshot;
|
|
const [state, setState] = useState<{ status: VideoCanvasSaveStatus; message?: string }>({
|
|
status: "saved",
|
|
});
|
|
const [conflicts, setConflicts] = useState<string[]>([]);
|
|
const applyRef = useRef(applySnapshot);
|
|
applyRef.current = applySnapshot;
|
|
const ready = loaded?.id === projectId;
|
|
|
|
useEffect(() => {
|
|
if (!ready || !loaded) return;
|
|
let active = true;
|
|
const queue = new VideoCanvasSaveQueue(
|
|
latestSnapshot.current,
|
|
loaded.revision,
|
|
async (value, revision) => {
|
|
const response = await fetch(`/api/canvas/${encodeURIComponent(projectId)}`, {
|
|
method: "PUT",
|
|
signal: AbortSignal.timeout(45_000),
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
...JSON.parse(value),
|
|
workspaceType: "video",
|
|
baseCanvasUpdatedAt: revision,
|
|
}),
|
|
});
|
|
const result = await response.json();
|
|
if (response.status === 409) {
|
|
const latest = await fetch(`/api/canvas/${encodeURIComponent(projectId)}`, {
|
|
cache: "no-store",
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
const remote = await latest.json();
|
|
if (!latest.ok || !remote.success || !remote.data?.updatedAt)
|
|
throw Error("读取其他页面的修改失败,当前草稿已保留,请重试");
|
|
throw new CanvasRevisionConflict(
|
|
"其他页面修改了相同内容,请选择冲突部分保留哪一版",
|
|
JSON.stringify({ title: remote.data.title, canvasData: remote.data.canvasData }),
|
|
String(remote.data.updatedAt),
|
|
);
|
|
}
|
|
if (!response.ok || !result.success || !result.data?.updatedAt) {
|
|
throw new Error(result.error || "保存失败,请点击重试");
|
|
}
|
|
return String(result.data.updatedAt);
|
|
},
|
|
(status, message) => {
|
|
if (active) setState({ status, message });
|
|
},
|
|
applyRef.current
|
|
? {
|
|
merge: mergeCanvasSnapshots,
|
|
apply: (value) => {
|
|
if (active) applyRef.current?.(value);
|
|
},
|
|
conflict: (fields) => {
|
|
if (active) setConflicts(fields);
|
|
},
|
|
}
|
|
: undefined,
|
|
);
|
|
queueRef.current = queue;
|
|
observedSnapshot.current = latestSnapshot.current;
|
|
setConflicts([]);
|
|
setState({ status: "saved" });
|
|
const backupKey = `video-canvas-draft:${projectId}`;
|
|
try {
|
|
const backup = JSON.parse(sessionStorage.getItem(backupKey) || "null");
|
|
if (backup?.base && backup?.snapshot) queue.restoreBackup(backup.base, backup.snapshot);
|
|
} catch {
|
|
/* Ignore corrupt draft cache; server content is intact. */
|
|
}
|
|
const retainDraft = () => {
|
|
try {
|
|
if (queue.dirty) sessionStorage.setItem(backupKey, JSON.stringify(queue.backup));
|
|
else sessionStorage.removeItem(backupKey);
|
|
} catch {
|
|
/* The unsaved-change warning and JSON export remain available when browser storage is full. */
|
|
}
|
|
};
|
|
// Fixed cadence also saves while generation progress is continuously changing.
|
|
const interval = window.setInterval(() => {
|
|
retainDraft();
|
|
if (queue.dirty && !queue.failed) void queue.flush().then(retainDraft);
|
|
}, 1500);
|
|
const onHidden = () => {
|
|
if (document.visibilityState === "hidden") {
|
|
retainDraft();
|
|
void queue.flush().then(retainDraft);
|
|
}
|
|
};
|
|
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
if (!queue.dirty) return;
|
|
retainDraft();
|
|
void queue.flush();
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
};
|
|
document.addEventListener("visibilitychange", onHidden);
|
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
return () => {
|
|
active = false;
|
|
clearInterval(interval);
|
|
document.removeEventListener("visibilitychange", onHidden);
|
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
retainDraft();
|
|
void queue.flush().then(retainDraft);
|
|
if (queueRef.current === queue) queueRef.current = null;
|
|
};
|
|
}, [projectId, ready, loaded]);
|
|
|
|
useEffect(() => {
|
|
if (ready && observedSnapshot.current !== snapshot) {
|
|
observedSnapshot.current = snapshot;
|
|
queueRef.current?.update(snapshot);
|
|
}
|
|
}, [snapshot, ready]);
|
|
|
|
const save = useCallback(() => {
|
|
if (!ready) return;
|
|
queueRef.current?.update(latestSnapshot.current);
|
|
void queueRef.current?.flush();
|
|
}, [ready]);
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
|
event.preventDefault();
|
|
save();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, [save]);
|
|
|
|
const resolveConflict = useCallback(
|
|
(choice: CanvasConflictChoice) => queueRef.current?.resolveConflict(choice),
|
|
[],
|
|
);
|
|
const downloadDraft = useCallback(() => {
|
|
const data = queueRef.current?.backup.snapshot || latestSnapshot.current;
|
|
const url = URL.createObjectURL(new Blob([data], { type: "application/json" }));
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = `video-canvas-${projectId}-draft.json`;
|
|
anchor.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
}, [projectId]);
|
|
return { ...state, save, ready, conflicts, resolveConflict, downloadDraft };
|
|
}
|