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
@github-actions

Copy link
Copy Markdown
Contributor

Hello there,
Thank you so much for taking the time and effort to create a pull request to our Nextcloud project.

We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process.

Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6

Thank you for contributing to Nextcloud and we hope to hear from you soon!

(If you believe you should not receive this message, you can add yourself to the blocklist.)

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 feedback-requested

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