diff --git a/components/canvas/canvas-delete-handlers.ts b/components/canvas/canvas-delete-handlers.ts index 00cdc25..5207fee 100644 --- a/components/canvas/canvas-delete-handlers.ts +++ b/components/canvas/canvas-delete-handlers.ts @@ -130,9 +130,8 @@ export function useCanvasDeleteHandlers({ ...edgePromises, ]) .then(() => { - for (const id of idsToDelete) { - deletingNodeIds.current.delete(id); - } + // Erfolg bedeutet hier nur: Mutation/Queue wurde angenommen. + // Den Delete-Lock erst lösen, wenn Convex-Snapshot die Node wirklich nicht mehr enthält. }) .catch((error: unknown) => { console.error("[Canvas] batch remove failed", error); diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index fcb4131..93093f8 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -230,6 +230,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const pendingEdgeSplitByClientRequestRef = useRef( new Map(), ); + const pendingDeleteAfterCreateClientRequestIdsRef = useRef(new Set()); /** Connection-Drop → neue Node: erlaubt Carry-over der Kante in der Rollback-Lücke (ohne Phantom nach Fehler). */ const pendingConnectionCreatesRef = useRef(new Set()); /** Nach create+drag: Convex liefert oft noch Erstellkoordinaten, bis `moveNode` committed — bis dahin Position pinnen. */ @@ -663,6 +664,22 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; const realNodeId = realId as string; + if ( + pendingDeleteAfterCreateClientRequestIdsRef.current.has(clientRequestId) + ) { + pendingDeleteAfterCreateClientRequestIdsRef.current.delete(clientRequestId); + removeOptimisticCreateLocally({ + clientRequestId, + removeNode: true, + removeEdge: true, + }); + deletingNodeIds.current.add(realNodeId); + await enqueueSyncMutationRef.current("batchRemoveNodes", { + nodeIds: [realId], + }); + return; + } + setNodes((current) => current.map((node) => { const nextParentId = @@ -712,7 +729,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); await remapCanvasSyncNodeId(canvasId as string, optimisticNodeId, realNodeId); remapCanvasOpNodeId(canvasId as string, optimisticNodeId, realNodeId); - }, [canvasId]); + }, [canvasId, removeOptimisticCreateLocally]); const runCreateNodeOnlineOnly = useCallback( async (args: Parameters[0]) => { @@ -966,6 +983,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { removeEdge: true, }); setEdgeSyncNonce((value) => value + 1); + } else if (op.type === "batchRemoveNodes") { + for (const nodeId of op.payload.nodeIds) { + deletingNodeIds.current.delete(nodeId as string); + } } await ackCanvasSyncOp(op.id); resolveCanvasOp(canvasId as string, op.id); @@ -1104,6 +1125,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { .filter((id): id is string => id !== null); if (createClientRequestIds.length > 0) { + if (isSyncOnline) { + for (const clientRequestId of createClientRequestIds) { + pendingDeleteAfterCreateClientRequestIdsRef.current.add( + clientRequestId, + ); + } + } + const droppedSync = await dropCanvasSyncOpsByClientRequestIds( canvasId as string, createClientRequestIds, @@ -1146,6 +1175,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { [ canvasId, enqueueSyncMutation, + isSyncOnline, refreshPendingSyncCount, removeOptimisticCreateLocally, ], @@ -1325,6 +1355,31 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { if (isOptimisticNodeId(realId as string)) { return; } + if ( + pendingDeleteAfterCreateClientRequestIdsRef.current.has(clientRequestId) + ) { + pendingDeleteAfterCreateClientRequestIdsRef.current.delete( + clientRequestId, + ); + pendingMoveAfterCreateRef.current.delete(clientRequestId); + pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); + pendingConnectionCreatesRef.current.delete(clientRequestId); + resolvedRealIdByClientRequestRef.current.delete(clientRequestId); + + const realNodeId = realId as string; + deletingNodeIds.current.add(realNodeId); + setNodes((current) => + current.filter((node) => node.id !== realNodeId), + ); + setEdges((current) => + current.filter( + (edge) => + edge.source !== realNodeId && edge.target !== realNodeId, + ), + ); + await runBatchRemoveNodesMutation({ nodeIds: [realId] }); + return; + } const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; setAssetBrowserTargetNodeId((current) => current === optimisticNodeId ? (realId as string) : current, @@ -1426,7 +1481,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); } }, - [canvasId, runMoveNodeMutation, runSplitEdgeAtExistingNodeMutation], + [ + canvasId, + runBatchRemoveNodesMutation, + runMoveNodeMutation, + runSplitEdgeAtExistingNodeMutation, + ], ); syncPendingMoveForClientRequestRef.current = syncPendingMoveForClientRequest; diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx index acca1c1..41b5fa1 100644 --- a/components/canvas/nodes/base-node-wrapper.tsx +++ b/components/canvas/nodes/base-node-wrapper.tsx @@ -1,7 +1,14 @@ "use client"; import type { ReactNode } from "react"; -import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react"; +import { + getConnectedEdges, + NodeResizeControl, + NodeToolbar, + Position, + useNodeId, + useReactFlow, +} from "@xyflow/react"; import { Trash2, Copy } from "lucide-react"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { NodeErrorBoundary } from "./node-error-boundary"; @@ -46,11 +53,32 @@ const INTERNAL_FIELDS = new Set([ function NodeToolbarActions() { const nodeId = useNodeId(); - const { deleteElements, getNode, getNodes, setNodes } = useReactFlow(); + const { deleteElements, getNode, getNodes, getEdges, setNodes } = useReactFlow(); const { createNodeWithIntersection } = useCanvasPlacement(); const handleDelete = () => { if (!nodeId) return; - void deleteElements({ nodes: [{ id: nodeId }] }); + const node = getNode(nodeId); + const resolvedNode = + node ?? + (() => { + const selectedNodes = getNodes().filter((candidate) => candidate.selected); + if (selectedNodes.length !== 1) return undefined; + return selectedNodes[0]; + })(); + const targetNodeId = resolvedNode?.id ?? nodeId; + + const connectedEdges = resolvedNode + ? getConnectedEdges([resolvedNode], getEdges()) + : []; + void deleteElements({ + nodes: [{ id: targetNodeId }], + edges: connectedEdges.map((edge) => ({ id: edge.id })), + }).catch((error: unknown) => { + console.error("[NodeToolbar] deleteElements failed", { + nodeId: targetNodeId, + error: String(error), + }); + }); }; const handleDuplicate = () => {