From 75ed27b1c30bada0208975eab0e58f0308f8835a Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 28 Mar 2026 23:42:12 +0100 Subject: [PATCH] feat: enhance image handling and node deletion logic in canvas - Introduced a new function to determine acceptable geometry for node deletion, improving synchronization checks with Convex. - Added image dimension retrieval for uploaded files, enhancing the handling of image nodes during drag-and-drop operations. - Updated drag-and-drop functionality to support image uploads, including error handling and user feedback for failed uploads. - Refactored existing logic to ensure better management of optimistic node states and improve overall user experience on the canvas. --- components/canvas/canvas.tsx | 110 ++++++++++++++++++++++++- components/canvas/nodes/image-node.tsx | 5 ++ 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 6acf7bd..8b21212 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -114,6 +114,26 @@ function isNodeGeometrySyncedWithConvex( ); } +/** Für Delete-Guard: ausreichend sync, wenn Löschen in Convex sicher ist (kein laufendes Move/Resize). */ +function isNodeDeleteGeometryAcceptable( + node: RFNode, + doc: Doc<"nodes">, +): boolean { + if (isNodeGeometrySyncedWithConvex(node, doc)) return true; + const posEq = + node.position.x === doc.positionX && + node.position.y === doc.positionY; + if (!posEq) return false; + const isMedia = + node.type === "asset" || + node.type === "image" || + node.type === "ai-image"; + // mergeNodesPreservingLocalState: ausgewählte Media-Nodes behalten oft Platzhalter-Maße in style, + // während Convex bereits echte Breite/Höhe hat — Position ist mit dem Server abgeglichen, Löschen ist ok. + if (isMedia && Boolean(node.selected)) return true; + return false; +} + function getNodeDeleteBlockReason( node: RFNode, convexById: Map>, @@ -121,7 +141,7 @@ function getNodeDeleteBlockReason( if (isOptimisticNodeId(node.id)) return "optimistic"; const doc = convexById.get(node.id); if (!doc) return "missingInConvex"; - if (!isNodeGeometrySyncedWithConvex(node, doc)) return "geometryPending"; + if (!isNodeDeleteGeometryAcceptable(node, doc)) return "geometryPending"; return null; } @@ -515,6 +535,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ── const moveNode = useMutation(api.nodes.move); const resizeNode = useMutation(api.nodes.resize); + const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const batchMoveNodes = useMutation(api.nodes.batchMove); const pendingMoveAfterCreateRef = useRef( new Map(), @@ -1935,19 +1956,100 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { [removeEdge], ); + async function getImageDimensions(file: File): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const objectUrl = URL.createObjectURL(file); + const image = new window.Image(); + + image.onload = () => { + const width = image.naturalWidth; + const height = image.naturalHeight; + URL.revokeObjectURL(objectUrl); + + if (!width || !height) { + reject(new Error("Could not read image dimensions")); + return; + } + + resolve({ width, height }); + }; + + image.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error("Could not decode image")); + }; + + image.src = objectUrl; + }); + } + const onDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); - event.dataTransfer.dropEffect = "move"; + const hasFiles = event.dataTransfer.types.includes("Files"); + event.dataTransfer.dropEffect = hasFiles ? "copy" : "move"; }, []); const onDrop = useCallback( - (event: React.DragEvent) => { + async (event: React.DragEvent) => { event.preventDefault(); const rawData = event.dataTransfer.getData( "application/lemonspace-node-type", ); if (!rawData) { + const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0; + if (hasFiles) { + 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 createNode({ + 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) => { + syncPendingMoveForClientRequest(clientRequestId, realId); + }); + } catch (err) { + console.error("Failed to upload dropped file:", err); + toast.error(msg.canvas.uploadFailed.title, err instanceof Error ? err.message : undefined); + } + return; + } + } return; } @@ -1992,7 +2094,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { syncPendingMoveForClientRequest(clientRequestId, realId); }); }, - [screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest], + [screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest, generateUploadUrl], ); // ─── Scherenmodus (K) — Kante klicken oder mit Maus durchschneiden ─ diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx index 512820e..7266a55 100644 --- a/components/canvas/nodes/image-node.tsx +++ b/components/canvas/nodes/image-node.tsx @@ -23,6 +23,7 @@ const ALLOWED_IMAGE_TYPES = new Set([ "image/webp", ]); const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const OPTIMISTIC_NODE_PREFIX = "optimistic_"; type ImageNodeData = { storageId?: string; @@ -80,6 +81,10 @@ export default function ImageNode({ const hasAutoSizedRef = useRef(false); useEffect(() => { + if (typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX)) { + return; + } + if (typeof data.width !== "number" || typeof data.height !== "number") { return; }