refactor(canvas): extract drop handling hook
This commit is contained in:
201
components/canvas/__tests__/use-canvas-drop.test.tsx
Normal file
201
components/canvas/__tests__/use-canvas-drop.test.tsx
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,9 +24,7 @@ import {
|
|||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { toast } from "@/lib/toast";
|
|
||||||
import {
|
import {
|
||||||
CANVAS_NODE_DND_MIME,
|
|
||||||
type CanvasConnectionValidationReason,
|
type CanvasConnectionValidationReason,
|
||||||
} from "@/lib/canvas-connection-policy";
|
} from "@/lib/canvas-connection-policy";
|
||||||
import { showCanvasConnectionRejectedToast } from "@/lib/toast-messages";
|
import { showCanvasConnectionRejectedToast } from "@/lib/toast-messages";
|
||||||
@@ -35,12 +33,9 @@ import { api } from "@/convex/_generated/api";
|
|||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import {
|
import {
|
||||||
isAdjustmentPresetNodeType,
|
isAdjustmentPresetNodeType,
|
||||||
isCanvasNodeType,
|
|
||||||
type CanvasNodeType,
|
|
||||||
} from "@/lib/canvas-node-types";
|
} from "@/lib/canvas-node-types";
|
||||||
|
|
||||||
import { nodeTypes } from "./node-types";
|
import { nodeTypes } from "./node-types";
|
||||||
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
|
|
||||||
import CanvasToolbar, {
|
import CanvasToolbar, {
|
||||||
type CanvasNavTool,
|
type CanvasNavTool,
|
||||||
} from "@/components/canvas/canvas-toolbar";
|
} from "@/components/canvas/canvas-toolbar";
|
||||||
@@ -68,9 +63,9 @@ import {
|
|||||||
} from "./canvas-helpers";
|
} from "./canvas-helpers";
|
||||||
import { useGenerationFailureWarnings } from "./canvas-generation-failures";
|
import { useGenerationFailureWarnings } from "./canvas-generation-failures";
|
||||||
import { useCanvasDeleteHandlers } from "./canvas-delete-handlers";
|
import { useCanvasDeleteHandlers } from "./canvas-delete-handlers";
|
||||||
import { getImageDimensions } from "./canvas-media-utils";
|
|
||||||
import { useCanvasNodeInteractions } from "./use-canvas-node-interactions";
|
import { useCanvasNodeInteractions } from "./use-canvas-node-interactions";
|
||||||
import { useCanvasConnections } from "./use-canvas-connections";
|
import { useCanvasConnections } from "./use-canvas-connections";
|
||||||
|
import { useCanvasDrop } from "./use-canvas-drop";
|
||||||
import { useCanvasScissors } from "./canvas-scissors";
|
import { useCanvasScissors } from "./canvas-scissors";
|
||||||
import { CanvasSyncProvider } from "./canvas-sync-context";
|
import { CanvasSyncProvider } from "./canvas-sync-context";
|
||||||
import { useCanvasData } from "./use-canvas-data";
|
import { useCanvasData } from "./use-canvas-data";
|
||||||
@@ -371,158 +366,16 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
console.error("[ReactFlow error]", { canvasId, id, error });
|
console.error("[ReactFlow error]", { canvasId, id, error });
|
||||||
}, [canvasId]);
|
}, [canvasId]);
|
||||||
|
|
||||||
// ─── Future hook seam: drop flows ─────────────────────────────
|
const { onDragOver, onDrop } = useCanvasDrop({
|
||||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
canvasId,
|
||||||
event.preventDefault();
|
isSyncOnline,
|
||||||
const hasFiles = event.dataTransfer.types.includes("Files");
|
t,
|
||||||
event.dataTransfer.dropEffect = hasFiles ? "copy" : "move";
|
screenToFlowPosition,
|
||||||
}, []);
|
generateUploadUrl,
|
||||||
|
runCreateNodeOnlineOnly,
|
||||||
const onDrop = useCallback(
|
notifyOfflineUnsupported,
|
||||||
async (event: React.DragEvent) => {
|
syncPendingMoveForClientRequest,
|
||||||
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 canvasSyncContextValue = useMemo(
|
const canvasSyncContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
212
components/canvas/use-canvas-drop.ts
Normal file
212
components/canvas/use-canvas-drop.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
|||||||
"tests/**/*.test.ts",
|
"tests/**/*.test.ts",
|
||||||
"components/canvas/__tests__/canvas-flow-reconciliation-helpers.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-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-node-interactions.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user