Skip to content

Fix production zoom scroll behavior#1295

Merged
rohan-pandeyy merged 17 commits into
AOSSIE-Org:mainfrom
VanshajPoonia:fix/1293-production-zoom-scroll
Jun 8, 2026
Merged

Fix production zoom scroll behavior#1295
rohan-pandeyy merged 17 commits into
AOSSIE-Org:mainfrom
VanshajPoonia:fix/1293-production-zoom-scroll

Conversation

@VanshajPoonia

@VanshajPoonia VanshajPoonia commented May 28, 2026

Copy link
Copy Markdown
Contributor

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

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

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.

@coderabbitai coderabbitai Bot left a comment

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.

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
Member

@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
Member

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

VanshajPoonia commented May 30, 2026

Copy link
Copy Markdown
Contributor Author

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
Member

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

rohan-pandeyy commented May 30, 2026

Copy link
Copy Markdown
Member

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 🤔

@coderabbitai coderabbitai Bot left a comment

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.

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

@coderabbitai coderabbitai Bot left a comment

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.

🧹 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.

@rohan-pandeyy rohan-pandeyy left a comment

Copy link
Copy Markdown
Member

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.

Comment thread frontend/src/components/Media/ZoomableImage.tsx
@rohan-pandeyy

Copy link
Copy Markdown
Member

@coderabbitai Please review frontend/src/components/Media/ZoomableImage.tsx for potential bugs, edge cases, and maintainability issues. Also compare its structure, modularity, and coding patterns with similar components elsewhere in the project, and highlight any inconsistencies or opportunities to better align with existing project conventions.

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== ZoomableImage.tsx ==="
cat frontend/src/components/Media/ZoomableImage.tsx

echo ""
echo "=== ZoomableImage.test.tsx ==="
cat "frontend/src/components/Media/__tests__/ZoomableImage.test.tsx"

echo ""
echo "=== Media folder structure ==="
tree frontend/src/components/Media/ 2>/dev/null || find frontend/src/components/Media -type f | sort

echo ""
echo "=== Other Media components ==="
for f in $(find frontend/src/components/Media -name "*.tsx" ! -name "ZoomableImage*" | sort); do
  echo "--- $f ---"
  cat "$f"
  echo ""
done

Length of output: 73708



@rohan-pandeyy — here is a detailed review of frontend/src/components/Media/ZoomableImage.tsx covering bugs, edge cases, maintainability, and project convention alignment.


🐛 Potential Bugs & Edge Cases

1. Dual pointer + mouse handlers fire on the same gesture

The component registers both onPointerDown/Move/Up/Cancel and onMouseDown/Move/Up/Leave. In Tauri's Chromium WebView, pointer events fire first:

handlePointerDown  → startDrag(e.pointerId, ...)  → dragStateRef = { pointerId: 42 }
handleMouseDown    → startDrag('mouse', ...)       → dragStateRef already set → returns false ✓
handlePointerEnd   → endDrag(42)                   → clears dragStateRef ✓
handleMouseEnd     → endDrag('mouse')              → pointerId mismatch → silent no-op ✓

While this happens to be safe today, handleMouseLeave calling endDrag('mouse') will silently fail when the drag was started by a pointer event. If pointerCapture is not acquired (e.g., the setPointerCapture?.() optional-chain is falsy), dragging past the edge could leave isPanning stuck as true in an unexpected race. The mouse handlers (~20 lines) are entirely redundant and should be removed.

2. Double viewport measurement in zoomBy

const geometry = getGeometry();          // calls getElementSize(viewport) internally
const viewport = viewportRef.current;
const viewportSize = getElementSize(viewport);  // called again for .left / .top

The two measurements happen in the same call but are not guaranteed to be the same if the viewport moves between them (e.g., during an animation or a rapid resize). Use geometry.viewportWidth/Height and store left/top from the single getElementSize call inside getGeometry() (extend the Geometry type with viewportLeft and viewportTop).

3. isOverImage guard prevents anchoring when cursor is in padding

const shouldAnchorX = isOverImage && currentTouchesViewport.width && newOverflow.width;

