feat(Viewer): add zoom/pan and rotate to image viewer#1812
Open
vladopol wants to merge 9 commits into
Open
Conversation
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
12 tasks
ShGKme
self-requested a review
July 15, 2026 17:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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 calledmoveTo()from inside the'pan'event handler —moveTo()synchronously re-fires'pan', and sincegetBoundingClientRect()returns pre-repaint geometry inside that cascade, the correction never converged: unbounded recursion (the freeze @Antreesy hit).t=1frame but never callsstep()for it, so zooming out settles at scale ≈ 1.002–1.007 instead of 1. Combined with strictscale === 1checks this left the viewer in the "neither pan nor zoom works" state @ShGKme found after the second double-click.What changed
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.zoomend.smoothMoveTo(0, 0)is gone), so it smoothly returns to fit regardless of where you double-click.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, andboundsPadding: 1pins 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
AI-assisted, per the AI policy: label +
Assisted-bytrailers. All changes reviewed and manually verified by the author.