9.6 KiB
9.6 KiB
Image Viewer Pinch Zoom
Problem
src/renderer/src/components/editor/ImageViewer.tsx:27stores viewer zoom and exposes toolbar zoom controls.src/renderer/src/components/editor/ImageViewer.tsx:106renders the inline image surface without a wheel handler.src/renderer/src/components/editor/ImageViewer.tsx:198renders the popup image surface without a wheel handler.- On Chromium/Electron, trackpad pinch gestures arrive as
wheelevents withctrlKey: true; the viewer does not intercept them, so users cannot pinch to zoom the image in or out.
Root Cause
ImageViewer only changes zoom through button clicks. The image surfaces let ctrl-wheel events bubble/default, so pinch gestures never update zoom and may be consumed by Chromium-level page zoom instead. The inline surface is scrollable only in fill layout; in intrinsic layout it is overflow-visible, so the fix must not depend on scrollability.
Non-goals
- Do not redesign the image viewer chrome or add new controls.
- Do not change PDF handling;
application/pdfstill delegates toPdfViewer. - Do not implement pan/drag or persistent per-file zoom.
- Do not change image loading, file IPC, or SSH/runtime preview fetching.
Design
- Add pure zoom helpers in a concrete renderer module such as
image-viewer-zoom.tsso the math can be tested under the repo's node-based Vitest setup:clampZoom(next)clamps to the existingMIN_ZOOMandMAX_ZOOM.shouldHandleImageZoomWheel(eventLike)returns true only for ctrl-wheel input.getPinchZoomFactor(deltaY, deltaMode)normalizes pixel/line/page wheel deltas and returns a bounded multiplier;deltaY === 0should not change zoom.getNextWheelZoom(currentZoom, deltaY, deltaMode)applies the factor and clamp without touching React state.
- Add an
applyZoomChange(fn)wrapper insideImageViewerthat uses functionalsetZoom, and resetzoomto1whenfilePath,mimeType, orcleanedContentchanges.EditorContentcan reuse the sameImageViewercomponent position across image files, so local zoom must not accidentally carry across file switches or external reloads. - Add refs for the inline image surface and popup image surface. Bind native
wheellisteners with{ passive: false }, not ReactonWheel; this repo already uses native non-passive wheel listeners where Chromium default prevention must be reliable. Keep the listener callback stable enough that it is not rebound on every zoom tick. - The wheel handler must:
- ignore ordinary wheel/trackpad scroll;
- handle only
event.ctrlKeywheel events, which Chromium/Electron uses for trackpad pinch and Ctrl+wheel zoom; - call
preventDefault()andstopPropagation()for every handled ctrl-wheel event, including when already clamped, so browser/app zoom does not also change; - map negative
deltaYto zoom in and positivedeltaYto zoom out; - use the bounded delta-scaled multiplier instead of applying the full toolbar
ZOOM_STEPonce per wheel event.
- Attach the listener to both image surfaces, not to
documentorwindow, so editor/terminal/browser panes outside the image viewer keep their existing wheel behavior. The popup listener must attach after the Radix dialog content mounts and clean up when it unmounts; a callback ref or an effect keyed byisPopupOpenis acceptable. - Keep existing toolbar buttons wired through the same clamp helper.
Data Flow
- Trackpad pinch over image surface
- Chromium emits
WheelEventwithctrlKey - Native non-passive
wheellistener intercepts it zoomstate updates- Existing inline and popup image transforms render at the new scale
- Footer percent updates from
zoomPercent - When the rendered file changes,
zoomresets to1while object URL creation/revocation stays on the existing path
Edge Cases
- Ordinary scrolling without
ctrlKeymust still scroll the image surface. - Pinch gestures at
MIN_ZOOMorMAX_ZOOMmust stay clamped. - Ctrl-wheel at
MIN_ZOOMorMAX_ZOOMmust still prevent default browser zoom. - Popup and inline surfaces within one
ImageViewermust stay in sync because they share onezoomstate. - Image diff panes each mount their own
ImageViewer; pinch over one side should only zoom that side. Do not introduce cross-pane sync in this fix. layout="intrinsic"must receive pinch events even though the inline surface is not an overflow scroller.- Ctrl-wheel over the footer toolbar should keep existing button behavior and not become a hidden zoom target; the listener belongs on the image surface only.
- PDF previews must remain unaffected.
- The behavior must work for local and SSH-backed images because it is renderer-only after bytes are loaded.
- Windows/Linux touchpads that also surface pinch as
ctrlKeywheel events should work without platform-specific branches. - File switches, file reloads, external file mutations, and multi-window sessions should not add shared state: zoom remains local to the mounted viewer, resets for new loaded image content, object URL cleanup remains unchanged, and native wheel listeners must be removed on unmount/remount.
- Existing CSS transform zoom does not resize the scrollable layout box. Do not try to solve transform-origin panning or full-image scroll extents in this change; keep pinch behavior consistent with the existing toolbar zoom.
Test Plan
- Unit tests: cover the extracted zoom helpers under the existing node Vitest config; this repo does not currently include jsdom, happy-dom, or Testing Library, so do not promise DOM component tests unless that tooling is deliberately added.
- Unit tests: assert negative/positive/zero
deltaY, pixel/line/pagedeltaModenormalization, min/max clamping, and bounded per-event zoom factors. - Unit tests: assert the wheel decision helper ignores non-ctrl wheel events and treats ctrl-wheel as handled even when the resulting zoom is already clamped or
deltaYis0. - Electron validation: open a PNG in Orca, dispatch a cancelable
WheelEventwithctrlKeyon the inline image surface, verify the displayed percent and visual scale change, and verify an ordinary wheel still scrolls where applicable. - Electron validation: open popup and repeat after the dialog mounts; close/reopen the popup and repeat once to catch duplicate or stale native listeners.
- Electron validation: open an image diff and verify ctrl-wheel on one pane changes only that pane's percent.
- Electron validation: switch from one image file to another or reload changed image content and verify zoom returns to
100%.
UI Quality Bar
Pinch zoom should feel like an existing viewer capability rather than a new UI surface: no layout shift, no new visible controls, footer percent remains stable, ordinary scrolling remains available, zoom changes are not jumpy under high-frequency trackpad events, and the popup chrome matches the current image viewer styling.
Review Screenshots
- Inline image viewer at default
100%. - Inline image viewer after pinch zoom in, showing a higher footer percent.
- Popup image viewer after pinch zoom out/in, showing the same percent as the inline viewer for that
ImageViewer. - Image diff viewer after pinch zooming only one pane, showing the other pane unchanged.
- Adjacent smoke state: normal editor/file surface still renders after closing the popup.
Rollout
- Add wheel/pinch zoom handling to
ImageViewer. - Add focused tests for the pinch handler behavior.
- Run typecheck, lint, and relevant renderer tests.
- Validate in Electron and capture screenshots.
Lightweight Eng Review
- Scope: kept to
ImageViewergesture handling and focused tests; no new viewer state model, persistence, or chrome changes. - Architecture/data flow: renderer-only DOM wheel handling updates existing
zoomstate; no main-process, IPC, SSH, runtime, or persistence boundary changes. - Failure modes covered:
- ordinary wheel scroll accidentally blocked;
- browser/app zoom also responding to pinch;
- zoom exceeding existing min/max;
- popup and inline surfaces diverging;
- diff panes being accidentally synchronized;
- native listener leaks or duplicate listeners after popup open/close;
- stale zoom state under rapid wheel event bursts;
- accidental zoom carryover across image file switches or external reloads;
- PDF previews accidentally receiving image gesture behavior.
- Test coverage required:
- node unit tests for extracted zoom math and wheel-decision helpers;
- Electron validation for native listener default-prevention behavior, popup wheel handling, diff-pane isolation, and zoom reset on image content changes;
- screenshot validation for inline default, inline zoomed, popup zoomed, image-diff isolation, and adjacent editor state.
- Performance/blast radius: low but not free; trackpads emit many wheel events, so the handler must do O(1) work, use functional state updates, avoid layout reads, and avoid document/window listeners. Blast radius stays inside mounted
ImageViewerinstances. - UI quality bar: no new visible UI; existing footer percent and scrollable image surface should remain visually stable.
- Required review screenshots:
- Inline image viewer at
100%. - Inline image viewer after pinch-equivalent zoom in.
- Popup image viewer after pinch-equivalent zoom.
- Image diff viewer after pinch-equivalent zoom on one pane only.
- Editor/file surface after closing popup.
- Inline image viewer at
- Residual risks: automated Electron validation can dispatch cancelable ctrl-wheel DOM events against the listener, but it does not fully prove OS-level physical trackpad pinch behavior or Chromium's browser-zoom default path. A manual trackpad smoke test is still the final confidence check when hardware is available.