If the user's cursor is outside the image's bounding box but within the viewport (e.g., in the dark border area around a portrait image), isOverImage is false. The axis that is already overflowing will then jump to center instead of anchoring to the mouse — the image snaps unexpectedly. Consider relaxing this guard to only require that the cursor be within the viewport rather than the image itself.

4. readRawDimensions can pick up clientWidth before natural load

const width = img?.naturalWidth || img?.clientWidth || fallbackDimensions?.width || 0;

naturalWidth is 0 while the image is loading. clientWidth at that point reflects whatever CSS-rendered width the browser assigned (which can be non-zero if the element has a fixed container). This could initialize the geometry with display dimensions instead of natural dimensions in some loading orders. Preferring fallbackDimensions over clientWidth before load, or adding an explicit img.complete guard, would be safer.

5. LINE_HEIGHT_MULTIPLIER = 33 is a magic constant

deltaMode === 1 (line scroll mode) converts one notch to 33px of scroll delta. Some browsers (e.g., Firefox) report line-mode by default for certain mice, making zoom ~33× faster than expected per click. This value is undocumented and untested. Consider adding a comment or making it configurable.

6. Fit-retry state not cleared in clearScheduledFit

clearScheduledFit cancels the animation frame but does not reset fitRetryCountRef. If a rapid sequence of image switches causes retries to hit MAX_FIT_RETRY_FRAMES, the component silently stops retrying. The counter is only reset in resetToFit(), so a new imagePath does eventually reset it — but a pathological sequence of very fast switches with slow geometry could leave the image unfitted. A defensive fitRetryCountRef.current = 0 in clearScheduledFit costs nothing.

7. Rotation hit-testing is footprint-only

isOverImage tests against the bounding-box footprint of the rotated image (the swap of width/height for 90°/270°). For a 90°-rotated image, the footprint box is correct, but if the project ever supports arbitrary rotation angles, the rectangular hit-test will disagree with the visually painted pixels. Noting this as a future fragility.


🔧 Maintainability Issues

8. Pure math helpers should live in a utility module

The top of the file contains ~10 pure functions (clamp, getCenteredAxisPosition, getOverflowRatio, interpolate, etc.) that have zero React dependency. The project already uses this pattern — dateUtils.ts is imported by ChronologicalGallery. Extracting these to zoomUtils.ts would:

  • Make them individually unit-testable
  • Reduce ZoomableImage.tsx from ~420 to ~320 lines
  • Allow reuse if a video viewer or similar component is added later

9. zoomBy is too long to reason about in isolation (~70 lines)

The function interleaves geometry queries, scale clamping, edge-snap logic, mouse-anchor computation, and overflow state updates. Consider splitting out a pure computeZoomTransform({ geometry, currentTransform, zoomChange, clientX, clientY }): TransformState function that can be unit-tested without React.

10. Redundant cursor style in three places

cursor is set on the viewport <div>, the zoom-content <div>, and the <img> (which has pointerEvents: 'none', so the cursor has no effect there). Only the outermost element needs it; the inner divs inherit automatically.

11. ImageViewer is missing displayName

ZoomableImage.displayName = 'ZoomableImage' is correctly set, but ImageViewer (also a forwardRef component) is missing the same. This is inconsistent and makes React DevTools debugging harder.


📐 Project Convention Alignment

Convention Other components ZoomableImage
Complex logic → custom hook MediaView uses useImageViewControls, useSlideshow, useKeyboardNavigation All logic inline in one 420-line component
React.FC<Props> for presentation MediaViewControls, NavigationButtons, ZoomControls, MediaInfoPanel Uses forwardRef (correct for imperative ref, but inner logic could still be extracted)
displayName on forwardRef ZoomableImage sets it ImageViewer does not
Pure utils in utils/ dateUtils.ts, etc. No zoomUtils.ts exists
ResizeObserver without feature-check ChronologicalGallery uses it unconditionally ZoomableImage guards with typeof ResizeObserver === 'function' — good, more defensive
Named exports All components ✅ Consistent

