Background
Split out from the cinematic double-tap zoom umbrella (#36, item 10 of the zoom-rendering audit). One of the three candidate behaviours was attempted and deliberately reverted — read the retro on #36 before picking this up.
Original intent (from the audit)
CanvasView.tsx:454-462 — if the user double-taps empty space, the camera always runs fitToViewport, even at extreme zoom levels. Sometimes you want a small zoom-out, not "show the entire world". Three candidate behaviours:
- Scale to 1× at the current focal point (Safari Smart Zoom default)
- "De-zoom by one step" — if scale > 1, go to 1×; if scale < 1, go to fit; if scale ≈ 1, no-op
- Fit-all (current behaviour)
What happened when it was tried
Option 1 was implemented on feat/cinematic-double-tap-zoom (PR #42, closed unmerged) and reverted. At typical fit-view scales (~0.5×), tapping empty space pulled the camera in to 1.0× — the opposite of the "I'm lost, take me home" intent. The retro's diagnosis: Safari's 1× is meaningful because it is the page's natural design scale; on a canvas, nodes live in world space with no canonical "100% view", so a scale-based reset is the wrong primitive. Fit-all was confirmed the right default.
Remaining live scope
- Option 2 (de-zoom by one step) is untested and remains plausible: it only diverges from fit-all when zoomed in past 1×, which is exactly the case where fit-all feels like a teleport.
- Alternatively (or additionally), expose the policy as a
CanvasView prop so embedding apps choose, with fit-all as the default.
- Option 1 (bare "reset to 1×") should not be re-attempted as a default — that decision is settled.
Acceptance criteria
Refs: #36
Agent prompt
Repo: workspace-sh/react-native-jsoncanvas. Read issue #36 in full (especially the
two retro comments — option 1 below was implemented in PR #42 and reverted), then
this issue.
Task: improve empty-space double-tap behaviour without regressing the confirmed
default. Constraints from the retro: a bare "reset to 1x" default is settled as
wrong (at fit scales ~0.5x it zooms IN, opposite of user intent); fit-all is the
correct default whenever scale <= 1.
Steps:
1. Read src/renderer/CanvasView.tsx — the double-tap handler's empty-space branch
(fitToViewport fallback, around lines 454-462) and the animateCamera tween.
2. Implement option 2 ("de-zoom by one step"): if scale > 1, animate to 1x while
preserving the current focal point; if scale is within ~2% of 1, no-op; if
scale < 1, fitToViewport as today. Consider exposing the policy as an optional
CanvasView prop (e.g. emptySpaceDoubleTap: 'fit' | 'step') defaulting to the
current fit-all if a prop feels cleaner — document the choice on the issue.
3. Do not modify pan/pinch/scroll-wheel gesture handlers — they are out of bounds.
4. Verify in the harness app at ~0.5x, ~1x, and ~3x starting scales; run the
existing animation tests.
Branch + PR (Conventional Commits), PR body includes "Refs: #<this issue>".
Background
Split out from the cinematic double-tap zoom umbrella (#36, item 10 of the zoom-rendering audit). One of the three candidate behaviours was attempted and deliberately reverted — read the retro on #36 before picking this up.
Original intent (from the audit)
CanvasView.tsx:454-462— if the user double-taps empty space, the camera always runsfitToViewport, even at extreme zoom levels. Sometimes you want a small zoom-out, not "show the entire world". Three candidate behaviours:What happened when it was tried
Option 1 was implemented on
feat/cinematic-double-tap-zoom(PR #42, closed unmerged) and reverted. At typical fit-view scales (~0.5×), tapping empty space pulled the camera in to 1.0× — the opposite of the "I'm lost, take me home" intent. The retro's diagnosis: Safari's 1× is meaningful because it is the page's natural design scale; on a canvas, nodes live in world space with no canonical "100% view", so a scale-based reset is the wrong primitive. Fit-all was confirmed the right default.Remaining live scope
CanvasViewprop so embedding apps choose, with fit-all as the default.Acceptance criteria
Refs: #36
Agent prompt