Skip to content

Fix production zoom scroll behavior#1295

Open
VanshajPoonia wants to merge 13 commits into
AOSSIE-Org:mainfrom
VanshajPoonia:fix/1293-production-zoom-scroll
Open

Fix production zoom scroll behavior#1295
VanshajPoonia wants to merge 13 commits into
AOSSIE-Org:mainfrom
VanshajPoonia:fix/1293-production-zoom-scroll

Conversation

@VanshajPoonia
Copy link
Copy Markdown
Contributor

@VanshajPoonia VanshajPoonia commented May 28, 2026

Fixes #1293

Screenshots/Recordings:

This PR addresses the zoom-on-scroll behavior described in issue #1293 and the linked reference issue #656.

Before:

  • In production builds, scroll zoom could fall back to cursor-anchored behavior even while the image still fit inside the viewport.
  • Zooming out could leave the image off-center because pan offsets were preserved.

After:

  • Images that fit inside the viewport zoom from the center.
  • Mouse-anchored zoom is only used on axes where the image overflows.
  • Non-overflowing axes stay centered.
  • Zooming back to minimum scale recenters the image instead of leaving it stuck off-center.

Additional Notes:

The regression was caused by the custom wheel listener depending on react-zoom-pan-pinch's internal wrapperComponent being available during the first effect run. In production builds, that timing could fail, causing the viewer to use the library's default wheel behavior.

This PR attaches the custom wheel listener to a stable container owned by ZoomableImage and disables the library's default wheel handling. A focused regression test was added to verify the custom wheel behavior still works when the internal transform wrapper is not ready.

Local verification completed:

cd frontend
npm test -- ZoomableImage.test.tsx
npm run lint:check
npm run build

AI Usage Disclosure:

We encourage contributors to use AI tools responsibly when creating Pull Requests. While AI can be a valuable aid, it is essential to ensure that your contributions meet the task requirements, build successfully, include relevant tests, and pass all linters. Submissions that do not meet these standards may be closed without warning to maintain the quality and integrity of the project. Please take the time to understand the changes you are proposing and their impact. AI slop is strongly discouraged and may lead to banning and blocking. Do not spam our repos with AI slop.

Check one of the checkboxes below:

  • This PR does not contain AI-generated code at all.
  • This PR contains AI-generated code. I have read the AI Usage Policy and this PR complies with this policy. I have tested the code locally and I am responsible for it.

I have used the following AI models and tools: Codex

Checklist

  • My PR addresses a single issue, fixes a single bug or makes a single improvement.
  • My code follows the project's code style and conventions
  • If applicable, I have made corresponding changes or additions to the documentation
  • If applicable, I have made corresponding changes or additions to tests
  • My changes generate no new warnings or errors
  • I have joined the Discord server and I will share a link to this PR with the project maintainers there
  • I have read the Contribution Guidelines
  • Once I submit my PR, CodeRabbit AI will automatically review it and I will address CodeRabbit's comments.
  • I have filled this PR template completely and carefully, and I understand that my PR may be closed without review otherwise.

Summary by CodeRabbit

  • Refactor

    • Improved zoom & pan behavior: more reliable viewport detection, rotation-aware sizing, axis-specific anchoring/clamping, overflow-aware wheel-zoom that recenters when appropriate, and immediate transforms for smoother, more predictable image interaction.
  • Tests

    • Expanded wheel-zoom test suite covering centered zoom, per-axis anchoring, clamping, fit-to-view minimum scaling, re-centering on zoom out, and immediate transform application across multiple overflow scenarios.

@github-actions github-actions Bot added bug Something isn't working build frontend labels May 28, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

ZoomableImage now uses a wheelAreaRef and getViewportElement() to measure viewports, replaces PAN_PADDING with axis-aware helpers, rewrites wheel-zoom interception to use rotation-aware scaled dimensions and mouse-anchored axis logic, updates JSX/handlers to use the new abstraction, and adds comprehensive wheel-zoom tests.

Changes

Dynamic Zoom Wheel Interceptor Refactoring

