diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index f7a3697..a08a915 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -2282,7 +2282,13 @@ function CanvasInner({ // so a drag frame never has to travel through React state to reach the // renderer. const storeApi = useStoreApi(); - const dragStartRef = useRef>(new Map()); + // Which nodes the current gesture is carrying. Membership is all this + // needs to record: it exists so the drag-STOP change (which arrives + // without the dragging flag) can still be recognised as part of the + // gesture. It held start POSITIONS while the drag-speed factor scaled + // node motion from its origin; that factor now applies to canvas panning + // instead, so the positions were dead weight. + const dragStartRef = useRef>(new Set()); const draggingRef = useRef(false); // ADR-011 stage 11.3: the smart-guide size cache - populated ONCE per drag // gesture (see the `startingNewDrag` check inside onNodesChange below, @@ -2611,19 +2617,14 @@ function CanvasInner({ // its corrected position and then reconcile after the backend echo. if (!change.dragging && !dragStartRef.current.has(change.id)) return change; sawGestureFrame = true; - // Gesture membership bookkeeping only: recording the start is what - // lets the drag-STOP change (which arrives without the dragging - // flag) be recognised above. The drag-speed factor deliberately - // does NOT touch node motion any more - the legacy feature it - // ports scaled canvas PANNING, never item movement - // (graphlink_view.py:72 "For controlling pan speed"; its pan - // handler multiplied each mouse delta by the factor). The straight - // port mis-wired it to node dragging; the factor now applies in - // the wrapper's own pan handler below. - if (!dragStartRef.current.has(change.id)) { - const node = currentNodes.find((n) => n.id === change.id); - dragStartRef.current.set(change.id, node ? { ...node.position } : { ...change.position }); - } + // Gesture membership only - see dragStartRef's own comment. The + // drag-speed factor deliberately does NOT touch node motion: the + // legacy feature it ports scaled canvas PANNING, never item + // movement (graphlink_view.py: "For controlling pan speed", whose + // pan handler multiplied each mouse delta by the factor). The + // straight port mis-wired it to node dragging; the factor now + // applies in the wrapper's own pan handler below. + dragStartRef.current.add(change.id); let finalPosition = { ...change.position }; // 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 @@ -2807,7 +2808,7 @@ function CanvasInner({ // Hover is only meaningful while the fade-connections lens is on; testing // otherwise would cost a pointer-move hit test for no visible effect. const onCanvasMouseMove = useCallback( - (event: MouseEvent) => { + (event: PointerEvent) => { if (!scene.fadeConnectionsEnabled) return; if (draggingRef.current) return; const id = connectionAt(event.clientX, event.clientY); @@ -2827,7 +2828,7 @@ function CanvasInner({ // pointer-delta times factor, incrementally per event. const panStateRef = useRef<{ lastX: number; lastY: number } | null>(null); const onCanvasMouseDown = useCallback( - (event: MouseEvent) => { + (event: PointerEvent) => { if ((event.target as HTMLElement).closest(".react-flow__node")) return; const id = connectionAt(event.clientX, event.clientY); setSelectedConnectionId(id); @@ -2842,7 +2843,7 @@ function CanvasInner({ [connectionAt], ); useEffect(() => { - const onWindowMouseMove = (event: MouseEvent) => { + const onWindowMouseMove = (event: PointerEvent) => { const pan = panStateRef.current; if (!pan) return; const factor = sceneRef.current.dragFactor; @@ -2864,11 +2865,16 @@ function CanvasInner({ store.setViewState(zoomFactor, scrollX, scrollY), )(zoom, x, y); }; - window.addEventListener("mousemove", onWindowMouseMove); - window.addEventListener("mouseup", onWindowMouseUp); + // Pointer events, not mouse events: React Flow's own pan (disabled + // here so the speed factor can apply) was pointer-based, so handling + // only mouse would have left touch and pen unable to pan at all. + window.addEventListener("pointermove", onWindowMouseMove); + window.addEventListener("pointerup", onWindowMouseUp); + window.addEventListener("pointercancel", onWindowMouseUp); return () => { - window.removeEventListener("mousemove", onWindowMouseMove); - window.removeEventListener("mouseup", onWindowMouseUp); + window.removeEventListener("pointermove", onWindowMouseMove); + window.removeEventListener("pointerup", onWindowMouseUp); + window.removeEventListener("pointercancel", onWindowMouseUp); }; }, [reactFlow, store, storeApi]); @@ -2896,13 +2902,13 @@ function CanvasInner({ useEffect(() => { const el = canvasWrapperRef.current; if (!el) return; - const move = (event: MouseEvent) => onCanvasMouseMove(event); - const down = (event: MouseEvent) => onCanvasMouseDown(event); - el.addEventListener("mousemove", move); - el.addEventListener("mousedown", down); + const move = (event: PointerEvent) => onCanvasMouseMove(event); + const down = (event: PointerEvent) => onCanvasMouseDown(event); + el.addEventListener("pointermove", move); + el.addEventListener("pointerdown", down); return () => { - el.removeEventListener("mousemove", move); - el.removeEventListener("mousedown", down); + el.removeEventListener("pointermove", move); + el.removeEventListener("pointerdown", down); }; }, [onCanvasMouseMove, onCanvasMouseDown]); diff --git a/web_ui/src/app/canvas/connections/ConnectionCanvas.tsx b/web_ui/src/app/canvas/connections/ConnectionCanvas.tsx index 51640e1..d696818 100644 --- a/web_ui/src/app/canvas/connections/ConnectionCanvas.tsx +++ b/web_ui/src/app/canvas/connections/ConnectionCanvas.tsx @@ -147,7 +147,8 @@ export function ConnectionCanvas({ // than by scaling the canvas itself, which keeps strokes a constant // width on screen and free of scaling artefacts at any zoom. ctx.setTransform(ratio * zoom, 0, 0, ratio * zoom, ratio * panX, ratio * panY); - ctx.lineWidth = 1.5 / zoom; + // Stroke width is set per connection below (selected links are drawn + // heavier), so none is set here. ctx.lineCap = "round"; const { diff --git a/web_ui/src/app/chrome/ViewPopover.tsx b/web_ui/src/app/chrome/ViewPopover.tsx index 46c5581..55bec0d 100644 --- a/web_ui/src/app/chrome/ViewPopover.tsx +++ b/web_ui/src/app/chrome/ViewPopover.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from "react"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { FILTERABLE_NODE_KINDS } from "../canvas/SceneCanvas"; import type { SceneStore } from "../canvas/sceneStore"; import { Popover } from "../overlays/overlays"; @@ -66,6 +66,51 @@ const DEFAULTS = { const GRID_SIZE_MIN = 4; const GRID_SIZE_MAX = 120; +/** + * Keeps a continuous control responsive while sending far fewer intents. + * + * A range input or a native colour picker fires change events for every + * pixel of pointer movement, and each intent here triggers a full state + * republish - so dragging one slider used to put ~100 round trips on the + * wire. This shows the in-flight value immediately and commits the last + * one after a short pause, the same debounce posture the canvas already + * uses for viewport reporting. + * + * Pending state clears whenever a fresh value arrives with nothing in + * flight, so a server-side clamp (grid spacing, drag factor and font size + * are all clamped) is always what ends up displayed - never a local value + * the backend rejected. + */ +function useDebouncedSetting(remote: T, commit: (value: T) => void, delayMs = 120) { + const [pending, setPending] = useState(null); + const timerRef = useRef | null>(null); + const commitRef = useRef(commit); + useEffect(() => { + commitRef.current = commit; + }, [commit]); + useEffect(() => { + if (timerRef.current === null) setPending(null); + }, [remote]); + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, + [], + ); + const set = useCallback( + (value: T) => { + setPending(value); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; + commitRef.current(value); + }, delayMs); + }, + [delayMs], + ); + return [pending === null ? remote : pending, set] as const; +} + /** A slider header: what the value is, and what it currently reads. */ function FieldRow({ label, value }: { label: string; value: string }) { return ( @@ -183,7 +228,23 @@ export function ViewPopover({ store }: { store: SceneStore }) { const filterKinds = useSyncExternalStore(store.subscribe, store.getFilterKinds); const filterStatuses = useSyncExternalStore(store.subscribe, store.getFilterStatuses); - const dragPercent = Math.round(scene.dragFactor * 100); + // Continuous controls commit through the debounce above; discrete ones + // (presets, style, toggles) stay immediate - one event, one intent. + const commitDragPercent = useCallback((percent: number) => store.setDragFactor(percent / 100), [store]); + const [dragPercent, setDragPercent] = useDebouncedSetting( + Math.round(scene.dragFactor * 100), + commitDragPercent, + ); + const commitGridSize = useCallback((size: number) => store.setGridSize(size), [store]); + const [gridSize, setGridSize] = useDebouncedSetting(grid.gridSize, commitGridSize); + const commitGridOpacity = useCallback((percent: number) => store.setGridOpacityPercent(percent), [store]); + const [gridOpacity, setGridOpacity] = useDebouncedSetting(grid.gridOpacityPercent, commitGridOpacity); + const commitGridColor = useCallback((color: string) => store.setGridColor(color), [store]); + const [gridColor, setGridColor] = useDebouncedSetting(grid.gridColor, commitGridColor); + const commitFontSize = useCallback((size: number) => store.setFontSize(size), [store]); + const [fontSizePt, setFontSizePt] = useDebouncedSetting(scene.fontSizePt, commitFontSize); + const commitFontColor = useCallback((color: string) => store.setFontColor(color), [store]); + const [fontColor, setFontColor] = useDebouncedSetting(scene.fontColor, commitFontColor); const filterCount = filterKinds.size + filterStatuses.size; const resetAll = () => { @@ -215,7 +276,7 @@ export function ViewPopover({ store }: { store: SceneStore }) { max={dragConfig.percentMax} value={dragPercent} aria-label="Canvas pan speed" - onChange={(e) => store.setDragFactor(Number(e.target.value) / 100)} + onChange={(e) => setDragPercent(Number(e.target.value))} />
{dragConfig.percentPresets.map((percent) => ( @@ -234,38 +295,38 @@ export function ViewPopover({ store }: { store: SceneStore }) {

Grid

- + store.setGridSize(Number(e.target.value))} + onChange={(e) => setGridSize(Number(e.target.value))} />
{grid.sizePresets.map((size) => ( ))}
- + store.setGridOpacityPercent(Number(e.target.value))} + onChange={(e) => setGridOpacity(Number(e.target.value))} />
@@ -281,12 +342,12 @@ export function ViewPopover({ store }: { store: SceneStore }) { ))}
- + store.setGridColor(color)} + onPick={setGridColor} /> store.setFontFamily(family)} ariaLabel="Font family" /> - + store.setFontSize(Number(e.target.value))} + onChange={(e) => setFontSizePt(Number(e.target.value))} /> - + store.setFontColor(color)} + onPick={setFontColor} /> {/* What the settings above actually produce on a node card - the readout the three separate controls never had. */} @@ -353,8 +414,8 @@ export function ViewPopover({ store }: { store: SceneStore }) { aria-hidden="true" style={{ fontFamily: scene.fontFamily, - fontSize: `${scene.fontSizePt}pt`, - color: scene.fontColor, + fontSize: `${fontSizePt}pt`, + color: fontColor, }} > The quick brown fox jumps over the lazy dog diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index 943c8f3..4496dba 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -274,6 +274,10 @@ body, otherwise apply. */ .scene-canvas .react-flow__pane { cursor: grab; + /* The wrapper owns the pan gesture (React Flow's own pan is disabled so + the speed factor can apply), so the browser must not claim touch + drags for scrolling before the handler sees them. */ + touch-action: none; } .scene-canvas.panning .react-flow__pane { @@ -1906,8 +1910,14 @@ body, flex-wrap: wrap; } -/* Filter chips: pill-shaped so multi-select membership reads differently - from the segmented single-choice controls above. */ +/* Toggle chips: pill-shaped, for selections drawn from an open-ended, + data-driven set - the node-kind/status filters here, and the Chat + Library's workspace tabs and tag filters. Distinct from .view-segment + above, which is for a short fixed set of mutually exclusive choices + (grid style, preset values) and is drawn as one connected control. + Chips carry no single/multi-select meaning of their own: the filters + are multi-select, the workspace tabs single-select, and both read + correctly because an active chip simply means "this one is on". */ .view-chip { font-size: 10px; font-weight: 600;