From 2a645b9489392d8a6883ed15afd4a7b3352fff54 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 28 Mar 2026 23:07:27 +0100 Subject: [PATCH] feat: implement node deletion handling and geometry synchronization in canvas - Added functionality to block node deletion based on synchronization status with Convex, providing user feedback through toast notifications. - Introduced helper functions to determine reasons for blocking deletions, enhancing user experience during canvas interactions. - Updated asset node styling to improve visual consistency and adjusted minimum dimensions for asset nodes to ensure better layout management. --- components/canvas/canvas.tsx | 123 +++++++++++++++--- components/canvas/nodes/asset-node.tsx | 2 +- components/canvas/nodes/base-node-wrapper.tsx | 2 +- lib/toast-messages.ts | 56 ++++++++ 4 files changed, 165 insertions(+), 18 deletions(-) diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 63baac8..6acf7bd 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -20,6 +20,7 @@ import { applyEdgeChanges, useReactFlow, reconnectEdge, + getConnectedEdges, type Node as RFNode, type Edge as RFEdge, type NodeChange, @@ -32,7 +33,7 @@ import { import { cn } from "@/lib/utils"; import "@xyflow/react/dist/style.css"; import { toast } from "@/lib/toast"; -import { msg } from "@/lib/toast-messages"; +import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages"; import { useConvexAuth, useMutation, useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; @@ -97,6 +98,33 @@ function clientRequestIdFromOptimisticNodeId(id: string): string | null { return suffix.length > 0 ? suffix : null; } +function isNodeGeometrySyncedWithConvex( + node: RFNode, + doc: Doc<"nodes">, +): boolean { + const styleW = node.style?.width; + const styleH = node.style?.height; + const w = typeof styleW === "number" ? styleW : doc.width; + const h = typeof styleH === "number" ? styleH : doc.height; + return ( + node.position.x === doc.positionX && + node.position.y === doc.positionY && + w === doc.width && + h === doc.height + ); +} + +function getNodeDeleteBlockReason( + node: RFNode, + convexById: Map>, +): CanvasNodeDeleteBlockReason | null { + if (isOptimisticNodeId(node.id)) return "optimistic"; + const doc = convexById.get(node.id); + if (!doc) return "missingInConvex"; + if (!isNodeGeometrySyncedWithConvex(node, doc)) return "geometryPending"; + return null; +} + function getConnectEndClientPoint( event: MouseEvent | TouchEvent, ): { x: number; y: number } | null { @@ -1167,28 +1195,38 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { let constrainedHeight = change.dimensions.height; // Axis with larger delta drives resize; the other axis is ratio-locked. + // Chrome must be subtracted before ratio math, then re-added. + const assetChromeHeight = 88; + const assetMinPreviewHeight = 150; + const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight; + const assetMinNodeWidth = 200; + if (heightDelta > widthDelta) { - constrainedWidth = constrainedHeight * targetRatio; + const previewHeight = Math.max(1, constrainedHeight - assetChromeHeight); + constrainedWidth = previewHeight * targetRatio; + constrainedHeight = assetChromeHeight + previewHeight; } else { - constrainedHeight = constrainedWidth / targetRatio; + const previewHeight = constrainedWidth / targetRatio; + constrainedHeight = assetChromeHeight + previewHeight; } - const assetChromeHeight = 88; - const assetMinPreviewHeight = 120; - const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight; - const assetMinNodeWidth = 140; - - const minWidthFromHeight = assetMinNodeHeight * targetRatio; - const minimumAllowedWidth = Math.max(assetMinNodeWidth, minWidthFromHeight); - const minimumAllowedHeight = minimumAllowedWidth / targetRatio; - - const enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth); - const enforcedHeight = Math.max( - constrainedHeight, - minimumAllowedHeight, + const minWidthFromPreview = assetMinPreviewHeight * targetRatio; + const minimumAllowedWidth = Math.max(assetMinNodeWidth, minWidthFromPreview); + const minPreviewFromWidth = minimumAllowedWidth / targetRatio; + const minimumAllowedHeight = Math.max( assetMinNodeHeight, + assetChromeHeight + minPreviewFromWidth, ); + let enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth); + let enforcedHeight = assetChromeHeight + enforcedWidth / targetRatio; + if (enforcedHeight < minimumAllowedHeight) { + enforcedHeight = minimumAllowedHeight; + enforcedWidth = (enforcedHeight - assetChromeHeight) * targetRatio; + } + enforcedWidth = Math.max(enforcedWidth, minimumAllowedWidth); + enforcedHeight = assetChromeHeight + enforcedWidth / targetRatio; + return { ...change, dimensions: { @@ -1759,6 +1797,58 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ], ); + const onBeforeDelete = useCallback( + async ({ + nodes: matchingNodes, + edges: matchingEdges, + }: { + nodes: RFNode[]; + edges: RFEdge[]; + }) => { + if (matchingNodes.length === 0) { + return true; + } + + const convexById = new Map>( + (convexNodes ?? []).map((n) => [n._id as string, n]), + ); + + const allowed: RFNode[] = []; + const blocked: RFNode[] = []; + const blockedReasons = new Set(); + for (const node of matchingNodes) { + const reason = getNodeDeleteBlockReason(node, convexById); + if (reason !== null) { + blocked.push(node); + blockedReasons.add(reason); + } else { + allowed.push(node); + } + } + + if (allowed.length === 0) { + const { title, desc } = msg.canvas.nodeDeleteBlockedExplain(blockedReasons); + toast.warning(title, desc); + return false; + } + + if (blocked.length > 0) { + const { title, desc } = msg.canvas.nodeDeleteBlockedPartial( + blocked.length, + blockedReasons, + ); + toast.warning(title, desc); + return { + nodes: allowed, + edges: getConnectedEdges(allowed, matchingEdges), + }; + } + + return true; + }, + [convexNodes], + ); + // ─── Node löschen → Convex ──────────────────────────────────── const onNodesDelete = useCallback( (deletedNodes: RFNode[]) => { @@ -2095,6 +2185,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { onReconnect={onReconnect} onReconnectStart={onReconnectStart} onReconnectEnd={onReconnectEnd} + onBeforeDelete={onBeforeDelete} onNodesDelete={onNodesDelete} onEdgesDelete={onEdgesDelete} onEdgeClick={scissorsMode ? onEdgeClickScissors : undefined} diff --git a/components/canvas/nodes/asset-node.tsx b/components/canvas/nodes/asset-node.tsx index 14eda07..c2fbc57 100644 --- a/components/canvas/nodes/asset-node.tsx +++ b/components/canvas/nodes/asset-node.tsx @@ -199,7 +199,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro {data.title = { frame: { minWidth: 200, minHeight: 150 }, group: { minWidth: 150, minHeight: 100 }, image: { minWidth: 140, minHeight: 120, keepAspectRatio: true }, - asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false }, + asset: { minWidth: 200, minHeight: 240, keepAspectRatio: false }, video: { minWidth: 200, minHeight: 120, keepAspectRatio: true }, // Chrome 88 + min. Viewport 120 → äußere Mindesthöhe 208 (siehe canvas onNodesChange) "ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false }, diff --git a/lib/toast-messages.ts b/lib/toast-messages.ts index a5a817b..017e1eb 100644 --- a/lib/toast-messages.ts +++ b/lib/toast-messages.ts @@ -1,6 +1,46 @@ // Zentrales Dictionary für alle Toast-Strings. // Spätere i18n: diese Datei gegen Framework-Lookup ersetzen. +/** Grund, warum ein Node-Löschen bis zur Convex-Synchronisierung blockiert ist. */ +export type CanvasNodeDeleteBlockReason = + | "optimistic" + | "missingInConvex" + | "geometryPending"; + +function canvasNodeDeleteWhy( + reasons: Set, +): { title: string; desc: string } { + if (reasons.size === 0) { + return { + title: "Löschen momentan nicht möglich", + desc: "Bitte kurz warten und erneut versuchen.", + }; + } + if (reasons.size === 1) { + const only = [...reasons][0]!; + if (only === "optimistic") { + return { + title: "Element wird noch angelegt", + desc: "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.", + }; + } + if (only === "missingInConvex") { + return { + title: "Element noch nicht verfügbar", + desc: "Dieses Element ist in der Datenbank noch nicht sichtbar. Warte einen Moment und versuche das Löschen erneut.", + }; + } + return { + title: "Änderungen werden gespeichert", + desc: "Position oder Größe ist noch nicht mit dem Server abgeglichen — zum Beispiel direkt nach Verschieben oder nach dem Ziehen an der Größe. Bitte kurz warten.", + }; + } + return { + title: "Löschen momentan nicht möglich", + desc: "Mindestens ein Element wird noch angelegt, oder Position bzw. Größe wird noch gespeichert. Bitte kurz warten und erneut versuchen.", + }; +} + export const msg = { canvas: { imageUploaded: { title: "Bild hochgeladen" }, @@ -17,6 +57,22 @@ export const msg = { nodesRemoved: (count: number) => ({ title: count === 1 ? "Element entfernt" : `${count} Elemente entfernt`, }), + /** Warum gerade kein (vollständiges) Löschen möglich ist — aus den gesammelten Gründen der blockierten Nodes. */ + nodeDeleteBlockedExplain: canvasNodeDeleteWhy, + nodeDeleteBlockedPartial: ( + blockedCount: number, + reasons: Set, + ) => { + const why = canvasNodeDeleteWhy(reasons); + const suffix = + blockedCount === 1 + ? "Ein Element wurde deshalb nicht gelöscht; die übrige Auswahl wurde entfernt." + : `${blockedCount} Elemente wurden deshalb nicht gelöscht; die übrige Auswahl wurde entfernt.`; + return { + title: "Nicht alle Elemente entfernt", + desc: `${why.desc} ${suffix}`, + }; + }, }, ai: {