Layer / File(s) Summary
Types and axis/position helpers
frontend/src/components/Media/ZoomableImage.tsx
Adds Size/OverflowState types and helpers: getCenteredAxisPosition, clampOverflowAxisPosition, getAxisPosition, minimum-scale and edge-fit helpers.
Scaled dimensions, overflow, and reset/viewport API
frontend/src/components/Media/ZoomableImage.tsx
Adds getBaseDimensions, getScaledDimensions, getOverflowState, and getViewportElement. Refactors handleReset and rotation-driven overflow effect to use the derived viewport element and centered-axis helper; updates handleReset dependencies.
ResizeObserver and wheel-area wiring
frontend/src/components/Media/ZoomableImage.tsx
Switches ResizeObserver and wheel listener target to wheelAreaRef, caches viewport rects via getViewportElement() (with fallback), and updates geometry sourcing for wheel handling.
Wheel-zoom algorithm rewrite with mouse anchoring
frontend/src/components/Media/ZoomableImage.tsx
Replaces wheel interceptor: clamp desired scale, compute scaled dimensions and per-axis overflow, decide centered vs axis-anchored targets using mouse position and axis helpers, update isOverflowing, and apply instant transforms (duration=0).
JSX structure and event handler integration
frontend/src/components/Media/ZoomableImage.tsx
Wraps TransformWrapper in a div with wheelAreaRef, disables built-in wheel handling, and updates onTransformed/onZoom/onPanning to use getViewportElement() and the new axis helpers for clamping.
Unit tests for wheel-zoom behavior
frontend/src/components/Media/__tests__/ZoomableImage.test.tsx
Adds Jest suite with mocks for convertFileSrc and react-zoom-pan-pinch, DOM rect helpers, wheel-scene setup, and multiple tests asserting anchored/centered/clamped transforms and scale changes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • AOSSIE-Org/PictoPy#835: Both PRs modify frontend/src/components/Media/ZoomableImage.tsx’s wheel-zoom interception and overflow-aware panning/clamping logic.

Suggested labels

TypeScript/JavaScript

Suggested reviewers

  • rahulharpal1603

Poem

🐇 I scurried near the wheel and twitched my nose,

