From 1bf1fd4a1b1539b954860a56338f711d4e3878dd Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 3 Apr 2026 23:12:30 +0200 Subject: [PATCH] refactor(canvas): extract drop handling hook --- .../canvas/__tests__/use-canvas-drop.test.tsx | 201 +++++++++++++++++ components/canvas/canvas.tsx | 169 +------------- components/canvas/use-canvas-drop.ts | 212 ++++++++++++++++++ vitest.config.ts | 1 + 4 files changed, 425 insertions(+), 158 deletions(-) create mode 100644 components/canvas/__tests__/use-canvas-drop.test.tsx create mode 100644 components/canvas/use-canvas-drop.ts diff --git a/components/canvas/__tests__/use-canvas-drop.test.tsx b/components/canvas/__tests__/use-canvas-drop.test.tsx new file mode 100644 index 0000000..7965139 --- /dev/null +++ b/components/canvas/__tests__/use-canvas-drop.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment jsdom + +import React, { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +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 { useCanvasDrop } from "@/components/canvas/use-canvas-drop"; + +vi.mock("@/lib/toast", () => ({ + toast: { + error: vi.fn(), + warning: vi.fn(), + }, +})); + +vi.mock("@/components/canvas/canvas-media-utils", () => ({ + getImageDimensions: vi.fn(async () => ({ width: 1600, height: 900 })), +})); + +const latestHandlersRef: { + current: ReturnType | null; +} = { current: null }; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">; + +type HookHarnessProps = { + isSyncOnline?: boolean; + generateUploadUrl?: ReturnType; + runCreateNodeOnlineOnly?: ReturnType; + notifyOfflineUnsupported?: ReturnType; + syncPendingMoveForClientRequest?: ReturnType; + screenToFlowPosition?: (position: { x: number; y: number }) => { x: number; y: number }; +}; + +function HookHarness({ + isSyncOnline = true, + generateUploadUrl = vi.fn(async () => "https://upload.test"), + runCreateNodeOnlineOnly = vi.fn(async () => "node-1"), + notifyOfflineUnsupported = vi.fn(), + syncPendingMoveForClientRequest = vi.fn(async () => undefined), + screenToFlowPosition = (position) => position, +}: HookHarnessProps) { + const handlers = useCanvasDrop({ + canvasId: asCanvasId("canvas-1"), + isSyncOnline, + t: ((key: string) => key) as (key: string) => string, + screenToFlowPosition, + generateUploadUrl, + runCreateNodeOnlineOnly, + notifyOfflineUnsupported, + syncPendingMoveForClientRequest, + }); + + useEffect(() => { + latestHandlersRef.current = handlers; + }, [handlers]); + + return null; +} + +describe("useCanvasDrop", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + json: async () => ({ storageId: "storage-1" }), + }))); + vi.stubGlobal("crypto", { + randomUUID: vi.fn(() => "req-1"), + }); + }); + + afterEach(async () => { + latestHandlersRef.current = null; + vi.clearAllMocks(); + vi.unstubAllGlobals(); + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + }); + + it("creates a node from a raw sidebar node type drop", async () => { + const runCreateNodeOnlineOnly = vi.fn(async () => "node-1"); + 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: 120, + clientY: 340, + dataTransfer: { + getData: vi.fn((type: string) => + type === CANVAS_NODE_DND_MIME ? "image" : "", + ), + files: [], + }, + } as unknown as React.DragEvent); + }); + + expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith({ + canvasId: "canvas-1", + type: "image", + positionX: 120, + positionY: 340, + width: NODE_DEFAULTS.image.width, + height: NODE_DEFAULTS.image.height, + data: { + ...NODE_DEFAULTS.image.data, + canvasId: "canvas-1", + }, + clientRequestId: "req-1", + }); + expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-1"); + }); + + it("creates an image node from a dropped image file", 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" }); + + 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(generateUploadUrl).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith("https://upload.test", { + method: "POST", + headers: { "Content-Type": "image/png" }, + body: file, + }); + expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith({ + canvasId: "canvas-1", + type: "image", + positionX: 240, + positionY: 180, + width: NODE_DEFAULTS.image.width, + height: NODE_DEFAULTS.image.height, + data: { + storageId: "storage-1", + filename: "photo.png", + mimeType: "image/png", + width: 1600, + height: 900, + canvasId: "canvas-1", + }, + clientRequestId: "req-1", + }); + expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith( + "req-1", + "node-image", + ); + }); +}); diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index d874ff6..d2e457a 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -24,9 +24,7 @@ import { } from "@xyflow/react"; import { cn } from "@/lib/utils"; import "@xyflow/react/dist/style.css"; -import { toast } from "@/lib/toast"; import { - CANVAS_NODE_DND_MIME, type CanvasConnectionValidationReason, } from "@/lib/canvas-connection-policy"; import { showCanvasConnectionRejectedToast } from "@/lib/toast-messages"; @@ -35,12 +33,9 @@ import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { isAdjustmentPresetNodeType, - isCanvasNodeType, - type CanvasNodeType, } from "@/lib/canvas-node-types"; import { nodeTypes } from "./node-types"; -import { NODE_DEFAULTS } from "@/lib/canvas-utils"; import CanvasToolbar, { type CanvasNavTool, } from "@/components/canvas/canvas-toolbar"; @@ -68,9 +63,9 @@ import { } from "./canvas-helpers"; import { useGenerationFailureWarnings } from "./canvas-generation-failures"; import { useCanvasDeleteHandlers } from "./canvas-delete-handlers"; -import { getImageDimensions } from "./canvas-media-utils"; import { useCanvasNodeInteractions } from "./use-canvas-node-interactions"; import { useCanvasConnections } from "./use-canvas-connections"; +import { useCanvasDrop } from "./use-canvas-drop"; import { useCanvasScissors } from "./canvas-scissors"; import { CanvasSyncProvider } from "./canvas-sync-context"; import { useCanvasData } from "./use-canvas-data"; @@ -371,158 +366,16 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { console.error("[ReactFlow error]", { canvasId, id, error }); }, [canvasId]); - // ─── Future hook seam: drop flows ───────────────────────────── - const onDragOver = useCallback((event: React.DragEvent) => { - event.preventDefault(); - const hasFiles = event.dataTransfer.types.includes("Files"); - event.dataTransfer.dropEffect = hasFiles ? "copy" : "move"; - }, []); - - const onDrop = useCallback( - async (event: React.DragEvent) => { - event.preventDefault(); - - const rawData = event.dataTransfer.getData( - CANVAS_NODE_DND_MIME, - ); - if (!rawData) { - const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0; - if (hasFiles) { - if (!isSyncOnline) { - notifyOfflineUnsupported("Upload per Drag-and-drop"); - return; - } - const file = event.dataTransfer.files[0]; - if (file.type.startsWith("image/")) { - try { - let dimensions: { width: number; height: number } | undefined; - try { - dimensions = await getImageDimensions(file); - } catch { - dimensions = undefined; - } - - const uploadUrl = await generateUploadUrl(); - const result = await fetch(uploadUrl, { - method: "POST", - headers: { "Content-Type": file.type }, - body: file, - }); - - if (!result.ok) { - throw new Error("Upload failed"); - } - - const { storageId } = (await result.json()) as { storageId: string }; - - const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); - const clientRequestId = crypto.randomUUID(); - - void runCreateNodeOnlineOnly({ - canvasId, - type: "image", - positionX: position.x, - positionY: position.y, - width: NODE_DEFAULTS.image.width, - height: NODE_DEFAULTS.image.height, - data: { - storageId, - filename: file.name, - mimeType: file.type, - ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}), - canvasId, - }, - clientRequestId, - }).then((realId) => { - void syncPendingMoveForClientRequest( - clientRequestId, - realId, - ).catch((error: unknown) => { - console.error( - "[Canvas] drop createNode syncPendingMove failed", - error, - ); - }); - }); - } catch (err) { - console.error("Failed to upload dropped file:", err); - toast.error(t('canvas.uploadFailed'), err instanceof Error ? err.message : undefined); - } - return; - } - } - return; - } - - // Support both plain type string (sidebar) and JSON payload (browser panels) - let nodeType: CanvasNodeType | null = null; - let payloadData: Record | undefined; - - try { - const parsed = JSON.parse(rawData); - if ( - typeof parsed === "object" && - parsed !== null && - typeof (parsed as { type?: unknown }).type === "string" && - isCanvasNodeType((parsed as { type: string }).type) - ) { - nodeType = (parsed as { type: CanvasNodeType }).type; - payloadData = parsed.data; - } - } catch { - if (isCanvasNodeType(rawData)) { - nodeType = rawData; - } - } - - if (!nodeType) { - toast.warning("Node-Typ nicht verfuegbar", "Unbekannter Node konnte nicht erstellt werden."); - return; - } - - const position = screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }); - - const defaults = NODE_DEFAULTS[nodeType] ?? { - width: 200, - height: 100, - data: {}, - }; - - const clientRequestId = crypto.randomUUID(); - void runCreateNodeOnlineOnly({ - canvasId, - type: nodeType, - positionX: position.x, - positionY: position.y, - width: defaults.width, - height: defaults.height, - data: { ...defaults.data, ...payloadData, canvasId }, - clientRequestId, - }).then((realId) => { - void syncPendingMoveForClientRequest(clientRequestId, realId).catch( - (error: unknown) => { - console.error( - "[Canvas] createNode syncPendingMove failed", - error, - ); - }, - ); - }); - }, - [ - screenToFlowPosition, - t, - canvasId, - generateUploadUrl, - isSyncOnline, - runCreateNodeOnlineOnly, - notifyOfflineUnsupported, - syncPendingMoveForClientRequest, - ], - ); + const { onDragOver, onDrop } = useCanvasDrop({ + canvasId, + isSyncOnline, + t, + screenToFlowPosition, + generateUploadUrl, + runCreateNodeOnlineOnly, + notifyOfflineUnsupported, + syncPendingMoveForClientRequest, + }); const canvasSyncContextValue = useMemo( () => ({ diff --git a/components/canvas/use-canvas-drop.ts b/components/canvas/use-canvas-drop.ts new file mode 100644 index 0000000..248a3b7 --- /dev/null +++ b/components/canvas/use-canvas-drop.ts @@ -0,0 +1,212 @@ +import { useCallback } from "react"; + +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 { + isCanvasNodeType, + type CanvasNodeType, +} from "@/lib/canvas-node-types"; +import { toast } from "@/lib/toast"; + +import { getImageDimensions } from "./canvas-media-utils"; + +type UseCanvasDropParams = { + canvasId: Id<"canvases">; + isSyncOnline: boolean; + t: (key: string) => string; + screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number }; + generateUploadUrl: () => Promise; + runCreateNodeOnlineOnly: (args: { + canvasId: Id<"canvases">; + type: CanvasNodeType; + positionX: number; + positionY: number; + width: number; + height: number; + data: Record; + clientRequestId?: string; + }) => Promise>; + notifyOfflineUnsupported: (featureLabel: string) => void; + syncPendingMoveForClientRequest: ( + clientRequestId: string, + realId?: Id<"nodes">, + ) => Promise; +}; + +function parseCanvasDropPayload(rawData: string): { + nodeType: CanvasNodeType; + payloadData?: Record; +} | null { + try { + const parsed = JSON.parse(rawData); + if ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as { type?: unknown }).type === "string" && + isCanvasNodeType((parsed as { type: string }).type) + ) { + return { + nodeType: (parsed as { type: CanvasNodeType }).type, + payloadData: (parsed as { data?: Record }).data, + }; + } + } catch { + if (isCanvasNodeType(rawData)) { + return { nodeType: rawData }; + } + } + + return null; +} + +export function useCanvasDrop({ + canvasId, + isSyncOnline, + t, + screenToFlowPosition, + generateUploadUrl, + runCreateNodeOnlineOnly, + notifyOfflineUnsupported, + syncPendingMoveForClientRequest, +}: UseCanvasDropParams) { + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + const hasFiles = event.dataTransfer.types.includes("Files"); + event.dataTransfer.dropEffect = hasFiles ? "copy" : "move"; + }, []); + + const onDrop = useCallback( + async (event: React.DragEvent) => { + event.preventDefault(); + + const rawData = event.dataTransfer.getData(CANVAS_NODE_DND_MIME); + if (!rawData) { + const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0; + if (hasFiles) { + if (!isSyncOnline) { + notifyOfflineUnsupported("Upload per Drag-and-drop"); + return; + } + + const file = event.dataTransfer.files[0]; + if (file.type.startsWith("image/")) { + try { + let dimensions: { width: number; height: number } | undefined; + try { + dimensions = await getImageDimensions(file); + } catch { + dimensions = undefined; + } + + const uploadUrl = await generateUploadUrl(); + const result = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }); + + if (!result.ok) { + throw new Error("Upload failed"); + } + + const { storageId } = (await result.json()) as { storageId: string }; + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + const clientRequestId = crypto.randomUUID(); + + void runCreateNodeOnlineOnly({ + canvasId, + type: "image", + positionX: position.x, + positionY: position.y, + width: NODE_DEFAULTS.image.width, + height: NODE_DEFAULTS.image.height, + data: { + storageId, + filename: file.name, + mimeType: file.type, + ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}), + canvasId, + }, + clientRequestId, + }).then((realId) => { + void syncPendingMoveForClientRequest(clientRequestId, realId).catch( + (error: unknown) => { + console.error("[Canvas] drop createNode syncPendingMove failed", error); + }, + ); + }); + } catch (error) { + console.error("Failed to upload dropped file:", error); + toast.error( + t("canvas.uploadFailed"), + error instanceof Error ? error.message : undefined, + ); + } + } + + return; + } + + return; + } + + const parsedPayload = parseCanvasDropPayload(rawData); + if (!parsedPayload) { + toast.warning( + "Node-Typ nicht verfuegbar", + "Unbekannter Node konnte nicht erstellt werden.", + ); + return; + } + + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + const defaults = NODE_DEFAULTS[parsedPayload.nodeType] ?? { + width: 200, + height: 100, + data: {}, + }; + const clientRequestId = crypto.randomUUID(); + + void runCreateNodeOnlineOnly({ + canvasId, + type: parsedPayload.nodeType, + positionX: position.x, + positionY: position.y, + width: defaults.width, + height: defaults.height, + data: { ...defaults.data, ...parsedPayload.payloadData, canvasId }, + clientRequestId, + }).then((realId) => { + void syncPendingMoveForClientRequest(clientRequestId, realId).catch( + (error: unknown) => { + console.error("[Canvas] createNode syncPendingMove failed", error); + }, + ); + }); + }, + [ + canvasId, + generateUploadUrl, + isSyncOnline, + notifyOfflineUnsupported, + runCreateNodeOnlineOnly, + screenToFlowPosition, + syncPendingMoveForClientRequest, + t, + ], + ); + + return { + onDragOver, + onDrop, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 3600b04..a672d96 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "tests/**/*.test.ts", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", "components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts", + "components/canvas/__tests__/use-canvas-drop.test.tsx", "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine.test.ts", "components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",