From 5549b302f773fb211924d5402f6f99763d3ad74e Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 15:35:55 -0700 Subject: [PATCH 01/11] diagram: extract pure viewport math from Canvas Pull the pan/zoom/pinch/momentum arithmetic out of Canvas.tsx into a new pure module drawing/viewport.ts (the functional core): wheel pan/zoom, zoom-around-cursor, pinch zoom, momentum friction, release-velocity estimation, and resize. Canvas keeps the DOM-bound work -- screen->canvas mapping, the rAF loop, and React state -- and calls these pure transforms. This is a behavior-preserving refactor (viewport updates still round-trip to the controller per frame); it exists to make the upcoming live-viewport change test-driven, since the math can now be unit-tested without jsdom. Part of the issue #707 work. --- src/diagram/drawing/Canvas.tsx | 216 ++++++-------------------- src/diagram/drawing/viewport.ts | 238 +++++++++++++++++++++++++++++ src/diagram/tests/viewport.test.ts | 198 ++++++++++++++++++++++++ 3 files changed, 485 insertions(+), 167 deletions(-) create mode 100644 src/diagram/drawing/viewport.ts create mode 100644 src/diagram/tests/viewport.test.ts diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index 9be9f9cc5..7f0234772 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -58,6 +58,18 @@ import { anyModuleHasModelReference } from '../module-warning'; import { CustomElement } from './SlateEditor'; import { Stock, stockBounds, stockContains, StockHeight, StockProps, StockWidth } from './Stock'; import { isDragMovement, shouldShowVariableDetails } from './pointer-utils'; +import { + VELOCITY_THRESHOLD, + calculateVelocity as computeVelocity, + isMomentumDone, + momentumOffsetAt, + pinchOffset, + pinchZoom, + resizeViewBox, + wheelPanOffset, + wheelZoom, + zoomAroundPoint, +} from './viewport'; import { pointerStateReset, resolveSelectionForReattachment } from '../selection-logic'; import { computeDragSelection, @@ -148,27 +160,9 @@ function computeElementBounds( const ZMax = 6; -// Momentum scrolling physics for macOS-native feel. -// macOS apps (Finder, Safari, Maps) have snappier deceleration than iOS. -// A friction coefficient of 0.05 means velocity retains 5% after 1 second, -// giving a ~0.5-0.8 second coast for typical pan gestures. -const FRICTION_COEFFICIENT = 0.05; -const FRICTION_LOG = Math.log(FRICTION_COEFFICIENT); // ≈ -3.0 - -// Stop momentum when velocity drops below this threshold. -// At 60fps, 15 px/s = 0.25 px/frame - imperceptible motion. -// Lower values make the stop feel more gradual and natural. -const VELOCITY_THRESHOLD = 15; - -// Pinch-to-zoom uses exponential scaling for natural feel. -// A divisor of 100 means cumulative deltaY of ~100 results in 2x zoom. -// This matches native macOS apps like Maps and Preview. -const PINCH_ZOOM_DIVISOR = 100; - -// MIN_ZOOM matches the 0.2 floor used in render() to avoid mismatch between -// view state and actual rendering (which clamps zoom < 0.2 to 1.0) -const MIN_ZOOM = 0.2; -const MAX_ZOOM = 5.0; +// Momentum physics, zoom limits, and the wheel/pinch math live in `viewport.ts` +// (the pure functional core); this shell resolves screen->canvas points and the +// rAF/timer lifecycle, then calls those pure transforms. // Tracked pointer for multi-touch pinch detection interface TrackedPointer { @@ -771,64 +765,10 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // ---- Momentum / velocity physics (shell-internal, escapes render) ------- - // Flutter-style friction simulation: calculates position at time t - // Based on Flutter's FrictionSimulation class - // x(t) = x0 + v0 * (friction^t - 1) / ln(friction) - const frictionPosition = (velocity: number, time: number): number => { - return (velocity * (Math.pow(FRICTION_COEFFICIENT, time) - 1)) / FRICTION_LOG; - }; - - // Velocity at time t: v(t) = v0 * friction^t - const frictionVelocity = (velocity: number, time: number): number => { - return velocity * Math.pow(FRICTION_COEFFICIENT, time); - }; - - // Calculate velocity from recent positions for momentum scrolling. - // Returns zero if the pointer was stationary before release (intentional stop). - const calculateVelocity = (): Point => { - const positions = r.velocityTracker.positions; - if (positions.length < 2) { - return { x: 0, y: 0 }; - } - - const now = window.performance.now(); - const lastPosition = positions[positions.length - 1]; - - // If the pointer has been stationary for more than 40ms before release, - // the user intentionally stopped - don't start momentum. - // 40ms ≈ 2.5 frames at 60fps, enough to detect intentional stops - // while still capturing quick flick-and-release gestures. - const timeSinceLastMove = now - lastPosition.timestamp; - if (timeSinceLastMove > 40) { - return { x: 0, y: 0 }; - } - - // Use last 100ms of samples for velocity calculation - const recentPositions = positions.filter((p) => now - p.timestamp < 100); - - if (recentPositions.length < 2) { - // Fall back to last two positions - const lastP = positions[positions.length - 1]; - const prev = positions[positions.length - 2]; - const dt = (lastP.timestamp - prev.timestamp) / 1000; // seconds - if (dt <= 0) return { x: 0, y: 0 }; - return { - x: (lastP.x - prev.x) / dt, - y: (lastP.y - prev.y) / dt, - }; - } - - // Calculate average velocity over recent samples - const firstP = recentPositions[0]; - const lastP = recentPositions[recentPositions.length - 1]; - const dt = (lastP.timestamp - firstP.timestamp) / 1000; // seconds - if (dt <= 0) return { x: 0, y: 0 }; - - return { - x: (lastP.x - firstP.x) / dt, - y: (lastP.y - firstP.y) / dt, - }; - }; + // Estimate release velocity from the tracked pointer samples. The decision + // logic (too-few-samples / stationary-stop / recent-average) lives in the pure + // `computeVelocity`; this shell only supplies the samples and the clock. + const calculateVelocity = (): Point => computeVelocity(r.velocityTracker.positions, window.performance.now()); const stopMomentumAnimation = (): void => { if (r.momentumAnimationId !== undefined) { @@ -851,30 +791,18 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac } const elapsed = (timestamp - r.momentumStartTime) / 1000; // seconds - const vx = r.momentumInitialVelocity.x; - const vy = r.momentumInitialVelocity.y; + const v0 = r.momentumInitialVelocity; - // Calculate current velocity - const currentVx = frictionVelocity(vx, elapsed); - const currentVy = frictionVelocity(vy, elapsed); - const currentSpeed = Math.sqrt(currentVx * currentVx + currentVy * currentVy); - - // Stop when velocity drops below threshold - if (currentSpeed < VELOCITY_THRESHOLD) { + // Stop when the decayed momentum speed drops below threshold. + if (isMomentumDone(v0, elapsed)) { stopMomentumAnimation(); return; } - // Calculate new position using friction simulation - // Note: We ADD the friction position because higher offset = view moves in positive direction - // but velocity is in screen coordinates where dragging right should move view left - const dx = frictionPosition(vx, elapsed); - const dy = frictionPosition(vy, elapsed); - - const newOffset = { - x: r.momentumStartOffset.x + dx, - y: r.momentumStartOffset.y + dy, - }; + // Note: the friction displacement is ADDED because a higher offset moves the + // view in the positive direction, while velocity is in screen coordinates + // where dragging right should move the view left. + const newOffset = momentumOffsetAt(r.momentumStartOffset, v0, elapsed); // Update viewBox with new offset const newViewBox = { @@ -959,27 +887,16 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac return; } - // Calculate scale factor + // Scale the starting zoom by the finger-distance ratio (clamped). const scale = currentDistance / interactionNow.initialDistance; - let newZoom = interactionNow.initialZoom * scale; - - // Clamp zoom level - newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)); + const newZoom = pinchZoom(interactionNow.initialZoom, scale); - // Get current pinch center in screen coordinates, then convert to canvas - // coordinates at the NEW zoom level + // Get the current pinch center in screen coordinates, then convert to canvas + // coordinates at the NEW zoom level. The fixed model point (under the fingers + // when the pinch began) is re-anchored under that center. const currentCenter = getPinchCenter(); const currentCenterCanvas = getCanvasPointWithZoom(currentCenter.x, currentCenter.y, newZoom); - - // The pinchModelPoint is fixed in model space - it's the point that was - // under the user's fingers when the pinch started. We want that same - // model point to remain under the current screen center. - // newOffset = currentCenterCanvas - pinchModelPoint - const modelPoint = interactionNow.modelPoint; - const newOffset = { - x: currentCenterCanvas.x - modelPoint.x, - y: currentCenterCanvas.y - modelPoint.y, - }; + const newOffset = pinchOffset(currentCenterCanvas, interactionNow.modelPoint); const newViewBox = { ...latest.current.props.view.viewBox, @@ -996,32 +913,18 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac const zoom = latest.current.props.view.zoom; const viewBox = latest.current.props.view.viewBox; - // Convert wheel delta to canvas coordinates - // deltaMode: 0 = pixels, 1 = lines, 2 = pages - let deltaX = e.deltaX; - let deltaY = e.deltaY; - - if (e.deltaMode === 1) { - // Lines - multiply by line height (typically ~16-20px) - deltaX *= 16; - deltaY *= 16; - } else if (e.deltaMode === 2) { - // Pages - use actual viewport dimensions from DOM, not stored viewBox - // which may be stale during resize transitions - const viewportWidth = svgRef.current?.clientWidth ?? viewBox.width; - const viewportHeight = svgRef.current?.clientHeight ?? viewBox.height; - deltaX *= viewportWidth; - deltaY *= viewportHeight; - } - - // Scale delta by zoom level (inverse because higher zoom = smaller view area) - deltaX /= zoom; - deltaY /= zoom; + // Page deltas (deltaMode 2) scroll a full viewport; measure it from the DOM + // since the stored viewBox size may be stale during a resize transition. + const viewportPx = { + width: svgRef.current?.clientWidth ?? viewBox.width, + height: svgRef.current?.clientHeight ?? viewBox.height, + }; + const newOffset = wheelPanOffset(viewBox, { x: e.deltaX, y: e.deltaY, mode: e.deltaMode }, zoom, viewportPx); const newViewBox = { ...viewBox, - x: viewBox.x - deltaX, - y: viewBox.y - deltaY, + x: newOffset.x, + y: newOffset.y, }; latest.current.props.onViewBoxChange(newViewBox, zoom); @@ -1032,33 +935,19 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac const handleNativeWheelZoom = (e: WheelEvent): void => { const zoom = latest.current.props.view.zoom; - // Exponential scaling: deltaY of PINCH_ZOOM_DIVISOR results in 2x zoom change. - // Negative deltaY = pinch out = zoom in, so we negate to get correct direction. - const scale = Math.pow(2, -e.deltaY / PINCH_ZOOM_DIVISOR); - let newZoom = zoom * scale; - - // Clamp zoom level - newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)); - - // Use epsilon comparison for floating point - if (Math.abs(newZoom - zoom) < 0.0001) { + // Exponential scaling (negative deltaY = pinch out = zoom in), clamped, with + // an epsilon no-op at the zoom limits. + const { zoom: newZoom, changed } = wheelZoom(zoom, e.deltaY); + if (!changed) { return; } - // Get cursor position in canvas coordinates + // Keep the model point under the cursor fixed across the zoom change: map the + // same screen pixel into canvas space at both the old and new zoom. const cursorCanvas = getCanvasPoint(e.clientX, e.clientY); const viewBox = latest.current.props.view.viewBox; - - // Calculate the point under cursor in model coordinates - const modelX = cursorCanvas.x - viewBox.x; - const modelY = cursorCanvas.y - viewBox.y; - - // Calculate new offset to keep the point under cursor stable const newCursorCanvas = getCanvasPointWithZoom(e.clientX, e.clientY, newZoom); - const newOffset = { - x: newCursorCanvas.x - modelX, - y: newCursorCanvas.y - modelY, - }; + const newOffset = zoomAroundPoint(viewBox, cursorCanvas, newCursorCanvas); const newViewBox = { ...viewBox, @@ -1124,14 +1013,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac if (oldSize) { const dWidth = contentRect.width - oldSize.width; const dHeight = contentRect.height - oldSize.height; - const canvasOffset = getCanvasOffset(); - - const newViewBox: ViewRect = { - x: canvasOffset.x + dWidth / 4, - y: canvasOffset.y + dHeight / 4, - width: contentRect.width, - height: contentRect.height, - }; + const newViewBox = resizeViewBox(getCanvasOffset(), dWidth, dHeight, contentRect.width, contentRect.height); latest.current.props.onViewBoxChange(newViewBox, latest.current.props.view.zoom); } diff --git a/src/diagram/drawing/viewport.ts b/src/diagram/drawing/viewport.ts new file mode 100644 index 000000000..b68fadc5a --- /dev/null +++ b/src/diagram/drawing/viewport.ts @@ -0,0 +1,238 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Pure viewport math for the canvas (the functional core extracted from + * `Canvas.tsx`). Every function here is a pure transform over plain numbers: + * given an already-resolved canvas-space point and the current viewport, it + * returns the next offset/zoom. The DOM-bound parts -- mapping a screen + * (clientX/Y) point into canvas space via `getBoundingClientRect` + + * `screenToCanvasPoint`, the rAF loop, the debounce timer, and React state -- + * stay in the Canvas shell, which resolves screen->canvas and then calls these. + * + * Keeping the arithmetic here makes the pan/zoom/pinch/momentum behavior unit + * testable without jsdom and keeps the shell focused on wiring and lifecycle. + */ + +import type { Point } from './common'; +import type { Rect as ViewRect } from '@simlin/core/datamodel'; + +// --- physics / interaction constants ------------------------------------- + +// Momentum scrolling physics for macOS-native feel. macOS apps (Finder, +// Safari, Maps) have snappier deceleration than iOS. A friction coefficient of +// 0.05 means velocity retains 5% after 1 second, giving a ~0.5-0.8s coast. +export const FRICTION_COEFFICIENT = 0.05; +export const FRICTION_LOG = Math.log(FRICTION_COEFFICIENT); // ~= -3.0 + +// Stop momentum when velocity drops below this threshold. At 60fps, 15 px/s = +// 0.25 px/frame -- imperceptible motion. Lower values make the stop feel more +// gradual and natural. +export const VELOCITY_THRESHOLD = 15; + +// Pinch/wheel zoom uses exponential scaling for a natural feel. A divisor of +// 100 means a cumulative deltaY of ~100 results in a 2x zoom change, matching +// native macOS apps like Maps and Preview. +export const PINCH_ZOOM_DIVISOR = 100; + +// MIN_ZOOM matches the 0.2 floor used in the render transform (which clamps +// zoom < 0.2 to 1.0); keeping the state floor and the render floor identical +// avoids a mismatch between stored view state and what is actually drawn. +export const MIN_ZOOM = 0.2; +export const MAX_ZOOM = 5.0; + +// A wheel-zoom step below this delta is treated as a no-op so floating-point +// noise at the zoom clamps doesn't churn the viewport. +const ZOOM_EPSILON = 0.0001; + +/** A timestamped pointer sample used for momentum velocity estimation. */ +export interface VelocitySample { + x: number; + y: number; + timestamp: number; +} + +/** Clamp a zoom value into the supported [MIN_ZOOM, MAX_ZOOM] range. */ +export function clampZoom(zoom: number): number { + return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); +} + +// --- wheel pan ----------------------------------------------------------- + +/** + * The new canvas offset after a wheel/trackpad pan. `delta.mode` is the native + * `WheelEvent.deltaMode` (0 = pixels, 1 = lines, 2 = pages); line and page + * deltas are resolved to pixels (pages use the live viewport size, which the + * shell measures from the DOM since the stored size may be stale mid-resize). + * The delta is divided by `zoom` because a higher zoom means a smaller visible + * model area, so a given screen delta covers fewer model units. Dragging the + * surface down/right moves the content the same way, hence the offset moves + * opposite the wheel delta. + */ +export function wheelPanOffset( + base: Point, + delta: { x: number; y: number; mode: number }, + zoom: number, + viewportPx: { width: number; height: number }, +): Point { + let deltaX = delta.x; + let deltaY = delta.y; + + if (delta.mode === 1) { + // Lines -- multiply by an approximate line height. + deltaX *= 16; + deltaY *= 16; + } else if (delta.mode === 2) { + // Pages -- one notch scrolls a full viewport. + deltaX *= viewportPx.width; + deltaY *= viewportPx.height; + } + + deltaX /= zoom; + deltaY /= zoom; + + return { + x: base.x - deltaX, + y: base.y - deltaY, + }; +} + +// --- wheel / pinch zoom -------------------------------------------------- + +/** + * Exponential wheel zoom: a `deltaY` of `PINCH_ZOOM_DIVISOR` halves/doubles the + * zoom, so zooming in then out by equal deltas returns to the original level. + * Negative `deltaY` (pinch out) zooms in. The result is clamped; `changed` is + * false when the clamped delta is within `ZOOM_EPSILON` so the caller can skip a + * no-op update at the zoom limits. + */ +export function wheelZoom(currentZoom: number, deltaY: number): { zoom: number; changed: boolean } { + const scale = Math.pow(2, -deltaY / PINCH_ZOOM_DIVISOR); + const zoom = clampZoom(currentZoom * scale); + return { zoom, changed: Math.abs(zoom - currentZoom) >= ZOOM_EPSILON }; +} + +/** + * The offset that keeps a fixed model point under the cursor across a zoom + * change. `cursorCanvasOld`/`cursorCanvasNew` are the same screen position + * mapped into canvas space at the old and new zoom respectively (the shell does + * those DOM-bound conversions). The model point under the cursor is + * `cursorCanvasOld - oldOffset`; after zooming we re-anchor that same model + * point under the (re-measured) cursor. + */ +export function zoomAroundPoint(oldOffset: Point, cursorCanvasOld: Point, cursorCanvasNew: Point): Point { + const modelX = cursorCanvasOld.x - oldOffset.x; + const modelY = cursorCanvasOld.y - oldOffset.y; + return { + x: cursorCanvasNew.x - modelX, + y: cursorCanvasNew.y - modelY, + }; +} + +/** Pinch zoom: scale the starting zoom by the finger-distance ratio, clamped. */ +export function pinchZoom(initialZoom: number, scale: number): number { + return clampZoom(initialZoom * scale); +} + +/** + * The offset that keeps `modelPoint` (the model point under the fingers when the + * pinch began) under the current pinch center. `centerCanvasNew` is the pinch + * center mapped into canvas space at the new zoom (resolved by the shell). + */ +export function pinchOffset(centerCanvasNew: Point, modelPoint: Point): Point { + return { + x: centerCanvasNew.x - modelPoint.x, + y: centerCanvasNew.y - modelPoint.y, + }; +} + +// --- momentum ------------------------------------------------------------ + +/** + * Flutter-style friction simulation: displacement at time `t` for an initial + * velocity `v0`. `x(t) - x0 = v0 * (friction^t - 1) / ln(friction)`. + */ +export function frictionPosition(velocity: number, time: number): number { + return (velocity * (Math.pow(FRICTION_COEFFICIENT, time) - 1)) / FRICTION_LOG; +} + +/** Velocity at time `t`: `v(t) = v0 * friction^t`. */ +export function frictionVelocity(velocity: number, time: number): number { + return velocity * Math.pow(FRICTION_COEFFICIENT, time); +} + +/** The momentum-decayed offset at `elapsedSec` after release. */ +export function momentumOffsetAt(startOffset: Point, v0: Point, elapsedSec: number): Point { + return { + x: startOffset.x + frictionPosition(v0.x, elapsedSec), + y: startOffset.y + frictionPosition(v0.y, elapsedSec), + }; +} + +/** True once the decayed momentum speed has dropped below `VELOCITY_THRESHOLD`. */ +export function isMomentumDone(v0: Point, elapsedSec: number): boolean { + const vx = frictionVelocity(v0.x, elapsedSec); + const vy = frictionVelocity(v0.y, elapsedSec); + return Math.hypot(vx, vy) < VELOCITY_THRESHOLD; +} + +/** + * Estimate release velocity (px/s) from recent pointer samples. Returns zero -- + * an intentional stop, no momentum -- when there are too few samples or the + * pointer was stationary for >40ms before release (~2.5 frames at 60fps, enough + * to distinguish a deliberate stop from a quick flick-and-release). Otherwise + * averages over the last 100ms of samples, falling back to the final two. + */ +export function calculateVelocity(positions: readonly VelocitySample[], now: number): Point { + if (positions.length < 2) { + return { x: 0, y: 0 }; + } + + const lastPosition = positions[positions.length - 1]; + if (now - lastPosition.timestamp > 40) { + return { x: 0, y: 0 }; + } + + const recentPositions = positions.filter((p) => now - p.timestamp < 100); + + if (recentPositions.length < 2) { + const lastP = positions[positions.length - 1]; + const prev = positions[positions.length - 2]; + const dt = (lastP.timestamp - prev.timestamp) / 1000; + if (dt <= 0) { + return { x: 0, y: 0 }; + } + return { + x: (lastP.x - prev.x) / dt, + y: (lastP.y - prev.y) / dt, + }; + } + + const firstP = recentPositions[0]; + const lastP = recentPositions[recentPositions.length - 1]; + const dt = (lastP.timestamp - firstP.timestamp) / 1000; + if (dt <= 0) { + return { x: 0, y: 0 }; + } + return { + x: (lastP.x - firstP.x) / dt, + y: (lastP.y - firstP.y) / dt, + }; +} + +// --- resize -------------------------------------------------------------- + +/** + * The viewBox after the canvas element resizes by (`dWidth`, `dHeight`) to the + * new (`width`, `height`). The offset shifts by a quarter of the delta so the + * content stays roughly centered as the surface grows/shrinks. + */ +export function resizeViewBox(offset: Point, dWidth: number, dHeight: number, width: number, height: number): ViewRect { + return { + x: offset.x + dWidth / 4, + y: offset.y + dHeight / 4, + width, + height, + }; +} diff --git a/src/diagram/tests/viewport.test.ts b/src/diagram/tests/viewport.test.ts new file mode 100644 index 000000000..fefc70ee0 --- /dev/null +++ b/src/diagram/tests/viewport.test.ts @@ -0,0 +1,198 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Unit tests for the pure viewport math (drawing/viewport.ts). No jsdom: every +// function takes already-resolved canvas-space numbers, so the behavior of +// pan/zoom/pinch/momentum is exercised here without the DOM. + +import { + MAX_ZOOM, + MIN_ZOOM, + PINCH_ZOOM_DIVISOR, + VELOCITY_THRESHOLD, + calculateVelocity, + clampZoom, + frictionPosition, + frictionVelocity, + isMomentumDone, + momentumOffsetAt, + pinchOffset, + pinchZoom, + resizeViewBox, + wheelPanOffset, + wheelZoom, + zoomAroundPoint, +} from '../drawing/viewport'; + +describe('clampZoom', () => { + it('clamps to the supported range and passes through in-range values', () => { + expect(clampZoom(MIN_ZOOM - 1)).toBe(MIN_ZOOM); + expect(clampZoom(MAX_ZOOM + 1)).toBe(MAX_ZOOM); + expect(clampZoom(1)).toBe(1); + }); +}); + +describe('wheelPanOffset', () => { + const base = { x: 100, y: 200 }; + const viewportPx = { width: 800, height: 600 }; + + it('subtracts a pixel delta scaled by zoom (zoom 1)', () => { + expect(wheelPanOffset(base, { x: 30, y: -40, mode: 0 }, 1, viewportPx)).toEqual({ x: 70, y: 240 }); + }); + + it('divides the delta by zoom so higher zoom pans less in model units', () => { + expect(wheelPanOffset(base, { x: 40, y: 0, mode: 0 }, 2, viewportPx)).toEqual({ x: 80, y: 200 }); + }); + + it('resolves line deltas (mode 1) at ~16px per line', () => { + expect(wheelPanOffset(base, { x: 1, y: 2, mode: 1 }, 1, viewportPx)).toEqual({ x: 100 - 16, y: 200 - 32 }); + }); + + it('resolves page deltas (mode 2) using the viewport size', () => { + expect(wheelPanOffset(base, { x: 1, y: -1, mode: 2 }, 1, viewportPx)).toEqual({ + x: base.x - viewportPx.width, + y: base.y + viewportPx.height, + }); + }); +}); + +describe('wheelZoom', () => { + it('halves the zoom for a +divisor deltaY and doubles for -divisor', () => { + expect(wheelZoom(1, PINCH_ZOOM_DIVISOR).zoom).toBeCloseTo(0.5, 6); + expect(wheelZoom(1, -PINCH_ZOOM_DIVISOR).zoom).toBeCloseTo(2, 6); + }); + + it('is symmetric: zoom in then out by equal deltas returns to start', () => { + const inZoom = wheelZoom(1, -50).zoom; + const out = wheelZoom(inZoom, 50).zoom; + expect(out).toBeCloseTo(1, 6); + }); + + it('clamps and reports no change at the zoom ceiling', () => { + const result = wheelZoom(MAX_ZOOM, -PINCH_ZOOM_DIVISOR); + expect(result.zoom).toBe(MAX_ZOOM); + expect(result.changed).toBe(false); + }); + + it('reports a change for an in-range step', () => { + expect(wheelZoom(1, -10).changed).toBe(true); + }); +}); + +describe('zoomAroundPoint', () => { + it('keeps the model point under the cursor fixed across a zoom change', () => { + const oldOffset = { x: 50, y: 50 }; + // At zoom 1 the cursor sits at canvas (200, 150) -> model (150, 100). + const cursorCanvasOld = { x: 200, y: 150 }; + // At a higher zoom the same screen pixel maps to a different canvas point. + const cursorCanvasNew = { x: 100, y: 75 }; + const newOffset = zoomAroundPoint(oldOffset, cursorCanvasOld, cursorCanvasNew); + // The model point under the cursor must be unchanged: cursorNew - newOffset. + expect(cursorCanvasNew.x - newOffset.x).toBeCloseTo(cursorCanvasOld.x - oldOffset.x, 6); + expect(cursorCanvasNew.y - newOffset.y).toBeCloseTo(cursorCanvasOld.y - oldOffset.y, 6); + }); +}); + +describe('pinchZoom / pinchOffset', () => { + it('scales the initial zoom by the finger-distance ratio, clamped', () => { + expect(pinchZoom(1, 2)).toBe(2); + expect(pinchZoom(1, 100)).toBe(MAX_ZOOM); + expect(pinchZoom(1, 0.01)).toBe(MIN_ZOOM); + }); + + it('is symmetric: spreading then pinching back returns to the start zoom', () => { + expect(pinchZoom(pinchZoom(1, 2), 0.5)).toBeCloseTo(1, 6); + }); + + it('places the offset so the model point sits under the pinch center', () => { + const center = { x: 300, y: 200 }; + const modelPoint = { x: 120, y: 80 }; + const offset = pinchOffset(center, modelPoint); + expect(center.x - offset.x).toBeCloseTo(modelPoint.x, 6); + expect(center.y - offset.y).toBeCloseTo(modelPoint.y, 6); + }); +}); + +describe('momentum friction', () => { + it('decays velocity monotonically toward zero', () => { + const v0 = 1000; + const v1 = frictionVelocity(v0, 0.1); + const v2 = frictionVelocity(v0, 0.5); + expect(v1).toBeLessThan(v0); + expect(v2).toBeLessThan(v1); + expect(frictionVelocity(v0, 0)).toBe(v0); + }); + + it('accumulates displacement monotonically in the direction of travel', () => { + const d1 = frictionPosition(1000, 0.1); + const d2 = frictionPosition(1000, 0.5); + expect(d1).toBeGreaterThan(0); + expect(d2).toBeGreaterThan(d1); + expect(frictionPosition(1000, 0)).toBeCloseTo(0, 10); + }); + + it('offsets from the start position by the decayed displacement', () => { + const start = { x: 10, y: 20 }; + const v0 = { x: 500, y: -300 }; + const at = momentumOffsetAt(start, v0, 0.2); + expect(at.x).toBeCloseTo(start.x + frictionPosition(v0.x, 0.2), 9); + expect(at.y).toBeCloseTo(start.y + frictionPosition(v0.y, 0.2), 9); + }); + + it('reports done once the decayed speed drops below the threshold', () => { + const v0 = { x: VELOCITY_THRESHOLD * 4, y: 0 }; + expect(isMomentumDone(v0, 0)).toBe(false); + // Friction retains 5%/s, so after enough time the speed is below threshold. + expect(isMomentumDone(v0, 2)).toBe(true); + }); +}); + +describe('calculateVelocity', () => { + it('returns zero with fewer than two samples', () => { + expect(calculateVelocity([], 100)).toEqual({ x: 0, y: 0 }); + expect(calculateVelocity([{ x: 0, y: 0, timestamp: 0 }], 100)).toEqual({ x: 0, y: 0 }); + }); + + it('returns zero when the pointer was stationary (>40ms) before release', () => { + const positions = [ + { x: 0, y: 0, timestamp: 0 }, + { x: 100, y: 0, timestamp: 50 }, + ]; + // now is 60ms after the last sample -> intentional stop. + expect(calculateVelocity(positions, 110)).toEqual({ x: 0, y: 0 }); + }); + + it('averages px/s over the recent (<100ms) samples', () => { + const positions = [ + { x: 0, y: 0, timestamp: 0 }, + { x: 50, y: 25, timestamp: 50 }, + { x: 100, y: 50, timestamp: 100 }, + ]; + // now == 100: all samples within 100ms; 100px over 0.1s = 1000 px/s. + expect(calculateVelocity(positions, 100)).toEqual({ x: 1000, y: 500 }); + }); + + it('falls back to the last two samples when only one is recent', () => { + const positions = [ + { x: 0, y: 0, timestamp: 0 }, + { x: 20, y: 10, timestamp: 130 }, + ]; + // now == 140: only the last sample is <100ms old, so recentPositions has 1. + // Fallback uses the final two: 20px over 0.13s. + const v = calculateVelocity(positions, 140); + expect(v.x).toBeCloseTo(20 / 0.13, 6); + expect(v.y).toBeCloseTo(10 / 0.13, 6); + }); +}); + +describe('resizeViewBox', () => { + it('shifts the offset by a quarter of the size delta and adopts the new size', () => { + expect(resizeViewBox({ x: 100, y: 200 }, 40, -20, 840, 580)).toEqual({ + x: 110, + y: 195, + width: 840, + height: 580, + }); + }); +}); From b370b37bee9c0c6f9daae78c0cf162a9f4b57bf6 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 15:42:30 -0700 Subject: [PATCH 02/11] diagram: unify canvas viewport state into a live viewport Replace the offset-only `movingCanvasOffset` local state with a unified `liveViewport {x, y, zoom}`. Offset and zoom now both resolve from the live viewport while a gesture is in flight (via getCanvasOffset/getCanvasZoom) and from props.view otherwise. This gives pinch and wheel-zoom a local home for the in-progress zoom, which they previously lacked -- the prerequisite for making those paths fully local. No behavior change: drag-pan still commits on pointer-up, and since a pan never changes zoom the live zoom equals props.view.zoom throughout. Part of the issue #707 work. --- src/diagram/drawing/Canvas.tsx | 54 ++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index 7f0234772..3ca8f72e1 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -305,6 +305,19 @@ interface CanvasRefs { momentumStartOffset: Point | undefined; } +// The local "live viewport" the canvas owns DURING a gesture (pan, momentum, +// wheel, pinch, resize). While set, the render transform and all gesture math +// read offset+zoom from here instead of `props.view`, so a multi-event gesture +// stays fully local and only notifies the controller once, on settle. `undefined` +// means "no gesture in flight -- read from props.view". It carries zoom as well +// as offset because pinch/wheel-zoom change zoom mid-gesture and there is +// otherwise no local home for it. +interface LiveViewport { + x: number; + y: number; + zoom: number; +} + // The snapshot of props + discrete/continuous state that event-time readers // (native wheel/gesture listeners, the momentum rAF loop, the ResizeObserver, // the deferred tool-change commit) must see CURRENT, not as captured by a stale @@ -316,7 +329,7 @@ interface LatestState { editingName: Array; dragSelectionPoint: Point | undefined; moveDelta: Point | undefined; - movingCanvasOffset: Point | undefined; + liveViewport: LiveViewport | undefined; svgSize: Readonly<{ width: number; height: number }> | undefined; inCreation: ViewElement | undefined; inCreationCloud: CloudViewElement | undefined; @@ -338,7 +351,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac const [editingName, setEditingName] = React.useState>([]); const [dragSelectionPoint, setDragSelectionPoint] = React.useState(undefined); const [moveDelta, setMoveDelta] = React.useState(undefined); - const [movingCanvasOffset, setMovingCanvasOffset] = React.useState(undefined); + const [liveViewport, setLiveViewport] = React.useState(undefined); const [initialBounds, setInitialBounds] = React.useState(viewRectDefault); const [svgSize, setSvgSize] = React.useState | undefined>(undefined); const [inCreation, setInCreation] = React.useState(undefined); @@ -392,7 +405,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac editingName, dragSelectionPoint, moveDelta, - movingCanvasOffset, + liveViewport, svgSize, inCreation, inCreationCloud, @@ -470,7 +483,13 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac setInCreationCloud(reset.inCreationCloud); }; - const getCanvasOffset = (): Readonly => latest.current.movingCanvasOffset ?? latest.current.props.view.viewBox; + // Offset/zoom resolve from the live viewport while a gesture is in flight, + // else from props.view. Every gesture-math and render read goes through these + // so a live gesture never has to round-trip through the controller to see its + // own in-progress viewport. + const getCanvasOffset = (): Readonly => latest.current.liveViewport ?? latest.current.props.view.viewBox; + + const getCanvasZoom = (): number => latest.current.liveViewport?.zoom ?? latest.current.props.view.zoom; const getElementByUid = (uid: UID): ViewElement => { let element: ViewElement | undefined; @@ -492,7 +511,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac x -= bounds.x; y -= bounds.y; } - return screenToCanvasPoint(x, y, latest.current.props.view.zoom); + return screenToCanvasPoint(x, y, getCanvasZoom()); }; // Helper to get canvas point with a specific zoom level @@ -1244,15 +1263,16 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac return; } - if (interactionNow.mode === 'panning' && latest.current.movingCanvasOffset) { + if (interactionNow.mode === 'panning' && latest.current.liveViewport) { + const live = latest.current.liveViewport; const newViewBox = { ...latest.current.props.view.viewBox, - x: latest.current.movingCanvasOffset.x, - y: latest.current.movingCanvasOffset.y, + x: live.x, + y: live.y, }; - latest.current.props.onViewBoxChange(newViewBox, latest.current.props.view.zoom); - setMovingCanvasOffset(undefined); + latest.current.props.onViewBoxChange(newViewBox, live.zoom); + setLiveViewport(undefined); // Start momentum animation for smooth deceleration startMomentumAnimation(); @@ -1332,9 +1352,10 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac trackPosition(newOffset.x, newOffset.y); // The panning mode was already entered on pointer-down; re-affirm it (it is - // the move-guard in handlePointerMove) alongside the continuous offset. + // the move-guard in handlePointerMove) alongside the continuous offset. A pan + // does not change zoom, so the live viewport keeps the current zoom. setInteraction({ mode: 'panning' }); - setMovingCanvasOffset(newOffset); + setLiveViewport({ x: newOffset.x, y: newOffset.y, zoom: getCanvasZoom() }); }; const handleDragSelection = (e: React.PointerEvent): void => { @@ -1428,7 +1449,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // Entering pinch mode supersedes any single-finger panning/dragSelecting // mode; the reducer returns the pinching variant carrying the fixed - // reference. Clear movingCanvasOffset so exiting pinch can't start momentum. + // reference. Clear the live viewport so exiting pinch can't start momentum. const { state: nextInteraction, effects } = reduceInteraction( latest.current.interaction, { @@ -1441,7 +1462,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac ); runEffects(effects, e.target as Element | undefined, e.pointerId); setInteraction(nextInteraction); - setMovingCanvasOffset(undefined); + setLiveViewport(undefined); return; } @@ -2384,7 +2405,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac if (!isEditingNameNow || props.selection.size === 0) { overlayClass += ' ' + styles.noPointerEvents; } else { - const zoom = props.view.zoom; + const zoom = getCanvasZoom(); const editingUid = only(props.selection); const editingElement = getElementByUid(editingUid) as NamedViewElement; const { rw, rh } = labelRadii(editingElement.type); @@ -2420,7 +2441,8 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac viewBox = `${left} ${top} ${width} ${height}`; } } else { - const zoom = props.view.zoom >= 0.2 ? props.view.zoom : 1; + const liveZoom = getCanvasZoom(); + const zoom = liveZoom >= 0.2 ? liveZoom : 1; const offset = getCanvasOffset(); transform = `matrix(${zoom} 0 0 ${zoom} ${offset.x * zoom} ${offset.y * zoom})`; From 8dbede037c6d9f982cb52b5c433e67eb90dca475 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 15:51:57 -0700 Subject: [PATCH 03/11] diagram: make momentum coast local, committing once on settle The momentum rAF loop now writes the local live viewport each frame instead of round-tripping every frame through the controller (the per-frame engine serialize/re-parse that issue #707 is about). A coasted pan commits exactly once, at the coast's natural end; startMomentumAnimation returns whether a coast actually started so pointer-up commits immediately only for a stationary release. The two paths are mutually exclusive, so a gesture commits once. Also anchor each drag-pan against the offset captured at press time (refs.panBaseOffset) rather than props.view.viewBox, so a pan that interrupts an in-flight coast (whose offset is not yet committed) continues from the on-screen position instead of snapping back to the last committed viewBox. Adds deterministic clock/rAF control to the gesture harness (velocity estimation and the coast both read the clock) plus a live-transform accessor, and covers: single-commit-on-settle, live transform during the coast, and the interrupted-coast -> pan continuity. Part of the issue #707 work. --- src/diagram/drawing/Canvas.tsx | 88 +++++--- src/diagram/tests/canvas-gesture-harness.tsx | 122 +++++++++++ .../tests/canvas-gestures-pan-zoom.test.tsx | 198 ++++++++++++++---- 3 files changed, 345 insertions(+), 63 deletions(-) diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index 3ca8f72e1..e235c59d8 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -297,6 +297,13 @@ interface CanvasRefs { // Multi-touch tracking for pinch gestures activePointers: Map; + // The canvas offset captured when a drag-pan begins. handleMovingCanvas + // anchors each move against this rather than props.view.viewBox, so a pan that + // interrupts an in-flight momentum coast (whose offset has not been committed + // back to props.view) starts from the on-screen position instead of jumping + // back to the last committed viewBox. + panBaseOffset: Point | undefined; + // Momentum/inertia animation velocityTracker: VelocityTracker; momentumAnimationId: number | undefined; @@ -380,6 +387,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac draggedLinkArc: undefined, }, activePointers: new Map(), + panBaseOffset: undefined, velocityTracker: { positions: [] }, momentumAnimationId: undefined, momentumStartTime: undefined, @@ -491,6 +499,27 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac const getCanvasZoom = (): number => latest.current.liveViewport?.zoom ?? latest.current.props.view.zoom; + // Push the live viewport to the controller exactly once and clear it. This is + // the single settle-time commit shared by every gesture tail (pan release with + // no momentum, momentum end, wheel debounce, pinch exit). Clearing the live + // state in the same synchronous stretch as onViewBoxChange -- whose controller + // path applies the optimistic view synchronously -- keeps props.view and the + // cleared live state consistent in one React commit, so the diagram does not + // snap back. A no-op when nothing is live. + const commitLiveViewport = (): void => { + const live = latest.current.liveViewport; + if (!live) { + return; + } + const newViewBox = { + ...latest.current.props.view.viewBox, + x: live.x, + y: live.y, + }; + latest.current.props.onViewBoxChange(newViewBox, live.zoom); + setLiveViewport(undefined); + }; + const getElementByUid = (uid: UID): ViewElement => { let element: ViewElement | undefined; if (uid === inCreationUid) { @@ -812,40 +841,42 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac const elapsed = (timestamp - r.momentumStartTime) / 1000; // seconds const v0 = r.momentumInitialVelocity; - // Stop when the decayed momentum speed drops below threshold. + // Natural end: the decayed speed dropped below threshold. This is the single + // commit point for a coasted pan -- push the final live viewport once, then + // stop. (An interruption, by contrast, stops without committing and lets the + // interrupting gesture inherit the live viewport.) if (isMomentumDone(v0, elapsed)) { + commitLiveViewport(); stopMomentumAnimation(); return; } // Note: the friction displacement is ADDED because a higher offset moves the // view in the positive direction, while velocity is in screen coordinates - // where dragging right should move the view left. + // where dragging right should move the view left. The coasted offset is held + // in the live viewport (immediate render) -- no per-frame controller + // round-trip; that is the whole point of issue #707. const newOffset = momentumOffsetAt(r.momentumStartOffset, v0, elapsed); - - // Update viewBox with new offset - const newViewBox = { - ...latest.current.props.view.viewBox, - x: newOffset.x, - y: newOffset.y, - }; - latest.current.props.onViewBoxChange(newViewBox, latest.current.props.view.zoom); + setLiveViewport({ x: newOffset.x, y: newOffset.y, zoom: getCanvasZoom() }); // Continue animation r.momentumAnimationId = window.requestAnimationFrame(animateMomentum); }; - // Start momentum animation after pan release - const startMomentumAnimation = (): void => { + // Start a momentum coast after pan release. Returns whether a coast actually + // started: the caller commits the pan immediately when it did NOT (a stationary + // release), and defers the single commit to the coast's natural end when it + // did. The two are mutually exclusive, so a gesture commits exactly once. + const startMomentumAnimation = (): boolean => { // Cancel any existing momentum animation first (defensive) stopMomentumAnimation(); const velocity = calculateVelocity(); - const speed = Math.sqrt(velocity.x * velocity.x + velocity.y * velocity.y); + const speed = Math.hypot(velocity.x, velocity.y); // Don't start animation if velocity is at or below threshold if (speed <= VELOCITY_THRESHOLD) { - return; + return false; } r.momentumInitialVelocity = velocity; @@ -853,6 +884,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac r.momentumStartTime = window.performance.now(); r.momentumAnimationId = window.requestAnimationFrame(animateMomentum); + return true; }; // Track position for velocity calculation during pan @@ -1046,6 +1078,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac r.pointerId = undefined; r.mouseDownPoint = undefined; r.selectionCenterOffset = undefined; + r.panBaseOffset = undefined; applyPointerStateReset(); @@ -1264,18 +1297,14 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac } if (interactionNow.mode === 'panning' && latest.current.liveViewport) { - const live = latest.current.liveViewport; - const newViewBox = { - ...latest.current.props.view.viewBox, - x: live.x, - y: live.y, - }; - - latest.current.props.onViewBoxChange(newViewBox, live.zoom); - setLiveViewport(undefined); - - // Start momentum animation for smooth deceleration - startMomentumAnimation(); + // Start the momentum coast first. If it starts, the live viewport stays set + // and the single commit is deferred to the coast's natural end; if it does + // not (a stationary release), commit the pan now. Exactly one commit either + // way. + const didStartMomentum = startMomentumAnimation(); + if (!didStartMomentum) { + commitLiveViewport(); + } } if (!r.mouseDownPoint) { @@ -1340,7 +1369,9 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac return; } - const base = latest.current.props.view.viewBox; + // Anchor against the offset captured at pan start (see refs.panBaseOffset), + // not props.view.viewBox, so an interrupted-momentum -> pan does not jump. + const base = r.panBaseOffset ?? latest.current.props.view.viewBox; const curr = getCanvasPoint(e.clientX, e.clientY); const newOffset = { @@ -1618,6 +1649,9 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // Initialize velocity tracking for momentum r.velocityTracker.positions = []; const canvasOffsetPan = getCanvasOffset(); + // Anchor the pan against the on-screen offset at press time (= the live + // viewport if a momentum coast was interrupted, else props.view.viewBox). + r.panBaseOffset = { x: canvasOffsetPan.x, y: canvasOffsetPan.y }; trackPosition(canvasOffsetPan.x, canvasOffsetPan.y); } // The pan-vs-drag-select mode came from the reducer; the in-creation diff --git a/src/diagram/tests/canvas-gesture-harness.tsx b/src/diagram/tests/canvas-gesture-harness.tsx index 411b5df8e..74f6ee5be 100644 --- a/src/diagram/tests/canvas-gesture-harness.tsx +++ b/src/diagram/tests/canvas-gesture-harness.tsx @@ -407,6 +407,13 @@ export interface CanvasHarness { * calls. */ clearMountCalls: () => void; + /** + * The current `transform` attribute on the canvas content `` -- the live + * viewport (offset+zoom) the user actually sees. Use this to assert that a + * gesture updates the view immediately, without (yet) committing through + * `onViewBoxChange`. + */ + getTransform: () => string | null; } /** @@ -493,9 +500,124 @@ export function renderCanvas(opts: HarnessOptions): CanvasHarness { fn.mockClear(); } }, + getTransform: () => result.container.querySelector('svg g[transform]')?.getAttribute('transform') ?? null, + }; +} + +// --------------------------------------------------------------------------- +// Deterministic clock + animation-frame control +// --------------------------------------------------------------------------- + +/** + * Controls `window.performance.now`, `requestAnimationFrame`, and + * `cancelAnimationFrame` so momentum (an rAF loop) and velocity estimation + * (which reads the clock) are deterministic. Opt-in per test: install before the + * gesture, `restore()` in a finally/afterEach. Tests that don't install it keep + * jsdom's real timers, so the momentum loop never fires (the historical default). + * + * - `tick(ms)` advances virtual time WITHOUT running frames -- use it between + * pointer events to set their timestamps (e.g. tick past 40ms before release + * to model a deliberate, non-flick stop that starts no momentum). + * - `frame(ms)` advances time and runs the currently-pending rAF callbacks once. + * - `flush()` runs frames until the rAF queue drains (momentum coasts to its + * natural end), bounded by `maxFrames`. + */ +export interface FakeClock { + tick: (ms: number) => void; + frame: (ms?: number) => void; + flush: (maxFrames?: number, ms?: number) => void; + now: () => number; + restore: () => void; +} + +export function installFakeClock(start = 1000): FakeClock { + const origRaf = window.requestAnimationFrame; + const origCancel = window.cancelAnimationFrame; + const perf = window.performance; + const origNow = perf.now.bind(perf); + + let now = start; + let nextId = 1; + const pending = new Map(); + + window.requestAnimationFrame = ((cb: FrameRequestCallback): number => { + const id = nextId++; + pending.set(id, cb); + return id; + }) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((id: number): void => { + pending.delete(id); + }) as typeof window.cancelAnimationFrame; + Object.defineProperty(perf, 'now', { configurable: true, writable: true, value: () => now }); + + const runDue = (): void => { + const due = Array.from(pending.values()); + pending.clear(); + if (due.length === 0) { + return; + } + act(() => { + for (const cb of due) { + cb(now); + } + }); + }; + + return { + tick: (ms: number) => { + now += ms; + }, + frame: (ms = 16) => { + now += ms; + runDue(); + }, + flush: (maxFrames = 1000, ms = 16) => { + let i = 0; + while (pending.size > 0 && i < maxFrames) { + now += ms; + runDue(); + i++; + } + }, + now: () => now, + restore: () => { + window.requestAnimationFrame = origRaf; + window.cancelAnimationFrame = origCancel; + Object.defineProperty(perf, 'now', { configurable: true, writable: true, value: origNow }); + }, }; } +/** + * Dispatch a native `wheel` event (the canvas registers a non-passive native + * wheel listener on the ``, so React's synthetic `onWheel` would not reach + * it). Trackpad pinch-zoom arrives as a wheel event with `ctrlKey`/`metaKey`. + */ +export function dispatchWheel( + target: Element, + init: { + deltaX?: number; + deltaY?: number; + deltaMode?: number; + clientX?: number; + clientY?: number; + ctrlKey?: boolean; + metaKey?: boolean; + } = {}, +): void { + act(() => { + fireEvent.wheel(target, { + deltaX: init.deltaX ?? 0, + deltaY: init.deltaY ?? 0, + deltaMode: init.deltaMode ?? 0, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + ctrlKey: init.ctrlKey ?? false, + metaKey: init.metaKey ?? false, + }); + }); +} + // --------------------------------------------------------------------------- // Gesture dispatch helpers // --------------------------------------------------------------------------- diff --git a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx index 8455de578..c290b6f0b 100644 --- a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx +++ b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx @@ -6,16 +6,20 @@ * Version 2.0, that can be found in the LICENSE file. */ -// Reconciler-level gesture tests for canvas pan and pinch-zoom of the React -// `Canvas` (Piece 1a; see -// docs/design-plans/2026-06-07-canvas-interaction-migration.md). The gestures -// end stationary so the momentum rAF loop does not fire (calculateVelocity -// returns zero once a frame is >40ms old, and only velocities above -// VELOCITY_THRESHOLD start momentum); each test asserts on the LAST -// onViewBoxChange call (the gesture's committed viewBox), tolerating the single -// mount-time fit call that clearMountCalls already drops. - -import { makeAux, pointerDown, pointerMove, pointerUp, renderCanvas } from './canvas-gesture-harness'; +// Reconciler-level gesture tests for canvas pan, pinch-zoom, and momentum of the +// React `Canvas` (Piece 1a; see +// docs/design-plans/2026-06-07-canvas-interaction-migration.md). Issue #707 made +// the viewport fully local during a gesture and commits to the controller +// (`onViewBoxChange`) exactly ONCE, on settle. These tests therefore assert two +// things: the committed viewBox at settle, and -- via the rendered `` +// transform -- that the view updates live BEFORE any commit. +// +// Timing is made deterministic with `installFakeClock`: velocity estimation and +// the momentum rAF loop both read the clock, so a "stationary" release is modeled +// by ticking past the 40ms stop window before pointer-up, and a flick by +// releasing immediately after a fast move. + +import { installFakeClock, makeAux, pointerDown, pointerMove, pointerUp, renderCanvas } from './canvas-gesture-harness'; interface ViewBoxCall { x: number; @@ -32,32 +36,148 @@ function lastViewBox(fn: jest.Mock): ViewBoxCall | undefined { return { x: last[0].x, y: last[0].y, zoom: last[1] }; } +// Parse the translate (e, f) out of `matrix(z 0 0 z e f)`. With zoom 1 the +// translate IS the live offset, so this reads the on-screen viewport directly. +function translate(transform: string | null): { x: number; y: number; zoom: number } { + const m = /matrix\(([^)]+)\)/.exec(transform ?? ''); + if (!m) { + throw new Error(`no matrix in transform: ${transform}`); + } + const [a, , , , e, f] = m[1].split(/[\s,]+/).map(Number); + // matrix translate is offset * zoom, so divide back out to recover the offset. + return { x: e / a, y: f / a, zoom: a }; +} + describe('Canvas gestures: pan (checklist 3)', () => { it('shift-drag with a mouse pans the viewBox and does NOT clear the selection', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)], selection: new Set([10]) }); h.clearMountCalls(); + const clock = installFakeClock(); + try { + pointerDown(h.svg, 500, 500, { shiftKey: true }); + clock.tick(10); + pointerMove(h.svg, 530, 540, { shiftKey: true, buttons: 1 }); + // Hold past the 40ms stop window so the release starts no momentum: the + // pan commits immediately, exactly once. + clock.tick(50); + pointerUp(h.svg, 530, 540, { shiftKey: true }); + + // newOffset = viewBox(0,0) + (curr - mouseDown) = (30, 40); zoom unchanged. + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(lastViewBox(h.callbacks.onViewBoxChange)).toEqual({ x: 30, y: 40, zoom: 1 }); + // A pan must not clear the selection. + expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + } finally { + clock.restore(); + } + }); - pointerDown(h.svg, 500, 500, { shiftKey: true }); - pointerMove(h.svg, 530, 540, { shiftKey: true, buttons: 1 }); - pointerUp(h.svg, 530, 540, { shiftKey: true }); - - // newOffset = viewBox(0,0) + (curr - mouseDown) = (30, 40); zoom unchanged. - expect(lastViewBox(h.callbacks.onViewBoxChange)).toEqual({ x: 30, y: 40, zoom: 1 }); - // A pan must not clear the selection (handlePointerCancel uses - // clearSelection = !isMovingCanvas, ~line 1243). - expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + it('updates the live transform during the drag before committing', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + const clock = installFakeClock(); + try { + pointerDown(h.svg, 500, 500, { shiftKey: true }); + clock.tick(10); + pointerMove(h.svg, 530, 540, { shiftKey: true, buttons: 1 }); + + // Mid-gesture: the diagram has visibly moved, but nothing is committed yet. + expect(translate(h.getTransform())).toMatchObject({ x: 30, y: 40 }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + } finally { + clock.restore(); + } }); it('a single-finger touch drag pans the viewBox and preserves the selection', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)], selection: new Set([10]) }); h.clearMountCalls(); + const clock = installFakeClock(); + try { + pointerDown(h.svg, 500, 500, { pointerType: 'touch', isPrimary: true }); + clock.tick(10); + pointerMove(h.svg, 540, 560, { pointerType: 'touch', isPrimary: true, buttons: 1 }); + clock.tick(50); + pointerUp(h.svg, 540, 560, { pointerType: 'touch', isPrimary: true }); + + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(lastViewBox(h.callbacks.onViewBoxChange)).toEqual({ x: 40, y: 60, zoom: 1 }); + expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + } finally { + clock.restore(); + } + }); +}); +describe('Canvas gestures: momentum (issue #707)', () => { + // A flick: fast move then immediate release (within the 40ms window) starts a + // momentum coast. The coast must update the view live and commit exactly once. + function flick(h: ReturnType, clock: ReturnType): void { pointerDown(h.svg, 500, 500, { pointerType: 'touch', isPrimary: true }); - pointerMove(h.svg, 540, 560, { pointerType: 'touch', isPrimary: true, buttons: 1 }); - pointerUp(h.svg, 540, 560, { pointerType: 'touch', isPrimary: true }); + clock.tick(10); + pointerMove(h.svg, 540, 540, { pointerType: 'touch', isPrimary: true, buttons: 1 }); + clock.tick(5); // released while still moving -> momentum + pointerUp(h.svg, 540, 540, { pointerType: 'touch', isPrimary: true }); + } - expect(lastViewBox(h.callbacks.onViewBoxChange)).toEqual({ x: 40, y: 60, zoom: 1 }); - expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + it('defers the commit until the coast settles, then commits exactly once', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + const clock = installFakeClock(); + try { + flick(h, clock); + + // Pointer-up started a coast: no commit yet, but the view has moved to the + // release offset (40, 40). + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + expect(translate(h.getTransform())).toMatchObject({ x: 40, y: 40 }); + + // A few frames in: still coasting, still no commit, but the offset advanced + // past the release point. + clock.frame(); + clock.frame(); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + expect(translate(h.getTransform()).x).toBeGreaterThan(40); + + // Run the coast to its natural end: exactly one commit, at the final offset. + clock.flush(); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(lastViewBox(h.callbacks.onViewBoxChange)!.x).toBeGreaterThan(40); + } finally { + clock.restore(); + } + }); + + it('a pan that interrupts a coast starts from the coasted offset, not props.view', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + const clock = installFakeClock(); + try { + flick(h, clock); + clock.frame(); + clock.frame(); + const coasted = translate(h.getTransform()); + expect(coasted.x).toBeGreaterThan(40); + + // Press interrupts the coast (no commit), then pan by (+20, +20) screen px. + pointerDown(h.svg, 200, 200, { pointerType: 'touch', isPrimary: true }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + clock.tick(10); + pointerMove(h.svg, 220, 220, { pointerType: 'touch', isPrimary: true, buttons: 1 }); + + // The pan anchors at the coasted offset, so the live view is coasted + 20, + // NOT props.view(0,0) + 20. + const panned = translate(h.getTransform()); + expect(panned.x).toBeCloseTo(coasted.x + 20, 3); + expect(panned.y).toBeCloseTo(coasted.y + 20, 3); + + clock.tick(50); + pointerUp(h.svg, 220, 220, { pointerType: 'touch', isPrimary: true }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(lastViewBox(h.callbacks.onViewBoxChange)!.x).toBeCloseTo(coasted.x + 20, 3); + } finally { + clock.restore(); + } }); }); @@ -83,18 +203,24 @@ describe('Canvas gestures: pinch (checklist 14)', () => { it('pointer-up exits pinch cleanly so a subsequent single-finger pan works', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); h.clearMountCalls(); - - pointerDown(h.svg, 100, 100, { pointerId: 1, pointerType: 'touch', isPrimary: true }); - pointerDown(h.svg, 200, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false }); - pointerMove(h.svg, 300, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false, buttons: 1 }); - pointerUp(h.svg, 300, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false }); - - // A fresh single-finger gesture after the pinch must pan (no stuck pinch). - h.callbacks.onViewBoxChange.mockClear(); - pointerDown(h.svg, 100, 100, { pointerId: 3, pointerType: 'touch', isPrimary: true }); - pointerMove(h.svg, 130, 130, { pointerId: 3, pointerType: 'touch', isPrimary: true, buttons: 1 }); - pointerUp(h.svg, 130, 130, { pointerId: 3, pointerType: 'touch', isPrimary: true }); - - expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + const clock = installFakeClock(); + try { + pointerDown(h.svg, 100, 100, { pointerId: 1, pointerType: 'touch', isPrimary: true }); + pointerDown(h.svg, 200, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false }); + pointerMove(h.svg, 300, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false, buttons: 1 }); + pointerUp(h.svg, 300, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false }); + + // A fresh single-finger gesture after the pinch must pan (no stuck pinch). + h.callbacks.onViewBoxChange.mockClear(); + pointerDown(h.svg, 100, 100, { pointerId: 3, pointerType: 'touch', isPrimary: true }); + clock.tick(10); + pointerMove(h.svg, 130, 130, { pointerId: 3, pointerType: 'touch', isPrimary: true, buttons: 1 }); + clock.tick(50); + pointerUp(h.svg, 130, 130, { pointerId: 3, pointerType: 'touch', isPrimary: true }); + + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + } finally { + clock.restore(); + } }); }); From cba6b93449e4b785aeefce81ebac1c5d6c43f9a0 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 15:59:09 -0700 Subject: [PATCH 04/11] diagram: make wheel pan/zoom local with a trailing-debounce commit Wheel and trackpad pan/zoom now update the local live viewport on each event and (re)arm a 200ms trailing debounce instead of round-tripping to the controller per event -- the per-frame engine serialize/re-parse that issue #707 targets. The single commit fires once the scroll settles. A new pointer-down cancels the pending debounce so it can't fire mid-gesture; the pan/pinch it starts inherits the live offset and commits the combined result. The unmount cleanup cancels (does not flush) the timer so no commit lands on an unmounting host. Adds a native-wheel dispatch helper to the harness and covers, under Jest fake timers, wheel pan/zoom single-commit-on-settle, burst re-arming, and a wheel interrupting a momentum coast (no stray commit, continues from the coasted offset). Part of the issue #707 work. --- src/diagram/drawing/Canvas.tsx | 84 +++++++--- .../tests/canvas-gestures-wheel.test.tsx | 149 ++++++++++++++++++ 2 files changed, 212 insertions(+), 21 deletions(-) create mode 100644 src/diagram/tests/canvas-gestures-wheel.test.tsx diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index e235c59d8..2471e3833 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -164,6 +164,11 @@ const ZMax = 6; // (the pure functional core); this shell resolves screen->canvas points and the // rAF/timer lifecycle, then calls those pure transforms. +// How long the wheel/trackpad gesture must be idle before its live viewport is +// committed to the controller. A wheel gesture is a stream of discrete events +// with no end event, so we coalesce a burst and commit once it settles. +const WHEEL_COMMIT_DELAY_MS = 200; + // Tracked pointer for multi-touch pinch detection interface TrackedPointer { id: number; @@ -304,6 +309,12 @@ interface CanvasRefs { // back to the last committed viewBox. panBaseOffset: Point | undefined; + // Trailing-debounce timer for wheel/trackpad pan+zoom. A wheel gesture has no + // native end event, so each wheel event updates the live viewport and (re)arms + // this timer; when it fires (the scroll has gone idle) the live viewport is + // committed once. Cancelled on unmount and by any interrupting gesture. + wheelCommitTimer: ReturnType | undefined; + // Momentum/inertia animation velocityTracker: VelocityTracker; momentumAnimationId: number | undefined; @@ -388,6 +399,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac }, activePointers: new Map(), panBaseOffset: undefined, + wheelCommitTimer: undefined, velocityTracker: { positions: [] }, momentumAnimationId: undefined, momentumStartTime: undefined, @@ -520,6 +532,28 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac setLiveViewport(undefined); }; + // (Re)arm the trailing-debounce commit for a wheel/trackpad gesture. Each + // wheel event calls this; the commit fires only once the scroll has been idle + // for WHEEL_COMMIT_DELAY_MS. + const scheduleWheelCommit = (): void => { + if (r.wheelCommitTimer !== undefined) { + clearTimeout(r.wheelCommitTimer); + } + r.wheelCommitTimer = setTimeout(() => { + r.wheelCommitTimer = undefined; + commitLiveViewport(); + }, WHEEL_COMMIT_DELAY_MS); + }; + + // Cancel a pending wheel commit WITHOUT committing -- used when an interrupting + // gesture (a new pointer-down / pinch) takes ownership of the live viewport. + const cancelWheelCommit = (): void => { + if (r.wheelCommitTimer !== undefined) { + clearTimeout(r.wheelCommitTimer); + r.wheelCommitTimer = undefined; + } + }; + const getElementByUid = (uid: UID): ViewElement => { let element: ViewElement | undefined; if (uid === inCreationUid) { @@ -961,7 +995,8 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // ---- Native wheel / Safari-gesture listeners (registered at mount) ------ const handleWheelPan = (e: WheelEvent): void => { - const zoom = latest.current.props.view.zoom; + const zoom = getCanvasZoom(); + const base = getCanvasOffset(); const viewBox = latest.current.props.view.viewBox; // Page deltas (deltaMode 2) scroll a full viewport; measure it from the DOM @@ -970,21 +1005,18 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac width: svgRef.current?.clientWidth ?? viewBox.width, height: svgRef.current?.clientHeight ?? viewBox.height, }; - const newOffset = wheelPanOffset(viewBox, { x: e.deltaX, y: e.deltaY, mode: e.deltaMode }, zoom, viewportPx); - - const newViewBox = { - ...viewBox, - x: newOffset.x, - y: newOffset.y, - }; + const newOffset = wheelPanOffset(base, { x: e.deltaX, y: e.deltaY, mode: e.deltaMode }, zoom, viewportPx); - latest.current.props.onViewBoxChange(newViewBox, zoom); + // Update the live viewport and (re)arm the trailing commit; do NOT round-trip + // to the controller per event. + setLiveViewport({ x: newOffset.x, y: newOffset.y, zoom }); + scheduleWheelCommit(); }; // Native wheel zoom handler using exponential scaling for natural macOS feel. // Exponential scaling ensures symmetric behavior: zoom in 2x then out 2x returns to original. const handleNativeWheelZoom = (e: WheelEvent): void => { - const zoom = latest.current.props.view.zoom; + const zoom = getCanvasZoom(); // Exponential scaling (negative deltaY = pinch out = zoom in), clamped, with // an epsilon no-op at the zoom limits. @@ -994,19 +1026,16 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac } // Keep the model point under the cursor fixed across the zoom change: map the - // same screen pixel into canvas space at both the old and new zoom. + // same screen pixel into canvas space at both the old (current live) and new + // zoom. getCanvasPoint reads the live zoom, so the old mapping is correct + // even mid-gesture. const cursorCanvas = getCanvasPoint(e.clientX, e.clientY); - const viewBox = latest.current.props.view.viewBox; + const base = getCanvasOffset(); const newCursorCanvas = getCanvasPointWithZoom(e.clientX, e.clientY, newZoom); - const newOffset = zoomAroundPoint(viewBox, cursorCanvas, newCursorCanvas); - - const newViewBox = { - ...viewBox, - x: newOffset.x, - y: newOffset.y, - }; + const newOffset = zoomAroundPoint(base, cursorCanvas, newCursorCanvas); - latest.current.props.onViewBoxChange(newViewBox, newZoom); + setLiveViewport({ x: newOffset.x, y: newOffset.y, zoom: newZoom }); + scheduleWheelCommit(); }; // Native wheel event handler with { passive: false } to ensure preventDefault works. @@ -1444,8 +1473,16 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac e.preventDefault(); e.stopPropagation(); - // Stop any momentum animation when user starts interacting + // A new press interrupts an in-flight viewport gesture: stop the momentum + // coast and cancel any pending wheel-debounce commit so neither fires + // mid-gesture. Neither commits -- the live viewport is preserved, and a pan or + // pinch started by this press inherits it (via panBaseOffset / the pinch + // reference reads) and commits the combined result on its own settle. (A press + // that starts no viewport gesture leaves the offset live; it is folded into + // the next viewport commit. viewBox is presentational and not independently + // saved, so this is benign.) stopMomentumAnimation(); + cancelWheelCommit(); // Track this pointer for multi-touch detection r.activePointers.set(e.pointerId, { @@ -2377,6 +2414,11 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac } // Cancel any running momentum animation and clear all momentum state stopMomentumAnimation(); + // Cancel a pending wheel-debounce commit WITHOUT firing it: committing + // during teardown would call onViewBoxChange (-> a setState on the + // unmounting host). The dropped commit is harmless -- viewBox is + // presentational and re-persisted on the next interaction. + cancelWheelCommit(); // Clear velocity tracking and pointer data r.velocityTracker.positions = []; r.activePointers.clear(); diff --git a/src/diagram/tests/canvas-gestures-wheel.test.tsx b/src/diagram/tests/canvas-gestures-wheel.test.tsx new file mode 100644 index 000000000..2a8ca8b3d --- /dev/null +++ b/src/diagram/tests/canvas-gestures-wheel.test.tsx @@ -0,0 +1,149 @@ +/** + * @jest-environment jsdom + * + * Copyright 2026 The Simlin Authors. All rights reserved. + * Use of this source code is governed by the Apache License, + * Version 2.0, that can be found in the LICENSE file. + */ + +// Reconciler-level tests for wheel/trackpad pan and zoom of the React `Canvas`. +// Issue #707: a wheel gesture has no native end event, so each wheel event +// updates the LOCAL live viewport (immediate render) and (re)arms a trailing +// debounce; the controller is notified (`onViewBoxChange`) exactly once, when the +// scroll settles. These tests drive native wheel events and use Jest fake timers +// to fire the debounce -- and, for the momentum-interruption case, to also drive +// the momentum rAF loop (Jest 30 fake timers fake performance.now / +// requestAnimationFrame / setTimeout together). + +import { act } from '@testing-library/react'; + +import { dispatchWheel, makeAux, pointerDown, pointerMove, pointerUp, renderCanvas } from './canvas-gesture-harness'; + +function translate(transform: string | null): { x: number; y: number; zoom: number } { + const m = /matrix\(([^)]+)\)/.exec(transform ?? ''); + if (!m) { + throw new Error(`no matrix in transform: ${transform}`); + } + const [a, , , , e, f] = m[1].split(/[\s,]+/).map(Number); + return { x: e / a, y: f / a, zoom: a }; +} + +describe('Canvas gestures: wheel pan (issue #707)', () => { + it('updates the live transform per event but commits once on settle', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + jest.useFakeTimers(); + try { + // wheelPanOffset subtracts delta/zoom from the offset (base 0,0; zoom 1). + dispatchWheel(h.svg, { deltaX: 30, deltaY: 40 }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + expect(translate(h.getTransform())).toMatchObject({ x: -30, y: -40 }); + + dispatchWheel(h.svg, { deltaX: 10, deltaY: 0 }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + expect(translate(h.getTransform())).toMatchObject({ x: -40, y: -40 }); + + // Settle: one commit carrying the cumulative offset. + act(() => { + jest.advanceTimersByTime(200); + }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + const [viewBox, zoom] = h.callbacks.onViewBoxChange.mock.calls[0]; + expect(viewBox).toMatchObject({ x: -40, y: -40 }); + expect(zoom).toBe(1); + } finally { + jest.useRealTimers(); + } + }); + + it('re-arms the debounce on each event so a burst commits only once', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + jest.useFakeTimers(); + try { + dispatchWheel(h.svg, { deltaX: 10, deltaY: 0 }); + act(() => { + jest.advanceTimersByTime(150); // not yet idle + }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + dispatchWheel(h.svg, { deltaX: 10, deltaY: 0 }); + act(() => { + jest.advanceTimersByTime(150); // re-armed: still within the new window + }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + act(() => { + jest.advanceTimersByTime(60); // now idle past 200ms since the last event + }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(h.callbacks.onViewBoxChange.mock.calls[0][0]).toMatchObject({ x: -20, y: 0 }); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe('Canvas gestures: wheel zoom (issue #707)', () => { + it('zooms around the cursor live and commits once on settle', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + jest.useFakeTimers(); + try { + // ctrlKey wheel = trackpad pinch-zoom. deltaY -100 -> 2x zoom (clamped ok). + dispatchWheel(h.svg, { deltaY: -100, ctrlKey: true, clientX: 0, clientY: 0 }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + expect(translate(h.getTransform()).zoom).toBeCloseTo(2, 5); + + act(() => { + jest.advanceTimersByTime(200); + }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(h.callbacks.onViewBoxChange.mock.calls[0][1]).toBeCloseTo(2, 5); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe('Canvas gestures: wheel interrupts momentum (issue #707)', () => { + it('continues from the coasted offset and commits once, without a stray commit', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + jest.useFakeTimers(); + try { + // Flick to start a momentum coast (fast move, immediate release). + pointerDown(h.svg, 500, 500, { pointerType: 'touch', isPrimary: true }); + act(() => { + jest.advanceTimersByTime(10); + }); + pointerMove(h.svg, 540, 540, { pointerType: 'touch', isPrimary: true, buttons: 1 }); + act(() => { + jest.advanceTimersByTime(5); + }); + pointerUp(h.svg, 540, 540, { pointerType: 'touch', isPrimary: true }); + + // Let the coast advance a couple of frames (~16ms each under fake timers). + act(() => { + jest.advanceTimersByTime(32); + }); + const coasted = translate(h.getTransform()); + expect(coasted.x).toBeGreaterThan(40); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + + // A wheel event interrupts the coast: no commit from the interruption, and + // the wheel pans from the coasted offset. + dispatchWheel(h.svg, { deltaX: 10, deltaY: 0 }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + const afterWheel = translate(h.getTransform()); + expect(afterWheel.x).toBeCloseTo(coasted.x - 10, 3); + + // Settle: exactly one commit, at the wheel-adjusted coasted offset. + act(() => { + jest.advanceTimersByTime(200); + }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(h.callbacks.onViewBoxChange.mock.calls[0][0].x).toBeCloseTo(coasted.x - 10, 3); + } finally { + jest.useRealTimers(); + } + }); +}); From 961bac3ca98f8ad0a4d38bdc40c5a105265805ff Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 16:01:27 -0700 Subject: [PATCH 05/11] diagram: make pinch-zoom local, committing once on pinch exit handlePinchMove now writes the local live viewport each move instead of round-tripping to the controller per move; the single commit fires when the second finger lifts. Pinch start anchors its fixed reference (initial zoom and the model point under the fingers) against the live viewport rather than props.view, so a pinch that follows an uncommitted pan keeps its place, and it no longer clears the live viewport on entry. Updates the pinch test to assert the live transform zooms during the move with no commit, and exactly one commit on exit. Part of the issue #707 work. --- src/diagram/drawing/Canvas.tsx | 27 ++++++++++--------- .../tests/canvas-gestures-pan-zoom.test.tsx | 14 ++++++---- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index 2471e3833..5b8060cbd 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -983,13 +983,9 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac const currentCenterCanvas = getCanvasPointWithZoom(currentCenter.x, currentCenter.y, newZoom); const newOffset = pinchOffset(currentCenterCanvas, interactionNow.modelPoint); - const newViewBox = { - ...latest.current.props.view.viewBox, - x: newOffset.x, - y: newOffset.y, - }; - - latest.current.props.onViewBoxChange(newViewBox, newZoom); + // Update the live viewport (immediate render); the single commit happens on + // pinch exit, not per move. + setLiveViewport({ x: newOffset.x, y: newOffset.y, zoom: newZoom }); }; // ---- Native wheel / Safari-gesture listeners (registered at mount) ------ @@ -1131,6 +1127,9 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // Handle end of pinch gesture if (latest.current.interaction.mode === 'pinching') { + // Commit the pinched viewport once, on exit (handlePinchMove kept it local + // throughout the gesture). + commitLiveViewport(); // When exiting pinch mode, clear all gesture state for a clean restart. // Continuing with a single finger after pinch leads to confusing UX. const { state: nextInteraction } = reduceInteraction( @@ -1505,32 +1504,34 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac const distance = getPinchDistance(); const center = getPinchCenter(); const centerCanvas = getCanvasPoint(center.x, center.y); - const viewBox = latest.current.props.view.viewBox; + // Anchor against the live viewport (= a prior pan's offset if one was in + // flight, else props.view), so a pinch that follows a pan keeps its place. + const base = getCanvasOffset(); // Calculate the MODEL point under the pinch center. This is the fixed // point in model space that should remain under the user's fingers // throughout the pinch gesture. const pinchModelPoint = { - x: centerCanvas.x - viewBox.x, - y: centerCanvas.y - viewBox.y, + x: centerCanvas.x - base.x, + y: centerCanvas.y - base.y, }; // Entering pinch mode supersedes any single-finger panning/dragSelecting // mode; the reducer returns the pinching variant carrying the fixed - // reference. Clear the live viewport so exiting pinch can't start momentum. + // reference. The live viewport is intentionally NOT cleared: handlePinchMove + // writes it each move and pinch exit commits it once. const { state: nextInteraction, effects } = reduceInteraction( latest.current.interaction, { kind: 'pinchStart', initialDistance: distance, - initialZoom: latest.current.props.view.zoom, + initialZoom: getCanvasZoom(), modelPoint: pinchModelPoint, }, interactionContext(), ); runEffects(effects, e.target as Element | undefined, e.pointerId); setInteraction(nextInteraction); - setLiveViewport(undefined); return; } diff --git a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx index c290b6f0b..986ea56a1 100644 --- a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx +++ b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx @@ -182,7 +182,7 @@ describe('Canvas gestures: momentum (issue #707)', () => { }); describe('Canvas gestures: pinch (checklist 14)', () => { - it('a second touch enters pinch mode and a pinch-apart zooms the viewBox in', () => { + it('a pinch-apart zooms the live view in and commits once on exit', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); h.clearMountCalls(); @@ -194,10 +194,14 @@ describe('Canvas gestures: pinch (checklist 14)', () => { // Spread the fingers: distance 100 -> 200, scale 2 -> zoom 1 -> 2. pointerMove(h.svg, 300, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false, buttons: 1 }); - const zoomed = lastViewBox(h.callbacks.onViewBoxChange); - expect(zoomed).toBeDefined(); - // Pinch changed the zoom (only pinch/wheel do; pan keeps zoom == 1). - expect(zoomed!.zoom).toBeCloseTo(2, 5); + // The move zooms the live transform but does NOT commit yet. + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + expect(translate(h.getTransform()).zoom).toBeCloseTo(2, 5); + + // Lifting the second finger exits pinch and commits the zoom exactly once. + pointerUp(h.svg, 300, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(lastViewBox(h.callbacks.onViewBoxChange)!.zoom).toBeCloseTo(2, 5); }); it('pointer-up exits pinch cleanly so a subsequent single-finger pan works', () => { From 146493a90363bdf2d5b15586e64251187cbcfb1e Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 16:06:59 -0700 Subject: [PATCH 06/11] diagram: fold a mid-gesture resize into the live viewport handleSvgResize now branches on whether a viewport gesture is in flight: when the live viewport is set it folds the re-centering offset shift into it (no commit), so the gesture's own settle commit carries the new size; when idle it commits immediately as before. Committing during a gesture would register as an external view change and snap the gesture. Resize is also gated to non-embedded mode (embedded draws to tight element bounds and ignores viewBox). Makes the harness ResizeObserver triggerable (jsdom never fires real ones) and covers idle-resize-commits and resize-mid-pan-folds-without-snapping. Part of the issue #707 work. --- src/diagram/drawing/Canvas.tsx | 20 ++++++-- src/diagram/tests/canvas-gesture-harness.tsx | 50 +++++++++++++++++-- .../tests/canvas-gestures-pan-zoom.test.tsx | 43 ++++++++++++++++ 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index 5b8060cbd..1c0879b69 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -1086,12 +1086,24 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac height: contentRect.height, }; const oldSize = latest.current.svgSize; - if (oldSize) { + // Embedded mode draws to tight element bounds and ignores viewBox, so a + // resize there only updates the measured size. + if (oldSize && !latest.current.props.embedded) { const dWidth = contentRect.width - oldSize.width; const dHeight = contentRect.height - oldSize.height; - const newViewBox = resizeViewBox(getCanvasOffset(), dWidth, dHeight, contentRect.width, contentRect.height); - - latest.current.props.onViewBoxChange(newViewBox, latest.current.props.view.zoom); + const live = latest.current.liveViewport; + if (live) { + // A viewport gesture is in flight: fold the re-centering offset shift into + // the live viewport so the gesture's own settle commit carries it. Do NOT + // commit here -- an onViewBoxChange mid-gesture would be seen as an + // external view change and snap the gesture (see the external-override + // effect). + const adjusted = resizeViewBox(live, dWidth, dHeight, contentRect.width, contentRect.height); + setLiveViewport({ x: adjusted.x, y: adjusted.y, zoom: live.zoom }); + } else { + const newViewBox = resizeViewBox(getCanvasOffset(), dWidth, dHeight, contentRect.width, contentRect.height); + latest.current.props.onViewBoxChange(newViewBox, getCanvasZoom()); + } } setSvgSize(newSvgSize); diff --git a/src/diagram/tests/canvas-gesture-harness.tsx b/src/diagram/tests/canvas-gesture-harness.tsx index 74f6ee5be..6179148f0 100644 --- a/src/diagram/tests/canvas-gesture-harness.tsx +++ b/src/diagram/tests/canvas-gesture-harness.tsx @@ -134,6 +134,48 @@ const CANVAS_RECT: DOMRect = { toJSON: () => ({}), }; +// A ResizeObserver that records its callback + observed targets so a test can +// synthesize a resize (jsdom never fires real ones). The Canvas reads the new +// size from `entry.target.clientWidth/Height`, so `triggerResize` sets those on +// each observed element before invoking the callback. +const liveResizeObservers = new Set(); + +class FakeResizeObserver { + private readonly callback: ResizeObserverCallback; + readonly targets = new Set(); + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + observe(target: Element): void { + this.targets.add(target); + liveResizeObservers.add(this); + } + unobserve(target: Element): void { + this.targets.delete(target); + } + disconnect(): void { + this.targets.clear(); + liveResizeObservers.delete(this); + } + fire(width: number, height: number): void { + for (const target of this.targets) { + Object.defineProperty(target, 'clientWidth', { configurable: true, value: width }); + Object.defineProperty(target, 'clientHeight', { configurable: true, value: height }); + const entry = { target, contentRect: { width, height } } as unknown as ResizeObserverEntry; + this.callback([entry], this as unknown as ResizeObserver); + } + } +} + +/** Synthesize a resize to `width`x`height` on every live observed element. */ +export function triggerResize(width: number, height: number): void { + act(() => { + for (const obs of liveResizeObservers) { + obs.fire(width, height); + } + }); +} + let polyfillsInstalled = false; /** @@ -158,11 +200,6 @@ export function installCanvasPolyfills(): void { g.DOMPoint = FakeDOMPoint as unknown as typeof DOMPoint; } if (typeof g.ResizeObserver !== 'function') { - class FakeResizeObserver { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - } g.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver; } @@ -414,6 +451,8 @@ export interface CanvasHarness { * `onViewBoxChange`. */ getTransform: () => string | null; + /** Synthesize a container resize to `width`x`height` (drives handleSvgResize). */ + resize: (width: number, height: number) => void; } /** @@ -501,6 +540,7 @@ export function renderCanvas(opts: HarnessOptions): CanvasHarness { } }, getTransform: () => result.container.querySelector('svg g[transform]')?.getAttribute('transform') ?? null, + resize: (width: number, height: number) => triggerResize(width, height), }; } diff --git a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx index 986ea56a1..726cf5a36 100644 --- a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx +++ b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx @@ -181,6 +181,49 @@ describe('Canvas gestures: momentum (issue #707)', () => { }); }); +describe('Canvas gestures: resize (issue #707)', () => { + it('commits the re-centered viewBox immediately when no gesture is in flight', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + // First resize establishes the known size; clear so we assert on the second. + h.resize(1000, 1000); + h.clearMountCalls(); + + h.resize(800, 800); + + // resizeViewBox((0,0), dW=-200, dH=-200, 800, 800) shifts by dW/4 = -50. + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(lastViewBox(h.callbacks.onViewBoxChange)).toEqual({ x: -50, y: -50, zoom: 1 }); + }); + + it('folds a resize during a pan into the live viewport without committing (no snap)', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.resize(1000, 1000); // establish svgSize before the gesture + h.clearMountCalls(); + const clock = installFakeClock(); + try { + // Begin a pan (not released): live viewport = (30, 40). + pointerDown(h.svg, 500, 500, { shiftKey: true }); + clock.tick(10); + pointerMove(h.svg, 530, 540, { shiftKey: true, buttons: 1 }); + expect(translate(h.getTransform())).toMatchObject({ x: 30, y: 40 }); + + // Resize mid-pan: folds the +dW/4 = +10 re-centering into the live viewport, + // updating the transform but committing nothing. + h.resize(1040, 1000); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + expect(translate(h.getTransform())).toMatchObject({ x: 40, y: 40 }); + + // Releasing stationary commits the pan once, carrying the folded offset. + clock.tick(50); + pointerUp(h.svg, 530, 540, { shiftKey: true }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(lastViewBox(h.callbacks.onViewBoxChange)).toMatchObject({ x: 40, y: 40 }); + } finally { + clock.restore(); + } + }); +}); + describe('Canvas gestures: pinch (checklist 14)', () => { it('a pinch-apart zooms the live view in and commits once on exit', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); From a1767057099e0b52178061f6407ff4d742d52413 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 16:12:08 -0700 Subject: [PATCH 07/11] diagram: let an external view change override a live gesture While a gesture owns the live viewport, props.view is expected to stay put (a gesture never commits mid-flight). If its offset/zoom value nonetheless changes -- centerVariable, module navigation, or an undo that restored a different viewport -- that external view must win. A new effect compares props.view by value against a baseline tracked while idle and, when a change is seen with the live viewport still set, drops the live viewport and cancels any pending momentum/wheel commit so the abandoned gesture leaves no stale commit. A self-commit clears the live viewport in the same React commit as its optimistic props.view update, so it is never misread as external; comparing by value (not snapshot identity) ignores a content-equal republished view. Adds a setViewport helper to the harness and covers the centerVariable/ navigation race against a pending wheel commit. Part of the issue #707 work. --- src/diagram/drawing/Canvas.tsx | 41 +++++++++++++++++++ src/diagram/tests/canvas-gesture-harness.tsx | 18 ++++++++ .../tests/canvas-gestures-wheel.test.tsx | 29 +++++++++++++ 3 files changed, 88 insertions(+) diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index 1c0879b69..b0319dfd0 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -315,6 +315,13 @@ interface CanvasRefs { // committed once. Cancelled on unmount and by any interrupting gesture. wheelCommitTimer: ReturnType | undefined; + // The props.view offset/zoom VALUE observed while no gesture was live. The + // external-override effect compares props.view against this to detect a + // non-gesture view change (centerVariable, navigation, undo) mid-gesture. + // Compared by value (not identity) so a content-equal republished snapshot does + // not look like an external change. + viewBaseline: { x: number; y: number; zoom: number } | undefined; + // Momentum/inertia animation velocityTracker: VelocityTracker; momentumAnimationId: number | undefined; @@ -400,6 +407,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac activePointers: new Map(), panBaseOffset: undefined, wheelCommitTimer: undefined, + viewBaseline: undefined, velocityTracker: { positions: [] }, momentumAnimationId: undefined, momentumStartTime: undefined, @@ -2288,6 +2296,39 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac return zLayers; }; + // ---- External-view override (issue #707) -------------------------------- + // While a gesture owns the live viewport, props.view is expected to stay put + // (a gesture does not commit mid-flight). If props.view's offset/zoom VALUE + // nonetheless changes, some other source moved the view -- centerVariable, + // module navigation, or an undo that restored a different viewport -- and that + // external view must win: drop the live viewport and cancel any pending + // momentum/wheel commit, with no stray commit of the abandoned gesture. A + // self-commit clears the live viewport in the same React commit as its + // optimistic props.view update, so it is never observed here as still-live. + // Comparison is by value against a baseline tracked while idle, so a + // content-equal republished snapshot (new identity, same viewport) is ignored. + React.useEffect(() => { + const pv = props.view; + const current = { x: pv.viewBox.x, y: pv.viewBox.y, zoom: pv.zoom }; + if (liveViewport) { + const baseline = r.viewBaseline; + if (baseline && (baseline.x !== current.x || baseline.y !== current.y || baseline.zoom !== current.zoom)) { + stopMomentumAnimation(); + cancelWheelCommit(); + setLiveViewport(undefined); + r.viewBaseline = current; + } + } else { + // Idle: track props.view as the baseline for the next gesture. + r.viewBaseline = current; + } + // Triggers: props.view (the external change) and liveViewport (gesture + // start/end, which moves the baseline). The handler functions are stable + // shell closures read directly and are intentionally not deps. (The repo lint + // config does not enable react-hooks/exhaustive-deps, so no disable directive + // is needed.) + }, [props.view, liveViewport]); + // ---- Mount / unmount effect --------------------------------------------- // componentDidMount -> mount effect; componentWillUnmount -> the cleanup. // Runs once (empty deps); reads the latest props/state through `latest`. diff --git a/src/diagram/tests/canvas-gesture-harness.tsx b/src/diagram/tests/canvas-gesture-harness.tsx index 6179148f0..01ab3f6a8 100644 --- a/src/diagram/tests/canvas-gesture-harness.tsx +++ b/src/diagram/tests/canvas-gesture-harness.tsx @@ -453,6 +453,12 @@ export interface CanvasHarness { getTransform: () => string | null; /** Synthesize a container resize to `width`x`height` (drives handleSvgResize). */ resize: (width: number, height: number) => void; + /** + * Push a new `props.view` with an overridden viewBox offset / zoom, modeling an + * EXTERNAL viewport change (centerVariable, module navigation, undo) -- i.e. one + * that did not originate from a canvas gesture. + */ + setViewport: (next: { x?: number; y?: number; zoom?: number }) => void; } /** @@ -541,6 +547,18 @@ export function renderCanvas(opts: HarnessOptions): CanvasHarness { }, getTransform: () => result.container.querySelector('svg g[transform]')?.getAttribute('transform') ?? null, resize: (width: number, height: number) => triggerResize(width, height), + setViewport: (next: { x?: number; y?: number; zoom?: number }) => { + const nextView: StockFlowView = { + ...currentView, + viewBox: { + ...currentView.viewBox, + x: next.x ?? currentView.viewBox.x, + y: next.y ?? currentView.viewBox.y, + }, + zoom: next.zoom ?? currentView.zoom, + }; + setProps({ view: nextView }); + }, }; } diff --git a/src/diagram/tests/canvas-gestures-wheel.test.tsx b/src/diagram/tests/canvas-gestures-wheel.test.tsx index 2a8ca8b3d..525a6558f 100644 --- a/src/diagram/tests/canvas-gestures-wheel.test.tsx +++ b/src/diagram/tests/canvas-gestures-wheel.test.tsx @@ -104,6 +104,35 @@ describe('Canvas gestures: wheel zoom (issue #707)', () => { }); }); +describe('Canvas gestures: external view change overrides a live gesture (issue #707)', () => { + it('clears the live wheel viewport and cancels the pending commit', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + jest.useFakeTimers(); + try { + // A wheel pan sets the live viewport and arms the debounce (uncommitted). + dispatchWheel(h.svg, { deltaX: 30, deltaY: 40 }); + expect(translate(h.getTransform())).toMatchObject({ x: -30, y: -40 }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + + // An external viewport change arrives (e.g. centerVariable / navigation). + h.setViewport({ x: 500, y: 600, zoom: 1 }); + + // The external view wins immediately -- the live wheel offset is dropped. + expect(translate(h.getTransform())).toMatchObject({ x: 500, y: 600, zoom: 1 }); + + // ...and the pending wheel commit was cancelled, so the abandoned gesture + // never commits a stale offset over the external view. + act(() => { + jest.advanceTimersByTime(200); + }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); +}); + describe('Canvas gestures: wheel interrupts momentum (issue #707)', () => { it('continues from the coasted offset and commits once, without a stray commit', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); From 00a047d4e88889daabe53515e4a846958470b881 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 16:14:33 -0700 Subject: [PATCH 08/11] diagram: document the live-viewport invariants; require Fixes in PRs Adds the issue #707 invariants to src/diagram/CLAUDE.md (live-viewport ownership + single settle commit, and the external-view override) and a test asserting embedded mode is viewport-inert -- wheel and resize never set a live viewport, commit, or produce a content transform (embedded draws to a tight viewBox attribute). Also adds a Pull Requests section to the root CLAUDE.md: PR descriptions must carry a GitHub 'Fixes #' closing keyword for each resolved issue (kept in the PR body, not commit messages, so the auto-close fires once on merge). Part of the issue #707 work. --- CLAUDE.md | 6 ++++ src/diagram/CLAUDE.md | 2 ++ .../tests/canvas-gestures-pan-zoom.test.tsx | 30 ++++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 095264d43..170a75e94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,12 @@ Lean on the pre-commit hook: run `git commit ...` and fix reported problems rath - Body: 1-2 paragraphs explaining "why", highlighting assumptions and non-obvious decisions - DO NOT use "fixes"/"resolves" or emoji in commit messages +## Pull Requests + +- The PR description MUST include a GitHub closing keyword -- `Fixes #` (or `Closes #`) -- for every issue the PR resolves, so merging auto-closes those issues. List one per line when a PR resolves several. +- Keep the closing keywords in the PR description, NOT in commit messages (commit messages deliberately avoid "fixes"/"resolves" per the style above; the auto-close should fire once, on merge, from the PR). +- The body should explain "why" and call out non-obvious decisions, mirroring the commit-message guidance. + ## Hard Rules IMPORTANT: Simple, general, testable, maintainable code is better than preserving an interface. There are NO places where VM bytecode is serialized to disk; backwards compatibility is ONLY needed for protobufs. diff --git a/src/diagram/CLAUDE.md b/src/diagram/CLAUDE.md index 5dab10d67..f0e050454 100644 --- a/src/diagram/CLAUDE.md +++ b/src/diagram/CLAUDE.md @@ -63,6 +63,8 @@ Material-style UI component library (40+ components): Accordion, AppBar, Button, Most project/engine invariants now live in `ProjectController` (`project-controller.ts`); the Editor-side ones describe rendering and UI-state consequences. Both are load-bearing. +- **Live viewport ownership** (Canvas, issue #707): during a viewport gesture -- drag-pan, momentum coast, wheel/trackpad pan+zoom, pinch -- the Canvas holds offset+zoom in local `liveViewport` state and renders from it, notifying the host (`onViewBoxChange`) exactly **once, on settle** (pan release with no momentum, the momentum coast's natural end, the wheel trailing-debounce `WHEEL_COMMIT_DELAY_MS`, or pinch exit). The former per-frame engine round-trip is gone. `getCanvasOffset()`/`getCanvasZoom()` resolve from `liveViewport` when set, else `props.view`; the single settle commit goes through `commitLiveViewport()`. The viewport math is the pure `drawing/viewport.ts`; the shell owns the rAF loop, the `refs.wheelCommitTimer` debounce, and screen->canvas mapping. A drag-pan anchors against `refs.panBaseOffset` (captured at press) so a pan that interrupts a coast does not snap back to the last committed viewBox. Interruptions (a new pointer-down / wheel) never commit and never clear `liveViewport`: the new gesture inherits it (via `panBaseOffset` / the pinch reference reads) and commits the combined result. Embedded mode is viewport-inert (it draws to tight element bounds and ignores viewBox/zoom). These gestures still ultimately call the controller's `queueViewUpdate`, just once per gesture instead of per frame. +- **External view overrides a live gesture** (Canvas, issue #707): a `useEffect` keyed on `[props.view, liveViewport]` compares `props.view`'s offset/zoom VALUE against `refs.viewBaseline` (tracked while idle). While a gesture is live, `props.view` is expected to stay put (a gesture never commits mid-flight), so any value change seen with `liveViewport` still set is external (centerVariable, module navigation, undo) and drops the live viewport + cancels pending momentum/wheel commits -- the external view wins, with no stray commit. A self-commit clears `liveViewport` in the same React commit as its optimistic `props.view` update, so it is never misread as external; comparing by value (not snapshot identity) ignores a content-equal republished view. - **Optimistic view updates** (controller): `updateView()`/`queueViewUpdate()` call `applyOptimisticView()` (synchronous snapshot replace of the active model's view + `projectVersion += 0.001`) *before* awaiting the engine round-trip. Any new view-modifying handler must go through these controller methods to avoid flicker. - **updateProject preserves the live view** (controller): `ProjectController.updateProject()` rebuilds `project` from the engine's serialized JSON, then merges via `preserveLiveView()` so the active model's view comes from the live snapshot (the most recent optimistic view). Without this, a slow engine round-trip racing with a newer pan/move would snap the diagram back to the engine's older view. The live view is round-tripped through JSON to re-link element `var` refs and stock inflow/outflow UIDs against the incoming variables. - **View-only updates never record undo history** (controller): the `queueViewUpdate` path calls `updateProject(serialized, { recordHistory: false, scheduleSave: false })` -- it refreshes `project` and bumps `projectVersion` but must not touch `projectHistory`/`projectOffset`: viewBox/zoom are serialized into the protobuf, so recording them would let a single momentum flick evict every real edit from the `MaxUndoSize` (5) buffer. Real edits go through `advanceProjectHistory` (project-history.ts), which discards the redo branch when editing after an undo. diff --git a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx index 726cf5a36..747dba545 100644 --- a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx +++ b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx @@ -19,7 +19,15 @@ // by ticking past the 40ms stop window before pointer-up, and a flick by // releasing immediately after a fast move. -import { installFakeClock, makeAux, pointerDown, pointerMove, pointerUp, renderCanvas } from './canvas-gesture-harness'; +import { + dispatchWheel, + installFakeClock, + makeAux, + pointerDown, + pointerMove, + pointerUp, + renderCanvas, +} from './canvas-gesture-harness'; interface ViewBoxCall { x: number; @@ -181,6 +189,26 @@ describe('Canvas gestures: momentum (issue #707)', () => { }); }); +describe('Canvas gestures: embedded mode is viewport-inert (issue #707)', () => { + it('ignores wheel and resize, never setting a live viewport or committing', () => { + const h = renderCanvas({ embedded: true, elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + + // A wheel in embedded mode is a no-op (handleNativeWheel early-returns), so + // nothing commits and there is no content transform (embedded draws to a + // tight viewBox attribute, leaving the content with no transform). + dispatchWheel(h.svg, { deltaX: 30, deltaY: 40 }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + expect(h.getTransform()).toBeNull(); + + // Resize in embedded mode only measures; it never commits a viewBox. + h.resize(1000, 1000); + h.resize(800, 800); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + expect(h.getTransform()).toBeNull(); + }); +}); + describe('Canvas gestures: resize (issue #707)', () => { it('commits the re-centered viewBox immediately when no gesture is in flight', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); From be3ef71f03a01aad3651a47c74efbbb9842f3043 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 16:41:39 -0700 Subject: [PATCH 09/11] diagram: address review on mid-gesture resize and orphaned viewport commits Three fixes from PR #734 review: - commitLiveViewport sources viewBox width/height from the live svgSize rather than props.view.viewBox, so a resize that fires mid-gesture settles with the current dimensions instead of stale ones (centerVariable and friends read view.viewBox.width/height). - A mid-gesture resize no longer folds a re-centering offset shift into the live viewport. That shift was immediately discarded by the next pan move / momentum frame (which recompute the offset from their press-time anchor), and shifting the offset out from under an active gesture is wrong anyway. The gesture now keeps full control of the offset; only svgSize updates, and the settle commit carries the new size. An idle resize still re-centers and commits immediately. - Generalize the wheel trailing-debounce into a guarded 'deferred commit' for an ORPHANED live viewport and also arm it when a press interrupts a momentum coast. handlePointerDown no longer cancels it. Its callback commits only when no pan/pinch/coast is active, so a plain click between a wheel scroll (or a coast) and the timer persists the viewport instead of stranding it in local state, with no double commit. Before this, a click interrupting a coast (or a pending wheel) left props.view stale until the next viewport gesture. Tests: resize-during-pan now asserts the offset is untouched, the pan continues from its anchor after the resize, and the commit carries the new dimensions; new tests cover a plain click persisting a pending wheel offset and an interrupted coast. Part of the issue #707 work. --- src/diagram/CLAUDE.md | 2 +- src/diagram/drawing/Canvas.tsx | 138 ++++++++++-------- .../tests/canvas-gestures-pan-zoom.test.tsx | 24 ++- .../tests/canvas-gestures-wheel.test.tsx | 72 +++++++++ 4 files changed, 167 insertions(+), 69 deletions(-) diff --git a/src/diagram/CLAUDE.md b/src/diagram/CLAUDE.md index f0e050454..b2f458352 100644 --- a/src/diagram/CLAUDE.md +++ b/src/diagram/CLAUDE.md @@ -63,7 +63,7 @@ Material-style UI component library (40+ components): Accordion, AppBar, Button, Most project/engine invariants now live in `ProjectController` (`project-controller.ts`); the Editor-side ones describe rendering and UI-state consequences. Both are load-bearing. -- **Live viewport ownership** (Canvas, issue #707): during a viewport gesture -- drag-pan, momentum coast, wheel/trackpad pan+zoom, pinch -- the Canvas holds offset+zoom in local `liveViewport` state and renders from it, notifying the host (`onViewBoxChange`) exactly **once, on settle** (pan release with no momentum, the momentum coast's natural end, the wheel trailing-debounce `WHEEL_COMMIT_DELAY_MS`, or pinch exit). The former per-frame engine round-trip is gone. `getCanvasOffset()`/`getCanvasZoom()` resolve from `liveViewport` when set, else `props.view`; the single settle commit goes through `commitLiveViewport()`. The viewport math is the pure `drawing/viewport.ts`; the shell owns the rAF loop, the `refs.wheelCommitTimer` debounce, and screen->canvas mapping. A drag-pan anchors against `refs.panBaseOffset` (captured at press) so a pan that interrupts a coast does not snap back to the last committed viewBox. Interruptions (a new pointer-down / wheel) never commit and never clear `liveViewport`: the new gesture inherits it (via `panBaseOffset` / the pinch reference reads) and commits the combined result. Embedded mode is viewport-inert (it draws to tight element bounds and ignores viewBox/zoom). These gestures still ultimately call the controller's `queueViewUpdate`, just once per gesture instead of per frame. +- **Live viewport ownership** (Canvas, issue #707): during a viewport gesture -- drag-pan, momentum coast, wheel/trackpad pan+zoom, pinch -- the Canvas holds offset+zoom in local `liveViewport` state and renders from it, notifying the host (`onViewBoxChange`) exactly **once, on settle** (pan release with no momentum, the momentum coast's natural end, pinch exit, or the `DEFERRED_COMMIT_DELAY_MS` trailing-debounce for wheel). The former per-frame engine round-trip is gone. `getCanvasOffset()`/`getCanvasZoom()` resolve from `liveViewport` when set, else `props.view`; the single settle commit goes through `commitLiveViewport()`, which sources viewBox width/height from the live `svgSize` so a mid-gesture resize settles with the current dimensions. The viewport math is the pure `drawing/viewport.ts`; the shell owns the rAF loop, the `refs.deferredCommitTimer` debounce, and screen->canvas mapping. A drag-pan anchors against `refs.panBaseOffset` (captured at press) so a pan that interrupts a coast does not snap back to the last committed viewBox. Interruptions (a new pointer-down / wheel) never commit and never clear `liveViewport`: the new gesture inherits it (via `panBaseOffset` / the pinch reference reads) and commits the combined result. The `refs.deferredCommitTimer` is the safety net for an **orphaned** live viewport -- one left by a wheel gesture (no end event) or by a coast a non-viewport press interrupted: its guarded callback commits only when no pan/pinch/coast is active, so a plain click between a scroll/coast and the timer still persists the viewport instead of stranding it, with no double commit. A mid-gesture resize updates only `svgSize` (the gesture keeps the offset; no re-centering shift to fight or be discarded); an idle resize re-centers and commits immediately. Embedded mode is viewport-inert (it draws to tight element bounds and ignores viewBox/zoom). These gestures still ultimately call the controller's `queueViewUpdate`, just once per gesture instead of per frame. - **External view overrides a live gesture** (Canvas, issue #707): a `useEffect` keyed on `[props.view, liveViewport]` compares `props.view`'s offset/zoom VALUE against `refs.viewBaseline` (tracked while idle). While a gesture is live, `props.view` is expected to stay put (a gesture never commits mid-flight), so any value change seen with `liveViewport` still set is external (centerVariable, module navigation, undo) and drops the live viewport + cancels pending momentum/wheel commits -- the external view wins, with no stray commit. A self-commit clears `liveViewport` in the same React commit as its optimistic `props.view` update, so it is never misread as external; comparing by value (not snapshot identity) ignores a content-equal republished view. - **Optimistic view updates** (controller): `updateView()`/`queueViewUpdate()` call `applyOptimisticView()` (synchronous snapshot replace of the active model's view + `projectVersion += 0.001`) *before* awaiting the engine round-trip. Any new view-modifying handler must go through these controller methods to avoid flicker. - **updateProject preserves the live view** (controller): `ProjectController.updateProject()` rebuilds `project` from the engine's serialized JSON, then merges via `preserveLiveView()` so the active model's view comes from the live snapshot (the most recent optimistic view). Without this, a slow engine round-trip racing with a newer pan/move would snap the diagram back to the engine's older view. The live view is round-tripped through JSON to re-link element `var` refs and stock inflow/outflow UIDs against the incoming variables. diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index b0319dfd0..0b1b34313 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -164,10 +164,13 @@ const ZMax = 6; // (the pure functional core); this shell resolves screen->canvas points and the // rAF/timer lifecycle, then calls those pure transforms. -// How long the wheel/trackpad gesture must be idle before its live viewport is -// committed to the controller. A wheel gesture is a stream of discrete events -// with no end event, so we coalesce a burst and commit once it settles. -const WHEEL_COMMIT_DELAY_MS = 200; +// How long an "orphaned" live viewport waits before being committed to the +// controller. Two cases produce one with no natural settle event of its own: a +// wheel/trackpad gesture (a stream of discrete events, no end event -- coalesce +// the burst), and a momentum coast interrupted by a press that does not become a +// viewport gesture. The deferred commit is guarded so a pan/pinch/momentum that +// DID take over commits instead (see scheduleDeferredCommit). +const DEFERRED_COMMIT_DELAY_MS = 200; // Tracked pointer for multi-touch pinch detection interface TrackedPointer { @@ -309,11 +312,12 @@ interface CanvasRefs { // back to the last committed viewBox. panBaseOffset: Point | undefined; - // Trailing-debounce timer for wheel/trackpad pan+zoom. A wheel gesture has no - // native end event, so each wheel event updates the live viewport and (re)arms - // this timer; when it fires (the scroll has gone idle) the live viewport is - // committed once. Cancelled on unmount and by any interrupting gesture. - wheelCommitTimer: ReturnType | undefined; + // Trailing-debounce timer that commits an "orphaned" live viewport -- one left + // by a wheel/trackpad gesture (no native end event) or by a momentum coast that + // a non-viewport press interrupted. Re-armed per wheel event / on interruption; + // its callback is guarded so an active pan/pinch/coast commits instead. Cleared + // on unmount and by an external-view override. + deferredCommitTimer: ReturnType | undefined; // The props.view offset/zoom VALUE observed while no gesture was live. The // external-override effect compares props.view against this to detect a @@ -406,7 +410,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac }, activePointers: new Map(), panBaseOffset: undefined, - wheelCommitTimer: undefined, + deferredCommitTimer: undefined, viewBaseline: undefined, velocityTracker: { positions: [] }, momentumAnimationId: undefined, @@ -531,34 +535,51 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac if (!live) { return; } + // Source the viewBox width/height from the live measured size, not + // props.view.viewBox: a resize that fired during this gesture updated + // `svgSize` but (by design) did not commit, so props.view still holds the + // pre-resize dimensions. viewBox width/height are pixel dimensions == the + // measured canvas size, so this settles the gesture with the current size. + const size = latest.current.svgSize ?? latest.current.props.view.viewBox; const newViewBox = { ...latest.current.props.view.viewBox, x: live.x, y: live.y, + width: size.width, + height: size.height, }; latest.current.props.onViewBoxChange(newViewBox, live.zoom); setLiveViewport(undefined); }; - // (Re)arm the trailing-debounce commit for a wheel/trackpad gesture. Each - // wheel event calls this; the commit fires only once the scroll has been idle - // for WHEEL_COMMIT_DELAY_MS. - const scheduleWheelCommit = (): void => { - if (r.wheelCommitTimer !== undefined) { - clearTimeout(r.wheelCommitTimer); - } - r.wheelCommitTimer = setTimeout(() => { - r.wheelCommitTimer = undefined; - commitLiveViewport(); - }, WHEEL_COMMIT_DELAY_MS); + // (Re)arm the deferred commit for an orphaned live viewport (a wheel/trackpad + // gesture, or a momentum coast a press just interrupted). The commit fires once + // things have been idle for DEFERRED_COMMIT_DELAY_MS. + const scheduleDeferredCommit = (): void => { + if (r.deferredCommitTimer !== undefined) { + clearTimeout(r.deferredCommitTimer); + } + r.deferredCommitTimer = setTimeout(() => { + r.deferredCommitTimer = undefined; + // If a viewport gesture (drag-pan, pinch, or a momentum coast) is now in + // flight, it owns the live viewport (which it inherited) and will commit on + // its own settle -- don't double-commit. Otherwise commit now, so a plain + // click/selection that interrupted a wheel scroll or a coast still persists + // the viewport rather than stranding it in local state. + const mode = latest.current.interaction.mode; + const viewportGestureActive = r.momentumAnimationId !== undefined || mode === 'panning' || mode === 'pinching'; + if (!viewportGestureActive) { + commitLiveViewport(); + } + }, DEFERRED_COMMIT_DELAY_MS); }; - // Cancel a pending wheel commit WITHOUT committing -- used when an interrupting - // gesture (a new pointer-down / pinch) takes ownership of the live viewport. - const cancelWheelCommit = (): void => { - if (r.wheelCommitTimer !== undefined) { - clearTimeout(r.wheelCommitTimer); - r.wheelCommitTimer = undefined; + // Cancel a pending deferred commit WITHOUT committing -- used on unmount and by + // an external-view override that supersedes the abandoned gesture. + const cancelDeferredCommit = (): void => { + if (r.deferredCommitTimer !== undefined) { + clearTimeout(r.deferredCommitTimer); + r.deferredCommitTimer = undefined; } }; @@ -1014,7 +1035,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // Update the live viewport and (re)arm the trailing commit; do NOT round-trip // to the controller per event. setLiveViewport({ x: newOffset.x, y: newOffset.y, zoom }); - scheduleWheelCommit(); + scheduleDeferredCommit(); }; // Native wheel zoom handler using exponential scaling for natural macOS feel. @@ -1039,7 +1060,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac const newOffset = zoomAroundPoint(base, cursorCanvas, newCursorCanvas); setLiveViewport({ x: newOffset.x, y: newOffset.y, zoom: newZoom }); - scheduleWheelCommit(); + scheduleDeferredCommit(); }; // Native wheel event handler with { passive: false } to ensure preventDefault works. @@ -1094,24 +1115,17 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac height: contentRect.height, }; const oldSize = latest.current.svgSize; - // Embedded mode draws to tight element bounds and ignores viewBox, so a - // resize there only updates the measured size. - if (oldSize && !latest.current.props.embedded) { + // Re-center + commit only when idle. Embedded mode draws to tight element + // bounds and ignores viewBox. While a viewport gesture owns the live viewport, + // the gesture keeps full control of the offset -- a resize must not shift it + // (that would fight the user / coast, and the shift would be discarded by the + // next move/frame anyway). Only `svgSize` updates here; the gesture's settle + // commit reads the new size from it (see commitLiveViewport). + if (oldSize && !latest.current.props.embedded && !latest.current.liveViewport) { const dWidth = contentRect.width - oldSize.width; const dHeight = contentRect.height - oldSize.height; - const live = latest.current.liveViewport; - if (live) { - // A viewport gesture is in flight: fold the re-centering offset shift into - // the live viewport so the gesture's own settle commit carries it. Do NOT - // commit here -- an onViewBoxChange mid-gesture would be seen as an - // external view change and snap the gesture (see the external-override - // effect). - const adjusted = resizeViewBox(live, dWidth, dHeight, contentRect.width, contentRect.height); - setLiveViewport({ x: adjusted.x, y: adjusted.y, zoom: live.zoom }); - } else { - const newViewBox = resizeViewBox(getCanvasOffset(), dWidth, dHeight, contentRect.width, contentRect.height); - latest.current.props.onViewBoxChange(newViewBox, getCanvasZoom()); - } + const newViewBox = resizeViewBox(getCanvasOffset(), dWidth, dHeight, contentRect.width, contentRect.height); + latest.current.props.onViewBoxChange(newViewBox, getCanvasZoom()); } setSvgSize(newSvgSize); @@ -1492,16 +1506,20 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac e.preventDefault(); e.stopPropagation(); - // A new press interrupts an in-flight viewport gesture: stop the momentum - // coast and cancel any pending wheel-debounce commit so neither fires - // mid-gesture. Neither commits -- the live viewport is preserved, and a pan or - // pinch started by this press inherits it (via panBaseOffset / the pinch - // reference reads) and commits the combined result on its own settle. (A press - // that starts no viewport gesture leaves the offset live; it is folded into - // the next viewport commit. viewBox is presentational and not independently - // saved, so this is benign.) + // A new press interrupts an in-flight momentum coast: stop it without + // committing -- the live viewport is preserved, and a pan or pinch started by + // this press inherits it (via panBaseOffset / the pinch reference reads) and + // commits the combined result on its own settle. If the press instead turns + // out NOT to be a viewport gesture (a click/selection), the deferred commit + // armed here persists the coasted viewport rather than stranding it; its + // callback skips when a viewport gesture is active, so there is no double + // commit. (A pending wheel commit is likewise left armed, not cancelled, for + // the same reason.) + const wasCoasting = r.momentumAnimationId !== undefined; stopMomentumAnimation(); - cancelWheelCommit(); + if (wasCoasting) { + scheduleDeferredCommit(); + } // Track this pointer for multi-touch detection r.activePointers.set(e.pointerId, { @@ -2314,7 +2332,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac const baseline = r.viewBaseline; if (baseline && (baseline.x !== current.x || baseline.y !== current.y || baseline.zoom !== current.zoom)) { stopMomentumAnimation(); - cancelWheelCommit(); + cancelDeferredCommit(); setLiveViewport(undefined); r.viewBaseline = current; } @@ -2468,11 +2486,11 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac } // Cancel any running momentum animation and clear all momentum state stopMomentumAnimation(); - // Cancel a pending wheel-debounce commit WITHOUT firing it: committing - // during teardown would call onViewBoxChange (-> a setState on the - // unmounting host). The dropped commit is harmless -- viewBox is - // presentational and re-persisted on the next interaction. - cancelWheelCommit(); + // Cancel a pending deferred commit WITHOUT firing it: committing during + // teardown would call onViewBoxChange (-> a setState on the unmounting + // host). The dropped commit is harmless -- viewBox is presentational and + // re-persisted on the next interaction. + cancelDeferredCommit(); // Clear velocity tracking and pointer data r.velocityTracker.positions = []; r.activePointers.clear(); diff --git a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx index 747dba545..a4dcab088 100644 --- a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx +++ b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx @@ -223,7 +223,7 @@ describe('Canvas gestures: resize (issue #707)', () => { expect(lastViewBox(h.callbacks.onViewBoxChange)).toEqual({ x: -50, y: -50, zoom: 1 }); }); - it('folds a resize during a pan into the live viewport without committing (no snap)', () => { + it('leaves the offset to an active pan and commits the new size on settle', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); h.resize(1000, 1000); // establish svgSize before the gesture h.clearMountCalls(); @@ -235,17 +235,25 @@ describe('Canvas gestures: resize (issue #707)', () => { pointerMove(h.svg, 530, 540, { shiftKey: true, buttons: 1 }); expect(translate(h.getTransform())).toMatchObject({ x: 30, y: 40 }); - // Resize mid-pan: folds the +dW/4 = +10 re-centering into the live viewport, - // updating the transform but committing nothing. - h.resize(1040, 1000); + // Resize mid-pan: the pan owns the offset, so the offset does NOT shift + // (no re-centering jump) and nothing commits. Only the measured size changes. + h.resize(1200, 900); expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); - expect(translate(h.getTransform())).toMatchObject({ x: 40, y: 40 }); + expect(translate(h.getTransform())).toMatchObject({ x: 30, y: 40 }); - // Releasing stationary commits the pan once, carrying the folded offset. + // The pan continues correctly from its press-time anchor after the resize + // (it must not be discarded or jump): another move yields (60, 80). + clock.tick(10); + pointerMove(h.svg, 560, 580, { shiftKey: true, buttons: 1 }); + expect(translate(h.getTransform())).toMatchObject({ x: 60, y: 80 }); + + // Releasing stationary commits once, carrying the final offset AND the new + // measured size (so view.viewBox.width/height are not left stale). clock.tick(50); - pointerUp(h.svg, 530, 540, { shiftKey: true }); + pointerUp(h.svg, 560, 580, { shiftKey: true }); expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); - expect(lastViewBox(h.callbacks.onViewBoxChange)).toMatchObject({ x: 40, y: 40 }); + const committed = h.callbacks.onViewBoxChange.mock.calls[0][0]; + expect(committed).toMatchObject({ x: 60, y: 80, width: 1200, height: 900 }); } finally { clock.restore(); } diff --git a/src/diagram/tests/canvas-gestures-wheel.test.tsx b/src/diagram/tests/canvas-gestures-wheel.test.tsx index 525a6558f..ad225bed3 100644 --- a/src/diagram/tests/canvas-gestures-wheel.test.tsx +++ b/src/diagram/tests/canvas-gestures-wheel.test.tsx @@ -104,6 +104,35 @@ describe('Canvas gestures: wheel zoom (issue #707)', () => { }); }); +describe('Canvas gestures: a plain click does not strand a pending wheel commit (issue #707)', () => { + it('still commits the wheel offset after an intervening click', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + jest.useFakeTimers(); + try { + // Wheel-pan arms the debounce (uncommitted). + dispatchWheel(h.svg, { deltaX: 30, deltaY: 40 }); + expect(translate(h.getTransform())).toMatchObject({ x: -30, y: -40 }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + + // A plain empty-canvas click (press+release) before the debounce fires. This + // is NOT a viewport gesture, so it must not strand the pending wheel commit. + pointerDown(h.svg, 500, 500); + pointerUp(h.svg, 500, 500); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + + // The debounce still fires and commits the wheel offset. + act(() => { + jest.advanceTimersByTime(200); + }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(h.callbacks.onViewBoxChange.mock.calls[0][0]).toMatchObject({ x: -30, y: -40 }); + } finally { + jest.useRealTimers(); + } + }); +}); + describe('Canvas gestures: external view change overrides a live gesture (issue #707)', () => { it('clears the live wheel viewport and cancels the pending commit', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); @@ -133,6 +162,49 @@ describe('Canvas gestures: external view change overrides a live gesture (issue }); }); +describe('Canvas gestures: a plain click interrupting a coast persists it (issue #707)', () => { + it('commits the coasted offset after the click, via the deferred commit', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + jest.useFakeTimers(); + try { + // Flick to start a coast, then advance a couple of frames. + pointerDown(h.svg, 500, 500, { pointerType: 'touch', isPrimary: true }); + act(() => { + jest.advanceTimersByTime(10); + }); + pointerMove(h.svg, 540, 540, { pointerType: 'touch', isPrimary: true, buttons: 1 }); + act(() => { + jest.advanceTimersByTime(5); + }); + pointerUp(h.svg, 540, 540, { pointerType: 'touch', isPrimary: true }); + act(() => { + jest.advanceTimersByTime(32); + }); + const coasted = translate(h.getTransform()); + expect(coasted.x).toBeGreaterThan(40); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + + // A plain click interrupts the coast. It is not a viewport gesture, so the + // coasted offset must not be stranded: the press stops the coast and arms a + // deferred commit. + pointerDown(h.svg, 200, 200); + pointerUp(h.svg, 200, 200); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + + // The deferred commit fires once idle, persisting the coasted offset exactly + // once. + act(() => { + jest.advanceTimersByTime(200); + }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(h.callbacks.onViewBoxChange.mock.calls[0][0].x).toBeCloseTo(coasted.x, 3); + } finally { + jest.useRealTimers(); + } + }); +}); + describe('Canvas gestures: wheel interrupts momentum (issue #707)', () => { it('continues from the coasted offset and commits once, without a stray commit', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); From fdbfa71dd29506eea42631f088ae7e4c5463b864 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 18:33:49 -0700 Subject: [PATCH 10/11] diagram: settle a coast interrupted by a clamped wheel zoom A ctrl/meta wheel zoom already clamped at MIN/MAX_ZOOM is a no-op, but handleNativeWheel had already stopped any momentum coast before handleNativeWheelZoom returned early -- leaving the coasted live viewport with no settle path, so the visible pan could be lost. Centralize coast interruption in interruptCoast(): it stops the coast and, when one was actually running, arms the deferred commit. handlePointerDown and handleNativeWheel both route through it, so the orphaned offset always has a settle path (re-armed by a moving wheel/pan, skipped when a pan/pinch/coast takes over, or fired by the timer for a click or a clamped no-op wheel) -- and no caller that stops a coast can silently drop it. Part of the issue #707 work. --- src/diagram/drawing/Canvas.tsx | 42 +++++++++++------- .../tests/canvas-gestures-wheel.test.tsx | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index 0b1b34313..49a02f3d3 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -583,6 +583,21 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac } }; + // Stop an in-flight momentum coast that something is interrupting, and -- only + // if a coast was actually running -- arm a deferred commit so the now-orphaned + // live viewport still has a settle path. Whatever interrupted then either + // re-arms (a wheel/pan that moves), makes the deferred callback skip (it becomes + // a pan/pinch/coast), or lets the timer fire (a click, or a wheel that was a + // clamped no-op). Every caller that stops a coast must go through this so the + // coasted pan is never silently dropped. + const interruptCoast = (): void => { + const wasCoasting = r.momentumAnimationId !== undefined; + stopMomentumAnimation(); + if (wasCoasting) { + scheduleDeferredCommit(); + } + }; + const getElementByUid = (uid: UID): ViewElement => { let element: ViewElement | undefined; if (uid === inCreationUid) { @@ -1073,8 +1088,10 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // Always prevent default to stop browser zoom, even at zoom limits e.preventDefault(); - // Stop any momentum animation when user starts interacting - stopMomentumAnimation(); + // Stop any momentum coast this wheel interrupts, arming a deferred commit so + // its offset still settles even if this wheel event turns out to be a no-op + // (a zoom already clamped at MIN/MAX returns early below without committing). + interruptCoast(); // On Mac trackpads, pinch-to-zoom is reported as wheel events with ctrlKey if (e.ctrlKey || e.metaKey) { @@ -1506,20 +1523,13 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac e.preventDefault(); e.stopPropagation(); - // A new press interrupts an in-flight momentum coast: stop it without - // committing -- the live viewport is preserved, and a pan or pinch started by - // this press inherits it (via panBaseOffset / the pinch reference reads) and - // commits the combined result on its own settle. If the press instead turns - // out NOT to be a viewport gesture (a click/selection), the deferred commit - // armed here persists the coasted viewport rather than stranding it; its - // callback skips when a viewport gesture is active, so there is no double - // commit. (A pending wheel commit is likewise left armed, not cancelled, for - // the same reason.) - const wasCoasting = r.momentumAnimationId !== undefined; - stopMomentumAnimation(); - if (wasCoasting) { - scheduleDeferredCommit(); - } + // A new press interrupts an in-flight momentum coast. The live viewport is + // preserved: a pan or pinch started by this press inherits it (via + // panBaseOffset / the pinch reference reads) and commits the combined result + // on its own settle, while a press that is NOT a viewport gesture (a + // click/selection) lets interruptCoast's deferred commit persist the coasted + // viewport. (A pending wheel commit is likewise left armed, not cancelled.) + interruptCoast(); // Track this pointer for multi-touch detection r.activePointers.set(e.pointerId, { diff --git a/src/diagram/tests/canvas-gestures-wheel.test.tsx b/src/diagram/tests/canvas-gestures-wheel.test.tsx index ad225bed3..02eef423a 100644 --- a/src/diagram/tests/canvas-gestures-wheel.test.tsx +++ b/src/diagram/tests/canvas-gestures-wheel.test.tsx @@ -18,6 +18,7 @@ import { act } from '@testing-library/react'; import { dispatchWheel, makeAux, pointerDown, pointerMove, pointerUp, renderCanvas } from './canvas-gesture-harness'; +import { MAX_ZOOM } from '../drawing/viewport'; function translate(transform: string | null): { x: number; y: number; zoom: number } { const m = /matrix\(([^)]+)\)/.exec(transform ?? ''); @@ -162,6 +163,49 @@ describe('Canvas gestures: external view change overrides a live gesture (issue }); }); +describe('Canvas gestures: a clamped wheel zoom interrupting a coast still settles (issue #707)', () => { + it('commits the coasted offset when a clamped (no-op) zoom stops the coast', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + jest.useFakeTimers(); + try { + // Pin zoom at MAX so a ctrl+wheel zoom-in is clamped to a no-op. + h.setViewport({ zoom: MAX_ZOOM }); + + // Flick to start a momentum coast, then advance a couple of frames. + pointerDown(h.svg, 500, 500, { pointerType: 'touch', isPrimary: true }); + act(() => { + jest.advanceTimersByTime(10); + }); + pointerMove(h.svg, 560, 560, { pointerType: 'touch', isPrimary: true, buttons: 1 }); + act(() => { + jest.advanceTimersByTime(5); + }); + pointerUp(h.svg, 560, 560, { pointerType: 'touch', isPrimary: true }); + act(() => { + jest.advanceTimersByTime(32); + }); + const coasted = translate(h.getTransform()); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + + // A ctrl+wheel zoom-in while already at MAX_ZOOM is clamped to a no-op, but + // it still stops the coast. The coasted offset must not be stranded. + dispatchWheel(h.svg, { deltaY: -100, ctrlKey: true, clientX: 0, clientY: 0 }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + + // The deferred commit fires once idle, persisting the coasted offset / zoom. + act(() => { + jest.advanceTimersByTime(200); + }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + expect(h.callbacks.onViewBoxChange.mock.calls[0][0].x).toBeCloseTo(coasted.x, 3); + expect(h.callbacks.onViewBoxChange.mock.calls[0][1]).toBeCloseTo(MAX_ZOOM, 5); + } finally { + jest.useRealTimers(); + } + }); +}); + describe('Canvas gestures: a plain click interrupting a coast persists it (issue #707)', () => { it('commits the coasted offset after the click, via the deferred commit', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); From fc01f19949c41c13d09947f4d6d4c82dd9c6c959 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Mon, 8 Jun 2026 18:54:15 -0700 Subject: [PATCH 11/11] diagram: abandon an active pan/pinch on external view override The external-view override effect cleared liveViewport but left the in-flight pointer gesture's state (interaction mode, panBaseOffset, pinch reference, activePointers) intact. If an external view change (centerVariable, navigation, undo) arrived mid drag-pan or pinch and the user kept moving, handleMovingCanvas / handlePinchMove would recreate liveViewport from the now-stale press-time anchor and the pointer-up would commit that abandoned gesture back over the external view -- so the external view did not actually win. When the override fires and a viewport pointer gesture (panning or pinching) is still in progress, also reset the interaction to idle and drop the pointer anchors (mouseDownPoint/panBaseOffset/pointerId/activePointers). That makes the continued move a no-op and the release a clean no-commit, so the external view wins. Non-viewport gestures don't touch liveViewport and are left alone. Part of the issue #707 work. --- src/diagram/CLAUDE.md | 2 +- src/diagram/drawing/Canvas.tsx | 17 ++++++ .../tests/canvas-gestures-pan-zoom.test.tsx | 52 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/diagram/CLAUDE.md b/src/diagram/CLAUDE.md index b2f458352..a4b22838c 100644 --- a/src/diagram/CLAUDE.md +++ b/src/diagram/CLAUDE.md @@ -64,7 +64,7 @@ Material-style UI component library (40+ components): Accordion, AppBar, Button, Most project/engine invariants now live in `ProjectController` (`project-controller.ts`); the Editor-side ones describe rendering and UI-state consequences. Both are load-bearing. - **Live viewport ownership** (Canvas, issue #707): during a viewport gesture -- drag-pan, momentum coast, wheel/trackpad pan+zoom, pinch -- the Canvas holds offset+zoom in local `liveViewport` state and renders from it, notifying the host (`onViewBoxChange`) exactly **once, on settle** (pan release with no momentum, the momentum coast's natural end, pinch exit, or the `DEFERRED_COMMIT_DELAY_MS` trailing-debounce for wheel). The former per-frame engine round-trip is gone. `getCanvasOffset()`/`getCanvasZoom()` resolve from `liveViewport` when set, else `props.view`; the single settle commit goes through `commitLiveViewport()`, which sources viewBox width/height from the live `svgSize` so a mid-gesture resize settles with the current dimensions. The viewport math is the pure `drawing/viewport.ts`; the shell owns the rAF loop, the `refs.deferredCommitTimer` debounce, and screen->canvas mapping. A drag-pan anchors against `refs.panBaseOffset` (captured at press) so a pan that interrupts a coast does not snap back to the last committed viewBox. Interruptions (a new pointer-down / wheel) never commit and never clear `liveViewport`: the new gesture inherits it (via `panBaseOffset` / the pinch reference reads) and commits the combined result. The `refs.deferredCommitTimer` is the safety net for an **orphaned** live viewport -- one left by a wheel gesture (no end event) or by a coast a non-viewport press interrupted: its guarded callback commits only when no pan/pinch/coast is active, so a plain click between a scroll/coast and the timer still persists the viewport instead of stranding it, with no double commit. A mid-gesture resize updates only `svgSize` (the gesture keeps the offset; no re-centering shift to fight or be discarded); an idle resize re-centers and commits immediately. Embedded mode is viewport-inert (it draws to tight element bounds and ignores viewBox/zoom). These gestures still ultimately call the controller's `queueViewUpdate`, just once per gesture instead of per frame. -- **External view overrides a live gesture** (Canvas, issue #707): a `useEffect` keyed on `[props.view, liveViewport]` compares `props.view`'s offset/zoom VALUE against `refs.viewBaseline` (tracked while idle). While a gesture is live, `props.view` is expected to stay put (a gesture never commits mid-flight), so any value change seen with `liveViewport` still set is external (centerVariable, module navigation, undo) and drops the live viewport + cancels pending momentum/wheel commits -- the external view wins, with no stray commit. A self-commit clears `liveViewport` in the same React commit as its optimistic `props.view` update, so it is never misread as external; comparing by value (not snapshot identity) ignores a content-equal republished view. +- **External view overrides a live gesture** (Canvas, issue #707): a `useEffect` keyed on `[props.view, liveViewport]` compares `props.view`'s offset/zoom VALUE against `refs.viewBaseline` (tracked while idle). While a gesture is live, `props.view` is expected to stay put (a gesture never commits mid-flight), so any value change seen with `liveViewport` still set is external (centerVariable, module navigation, undo) and drops the live viewport + cancels pending momentum/deferred commits -- the external view wins, with no stray commit. A self-commit clears `liveViewport` in the same React commit as its optimistic `props.view` update, so it is never misread as external; comparing by value (not snapshot identity) ignores a content-equal republished view. If a pointer-driven viewport gesture (drag-pan or pinch) is still physically in progress when this fires, it is also abandoned (interaction -> idle; `panBaseOffset`/`mouseDownPoint`/`pointerId`/`activePointers` cleared) -- otherwise a continued move would recreate `liveViewport` from the stale press-time anchor and the pointer-up could commit that abandoned gesture back over the external view. - **Optimistic view updates** (controller): `updateView()`/`queueViewUpdate()` call `applyOptimisticView()` (synchronous snapshot replace of the active model's view + `projectVersion += 0.001`) *before* awaiting the engine round-trip. Any new view-modifying handler must go through these controller methods to avoid flicker. - **updateProject preserves the live view** (controller): `ProjectController.updateProject()` rebuilds `project` from the engine's serialized JSON, then merges via `preserveLiveView()` so the active model's view comes from the live snapshot (the most recent optimistic view). Without this, a slow engine round-trip racing with a newer pan/move would snap the diagram back to the engine's older view. The live view is round-tripped through JSON to re-link element `var` refs and stock inflow/outflow UIDs against the incoming variables. - **View-only updates never record undo history** (controller): the `queueViewUpdate` path calls `updateProject(serialized, { recordHistory: false, scheduleSave: false })` -- it refreshes `project` and bumps `projectVersion` but must not touch `projectHistory`/`projectOffset`: viewBox/zoom are serialized into the protobuf, so recording them would let a single momentum flick evict every real edit from the `MaxUndoSize` (5) buffer. Real edits go through `advanceProjectHistory` (project-history.ts), which discards the redo branch when editing after an undo. diff --git a/src/diagram/drawing/Canvas.tsx b/src/diagram/drawing/Canvas.tsx index 49a02f3d3..00aee09fb 100644 --- a/src/diagram/drawing/Canvas.tsx +++ b/src/diagram/drawing/Canvas.tsx @@ -2345,6 +2345,23 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac cancelDeferredCommit(); setLiveViewport(undefined); r.viewBaseline = current; + // If a pointer-driven viewport gesture (drag-pan or pinch) is still + // physically in progress, abandon it too. Clearing only liveViewport is + // not enough: a continued pointer move would recreate it from the now + // stale press-time anchor (panBaseOffset) / pinch reference and the + // pointer-up could then commit that abandoned gesture back over the + // external view. Resetting the interaction to idle and dropping the + // pointer anchors makes handleMovingCanvas/handlePinchMove no-op and the + // release a clean no-commit. (Non-viewport gestures don't touch + // liveViewport, so they're left alone.) + const mode = latest.current.interaction.mode; + if (mode === 'panning' || mode === 'pinching') { + setInteraction(idleState); + r.mouseDownPoint = undefined; + r.panBaseOffset = undefined; + r.pointerId = undefined; + r.activePointers.clear(); + } } } else { // Idle: track props.view as the baseline for the next gesture. diff --git a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx index a4dcab088..caa1b0138 100644 --- a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx +++ b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx @@ -209,6 +209,58 @@ describe('Canvas gestures: embedded mode is viewport-inert (issue #707)', () => }); }); +describe('Canvas gestures: external override during an active pointer gesture (issue #707)', () => { + it('abandons an active drag-pan so it cannot commit over the external view', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + const clock = installFakeClock(); + try { + pointerDown(h.svg, 500, 500, { shiftKey: true }); + clock.tick(10); + pointerMove(h.svg, 530, 540, { shiftKey: true, buttons: 1 }); + expect(translate(h.getTransform())).toMatchObject({ x: 30, y: 40 }); + + // An external view change arrives mid-drag (undo / centerVariable / nav). + h.setViewport({ x: 200, y: 300, zoom: 1 }); + expect(translate(h.getTransform())).toMatchObject({ x: 200, y: 300 }); + + // Continuing to drag must NOT recreate the pan from the now-stale baseline. + clock.tick(10); + pointerMove(h.svg, 560, 580, { shiftKey: true, buttons: 1 }); + expect(translate(h.getTransform())).toMatchObject({ x: 200, y: 300 }); + + // Releasing must NOT commit the abandoned gesture over the external view. + clock.tick(50); + pointerUp(h.svg, 560, 580, { shiftKey: true }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + } finally { + clock.restore(); + } + }); + + it('abandons an active pinch so it cannot commit over the external view', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + + pointerDown(h.svg, 100, 100, { pointerId: 1, pointerType: 'touch', isPrimary: true }); + pointerDown(h.svg, 200, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false }); + pointerMove(h.svg, 300, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false, buttons: 1 }); + expect(translate(h.getTransform()).zoom).toBeCloseTo(2, 5); + + // External view change mid-pinch. + h.setViewport({ x: 200, y: 300, zoom: 1 }); + expect(translate(h.getTransform())).toMatchObject({ x: 200, y: 300, zoom: 1 }); + + // Continuing the pinch must NOT recreate the viewport from the stale ref. + pointerMove(h.svg, 400, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false, buttons: 1 }); + expect(translate(h.getTransform())).toMatchObject({ x: 200, y: 300, zoom: 1 }); + + // Lifting a finger must NOT commit the abandoned pinch over the external view. + pointerUp(h.svg, 400, 100, { pointerId: 2, pointerType: 'touch', isPrimary: false }); + expect(h.callbacks.onViewBoxChange).not.toHaveBeenCalled(); + }); +}); + describe('Canvas gestures: resize (issue #707)', () => { it('commits the re-centered viewBox immediately when no gesture is in flight', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] });