Skip to content

Commit cd4a727

Browse files
committed
Delay scroll zoom anchoring until image fills viewport
1 parent 5b8af33 commit cd4a727

2 files changed

Lines changed: 106 additions & 21 deletions

File tree

frontend/src/components/Media/ZoomableImage.tsx

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const ZOOM_FACTOR = 0.001;
1717
const LINE_HEIGHT_MULTIPLIER = 33;
1818
const MAX_SCALE = 8;
1919
const MIN_SCALE = 1;
20+
const SCALE_EPSILON = 0.0001;
2021

2122
type Size = {
2223
width: number;
@@ -87,6 +88,23 @@ const getAxisPosition = (
8788
: centeredPosition;
8889
};
8990

91+
const axisTouchesViewport = (scaledSize: number, viewportSize: number) =>
92+
scaledSize >= viewportSize - SCALE_EPSILON;
93+
94+
const getFirstViewportEdgeScale = (
95+
baseDimensions: Size,
96+
viewportWidth: number,
97+
viewportHeight: number,
98+
) =>
99+
Math.max(
100+
MIN_SCALE,
101+
Math.min(
102+
MAX_SCALE,
103+
viewportWidth / baseDimensions.width,
104+
viewportHeight / baseDimensions.height,
105+
),
106+
);
107+
90108
export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
91109
({ imagePath, alt, rotation, resetSignal }, ref) => {
92110
const transformRef = useRef<ReactZoomPanPinchRef>(null);
@@ -111,26 +129,29 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
111129
[],
112130
);
113131

114-
const getScaledDimensions = useCallback(
115-
(scale: number): Size | null => {
116-
if (!imageRef.current) return null;
132+
const getBaseDimensions = useCallback((): Size | null => {
133+
if (!imageRef.current) return null;
117134

118-
const renderedWidth = imageRef.current.clientWidth;
119-
const renderedHeight = imageRef.current.clientHeight;
135+
const renderedWidth = imageRef.current.clientWidth;
136+
const renderedHeight = imageRef.current.clientHeight;
120137

121-
if (!renderedWidth || !renderedHeight) return null;
138+
if (!renderedWidth || !renderedHeight) return null;
122139

123-
const effectiveDims = getEffectiveDimensions(
124-
renderedWidth,
125-
renderedHeight,
126-
);
140+
return getEffectiveDimensions(renderedWidth, renderedHeight);
141+
}, [getEffectiveDimensions]);
142+
143+
const getScaledDimensions = useCallback(
144+
(scale: number): Size | null => {
145+
const baseDimensions = getBaseDimensions();
146+
147+
if (!baseDimensions) return null;
127148

128149
return {
129-
width: effectiveDims.width * scale,
130-
height: effectiveDims.height * scale,
150+
width: baseDimensions.width * scale,
151+
height: baseDimensions.height * scale,
131152
};
132153
},
133-
[getEffectiveDimensions],
154+
[getBaseDimensions],
134155
);
135156

136157
const getOverflowState = useCallback(
@@ -286,10 +307,42 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
286307
const zoomChange = -e.deltaY * multiplier * factor;
287308

288309
const currentScale = transformState.scale;
289-
const newScale = Math.max(
310+
const desiredScale = Math.max(
290311
MIN_SCALE,
291312
Math.min(MAX_SCALE, currentScale + zoomChange),
292313
);
314+
const baseDimensions = getBaseDimensions();
315+
if (!baseDimensions) return;
316+
317+
const currentDimensions = {
318+
width: baseDimensions.width * currentScale,
319+
height: baseDimensions.height * currentScale,
320+
};
321+
const currentTouchesViewport = {
322+
width: axisTouchesViewport(
323+
currentDimensions.width,
324+
viewportRect.width,
325+
),
326+
height: axisTouchesViewport(
327+
currentDimensions.height,
328+
viewportRect.height,
329+
),
330+
};
331+
const isZoomingIn = desiredScale > currentScale;
332+
333+
const shouldFitFirst =
334+
isZoomingIn &&
335+
!currentTouchesViewport.width &&
336+
!currentTouchesViewport.height;
337+
const firstViewportEdgeScale = getFirstViewportEdgeScale(
338+
baseDimensions,
339+
viewportRect.width,
340+
viewportRect.height,
341+
);
342+
const newScale =
343+
shouldFitFirst && desiredScale > firstViewportEdgeScale
344+
? firstViewportEdgeScale
345+
: desiredScale;
293346

294347
const scaledDimensions = getScaledDimensions(newScale);
295348
if (!scaledDimensions) return;
@@ -318,19 +371,23 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
318371
mouseViewportX - (mouseViewportX - transformState.positionX) * ratio;
319372
const anchoredY =
320373
mouseViewportY - (mouseViewportY - transformState.positionY) * ratio;
374+
const shouldAnchorX =
375+
isOverImage && currentTouchesViewport.width && newOverflow.width;
376+
const shouldAnchorY =
377+
isOverImage && currentTouchesViewport.height && newOverflow.height;
321378

322379
const targetX = shouldRecenter
323380
? centeredX
324381
: getAxisPosition(
325-
isOverImage ? anchoredX : centeredX,
382+
shouldAnchorX ? anchoredX : centeredX,
326383
viewportRect.width,
327384
scaledDimensions.width,
328385
newOverflow.width,
329386
);
330387
const targetY = shouldRecenter
331388
? centeredY
332389
: getAxisPosition(
333-
isOverImage ? anchoredY : centeredY,
390+
shouldAnchorY ? anchoredY : centeredY,
334391
viewportRect.height,
335392
scaledDimensions.height,
336393
newOverflow.height,
@@ -349,7 +406,12 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
349406
resizeObserver.disconnect();
350407
wheelElement.removeEventListener('wheel', handleWheelInterceptor, true);
351408
};
352-
}, [getOverflowState, getScaledDimensions, getViewportElement]);
409+
}, [
410+
getBaseDimensions,
411+
getOverflowState,
412+
getScaledDimensions,
413+
getViewportElement,
414+
]);
353415

354416
return (
355417
<div ref={wheelAreaRef} className="h-full w-full">

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ describe('ZoomableImage wheel behavior', () => {
182182
expectLatestTransform(290, 245, 1.1);
183183
});
184184

185-
test('anchors horizontally and keeps the vertical axis centered when only width overflows', () => {
185+
test('centers and stops at the viewport edge before mouse anchoring begins', () => {
186186
const { wheelArea } = setupWheelScene({
187187
wrapperSize: { width: 800, height: 600 },
188188
imageSize: { width: 760, height: 300 },
@@ -194,10 +194,33 @@ describe('ZoomableImage wheel behavior', () => {
194194
clientY: 300,
195195
});
196196

197-
expectLatestTransform(-36, 135, 1.1);
197+
expectLatestTransform(0, 142.10526315789474, 1.0526315789473684);
198198
});
199199

200-
test('anchors vertically and keeps the horizontal axis centered when only height overflows', () => {
200+
test('anchors horizontally after the width has reached the viewport edge', () => {
201+
mockTransformState.scale = 1.1;
202+
mockTransformState.positionX = -18;
203+
mockTransformState.positionY = 135;
204+
205+
const { wheelArea } = setupWheelScene({
206+
wrapperSize: { width: 800, height: 600 },
207+
imageSize: { width: 760, height: 300 },
208+
});
209+
210+
fireEvent.wheel(wheelArea, {
211+
deltaY: -100,
212+
clientX: 750,
213+
clientY: 300,
214+
});
215+
216+
expectLatestTransform(-87.81818181818176, 120, 1.2000000000000002);
217+
});
218+
219+
test('anchors vertically after the height has reached the viewport edge', () => {
220+
mockTransformState.scale = 1.1;
221+
mockTransformState.positionX = 235;
222+
mockTransformState.positionY = -8;
223+
201224
const { wheelArea } = setupWheelScene({
202225
wrapperSize: { width: 800, height: 600 },
203226
imageSize: { width: 300, height: 560 },
@@ -209,7 +232,7 @@ describe('ZoomableImage wheel behavior', () => {
209232
clientY: 550,
210233
});
211234

212-
expectLatestTransform(235, -16, 1.1);
235+
expectLatestTransform(220, -58.72727272727275, 1.2000000000000002);
213236
});
214237

215238
test('anchors both axes when width and height overflow', () => {

0 commit comments

Comments
 (0)