Skip to content

Commit 9200adb

Browse files
committed
Fix production zoom fit initialization
1 parent 43412d4 commit 9200adb

2 files changed

Lines changed: 162 additions & 27 deletions

File tree

frontend/src/components/Media/ZoomableImage.tsx

Lines changed: 143 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ type OverflowState = {
2929
height: boolean;
3030
};
3131

32+
type FitTransform = {
33+
positionX: number;
34+
positionY: number;
35+
scale: number;
36+
};
37+
3238
type AnimationType =
3339
| 'easeOut'
3440
| 'linear'
@@ -128,11 +134,33 @@ const getFirstViewportEdgeScale = (
128134
),
129135
);
130136

137+
const getFitTransform = (
138+
baseDimensions: Size,
139+
viewportWidth: number,
140+
viewportHeight: number,
141+
): FitTransform | null => {
142+
if (!viewportWidth || !viewportHeight) return null;
143+
144+
const scale = getMinimumScale(baseDimensions, viewportWidth, viewportHeight);
145+
const scaledWidth = baseDimensions.width * scale;
146+
const scaledHeight = baseDimensions.height * scale;
147+
148+
return {
149+
positionX: getCenteredAxisPosition(viewportWidth, scaledWidth),
150+
positionY: getCenteredAxisPosition(viewportHeight, scaledHeight),
151+
scale,
152+
};
153+
};
154+
131155
export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
132156
({ imagePath, alt, rotation, resetSignal }, ref) => {
133157
const transformRef = useRef<ReactZoomPanPinchRef>(null);
134158
const wheelAreaRef = useRef<HTMLDivElement>(null);
135159
const imageRef = useRef<HTMLImageElement>(null);
160+
const isFitInitializedRef = useRef(false);
161+
const hasUserInteractedRef = useRef(false);
162+
const fitTransformRef = useRef<FitTransform | null>(null);
163+
const fitFrameRef = useRef<number | null>(null);
136164
const [isOverflowing, setIsOverflowing] = useState(false);
137165
const [minScale, setMinScale] = useState(MIN_SCALE);
138166
const rotationRef = useRef(rotation);
@@ -143,10 +171,6 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
143171
);
144172
}, []);
145173

