Skip to content

Commit efa51e6

Browse files
committed
Harden button zoom animation
1 parent 72cd74f commit efa51e6

3 files changed

Lines changed: 266 additions & 17 deletions

File tree

frontend/src/components/Media/ZoomableImage.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
6969
>
7070
<div
7171
data-testid="zoom-content"
72-
onTransitionEnd={handleZoomTransitionEnd}
72+
onTransitionEnd={(e) => {
73+
if (
74+
e.target === e.currentTarget &&
75+
e.propertyName === 'transform'
76+
) {
77+
handleZoomTransitionEnd();
78+
}
79+
}}
7380
style={{
7481
position: 'relative',
7582
width: contentDimensions

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

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -877,4 +877,205 @@ describe('ZoomableImage controlled transform behavior', () => {
877877

878878
expectCurrentTransform(0, 0, 0.5);
879879
});
880+
881+
describe('control button zoom animation', () => {
882+
// jsdom has no TransitionEvent constructor, and fireEvent.transitionEnd does
883+
// not deliver `propertyName` to React's synthetic event. Build the event
884+
// manually (mirroring firePointerEvent) so the handler's property filter
885+
// sees a real value.
886+
const fireTransitionEnd = (element: Element, propertyName: string) => {
887+
const event = new Event('transitionend', {
888+
bubbles: true,
889+
cancelable: true,
890+
});
891+
Object.defineProperty(event, 'propertyName', {
892+
configurable: true,
893+
value: propertyName,
894+
});
895+
fireEvent(element, event);
896+
};
897+
898+
const setupSceneWithRef = (
899+
viewportSize: { width: number; height: number },
900+
imageSize: { width: number; height: number },
901+
) => {
902+
const imageRef = createRef<ZoomableImageRef>();
903+
904+
render(
905+
<ZoomableImage
906+
ref={imageRef}
907+
imagePath="/tmp/photo.jpg"
908+
alt="test image"
909+
rotation={0}
910+
/>,
911+
);
912+
913+
const viewport = screen.getByTestId('zoom-viewport');
914+
const content = screen.getByTestId('zoom-content');
915+
const image = screen.getByAltText('test image');
916+
917+
mockElementRect(
918+
viewport,
919+
{ ...viewportSize, left: 0, top: 0 },
920+
{ clientWidth: viewportSize.width, clientHeight: viewportSize.height },
921+
);
922+
mockImageDimensions(image, imageSize);
923+
fireEvent.load(image);
924+
925+
return { imageRef, viewport, content, image };
926+
};
927+
928+
test('enables a smooth transition for a button zoom that changes scale', () => {
929+
const { imageRef, content } = setupSceneWithRef(
930+
{ width: 800, height: 600 },
931+
{ width: 400, height: 300 },
932+
);
933+
934+
act(() => {
935+
imageRef.current?.zoomIn();
936+
});
937+
938+
expect(content.style.transition).toBe('transform 250ms ease-out');
939+
expect(getCurrentTransform().scale).toBeCloseTo(1.5);
940+
});
941+
942+
test('switching to the wheel cancels the in-flight button transition', () => {
943+
const { imageRef, viewport, content } = setupSceneWithRef(
944+
{ width: 800, height: 600 },
945+
{ width: 400, height: 300 },
946+
);
947+
948+
act(() => {
949+
imageRef.current?.zoomIn();
950+
});
951+
expect(content.style.transition).toBe('transform 250ms ease-out');
952+
953+
fireEvent.wheel(viewport, { deltaY: -100, clientX: 400, clientY: 300 });
954+
955+
// Wheel zoom must stay instant: the transition is cleared immediately.
956+
expect(content.style.transition).toBe('');
957+
});
958+
959+
test('transitionend ends the animation so later transforms are instant', () => {
960+
const { imageRef, content } = setupSceneWithRef(
961+
{ width: 800, height: 600 },
962+
{ width: 400, height: 300 },
963+
);
964+
965+
act(() => {
966+
imageRef.current?.zoomIn();
967+
});
968+
expect(content.style.transition).toBe('transform 250ms ease-out');
969+
970+
act(() => {
971+
fireTransitionEnd(content, 'transform');
972+
});
973+
974+
expect(content.style.transition).toBe('');
975+
});
976+
977+
test('ignores unrelated transitionend events', () => {
978+
const { imageRef, content } = setupSceneWithRef(
979+
{ width: 800, height: 600 },
980+
{ width: 400, height: 300 },
981+
);
982+
983+
act(() => {
984+
imageRef.current?.zoomIn();
985+
});
986+
987+
// A bubbled, non-transform transitionend must not clear the animation.
988+
act(() => {
989+
fireTransitionEnd(content, 'opacity');
990+
});
991+
992+
expect(content.style.transition).toBe('transform 250ms ease-out');
993+
});
994+
995+
test('does not animate a button zoom that is clamped at maximum scale', () => {
996+
const { imageRef, content } = setupSceneWithRef(
997+
{ width: 800, height: 600 },
998+
{ width: 800, height: 600 },
999+
);
1000+
1001+
// Saturate at MAX_SCALE; well past the 1.5^n needed to reach 8.
1002+
for (let i = 0; i < 12; i += 1) {
1003+
act(() => {
1004+
imageRef.current?.zoomIn();
1005+
});
1006+
}
1007+
1008+
expect(getCurrentTransform().scale).toBeCloseTo(8);
1009+
// The final click could not change the transform, so no transition runs.
1010+
expect(content.style.transition).toBe('');
1011+
});
1012+
1013+
test('does not animate a button zoom that is clamped at minimum scale', () => {
1014+
const { imageRef, content } = setupSceneWithRef(
1015+
{ width: 800, height: 600 },
1016+
{ width: 400, height: 300 },
1017+
);
1018+
1019+
// The image already fits at minimum scale; zooming out is a no-op.
1020+
act(() => {
1021+
imageRef.current?.zoomOut();
1022+
});
1023+
1024+
expect(getCurrentTransform().scale).toBeCloseTo(1);
1025+
expect(content.style.transition).toBe('');
1026+
});
1027+
1028+
test('ignores a transform transitionend bubbled from a child element', () => {
1029+
const { imageRef, content, image } = setupSceneWithRef(
1030+
{ width: 800, height: 600 },
1031+
{ width: 400, height: 300 },
1032+
);
1033+
1034+
act(() => {
1035+
imageRef.current?.zoomIn();
1036+
});
1037+
expect(content.style.transition).toBe('transform 250ms ease-out');
1038+
1039+
// A transform transitionend from the child <img> bubbles to the content
1040+
// handler, but must be ignored (target !== currentTarget).
1041+
act(() => {
1042+
fireTransitionEnd(image, 'transform');
1043+
});
1044+
1045+
expect(content.style.transition).toBe('transform 250ms ease-out');
1046+
});
1047+
1048+
test('skips the transition when reduced motion is preferred', () => {
1049+
const matchMediaSpy = jest.spyOn(window, 'matchMedia').mockImplementation(
1050+
(query: string) =>
1051+
({
1052+
matches: query.includes('prefers-reduced-motion'),
1053+
media: query,
1054+
onchange: null,
1055+
addListener: jest.fn(),
1056+
removeListener: jest.fn(),
1057+
addEventListener: jest.fn(),
1058+
removeEventListener: jest.fn(),
1059+
dispatchEvent: jest.fn(),
1060+
}) as unknown as MediaQueryList,
1061+
);
1062+
1063+
try {
1064+
const { imageRef, content } = setupSceneWithRef(
1065+
{ width: 800, height: 600 },
1066+
{ width: 400, height: 300 },
1067+
);
1068+
1069+
act(() => {
1070+
imageRef.current?.zoomIn();
1071+
});
1072+
1073+
// The zoom still applies, but without the CSS transition.
1074+
expect(getCurrentTransform().scale).toBeCloseTo(1.5);
1075+
expect(content.style.transition).toBe('');
1076+
} finally {
1077+
matchMediaSpy.mockRestore();
1078+
}
1079+
});
1080+
});
8801081
});

