fix(canvas): cover drop regressions and lint

This commit is contained in:
2026-04-03 23:19:58 +02:00
parent 1bf1fd4a1b
commit 376291a193
2 changed files with 131 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy"; import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy";
import { NODE_DEFAULTS } from "@/lib/canvas-utils"; import { NODE_DEFAULTS } from "@/lib/canvas-utils";
import { toast } from "@/lib/toast";
import { useCanvasDrop } from "@/components/canvas/use-canvas-drop"; import { useCanvasDrop } from "@/components/canvas/use-canvas-drop";
vi.mock("@/lib/toast", () => ({ vi.mock("@/lib/toast", () => ({
@@ -66,8 +67,10 @@ function HookHarness({
describe("useCanvasDrop", () => { describe("useCanvasDrop", () => {
let container: HTMLDivElement | null = null; let container: HTMLDivElement | null = null;
let root: Root | null = null; let root: Root | null = null;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.stubGlobal("fetch", vi.fn(async () => ({ vi.stubGlobal("fetch", vi.fn(async () => ({
ok: true, ok: true,
json: async () => ({ storageId: "storage-1" }), json: async () => ({ storageId: "storage-1" }),
@@ -80,6 +83,7 @@ describe("useCanvasDrop", () => {
afterEach(async () => { afterEach(async () => {
latestHandlersRef.current = null; latestHandlersRef.current = null;
vi.clearAllMocks(); vi.clearAllMocks();
consoleErrorSpy.mockRestore();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
if (root) { if (root) {
await act(async () => { await act(async () => {
@@ -198,4 +202,110 @@ describe("useCanvasDrop", () => {
"node-image", "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(
<HookHarness
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
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(
<HookHarness
generateUploadUrl={generateUploadUrl}
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
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");
});
}); });

View File

@@ -100,7 +100,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const [nodes, setNodes] = useState<RFNode[]>([]); const [nodes, setNodes] = useState<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]); const [edges, setEdges] = useState<RFEdge[]>([]);
const edgesRef = useRef(edges); const edgesRef = useRef(edges);
edgesRef.current = edges;
const deletingNodeIds = useRef<Set<string>>(new Set()); const deletingNodeIds = useRef<Set<string>>(new Set());
const { const {
@@ -148,7 +147,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Future hook seam: render composition + shared local flow state ───── // ─── Future hook seam: render composition + shared local flow state ─────
const nodesRef = useRef<RFNode[]>(nodes); const nodesRef = useRef<RFNode[]>(nodes);
nodesRef.current = nodes;
const [scissorsMode, setScissorsMode] = useState(false); const [scissorsMode, setScissorsMode] = useState(false);
const [scissorStrokePreview, setScissorStrokePreview] = useState< const [scissorStrokePreview, setScissorStrokePreview] = useState<
@@ -237,7 +235,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}, [scissorsMode, navTool]); }, [scissorsMode, navTool]);
const scissorsModeRef = useRef(scissorsMode); 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 // Drag-Lock: während des Drags kein Convex-Override
const isDragging = useRef(false); const isDragging = useRef(false);
@@ -326,7 +335,15 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
useEffect(() => { useEffect(() => {
if (isDragging.current) return; if (isDragging.current) return;
setNodes((nds) => withResolvedCompareData(nds, edges)); let cancelled = false;
queueMicrotask(() => {
if (!cancelled) {
setNodes((nds) => withResolvedCompareData(nds, edges));
}
});
return () => {
cancelled = true;
};
}, [edges]); }, [edges]);
const { const {