ai-chatbot/hooks/use-video-canvas-persistence.ts

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 };
}