146-
useEffect(() => {
147-
rotationRef.current = rotation;
148-
}, [rotation]);
149-
150174
const getEffectiveDimensions = useCallback(
151175
(width: number, height: number) => {
152176
const normalizedRotation = ((rotationRef.current % 360) + 360) % 360;
@@ -211,7 +235,18 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
211235
[],
212236
);
213237

214-
const handleReset = useCallback(
238+
const clearScheduledFit = useCallback(() => {
239+
if (fitFrameRef.current === null) return;
240+
241+
if (typeof window.cancelAnimationFrame === 'function') {
242+
window.cancelAnimationFrame(fitFrameRef.current);
243+
} else {
244+
window.clearTimeout(fitFrameRef.current);
245+
}
246+
fitFrameRef.current = null;
247+
}, []);
248+
249+
const applyFitTransform = useCallback(
215250
(duration = 200, animationType: AnimationType = 'easeOut') => {
216251
const viewportElement = getViewportElement();
217252
if (!transformRef.current || !viewportElement || !imageRef.current)
@@ -222,21 +257,22 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
222257

223258
if (!baseDimensions) return;
224259

225-
const scale = getMinimumScale(
260+
const fitTransform = getFitTransform(
226261
baseDimensions,
227262
wrapperRect.width,
228263
wrapperRect.height,
229264
);
230-
const renderedW = baseDimensions.width * scale;
231-
const renderedH = baseDimensions.height * scale;
232-
const centerX = getCenteredAxisPosition(wrapperRect.width, renderedW);
233-
const centerY = getCenteredAxisPosition(wrapperRect.height, renderedH);
234265

235-
setMinimumScale(scale);
266+
if (!fitTransform) return;
267+
268+
fitTransformRef.current = fitTransform;
269+
isFitInitializedRef.current = true;
270+
hasUserInteractedRef.current = false;
271+
setMinimumScale(fitTransform.scale);
236272
transformRef.current.setTransform(
237-
centerX,
238-
centerY,
239-
scale,
273+
fitTransform.positionX,
274+
fitTransform.positionY,
275+
fitTransform.scale,
240276
duration,
241277
animationType,
242278
);
@@ -245,16 +281,56 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
245281
[getBaseDimensions, getViewportElement, setMinimumScale],
246282
);
247283

284+
const scheduleFitTransform = useCallback(
285+
(duration = 200, animationType: AnimationType = 'easeOut') => {
286+
clearScheduledFit();
287+
288+
const scheduleFrame: (callback: FrameRequestCallback) => number =
289+
typeof window.requestAnimationFrame === 'function'
290+
? window.requestAnimationFrame.bind(window)
291+
: (callback) =>
292+
window.setTimeout(() => callback(performance.now()), 0);
293+
294+
fitFrameRef.current = scheduleFrame(() => {
295+
fitFrameRef.current = null;
296+
applyFitTransform(duration, animationType);
297+
});
298+
},
299+
[applyFitTransform, clearScheduledFit],
300+
);
301+
302+
const handleReset = useCallback(
303+
(duration = 200, animationType: AnimationType = 'easeOut') => {
304+
isFitInitializedRef.current = false;
305+
hasUserInteractedRef.current = false;
306+
scheduleFitTransform(duration, animationType);
307+
},
308+
[scheduleFitTransform],
309+
);
310+
248311
useImperativeHandle(ref, () => ({
249-
zoomIn: () => transformRef.current?.zoomIn(),
250-
zoomOut: () => transformRef.current?.zoomOut(),
312+
zoomIn: () => {
313+
hasUserInteractedRef.current = true;
314+
transformRef.current?.zoomIn();
315+
},
316+
zoomOut: () => {
317+
hasUserInteractedRef.current = true;
318+
transformRef.current?.zoomOut();
319+
},
251320
reset: () => handleReset(),
252321
}));
253322

254323
useEffect(() => {
255324
handleReset();
256325
}, [resetSignal, handleReset]);
257326

327+
useEffect(() => {
328+
rotationRef.current = rotation;
329+
isFitInitializedRef.current = false;
330+
hasUserInteractedRef.current = false;
331+
scheduleFitTransform(0);
332+
}, [rotation, scheduleFitTransform]);
333+
258334
useEffect(() => {
259335
const viewportElement = getViewportElement();
260336
if (!transformRef.current || !viewportElement || !imageRef.current)
@@ -282,12 +358,15 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
282358

283359
useEffect(() => {
284360
setIsOverflowing(false);
361+
isFitInitializedRef.current = false;
362+
hasUserInteractedRef.current = false;
363+
fitTransformRef.current = null;
285364

286365
const img = imageRef.current;
287366
if (!img) return;
288367

289368
const handleImageLoad = () => {
290-
handleReset(0);
369+
scheduleFitTransform(0);
291370
};
292371

293372
if (img.complete && img.naturalWidth > 0) {
@@ -296,7 +375,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
296375
img.addEventListener('load', handleImageLoad);
297376
return () => img.removeEventListener('load', handleImageLoad);
298377
}
299-
}, [imagePath, handleReset]);
378+
}, [imagePath, scheduleFitTransform]);
300379

301380
useEffect(() => {
302381
const wheelElement = wheelAreaRef.current;
@@ -313,13 +392,20 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
313392

314393
const baseDimensions = getBaseDimensions();
315394
if (baseDimensions) {
316-
setMinimumScale(
317-
getMinimumScale(
318-
baseDimensions,
319-
cachedViewportRect.width,
320-
cachedViewportRect.height,
321-
),
395+
const fitTransform = getFitTransform(
396+
baseDimensions,
397+
cachedViewportRect.width,
398+
cachedViewportRect.height,
322399
);
400+
401+
if (fitTransform) {
402+
fitTransformRef.current = fitTransform;
403+
setMinimumScale(fitTransform.scale);
404+
405+
if (!hasUserInteractedRef.current) {
406+
scheduleFitTransform(0);
407+
}
408+
}
323409
}
324410
});
325411

@@ -329,6 +415,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
329415
e.preventDefault();
330416
e.stopPropagation();
331417
e.stopImmediatePropagation();
418+
clearScheduledFit();
332419

333420
if (!imageRef.current || !transformRef.current) return;
334421

@@ -356,12 +443,35 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
356443
const baseDimensions = getBaseDimensions();
357444
if (!baseDimensions) return;
358445

446+
const fitTransform = getFitTransform(
447+
baseDimensions,
448+
viewportRect.width,
449+
viewportRect.height,
450+
);
451+
if (!fitTransform) return;
452+
359453
const minimumScale = getMinimumScale(
360454
baseDimensions,
361455
viewportRect.width,
362456
viewportRect.height,
363457
);
364-
const currentScale = transformState.scale;
458+
fitTransformRef.current = fitTransform;
459+
460+
const shouldUseFitTransform =
461+
!isFitInitializedRef.current &&
462+
fitTransform.scale < MIN_SCALE - SCALE_EPSILON &&
463+
transformState.scale > fitTransform.scale + SCALE_EPSILON &&
464+
Math.abs(transformState.positionX) < SCALE_EPSILON &&
465+
Math.abs(transformState.positionY) < SCALE_EPSILON;
466+
const currentScale = shouldUseFitTransform
467+
? fitTransform.scale
468+
: transformState.scale;
469+
const currentPositionX = shouldUseFitTransform
470+
? fitTransform.positionX
471+
: transformState.positionX;
472+
const currentPositionY = shouldUseFitTransform
473+
? fitTransform.positionY
474+
: transformState.positionY;
365475
const desiredScale = Math.max(
366476
minimumScale,
367477
Math.min(MAX_SCALE, currentScale + zoomChange),
@@ -423,9 +533,9 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
423533
const mouseViewportX = e.clientX - viewportRect.left;
424534
const mouseViewportY = e.clientY - viewportRect.top;
425535
const anchoredX =
426-
mouseViewportX - (mouseViewportX - transformState.positionX) * ratio;
536+
mouseViewportX - (mouseViewportX - currentPositionX) * ratio;
427537
const anchoredY =
428-
mouseViewportY - (mouseViewportY - transformState.positionY) * ratio;
538+
mouseViewportY - (mouseViewportY - currentPositionY) * ratio;
429539
const shouldAnchorX =
430540
isOverImage && currentTouchesViewport.width && newOverflow.width;
431541
const shouldAnchorY =
@@ -449,6 +559,8 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
449559
);
450560

451561
setIsOverflowing(newOverflow.width || newOverflow.height);
562+
isFitInitializedRef.current = true;
563+
hasUserInteractedRef.current = true;
452564
transformRef.current.setTransform(targetX, targetY, newScale, 0);
453565
};
454566

@@ -458,14 +570,17 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
458570
});
459571

460572
return () => {
573+
clearScheduledFit();
461574
resizeObserver.disconnect();
462575
wheelElement.removeEventListener('wheel', handleWheelInterceptor, true);
463576
};
464577
}, [
578+
clearScheduledFit,
465579
getBaseDimensions,
466580
getOverflowState,
467581
getScaledDimensions,
468582
getViewportElement,
583+
scheduleFitTransform,
469584
setMinimumScale,
470585
]);
471586

