Compare commits
No commits in common. "main" and "codex/agent-chat-scroll-20261106" have entirely different histories.
main
...
codex/agen
@ -4,8 +4,7 @@ import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
|||||||
import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft";
|
import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft";
|
||||||
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||||
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
|
import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||||
import equal from "fast-deep-equal";
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -172,7 +171,6 @@ import {
|
|||||||
type VideoCanvasSymbolMarkerId,
|
type VideoCanvasSymbolMarkerId,
|
||||||
} from "@/lib/canvas/video-canvas-markers";
|
} from "@/lib/canvas/video-canvas-markers";
|
||||||
import {
|
import {
|
||||||
isCanvasImageUploadPending,
|
|
||||||
LOCAL_UPLOAD_PENDING_METADATA_KEY,
|
LOCAL_UPLOAD_PENDING_METADATA_KEY,
|
||||||
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
|
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
|
||||||
} from "@/lib/canvas/local-upload-state";
|
} from "@/lib/canvas/local-upload-state";
|
||||||
@ -1939,7 +1937,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute));
|
const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute));
|
||||||
const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null);
|
const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null);
|
||||||
const canvasSyncErrorRef = useRef("");
|
const canvasSyncErrorRef = useRef("");
|
||||||
const [canvasSyncConflicts, setCanvasSyncConflicts] = useState<string[]>([]);
|
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
|
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
|
||||||
const [editTitle, setEditTitle] = useState("");
|
const [editTitle, setEditTitle] = useState("");
|
||||||
@ -3381,17 +3378,13 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const base = JSON.stringify({ canvasData });
|
const base = JSON.stringify({ canvasData });
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: base };
|
savedCanvasSnapshotRef.current = { id, snapshot: base };
|
||||||
externalCanvasConflictRef.current = false;
|
externalCanvasConflictRef.current = false;
|
||||||
setCanvasSyncConflicts([]);
|
|
||||||
const draft = readCanvasDraft(id);
|
const draft = readCanvasDraft(id);
|
||||||
if (draft) {
|
if (draft) {
|
||||||
const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base);
|
const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base);
|
||||||
if (merged.conflicts.length) {
|
if (merged.conflicts.length) {
|
||||||
externalCanvasConflictRef.current = true;
|
externalCanvasConflictRef.current = true;
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: draft.base };
|
useCanvasStore.getState().loadCanvas(JSON.parse(draft.snapshot).canvasData);
|
||||||
setCanvasSyncConflicts(merged.conflicts);
|
toast.warning("同一画布内容在多处被修改,本地草稿已恢复;请保留此页并联系管理员核对冲突。");
|
||||||
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
|
|
||||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
|
||||||
toast.warning(canvasSyncErrorRef.current);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||||
@ -5122,64 +5115,16 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const canvasPersistenceScope = useRef(id);
|
const canvasPersistenceScope = useRef(id);
|
||||||
const canvasRemoteOperationActive = useRef(false);
|
const canvasRemoteOperationActive = useRef(false);
|
||||||
const canvasLeaveApprovedRef = useRef(false);
|
|
||||||
const [canvasLeaveUrl, setCanvasLeaveUrl] = useState("");
|
|
||||||
const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false);
|
|
||||||
const [canvasLeaveError, setCanvasLeaveError] = useState("");
|
|
||||||
canvasPersistenceScope.current = id;
|
canvasPersistenceScope.current = id;
|
||||||
const hasUnsavedCanvasChanges = useCallback(() => {
|
|
||||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
|
|
||||||
if (useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) return true;
|
|
||||||
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
|
|
||||||
const current = JSON.parse(JSON.stringify(buildPersistedCanvasData())) as CanvasState;
|
|
||||||
const content = (value: CanvasState) => [value.nodes.map(node => {
|
|
||||||
const { updatedAt: _updatedAt, ...nodeData } = node;
|
|
||||||
return { ...nodeData, connections: node.connections || [] };
|
|
||||||
}), value.edges || [], value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
|
|
||||||
return !equal(content(saved), content(current));
|
|
||||||
}, [buildPersistedCanvasData, id]);
|
|
||||||
const requestCanvasLeave = useCallback((url: string) => {
|
|
||||||
if (hasUnsavedCanvasChanges()) {
|
|
||||||
setCanvasLeaveError("");
|
|
||||||
setCanvasLeaveUrl(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
canvasLeaveApprovedRef.current = true;
|
|
||||||
window.location.assign(url);
|
|
||||||
}, [hasUnsavedCanvasChanges]);
|
|
||||||
const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => {
|
|
||||||
if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false;
|
|
||||||
const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData });
|
|
||||||
const merged = mergeCanvasSnapshots(
|
|
||||||
savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : remoteSnapshot,
|
|
||||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
|
||||||
remoteSnapshot,
|
|
||||||
);
|
|
||||||
if (merged.changed) useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
|
||||||
useCanvasStore.getState().setCanvasRevision(result.updatedAt, result.updatedAt);
|
|
||||||
if (merged.conflicts.length) {
|
|
||||||
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
|
|
||||||
if (!externalCanvasConflictRef.current) toast.warning(canvasSyncErrorRef.current);
|
|
||||||
externalCanvasConflictRef.current = true;
|
|
||||||
setCanvasSyncConflicts(merged.conflicts);
|
|
||||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current?.snapshot || remoteSnapshot, snapshot: merged.snapshot });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
|
||||||
externalCanvasConflictRef.current = false;
|
|
||||||
setCanvasSyncConflicts([]);
|
|
||||||
canvasSyncErrorRef.current = "";
|
|
||||||
return true;
|
|
||||||
}, [buildPersistedCanvasData, id]);
|
|
||||||
const persistCanvasStateNow = useCallback(
|
const persistCanvasStateNow = useCallback(
|
||||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||||
if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
|
if (canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
|
||||||
if (externalCanvasConflictRef.current) return false;
|
if (externalCanvasConflictRef.current) return false;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
let canvasData = buildPersistedCanvasData();
|
let canvasData = buildPersistedCanvasData();
|
||||||
let snapshot = JSON.stringify({ canvasData });
|
let snapshot = JSON.stringify({ canvasData });
|
||||||
if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) {
|
if (savedCanvasSnapshotRef.current?.id === id && savedCanvasSnapshotRef.current.snapshot === snapshot) {
|
||||||
clearCanvasDraft(id);
|
clearCanvasDraft(id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -5217,7 +5162,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
if (canvasPersistenceScope.current !== id) return false;
|
if (canvasPersistenceScope.current !== id) return false;
|
||||||
} else {
|
} else {
|
||||||
externalCanvasConflictRef.current = true;
|
externalCanvasConflictRef.current = true;
|
||||||
setCanvasSyncConflicts(merged.conflicts);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5227,19 +5171,15 @@ export default function CanvasEditorLegacyPage({
|
|||||||
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||||
});
|
});
|
||||||
canvasSyncErrorRef.current = result.status === 409
|
canvasSyncErrorRef.current = result.status === 409
|
||||||
? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
|
? "画布同步发生冲突,本次任务未提交。当前改动未覆盖服务端内容,请保留此页并联系管理员核对。"
|
||||||
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`;
|
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"},本次任务未提交。${retained ? "本地草稿已保留,请检查网络后再次发送。" : "请保留当前页面,检查网络后再次发送,暂勿刷新。"}`;
|
||||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||||
} else {
|
} else {
|
||||||
canvasSyncErrorRef.current = "";
|
canvasSyncErrorRef.current = "";
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot };
|
savedCanvasSnapshotRef.current = { id, snapshot };
|
||||||
clearCanvasDraft(id);
|
clearCanvasDraft(id);
|
||||||
externalCanvasConflictRef.current = false;
|
externalCanvasConflictRef.current = false;
|
||||||
setCanvasSyncConflicts([]);
|
|
||||||
externalCanvasConflictNotifiedRef.current = false;
|
externalCanvasConflictNotifiedRef.current = false;
|
||||||
if (result.data?.canvasData && result.data?.updatedAt) {
|
|
||||||
return applyCanvasRemoteSnapshot({ projectId: id, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) });
|
|
||||||
}
|
|
||||||
if (result.data?.updatedAt) useCanvasStore
|
if (result.data?.updatedAt) useCanvasStore
|
||||||
.getState()
|
.getState()
|
||||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||||
@ -5249,7 +5189,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeCanvasWorkspaceType, applyCanvasRemoteSnapshot, buildPersistedCanvasData, id]
|
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistCanvasState = useCallback(
|
const persistCanvasState = useCallback(
|
||||||
@ -5258,124 +5198,48 @@ export default function CanvasEditorLegacyPage({
|
|||||||
[persistCanvasStateNow]
|
[persistCanvasStateNow]
|
||||||
);
|
);
|
||||||
|
|
||||||
const finishCanvasLeave = useCallback(async (save: boolean) => {
|
|
||||||
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
|
|
||||||
setCanvasLeaveError("");
|
|
||||||
if (save && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
|
||||||
setCanvasLeaveError("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再保存并离开。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (canvasAutosaveTimer.current) {
|
|
||||||
clearTimeout(canvasAutosaveTimer.current);
|
|
||||||
canvasAutosaveTimer.current = null;
|
|
||||||
}
|
|
||||||
if (save) {
|
|
||||||
setIsSavingBeforeLeave(true);
|
|
||||||
try {
|
|
||||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布暂时无法同步,请保留此页并稍后重试。");
|
|
||||||
if (hasUnsavedCanvasChanges() && !(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current);
|
|
||||||
if (hasUnsavedCanvasChanges()) throw new Error("还有改动正在同步,请稍后重试离开。");
|
|
||||||
} catch (error) {
|
|
||||||
setCanvasLeaveError(error instanceof Error ? error.message : "画布暂时无法同步,请保留此页并稍后重试。");
|
|
||||||
setIsSavingBeforeLeave(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvasLeaveApprovedRef.current = true;
|
|
||||||
clearCanvasDraft(id);
|
|
||||||
window.location.assign(canvasLeaveUrl);
|
|
||||||
}, [canvasLeaveUrl, hasUnsavedCanvasChanges, id, isSavingBeforeLeave, persistCanvasState]);
|
|
||||||
|
|
||||||
const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => {
|
|
||||||
return canvasSaveQueue.current!.enqueue(async () => {
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const remote = await canvasService.getProject(id);
|
|
||||||
if (canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return;
|
|
||||||
if (!remote.success || !remote.data?.canvasData || savedCanvasSnapshotRef.current?.id !== id) {
|
|
||||||
throw new Error(remote.error || "画布暂时无法读取,改动已保留,请稍后重试同步。");
|
|
||||||
}
|
|
||||||
const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
|
|
||||||
const merged = mergeCanvasSnapshots(
|
|
||||||
savedCanvasSnapshotRef.current.snapshot,
|
|
||||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
|
||||||
remoteSnapshot,
|
|
||||||
choice,
|
|
||||||
);
|
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
|
||||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
|
||||||
const revision = String(remote.data.updatedAt);
|
|
||||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
|
||||||
externalCanvasConflictRef.current = false;
|
|
||||||
canvasSyncErrorRef.current = "";
|
|
||||||
setCanvasSyncConflicts([]);
|
|
||||||
if (!(await persistCanvasStateNow("autosave"))) throw new Error(canvasSyncErrorRef.current);
|
|
||||||
toast.success("画布已合并同步,可以继续操作");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "画布同步失败,改动已保留,请稍后重试。");
|
|
||||||
} finally {
|
|
||||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [buildPersistedCanvasData, id, persistCanvasStateNow]);
|
|
||||||
|
|
||||||
const runCanvasRemoteOperation = useCallback(
|
const runCanvasRemoteOperation = useCallback(
|
||||||
(operation: CanvasRemoteOperation) => {
|
(operation: () => Promise<void>) => {
|
||||||
if (canvasAutosaveTimer.current) {
|
if (canvasAutosaveTimer.current) {
|
||||||
clearTimeout(canvasAutosaveTimer.current);
|
clearTimeout(canvasAutosaveTimer.current);
|
||||||
canvasAutosaveTimer.current = null;
|
canvasAutosaveTimer.current = null;
|
||||||
}
|
}
|
||||||
return canvasSaveQueue.current!.enqueueOperation(
|
return canvasSaveQueue.current!.enqueueOperation(
|
||||||
async () => {
|
() => persistCanvasStateNow("autosave"),
|
||||||
const saved = await persistCanvasStateNow("autosave");
|
|
||||||
if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
async () => {
|
async () => {
|
||||||
canvasRemoteOperationActive.current = true;
|
canvasRemoteOperationActive.current = true;
|
||||||
try {
|
try {
|
||||||
const result = await operation();
|
await operation();
|
||||||
if (result && result.projectId === id && canvasPersistenceScope.current === id) {
|
|
||||||
savedCanvasSnapshotRef.current = { id, snapshot: JSON.stringify({ canvasData: result.canvasData }) };
|
|
||||||
canvasSyncErrorRef.current = "";
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
canvasRemoteOperationActive.current = false;
|
canvasRemoteOperationActive.current = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[persistCanvasStateNow, id]
|
[persistCanvasStateNow]
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistCanvasBeforeAgent = useCallback(async () => {
|
const persistCanvasBeforeAgent = useCallback(async () => {
|
||||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
|
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
|
||||||
throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
|
throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
|
||||||
if (savedCanvasSnapshotRef.current?.id === id) {
|
if (savedCanvasSnapshotRef.current?.id === id) {
|
||||||
if (!hasUnsavedCanvasChanges()) return true;
|
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData;
|
||||||
|
const current = buildPersistedCanvasData();
|
||||||
|
if (JSON.stringify(saved.nodes) === JSON.stringify(current.nodes) && JSON.stringify(saved.edges) === JSON.stringify(current.edges)) return true;
|
||||||
}
|
}
|
||||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||||
if (canvasAutosaveTimer.current) {
|
if (canvasAutosaveTimer.current) {
|
||||||
clearTimeout(canvasAutosaveTimer.current);
|
clearTimeout(canvasAutosaveTimer.current);
|
||||||
canvasAutosaveTimer.current = null;
|
canvasAutosaveTimer.current = null;
|
||||||
}
|
}
|
||||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
|
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
|
||||||
return true;
|
return true;
|
||||||
}, [persistCanvasState, hasUnsavedCanvasChanges, id]);
|
}, [persistCanvasState, buildPersistedCanvasData, id]);
|
||||||
|
|
||||||
const canvasRemoteOperationContext = useMemo(() => ({
|
|
||||||
run: runCanvasRemoteOperation,
|
|
||||||
applySnapshot: applyCanvasRemoteSnapshot,
|
|
||||||
}), [runCanvasRemoteOperation, applyCanvasRemoteSnapshot]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVideoMode) return;
|
if (isVideoMode) return;
|
||||||
const beforeRefresh = (event: Event) => {
|
const beforeRefresh = (event: Event) => {
|
||||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||||
if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
|
||||||
request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (useCanvasStore.getState().isGenerating) {
|
if (useCanvasStore.getState().isGenerating) {
|
||||||
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
|
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
|
||||||
return;
|
return;
|
||||||
@ -5411,32 +5275,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
||||||
};
|
};
|
||||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoading || isAgentMode) return;
|
|
||||||
const retrySync = () => {
|
|
||||||
if (canvasLeaveApprovedRef.current || externalCanvasConflictRef.current || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || !hasUnsavedCanvasChanges()) return;
|
|
||||||
void persistCanvasState("autosave").catch(error => console.error("[Canvas] background sync failed:", error));
|
|
||||||
};
|
|
||||||
const onVisibilityChange = () => {
|
|
||||||
if (!canvasLeaveApprovedRef.current && document.visibilityState === "hidden" && hasUnsavedCanvasChanges()) {
|
|
||||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const retryTimer = window.setInterval(() => {
|
|
||||||
if (document.visibilityState === "visible" && navigator.onLine && canvasSyncErrorRef.current) retrySync();
|
|
||||||
}, 20_000);
|
|
||||||
window.addEventListener("online", retrySync);
|
|
||||||
window.addEventListener("focus", retrySync);
|
|
||||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(retryTimer);
|
|
||||||
window.removeEventListener("online", retrySync);
|
|
||||||
window.removeEventListener("focus", retrySync);
|
|
||||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
||||||
};
|
|
||||||
}, [buildPersistedCanvasData, hasUnsavedCanvasChanges, id, isAgentMode, isAgentOperationRefreshActive, isLoading, persistCanvasState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentOperationRefreshActive()) return;
|
if (isLoading || isAgentOperationRefreshActive()) return;
|
||||||
@ -5539,25 +5378,18 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
// 浏览器离开确认只保护尚未同步的内容;真正离开时保留草稿并发送一次保存。
|
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentMode) return;
|
if (isLoading || isAgentMode) return;
|
||||||
|
|
||||||
const retainDraft = () => {
|
const handleBeforeUnload = () => {
|
||||||
if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null;
|
|
||||||
const canvasData = buildPersistedCanvasData();
|
|
||||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData }) });
|
|
||||||
return canvasData;
|
|
||||||
};
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
||||||
if (!retainDraft()) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = "true";
|
|
||||||
};
|
|
||||||
const handlePageHide = () => {
|
|
||||||
try {
|
try {
|
||||||
const canvasData = retainDraft();
|
if (!canvasReadyRef.current || isAgentOperationRefreshActive()) return;
|
||||||
if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return;
|
const canvasData = buildPersistedCanvasData();
|
||||||
|
writeCanvasDraft(id, {
|
||||||
|
base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }),
|
||||||
|
snapshot: JSON.stringify({ canvasData }),
|
||||||
|
});
|
||||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||||
void fetch(`/api/canvas/${id}`, {
|
void fetch(`/api/canvas/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@ -5576,20 +5408,18 @@ export default function CanvasEditorLegacyPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
window.addEventListener("pagehide", handlePageHide);
|
window.addEventListener("pagehide", handleBeforeUnload);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
window.removeEventListener("pagehide", handlePageHide);
|
window.removeEventListener("pagehide", handleBeforeUnload);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeCanvasWorkspaceType,
|
activeCanvasWorkspaceType,
|
||||||
buildPersistedCanvasData,
|
buildPersistedCanvasData,
|
||||||
hasUnsavedCanvasChanges,
|
|
||||||
id,
|
id,
|
||||||
isAgentOperationRefreshActive,
|
isAgentOperationRefreshActive,
|
||||||
isAgentMode,
|
isAgentMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 智能命名与缩略图逻辑
|
// 智能命名与缩略图逻辑
|
||||||
@ -10583,7 +10413,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CanvasRemoteOperationContext.Provider value={canvasRemoteOperationContext}>
|
<CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div
|
<div
|
||||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||||
@ -10599,30 +10429,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CanvasAgentUiListener projectId={id} workspace="canvas" />
|
<CanvasAgentUiListener projectId={id} workspace="canvas" />
|
||||||
<Dialog open={Boolean(canvasLeaveUrl)} onOpenChange={open => { if (!open && !isSavingBeforeLeave) setCanvasLeaveUrl(""); }}>
|
|
||||||
<DialogContent onEscapeKeyDown={event => { if (isSavingBeforeLeave) event.preventDefault(); }} onPointerDownOutside={event => event.preventDefault()}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>离开前保存画布?</DialogTitle>
|
|
||||||
<DialogDescription>平时会自动保存。当前还有改动尚未同步到云端,可以保存后离开,或放弃这些未同步的改动。</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{canvasLeaveError ? <p role="alert" className="text-sm text-destructive">{canvasLeaveError}</p> : null}
|
|
||||||
<DialogFooter className="gap-2">
|
|
||||||
<Button disabled={isSavingBeforeLeave} variant="ghost" onClick={() => setCanvasLeaveUrl("")}>继续编辑</Button>
|
|
||||||
<Button disabled={isSavingBeforeLeave} variant="outline" onClick={() => void finishCanvasLeave(false)}>不保存并离开</Button>
|
|
||||||
<Button disabled={isSavingBeforeLeave} onClick={() => void finishCanvasLeave(true)}>{isSavingBeforeLeave ? "正在保存…" : "保存并离开"}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
{canvasSyncConflicts.length > 0 ? (
|
|
||||||
<div role="alert" className="absolute inset-x-3 top-28 z-[60] mx-auto flex max-w-2xl flex-wrap items-center gap-3 rounded-lg border bg-background p-4 text-sm shadow-md">
|
|
||||||
<div className="min-w-0 flex-1 basis-64">
|
|
||||||
<p className="font-medium">画布同步发生冲突</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">本页和云端的同一内容有不同修改。请选择保留哪边的冲突内容,其余图片和修改会合并保留。</p>
|
|
||||||
</div>
|
|
||||||
<Button disabled={isSaving} onClick={() => void resolveCanvasSyncConflict("local")}>保留本页修改</Button>
|
|
||||||
<Button disabled={isSaving} variant="outline" onClick={() => void resolveCanvasSyncConflict("remote")}>保留云端修改</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0"
|
||||||
@ -11332,12 +11138,14 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
{isVideoMode ? brandEntry : (
|
{isVideoMode ? brandEntry : (
|
||||||
<CanvasSurfaceHeader
|
<CanvasSurfaceHeader
|
||||||
|
isSaving={isSaving}
|
||||||
onAddImage={handleAddImageNode}
|
onAddImage={handleAddImageNode}
|
||||||
onHome={() => requestCanvasLeave("/")}
|
onHome={goHome}
|
||||||
onOpenFiles={() => {
|
onOpenFiles={() => {
|
||||||
setIsPanelOpen(true);
|
setIsPanelOpen(true);
|
||||||
setPanelMode("fileList");
|
setPanelMode("fileList");
|
||||||
}}
|
}}
|
||||||
|
onSave={() => void handleSave()}
|
||||||
projectTitle={projectTitle}
|
projectTitle={projectTitle}
|
||||||
rightInset={panelOcclusionRight}
|
rightInset={panelOcclusionRight}
|
||||||
/>
|
/>
|
||||||
@ -11442,7 +11250,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
createUrl={createNewCanvasUrl}
|
createUrl={createNewCanvasUrl}
|
||||||
currentId={id}
|
currentId={id}
|
||||||
onClose={() => setPanelMode("chat")}
|
onClose={() => setPanelMode("chat")}
|
||||||
onNavigate={requestCanvasLeave}
|
|
||||||
workspaceLabel={workspaceLabel}
|
workspaceLabel={workspaceLabel}
|
||||||
workspaceType={activeCanvasWorkspaceType}
|
workspaceType={activeCanvasWorkspaceType}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,39 +10,31 @@ export function AppUpdateNotice() {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
const controller = new AbortController();
|
||||||
let controller: AbortController | undefined;
|
|
||||||
let checking = false;
|
let checking = false;
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
if (disposed || checking || document.visibilityState !== "visible") return;
|
if (checking || document.visibilityState !== "visible") return;
|
||||||
checking = true;
|
checking = true;
|
||||||
controller = new AbortController();
|
|
||||||
const current = controller;
|
|
||||||
const timeout = window.setTimeout(() => current.abort(), 8000);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal });
|
const response = await fetch("/api/app-version", { cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]) });
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version)
|
if (!controller.signal.aborted && typeof data.version === "string" && data.version)
|
||||||
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
|
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
|
||||||
} catch {
|
} catch {
|
||||||
// A failed version read leaves the current workspace available.
|
// A failed version read leaves the current workspace available.
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout);
|
|
||||||
checking = false;
|
checking = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void check();
|
void check();
|
||||||
const interval = window.setInterval(check, 60_000);
|
const interval = window.setInterval(check, 60_000);
|
||||||
window.addEventListener("focus", check);
|
window.addEventListener("focus", check);
|
||||||
window.addEventListener("online", check);
|
|
||||||
document.addEventListener("visibilitychange", check);
|
document.addEventListener("visibilitychange", check);
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
controller.abort();
|
||||||
controller?.abort();
|
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
window.removeEventListener("focus", check);
|
window.removeEventListener("focus", check);
|
||||||
window.removeEventListener("online", check);
|
|
||||||
document.removeEventListener("visibilitychange", check);
|
document.removeEventListener("visibilitychange", check);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@ -68,7 +60,6 @@ export function AppUpdateNotice() {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
|
|
||||||
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
|
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useStickToBottom } from "use-stick-to-bottom";
|
|||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
||||||
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
|
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
|
||||||
import { useCanvasRemoteOperation, useCanvasRemoteSnapshot } from "@/components/canvas/CanvasRemoteOperationContext";
|
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||||
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
|
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||||
import { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
|
import { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
|
||||||
@ -3248,7 +3248,6 @@ export function CanvasChatPanel({
|
|||||||
onBeforeAgentRequest,
|
onBeforeAgentRequest,
|
||||||
}: CanvasChatPanelProps) {
|
}: CanvasChatPanelProps) {
|
||||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
||||||
const applyCanvasRemoteSnapshot = useCanvasRemoteSnapshot();
|
|
||||||
const isWorkspacePresentation = presentation === "workspace";
|
const isWorkspacePresentation = presentation === "workspace";
|
||||||
const isVideoWorkspace = workspaceVariant === "video";
|
const isVideoWorkspace = workspaceVariant === "video";
|
||||||
const propCanvasToolbarConfig = useMemo(
|
const propCanvasToolbarConfig = useMemo(
|
||||||
@ -7991,12 +7990,8 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
const store = useCanvasStore.getState();
|
const store = useCanvasStore.getState();
|
||||||
store.markAgentOperationRefresh();
|
store.markAgentOperationRefresh();
|
||||||
if (applyCanvasRemoteSnapshot) {
|
|
||||||
if (!applyCanvasRemoteSnapshot({ projectId: targetProjectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt || "") })) return false;
|
|
||||||
} else {
|
|
||||||
store.loadCanvas(result.data.canvasData);
|
store.loadCanvas(result.data.canvasData);
|
||||||
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||||
}
|
|
||||||
if (createdNodeIds.length > 0) {
|
if (createdNodeIds.length > 0) {
|
||||||
const refreshedStore = useCanvasStore.getState();
|
const refreshedStore = useCanvasStore.getState();
|
||||||
refreshedStore.selectNodes(createdNodeIds);
|
refreshedStore.selectNodes(createdNodeIds);
|
||||||
@ -8024,7 +8019,7 @@ export function CanvasChatPanel({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
|
[applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -8972,10 +8967,6 @@ export function CanvasChatPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const beforeRefresh = (event: Event) => {
|
const beforeRefresh = (event: Event) => {
|
||||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||||
if (composerUploadProgress) {
|
|
||||||
request.block("素材仍在上传,请等待上传完成后再刷新。当前页面和输入已保留。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
|
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
|
||||||
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
|
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
|
||||||
return;
|
return;
|
||||||
@ -8985,17 +8976,9 @@ export function CanvasChatPanel({
|
|||||||
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = "true";
|
|
||||||
};
|
|
||||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||||
if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload);
|
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||||
return () => {
|
}, [activeChatId, input, attachments, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
|
||||||
window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
window.removeEventListener("beforeunload", beforeUnload);
|
|
||||||
};
|
|
||||||
}, [activeChatId, input, attachments, composerUploadProgress, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
|
|
||||||
|
|
||||||
const restorePendingClawdRequestToComposer = useCallback(
|
const restorePendingClawdRequestToComposer = useCallback(
|
||||||
(snapshot: PendingClawdRequestRestoreSnapshot) => {
|
(snapshot: PendingClawdRequestRestoreSnapshot) => {
|
||||||
|
|||||||
@ -19,7 +19,6 @@ const PROJECT_LIST_PAGE_SIZE = 24;
|
|||||||
interface CanvasFileListPanelProps {
|
interface CanvasFileListPanelProps {
|
||||||
currentId: string;
|
currentId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onNavigate?: (url: string) => void;
|
|
||||||
workspaceType?: CanvasWorkspaceType;
|
workspaceType?: CanvasWorkspaceType;
|
||||||
workspaceLabel?: string;
|
workspaceLabel?: string;
|
||||||
createUrl?: string;
|
createUrl?: string;
|
||||||
@ -29,7 +28,6 @@ interface CanvasFileListPanelProps {
|
|||||||
export function CanvasFileListPanel({
|
export function CanvasFileListPanel({
|
||||||
currentId,
|
currentId,
|
||||||
onClose,
|
onClose,
|
||||||
onNavigate,
|
|
||||||
workspaceType = "canvas",
|
workspaceType = "canvas",
|
||||||
workspaceLabel = "画布",
|
workspaceLabel = "画布",
|
||||||
createUrl = "/canvas?new=1",
|
createUrl = "/canvas?new=1",
|
||||||
@ -90,10 +88,6 @@ export function CanvasFileListPanel({
|
|||||||
|
|
||||||
// 创建新画布
|
// 创建新画布
|
||||||
const handleCreateNew = async () => {
|
const handleCreateNew = async () => {
|
||||||
if (onNavigate) {
|
|
||||||
onNavigate(createUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(createUrl);
|
router.push(createUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -106,10 +100,6 @@ export function CanvasFileListPanel({
|
|||||||
}
|
}
|
||||||
const targetUrl = buildProjectUrl(id);
|
const targetUrl = buildProjectUrl(id);
|
||||||
onClose(); // 关闭文件列表面板
|
onClose(); // 关闭文件列表面板
|
||||||
if (onNavigate) {
|
|
||||||
onNavigate(targetUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 强制整页跳转,避免仅更新 URL 不刷新
|
// 强制整页跳转,避免仅更新 URL 不刷新
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.assign(targetUrl);
|
window.location.assign(targetUrl);
|
||||||
|
|||||||
@ -1,26 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import type { CanvasState } from "@/types/canvas";
|
|
||||||
|
|
||||||
export type CanvasRemoteOperationResult = {
|
|
||||||
projectId: string;
|
|
||||||
canvasData: CanvasState;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
export type CanvasRemoteOperation = (() => Promise<void>) | (() => Promise<CanvasRemoteOperationResult | undefined>);
|
|
||||||
|
|
||||||
export const CanvasRemoteOperationContext = createContext<
|
export const CanvasRemoteOperationContext = createContext<
|
||||||
{
|
((operation: () => Promise<void>) => Promise<void>) | null
|
||||||
run: (operation: CanvasRemoteOperation) => Promise<void>;
|
|
||||||
applySnapshot: (result: CanvasRemoteOperationResult) => boolean;
|
|
||||||
} | null
|
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
export function useCanvasRemoteOperation() {
|
export function useCanvasRemoteOperation() {
|
||||||
return useContext(CanvasRemoteOperationContext)?.run || null;
|
return useContext(CanvasRemoteOperationContext);
|
||||||
}
|
|
||||||
|
|
||||||
export function useCanvasRemoteSnapshot() {
|
|
||||||
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||||
|
|
||||||
|
import { Loader2, Save } from "lucide-react";
|
||||||
|
|
||||||
type CanvasSurfaceHeaderProps = {
|
type CanvasSurfaceHeaderProps = {
|
||||||
|
isSaving: boolean;
|
||||||
onAddImage: () => void;
|
onAddImage: () => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
onOpenFiles: () => void;
|
onOpenFiles: () => void;
|
||||||
|
onSave: () => void;
|
||||||
projectTitle: string;
|
projectTitle: string;
|
||||||
rightInset: number;
|
rightInset: number;
|
||||||
};
|
};
|
||||||
@ -14,9 +18,11 @@ const iconButtonClass =
|
|||||||
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
||||||
|
|
||||||
export function CanvasSurfaceHeader({
|
export function CanvasSurfaceHeader({
|
||||||
|
isSaving,
|
||||||
onAddImage,
|
onAddImage,
|
||||||
onHome,
|
onHome,
|
||||||
onOpenFiles,
|
onOpenFiles,
|
||||||
|
onSave,
|
||||||
projectTitle,
|
projectTitle,
|
||||||
rightInset,
|
rightInset,
|
||||||
}: CanvasSurfaceHeaderProps) {
|
}: CanvasSurfaceHeaderProps) {
|
||||||
@ -71,6 +77,19 @@ export function CanvasSurfaceHeader({
|
|||||||
<ReferenceIcon name="plus" size={16} />
|
<ReferenceIcon name="plus" size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onSave}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,5 +63,4 @@ export async function executeCanvasToolbarCapability(
|
|||||||
}
|
}
|
||||||
current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||||
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||||
return { projectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,5 +107,5 @@ export function mergeCanvasSnapshots(
|
|||||||
(e: any) => ids.has(e.source) && ids.has(e.target),
|
(e: any) => ids.has(e.source) && ids.has(e.target),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { snapshot: JSON.stringify(snapshot), conflicts, changed: !same(snapshot, local) };
|
return { snapshot: JSON.stringify(snapshot), conflicts };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,16 +10,11 @@ async function setup(page: Page, failRead = false) {
|
|||||||
const state = {
|
const state = {
|
||||||
canvasData: { nodes: [imageNode("source")], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" },
|
canvasData: { nodes: [imageNode("source")], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" },
|
||||||
revision: 1, failSave: false, failRead, conflictNextSave: false, conflictSameNode: false,
|
revision: 1, failSave: false, failRead, conflictNextSave: false, conflictSameNode: false,
|
||||||
writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[],
|
writes: [] as any[], metadataWrites: 0, submissions: [] as any[],
|
||||||
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
|
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
|
||||||
nativeResultNext: false,
|
|
||||||
holdUpload: false, uploadStarted: 0, finishUpload: null as (() => void) | null,
|
|
||||||
};
|
};
|
||||||
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
|
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
|
||||||
const project = () => ({ id: projectId, title: "同步验证", canvasData: JSON.parse(JSON.stringify(state.canvasData, (_key, value) =>
|
const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, updatedAt: revision() });
|
||||||
value && typeof value === "object" && !Array.isArray(value)
|
|
||||||
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
|
|
||||||
: value)), updatedAt: revision() });
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
page.on("pageerror", error => errors.push(error.message));
|
page.on("pageerror", error => errors.push(error.message));
|
||||||
await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
|
await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
|
||||||
@ -28,10 +23,6 @@ async function setup(page: Page, failRead = false) {
|
|||||||
const path = new URL(request.url()).pathname;
|
const path = new URL(request.url()).pathname;
|
||||||
let data: unknown = {};
|
let data: unknown = {};
|
||||||
let status = 200;
|
let status = 200;
|
||||||
if (path.startsWith("/api/files/") && request.method() === "POST" && state.holdUpload) {
|
|
||||||
state.uploadStarted++;
|
|
||||||
await new Promise<void>(resolve => { state.finishUpload = resolve; });
|
|
||||||
}
|
|
||||||
if (path === `/api/canvas/${projectId}`) {
|
if (path === `/api/canvas/${projectId}`) {
|
||||||
if (request.method() === "GET") {
|
if (request.method() === "GET") {
|
||||||
status = state.failRead ? 500 : 200;
|
status = state.failRead ? 500 : 200;
|
||||||
@ -62,23 +53,11 @@ async function setup(page: Page, failRead = false) {
|
|||||||
data = { success: true, previousCanvasRevision, data: project() };
|
data = { success: true, previousCanvasRevision, data: project() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (path === `/api/canvas/${projectId}/capabilities` && request.method() === "POST") {
|
|
||||||
state.capabilitySubmissions.push(request.postDataJSON());
|
|
||||||
state.canvasData.nodes.push({ ...imageNode(`enhanced-${state.capabilitySubmissions.length}`), position: { x: 520, y: 130 } });
|
|
||||||
state.revision++;
|
|
||||||
data = { success: true, task: { status: "succeeded" } };
|
|
||||||
} else if (path === "/api/canvas/agent-chat" && request.method() === "POST") {
|
} else if (path === "/api/canvas/agent-chat" && request.method() === "POST") {
|
||||||
state.submissions.push(request.postDataJSON());
|
state.submissions.push(request.postDataJSON());
|
||||||
let operation = "";
|
|
||||||
if (state.nativeResultNext) {
|
|
||||||
state.nativeResultNext = false;
|
|
||||||
state.canvasData.nodes.push({ ...imageNode("agent-result"), position: { x: 520, y: 130 } });
|
|
||||||
state.revision++;
|
|
||||||
operation = `data: ${JSON.stringify({ type: "data-canvasOperation", data: { status: "applied", projectId, createdNodeIds: ["agent-result"] } })}\n\n`;
|
|
||||||
}
|
|
||||||
if (state.holdRequest) await new Promise<void>(resolve => { state.finishRequest = resolve; });
|
if (state.holdRequest) await new Promise<void>(resolve => { state.finishRequest = resolve; });
|
||||||
await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" }, body:
|
await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" }, body:
|
||||||
'data: {"type":"start","messageId":"sync-assistant"}\n\n' + operation + 'data: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' });
|
'data: {"type":"start","messageId":"sync-assistant"}\n\ndata: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' });
|
||||||
return;
|
return;
|
||||||
} else if (path === "/api/app-version") data = { version: state.appVersion };
|
} else if (path === "/api/app-version") data = { version: state.appVersion };
|
||||||
else if (path === "/api/files/direct-upload") {
|
else if (path === "/api/files/direct-upload") {
|
||||||
@ -114,207 +93,6 @@ async function moveImage(page: Page) {
|
|||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
}
|
}
|
||||||
|
|
||||||
const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => {
|
|
||||||
const event = new Event("beforeunload", { cancelable: true });
|
|
||||||
Object.defineProperty(event, "returnValue", { value: "", writable: true });
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
return event.defaultPrevented && Boolean(event.returnValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("background saving recovers when the network returns without a manual save", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await expect(page.getByRole("button", { name: "保存", exact: true })).toHaveCount(0);
|
|
||||||
state.failSave = true;
|
|
||||||
await moveImage(page);
|
|
||||||
await expect.poll(() => state.writes.filter(body => body.canvasData).length).toBeGreaterThan(0);
|
|
||||||
expect(state.canvasData.nodes[0].position.x).toBe(160);
|
|
||||||
state.failSave = false;
|
|
||||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
|
||||||
await expect.poll(() => state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
|
||||||
await expect.poll(() => unloadWouldBeBlocked(page)).toBe(false);
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("selecting a saved image does not require another save before an Agent message", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await page.locator('[data-canvas-node-id="source"]').click();
|
|
||||||
state.failSave = true;
|
|
||||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
|
||||||
await editor.fill("分析这张已经保存的图片");
|
|
||||||
await editor.press("Enter");
|
|
||||||
await expect.poll(() => state.submissions.length).toBe(1);
|
|
||||||
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source"]);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
|
||||||
await page.locator('[data-canvas-node-id="source"]').click();
|
|
||||||
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
|
||||||
state.failSave = true;
|
|
||||||
await moveImage(page);
|
|
||||||
const writes = state.writes.length;
|
|
||||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
|
||||||
expect(state.writes).toHaveLength(writes);
|
|
||||||
const draft = await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId);
|
|
||||||
expect(JSON.parse(JSON.parse(draft!).snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pagehide and visibilitychange issue only one closing save", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
state.failSave = true;
|
|
||||||
await moveImage(page);
|
|
||||||
const writes = state.writes.length;
|
|
||||||
await page.evaluate(() => {
|
|
||||||
window.dispatchEvent(new Event("pagehide"));
|
|
||||||
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
});
|
|
||||||
await expect.poll(() => state.writes.length).toBe(writes + 1);
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaving with pending changes can continue editing or save before navigating", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
|
||||||
await moveImage(page);
|
|
||||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
|
||||||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
await page.screenshot({ path: test.info().outputPath("leave-dialog-desktop.png"), animations: "disabled" });
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.screenshot({ path: test.info().outputPath("leave-dialog-mobile.png"), animations: "disabled" });
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
|
||||||
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
|
|
||||||
await expect(dialog).toHaveCount(0);
|
|
||||||
await moveImage(page);
|
|
||||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
|
||||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
|
||||||
await expect(page.getByText("已返回首页")).toBeVisible();
|
|
||||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("failed saving keeps the leave dialog and canvas until a successful retry", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
|
||||||
state.failSave = true;
|
|
||||||
await moveImage(page);
|
|
||||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
|
||||||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
|
||||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
|
||||||
await expect(dialog.getByRole("alert")).toContainText("HTTP 503");
|
|
||||||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
state.failSave = false;
|
|
||||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
|
||||||
await expect(page.getByText("已返回首页")).toBeVisible();
|
|
||||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("discarding pending changes leaves without saving or restoring the discarded draft", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
|
||||||
state.failSave = true;
|
|
||||||
await moveImage(page);
|
|
||||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
|
||||||
const writes = state.writes.length;
|
|
||||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
|
||||||
await page.getByRole("button", { name: "不保存并离开", exact: true }).click();
|
|
||||||
await expect(page.getByText("已返回首页")).toBeVisible();
|
|
||||||
expect(state.writes).toHaveLength(writes);
|
|
||||||
expect(state.canvasData.nodes[0].position.x).toBe(160);
|
|
||||||
expect(await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId)).toBeNull();
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creating another project also offers to preserve pending canvas changes", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
state.failSave = true;
|
|
||||||
await moveImage(page);
|
|
||||||
await page.getByRole("button", { name: "查看项目文件", exact: true }).click();
|
|
||||||
await page.getByRole("button", { name: "新建画布", exact: true }).first().click();
|
|
||||||
await expect(page.getByRole("dialog", { name: "离开前保存画布?" })).toBeVisible();
|
|
||||||
await page.getByRole("button", { name: "继续编辑", exact: true }).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/canvas/${projectId}$`));
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an enhanced result can be moved and archived before the next save without blocking Agent", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await page.locator('[data-canvas-node-id="source"]').click();
|
|
||||||
await page.locator('[data-tool-key="enhance"]').click();
|
|
||||||
const result = page.locator('[data-canvas-node-id="enhanced-1"]');
|
|
||||||
await expect(result).toBeVisible();
|
|
||||||
const box = (await result.boundingBox())!;
|
|
||||||
await page.mouse.move(box.x + 100, box.y + 80);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
|
|
||||||
await page.mouse.up();
|
|
||||||
state.canvasData.nodes.find(node => node.id === "enhanced-1")!.data.src = `${imageOrigin}/uploads/archived-result.png`;
|
|
||||||
state.revision++;
|
|
||||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
|
||||||
await editor.fill("保留增强结果的位置,继续分析");
|
|
||||||
await editor.press("Enter");
|
|
||||||
await expect.poll(() => state.submissions.length).toBe(1);
|
|
||||||
const saved = state.canvasData.nodes.find(node => node.id === "enhanced-1")!;
|
|
||||||
expect(saved.position.x).toBeGreaterThan(520);
|
|
||||||
expect(saved.data.src).toContain("archived-result.png");
|
|
||||||
expect(state.capabilitySubmissions).toHaveLength(1);
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toolbar save failures show the cause and recover without a paid submission", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await moveImage(page);
|
|
||||||
state.failSave = true;
|
|
||||||
await page.locator('[data-tool-key="enhance"]').click();
|
|
||||||
await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible();
|
|
||||||
await expect(page.getByText(/本次处理未提交/)).toBeVisible();
|
|
||||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
|
||||||
state.failSave = false;
|
|
||||||
await page.locator('[data-tool-key="enhance"]').click();
|
|
||||||
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
|
|
||||||
expect(state.capabilitySubmissions).toHaveLength(1);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("native Agent results update the save baseline before moving and archiving the result", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
state.nativeResultNext = true;
|
|
||||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
|
||||||
await editor.fill("先分析再处理图片");
|
|
||||||
await editor.press("Enter");
|
|
||||||
const result = page.locator('[data-canvas-node-id="agent-result"]');
|
|
||||||
await expect(result).toBeVisible();
|
|
||||||
await expect(editor).toBeEditable();
|
|
||||||
const box = (await result.boundingBox())!;
|
|
||||||
await page.mouse.move(box.x + 100, box.y + 80);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
|
|
||||||
await page.mouse.up();
|
|
||||||
state.canvasData.nodes.find(node => node.id === "agent-result")!.data.src = `${imageOrigin}/uploads/agent-archived.png`;
|
|
||||||
state.revision++;
|
|
||||||
await editor.fill("保留位置,继续分析");
|
|
||||||
await editor.press("Enter");
|
|
||||||
await expect.poll(() => state.submissions.length).toBe(2);
|
|
||||||
const saved = state.canvasData.nodes.find(node => node.id === "agent-result")!;
|
|
||||||
expect(saved.position.x).toBeGreaterThan(520);
|
|
||||||
expect(saved.data.src).toContain("agent-archived.png");
|
|
||||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("upload naming and consecutive Agent messages sync automatically without manual saves", async ({ page }) => {
|
test("upload naming and consecutive Agent messages sync automatically without manual saves", async ({ page }) => {
|
||||||
const { state, errors } = await setup(page);
|
const { state, errors } = await setup(page);
|
||||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||||
@ -391,39 +169,6 @@ test("conflicting edits keep both canvases and do not submit an Agent task", asy
|
|||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const choice of ["local", "remote"] as const) {
|
|
||||||
test(`an existing conflicting draft can keep the ${choice} edit and resume without losing other images`, async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await moveImage(page);
|
|
||||||
state.conflictSameNode = true;
|
|
||||||
state.conflictNextSave = true;
|
|
||||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
|
||||||
await editor.fill("继续分析图片");
|
|
||||||
await editor.press("Enter");
|
|
||||||
await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toBeVisible();
|
|
||||||
state.canvasData.nodes.push(imageNode("remote-addition"));
|
|
||||||
state.revision++;
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.locator('[data-canvas-node-id="remote-addition"]')).toBeVisible();
|
|
||||||
if (choice === "local") {
|
|
||||||
await page.screenshot({ path: test.info().outputPath("conflict-recovery-desktop.png"), animations: "disabled" });
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.screenshot({ path: test.info().outputPath("conflict-recovery-mobile.png"), animations: "disabled" });
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
|
||||||
}
|
|
||||||
await page.getByRole("button", { name: choice === "local" ? "保留本页修改" : "保留云端修改", exact: true }).click();
|
|
||||||
await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toHaveCount(0);
|
|
||||||
await editor.press("Enter");
|
|
||||||
await expect.poll(() => state.submissions.length).toBe(1);
|
|
||||||
const x = state.canvasData.nodes.find(node => node.id === "source")!.position.x;
|
|
||||||
if (choice === "remote") expect(x).toBe(500);
|
|
||||||
else { expect(x).toBeGreaterThan(160); expect(x).not.toBe(500); }
|
|
||||||
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source", "remote-addition"]);
|
|
||||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a failed canvas read cannot create or save an empty replacement", async ({ page }) => {
|
test("a failed canvas read cannot create or save an empty replacement", async ({ page }) => {
|
||||||
const { state, errors } = await setup(page, true);
|
const { state, errors } = await setup(page, true);
|
||||||
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
|
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
|
||||||
@ -452,92 +197,6 @@ async function countRefreshes(page: Page, state: { appVersion: string }) {
|
|||||||
return () => navigations;
|
return () => navigations;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("unfinished image uploads remain on the page even after their canvas placeholder was saved", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
const refreshes = await countRefreshes(page, state);
|
|
||||||
state.holdUpload = true;
|
|
||||||
await page.mouse.click(100, 90);
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const bytes = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2d0AAAAASUVORK5CYII="), c => c.charCodeAt(0));
|
|
||||||
const transfer = new DataTransfer();
|
|
||||||
transfer.items.add(new File([bytes], "上传保护.png", { type: "image/png" }));
|
|
||||||
window.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
|
||||||
});
|
|
||||||
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
|
|
||||||
await expect.poll(() => state.canvasData.nodes.filter(node => node.data.src === "").length).toBe(1);
|
|
||||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
|
||||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
|
||||||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
|
||||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
|
||||||
await expect(dialog.getByRole("alert")).toContainText("还有图片未上传成功");
|
|
||||||
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
|
|
||||||
await showUpdate(page, state);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
|
||||||
await expect(page.getByRole("alertdialog")).toContainText("还有图片未上传成功");
|
|
||||||
expect(refreshes()).toBe(0);
|
|
||||||
state.holdUpload = false;
|
|
||||||
state.finishUpload?.();
|
|
||||||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
|
||||||
await expect.poll(refreshes).toBe(1);
|
|
||||||
expect(state.canvasData.nodes).toHaveLength(2);
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a composer upload blocks release refresh and closing until the image and input are retained", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
const refreshes = await countRefreshes(page, state);
|
|
||||||
state.holdUpload = true;
|
|
||||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
|
||||||
await editor.fill("上传后保留这条输入");
|
|
||||||
await editor.evaluate(element => {
|
|
||||||
const transfer = new DataTransfer();
|
|
||||||
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "聊天上传保护.png", { type: "image/png" }));
|
|
||||||
element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
|
||||||
});
|
|
||||||
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
|
|
||||||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
|
||||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
|
||||||
await showUpdate(page, state);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
|
||||||
await expect(page.getByRole("alertdialog")).toContainText("素材仍在上传");
|
|
||||||
expect(refreshes()).toBe(0);
|
|
||||||
state.holdUpload = false;
|
|
||||||
state.finishUpload?.();
|
|
||||||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
|
||||||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toHaveCount(1);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
|
||||||
await expect.poll(refreshes).toBe(1);
|
|
||||||
await expect(editor).toContainText("上传后保留这条输入");
|
|
||||||
await expect(editor.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
|
||||||
expect(state.canvasData.nodes).toHaveLength(2);
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => {
|
|
||||||
await page.context().addInitScript(() => {
|
|
||||||
Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true });
|
|
||||||
Object.defineProperty(AbortSignal, "timeout", { value: undefined, configurable: true });
|
|
||||||
});
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await showUpdate(page, state);
|
|
||||||
await expect(page.getByRole("button", { name: "同步并刷新", exact: true })).toBeVisible();
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a release is detected by the minute timer without requiring a focus change", async ({ page }) => {
|
|
||||||
await page.clock.install();
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
state.appVersion = "20261106T120000Z-nextrelease1";
|
|
||||||
await page.clock.fastForward(60_001);
|
|
||||||
await expect(page.getByRole("alertdialog")).toContainText("平台已更新");
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => {
|
test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => {
|
||||||
const { state, errors } = await setup(page);
|
const { state, errors } = await setup(page);
|
||||||
const refreshes = await countRefreshes(page, state);
|
const refreshes = await countRefreshes(page, state);
|
||||||
@ -590,19 +249,9 @@ test("an update keeps the current page when automatic synchronization fails", as
|
|||||||
await showUpdate(page, state);
|
await showUpdate(page, state);
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||||
await expect(page.getByRole("alertdialog")).toContainText("HTTP 503");
|
await expect(page.getByRole("alertdialog")).toContainText("HTTP 503");
|
||||||
await expect(page.getByRole("alertdialog")).not.toContainText("本次任务未提交");
|
|
||||||
await expect(page.getByRole("alertdialog")).not.toContainText("再次发送");
|
|
||||||
expect(refreshes()).toBe(0);
|
expect(refreshes()).toBe(0);
|
||||||
expect(state.submissions).toHaveLength(0);
|
expect(state.submissions).toHaveLength(0);
|
||||||
await page.screenshot({ path: test.info().outputPath("update-error-desktop.png"), animations: "disabled" });
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.screenshot({ path: test.info().outputPath("update-error-mobile.png"), animations: "disabled" });
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
|
||||||
await page.getByRole("button", { name: "返回处理", exact: true }).click();
|
|
||||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
|
||||||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
|
||||||
state.failSave = false;
|
state.failSave = false;
|
||||||
await showUpdate(page, state);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||||
await expect.poll(refreshes).toBe(1);
|
await expect.poll(refreshes).toBe(1);
|
||||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user