diff --git a/backend/canvas.py b/backend/canvas.py index 9b7b52e..3456ffe 100644 --- a/backend/canvas.py +++ b/backend/canvas.py @@ -702,6 +702,12 @@ class SceneDocument: # legacy scene's orthogonal_routing bool, same bare-bool/"scene"-topic # shape as fade_connections_enabled above. orthogonal_routing: bool = False + # R7.5b-3: Qt-removal plan R7.5's third and final canvas-visual parity + # fix - the legacy scene's smart_guides bool, same bare-bool/"scene"-topic + # shape as the two toggles above. The snap math itself is 100% client-side + # (web_ui/src/app/canvas/smartGuides.ts), matching the legacy split where + # ChatScene owned only the flag and the geometry ran in the view layer. + smart_guides: bool = False drag_factor: float = 1.0 # Canvas font (ChatScene's setFontFamily/-Size/-Color state, R2): defaults # match the legacy scene's own construction-time values. @@ -2800,6 +2806,7 @@ def scene_payload(self) -> dict[str, Any]: "snapToGrid": self.snap_to_grid, "fadeConnectionsEnabled": self.fade_connections_enabled, "orthogonalRouting": self.orthogonal_routing, + "smartGuides": self.smart_guides, "dragFactor": self.drag_factor, "fontFamily": self.font_family, "fontSizePt": self.font_size_pt, @@ -4001,6 +4008,10 @@ async def set_orthogonal_routing(enabled): document.orthogonal_routing = bool(enabled) await publish_scene() + async def set_smart_guides(enabled): + document.smart_guides = bool(enabled) + await publish_scene() + async def set_drag_factor(factor): document.set_drag_factor(factor) await publish_scene() @@ -4067,6 +4078,7 @@ async def set_view_state(zoom_factor, scroll_x, scroll_y): # setSnapToGrid/setFadeConnections above - the Python function name above # doesn't need to match. bus.register_intent("scene", "setOrthogonalConnections", set_orthogonal_routing) + bus.register_intent("scene", "setSmartGuides", set_smart_guides) bus.register_intent("scene", "setDragFactor", set_drag_factor) bus.register_intent("scene", "setViewState", set_view_state) # R4.3: per-node cancel for a ConversationNode's in-flight reply. Reuses diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 4e6233c..5000202 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -2186,6 +2186,24 @@ async def run(): asyncio.run(run()) +def test_smart_guides_defaults_false_and_the_setter_publishes_scene(): + # R7.5b-3: the third and final canvas-visual parity fix - the snap math + # itself is frontend-only (smartGuides.ts); the backend owns just the + # toggle, matching legacy's ChatScene-owns-the-flag split. + async def run(): + bus, document, recorder = make_bus() + assert document.scene_payload()["smartGuides"] is False + + await bus.dispatch_intent("scene", "setSmartGuides", [True]) + assert document.scene_payload()["smartGuides"] is True + assert recorder.topics_seen()[-1] == "scene" + + await bus.dispatch_intent("scene", "setSmartGuides", [False]) + assert document.scene_payload()["smartGuides"] is False + + asyncio.run(run()) + + # -- R4.4a: Generate/Regenerate Image - domain-level resolvers --------------- diff --git a/graphlink_app/graphlink_scene_payload.py b/graphlink_app/graphlink_scene_payload.py index 102718f..6cbc2b7 100644 --- a/graphlink_app/graphlink_scene_payload.py +++ b/graphlink_app/graphlink_scene_payload.py @@ -327,6 +327,9 @@ class SceneStatePayload: # R7.5b-2: Qt-removal plan R7.5's second canvas-visual parity fix - # populated 1:1 from backend/canvas.py's SceneDocument.orthogonal_routing. orthogonalRouting: bool + # R7.5b-3: the third and final canvas-visual parity fix - populated 1:1 + # from backend/canvas.py's SceneDocument.smart_guides. + smartGuides: bool dragFactor: float fontFamily: str fontSizePt: int diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index 0cf29bb..d808e39 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -5,6 +5,7 @@ import { MiniMap, Position, ReactFlow, + ViewportPortal, useReactFlow, useStore, type Connection, @@ -42,6 +43,7 @@ import { VIEWPORT_REPORT_DEBOUNCE_MS, } from "./canvasConstants"; import { SceneStore, scaleDragPosition } from "./sceneStore"; +import { computeSmartGuideSnap, type GuideLine, type Rect } from "./smartGuides"; // R6.1: Notes/Frames/Containers - the generated SceneNodeRow type (codegen // source: backend/canvas.py's scene_payload()) has not been regenerated yet @@ -839,6 +841,20 @@ export function isOrthogonalEligible(sourceKind: string | undefined, targetKind: return ORTHO_ELIGIBLE_TARGET_KINDS.has(targetKind); } +// R7.5b-3: one node's current rendered size in flow units, for smart-guide +// rects - see the comment on CanvasInner's reactFlow hoist for why the +// internal store alone is not enough and the DOM is the reliable fallback. +type MeasuredSizeSource = { getInternalNode: (id: string) => { measured?: { width?: number; height?: number } } | undefined }; +function measuredNodeSize(reactFlow: MeasuredSizeSource, id: string): { width: number; height: number } | null { + const measured = reactFlow.getInternalNode(id)?.measured; + if (measured?.width !== undefined && measured?.height !== undefined) { + return { width: measured.width, height: measured.height }; + } + const el = document.querySelector(`.react-flow__node[data-id="${CSS.escape(id)}"]`); + if (!(el instanceof HTMLElement) || el.offsetWidth === 0) return null; + return { width: el.offsetWidth, height: el.offsetHeight }; +} + // Exported standalone for direct unit testing, same posture as toFlowNodes // above - R7.5b-1's fade-connections opacity logic doesn't need a mounted // to verify. @@ -887,6 +903,19 @@ export function makeDebouncedViewportReport( function CanvasInner({ store }: { store: SceneStore }) { const scene = useSyncExternalStore(store.subscribe, store.getScene); const grid = useSyncExternalStore(store.subscribe, store.getGrid); + // Hoisted above onNodesChange: smart guides (R7.5b-3) need node + // dimensions. Neither the local `nodes` array NOR React Flow's internal + // store reliably has them here: toFlowNodes rebuilds the array from every + // scene snapshot with fresh objects (no `measured`), and RF's own + // adoptUserNodes then rebuilds its internal `measured` FROM those user + // objects (verified against the installed @xyflow/system source, and + // confirmed live: `getInternalNode(...).measured` was {} at drag time), so + // both copies get wiped on every publish. measuredNodeSize below prefers + // the internal store when populated and falls back to the live DOM + // element's layout size - offsetWidth/Height are pre-transform layout px + // (flow units at any zoom), and reading the live rendered rect is also the + // most faithful translation of legacy's own sceneBoundingRect() reads. + const reactFlow = useReactFlow(); // Local node state exists so dragging is fluid; backend snapshots are the // truth and reconcile in whenever nothing is being dragged. dragStartRef @@ -901,6 +930,14 @@ function CanvasInner({ store }: { store: SceneStore }) { // flag. const [hoveredEdgeId, setHoveredEdgeId] = useState(null); + // R7.5b-3: the smart-guide lines currently visible during a drag - local + // component state only, never scene state, matching legacy's non-persisted + // ChatScene.smart_guide_lines. Set per drag frame in onNodesChange, cleared + // on drag end; the render-time gate below (not an effect) hides any stale + // lines instantly if the toggle flips off mid-drag. + const [smartGuideLines, setSmartGuideLines] = useState([]); + const visibleGuideLines = scene.smartGuides ? smartGuideLines : []; + // R6.3: viewport (pan/zoom) reporting - see makeDebouncedViewportReport's // own doc above. onMove fires on every frame of a pan/zoom gesture (never // just once at the end, unlike NodeResizer's onResizeEnd), so the debounce @@ -937,27 +974,78 @@ function CanvasInner({ store }: { store: SceneStore }) { // drag frame. const memberChanges: NodeChange[] = []; const memberMoveIntents: Array<{ id: string; x: number; y: number }> = []; + // R7.5b-3: guides produced by this frame's drag changes - applied via + // one setSmartGuideLines call after the map, cleared on drag end. + // DELIBERATE deviation for multi-select drags (review-confirmed): + // legacy cleared guides per-item, so only the LAST-processed item's + // guides survived each frame - an artifact of Qt's per-item itemChange + // ordering, not a design choice. Accumulating every co-mover's guides + // shows all live alignments instead of an arbitrary one. + const frameGuides: GuideLine[] = []; + let sawDragging = false; + let sawDragEnd = false; const scaled = changes.map((change) => { if (change.type !== "position" || !change.position) return change; if (change.dragging) { draggingRef.current = true; + sawDragging = true; let start = dragStartRef.current.get(change.id); if (!start) { const node = nodes.find((n) => n.id === change.id); start = node ? { ...node.position } : { ...change.position }; dragStartRef.current.set(change.id, start); } - const scaledPosition = scaleDragPosition(start, change.position, scene.dragFactor); - memberChanges.push(...applyGroupDragDelta(nodes, change.id, scaledPosition)); + let finalPosition = scaleDragPosition(start, change.position, scene.dragFactor); + // R7.5b-3: smart-guide snap, as a LAYERED PASS on top of React + // Flow's native grid-snap (which, when enabled, already ran inside + // RF before this change was emitted) - the recorded design + // decision, chosen over re-implementing grid-snap manually. Smart + // guides win per-axis when both would apply, reproducing legacy + // snap_position's own per-axis priority. Runs BEFORE + // applyGroupDragDelta below so carried group members ride the + // corrected delta. Legacy's !isSelected() candidate exclusion is + // translated as !n.selected (every co-mover in a multi-select drag + // reports its own dragging change with selected=true); a dragged + // group's own members are additionally excluded - they move in + // lockstep with the group, so "aligning" against them is always + // trivially true and would freeze the drag (a gap legacy never had + // to answer: this canvas carries members via synthetic deltas, not + // Qt child-item parenting - per the recorded design decision, only + // the group's own rect snaps and members ride the delta). + if (scene.smartGuides) { + const moving = nodes.find((n) => n.id === change.id); + const movingSize = measuredNodeSize(reactFlow, change.id); + if (moving && movingSize) { + const memberIds = groupDragKindOf(moving) + ? new Set((moving.data as { itemIds?: string[] }).itemIds ?? []) + : new Set(); + const candidates: Rect[] = []; + for (const n of nodes) { + if (n.id === change.id || n.selected || memberIds.has(n.id)) continue; + const size = measuredNodeSize(reactFlow, n.id); + if (!size) continue; + candidates.push({ x: n.position.x, y: n.position.y, width: size.width, height: size.height }); + } + const snap = computeSmartGuideSnap( + { x: finalPosition.x, y: finalPosition.y, width: movingSize.width, height: movingSize.height }, + candidates, + ); + finalPosition = { x: snap.x, y: snap.y }; + frameGuides.push(...snap.guides); + } + } + memberChanges.push(...applyGroupDragDelta(nodes, change.id, finalPosition)); return { ...change, - position: scaledPosition, + position: finalPosition, }; } // Drag end: commit the node's final (already-scaled) position. draggingRef.current = false; + sawDragEnd = true; const settled = nodes.find((n) => n.id === change.id); + dragStartRef.current.delete(change.id); if (settled) { store.moveNode(change.id, settled.position.x, settled.position.y); if (groupDragKindOf(settled)) { @@ -967,17 +1055,30 @@ function CanvasInner({ store }: { store: SceneStore }) { if (member) memberMoveIntents.push({ id: memberId, x: member.position.x, y: member.position.y }); } } + // R7.5b-3 review fix: RF's drag-stop change carries its own + // internal RAW pointer-derived position - the drag-factor/smart- + // guide corrections applied per frame above never feed back into + // RF's drag state. Passing the raw change through here made the + // node visibly bounce off its corrected position on release, then + // reconcile after the backend echo. Return the settled (last + // corrected frame's) position instead, which is also exactly what + // moveNode just committed - local state and the wire now agree at + // the instant of release. + return { ...change, position: { ...settled.position } }; } - dragStartRef.current.delete(change.id); return change; }); // Persist each carried-along member's settled position too - the same // moveNode call site the group's own commit above uses, fired after // the map so every real change's own drag-end has already run. for (const move of memberMoveIntents) store.moveNode(move.id, move.x, move.y); + // Guides re-derive every drag frame (legacy cleared + re-added its + // QGraphicsLineItems per recompute); drag end always clears. + if (sawDragging) setSmartGuideLines(frameGuides); + else if (sawDragEnd) setSmartGuideLines([]); setNodes((current) => applyNodeChanges([...scaled, ...memberChanges], current)); }, - [nodes, scene.dragFactor, store], + [nodes, scene.dragFactor, scene.smartGuides, reactFlow, store], ); const onConnect = useCallback( @@ -1014,7 +1115,7 @@ function CanvasInner({ store }: { store: SceneStore }) { [store, nodes], ); - const { screenToFlowPosition } = useReactFlow(); + const { screenToFlowPosition } = reactFlow; const onDoubleClick = useCallback( (event: React.MouseEvent) => { // Double-click on empty canvas creates a node there - the R1 stand-in @@ -1064,6 +1165,44 @@ function CanvasInner({ store }: { store: SceneStore }) { style={{ opacity: grid.gridOpacityPercent / 100 }} /> + {/* R7.5b-3: smart-guide lines. Legacy's guides were QGraphicsLineItems + in the same unified scene as the nodes, panning/zooming for free - + ViewportPortal is the direct React Flow analog (children render + inside the same CSS-transformed layer, in flow coordinates). The + dash color is Qt's QColor(128, 128, 128, 200) ported exactly + (200/255 = 0.784 alpha). pointerEvents none so a guide can never + swallow the drag it is annotating. */} + {visibleGuideLines.length > 0 && ( + + {visibleGuideLines.map((guide, index) => ( +
+ ))} + + )}
); diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts index afa544a..6b4ccf1 100644 --- a/web_ui/src/app/canvas/sceneStore.test.ts +++ b/web_ui/src/app/canvas/sceneStore.test.ts @@ -98,6 +98,7 @@ function validScenePayload(overrides: Record = {}) { snapToGrid: true, fadeConnectionsEnabled: false, orthogonalRouting: false, + smartGuides: false, dragFactor: 0.5, fontFamily: "Segoe UI", fontSizePt: 9, diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts index b879246..eb235d5 100644 --- a/web_ui/src/app/canvas/sceneStore.ts +++ b/web_ui/src/app/canvas/sceneStore.ts @@ -25,6 +25,7 @@ export const initialSceneState: SceneState = { snapToGrid: false, fadeConnectionsEnabled: false, orthogonalRouting: false, + smartGuides: false, dragFactor: 1, fontFamily: "Segoe UI", fontSizePt: 9, @@ -541,6 +542,11 @@ export class SceneStore { this.transport.intent("scene", "setOrthogonalConnections", [enabled]); } + // R7.5b-3: the fourth and final legacy grid-control toggle. + setSmartGuides(enabled: boolean): void { + this.transport.intent("scene", "setSmartGuides", [enabled]); + } + setDragFactor(factor: number): void { this.transport.intent("scene", "setDragFactor", [factor]); } diff --git a/web_ui/src/app/canvas/smartGuides.test.ts b/web_ui/src/app/canvas/smartGuides.test.ts new file mode 100644 index 0000000..6b56098 --- /dev/null +++ b/web_ui/src/app/canvas/smartGuides.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { ALIGNMENT_TOLERANCE_PX, computeSmartGuideSnap, type Rect } from "./smartGuides"; + +// Direct unit coverage of the legacy _calculate_smart_guide_snap port - every +// documented semantic (same-key matching, key order, first-candidate-wins, +// strict tolerance, per-axis independence, early exit) gets its own test so a +// regression in any one rule fails loudly by name. + +function rect(x: number, y: number, width = 100, height = 50): Rect { + return { x, y, width, height }; +} + +describe("computeSmartGuideSnap", () => { + it("exports legacy's exact ALIGNMENT_TOLERANCE of 5px as the default", () => { + expect(ALIGNMENT_TOLERANCE_PX).toBe(5); + }); + + it("returns the position unchanged with no candidates", () => { + const result = computeSmartGuideSnap(rect(203, 400), []); + expect(result).toEqual({ x: 203, y: 400, guides: [] }); + }); + + it("returns the position unchanged when nothing aligns", () => { + const result = computeSmartGuideSnap(rect(0, 0), [rect(500, 500)]); + expect(result).toEqual({ x: 0, y: 0, guides: [] }); + }); + + it("snaps left-to-left and draws a vertical guide spanning the union of both rects", () => { + const result = computeSmartGuideSnap(rect(203, 400), [rect(200, 100, 80, 40)]); + expect(result.x).toBe(200); + expect(result.y).toBe(400); + expect(result.guides).toEqual([ + // position = the candidate's key value; span = min(tops)..max(bottoms). + { orientation: "vertical", position: 200, start: 100, end: 450 }, + ]); + }); + + it("snaps center-to-center, back-solving x from the moving rect's half-width", () => { + // moving center = 0 + 100/2 = 50; candidate center = 30 + 44/2 = 52. + const result = computeSmartGuideSnap(rect(0, 400), [rect(30, 100, 44, 40)]); + expect(result.x).toBe(2); // 52 - 100/2 + }); + + it("snaps right-to-right, back-solving x from the moving rect's full width", () => { + // moving right = 100; candidate right = 52 + 50 = 102. + const result = computeSmartGuideSnap(rect(0, 400), [rect(52, 100, 50, 40)]); + expect(result.x).toBe(2); // 102 - 100 + }); + + it("snaps top-to-top and draws a horizontal guide", () => { + const result = computeSmartGuideSnap(rect(400, 103), [rect(100, 100, 80, 40)]); + expect(result.y).toBe(100); + expect(result.guides).toEqual([ + { orientation: "horizontal", position: 100, start: 100, end: 500 }, + ]); + }); + + it("snaps middle-to-middle, back-solving y from the moving rect's half-height", () => { + // moving middle = 25; candidate middle = -100 + 254/2 = 27; candidate's + // top (-100) and bottom (154) are both far outside tolerance, isolating + // the middle key. + const result = computeSmartGuideSnap(rect(400, 0), [rect(100, -100, 80, 254)]); + expect(result.y).toBe(2); // 27 - 50/2 + }); + + it("snaps bottom-to-bottom, back-solving y from the moving rect's full height", () => { + // moving bottom = 50; candidate bottom = -200 + 252 = 52. + const result = computeSmartGuideSnap(rect(400, 0), [rect(100, -200, 80, 252)]); + expect(result.y).toBe(2); // 52 - 50 + }); + + it("uses a STRICT less-than tolerance (a 5px offset does not snap, 4.9px does)", () => { + expect(computeSmartGuideSnap(rect(205, 400), [rect(200, 100)]).x).toBe(205); + expect(computeSmartGuideSnap(rect(204.9, 400), [rect(200, 100)]).x).toBe(200); + }); + + it("tries keys in left/center/right order and takes the FIRST hit, not the closest", () => { + // moving left = 203 (3px from candidate left 200); moving center = 253 + // (1px from candidate center 252 with candidate width 104). Center is + // closer, but left is tested first - legacy took the first hit. + const result = computeSmartGuideSnap(rect(203, 400), [rect(200, 100, 104, 40)]); + expect(result.x).toBe(200); + }); + + it("scans candidates in array order and takes the first aligning one", () => { + const result = computeSmartGuideSnap(rect(203, 400), [ + rect(200, 100), + rect(201, 200), + ]); + expect(result.x).toBe(200); + expect(result.guides).toHaveLength(1); + expect(result.guides[0].position).toBe(200); + }); + + it("can snap X and Y against two DIFFERENT candidates in one call", () => { + const result = computeSmartGuideSnap(rect(203, 402), [ + rect(200, 900), // aligns X only (left 200 vs 203) + rect(900, 400), // aligns Y only (top 400 vs 402) + ]); + expect(result.x).toBe(200); + expect(result.y).toBe(400); + expect(result.guides).toHaveLength(2); + expect(result.guides.map((g) => g.orientation).sort()).toEqual(["horizontal", "vertical"]); + }); + + it("stops scanning once both axes are snapped - at most one guide per axis", () => { + const result = computeSmartGuideSnap(rect(203, 402), [ + rect(200, 400), // aligns both axes at once + rect(201, 401), // would also align both - must contribute nothing + ]); + expect(result.x).toBe(200); + expect(result.y).toBe(400); + expect(result.guides).toHaveLength(2); + expect(result.guides.every((g) => g.position === 200 || g.position === 400)).toBe(true); + }); + + it("an already-snapped axis is not re-tested against later candidates", () => { + // First candidate snaps X; second would snap X differently but only its + // Y alignment may land. + const result = computeSmartGuideSnap(rect(203, 402), [ + rect(200, 900), // X -> 200 + rect(204, 400), // X would -> 204, but X is taken; Y -> 400 + ]); + expect(result.x).toBe(200); + expect(result.y).toBe(400); + }); +}); diff --git a/web_ui/src/app/canvas/smartGuides.ts b/web_ui/src/app/canvas/smartGuides.ts new file mode 100644 index 0000000..f3aa5de --- /dev/null +++ b/web_ui/src/app/canvas/smartGuides.ts @@ -0,0 +1,120 @@ +/** + * Smart-guide alignment snapping (Qt-removal plan R7.5b-3) - a pure, direct + * translation of the legacy ChatScene._calculate_smart_guide_snap + * (graphlink_scene.py), deliberately framework-free so the whole algorithm is + * unit-testable without mounting a canvas (the scaleDragPosition/toFlowNodes + * precedent). SceneCanvas.tsx calls this per drag frame and renders the + * returned guide lines through React Flow's . + * + * Ported semantics, all confirmed against the legacy source by recon: + * - 3 alignment keys per axis, matched SAME-KEY-ONLY (moving-left vs + * candidate-left, center vs center, ... - never left-to-center). + * - Candidates scanned in array order; per candidate, X keys are tried in + * left/center/right order and Y keys in top/middle/bottom order, first hit + * within tolerance wins that axis. X and Y may therefore snap against two + * DIFFERENT candidates in one call. + * - The scan stops the moment both axes are snapped - it does not keep + * looking for a "closer" alignment. At most one guide line per axis. + * - Tolerance is a strict less-than (legacy: abs(m - s) < ALIGNMENT_TOLERANCE). + * - A guide line sits at the CANDIDATE's key value and spans the union of + * the two rects along the perpendicular axis. + */ + +// Legacy's ALIGNMENT_TOLERANCE = 5 (scene px), verbatim. One constant gates +// both "show the guide" and "snap the position" - legacy had no separate +// show-but-don't-snap zone, so neither does this. +export const ALIGNMENT_TOLERANCE_PX = 5; + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export interface GuideLine { + orientation: "vertical" | "horizontal"; + /** The aligned coordinate: x for vertical guides, y for horizontal. */ + position: number; + /** Span along the perpendicular axis (y-range for vertical, x-range for + * horizontal) - the union of the moving and candidate rects, matching + * legacy's min(top,top)..max(bottom,bottom) segments (never full-canvas + * lines). */ + start: number; + end: number; +} + +export interface SmartGuideSnapResult { + x: number; + y: number; + guides: GuideLine[]; +} + +// Key order matters: legacy tested left/center/right (and top/middle/bottom) +// in this exact order and took the FIRST within-tolerance hit, not the +// closest one. +const VERTICAL_KEYS = ["left", "center", "right"] as const; +const HORIZONTAL_KEYS = ["top", "middle", "bottom"] as const; + +function verticalValues(r: Rect): Record<(typeof VERTICAL_KEYS)[number], number> { + return { left: r.x, center: r.x + r.width / 2, right: r.x + r.width }; +} + +function horizontalValues(r: Rect): Record<(typeof HORIZONTAL_KEYS)[number], number> { + return { top: r.y, middle: r.y + r.height / 2, bottom: r.y + r.height }; +} + +export function computeSmartGuideSnap( + movingRect: Rect, + candidates: Rect[], + tolerance: number = ALIGNMENT_TOLERANCE_PX, +): SmartGuideSnapResult { + let snappedX: number | null = null; + let snappedY: number | null = null; + const guides: GuideLine[] = []; + + const movingV = verticalValues(movingRect); + const movingH = horizontalValues(movingRect); + + for (const candidate of candidates) { + if (snappedX === null) { + const candV = verticalValues(candidate); + for (const key of VERTICAL_KEYS) { + if (Math.abs(movingV[key] - candV[key]) < tolerance) { + const offset = key === "left" ? 0 : key === "center" ? movingRect.width / 2 : movingRect.width; + snappedX = candV[key] - offset; + guides.push({ + orientation: "vertical", + position: candV[key], + start: Math.min(movingRect.y, candidate.y), + end: Math.max(movingRect.y + movingRect.height, candidate.y + candidate.height), + }); + break; + } + } + } + if (snappedY === null) { + const candH = horizontalValues(candidate); + for (const key of HORIZONTAL_KEYS) { + if (Math.abs(movingH[key] - candH[key]) < tolerance) { + const offset = key === "top" ? 0 : key === "middle" ? movingRect.height / 2 : movingRect.height; + snappedY = candH[key] - offset; + guides.push({ + orientation: "horizontal", + position: candH[key], + start: Math.min(movingRect.x, candidate.x), + end: Math.max(movingRect.x + movingRect.width, candidate.x + candidate.width), + }); + break; + } + } + } + if (snappedX !== null && snappedY !== null) break; + } + + return { + x: snappedX ?? movingRect.x, + y: snappedY ?? movingRect.y, + guides, + }; +} diff --git a/web_ui/src/app/chrome/ViewPopover.test.tsx b/web_ui/src/app/chrome/ViewPopover.test.tsx index 94b6a0c..4b0985e 100644 --- a/web_ui/src/app/chrome/ViewPopover.test.tsx +++ b/web_ui/src/app/chrome/ViewPopover.test.tsx @@ -25,6 +25,7 @@ function makeStore(sceneOverrides: Partial = {}) { const setFadeConnections = vi.fn(); const setSnapToGrid = vi.fn(); const setOrthogonalConnections = vi.fn(); + const setSmartGuides = vi.fn(); const store = { subscribe: (l: () => void) => { listeners.add(l); @@ -42,11 +43,12 @@ function makeStore(sceneOverrides: Partial = {}) { setSnapToGrid, setFadeConnections, setOrthogonalConnections, + setSmartGuides, setFontFamily: vi.fn(), setFontSize: vi.fn(), setFontColor: vi.fn(), }; - return { store, setFadeConnections, setSnapToGrid, setOrthogonalConnections }; + return { store, setFadeConnections, setSnapToGrid, setOrthogonalConnections, setSmartGuides }; } function renderOpen(store: unknown) { @@ -115,3 +117,29 @@ describe("ViewPopover (R7.5b-2 Orthogonal Routing checkbox)", () => { expect(setFadeConnections).not.toHaveBeenCalled(); }); }); + +// R7.5b-3: same posture again - only the new control is covered. +describe("ViewPopover (R7.5b-3 Smart Guides checkbox)", () => { + it("reflects smartGuides=false as unchecked and smartGuides=true as checked", () => { + const off = makeStore({ smartGuides: false }); + const { unmount } = renderOpen(off.store); + expect(screen.getByRole("checkbox", { name: "Smart Guides" })).not.toBeChecked(); + unmount(); + + const on = makeStore({ smartGuides: true }); + renderOpen(on.store); + expect(screen.getByRole("checkbox", { name: "Smart Guides" })).toBeChecked(); + }); + + it("calls store.setSmartGuides with the new value on toggle, independent of the other toggles", async () => { + const user = userEvent.setup(); + const { store, setSmartGuides, setOrthogonalConnections, setFadeConnections } = makeStore({ smartGuides: false }); + renderOpen(store); + + await user.click(screen.getByRole("checkbox", { name: "Smart Guides" })); + + expect(setSmartGuides).toHaveBeenCalledWith(true); + expect(setOrthogonalConnections).not.toHaveBeenCalled(); + expect(setFadeConnections).not.toHaveBeenCalled(); + }); +}); diff --git a/web_ui/src/app/chrome/ViewPopover.tsx b/web_ui/src/app/chrome/ViewPopover.tsx index dbe16fb..e748b93 100644 --- a/web_ui/src/app/chrome/ViewPopover.tsx +++ b/web_ui/src/app/chrome/ViewPopover.tsx @@ -117,6 +117,15 @@ export function ViewPopover({ store }: { store: SceneStore }) { /> Orthogonal Routing + {/* R7.5b-3: the fourth and final legacy grid-control toggle. */} +
diff --git a/web_ui/src/app/chrome/help-data/sections.ts b/web_ui/src/app/chrome/help-data/sections.ts index 3d23dfa..081e18f 100644 --- a/web_ui/src/app/chrome/help-data/sections.ts +++ b/web_ui/src/app/chrome/help-data/sections.ts @@ -279,7 +279,7 @@ export const HELP_SECTIONS: HelpSection[] = [ }, { "action": "Grid and Guide Controls", - "description": "The controls overlay can enable snap-to-grid, font controls, faded connections, and orthogonal connection routing. Smart guides are not available yet in this build. These tools are useful when a canvas needs visual cleanup rather than new AI output." + "description": "The controls overlay can enable snap-to-grid, smart guides, orthogonal routing, font controls, and faded connections. These tools are useful when a canvas needs visual cleanup rather than new AI output." }, { "action": "Reduce Visual Noise", diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json index 9884cac..6cc02d0 100644 --- a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json +++ b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json @@ -503,6 +503,9 @@ "schemaVersion": { "type": "integer" }, + "smartGuides": { + "type": "boolean" + }, "snapToGrid": { "type": "boolean" } @@ -516,6 +519,7 @@ "snapToGrid", "fadeConnectionsEnabled", "orthogonalRouting", + "smartGuides", "dragFactor", "fontFamily", "fontSizePt", diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.ts b/web_ui/src/lib/bridge-core/generated/scene-state.ts index 479e761..02d26f6 100644 --- a/web_ui/src/lib/bridge-core/generated/scene-state.ts +++ b/web_ui/src/lib/bridge-core/generated/scene-state.ts @@ -133,6 +133,7 @@ export interface SceneState { snapToGrid: boolean; fadeConnectionsEnabled: boolean; orthogonalRouting: boolean; + smartGuides: boolean; dragFactor: number; fontFamily: string; fontSizePt: number; @@ -724,6 +725,11 @@ function checkSceneState(value: unknown, path: string, errors: string[]): void { if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.orthogonalRouting: missing required field`); else { if (typeof fieldValue !== "boolean") errors.push(`${path}.orthogonalRouting` + ": expected boolean"); } } + { + const fieldValue = value["smartGuides"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.smartGuides: missing required field`); + else { if (typeof fieldValue !== "boolean") errors.push(`${path}.smartGuides` + ": expected boolean"); } + } { const fieldValue = value["dragFactor"]; if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.dragFactor: missing required field`);