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
60 changes: 33 additions & 27 deletions web_ui/src/app/canvas/SceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<string, { x: number; y: number }>>(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<Set<string>>(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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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]);

Expand Down Expand Up @@ -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]);

Expand Down
3 changes: 2 additions & 1 deletion web_ui/src/app/canvas/connections/ConnectionCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
105 changes: 83 additions & 22 deletions web_ui/src/app/chrome/ViewPopover.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<T>(remote: T, commit: (value: T) => void, delayMs = 120) {
const [pending, setPending] = useState<T | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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))}
/>
<div className="view-segment" role="group" aria-label="Drag speed presets">
{dragConfig.percentPresets.map((percent) => (
Expand All @@ -234,38 +295,38 @@ export function ViewPopover({ store }: { store: SceneStore }) {

<section className="view-section" aria-label="Grid">
<p className="view-section-title">Grid</p>
<FieldRow label="Spacing" value={`${grid.gridSize}px`} />
<FieldRow label="Spacing" value={`${gridSize}px`} />
<input
type="range"
className="view-slider"
min={GRID_SIZE_MIN}
max={GRID_SIZE_MAX}
value={grid.gridSize}
value={gridSize}
aria-label="Grid spacing"
onChange={(e) => store.setGridSize(Number(e.target.value))}
onChange={(e) => setGridSize(Number(e.target.value))}
/>
<div className="view-segment" role="group" aria-label="Grid spacing presets">
{grid.sizePresets.map((size) => (
<button
key={size}
type="button"
className={"view-segment-btn" + (size === grid.gridSize ? " active" : "")}
aria-pressed={size === grid.gridSize}
className={"view-segment-btn" + (size === gridSize ? " active" : "")}
aria-pressed={size === gridSize}
onClick={() => store.setGridSize(size)}
>
{size}px
</button>
))}
</div>
<FieldRow label="Opacity" value={`${grid.gridOpacityPercent}%`} />
<FieldRow label="Opacity" value={`${gridOpacity}%`} />
<input
type="range"
className="view-slider"
min={0}
max={100}
value={grid.gridOpacityPercent}
value={gridOpacity}
aria-label="Grid opacity"
onChange={(e) => store.setGridOpacityPercent(Number(e.target.value))}
onChange={(e) => setGridOpacity(Number(e.target.value))}
/>
<FieldRow label="Style" value={grid.gridStyle} />
<div className="view-segment" role="group" aria-label="Grid style">
Expand All @@ -281,12 +342,12 @@ export function ViewPopover({ store }: { store: SceneStore }) {
</button>
))}
</div>
<FieldRow label="Color" value={grid.gridColor.toUpperCase()} />
<FieldRow label="Color" value={gridColor.toUpperCase()} />
<SwatchRow
presets={grid.colorPresets}
current={grid.gridColor}
current={gridColor}
ariaPrefix="Grid color"
onPick={(color) => store.setGridColor(color)}
onPick={setGridColor}
/>
<ToggleRow
label="Snap to Grid"
Expand Down Expand Up @@ -329,22 +390,22 @@ export function ViewPopover({ store }: { store: SceneStore }) {
onChange={(family) => store.setFontFamily(family)}
ariaLabel="Font family"
/>
<FieldRow label="Size" value={`${scene.fontSizePt}pt`} />
<FieldRow label="Size" value={`${fontSizePt}pt`} />
<input
type="range"
className="view-slider"
min={fontConfig.sizeMin}
max={fontConfig.sizeMax}
value={scene.fontSizePt}
value={fontSizePt}
aria-label="Font size"
onChange={(e) => store.setFontSize(Number(e.target.value))}
onChange={(e) => setFontSizePt(Number(e.target.value))}
/>
<FieldRow label="Color" value={scene.fontColor.toUpperCase()} />
<FieldRow label="Color" value={fontColor.toUpperCase()} />
<SwatchRow
presets={fontConfig.colorPresets}
current={scene.fontColor}
current={fontColor}
ariaPrefix="Font color"
onPick={(color) => store.setFontColor(color)}
onPick={setFontColor}
/>
{/* What the settings above actually produce on a node card - the
readout the three separate controls never had. */}
Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions web_ui/src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Loading