Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion web_ui/src/app/canvas/SceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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<Map<string, { width: number; height: number }>>(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<EdgeSyncEntry[]>([]);
// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string>();
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<SceneFlowNode>[] = [];
// R7.5b-3: guides re-derive every drag frame. DELIBERATE deviation for
// multi-select drags (review-confirmed): legacy cleared guides
Expand Down Expand Up @@ -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<string, { x: number; y: number }>();
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;
Expand Down Expand Up @@ -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
Expand Down
182 changes: 182 additions & 0 deletions web_ui/src/app/canvas/drag/edgeSync.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
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<SVGPathElement>(
`.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<string, { x: number; y: number }>,
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);
}
}
Loading