Skip to content

feat(Viewer): add zoom/pan and rotate to image viewer#1812

Open
vladopol wants to merge 9 commits into
nextcloud:mainfrom
vladopol:feat/image-viewer-panzoom
Open

feat(Viewer): add zoom/pan and rotate to image viewer#1812
vladopol wants to merge 9 commits into
nextcloud:mainfrom
vladopol:feat/image-viewer-panzoom

Conversation

@vladopol

Copy link
Copy Markdown

This is a reworked resubmission of #1756, which was rightfully closed — the previous version shipped with functional defects that testing should have caught. This version has been verified: every scenario from the #1756 review was reproduced against the old code, fixed, covered by an automated test, and additionally checked by hand in the dev app.

Closes #1535 (zoom), closes #1757 (rotate)

Root causes of the #1756 defects

All five reported problems traced back to two causes:

  1. The custom clampToBounds() was broken by design. It clamped the full-size wrapper instead of the image, so a letterboxed image could be panned almost entirely off-screen. Worse, it called moveTo() from inside the 'pan' event handler — moveTo() synchronously re-fires 'pan', and since getBoundingClientRect() returns pre-repaint geometry inside that cascade, the correction never converged: unbounded recursion (the freeze @Antreesy hit).
  2. panzoom's smooth zoom never reaches its target: amator computes the final t=1 frame but never calls step() for it, so zooming out settles at scale ≈ 1.002–1.007 instead of 1. Combined with strict scale === 1 checks this left the viewer in the "neither pan nor zoom works" state @ShGKme found after the second double-click.

What changed

  • Bounds are computed from the image's fit box using layout values only (offsetWidth/clientWidth — unaffected by CSS transforms, so never stale mid-animation). Per axis: the image stays centered while it fits the container, and its edges never move inside the container edges once it exceeds it.
  • The clamp mutates panzoom's transform model in place inside the event handlers — applied with the already-scheduled repaint, cannot re-trigger events, no recursion possible.
  • All fit-state checks use an epsilon; the scale snaps to exactly 1 on zoomend.
  • Zoom-out now re-centers the image on every animation frame (the conflicting smoothMoveTo(0, 0) is gone), so it smoothly returns to fit regardless of where you double-click.
  • Rotating by 90°/270° applies a fit compensation so landscape images don't overflow the container; container resizes re-measure and re-clamp.

Why not native bounds: true (@Antreesy's question): panzoom's built-in bounds track the transformed element — here the full-size wrapper, not the letterboxed image inside it, so they allow exactly the "image pans out of view" bug. Attaching panzoom directly to the <img> doesn't work either: the DOM controller's bbox assumes the element sits at the owner's top-left, which breaks flex centering, and boundsPadding: 1 pins a letterboxed image to a corner. A custom clamp is required; it just has to be written against the transform model instead of live DOM rects.

How it was tested

  • Automated: a Playwright harness replicating the component's exact DOM/CSS/logic drives real mouse interactions. Each review scenario from feat(Viewer): add zoom/pan and rotate to image viewer #1756 (pan-out-of-viewport, boundary freeze, off-center zoom-out displacement, dead-interaction state, wrong pan boundary) reproduces against the old logic and passes with this one, plus 13 edge cases: letterboxed portrait/landscape/small images, five consecutive zoom cycles, wheel zoom to 8× and back at a corner, rotate + zoom + pan combinations, container resize while zoomed, and a stress drag/zoom run with zero errors.
  • Manual: all of the above exercised by hand in the running app (macOS).

AI-assisted, per the AI policy: label + Assisted-by trailers. All changes reviewed and manually verified by the author.

vladopol added 9 commits June 17, 2026 20:33
Install panzoom library (9.4.4, 6 kB) and apply it to the image
viewer wrapper in ViewerHandlerImages.vue:

- Scroll wheel to zoom in/out (1× – 8×)
- Drag to pan when zoomed in
- Double-click to zoom in 3× at cursor, double-click again to reset
- Auto-center image when zoom returns to 1×
- Pan blocked at minimum zoom to prevent accidental shifts
- Cursor changes: zoom-in at 1×, grab/grabbing when zoomed

Fixes nextcloud#1535

Signed-off-by: Vladimir Poluliashenko <vladopol@gmail.com>
Assisted-by: ClaudeCode:claude-sonnet-4-6
Signed-off-by: Vladimir Poluliashenko <vladopol@gmail.com>
Assisted-by: ClaudeCode:claude-sonnet-4-6
…lement

Signed-off-by: Vladimir Poluliashenko <vladopol@gmail.com>
Assisted-by: ClaudeCode:claude-sonnet-4-6
- Use useTemplateRef API (Vue 3.5+) for panzoom wrapper ref
- Use plain variable for panzoom instance (no reactive state needed)
- Remove unnecessary null guard in initPanzoom (wrapperRef is always
  defined when called from onImageLoad)
- Add comment explaining dblclick.capture usage

Signed-off-by: Vladimir Poluliashenko <vladopol@gmail.com>
Assisted-by: ClaudeCode:claude-sonnet-4-6
Replace panzoom's built-in bounds option (ineffective when the panzoom
element fills its parent 100%) with manual clamping via
getBoundingClientRect. clampToBounds() is called on panend and after
each zoom to smoothly return the image within the viewport if needed.