@@ -505,6 +620,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
505620
setIsOverflowing(overflow.width || overflow.height);
506621
}}
507622
onPanning={(ref) => {
623+
hasUserInteractedRef.current = true;
508624
const scale = ref.state.scale;
509625
const wrapper = getViewportElement();
510626
if (!wrapper || !imageRef.current) return;

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,25 @@ describe('ZoomableImage wheel behavior', () => {
293293
expectLatestTransform(0, 0, 0.5);
294294
});
295295

296+
test('starts a production first wheel from fit-to-view when transform state is stale', () => {
297+
mockTransformState.scale = 1;
298+
mockTransformState.positionX = 0;
299+
mockTransformState.positionY = 0;
300+
301+
const { wheelArea } = setupWheelScene({
302+
wrapperSize: { width: 500, height: 400 },
303+
imageSize: { width: 1000, height: 800 },
304+
});
305+
306+
fireEvent.wheel(wheelArea, {
307+
deltaY: -100,
308+
clientX: 450,
309+
clientY: 350,
310+
});
311+
312+
expectLatestTransform(-90, -70, 0.6);
313+
});
314+
296315
test('clamps wheel zoom targets so the image cannot be pulled offscreen', () => {
297316
mockTransformState.scale = 2;
298317
mockTransformState.positionX = 2000;

0 commit comments

Comments
 (0)