Skip to content

Commit 72cd74f

Browse files
committed
Smooth mouse wheel zoom scaling
1 parent 143ca19 commit 72cd74f

5 files changed

Lines changed: 85 additions & 46 deletions

File tree

frontend/src/components/Media/ZoomableImage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
2929
contentDimensions,
3030
imageOffset,
3131
cursor,
32+
isButtonZoom,
3233
handleImageLoad,
3334
handlePointerDown,
3435
handlePointerMove,
3536
handlePointerEnd,
3637
handlePointerLeave,
38+
handleZoomTransitionEnd,
3739
zoomIn,
3840
zoomOut,
3941
reset,
@@ -67,6 +69,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
6769
>
6870
<div
6971
data-testid="zoom-content"
72+
onTransitionEnd={handleZoomTransitionEnd}
7073
style={{
7174
position: 'relative',
7275
width: contentDimensions
@@ -78,6 +81,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
7881
transform: `translate3d(${transformState.positionX}px, ${transformState.positionY}px, 0) scale(${transformState.scale})`,
7982
transformOrigin: '0 0',
8083
willChange: 'transform',
84+
transition: isButtonZoom ? 'transform 250ms ease-out' : undefined,
8185
}}
8286
>
8387
<img

frontend/src/components/Media/__tests__/ZoomableImage.test.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,11 @@ describe('ZoomableImage controlled transform behavior', () => {
286286
clientY: 550,
287287
});
288288

289-
expectCurrentTransform(290, 245, 1.1);
289+
expectCurrentTransform(
290+
289.4829081924352,
291+
244.7414540962176,
292+
1.1051709180756477,
293+
);
290294
});
291295

292296
test('stops at the first viewport edge before mouse anchoring begins', () => {
@@ -336,7 +340,11 @@ describe('ZoomableImage controlled transform behavior', () => {
336340
clientY: 300,
337341
});
338342

339-
expectCurrentTransform(-71.25, 127.10526315789474, 1.1526315789473685);
343+
expectCurrentTransform(
344+
-78.87818855673584,
345+
125.49932872489774,
346+
1.1633378085006818,
347+
);
340348
});
341349

342350
test('anchors vertically only after height has reached the viewport edge', () => {
@@ -357,9 +365,9 @@ describe('ZoomableImage controlled transform behavior', () => {
357365
});
358366

359367
expectCurrentTransform(
360-
224.28571428571428,
361-
-51.33333333333337,
362-
1.1714285714285715,
368+
222.3832453092709,
369+
-57.84400494160627,
370+
1.184111697938194,
363371
);
364372
});
365373

@@ -380,7 +388,11 @@ describe('ZoomableImage controlled transform behavior', () => {
380388
clientY: 500,
381389
});
382390

383-
expectCurrentTransform(-78.75, -76.85185185185185, 0.9888888888888888);
391+
expectCurrentTransform(
392+
-73.61964265295342,
393+
-73.05158715033576,
394+
0.9823741494005757,
395+
);
384396
});
385397

386398
test('recenters when zooming back to minimum scale', () => {
@@ -420,7 +432,11 @@ describe('ZoomableImage controlled transform behavior', () => {
420432
clientY: 500,
421433
});
422434

423-
expectCurrentTransform(-43, -32, 0.55);
435+
expectCurrentTransform(
436+
-21.29705614748953,
437+
-15.90707397752716,
438+
0.5256355481880121,
439+
);
424440
});
425441

426442
test('panning clamps overflowing axes and keeps fitting axes centered', () => {
@@ -457,7 +473,7 @@ describe('ZoomableImage controlled transform behavior', () => {
457473
clientY: 1000,
458474
});
459475

460-
expectCurrentTransform(0, 127.10526315789474, 1.1526315789473685);
476+
expectCurrentTransform(0, 125.49932872489774, 1.1633378085006818);
461477
});
462478