Signed-off-by: Vladimir Poluliashenko <vladopol@gmail.com>
Assisted-by: ClaudeCode:claude-sonnet-4-6
- Clamp on every pan event (not just panend) for immediate stopping
- Disable smoothScroll (inertia) so the image halts on release without
  overshooting the boundary

Signed-off-by: Vladimir Poluliashenko <vladopol@gmail.com>
Assisted-by: ClaudeCode:claude-sonnet-4-6
smoothZoomAbs(x, y, 0) scaled the image to zero, making it disappear.
Using ZOOM_MIN (1) correctly returns the image to its original size.

Signed-off-by: Vladimir Poluliashenko <vladopol@gmail.com>
Assisted-by: ClaudeCode:claude-sonnet-4-6
Add rotate left (−90°) and rotate right (+90°) buttons to the image
viewer actions menu alongside the existing "Open in web browser" action.

Rotation state and controls are fully self-contained in ViewerHandlerImages:
- rotateLeft/rotateRight adjust rotation in 90° steps using unbounded angle
  increments so the CSS transition always animates in the correct direction
- Rotation resets to 0 when switching images
- ViewerHandlerImages exposes an actions array; ViewerApp renders it
  generically via component :is — no image-specific code in ViewerApp

Fixes nextcloud#1757

Signed-off-by: Vladimir Poluliashenko <vladopol@gmail.com>
Assisted-by: ClaudeCode:claude-sonnet-4-6
Fixes the issues found in review:

- Pan could move a letterboxed image almost entirely out of the viewport:
  bounds were clamped against the full-size wrapper instead of the image.
  Bounds are now computed from the image fit box (layout values only) —
  per axis the image is centered while it fits the container and its edges
  never leave the container edges once it exceeds it.
- Dragging at the pan boundary could freeze the viewer: clampToBounds()
  called moveTo() from the 'pan' event handler, which re-fires 'pan'
  recursively while getBoundingClientRect() still returns the pre-repaint
  geometry, so the correction never converged. The clamp now mutates the
  panzoom transform model in place inside the event handlers (applied with
  the already scheduled repaint) and cannot re-trigger events.
- Double-click zoom-out around an off-center point left the image
  displaced or off-screen: the bounds correction now re-centers the image
  on every animation frame, so it smoothly returns to the centered fit.
- After a zoom-out animation the scale settled at ~1.002..1.007 instead
  of 1 (panzoom's animation never applies the final t=1 frame), so strict
  scale === 1 checks left the viewer in a state where neither pan nor
  zoom worked. All fit checks now use an epsilon and the scale is snapped
  to exactly 1 when the zoom-out animation ends.
- Rotating an image by 90°/270° could overflow the container: the rotated
  image is now compensated to keep it fully visible.
- Container resizes re-measure the fit box and re-clamp the transform.

Signed-off-by: Vladimir Poluliashenko <vladopol@gmail.com>
Assisted-by: ClaudeCode:claude-sonnet-5
@ShGKme ShGKme added enhancement New feature or request AI assisted This PR contains AI-assisted commits labels Jul 15, 2026
@ShGKme
ShGKme self-requested a review July 15, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI assisted This PR contains AI-assisted commits enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Image viewer: add rotate left/right buttons macOS: Zoom on pictures

2 participants