Skip to content

fix: make zoom in the diff dialog obey the cursor and stay inside the dialog - #383

Merged
nGervasyuk merged 5 commits into
Visual-Regression-Tracker:masterfrom
nGervasyuk:fix/details-dialog-zoom
Jul 27, 2026
Merged

fix: make zoom in the diff dialog obey the cursor and stay inside the dialog#383
nGervasyuk merged 5 commits into
Visual-Regression-Tracker:masterfrom
nGervasyuk:fix/details-dialog-zoom

Conversation

@nGervasyuk

@nGervasyuk nGervasyuk commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

What & why

Zooming in the fullscreen approve/reject dialog can escape into the browser. Zoom out a little with the pointer away from the centre and the next scroll zooms the whole page rather than the screenshot; the only way back is resetting the browser zoom.

Two things combine to cause it:

  1. The wheel listener lives on the element being scaled. <Stage> is CSS-scaled with transform: scale(...), so zooming out physically shrinks the canvas. Once it slips out from under the pointer, the wheel no longer reaches Konva, nothing calls preventDefault(), and the browser handles the gesture — with a trackpad pinch that is page zoom.
  2. transformOrigin: "top left". Zoom does not follow the cursor, so the image runs toward the top-left corner as it shrinks. That is what puts the pointer off the canvas within a couple of gestures, which makes (1) easy to hit.

Everything below is the same gesture in the same dialog, so it comes as one change.

Changes

  • The pane owns the wheel. The listener moves to the surrounding pane, which keeps its size no matter the zoom, so the dialog always consumes the gesture — including over the empty space beside a narrow screenshot, where previously nothing happened. React 17 registers onWheel passively, so preventDefault() from JSX is silently ignored; the listener is attached with { passive: false } instead.
  • Zoom follows the cursor. The image point under the pointer stays put, by moving the shared scroll position rather than the drag translate, so both panes stay in sync as they already do.
  • Bounds are shared. The scale is clamped to 0.1–10 in scale.helper, and the toolbar Zoom In/Out buttons go through the same clamp. They previously scaled the stage directly, so Zoom Out could shrink the image to zero width even though the wheel stopped at the floor.
  • The gesture eases out. Each wheel event accumulates into a target scale that an animation frame loop chases a share at a time, so zoom decelerates and coasts to a stop instead of freezing on the last tick. The cursor anchor is resolved once per event and reused for every frame, so the anchor point does not drift mid-animation. ZOOM_EASING is the single knob: higher is snappier, lower coasts longer.

Drag/pan state is untouched.

Testing

Five Playwright tests, each verified to fail without the part of the change it covers:

Test Without the fix
keeps wheel zoom inside the image pane defaultPrevented === false
zooms with the cursor off the artwork image width unchanged (306.2306.2)
zooms towards the cursor pane stays at scrollLeft === 0
holds the zoom buttons to the same limits as the wheel image shrinks to 0 width
eases the zoom out instead of stopping dead one distinct width across twelve frames instead of many

The cursor-anchoring test asserts the horizontal axis only: the pane grows in height rather than scrolling, so there is no vertical scroll to check.

Playwright 26/26, tsc --noEmit, eslint, prettier and vite build clean on changed files. No snapshot change — the default scale is unaffected.

Screenshots

Screen.Recording.2026-07-27.at.17.23.22.mov

The easing constant is a matter of taste; happy to retune it.

Zoom was handled on the Konva stage, which is the element being scaled.
Zooming out shrinks it out from under the pointer, after which the wheel
reaches the browser instead and pinching zooms the whole page - the only
way back is resetting the browser zoom.

Move the listener to the surrounding pane, which keeps its size, clamp
the scale, and keep the point under the cursor in place instead of
scaling from the top left corner, which is what made it so easy to lose
the image in the first place. React 17 registers onWheel passively, so
the listener is attached manually.
@nGervasyuk nGervasyuk self-assigned this Jul 27, 2026
The toolbar buttons scaled the stage directly, so Zoom Out could shrink
the image to nothing while the wheel stopped at the floor. Move the
bounds into the scale helper and clamp both paths through it.
Drives a real pointer instead of a synthetic event: shrinks the image
until it no longer reaches the far side of the pane, then zooms from the
empty half. With the listener back on the canvas the width does not
budge.
Every wheel event applied its whole step immediately, so a gesture ended
as abruptly as it started. Accumulate the step into a target scale and
chase it a share at a time on animation frames, which coasts to a stop.
The cursor anchor is resolved once per gesture so the point under the
pointer stays put for the whole animation.
@nGervasyuk nGervasyuk changed the title fix: keep wheel zoom inside the diff dialog and anchor it to the cursor fix: make zoom in the diff dialog obey the cursor and stay inside the dialog Jul 27, 2026
Ease over elapsed time rather than a fixed share per frame, so the
gesture lasts as long on a 120Hz display as on a 60Hz one, and stretch
the coast a little. While the animation drives the scroll position,
ignore the scroll events it causes - echoing them back into state
rendered the pane twice per frame.
@sonarqubecloud

Copy link
Copy Markdown

@nGervasyuk
nGervasyuk requested a review from pashidlos July 27, 2026 14:25

@pashidlos pashidlos 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.

LGTM

@nGervasyuk
nGervasyuk merged commit 941ad75 into Visual-Regression-Tracker:master Jul 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants