diff --git a/components/canvas/__tests__/use-canvas-drop.test.tsx b/components/canvas/__tests__/use-canvas-drop.test.tsx index 7965139..1ebf676 100644 --- a/components/canvas/__tests__/use-canvas-drop.test.tsx +++ b/components/canvas/__tests__/use-canvas-drop.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Id } from "@/convex/_generated/dataModel"; import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy"; import { NODE_DEFAULTS } from "@/lib/canvas-utils"; +import { toast } from "@/lib/toast"; import { useCanvasDrop } from "@/components/canvas/use-canvas-drop"; vi.mock("@/lib/toast", () => ({ @@ -66,8 +67,10 @@ function HookHarness({ describe("useCanvasDrop", () => { let container: HTMLDivElement | null = null; let root: Root | null = null; + let consoleErrorSpy: ReturnType; beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ storageId: "storage-1" }), @@ -80,6 +83,7 @@ describe("useCanvasDrop", () => { afterEach(async () => { latestHandlersRef.current = null; vi.clearAllMocks(); + consoleErrorSpy.mockRestore(); vi.unstubAllGlobals(); if (root) { await act(async () => { @@ -198,4 +202,110 @@ describe("useCanvasDrop", () => { "node-image", ); }); + + it("creates a node from a JSON payload drop", async () => { + const runCreateNodeOnlineOnly = vi.fn(async () => "node-video"); + const syncPendingMoveForClientRequest = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + await latestHandlersRef.current?.onDrop({ + preventDefault: vi.fn(), + clientX: 90, + clientY: 75, + dataTransfer: { + getData: vi.fn((type: string) => + type === CANVAS_NODE_DND_MIME + ? JSON.stringify({ + type: "video", + data: { + assetId: "asset-42", + label: "Clip", + }, + }) + : "", + ), + files: [], + }, + } as unknown as React.DragEvent); + }); + + expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith({ + canvasId: "canvas-1", + type: "video", + positionX: 90, + positionY: 75, + width: NODE_DEFAULTS.video.width, + height: NODE_DEFAULTS.video.height, + data: { + ...NODE_DEFAULTS.video.data, + assetId: "asset-42", + label: "Clip", + canvasId: "canvas-1", + }, + clientRequestId: "req-1", + }); + expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-video"); + }); + + it("shows an upload failure toast when the dropped file upload fails", async () => { + const generateUploadUrl = vi.fn(async () => "https://upload.test"); + const runCreateNodeOnlineOnly = vi.fn(async () => "node-image"); + const syncPendingMoveForClientRequest = vi.fn(async () => undefined); + const file = new File(["image-bytes"], "photo.png", { type: "image/png" }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + json: async () => ({ storageId: "storage-1" }), + })), + ); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + await latestHandlersRef.current?.onDrop({ + preventDefault: vi.fn(), + clientX: 240, + clientY: 180, + dataTransfer: { + getData: vi.fn(() => ""), + files: [file], + }, + } as unknown as React.DragEvent); + }); + + expect(runCreateNodeOnlineOnly).not.toHaveBeenCalled(); + expect(syncPendingMoveForClientRequest).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Failed to upload dropped file:", + expect.any(Error), + ); + expect(toast.error).toHaveBeenCalledWith("canvas.uploadFailed", "Upload failed"); + }); }); diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index d2e457a..8f755ce 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -100,7 +100,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); const edgesRef = useRef(edges); - edgesRef.current = edges; const deletingNodeIds = useRef>(new Set()); const { @@ -148,7 +147,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Future hook seam: render composition + shared local flow state ───── const nodesRef = useRef(nodes); - nodesRef.current = nodes; const [scissorsMode, setScissorsMode] = useState(false); const [scissorStrokePreview, setScissorStrokePreview] = useState< @@ -237,7 +235,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, [scissorsMode, navTool]); const scissorsModeRef = useRef(scissorsMode); - scissorsModeRef.current = scissorsMode; + + useEffect(() => { + edgesRef.current = edges; + }, [edges]); + + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); + + useEffect(() => { + scissorsModeRef.current = scissorsMode; + }, [scissorsMode]); // Drag-Lock: während des Drags kein Convex-Override const isDragging = useRef(false); @@ -326,7 +335,15 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { useEffect(() => { if (isDragging.current) return; - setNodes((nds) => withResolvedCompareData(nds, edges)); + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) { + setNodes((nds) => withResolvedCompareData(nds, edges)); + } + }); + return () => { + cancelled = true; + }; }, [edges]); const {