From 60f45011df43d49923576d45f66648030910231c Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Fri, 14 Aug 2026 15:30:46 -0400 Subject: [PATCH] Write connection paths during a drag in the same event that moves the node Reported symptom, described precisely by the reporter: the node itself tracks the pointer correctly, but the connection's pixels play catch-up. A gap opens between the node's connection dot and the end of its line, the gap grows with drag speed and points opposite the direction of travel, and it closes as soon as movement stops. That is a repaint arriving late, not wrong geometry - the shape the app eventually renders is correct, it just reaches the screen a frame after the node does. The cause is that the two are not equally cheap to put on screen. A node card's position is a transform on a small element. A connection is a path inside a canvas-sized drawing surface whose shape must be recomputed and re-rasterized. Nothing forces those to land in the same frame, so under movement the card arrives at its new position while the line is still drawn for the previous one. Working node editors avoid this by not waiting for a render pass at all during a drag: Drawflow's pointer handler writes the node's position and then immediately writes the path data of every affected connection, so both are in the DOM before the browser paints. This change does the same here. Once a gesture starts, the connections it will move are resolved once, and on each frame - after the corrected position is known - their path data is written directly, inside the same pointer event. This does not replace React Flow's rendering. It renders the same edges from its own state immediately afterwards and computes the identical shape, using the same handle-anchoring rules this module reproduces. The synchronous write only ensures the correct shape is already in the DOM for the frame currently being painted. Anything unresolvable - a missing path element, unmeasured handles, an unknown edge type - is skipped and left entirely to React Flow, and the plan is rebuilt every gesture so it can never describe a stale graph. Co-Authored-By: Claude Opus 5 --- web_ui/src/app/canvas/SceneCanvas.tsx | 48 ++++++- web_ui/src/app/canvas/drag/edgeSync.ts | 182 +++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 web_ui/src/app/canvas/drag/edgeSync.ts diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index 0e97ffb..31fae9c 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -47,6 +47,7 @@ import { import { handleKeyboardContextMenu } from "./keyboardContextMenu"; import { SceneStore, scaleDragPosition } from "./sceneStore"; import { computeSmartGuideSnap, type GuideLine, type Rect } from "./smartGuides"; +import { buildEdgeSyncPlan, syncEdgePaths, type EdgeSyncEntry } from "./drag/edgeSync"; import { useLodVisibility } from "./useLodVisibility"; /** @@ -2279,6 +2280,9 @@ function CanvasInner({ // plain ref (not state) since a rebuild must never itself trigger a // re-render - it only matters to onNodesChange's own closure. const dragSizeCacheRef = useRef>(new Map()); + // The connections this gesture must keep in step, resolved once when it + // starts - see drag/edgeSync.ts for why they are written synchronously. + const edgeSyncPlanRef = useRef([]); // ADR-011 stage 11.1: ONE ToFlowNodesCache for this canvas's whole // lifetime, threaded into every toFlowNodes call below - this is what // actually makes the per-node dispatcher/whole-flow-node memoization in @@ -2466,6 +2470,13 @@ function CanvasInner({ // off, hoveredEdgeId whenever it's on. const edgeHoverKey = scene.fadeConnectionsEnabled ? hoveredEdgeId : null; const edges = useMemo(() => toFlowEdges(scene, edgeHoverKey), [scene, edgeHoverKey]); + // Mirror of the rendered edges, read by the drag pipeline when it resolves + // which connections a gesture must keep in step. A ref rather than a + // dependency so the middleware is not re-registered whenever edges change. + const edgesRef = useRef(edges); + useEffect(() => { + edgesRef.current = edges; + }, [edges]); // R8a: the minimap used to render every node as React Flow's own default // plain rectangle (no nodeColor/nodeStrokeColor was ever passed), which @@ -2547,9 +2558,27 @@ function CanvasInner({ // nothing was already dragging coming into this batch. A multi-select // drag's first frame reports several dragging changes in ONE batch and // still rebuilds once, not once per change. - if (scene.smartGuides && !draggingRef.current && changes.some((c) => c.type === "position" && c.dragging)) { + const startingGesture = !draggingRef.current && changes.some((c) => c.type === "position" && c.dragging); + if (startingGesture && scene.smartGuides) { dragSizeCacheRef.current = buildDragSizeCache(reactFlow, currentNodes); } + if (startingGesture) { + // Every node this gesture will move: the dragged nodes plus, for a + // group, everything it carries - so an edge attached to a carried + // member is redrawn too, not just the ones touching the group node. + const movingIds = new Set(); + for (const c of changes) { + if (c.type !== "position" || !c.dragging) continue; + movingIds.add(c.id); + const node = currentNodes.find((n) => n.id === c.id); + if (node && groupDragKindOf(node)) { + for (const memberId of collectTransitiveMemberIds(currentNodes, node)) movingIds.add(memberId); + } + } + edgeSyncPlanRef.current = buildEdgeSyncPlan(edgesRef.current, movingIds, (id) => + storeApi.getState().nodeLookup.get(id), + ); + } const memberChanges: NodeChange[] = []; // R7.5b-3: guides re-derive every drag frame. DELIBERATE deviation for // multi-select drags (review-confirmed): legacy cleared guides @@ -2610,6 +2639,21 @@ function CanvasInner({ if (!sawGestureFrame) return changes; pendingGuidesRef.current = frameGuides; + + // Write the affected connection paths NOW, inside the pointer event + // that produced these positions, so the line and the card it is + // attached to reach the screen in the same frame. React Flow renders + // the same edges from its own state immediately afterwards and + // computes the identical shape; this write only ensures the correct + // shape is already in the DOM for the frame being painted. See + // drag/edgeSync.ts for the full reasoning. + if (edgeSyncPlanRef.current.length > 0) { + const movedPositions = new Map(); + for (const c of [...corrected, ...memberChanges]) { + if (c.type === "position" && c.position) movedPositions.set(c.id, c.position); + } + syncEdgePaths(edgeSyncPlanRef.current, movedPositions, (id) => storeApi.getState().nodeLookup.get(id)); + } // Group members ride in the SAME batch as the node that carries them, // so React Flow commits the group and its members together. return memberChanges.length > 0 ? [...corrected, ...memberChanges] : corrected; @@ -2692,6 +2736,8 @@ function CanvasInner({ setSmartGuideLines((current) => (current.length === 0 && frameGuides.length === 0 ? current : frameGuides)); } else if (sawDragEnd) { pendingGuidesRef.current = []; + // Gesture over: React Flow owns the edges again until the next one. + edgeSyncPlanRef.current = []; setSmartGuideLines((current) => (current.length === 0 ? current : [])); } // Suspend off-viewport culling while a drag is in flight - see diff --git a/web_ui/src/app/canvas/drag/edgeSync.ts b/web_ui/src/app/canvas/drag/edgeSync.ts new file mode 100644 index 0000000..7a58952 --- /dev/null +++ b/web_ui/src/app/canvas/drag/edgeSync.ts @@ -0,0 +1,182 @@ +/** + * Synchronous connection-path updates during a drag. + * + * THE PROBLEM THIS SOLVES. A node card's position is a CSS transform on a + * small div; a connection is a path inside a canvas-sized SVG whose shape + * has to be recomputed and re-rasterized. Those two are not equally cheap + * and they do not have to land in the same frame. When they don't, the card + * arrives on screen at the new position while the line is still drawn for + * the old one, and a gap opens between the card's connection dot and the + * end of its line. The gap grows with pointer speed, points opposite the + * direction of travel, and closes the moment movement stops - because the + * line simply catches up once nothing is moving any more. Nothing about the + * app's state is wrong while this happens: the geometry React eventually + * renders is correct, it just reaches the screen a frame late. + * + * THE APPROACH. This is the technique working node editors use. Drawflow, + * for instance, does not wait for a framework to re-render during a drag: + * its pointer handler writes the node's position and then immediately + * writes the `d` attribute of every affected connection, so both are in the + * DOM before the browser paints that frame. This module does the same thing + * for this canvas: on each drag frame, after the corrected node position is + * known, it writes the affected paths directly, inside the same pointer + * event. React Flow still renders those same edges from its own state a + * moment later and computes the identical `d` - this write is not a + * replacement for that, it just gets the correct value into the DOM in time + * for the current frame instead of the next one. + * + * WHY IT IS SAFE. Every value written here is computed the way React Flow + * computes it (see handlePoint below, which mirrors the library's own + * handle-anchoring rules), so the imperative write and the subsequent + * render agree. If anything here cannot resolve - an edge with no rendered + * path element, a node whose handles have not been measured yet, an edge + * type this module does not know how to draw - that edge is skipped and + * left entirely to React Flow, which is the pre-existing behaviour. The + * plan is rebuilt at the start of every gesture, so it can never describe + * a stale graph. + */ + +import { Position, getBezierPath } from "@xyflow/react"; + +/** The handle geometry React Flow measured for one node, in flow units. */ +interface HandleBound { + id?: string | null; + x: number; + y: number; + width: number; + height: number; + position: Position; +} + +interface InternalNodeLike { + internals: { + positionAbsolute: { x: number; y: number }; + handleBounds?: { source?: HandleBound[] | null; target?: HandleBound[] | null } | null; + }; +} + +/** One edge this gesture must keep in step, resolved once at gesture start. */ +export interface EdgeSyncEntry { + path: SVGPathElement; + sourceId: string; + targetId: string; + source: HandleBound; + target: HandleBound; + /** Orthogonal edges use a right-angle path; see OrthogonalEdge.tsx. */ + orthogonal: boolean; +} + +/** + * Where a connection meets a handle, in flow coordinates. + * + * Mirrors @xyflow/system's own getHandlePosition: the anchor is centred on + * the handle across the edge it sits on, and sits at the handle's OUTER rim + * along the direction it faces - not at the handle's centre. Reproducing + * that exactly is what keeps this module's write identical to the value + * React Flow renders a moment later. + */ +function handlePoint(node: InternalNodeLike, handle: HandleBound): { x: number; y: number } { + const x = handle.x + node.internals.positionAbsolute.x; + const y = handle.y + node.internals.positionAbsolute.y; + const { width, height } = handle; + switch (handle.position) { + case Position.Top: + return { x: x + width / 2, y }; + case Position.Right: + return { x: x + width, y: y + height / 2 }; + case Position.Bottom: + return { x: x + width / 2, y: y + height }; + case Position.Left: + default: + return { x, y: y + height / 2 }; + } +} + +/** + * Resolve every edge touching `movingIds` to the DOM path and handle + * geometry needed to redraw it, once, at the start of a gesture. + * + * Returns an empty array when nothing is resolvable, in which case the + * caller simply does no synchronous work and React Flow's own rendering is + * the only thing driving the edges - exactly as before this module existed. + */ +export function buildEdgeSyncPlan( + edges: Array<{ id: string; source: string; target: string; type?: string }>, + movingIds: ReadonlySet, + getInternalNode: (id: string) => InternalNodeLike | undefined, + container: ParentNode = document, +): EdgeSyncEntry[] { + const plan: EdgeSyncEntry[] = []; + for (const edge of edges) { + if (!movingIds.has(edge.source) && !movingIds.has(edge.target)) continue; + const sourceNode = getInternalNode(edge.source); + const targetNode = getInternalNode(edge.target); + const source = sourceNode?.internals.handleBounds?.source?.[0]; + const target = targetNode?.internals.handleBounds?.target?.[0]; + if (!source || !target) continue; + const path = container.querySelector( + `.react-flow__edge[data-id="${CSS.escape(edge.id)}"] .react-flow__edge-path`, + ); + if (!path) continue; + plan.push({ + path, + sourceId: edge.source, + targetId: edge.target, + source, + target, + orthogonal: edge.type === "orthogonal", + }); + } + return plan; +} + +/** + * Redraw every planned edge from the positions this frame produced. + * + * `positions` supplies the nodes that moved this frame; anything absent + * falls back to the position React Flow currently holds, so an edge with + * only one moving end is drawn correctly without the caller having to + * enumerate the stationary side. + */ +export function syncEdgePaths( + plan: readonly EdgeSyncEntry[], + positions: ReadonlyMap, + getInternalNode: (id: string) => InternalNodeLike | undefined, +): void { + for (const entry of plan) { + const sourceNode = getInternalNode(entry.sourceId); + const targetNode = getInternalNode(entry.targetId); + if (!sourceNode || !targetNode) continue; + + const movedSource = positions.get(entry.sourceId); + const movedTarget = positions.get(entry.targetId); + // Draw from this frame's corrected position where the node moved, and + // from React Flow's own current value where it did not. + const sourceOrigin = movedSource + ? { internals: { ...sourceNode.internals, positionAbsolute: movedSource } } + : sourceNode; + const targetOrigin = movedTarget + ? { internals: { ...targetNode.internals, positionAbsolute: movedTarget } } + : targetNode; + + const s = handlePoint(sourceOrigin, entry.source); + const t = handlePoint(targetOrigin, entry.target); + + let d: string; + if (entry.orthogonal) { + // The right-angle shape OrthogonalEdge.tsx draws, reproduced exactly. + const midY = s.y + (t.y - s.y) / 2; + d = `M ${s.x},${s.y} L ${s.x},${midY} L ${t.x},${midY} L ${t.x},${t.y}`; + } else { + [d] = getBezierPath({ + sourceX: s.x, + sourceY: s.y, + sourcePosition: entry.source.position, + targetX: t.x, + targetY: t.y, + targetPosition: entry.target.position, + }); + } + entry.path.setAttribute("d", d); + } +}