Skip to content

diagram: make the canvas viewport local during gestures#734

Merged
bpowers merged 11 commits into
mainfrom
issue-707-canvas-live-viewport
Jun 9, 2026
Merged

diagram: make the canvas viewport local during gestures#734
bpowers merged 11 commits into
mainfrom
issue-707-canvas-live-viewport

Conversation

@bpowers

@bpowers bpowers commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Summary

Implements Option C for issue #707: the canvas now owns its viewport (offset+zoom) locally during a gesture and notifies the controller (onViewBoxChange -> queueViewUpdate) exactly once, on settle, instead of once per animation frame / wheel event. This removes the full engine round-trip -- applyPatch + serializeProtobuf + serializeJson(includeStdlib) + projectFromJson + preserveLiveView -- that previously ran on every pan/zoom frame.

Drag-pan already committed once on release; this generalizes that pattern to every viewport path (momentum, wheel/trackpad pan+zoom, pinch, resize) and unifies them behind a single local liveViewport {x, y, zoom}.

Fixes #707.

What changed

  • New pure module drawing/viewport.ts (functional core): wheel pan/zoom, zoom-around-cursor, pinch, momentum friction, release-velocity, resize -- unit-tested without jsdom.
  • Canvas.tsx holds offset+zoom in local liveViewport and renders from it; getCanvasOffset()/getCanvasZoom() resolve from it when set, else props.view. One commitLiveViewport() is the settle-time commit for all paths:
    • drag-pan: on release (no momentum) or the coast's natural end (with momentum) -- never both;
    • wheel/trackpad: a 200ms trailing debounce;
    • pinch: on exit.
  • Drag-pan anchors against refs.panBaseOffset so a pan interrupting a coast doesn't snap back to the last committed viewBox.
  • Interruptions never commit and never lose the live offset; the new gesture inherits it and commits the combined result.
  • An external props.view change (centerVariable / navigation / undo) observed mid-gesture overrides the gesture (drops the live viewport, cancels pending commits).
  • A mid-gesture resize folds into the live viewport (no commit); embedded mode stays viewport-inert.

Testing

  • tests/viewport.test.ts covers the pure math.
  • Reconciler-level gesture tests assert the contract -- a multi-event gesture fires onViewBoxChange exactly once (on settle) and the rendered <g transform> updates live in between -- across pan, momentum (single-commit + interrupted-coast continuity), wheel pan/zoom (debounce + burst re-arm + momentum interruption), pinch (commit on exit), resize (fold vs commit), embedded inertness, and the external-view override.
  • Harness gains deterministic clock/rAF control, native wheel dispatch, a live-transform accessor, and resize / external-view helpers.
  • Full diagram suite green (1144 tests); the pre-commit hook (Rust + WASM + TS + pysimlin) passed on every commit.

Notes / follow-up

The controller is unchanged: queueViewUpdate's rebuild still runs once per gesture. Issue #707's "Option B" (skipping the full project rebuild for view-only updates) remains a possible follow-up but is out of scope here.

bpowers added 8 commits June 8, 2026 15:35
Pull the pan/zoom/pinch/momentum arithmetic out of Canvas.tsx into a new
pure module drawing/viewport.ts (the functional core): wheel pan/zoom,
zoom-around-cursor, pinch zoom, momentum friction, release-velocity
estimation, and resize. Canvas keeps the DOM-bound work -- screen->canvas
mapping, the rAF loop, and React state -- and calls these pure transforms.

This is a behavior-preserving refactor (viewport updates still round-trip
to the controller per frame); it exists to make the upcoming live-viewport
change test-driven, since the math can now be unit-tested without jsdom.

Part of the issue #707 work.
Replace the offset-only `movingCanvasOffset` local state with a unified
`liveViewport {x, y, zoom}`. Offset and zoom now both resolve from the live
viewport while a gesture is in flight (via getCanvasOffset/getCanvasZoom) and
from props.view otherwise. This gives pinch and wheel-zoom a local home for
the in-progress zoom, which they previously lacked -- the prerequisite for
making those paths fully local.

No behavior change: drag-pan still commits on pointer-up, and since a pan
never changes zoom the live zoom equals props.view.zoom throughout.