frontend/src/hooks/useZoomTransform.ts

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export const useZoomTransform = ({
6060
const fitFrameRef = useRef<number | null>(null);
6161
const fitRetryCountRef = useRef(0);
6262
const dragStateRef = useRef<DragState | null>(null);
63+
const buttonZoomTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
64+
null,
65+
);
6366
const rotationRef = useRef(rotation);
6467
const [transformState, setTransformState] = useState<TransformState>(
6568
transformStateRef.current,
@@ -236,12 +239,21 @@ export const useZoomTransform = ({
236239
[applyFitTransform, clearScheduledFit],
237240
);
238241

242+
const clearButtonZoomAnimation = useCallback(() => {
243+
setIsButtonZoom(false);
244+
if (buttonZoomTimeoutRef.current !== null) {
245+
clearTimeout(buttonZoomTimeoutRef.current);
246+
buttonZoomTimeoutRef.current = null;
247+
}
248+
}, []);
249+
239250
const resetToFit = useCallback(() => {
251+
clearButtonZoomAnimation();
240252
isFitInitializedRef.current = false;
241253
hasUserInteractedRef.current = false;
242254
fitRetryCountRef.current = 0;
243255
scheduleFitTransform(false);
244-
}, [scheduleFitTransform]);
256+
}, [clearButtonZoomAnimation, scheduleFitTransform]);
245257

246258
const zoomBy = useCallback(
247259
(zoomRatio: number, clientX?: number, clientY?: number) => {
@@ -294,6 +306,7 @@ export const useZoomTransform = ({
294306

295307
const handleResize = () => {
296308
if (hasUserInteractedRef.current) {
309+
clearButtonZoomAnimation();
297310
applyTransform(transformStateRef.current);
298311
} else {
299312
resetToFit();
@@ -313,7 +326,7 @@ export const useZoomTransform = ({
313326
resizeObserver?.disconnect();
314327
window.removeEventListener('resize', handleResize);
315328
};
316-
}, [applyTransform, resetToFit]);
329+
}, [applyTransform, clearButtonZoomAnimation, resetToFit]);
317330

318331
useEffect(() => {
319332
const viewport = viewportRef.current;
@@ -324,7 +337,7 @@ export const useZoomTransform = ({
324337
e.stopPropagation();
325338
e.stopImmediatePropagation();
326339

327-
setIsButtonZoom(false);
340+
clearButtonZoomAnimation();
328341

329342
const isLineMode = e.deltaMode === 1;
330343
const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1;
@@ -342,11 +355,14 @@ export const useZoomTransform = ({
342355
return () => {
343356
viewport.removeEventListener('wheel', handleWheel, true);
344357
};
345-
}, [zoomBy]);
358+
}, [clearButtonZoomAnimation, zoomBy]);
346359

347360
useEffect(
348361
() => () => {
349362
clearScheduledFit();
363+
if (buttonZoomTimeoutRef.current !== null) {
364+
clearTimeout(buttonZoomTimeoutRef.current);
365+
}
350366
},
351367
[clearScheduledFit],
352368
);
@@ -375,6 +391,8 @@ export const useZoomTransform = ({
375391

376392
if (!overflow.width && !overflow.height) return false;
377393

394+
clearButtonZoomAnimation();
395+
378396
dragStateRef.current = {
379397
pointerId,
380398
startClientX: clientX,
@@ -386,7 +404,7 @@ export const useZoomTransform = ({
386404
setIsPanning(true);
387405
return true;
388406
},
389-
[getGeometry],
407+
[clearButtonZoomAnimation, getGeometry],
390408
);
391409

392410
const updateDrag = useCallback(
@@ -469,17 +487,40 @@ export const useZoomTransform = ({
469487
[endDrag],
470488
);
471489

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-
}, []);
490+
const startButtonZoom = useCallback(
491+
(ratio: number) => {
492+
clearButtonZoomAnimation();
493+
494+
const before = transformStateRef.current;
495+
const didApply = zoomBy(ratio);
496+
const after = transformStateRef.current;
497+
const didChange =
498+
didApply &&
499+
(Math.abs(after.scale - before.scale) > SCALE_EPSILON ||
500+
Math.abs(after.positionX - before.positionX) > SCALE_EPSILON ||
501+
Math.abs(after.positionY - before.positionY) > SCALE_EPSILON);
502+
503+
if (!didChange) return;
504+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
505+
return;
506+
507+
setIsButtonZoom(true);
508+
buttonZoomTimeoutRef.current = setTimeout(() => {
509+
setIsButtonZoom(false);
510+
buttonZoomTimeoutRef.current = null;
511+
}, 300);
512+
},
513+
[clearButtonZoomAnimation, zoomBy],
514+
);
515+
const zoomIn = useCallback(
516+
() => startButtonZoom(CONTROL_BUTTON_ZOOM_RATIO),
517+
[startButtonZoom],
518+
);
519+
const zoomOut = useCallback(
520+
() => startButtonZoom(1 / CONTROL_BUTTON_ZOOM_RATIO),
521+
[startButtonZoom],
522+
);
523+
const handleZoomTransitionEnd = clearButtonZoomAnimation;
483524

484525
const contentDimensions = rawDimensions
485526
? getEffectiveDimensions(

0 commit comments

Comments
 (0)