Skip to content

slides: pinch to zoom the canvas, and move it with two fingers - #278

Open
iamgeo92 wants to merge 1 commit into
nyblnet:mainfrom
iamgeo92:pinch-zoom
Open

slides: pinch to zoom the canvas, and move it with two fingers#278
iamgeo92 wants to merge 1 commit into
nyblnet:mainfrom
iamgeo92:pinch-zoom

Conversation

@iamgeo92

@iamgeo92 iamgeo92 commented Aug 10, 2026

Copy link
Copy Markdown

The problem

A deck opens at 19–27% on a handset — measured on the starter deck:

iPhone 14 portrait  (390×664)  →  25%
iPhone 14 landscape (844×390)  →  27%
iPhone SE / Display Zoom (320×568) → 19%

So a phone always needs to zoom. And the only way to reach the zoom was the
two small buttons in the bottom corner, because multi-touch over the canvas was
swallowed outright:

// canvas.ts — before
const swallowPinch = (ev: TouchEvent) => { if (ev.touches.length > 1) ev.preventDefault() }
this.scroller.addEventListener('touchstart', swallowPinch, { passive: false })
this.scroller.addEventListener('touchmove',  swallowPinch, { passive: false })

Swallowing it is right — the comment above it explains why, and it should
stay: a page-level pinch throws Selecto's coordinates off mid-marquee and has
crashed the tab. But it left the gesture meaning nothing at all, on the one
device where it is the first thing everybody reaches for.

The fix

The pinch drives the editor's own zoom. The page still never zooms.

Anchored, not re-centred. setZoom re-centres the view on the slide, which
is right for a button and wrong for a gesture — the thing being pinched would
slide out from under the fingers doing the pinching. zoomAround() compensates
the scroll so the slide point between your fingers stays put. Measured drift
across a full pinch: 1.6 slide px (it was 640 — a whole half-slide — before
the scroll compensation actually landed).

The midpoint pans, so one gesture both scales and moves, as on every map and
canvas tool.

The part that took the longest to find

The Moveable gesture lock has to stand down for the duration. The first
finger has already told Moveable a drag is beginning, and the lock pins the
scroller to wherever that finger landed:

this.scroller.addEventListener('scroll', () => {
  if (gestureLock) { this.scroller.scrollLeft = lockL; this.scroller.scrollTop = lockT }
})

So every scroll the zoom performed was snapped straight back, and the slide
zoomed about the wrong point — the first pinch step anchored correctly and every
step after it collapsed to scrollLeft = 0. The pinch now claims the scroller
and stops that drag. The lock still does its real job for one-finger drags.

Before / after

Identical gestures both sides: pinch out, pinch again off-centre, pinch back in.

pinch to zoom, before and after

before:  25% → 25% → 25% → 25%      (the gesture does nothing)
after:   25% → 93% → 198% → 56%
before — pinched, still 25% after — 198%, anchored where the fingers were

How this was verified

iPhone emulation (390×664, DPR 3, hasTouch) with real two-point CDP touch
sequences:

case result
pinch out zooms in (25% → 80%) pass
pinch in zooms out (80% → 25%) pass
the pinched point stays put — drift 1.6 slide px pass
two fingers travelling together pan the canvas pass
repeated pinching stays inside the existing 0.5×–8× clamp pass
the page itself is never zoomed (visualViewport.scale === 1) pass
a one-finger tap still selects pass
a one-finger drag still moves an element pass
desktop: the − / + zoom buttons still work pass

Gates:

node_modules/.bin/tsc -b                                        clean
npm run build:single                                            ok (664KB shell)
node scripts/shell-gate.mjs …/Bento_Slides.bento.html           splice contract OK

No new user-facing strings, so no i18n catalog changes. No format or kernel
change; nothing under sync/.

Two honest notes

  • Emulation, not a device. Verified under Chromium's iPhone emulation. Real
    Safari also has non-standard gesturestart/gesturechange events; this
    implementation deliberately uses plain touchstart/touchmove, which work on
    both, but momentum and rubber-banding on real glass deserve a device check.
  • Synthetic touch and Moveable. CLAUDE.md's testing note is accurate — a
    scripted one-finger drag moves an element much further than the finger
    travels. I checked that against main with the same script: the numbers are
    identical with and without this change, so it is the known
    synthetic-events artifact and not a regression here. Real-mouse/real-finger QA
    is still the right bar for the drag path.

Changelog entry — not in the diff, on purpose

CHANGELOG.md is this repo's named conflict magnet (docs/PARALLEL-WORK.md §3). With several of these open at once, and [Unreleased] being emptied every time a release is cut, a changelog hunk made every one of them conflict on that file and nothing else — twice over. They carry no CHANGELOG.md change so they stay mergeable; here is the entry to drop in at release time, or I'll add it back in whatever form you prefer.

- **Pinch to zoom the canvas, and move it with two fingers.** A deck opens at
  19–27% on a handset — always needing zoom — and the only way to reach it was
  the two small buttons in the bottom corner, because multi-touch on the canvas
  was swallowed outright. Swallowing it was right (a page-level pinch throws off
  the marquee's coordinates and has crashed the tab) but it left the gesture
  meaning nothing at all. A pinch now drives the editor's own zoom, anchored on
  the point between your fingers rather than re-centring on the slide, so what
  you are pinching stays where you put it; the midpoint's travel pans at the
  same time. The page itself still never zooms, one-finger selection, dragging
  and the marquee are untouched, and the zoom stays inside the same 0.5×–8×
  bounds as the buttons.

A deck opens at 19-27% on a handset — always needing zoom — and the only
way to reach the zoom was the two small buttons in the bottom corner,
because multi-touch over the canvas was swallowed outright.

Swallowing it was right: a page-level pinch throws Selecto's coordinates
off mid-marquee and has crashed the tab. But it left the gesture meaning
nothing, on the one device where it is the gesture everybody reaches for
first.

The pinch now drives the editor's own zoom, anchored on the point
between the fingers. setZoom re-centres on the slide, which is right for
a button and wrong for a gesture — the thing being pinched would slide
out from under the fingers doing the pinching — so zoomAround()
compensates the scroll instead. The midpoint's own travel pans at the
same time, which is the other half of the same gesture on every canvas
tool.

The Moveable gesture lock has to stand down for the duration. The first
finger has already told Moveable a drag is beginning, which pins the
scroller to where that finger landed, so every scroll the zoom performed
was snapped straight back and the slide zoomed about the wrong point.
The pinch claims the scroller and stops that drag.

The page itself still never zooms; one-finger selection, dragging and
the marquee are untouched; and the result stays inside the same 0.5x-8x
bounds as the buttons.
@iamgeo92

Copy link
Copy Markdown
Author

@nyblnet — one for the queue when you have time.

A deck opens at 19–27% on a handset, so a phone always needs to zoom, and the only way there was the two small corner buttons — multi-touch over the canvas was swallowed outright. Swallowing it is right (your comment explains the Selecto/marquee crash), but it left the gesture meaning nothing on the one device where everybody reaches for it first.

The bit that took longest to find, and the bit I'd most like you to sanity-check: the Moveable gesture lock has to stand down for the duration. The first finger has already told Moveable a drag is starting, which pins the scroller to where that finger landed — so every scroll the zoom performed was snapped straight back and the slide zoomed about the wrong point. Anchor drift went from 640 slide px to 1.6.

One file, +81/−7. One-finger selection, dragging and the marquee are untouched, and the page itself still never zooms.

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.

1 participant