I measure bounds where the zooming goes,
Center when it fits, anchor when it spills,
Clamp every edge with careful little thrills,
Hopping with joy as setTransform fills.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing production zoom scroll behavior, which is the core objective of the PR.
Linked Issues check ✅ Passed The code changes implement all required objectives: dynamic minimum zoom with axis-dependent anchoring, overflow detection, mouse-anchored zoom only on overflowing axes, and automatic re-centering at minimum scale.
Out of Scope Changes check ✅ Passed All changes in ZoomableImage.tsx and its test file directly address the production zoom scroll regression and implement the required axis-dependent anchoring and overflow logic.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/components/Media/__tests__/ZoomableImage.test.tsx`:
- Around line 97-127: Add two new tests in ZoomableImage.test.tsx alongside the
existing "stable container intercept" case: (1) an axis-dependent anchoring test
that sets up mockElementRect so only one axis overflows (e.g., width larger than
container but height fits), fires a wheel event and asserts mockSetTransform was
called with the expected anchor coordinates and scale change (use the same
pattern as the existing test referencing wheelArea, image, fireEvent.wheel, and
mockSetTransform), and (2) a recenter-on-min-scale test that starts at >min
scale, simulates a zoom-out to the minimum scale via wheel events, and asserts
that the transform was reset to the centered coordinates (relying on
ZoomableImage, mockElementRect, and mockSetTransform to verify recentering).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cf69a53e-2307-4afe-88f9-84a1a37efe16

📥 Commits

Reviewing files that changed from the base of the PR and between b88b61d and e84a26b.

📒 Files selected for processing (2)
  • frontend/src/components/Media/ZoomableImage.tsx
  • frontend/src/components/Media/__tests__/ZoomableImage.test.tsx

Comment thread frontend/src/components/Media/__tests__/ZoomableImage.test.tsx Outdated
@rohan-pandeyy
Copy link
Copy Markdown
Contributor

@VanshajPoonia can you create a test release in your fork for me to test the implementation?

@rohan-pandeyy rohan-pandeyy added the question Further information is requested label May 29, 2026
@VanshajPoonia
Copy link
Copy Markdown
Contributor Author

Sure, I created a test release in my fork:

https://github.com/VanshajPoonia/PictoPy/releases/tag/v1.1.0-pr1295-test.1

The release is for testing the zoom on scroll implementation from this PR

@rohan-pandeyy
Copy link
Copy Markdown
Contributor

Not exactly what I meant. To create a release for testing, you'll need to manually trigger the build-and-release.yml workflow in your fork and make sure you've selected the correct branch.

For the workflow to run successfully, you'll also need to remove a few GitHub.secret references and some additional lines from the workflow file.

Please refer to the link in the Discord server for more details on how to get the workflow running on your fork:
https://discord.com/channels/1022871757289422898/1311271974630330388/1504393318274961508

@VanshajPoonia
Copy link
Copy Markdown
Contributor Author

VanshajPoonia commented May 30, 2026

Hi, just confirming before I run the test release workflow.

I’m planning to use a separate temporary branch in my fork with my PR changes plus the workflow changes from the Discord thread you referred me to.

The temporary branch would include:

  1. Removing the Tauri signing private key env line.
  2. Removing the Tauri signing private key password env line.
  3. Setting includeUpdaterJson to false.
  4. Setting createUpdaterArtifacts to false.
  5. Adding releaseName for the manual workflow run.

I’ll keep my actual PR branch unchanged with only the zoom fix. The temporary branch will only be used to run build-and-release.yml from my fork, and I’ll share the generated release link once it finishes.

Does that match what you expected?

@rohan-pandeyy
Copy link
Copy Markdown
Contributor

Hi, just confirming before I run the test release workflow.

I’m planning to use a separate temporary branch in my fork with my PR changes plus the workflow changes from the Discord thread you referred me to.

The temporary branch would include:

  1. Removing the Tauri signing private key env line.

  2. Removing the Tauri signing private key password env line.

  3. Setting includeUpdaterJson to false.

  4. Setting createUpdaterArtifacts to false.

  5. Adding releaseName for the manual workflow run.

I’ll keep my actual PR branch unchanged with only the zoom fix. The temporary branch will only be used to run build-and-release.yml from my fork, and I’ll share the generated release link once it finishes.

Does that match what you expected?

Yes, that's exactly the right approach

@VanshajPoonia
Copy link
Copy Markdown
Contributor Author

I ran the test release workflow from a temporary branch in the fork using the fork safe workflow changes.

Release link:
https://github.com/VanshajPoonia/PictoPy/releases/tag/v1.1.0-pr1295-test.2

Workflow run:
https://github.com/VanshajPoonia/PictoPy/actions/runs/26691066575

@rohan-pandeyy
Copy link
Copy Markdown
Contributor

rohan-pandeyy commented May 30, 2026

I ran the test release workflow from a temporary branch in the fork using the fork safe workflow changes.

Release link: https://github.com/VanshajPoonia/PictoPy/releases/tag/v1.1.0-pr1295-test.2

The Zoom on Scroll functionality is still the same (broken) in the release 🤔

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/Media/ZoomableImage.tsx (1)

154-159: ⚡ Quick win

Use a single owned viewport element (wheelAreaRef) for all geometry math
getViewportElement() falls back to transformRef.current?.instance?.wrapperComponent, but wrapperComponent is an internal (undocumented, non-public) implementation detail in react-zoom-pan-pinch v3.7.0; this can couple rect/overflow/wheel calculations to library internals and change behavior across lifecycle phases when wrapperComponent becomes available. Prefer always measuring against wheelAreaRef.current for getBoundingClientRect()/overflow math (and remove the wrapperComponent fallback).

Also applies to: 248-255, 268-271

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/Media/ZoomableImage.tsx` around lines 154 - 159,
Replace the logic in getViewportElement so it always returns the owned
wheelAreaRef.current (remove any use of
transformRef.current?.instance?.wrapperComponent), and update any other places
that currently branch to wrapperComponent (referenced as transformRef,
wrapperComponent and getViewportElement) to use wheelAreaRef.current for all
getBoundingClientRect/overflow/wheel geometry math; ensure wheelAreaRef is
non-null where used or guard with a clear early return to avoid accessing
undefined.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/components/Media/ZoomableImage.tsx`:
- Around line 114-150: getScaledDimensions and getOverflowState assume scale===1
is the fit-to-view floor; instead compute a true minimum scale from the measured
viewer size and the image’s natural dimensions (use
imageRef.current.naturalWidth/naturalHeight and the current viewportRect) and
use that as the baseline. Update getScaledDimensions (which currently calls
getEffectiveDimensions) to accept or compute minScale and return dimensions
based on Math.max(scale, minScale); update getOverflowState to compare
scaledDimensions (using that minScale) against viewportWidth/viewportHeight; and
change the wheel-recenter path to clamp the target scale to that computed
minScale so recentering never produces a scale smaller than the measured
minimum. Ensure references to getScaledDimensions, getOverflowState,
getEffectiveDimensions, imageRef, and viewportRect are used to locate and
implement these changes.

---

Nitpick comments:
In `@frontend/src/components/Media/ZoomableImage.tsx`:
- Around line 154-159: Replace the logic in getViewportElement so it always
returns the owned wheelAreaRef.current (remove any use of
transformRef.current?.instance?.wrapperComponent), and update any other places
that currently branch to wrapperComponent (referenced as transformRef,
wrapperComponent and getViewportElement) to use wheelAreaRef.current for all
getBoundingClientRect/overflow/wheel geometry math; ensure wheelAreaRef is
non-null where used or guard with a clear early return to avoid accessing
undefined.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 69f802e3-5394-4450-b1ad-78f06ec4f375

📥 Commits

Reviewing files that changed from the base of the PR and between e84a26b and 5b8af33.

📒 Files selected for processing (2)
  • frontend/src/components/Media/ZoomableImage.tsx
  • frontend/src/components/Media/__tests__/ZoomableImage.test.tsx

Comment thread frontend/src/components/Media/ZoomableImage.tsx Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
frontend/src/components/Media/__tests__/ZoomableImage.test.tsx (1)

159-183: ⚡ Quick win

Document the intentionally-null wrapper to protect the regression scenario.

This test deliberately leaves mockWrapperComponent as null (reset in beforeEach), so it exercises the getViewportElement() fallback to wheelArea — i.e. the exact "internal transform wrapper not ready" path this PR fixes. Without a note, a future maintainer could add mockWrapperComponent = ... here and silently invalidate the regression coverage.

📝 Suggested clarifying comment
   test('keeps zoom centered while the image still fits in the viewport', () => {
+    // Intentionally do NOT set mockWrapperComponent: this verifies the
+    // getViewportElement() fallback when the library wrapper isn't ready (`#1293`).
     const { container } = renderZoomableImage();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/Media/__tests__/ZoomableImage.test.tsx` around lines
159 - 183, Add a short explanatory comment in the test "keeps zoom centered
while the image still fits in the viewport" stating that mockWrapperComponent is
intentionally left null (it’s reset in beforeEach) so the test exercises the
getViewportElement() fallback to wheelArea; place the comment near the call to
renderZoomableImage()/mockWrapperComponent declaration so future maintainers
don't set mockWrapperComponent and inadvertently remove this regression coverage
for getViewportElement(), wheelArea and the internal transform wrapper path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@frontend/src/components/Media/__tests__/ZoomableImage.test.tsx`:
- Around line 159-183: Add a short explanatory comment in the test "keeps zoom
centered while the image still fits in the viewport" stating that
mockWrapperComponent is intentionally left null (it’s reset in beforeEach) so
the test exercises the getViewportElement() fallback to wheelArea; place the
comment near the call to renderZoomableImage()/mockWrapperComponent declaration
so future maintainers don't set mockWrapperComponent and inadvertently remove
this regression coverage for getViewportElement(), wheelArea and the internal
transform wrapper path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8deb3f05-4d25-444b-8724-95f95d6c46a8

