Skip to content

Commit 69fc975

Browse files
committed
Retry zoom fit until transform content mounts
1 parent df7f302 commit 69fc975

2 files changed

Lines changed: 98 additions & 9 deletions

File tree

frontend/src/components/Media/ZoomableImage.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const LINE_HEIGHT_MULTIPLIER = 33;
1818
const MAX_SCALE = 8;
1919
const MIN_SCALE = 1;
2020
const SCALE_EPSILON = 0.0001;
21+
const MAX_FIT_RETRY_FRAMES = 12;
2122

2223
type Size = {
2324
width: number;
@@ -162,6 +163,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
162163
const hasPendingFitRef = useRef(true);
163164
const fitTransformRef = useRef<FitTransform | null>(null);
164165
const fitFrameRef = useRef<number | null>(null);
166+
const fitRetryCountRef = useRef(0);
165167
const [isOverflowing, setIsOverflowing] = useState(false);
166168
const [minScale, setMinScale] = useState(MIN_SCALE);
167169
const rotationRef = useRef(rotation);
@@ -252,40 +254,47 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
252254
hasUserInteractedRef.current = false;
253255
hasPendingFitRef.current = true;
254256
fitTransformRef.current = null;
257+
fitRetryCountRef.current = 0;
255258
}, []);
256259

257260
const applyFitTransform = useCallback(
258261
(duration = 200, animationType: AnimationType = 'easeOut') => {
262+
const transform = transformRef.current;
263+
const contentElement = transform?.instance?.contentComponent;
259264
const viewportElement = getViewportElement();
260-
if (!transformRef.current || !viewportElement || !imageRef.current)
261-
return;
265+
if (!transform || !contentElement || !viewportElement || !imageRef.current)
266+
return false;
262267

263268
const wrapperRect = viewportElement.getBoundingClientRect();
269+
if (!wrapperRect.width || !wrapperRect.height) return false;
270+
264271
const baseDimensions = getBaseDimensions();
265272

266-
if (!baseDimensions) return;
273+
if (!baseDimensions) return false;
267274

268275
const fitTransform = getFitTransform(
269276
baseDimensions,
270277
wrapperRect.width,
271278
wrapperRect.height,
272279
);
273280

274-
if (!fitTransform) return;
281+
if (!fitTransform) return false;
275282

276283
fitTransformRef.current = fitTransform;
277284
isFitInitializedRef.current = true;
278285
hasUserInteractedRef.current = false;
279286
hasPendingFitRef.current = false;
287+
fitRetryCountRef.current = 0;
280288
setMinimumScale(fitTransform.scale);
281-
transformRef.current.setTransform(
289+
transform.setTransform(
282290
fitTransform.positionX,
283291
fitTransform.positionY,
284292
fitTransform.scale,
285293
duration,
286294
animationType,
287295
);
288296
setIsOverflowing(false);
297+
return true;
289298
},
290299
[getBaseDimensions, getViewportElement, setMinimumScale],
291300
);
@@ -302,7 +311,16 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
302311

303312
fitFrameRef.current = scheduleFrame(() => {
304313
fitFrameRef.current = null;
305-
applyFitTransform(duration, animationType);
314+
const applied = applyFitTransform(duration, animationType);
315+
316+
if (
317+
!applied &&
318+
hasPendingFitRef.current &&
319+
fitRetryCountRef.current < MAX_FIT_RETRY_FRAMES
320+
) {
321+
fitRetryCountRef.current += 1;
322+
scheduleFitTransform(duration, animationType);
323+
}
306324
});
307325
},
308326
[applyFitTransform, clearScheduledFit],
@@ -418,9 +436,14 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
418436
e.preventDefault();
419437
e.stopPropagation();
420438
e.stopImmediatePropagation();
421-
clearScheduledFit();
422439

423440
if (!imageRef.current || !transformRef.current) return;
441+
if (!transformRef.current.instance.contentComponent) {
442+
scheduleFitTransform(0);
443+
return;
444+
}
445+
446+
clearScheduledFit();
424447

425448
const transformState = transformRef.current.instance.transformState;
426449
const viewportElement = getViewportElement() ?? wheelElement;

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

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, screen } from '@testing-library/react';
1+
import { act, fireEvent, render, screen } from '@testing-library/react';
22
import type { ReactNode, Ref } from 'react';
33
import { ZoomableImage } from '../ZoomableImage';
44

