diff --git a/components/canvas/canvas-command-palette.tsx b/components/canvas/canvas-command-palette.tsx index b9d7e0c..f36a915 100644 --- a/components/canvas/canvas-command-palette.tsx +++ b/components/canvas/canvas-command-palette.tsx @@ -67,7 +67,7 @@ export function CanvasCommandPalette() { return () => document.removeEventListener("keydown", onKeyDown); }, []); - const handleAddNode = async ( + const handleAddNode = ( type: CanvasNodeTemplate["type"], data: CanvasNodeTemplate["defaultData"], width: number, @@ -75,14 +75,17 @@ export function CanvasCommandPalette() { ) => { const offset = (nodeCountRef.current % 8) * 24; nodeCountRef.current += 1; - await createNodeWithIntersection({ + setOpen(false); + void createNodeWithIntersection({ type, position: { x: 100 + offset, y: 100 + offset }, width, height, data, + clientRequestId: crypto.randomUUID(), + }).catch((error) => { + console.error("[CanvasCommandPalette] createNode failed", error); }); - setOpen(false); }; return ( @@ -104,7 +107,7 @@ export function CanvasCommandPalette() { key={template.type} keywords={NODE_SEARCH_KEYWORDS[template.type] ?? []} onSelect={() => - void handleAddNode( + handleAddNode( template.type, template.defaultData, template.width, diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx index c1a6a12..239f576 100644 --- a/components/canvas/canvas-placement-context.tsx +++ b/components/canvas/canvas-placement-context.tsx @@ -28,6 +28,7 @@ type CreateNodeMutation = ReactMutation< data: unknown; parentId?: Id<"nodes">; zIndex?: number; + clientRequestId?: string; }, Id<"nodes"> > @@ -67,6 +68,8 @@ type CreateNodeWithIntersectionInput = { data?: Record; clientPosition?: FlowPoint; zIndex?: number; + /** Correlate optimistic node id with server id after create (see canvas move flush). */ + clientRequestId?: string; }; type CanvasPlacementContextValue = { @@ -132,6 +135,10 @@ interface CanvasPlacementProviderProps { canvasId: Id<"canvases">; createNode: CreateNodeMutation; createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation; + onCreateNodeSettled?: (payload: { + clientRequestId?: string; + realId: Id<"nodes">; + }) => void; children: ReactNode; } @@ -139,6 +146,7 @@ export function CanvasPlacementProvider({ canvasId, createNode, createNodeWithEdgeSplit, + onCreateNodeSettled, children, }: CanvasPlacementProviderProps) { const { flowToScreenPosition } = useReactFlow(); @@ -153,6 +161,7 @@ export function CanvasPlacementProvider({ data, clientPosition, zIndex, + clientRequestId, }: CreateNodeWithIntersectionInput) => { const defaults = NODE_DEFAULTS[type] ?? { width: 200, @@ -174,7 +183,7 @@ export function CanvasPlacementProvider({ hitEdgeFromClientPosition ?? getIntersectedPersistedEdge(centerClientPosition, edges); - const nodePayload = { + const baseNodePayload = { canvasId, type, positionX: position.x, @@ -189,24 +198,39 @@ export function CanvasPlacementProvider({ ...(zIndex !== undefined ? { zIndex } : {}), }; + const createNodePayload = { + ...baseNodePayload, + ...(clientRequestId !== undefined ? { clientRequestId } : {}), + }; + + const notifySettled = (realId: Id<"nodes">) => { + onCreateNodeSettled?.({ clientRequestId, realId }); + }; + if (!hitEdge) { - return await createNode(nodePayload); + const realId = await createNode(createNodePayload); + notifySettled(realId); + return realId; } const handles = NODE_HANDLE_MAP[type]; if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) { - return await createNode(nodePayload); + const realId = await createNode(createNodePayload); + notifySettled(realId); + return realId; } try { - return await createNodeWithEdgeSplit({ - ...nodePayload, + const realId = await createNodeWithEdgeSplit({ + ...baseNodePayload, splitEdgeId: hitEdge.id as Id<"edges">, newNodeTargetHandle: normalizeHandle(handles.target), newNodeSourceHandle: normalizeHandle(handles.source), splitSourceHandle: normalizeHandle(hitEdge.sourceHandle), splitTargetHandle: normalizeHandle(hitEdge.targetHandle), }); + notifySettled(realId); + return realId; } catch (error) { console.error("[Canvas placement] edge split failed", { edgeId: hitEdge.id, @@ -216,7 +240,14 @@ export function CanvasPlacementProvider({ throw error; } }, - [canvasId, createNode, createNodeWithEdgeSplit, edges, flowToScreenPosition], + [ + canvasId, + createNode, + createNodeWithEdgeSplit, + edges, + flowToScreenPosition, + onCreateNodeSettled, + ], ); const value = useMemo( diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx index 3c97a97..8e58838 100644 --- a/components/canvas/canvas-toolbar.tsx +++ b/components/canvas/canvas-toolbar.tsx @@ -34,6 +34,7 @@ export default function CanvasToolbar({ width, height, data, + clientRequestId: crypto.randomUUID(), }); }; diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 93f0782..771bc5f 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -46,6 +46,18 @@ interface CanvasInnerProps { canvasId: Id<"canvases">; } +const OPTIMISTIC_NODE_PREFIX = "optimistic_"; + +function isOptimisticNodeId(id: string): boolean { + return id.startsWith(OPTIMISTIC_NODE_PREFIX); +} + +function clientRequestIdFromOptimisticNodeId(id: string): string | null { + if (!isOptimisticNodeId(id)) return null; + const suffix = id.slice(OPTIMISTIC_NODE_PREFIX.length); + return suffix.length > 0 ? suffix : null; +} + function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] { const persistedEdges = edges.filter((edge) => edge.className !== "temp"); let hasNodeUpdates = false; @@ -353,6 +365,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const moveNode = useMutation(api.nodes.move); const resizeNode = useMutation(api.nodes.resize); const batchMoveNodes = useMutation(api.nodes.batchMove); + const pendingMoveAfterCreateRef = useRef( + new Map(), + ); + const resolvedRealIdByClientRequestRef = useRef(new Map>()); + + /** Pairing: create kann vor oder nach Drag-Ende fertig sein — was zuerst kommt, speichert; das andere triggert moveNode. */ + const syncPendingMoveForClientRequest = useCallback( + (clientRequestId: string | undefined, realId?: Id<"nodes">) => { + if (!clientRequestId) return; + + if (realId !== undefined) { + const pending = pendingMoveAfterCreateRef.current.get(clientRequestId); + if (pending) { + pendingMoveAfterCreateRef.current.delete(clientRequestId); + resolvedRealIdByClientRequestRef.current.delete(clientRequestId); + void moveNode({ + nodeId: realId, + positionX: pending.positionX, + positionY: pending.positionY, + }); + return; + } + resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); + return; + } + + const r = resolvedRealIdByClientRequestRef.current.get(clientRequestId); + const p = pendingMoveAfterCreateRef.current.get(clientRequestId); + if (!r || !p) return; + pendingMoveAfterCreateRef.current.delete(clientRequestId); + resolvedRealIdByClientRequestRef.current.delete(clientRequestId); + void moveNode({ + nodeId: r, + positionX: p.positionX, + positionY: p.positionY, + }); + }, + [moveNode], + ); + const createNode = useMutation(api.nodes.create).withOptimisticUpdate( (localStore, args) => { const current = localStore.getQuery(api.nodes.list, { @@ -360,8 +412,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); if (current === undefined) return; - const tempId = - `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 11)}` as Id<"nodes">; + const tempId = ( + args.clientRequestId + ? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}` + : `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` + ) as Id<"nodes">; const synthetic: Doc<"nodes"> = { _id: tempId, @@ -795,19 +850,41 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { try { // isDragging bleibt true bis alle Mutations resolved sind if (draggedNodes.length > 1) { - await batchMoveNodes({ - moves: draggedNodes.map((n) => ({ - nodeId: n.id as Id<"nodes">, - positionX: n.position.x, - positionY: n.position.y, - })), - }); + for (const n of draggedNodes) { + const cid = clientRequestIdFromOptimisticNodeId(n.id); + if (cid) { + pendingMoveAfterCreateRef.current.set(cid, { + positionX: n.position.x, + positionY: n.position.y, + }); + syncPendingMoveForClientRequest(cid); + } + } + const realMoves = draggedNodes.filter((n) => !isOptimisticNodeId(n.id)); + if (realMoves.length > 0) { + await batchMoveNodes({ + moves: realMoves.map((n) => ({ + nodeId: n.id as Id<"nodes">, + positionX: n.position.x, + positionY: n.position.y, + })), + }); + } } else { - await moveNode({ - nodeId: node.id as Id<"nodes">, - positionX: node.position.x, - positionY: node.position.y, - }); + const cid = clientRequestIdFromOptimisticNodeId(node.id); + if (cid) { + pendingMoveAfterCreateRef.current.set(cid, { + positionX: node.position.x, + positionY: node.position.y, + }); + syncPendingMoveForClientRequest(cid); + } else { + await moveNode({ + nodeId: node.id as Id<"nodes">, + positionX: node.position.x, + positionY: node.position.y, + }); + } } if (!intersectedEdgeId) { @@ -871,6 +948,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { moveNode, removeEdge, setHighlightedIntersectionEdge, + syncPendingMoveForClientRequest, ], ); @@ -1002,7 +1080,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { data: {}, }; - createNode({ + const clientRequestId = crypto.randomUUID(); + void createNode({ canvasId, type: nodeType, positionX: position.x, @@ -1010,9 +1089,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { width: defaults.width, height: defaults.height, data: { ...defaults.data, canvasId }, + clientRequestId, + }).then((realId) => { + syncPendingMoveForClientRequest(clientRequestId, realId); }); }, - [screenToFlowPosition, createNode, canvasId], + [screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest], ); // ─── Loading State ──────────────────────────────────────────── @@ -1032,6 +1114,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { canvasId={canvasId} createNode={createNode} createNodeWithEdgeSplit={createNodeWithEdgeSplit} + onCreateNodeSettled={({ clientRequestId, realId }) => + syncPendingMoveForClientRequest(clientRequestId, realId) + } >
diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx index 97a7a9d..769b617 100644 --- a/components/canvas/nodes/base-node-wrapper.tsx +++ b/components/canvas/nodes/base-node-wrapper.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react"; -import { Trash2, Copy, Loader2 } from "lucide-react"; +import { Trash2, Copy } from "lucide-react"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { NodeErrorBoundary } from "./node-error-boundary"; @@ -46,21 +46,16 @@ function NodeToolbarActions() { const nodeId = useNodeId(); const { deleteElements, getNode, getNodes, setNodes } = useReactFlow(); const { createNodeWithIntersection } = useCanvasPlacement(); - const [isDuplicating, setIsDuplicating] = useState(false); - const handleDelete = () => { if (!nodeId) return; void deleteElements({ nodes: [{ id: nodeId }] }); }; - const handleDuplicate = async () => { - if (!nodeId || isDuplicating) return; + const handleDuplicate = () => { + if (!nodeId) return; const node = getNode(nodeId); if (!node) return; - setIsDuplicating(true); - - try { // Strip internal/runtime fields, keep only user content const originalData = (node.data ?? {}) as Record; const cleanedData: Record = {}; @@ -81,7 +76,15 @@ function NodeToolbarActions() { 0, ); - const createdNodeId = await createNodeWithIntersection({ + // Deselect source node immediately for instant visual feedback + setNodes((nodes) => + nodes.map((n) => + n.id === nodeId ? { ...n, selected: false } : n, + ), + ); + + // Fire-and-forget: optimistic update makes the duplicate appear instantly + void createNodeWithIntersection({ type: node.type ?? "text", position: { x: originalPosition.x + 50, @@ -91,34 +94,8 @@ function NodeToolbarActions() { height, data: cleanedData, zIndex: maxZIndex + 1, + clientRequestId: crypto.randomUUID(), }); - - const selectCreatedNode = (attempt = 0) => { - const createdNode = getNode(createdNodeId); - if (!createdNode) { - if (attempt < 10) { - requestAnimationFrame(() => selectCreatedNode(attempt + 1)); - } - return; - } - - setNodes((nodes) => - nodes.map((n) => { - if (n.id === nodeId) { - return { ...n, selected: false }; - } - if (n.id === createdNodeId) { - return { ...n, selected: true }; - } - return n; - }), - ); - }; - - selectCreatedNode(); - } finally { - setIsDuplicating(false); - } }; const stopPropagation = (e: React.MouseEvent | React.PointerEvent) => { @@ -130,17 +107,12 @@ function NodeToolbarActions() {