diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 2069ce994..b511dda7c 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -1,6 +1,7 @@ "use client"; import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue"; +import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext"; import { AlertCircle, AlertTriangle, @@ -5080,9 +5081,10 @@ export default function CanvasEditorLegacyPage({ if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue(); const canvasAutosaveTimer = useRef | null>(null); const canvasPersistenceScope = useRef(id); + const canvasRemoteOperationActive = useRef(false); canvasPersistenceScope.current = id; - const persistCanvasState = useCallback( - (reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => { + const persistCanvasStateNow = useCallback( + async (reason: "autosave" | "delete" | "beforeunload") => { if (canvasPersistenceScope.current !== id) return false; setIsSaving(true); try { @@ -5122,10 +5124,37 @@ export default function CanvasEditorLegacyPage({ } finally { if (canvasPersistenceScope.current === id) setIsSaving(false); } - }), + }, [activeCanvasWorkspaceType, buildPersistedCanvasData, id] ); + const persistCanvasState = useCallback( + (reason: "autosave" | "delete" | "beforeunload") => + canvasSaveQueue.current!.enqueue(() => persistCanvasStateNow(reason)), + [persistCanvasStateNow] + ); + + const runCanvasRemoteOperation = useCallback( + (operation: () => Promise) => { + if (canvasAutosaveTimer.current) { + clearTimeout(canvasAutosaveTimer.current); + canvasAutosaveTimer.current = null; + } + return canvasSaveQueue.current!.enqueueOperation( + () => persistCanvasStateNow("autosave"), + async () => { + canvasRemoteOperationActive.current = true; + try { + await operation(); + } finally { + canvasRemoteOperationActive.current = false; + } + } + ); + }, + [persistCanvasStateNow] + ); + const persistCanvasBeforeAgent = useCallback(() => { // Flush the pending local edit once instead of racing its scheduled autosave. if (canvasAutosaveTimer.current) { @@ -5168,6 +5197,7 @@ export default function CanvasEditorLegacyPage({ const refreshIfExternallyUpdated = async () => { if ( cancelled || + canvasRemoteOperationActive.current || document.visibilityState !== "visible" || isSaving || externalCanvasConflictRef.current || @@ -5197,7 +5227,7 @@ export default function CanvasEditorLegacyPage({ } const result = await canvasService.getProject(id); - if (cancelled || !result.success || !result.data) return; + if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return; useCanvasStore.getState().markAgentOperationRefresh(); hydrateCanvasProject(result.data); lastLocalCanvasMutationAtRef.current = Date.now(); @@ -10281,6 +10311,7 @@ export default function CanvasEditorLegacyPage({ } return ( +
+ ); } diff --git a/components/canvas/CanvasRemoteOperationContext.tsx b/components/canvas/CanvasRemoteOperationContext.tsx new file mode 100644 index 000000000..432d6d1b8 --- /dev/null +++ b/components/canvas/CanvasRemoteOperationContext.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { createContext, useContext } from "react"; + +export const CanvasRemoteOperationContext = createContext< + ((operation: () => Promise) => Promise) | null +>(null); + +export function useCanvasRemoteOperation() { + return useContext(CanvasRemoteOperationContext); +} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index c2b685fe9..e673c09e2 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -232,6 +232,8 @@ import { import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors"; import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state"; import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; +import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability"; +import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext"; import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry"; import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types"; import { cn, fetcher } from "@/lib/utils"; @@ -2078,7 +2080,7 @@ export function ImageNode({ const selectNode = useCanvasStore((state) => state.selectNode); const projectId = useCanvasStore((state) => state.projectId); const getCanvasState = useCanvasStore((state) => state.getCanvasState); - const loadCanvas = useCanvasStore((state) => state.loadCanvas); + const runCanvasRemoteOperation = useCanvasRemoteOperation(); const addPendingChatAttachment = useCanvasStore( (state) => state.addPendingChatAttachment ); @@ -6708,7 +6710,7 @@ export function ImageNode({ const executeSharedToolbarCapability = async ( action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split" ) => { - if (!projectId) { + if (!projectId || !runCanvasRemoteOperation) { toast.error("画布还在加载,请稍后再试"); return; } @@ -6727,21 +6729,7 @@ export function ImageNode({ projectId, source: "toolbar", }); - const response = await fetch(`/api/canvas/${projectId}/capabilities`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(command), - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok || payload?.success !== true) { - throw new Error(payload?.error || `${capability.title}失败`); - } - - const projectResult = await canvasService.getProject(projectId); - if (!projectResult.success || !projectResult.data?.canvasData) { - throw new Error(projectResult.error || "结果已生成,但画布同步失败"); - } - loadCanvas(projectResult.data.canvasData); + await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command)); toast.success(`${capability.title}完成`); } catch (error) { const message = diff --git a/lib/client/canvas-save-queue.ts b/lib/client/canvas-save-queue.ts index 87dbbe791..d91b01583 100644 --- a/lib/client/canvas-save-queue.ts +++ b/lib/client/canvas-save-queue.ts @@ -1,11 +1,20 @@ /** Serialize local saves so each request reads the revision returned by the last. */ export function createCanvasSaveQueue() { let pending: Promise = Promise.resolve(); - return { + const queue = { enqueue(save: () => Promise): Promise { const next = pending.then(save, save); pending = next.catch(() => undefined); return next; }, + enqueueOperation(save: () => Promise, operation: () => Promise) { + return queue.enqueue(async () => { + if (!(await save())) { + throw new Error("画布尚未保存成功,请确认网络或画布同步状态后重试"); + } + await operation(); + }); + }, }; + return queue; } diff --git a/lib/client/canvas-toolbar-capability.ts b/lib/client/canvas-toolbar-capability.ts new file mode 100644 index 000000000..c31e101a8 --- /dev/null +++ b/lib/client/canvas-toolbar-capability.ts @@ -0,0 +1,40 @@ +import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; +import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot"; +import { canvasService } from "@/services/canvas"; +import { useCanvasStore } from "@/store/canvas"; + +/** Runs after the editor has saved and while its save queue is reserved. */ +export async function executeCanvasToolbarCapability( + command: CanvasCapabilityClientCommand, +) { + const projectId = command.projectId!; + const before = useCanvasStore.getState(); + if (before.projectId !== projectId) throw new Error("画布已切换,请重新选择图片"); + const base = JSON.stringify({ canvasData: before.getCanvasState() }); + const response = await fetch(`/api/canvas/${projectId}/capabilities`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(command), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.success !== true) { + throw new Error(payload?.error || "图片处理失败"); + } + + const result = await canvasService.getProject(projectId); + if (!result.success || !result.data?.canvasData) { + throw new Error(result.error || "结果已生成,但画布同步失败"); + } + const current = useCanvasStore.getState(); + if (current.projectId !== projectId) return; + const merged = mergeCanvasSnapshots( + base, + JSON.stringify({ canvasData: current.getCanvasState() }), + JSON.stringify({ canvasData: result.data.canvasData }), + ); + if (merged.conflicts.length > 0) { + throw new Error("结果已生成,但画布有冲突;请保留本地改动后刷新查看结果"); + } + current.loadCanvas(JSON.parse(merged.snapshot).canvasData); + current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); +} diff --git a/tests/canvas-toolbar.config.ts b/tests/canvas-toolbar.config.ts new file mode 100644 index 000000000..b0a0d199e --- /dev/null +++ b/tests/canvas-toolbar.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from "@playwright/test"; +export default defineConfig({ testDir: "./routes", testMatch: /canvas-toolbar-capability\.test\.ts|canvas-capability-runtime\.test\.ts/, workers: 1, reporter: "list" }); diff --git a/tests/routes/canvas-toolbar-capability.test.ts b/tests/routes/canvas-toolbar-capability.test.ts new file mode 100644 index 000000000..bb4a03b76 --- /dev/null +++ b/tests/routes/canvas-toolbar-capability.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue"; +import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability"; +import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; +import { useCanvasStore } from "@/store/canvas"; + +const originalFetch = globalThis.fetch; +const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, data: { src: `https://example.com/${id}.png` } }); +const command = () => createCanvasCapabilityClientCommand({ + projectId: "project-a", capabilityId: "image.remove_watermark", nodeIds: ["source"], source: "toolbar", +}); +test.beforeEach(() => { + useCanvasStore.getState().clearCanvas(); + useCanvasStore.getState().setProject("project-a"); + useCanvasStore.getState().loadCanvas({ nodes: [node("source") as any], edges: [] }); + useCanvasStore.getState().setCanvasRevision("r1", "r1"); +}); +test.afterEach(() => { globalThis.fetch = originalFetch; }); + +test("waits for the selected image to be saved before submitting and keeps subsequent saves behind the result", async () => { + const queue = createCanvasSaveQueue(); + const events: string[] = []; + let finishSave!: () => void; + const pendingSave = new Promise(resolve => { finishSave = resolve; }); + const pending = queue.enqueueOperation(async () => { + events.push("saving"); await pendingSave; events.push("saved"); return true; + }, async () => { events.push("submitted"); }); + const autosave = queue.enqueue(async () => { events.push("autosaved"); }); + await Promise.resolve(); + expect(events).toEqual(["saving"]); + finishSave(); + await Promise.all([pending, autosave]); + expect(events).toEqual(["saving", "saved", "submitted", "autosaved"]); +}); + +test("a failed save does not submit a paid image task or block later saves", async () => { + const queue = createCanvasSaveQueue(); + let submissions = 0; + await expect(queue.enqueueOperation(async () => false, async () => { submissions++; })).rejects.toThrow("尚未保存成功"); + expect(submissions).toBe(0); + expect(await queue.enqueue(async () => "saved")).toBe("saved"); +}); + +test("merges the result with edits made while processing and advances the save revision", async () => { + globalThis.fetch = (async (url) => { + if (String(url).endsWith("/capabilities")) { + useCanvasStore.getState().moveNode("source", { x: 50, y: 80 }); + return Response.json({ success: true }); + } + return Response.json({ success: true, data: { + updatedAt: "r2", canvasData: { nodes: [node("source"), node("result")], edges: [] }, + } }); + }) as typeof fetch; + await executeCanvasToolbarCapability(command()); + const state = useCanvasStore.getState(); + expect(state.nodes.map(n => n.id)).toEqual(["source", "result"]); + expect(state.nodes[0].position).toEqual({ x: 50, y: 80 }); + expect(state.canvasRevision).toBe("r2"); +}); + +test("a provider failure is shown without reloading or changing the canvas", async () => { + globalThis.fetch = (async () => Response.json({ success: false, error: "去水印服务繁忙" }, { status: 503 })) as typeof fetch; + await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("去水印服务繁忙"); + expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source"]); + expect(useCanvasStore.getState().canvasRevision).toBe("r1"); +}); + +test("switching projects during processing does not load the old result into the new project", async () => { + globalThis.fetch = (async (url) => { + if (String(url).endsWith("/capabilities")) { + useCanvasStore.getState().setProject("project-b"); + useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] }); + return Response.json({ success: true }); + } + return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } }); + }) as typeof fetch; + await executeCanvasToolbarCapability(command()); + expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]); + expect(useCanvasStore.getState().canvasRevision).toBe("r1"); +});