@@ -17,6 +17,8 @@ const mockSetTransform = jest.fn(
1717
},
1818
);
1919
let mockWrapperComponent: HTMLDivElement | null = null;
20+
let mockContentComponent: HTMLDivElement | null = null;
21+
let mockShouldExposeContentComponent = true;
2022

2123
jest.mock('@tauri-apps/api/core', () => ({
2224
convertFileSrc: (path: string) => path,
@@ -32,6 +34,11 @@ jest.mock('react-zoom-pan-pinch', () => {
3234
get wrapperComponent() {
3335
return mockWrapperComponent;
3436
},
37+
get contentComponent() {
38+
return mockShouldExposeContentComponent
39+
? mockContentComponent
40+
: null;
41+
},
3542
transformState: mockTransformState,
3643
},
3744
setTransform: mockSetTransform,
@@ -48,7 +55,16 @@ jest.mock('react-zoom-pan-pinch', () => {
4855
);
4956

5057
const TransformComponent = ({ children }: { children: ReactNode }) =>
51-
React.createElement('div', null, children);
58+
React.createElement(
59+
'div',
60+
{
61+
'data-testid': 'transform-content',
62+
ref: (node: HTMLDivElement | null) => {
63+
mockContentComponent = node;
64+
},
65+
},
66+
children,
67+
);
5268

5369
return {
5470
TransformWrapper,
@@ -122,9 +138,11 @@ const setupWheelScene = ({
122138
const { container } = renderZoomableImage();
123139
const wheelArea = container.firstElementChild as HTMLElement;
124140
const transformWrapper = screen.getByTestId('transform-wrapper');
141+
const transformContent = screen.getByTestId('transform-content');
125142
const image = screen.getByAltText('test image');
126143

127144
mockWrapperComponent = transformWrapper as HTMLDivElement;
145+
mockContentComponent = transformContent as HTMLDivElement;
128146

129147
mockElementRect(
130148
wheelArea,
@@ -180,6 +198,8 @@ describe('ZoomableImage wheel behavior', () => {
180198
// Keep the internal wrapper unavailable by default so tests cover the
181199
// production fallback path where the wheel area owns scroll handling.
182200
mockWrapperComponent = null;
201+
mockContentComponent = null;
202+
mockShouldExposeContentComponent = true;
183203
mockTransformState.scale = 1;
184204
mockTransformState.positionX = 0;
185205
mockTransformState.positionY = 0;
@@ -345,6 +365,52 @@ describe('ZoomableImage wheel behavior', () => {
345365
expectLatestTransform(-90, -70, 0.6);
346366
});
347367

368+
test('retries initial fit until the transform content is ready', () => {
369+
jest.useFakeTimers();
370+
mockShouldExposeContentComponent = false;
371+
372+
const { container } = renderZoomableImage();
373+
374+
const wheelArea = container.firstElementChild as HTMLElement;
375+
const transformWrapper = screen.getByTestId('transform-wrapper');
376+
const transformContent = screen.getByTestId('transform-content');
377+
const image = screen.getByAltText('test image');
378+
379+
mockWrapperComponent = transformWrapper as HTMLDivElement;
380+
mockElementRect(
381+
wheelArea,
382+
{ width: 500, height: 400, left: 0, top: 0 },
383+
{ clientWidth: 500, clientHeight: 400 },
384+
);
385+
mockElementRect(
386+
transformWrapper,
387+
{ width: 500, height: 400, left: 0, top: 0 },
388+
{ clientWidth: 500, clientHeight: 400 },
389+
);
390+
mockElementRect(
391+
image,
392+
{ width: 1000, height: 800, left: 0, top: 0 },
393+
{ clientWidth: 1000, clientHeight: 800 },
394+
);
395+
396+
fireEvent.load(image);
397+
398+
act(() => {
399+
jest.runOnlyPendingTimers();
400+
});
401+
expect(mockSetTransform).not.toHaveBeenCalled();
402+
403+
mockShouldExposeContentComponent = true;
404+
mockContentComponent = transformContent as HTMLDivElement;
405+
406+
act(() => {
407+
jest.runOnlyPendingTimers();
408+
});
409+
410+
expectLatestTransform(0, 0, 0.5);
411+
jest.useRealTimers();
412+
});
413+
348414
test('starts from the new image fit transform after switching images', () => {
349415
const { container, rerender } = renderZoomableImage({
350416
imagePath: '/tmp/photo-a.jpg',

0 commit comments

Comments
 (0)