Part of the issue #707 work.
The momentum rAF loop now writes the local live viewport each frame instead
of round-tripping every frame through the controller (the per-frame engine
serialize/re-parse that issue #707 is about). A coasted pan commits exactly
once, at the coast's natural end; startMomentumAnimation returns whether a
coast actually started so pointer-up commits immediately only for a stationary
release. The two paths are mutually exclusive, so a gesture commits once.

Also anchor each drag-pan against the offset captured at press time
(refs.panBaseOffset) rather than props.view.viewBox, so a pan that interrupts
an in-flight coast (whose offset is not yet committed) continues from the
on-screen position instead of snapping back to the last committed viewBox.

Adds deterministic clock/rAF control to the gesture harness (velocity
estimation and the coast both read the clock) plus a live-transform accessor,
and covers: single-commit-on-settle, live transform during the coast, and the
interrupted-coast -> pan continuity.

Part of the issue #707 work.
Wheel and trackpad pan/zoom now update the local live viewport on each event
and (re)arm a 200ms trailing debounce instead of round-tripping to the
controller per event -- the per-frame engine serialize/re-parse that issue
#707 targets. The single commit fires once the scroll settles. A new
pointer-down cancels the pending debounce so it can't fire mid-gesture; the
pan/pinch it starts inherits the live offset and commits the combined result.
The unmount cleanup cancels (does not flush) the timer so no commit lands on
an unmounting host.

Adds a native-wheel dispatch helper to the harness and covers, under Jest
fake timers, wheel pan/zoom single-commit-on-settle, burst re-arming, and a
wheel interrupting a momentum coast (no stray commit, continues from the
coasted offset).

Part of the issue #707 work.
handlePinchMove now writes the local live viewport each move instead of
round-tripping to the controller per move; the single commit fires when the
second finger lifts. Pinch start anchors its fixed reference (initial zoom and
the model point under the fingers) against the live viewport rather than
props.view, so a pinch that follows an uncommitted pan keeps its place, and it
no longer clears the live viewport on entry.

Updates the pinch test to assert the live transform zooms during the move with
no commit, and exactly one commit on exit.

Part of the issue #707 work.
handleSvgResize now branches on whether a viewport gesture is in flight: when
the live viewport is set it folds the re-centering offset shift into it (no
commit), so the gesture's own settle commit carries the new size; when idle it
commits immediately as before. Committing during a gesture would register as an
external view change and snap the gesture. Resize is also gated to non-embedded
mode (embedded draws to tight element bounds and ignores viewBox).

Makes the harness ResizeObserver triggerable (jsdom never fires real ones) and
covers idle-resize-commits and resize-mid-pan-folds-without-snapping.

Part of the issue #707 work.
While a gesture owns the live viewport, props.view is expected to stay put (a
gesture never commits mid-flight). If its offset/zoom value nonetheless changes
-- centerVariable, module navigation, or an undo that restored a different
viewport -- that external view must win. A new effect compares props.view by
value against a baseline tracked while idle and, when a change is seen with the
live viewport still set, drops the live viewport and cancels any pending
momentum/wheel commit so the abandoned gesture leaves no stale commit. A
self-commit clears the live viewport in the same React commit as its optimistic
props.view update, so it is never misread as external; comparing by value (not
snapshot identity) ignores a content-equal republished view.

Adds a setViewport helper to the harness and covers the centerVariable/
navigation race against a pending wheel commit.

Part of the issue #707 work.
Adds the issue #707 invariants to src/diagram/CLAUDE.md (live-viewport
ownership + single settle commit, and the external-view override) and a test
asserting embedded mode is viewport-inert -- wheel and resize never set a live
viewport, commit, or produce a content transform (embedded draws to a tight
viewBox attribute).

Also adds a Pull Requests section to the root CLAUDE.md: PR descriptions must
carry a GitHub 'Fixes #<n>' closing keyword for each resolved issue (kept in the
PR body, not commit messages, so the auto-close fires once on merge).

