refactor(canvas): extract drop handling hook

This commit is contained in:
2026-04-03 23:12:30 +02:00
parent c8597169a1
commit 1bf1fd4a1b
4 changed files with 425 additions and 158 deletions

View File

@@ -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<typeof useCanvasDrop> | 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<typeof vi.fn>;
runCreateNodeOnlineOnly?: ReturnType<typeof vi.fn>;
notifyOfflineUnsupported?: ReturnType<typeof vi.fn>;
syncPendingMoveForClientRequest?: ReturnType<typeof vi.fn>;
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(
<HookHarness
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
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(
<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(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",
);
});
});

View File

@@ -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<string, unknown> | 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(
() => ({

View File

@@ -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<string>;
runCreateNodeOnlineOnly: (args: {
canvasId: Id<"canvases">;
type: CanvasNodeType;
positionX: number;
positionY: number;
width: number;
height: number;
data: Record<string, unknown>;
clientRequestId?: string;
}) => Promise<Id<"nodes">>;
notifyOfflineUnsupported: (featureLabel: string) => void;
syncPendingMoveForClientRequest: (
clientRequestId: string,
realId?: Id<"nodes">,
) => Promise<void>;
};
function parseCanvasDropPayload(rawData: string): {
nodeType: CanvasNodeType;
payloadData?: Record<string, unknown>;
} | 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<string, unknown> }).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,
};
}