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..a4b22838c 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, 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/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 9be9f9cc5..00aee09fb 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,17 @@ 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; +// 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. -// 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; +// 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 { @@ -303,6 +305,27 @@ 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; + + // 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 + // 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; @@ -311,6 +334,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 @@ -322,7 +358,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; @@ -344,7 +380,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); @@ -373,6 +409,9 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac draggedLinkArc: undefined, }, activePointers: new Map(), + panBaseOffset: undefined, + deferredCommitTimer: undefined, + viewBaseline: undefined, velocityTracker: { positions: [] }, momentumAnimationId: undefined, momentumStartTime: undefined, @@ -398,7 +437,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac editingName, dragSelectionPoint, moveDelta, - movingCanvasOffset, + liveViewport, svgSize, inCreation, inCreationCloud, @@ -476,7 +515,88 @@ 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; + + // 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; + } + // 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 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 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; + } + }; + + // 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; @@ -498,7 +618,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 @@ -771,64 +891,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,54 +917,44 @@ 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; - - // 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) { + const v0 = r.momentumInitialVelocity; + + // 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; } - // 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, - }; - - // 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); + // 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. 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); + 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; @@ -906,6 +962,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 @@ -959,114 +1016,66 @@ 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); + const newOffset = pinchOffset(currentCenterCanvas, interactionNow.modelPoint); - // 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 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) ------ 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; - // 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; - - const newViewBox = { - ...viewBox, - x: viewBox.x - deltaX, - y: viewBox.y - deltaY, + // 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(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 }); + scheduleDeferredCommit(); }; // 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; - - // 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; + const zoom = getCanvasZoom(); - // 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 (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; - - // 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 base = getCanvasOffset(); const newCursorCanvas = getCanvasPointWithZoom(e.clientX, e.clientY, newZoom); - const newOffset = { - x: newCursorCanvas.x - modelX, - y: newCursorCanvas.y - modelY, - }; - - 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 }); + scheduleDeferredCommit(); }; // Native wheel event handler with { passive: false } to ensure preventDefault works. @@ -1079,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) { @@ -1121,19 +1132,17 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac height: contentRect.height, }; const oldSize = latest.current.svgSize; - if (oldSize) { + // 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 canvasOffset = getCanvasOffset(); - - const newViewBox: ViewRect = { - x: canvasOffset.x + dWidth / 4, - y: canvasOffset.y + dHeight / 4, - width: contentRect.width, - height: contentRect.height, - }; - - latest.current.props.onViewBoxChange(newViewBox, latest.current.props.view.zoom); + const newViewBox = resizeViewBox(getCanvasOffset(), dWidth, dHeight, contentRect.width, contentRect.height); + latest.current.props.onViewBoxChange(newViewBox, getCanvasZoom()); } setSvgSize(newSvgSize); @@ -1145,6 +1154,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(); @@ -1168,6 +1178,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( @@ -1362,18 +1375,15 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac return; } - if (interactionNow.mode === 'panning' && latest.current.movingCanvasOffset) { - const newViewBox = { - ...latest.current.props.view.viewBox, - x: latest.current.movingCanvasOffset.x, - y: latest.current.movingCanvasOffset.y, - }; - - latest.current.props.onViewBoxChange(newViewBox, latest.current.props.view.zoom); - setMovingCanvasOffset(undefined); - - // Start momentum animation for smooth deceleration - startMomentumAnimation(); + if (interactionNow.mode === 'panning' && latest.current.liveViewport) { + // 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) { @@ -1438,7 +1448,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 = { @@ -1450,9 +1462,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 => { @@ -1510,8 +1523,13 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac e.preventDefault(); e.stopPropagation(); - // Stop any momentum animation when user starts interacting - stopMomentumAnimation(); + // 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, { @@ -1534,32 +1552,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 movingCanvasOffset 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); - setMovingCanvasOffset(undefined); return; } @@ -1715,6 +1735,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 @@ -2301,6 +2324,56 @@ 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(); + 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. + 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`. @@ -2440,6 +2513,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 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(); @@ -2502,7 +2580,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); @@ -2538,7 +2616,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})`; 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/canvas-gesture-harness.tsx b/src/diagram/tests/canvas-gesture-harness.tsx index 411b5df8e..01ab3f6a8 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; } @@ -407,6 +444,21 @@ 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; + /** 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; } /** @@ -493,9 +545,137 @@ export function renderCanvas(opts: HarnessOptions): CanvasHarness { fn.mockClear(); } }, + 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 }); + }, }; } +// --------------------------------------------------------------------------- +// 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..caa1b0138 100644 --- a/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx +++ b/src/diagram/tests/canvas-gestures-pan-zoom.test.tsx @@ -6,16 +6,28 @@ * 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. +// 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 { 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; @@ -32,37 +44,276 @@ 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 }); - 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(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(); + } + }); - // 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 }); + } + + 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(); + } + }); +}); + +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: 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 }); - expect(lastViewBox(h.callbacks.onViewBoxChange)).toEqual({ x: 40, y: 60, zoom: 1 }); - expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + // 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)] }); + // 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('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(); + 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: 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: 30, y: 40 }); + + // 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, 560, 580, { shiftKey: true }); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + const committed = h.callbacks.onViewBoxChange.mock.calls[0][0]; + expect(committed).toMatchObject({ x: 60, y: 80, width: 1200, height: 900 }); + } finally { + clock.restore(); + } }); }); 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(); @@ -74,27 +325,37 @@ 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', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); h.clearMountCalls(); + 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 }); - 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 }); - // 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); + expect(h.callbacks.onViewBoxChange).toHaveBeenCalledTimes(1); + } finally { + clock.restore(); + } }); }); 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..02eef423a --- /dev/null +++ b/src/diagram/tests/canvas-gestures-wheel.test.tsx @@ -0,0 +1,294 @@ +/** + * @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'; +import { MAX_ZOOM } from '../drawing/viewport'; + +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: 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)] }); + 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: 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)] }); + 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)] }); + 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(); + } + }); +}); 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, + }); + }); +});