Part of the issue #707 work.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00a047d4e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/diagram/drawing/Canvas.tsx Outdated
// external view change and snap the gesture (see the external-override
// effect).
const adjusted = resizeViewBox(live, dWidth, dHeight, contentRect.width, contentRect.height);
setLiveViewport({ x: adjusted.x, y: adjusted.y, zoom: live.zoom });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve resized dimensions when settling live viewport

When the canvas resizes during an active viewport gesture, this branch computes the resized width/height but stores only x, y, and zoom in liveViewport. The later commitLiveViewport() rebuilds the committed viewBox from props.view.viewBox, so the resize is settled with the old dimensions. In split-pane/detail-panel resize while panning, pinching, or before wheel debounce fires, the controller keeps stale viewport dimensions, which affects subsequent centering/zoom calculations that read view.viewBox.width/height.

Useful? React with 👍 / 👎.

Comment thread src/diagram/drawing/Canvas.tsx Outdated
// the next viewport commit. viewBox is presentational and not independently
// saved, so this is benign.)
stopMomentumAnimation();
cancelWheelCommit();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Commit pending wheel viewport before plain clicks

If a user wheel-pans/zooms and then clicks an element before the 200 ms debounce fires, this unconditionally cancels the only scheduled settle commit. A normal click/selection path does not call commitLiveViewport(), so onViewBoxChange never reaches the controller until some later viewport gesture; the screen stays moved via local state, but persistence and any host logic reading props.view keep using the old viewport.

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Jun 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.60%. Comparing base (d42c8a7) to head (fc01f19).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #734   +/-   ##
=======================================
  Coverage   90.60%   90.60%           
=======================================
  Files         224      224           
  Lines      136247   136247           
=======================================
  Hits       123447   123447           
  Misses      12800    12800           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Code review for PR #734

[P2] Resize during a gesture: the fold is overwritten by subsequent gesture math

File: src/diagram/drawing/Canvas.tsx:1099-1115

handleSvgResize folds the re-centering shift (dWidth/4, dHeight/4) into liveViewport when a gesture is in flight, but it does not shift the gesture-time anchors. handleMovingCanvas recomputes the live offset every move from r.panBaseOffset + (curr - mouseDownPoint) (line 1422), and animateMomentum recomputes every frame from r.momentumStartOffset + frictionPosition(...) (line 901). Both anchors are captured at gesture start and never updated by the resize fold, so the next pointer-move (during a drag-pan that survives the resize) or the next rAF tick (during a momentum coast) overwrites liveViewport and discards the resize shift. The included "folds a resize during a pan" test passes only because it never issues another pointerMove after the resize. To make the fold actually persist, the resize handler should also shift r.panBaseOffset and r.momentumStartOffset by (dWidth/4, dHeight/4) when they are set. Repro: drag-pan with a ResizeObserver-firing layout change (e.g. dock auto-hide, DevTools opening) mid-drag, then continue dragging — the diagram jumps back by the resize shift.

Overall correctness verdict

The patch is functionally correct for the primary issue #707 goal (single-commit-per-gesture) and the tests cover all the main paths. The resize-fold issue above is a real but edge-case correctness gap; it does not block merge, but the intended fold behavior will not survive past the next move/frame.

…ommits

Three fixes from PR #734 review:

- commitLiveViewport sources viewBox width/height from the live svgSize rather
  than props.view.viewBox, so a resize that fires mid-gesture settles with the
  current dimensions instead of stale ones (centerVariable and friends read
  view.viewBox.width/height).
- A mid-gesture resize no longer folds a re-centering offset shift into the live
  viewport. That shift was immediately discarded by the next pan move / momentum
  frame (which recompute the offset from their press-time anchor), and shifting
  the offset out from under an active gesture is wrong anyway. The gesture now
  keeps full control of the offset; only svgSize updates, and the settle commit
  carries the new size. An idle resize still re-centers and commits immediately.
- Generalize the wheel trailing-debounce into a guarded 'deferred commit' for an
  ORPHANED live viewport and also arm it when a press interrupts a momentum
  coast. handlePointerDown no longer cancels it. Its callback commits only when
  no pan/pinch/coast is active, so a plain click between a wheel scroll (or a
  coast) and the timer persists the viewport instead of stranding it in local
  state, with no double commit. Before this, a click interrupting a coast (or a
  pending wheel) left props.view stale until the next viewport gesture.