📥 Commits

Reviewing files that changed from the base of the PR and between cd4a727 and cda6bcc.

📒 Files selected for processing (2)
  • frontend/src/components/Media/ZoomableImage.tsx
  • frontend/src/components/Media/__tests__/ZoomableImage.test.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/components/Media/ZoomableImage.tsx

@VanshajPoonia
Copy link
Copy Markdown
Contributor Author

I am still working on this issue

@VanshajPoonia
Copy link
Copy Markdown
Contributor Author

@rohan-pandeyy Thank you for testing the earlier build.

I have pushed an updated implementation that removes the hidden zoom lifecycle dependency on react-zoom-pan-pinch for this viewer and now controls the image transform directly. The public ImageViewer API is unchanged.

I also updated the temporary fork-only workflow branch and ran the build-and-release workflow successfully from there.

Root cause: the custom zoom behavior depended on react-zoom-pan-pinch internals being initialized at the right time. In production, the image/wrapper sizing and transform lifecycle could initialize differently than in dev, so the viewer sometimes fell back into stale/default transform behavior.

Fix: ZoomableImage now owns the image transform directly instead of layering custom behavior over react-zoom-pan-pinch. Wheel zoom, fit-to-view, panning, clamping, image switch reset, rotation reset, and recentering are handled in one controlled path.

Workflow run:
https://github.com/VanshajPoonia/PictoPy/actions/runs/26734665656

Release link:
https://github.com/VanshajPoonia/PictoPy/releases/tag/v1.1.0-pr1295-test.9

The real PR branch is still focused on the zoom fix only. The temporary release branch contains the fork-safe workflow changes needed to generate test artifacts.

Copy link
Copy Markdown
Contributor

@rohan-pandeyy rohan-pandeyy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VanshajPoonia, thanks for the test release. I verified the changes, and they do work. However, there is a subtle behavioral difference that I'd like to point out. Please read the comment mentioned within ZoomableImage component.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous ZoomableImage implementation softened zoom behavior in two ways:

  1. The wheel configuration used a zoom step of 0.1 with a smoothing factor of 0.001, which naturally eased the motion.
  2. The image position was blended back toward the center using safeRatio, making the transition away from the current anchor gradual rather than immediate.

Your implementation is more piecewise in nature: if the image still fits, it remains centered; if it overflows, it switches to edge or cursor anchoring and then clamps the final position. Because of this, when testing the same mouse-wheel movement on an image that starts at or above the viewport size and is being zoomed out, the interaction feels noticeably harsher.

TL;DR

Before: The wheel delta was smoothed, and image positioning was continuously interpolated or anchored with additional smoothing.

Current implementation: The wheel change is applied more directly, and the anchoring rule switches as soon as the overflow state changes. As a result, larger images that start at or above viewport resolution feel less smooth when zooming out.

The resulting values are correct, but the perceived motion changes because the transition between positioning modes is more abrupt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working build frontend question Further information is requested

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: Dynamic Zoom on Scroll Not Working in Production

2 participants