463479
test('reuses the drag-start geometry while panning', () => {
@@ -771,16 +787,20 @@ describe('ZoomableImage controlled transform behavior', () => {
771787

772788
// A line-mode wheel (deltaMode === 1) reports scroll in lines, not pixels.
773789
// It must be normalized by LINE_HEIGHT_MULTIPLIER (33), so a 3-line notch
774-
// zooms by 3 * 33 * ZOOM_FACTOR(0.001) = 0.099 -> scale 1.099, identical to
775-
// a 99px pixel-mode notch. Without the multiplier it would be only 0.003.
790+
// produces the same zoomRatio as a 99px pixel-mode notch: exp(99 * 0.001).
791+
// Without the multiplier, a 3-line notch would produce exp(3 * 0.001) instead.
776792
fireEvent.wheel(viewport, {
777793
deltaY: -3,
778794
deltaMode: 1,
779795
clientX: 400,
780796
clientY: 300,
781797
});
782798

783-
expectCurrentTransform(290.1, 245.05, 1.099);
799+
expectCurrentTransform(
800+
289.5933700441118,
801+
244.7966850220559,
802+
1.104066299558882,
803+
);
784804
});
785805

786806
test('retries the fit until the viewport can be measured', () => {

frontend/src/hooks/useZoomTransform.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import {
66
type PointerEvent as ReactPointerEvent,
77
} from 'react';
88
import {
9-
CONTROL_BUTTON_ZOOM_STEP,
9+
CONTROL_BUTTON_ZOOM_RATIO,
1010
LINE_HEIGHT_MULTIPLIER,
1111
MAX_FIT_RETRY_FRAMES,
1212
MAX_SCALE,
1313
MIN_SCALE,
1414
SCALE_EPSILON,
15-
ZOOM_FACTOR,
15+
WHEEL_ZOOM_SENSITIVITY,
1616
clamp,
1717
computeZoomTransform,
1818
getAxisPosition,
@@ -67,6 +67,7 @@ export const useZoomTransform = ({
6767
const [rawDimensions, setRawDimensions] = useState<Size | null>(null);
6868
const [isOverflowing, setIsOverflowing] = useState(false);
6969
const [isPanning, setIsPanning] = useState(false);
70+
const [isButtonZoom, setIsButtonZoom] = useState(false);
7071

7172
const setRawImageDimensions = useCallback((dimensions: Size | null) => {
7273
rawDimensionsRef.current = dimensions;
@@ -243,7 +244,7 @@ export const useZoomTransform = ({
243244
}, [scheduleFitTransform]);
244245

245246
const zoomBy = useCallback(
246-
(zoomChange: number, clientX?: number, clientY?: number) => {
247+
(zoomRatio: number, clientX?: number, clientY?: number) => {
247248
const geometry = getGeometry();
248249

249250
if (!geometry) return false;
@@ -255,7 +256,7 @@ export const useZoomTransform = ({
255256
computeZoomTransform({
256257
geometry,
257258
currentTransform,
258-
zoomChange,
259+
zoomRatio,
259260
clientX,
260261
clientY,
261262
}),
@@ -323,11 +324,14 @@ export const useZoomTransform = ({
323324
e.stopPropagation();
324325
e.stopImmediatePropagation();
325326

327+
setIsButtonZoom(false);
328+
326329
const isLineMode = e.deltaMode === 1;
327330
const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1;
328-
const zoomChange = -e.deltaY * multiplier * ZOOM_FACTOR;
331+
const normalizedDelta = -e.deltaY * multiplier;
332+
const zoomRatio = Math.exp(normalizedDelta * WHEEL_ZOOM_SENSITIVITY);
329333

330-
zoomBy(zoomChange, e.clientX, e.clientY);
334+
zoomBy(zoomRatio, e.clientX, e.clientY);
331335
};
332336

333337
viewport.addEventListener('wheel', handleWheel, {
@@ -465,11 +469,17 @@ export const useZoomTransform = ({
465469
[endDrag],
466470
);
467471

468-
const zoomIn = useCallback(() => zoomBy(CONTROL_BUTTON_ZOOM_STEP), [zoomBy]);
469-
const zoomOut = useCallback(
470-
() => zoomBy(-CONTROL_BUTTON_ZOOM_STEP),
471-
[zoomBy],
472-
);
472+
const zoomIn = useCallback(() => {
473+
setIsButtonZoom(true);
474+
zoomBy(CONTROL_BUTTON_ZOOM_RATIO);
475+
}, [zoomBy]);
476+
const zoomOut = useCallback(() => {
477+
setIsButtonZoom(true);
478+
zoomBy(1 / CONTROL_BUTTON_ZOOM_RATIO);
479+
}, [zoomBy]);
480+
const handleZoomTransitionEnd = useCallback(() => {
481+
setIsButtonZoom(false);
482+
}, []);
473483

474484
const contentDimensions = rawDimensions
475485
? getEffectiveDimensions(
@@ -495,11 +505,13 @@ export const useZoomTransform = ({
495505
contentDimensions,
496506
imageOffset,
497507
cursor,
508+
isButtonZoom,
498509
handleImageLoad,
499510
handlePointerDown,
500511
handlePointerMove,
501512
handlePointerEnd,
502513
handlePointerLeave,
514+
handleZoomTransitionEnd,
503515
zoomIn,
504516
zoomOut,
505517
reset: resetToFit,

frontend/src/utils/__tests__/zoomUtils.test.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ describe('zoomUtils', () => {
5656
const next = computeZoomTransform({
5757
geometry,
5858
currentTransform: { positionX: 300, positionY: 250, scale: 1 },
59-
zoomChange: 0.1,
59+
zoomRatio: 1.1,
6060
clientX: 770,
6161
clientY: 560,
6262
});
@@ -78,7 +78,7 @@ describe('zoomUtils', () => {
7878
const next = computeZoomTransform({
7979
geometry,
8080
currentTransform: { positionX: 20, positionY: 150, scale: 1 },
81-
zoomChange: 0.1,
81+
zoomRatio: 1.1,
8282
clientX: 750,
8383
clientY: 300,
8484
});
@@ -100,14 +100,14 @@ describe('zoomUtils', () => {
100100
const next = computeZoomTransform({
101101
geometry,
102102
currentTransform: { positionX: -80, positionY: 135, scale: 1.1 },
103-
zoomChange: 0.1,
103+
zoomRatio: 1.1,
104104
clientX: 700,
105105
clientY: 50,
106106
});
107107

108-
expect(next.positionX).toBeCloseTo(-150.90909090909088);
109-
expect(next.positionY).toBeCloseTo(120);
110-
expect(next.scale).toBeCloseTo(1.2);
108+
expect(next.positionX).toBeCloseTo(-158);
109+
expect(next.positionY).toBeCloseTo(118.5);
110+
expect(next.scale).toBeCloseTo(1.21);
111111
});
112112

113113
test('blends overflowing axes back toward center while zooming out', () => {
@@ -122,21 +122,21 @@ describe('zoomUtils', () => {
122122
const zoomedIn = computeZoomTransform({
123123
geometry,
124124
currentTransform: getFitTransform(geometry),
125-
zoomChange: 0.1,
125+
zoomRatio: 1.1,
126126
clientX: 700,
127127
clientY: 500,
128128
});
129129
const zoomedOut = computeZoomTransform({
130130
geometry,
131131
currentTransform: zoomedIn,
132-
zoomChange: -0.05,
132+
zoomRatio: 0.95,
133133
clientX: 700,
134134
clientY: 500,
135135
});
136136

137-
expect(zoomedOut.positionX).toBeCloseTo(-43);
138-
expect(zoomedOut.positionY).toBeCloseTo(-32);
139-
expect(zoomedOut.scale).toBeCloseTo(0.55);
137+
expect(zoomedOut.positionX).toBeCloseTo(-18.6075);
138+
expect(zoomedOut.positionY).toBeCloseTo(-13.905);
139+
expect(zoomedOut.scale).toBeCloseTo(0.5225);
140140
});
141141

142142
test('anchors one axis or both axes according to overflow state', () => {
@@ -151,20 +151,20 @@ describe('zoomUtils', () => {
151151
const widthAtEdge = computeZoomTransform({
152152
geometry: widthOnlyGeometry,
153153
currentTransform: { positionX: 20, positionY: 150, scale: 1 },
154-
zoomChange: 0.1,
154+
zoomRatio: 1.1,
155155
clientX: 750,
156156
clientY: 300,
157157
});
158158
const widthAnchored = computeZoomTransform({
159159
geometry: widthOnlyGeometry,
160160
currentTransform: widthAtEdge,
161-
zoomChange: 0.1,
161+
zoomRatio: 1.1,
162162
clientX: 750,
163163
clientY: 300,
164164
});
165165

166-
expect(widthAnchored.positionX).toBeCloseTo(-71.25);
167-
expect(widthAnchored.positionY).toBeCloseTo(127.10526315789474);
166+
expect(widthAnchored.positionX).toBeCloseTo(-75);
167+
expect(widthAnchored.positionY).toBeCloseTo(126.31578947368419);
168168

169169
const bothAxisGeometry = makeGeometry({
170170
viewportWidth: 800,
@@ -177,19 +177,19 @@ describe('zoomUtils', () => {
177177
const bothAtEdge = computeZoomTransform({
178178
geometry: bothAxisGeometry,
179179
currentTransform: getFitTransform(bothAxisGeometry),
180-
zoomChange: 0.1,
180+
zoomRatio: 1.1,
181181
clientX: 700,
182182
clientY: 500,
183183
});
184184
const bothAnchored = computeZoomTransform({
185185
geometry: bothAxisGeometry,
186186
currentTransform: bothAtEdge,
187-
zoomChange: 0.1,
187+
zoomRatio: 1.1,
188188
clientX: 700,
189189
clientY: 500,
190190
});
191191

192-
expect(bothAnchored.positionX).toBeCloseTo(-78.75);
193-
expect(bothAnchored.positionY).toBeCloseTo(-76.85185185185185);
192+
expect(bothAnchored.positionX).toBeCloseTo(-70);
193+
expect(bothAnchored.positionY).toBeCloseTo(-70.37037037037032);
194194
});
195195
});

frontend/src/utils/zoomUtils.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
export const ZOOM_FACTOR = 0.001;
21
// Browser line-mode wheel events report lines, not pixels. This normalizes one
32
// line-scroll notch to the commonly used 33px pixel delta.
43
export const LINE_HEIGHT_MULTIPLIER = 33;
4+
// Sensitivity for exponential wheel zoom: ratio = exp(normalizedDelta * sensitivity).
5+
// A mouse notch (~100px delta) becomes ~10.5% scale change; trackpad stays smooth.
6+
export const WHEEL_ZOOM_SENSITIVITY = 0.001;
57
export const MAX_SCALE = 8;
68
export const MIN_SCALE = 1;
79
export const SCALE_EPSILON = 0.0001;
810
export const MAX_FIT_RETRY_FRAMES = 12;
9-
export const CONTROL_BUTTON_ZOOM_STEP = 0.5;
11+
// Multiplicative zoom ratio for the zoom-in/out buttons (50% per click).
12+
export const CONTROL_BUTTON_ZOOM_RATIO = 1.5;
1013

1114
export type Size = {
1215
width: number;
@@ -37,7 +40,7 @@ export type Geometry = {
3740
type ComputeZoomTransformOptions = {
3841
geometry: Geometry;
3942
currentTransform: TransformState;
40-
zoomChange: number;
43+
zoomRatio: number;
4144
clientX?: number;
4245
clientY?: number;
4346
};
@@ -231,12 +234,12 @@ export const getElementSize = (element: HTMLElement) => {
231234
export const computeZoomTransform = ({
232235
geometry,
233236
currentTransform,
234-
zoomChange,
237+
zoomRatio,
235238
clientX,
236239
clientY,
237240
}: ComputeZoomTransformOptions): TransformState => {
238241
const desiredScale = clamp(
239-
currentTransform.scale + zoomChange,
242+
currentTransform.scale * zoomRatio,
240243
geometry.minScale,
241244
MAX_SCALE,
242245
);

0 commit comments

Comments
 (0)