Tests: resize-during-pan now asserts the offset is untouched, the pan continues
from its anchor after the resize, and the commit carries the new dimensions;
new tests cover a plain click persisting a pending wheel offset and an
interrupted coast.

Part of the issue #707 work.
@bpowers

bpowers commented Jun 8, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: be3ef71f03

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1048 to 1050
const { zoom: newZoom, changed } = wheelZoom(zoom, e.deltaY);
if (!changed) {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve coast commits when zoom is clamped

When a momentum coast is active and the user sends a ctrl/meta wheel zoom that is already clamped at MIN_ZOOM or MAX_ZOOM, handleNativeWheel has just stopped the momentum loop, but this early return skips scheduleDeferredCommit(). That leaves the coasted liveViewport with no remaining settle path, so the visible pan can be lost instead of being sent to onViewBoxChange; schedule a deferred commit (or avoid stopping the coast) when a clamped zoom event interrupts an existing live viewport.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

[P3] Wheel-zoom-at-limit during a momentum coast can leave the live viewport orphaned

In src/diagram/drawing/Canvas.tsx:1068-1085, handleNativeWheel calls stopMomentumAnimation() (which stops the coast without committing or clearing liveViewport) and then routes to handleNativeWheelZoom. When the zoom would land at MIN_ZOOM/MAX_ZOOM (or below ZOOM_EPSILON), handleNativeWheelZoom early-returns without calling setLiveViewport or scheduleDeferredCommit. The result is an orphaned liveViewport (the coast's last frame) that won't commit: r.momentumAnimationId is now undefined, so a subsequent handlePointerDown sees wasCoasting = false and skips scheduling, and a plain click won't persist the state either. This is the same class of stranding that the latest commit fixed for the press-interrupting-a-coast path; only this wheel-zoom-at-limit edge case is missed. Visually the view stays consistent, but props.view never receives the commit, so the change is lost on unmount/navigation. A minimal fix is to capture wasCoasting in handleNativeWheel (mirroring handlePointerDown) and call scheduleDeferredCommit() when true.


Overall correctness verdict: correct. The PR is well-tested and the recently-addressed review feedback resolves the more significant prior gaps (mid-resize offset corruption, orphaned-wheel state). The remaining finding above is a narrow edge case that doesn't block correctness for the common paths.

A ctrl/meta wheel zoom already clamped at MIN/MAX_ZOOM is a no-op, but
handleNativeWheel had already stopped any momentum coast before
handleNativeWheelZoom returned early -- leaving the coasted live viewport with
no settle path, so the visible pan could be lost.

Centralize coast interruption in interruptCoast(): it stops the coast and, when
one was actually running, arms the deferred commit. handlePointerDown and
handleNativeWheel both route through it, so the orphaned offset always has a
settle path (re-armed by a moving wheel/pan, skipped when a pan/pinch/coast
takes over, or fired by the timer for a click or a clamped no-op wheel) -- and
no caller that stops a coast can silently drop it.

Part of the issue #707 work.
@bpowers

bpowers commented Jun 9, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fdbfa71dd2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2344 to +2346
stopMomentumAnimation();
cancelDeferredCommit();
setLiveViewport(undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset active gesture state on external override

When an external view change arrives during an active drag-pan or pinch, this effect only clears liveViewport; it leaves interaction, panBaseOffset, and the pinch reference data intact. If the user keeps moving the pointer after a center/navigation/undo update, handleMovingCanvas or handlePinchMove will recreate liveViewport from the stale gesture baseline and the later pointer-up can commit that abandoned gesture over the external view, so the external viewport does not actually win in active pointer gestures.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review

I reviewed the diff (10 commits, ~1.5k additions) covering the canvas viewport refactor for issue #707. The design is well-considered, the pure/imperative split (drawing/viewport.ts vs Canvas.tsx) is clean, and the test coverage spans the meaningful interaction sequences (single-commit-on-settle, interrupted-coast-continuity, burst re-arm, mid-gesture resize folding, external view override, embedded inertness, plain-click-doesn't-strand-pending-commit, and the clamped-zoom + interrupted-coast settle path that motivated interruptCoast).

I traced the key state transitions and timer interactions, including:

  • The mutually-exclusive single-commit invariant across pan release / momentum end / pinch exit / deferred timer.
  • The interruption chain (coast → pan → release) anchoring via panBaseOffset.
  • The useEffect([props.view, liveViewport]) override against r.viewBaseline, including the self-commit case where the batched onViewBoxChange + setLiveViewport(undefined) lands in the same React commit so the effect sees liveViewport === undefined and updates baseline rather than firing the override.
  • The deferred-commit guard skipping when a viewport gesture took over, and commitLiveViewport's early-return preventing double commits when the deferred timer fires after a self-commit already cleared the live viewport.
  • commitLiveViewport sourcing width/height from svgSize ?? props.view.viewBox so a mid-gesture resize settles with current dimensions.

The math in viewport.ts (wheelPanOffset / wheelZoom / zoomAroundPoint / pinchOffset / friction model) is internally consistent and matches the prior in-line code.

I did not identify any discrete actionable bugs to flag.

Overall correctness verdict: correct.

The external-view override effect cleared liveViewport but left the in-flight
pointer gesture's state (interaction mode, panBaseOffset, pinch reference,
activePointers) intact. If an external view change (centerVariable, navigation,
undo) arrived mid drag-pan or pinch and the user kept moving, handleMovingCanvas
/ handlePinchMove would recreate liveViewport from the now-stale press-time
anchor and the pointer-up would commit that abandoned gesture back over the
external view -- so the external view did not actually win.

When the override fires and a viewport pointer gesture (panning or pinching) is
still in progress, also reset the interaction to idle and drop the pointer
anchors (mouseDownPoint/panBaseOffset/pointerId/activePointers). That makes the
continued move a no-op and the release a clean no-commit, so the external view
wins. Non-viewport gestures don't touch liveViewport and are left alone.

Part of the issue #707 work.
@bpowers

bpowers commented Jun 9, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review verdict

I reviewed the changes against the issue 707 goal (one settle-time commit per gesture) and walked through the gesture interactions (pan, momentum, wheel pan/zoom, pinch, resize) plus the external-view override path. The architecture is clean -- pure math in viewport.ts, lifecycle in the shell -- and the single-commit contract is rigorously enforced across every gesture tail: pan release with no momentum (commitLiveViewport in handlePointerCancel), momentum natural end (commitLiveViewport in animateMomentum), wheel/trackpad (scheduleDeferredCommit 200ms trailing debounce re-armed per event, guarded callback skips when a viewport gesture is now active), and pinch exit (commitLiveViewport in handlePointerCancel). interruptCoast correctly arms a deferred commit only when something actually was coasting, so a non-viewport press between a coast and the timer still persists the viewport.

I traced several potentially risky scenarios: an orphaned wheel timer firing while a different gesture is now in flight (guarded by viewportGestureActive), a coast interrupted by another viewport gesture vs by a click (correctly differentiated; the new gesture inherits via panBaseOffset or the pinch reference reads, a click lets the deferred commit fire), mid-gesture resize (only updates svgSize; commit reads the new dimensions at settle), external view change during pan/pinch (drops the live viewport AND neutralizes the in-flight gesture by resetting interaction to idle and clearing pointer anchors, so a continued move/release cannot re-write the live viewport). The StrictMode mount/unmount/mount cycle is covered by the symmetric cleanup that cancels the deferred timer and momentum animation.

Test coverage is thorough -- the new viewport.test.ts covers the pure math, and the reconciler-level gesture tests assert both the single committed viewBox AND that the live g transform updates in between, including interrupted-coast continuity, wheel-zoom clamped-to-no-op interrupting a coast, the abandoned-gesture override semantics, and resize-during-pan.

No findings.

Overall correctness verdict: Correct.

@bpowers bpowers merged commit aa1477e into main Jun 9, 2026
15 checks passed
@bpowers bpowers deleted the issue-707-canvas-live-viewport branch June 9, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Canvas view changes trigger a full engine round-trip per frame during pan/zoom

1 participant