The biggest gap is that MediaView and other complex components extract their stateful logic into named custom hooks, while ZoomableImage inlines everything. A useZoomTransform(viewportRef, imageRef, rotation, imagePath, resetSignal) hook would bring this into alignment with the project's own patterns and make the JSX in ZoomableImage trivially readable.


Summary

Priority Issue
🔴 Bug Dual pointer+mouse handlers — isPanning can stick if pointer capture fails
🔴 Bug Double getElementSize in zoomBy — divergent left/top vs dimensions
🟡 Edge case isOverImage guard causes snap-to-center when cursor is in padding
🟡 Edge case readRawDimensions prefers clientWidth over fallbackDimensions pre-load
🟡 Edge case LINE_HEIGHT_MULTIPLIER undocumented; causes 33× faster zoom in line-mode
🟡 Edge case fitRetryCountRef not reset in clearScheduledFit
🔵 Maintainability Pure math functions should be in zoomUtils.ts
🔵 Maintainability zoomBy should be split into a testable pure function
🔵 Convention Logic should be extracted to useZoomTransform hook per project pattern
🔵 Convention ImageViewer missing displayName
🔵 Cosmetic Redundant cursor style on 3 elements

@rohan-pandeyy

Copy link
Copy Markdown
Member

Both bugs are valid.

The edge cases will need to be tested thoroughly.

I agree with the maintainability concern regarding the math functions. The file is becoming quite large, and separating them out would make it easier to manage and maintain.

The convention-related comments are mandatory and should be addressed.

The cosmetic suggestions are good to have, but they are lower priority.

@rohan-pandeyy

Copy link
Copy Markdown
Member

Hey @VanshajPoonia,
I think some of my feedback may not have been communicated as clearly as I intended. Would you be able to join our upcoming weekly sync meeting on Saturday, 6 June, at 2:30 PM IST. I can clarify there better.
The meeting happens every week on Saturdays at 2:30 PM IST in AOSSIE's Discord Server Meeting Room

@VanshajPoonia

Copy link
Copy Markdown
Contributor Author

I will hopefully be able to hop on. I do we are going back and forth a lot on this particular issue.
I hope it does not mess with my reputation in the org.

@VanshajPoonia

VanshajPoonia commented Jun 6, 2026

Copy link
Copy Markdown
Contributor Author

(Refer to the next comment)

@VanshajPoonia

Copy link
Copy Markdown
Contributor Author

Thanks for the earlier feedback. I pushed the follow-up zoom updates.

This now includes both changes:

  1. Mouse-wheel zoom smoothness
    Wheel zoom now uses a proportional/exponential zoom ratio instead of an additive scale step. This prevents a physical mouse-wheel notch from creating a large absolute jump at low fit scales, especially on high-resolution images that start below 1:1 fit scale.

  2. Button zoom animation
    Zoom-in / zoom-out button clicks now use a 250ms ease-out transform transition, while wheel zoom stays instant and responsive. I also hardened the animation state so it does not get stuck at clamped min/max zoom, clears on wheel/pan/resize/reset/transitionend/unmount, and skips animation entirely when prefers-reduced-motion is enabled.

The existing fit-to-view, edge-before-anchoring, axis-dependent anchoring, and minimum-recenter behavior are kept intact.

I tested this locally on macOS with an external mouse, and the zoom-in behavior feels much smoother now, especially on high-resolution images.

Commits:
72cd74f Smooth mouse wheel zoom scaling
efa51e6 Harden button zoom animation

Verification:
111/111 tests passing
TypeScript clean
ESLint clean
Prettier clean

I also merged the same changes into my temporary test-release workflow branch and ran the fork release workflow again.

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

New test release:
https://github.com/VanshajPoonia/PictoPy/releases/tag/v1.1.0-pr1295-test.14

Could you please verify this build on Windows as well, especially with a physical mouse wheel and the zoom buttons?

@rohan-pandeyy rohan-pandeyy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great work!
And thanks for your patience with all the test releases 😄

@rohan-pandeyy rohan-pandeyy merged commit 1127dca into AOSSIE-Org:main Jun 8, 2026
9 checks passed
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