Skip to content

Commit cda6bcc

Browse files
committed
Derive media zoom minimum from viewer size
1 parent cd4a727 commit cda6bcc

2 files changed

Lines changed: 120 additions & 45 deletions

File tree

frontend/src/components/Media/ZoomableImage.tsx

Lines changed: 101 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,40 @@ const getAxisPosition = (
9191
const axisTouchesViewport = (scaledSize: number, viewportSize: number) =>
9292
scaledSize >= viewportSize - SCALE_EPSILON;
9393

94+
const getMinimumScale = (
95+
baseDimensions: Size,
96+
viewportWidth: number,
97+
viewportHeight: number,
98+
) => {
99+
if (
100+
!baseDimensions.width ||
101+
!baseDimensions.height ||
102+
!viewportWidth ||
103+
!viewportHeight
104+
) {
105+
return MIN_SCALE;
106+
}
107+
108+
return Math.min(
109+
MIN_SCALE,
110+
viewportWidth / baseDimensions.width,
111+
viewportHeight / baseDimensions.height,
112+
);
113+
};
114+
94115
const getFirstViewportEdgeScale = (
95116
baseDimensions: Size,
96117
viewportWidth: number,
97118
viewportHeight: number,
98119
) =>
99-
Math.max(
100-
MIN_SCALE,
101-
Math.min(
102-
MAX_SCALE,
103-
viewportWidth / baseDimensions.width,
104-
viewportHeight / baseDimensions.height,
120+
Math.min(
121+
MAX_SCALE,
122+
Math.max(
123+
getMinimumScale(baseDimensions, viewportWidth, viewportHeight),
124+
Math.min(
125+
viewportWidth / baseDimensions.width,
126+
viewportHeight / baseDimensions.height,
127+
),
105128
),
106129
);
107130

@@ -111,8 +134,15 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
111134
const wheelAreaRef = useRef<HTMLDivElement>(null);
112135
const imageRef = useRef<HTMLImageElement>(null);
113136
const [isOverflowing, setIsOverflowing] = useState(false);
137+
const [minScale, setMinScale] = useState(MIN_SCALE);
114138
const rotationRef = useRef(rotation);
115139

140+
const setMinimumScale = useCallback((scale: number) => {
141+
setMinScale((currentScale) =>
142+
Math.abs(currentScale - scale) < SCALE_EPSILON ? currentScale : scale,
143+
);
144+
}, []);
145+
116146
useEffect(() => {
117147
rotationRef.current = rotation;
118148
}, [rotation]);
@@ -132,8 +162,10 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
132162
const getBaseDimensions = useCallback((): Size | null => {
133163
if (!imageRef.current) return null;
134164

135-
const renderedWidth = imageRef.current.clientWidth;
136-
const renderedHeight = imageRef.current.clientHeight;
165+
const renderedWidth =
166+
imageRef.current.naturalWidth || imageRef.current.clientWidth;
167+
const renderedHeight =
168+
imageRef.current.naturalHeight || imageRef.current.clientHeight;
137169

138170
if (!renderedWidth || !renderedHeight) return null;
139171

@@ -186,30 +218,21 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
186218
return;
187219

188220
const wrapperRect = viewportElement.getBoundingClientRect();
189-
const img = imageRef.current;
190-
191-
const scale = 1;
192-
const baseW = img.naturalWidth || img.clientWidth;
193-
const baseH = img.naturalHeight || img.clientHeight;
194-
195-
if (!baseW || !baseH) return;
196-
197-
const effectiveDims = getEffectiveDimensions(baseW, baseH);
198-
const imgAspect = effectiveDims.width / effectiveDims.height;
199-
const viewAspect = wrapperRect.width / wrapperRect.height;
221+
const baseDimensions = getBaseDimensions();
200222

201-
let renderedW, renderedH;
202-
if (imgAspect > viewAspect) {
203-
renderedW = Math.min(effectiveDims.width, wrapperRect.width);
204-
renderedH = renderedW / imgAspect;
205-
} else {
206-
renderedH = Math.min(effectiveDims.height, wrapperRect.height);
207-
renderedW = renderedH * imgAspect;
208-
}
223+
if (!baseDimensions) return;
209224

225+
const scale = getMinimumScale(
226+
baseDimensions,
227+
wrapperRect.width,
228+
wrapperRect.height,
229+
);
230+
const renderedW = baseDimensions.width * scale;
231+
const renderedH = baseDimensions.height * scale;
210232
const centerX = getCenteredAxisPosition(wrapperRect.width, renderedW);
211233
const centerY = getCenteredAxisPosition(wrapperRect.height, renderedH);
212234

235+
setMinimumScale(scale);
213236
transformRef.current.setTransform(
214237
centerX,
215238
centerY,
@@ -219,7 +242,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
219242
);
220243
setIsOverflowing(false);
221244
},
222-
[getEffectiveDimensions, getViewportElement],
245+
[getBaseDimensions, getViewportElement, setMinimumScale],
223246
);
224247

225248
useImperativeHandle(ref, () => ({
@@ -239,10 +262,23 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
239262

240263
const scale = transformRef.current.instance.transformState.scale;
241264
const rect = viewportElement.getBoundingClientRect();
265+
const baseDimensions = getBaseDimensions();
266+
267+
if (baseDimensions) {
268+
setMinimumScale(
269+
getMinimumScale(baseDimensions, rect.width, rect.height),
270+
);
271+
}
242272

243273
const overflow = getOverflowState(scale, rect.width, rect.height);
244274
setIsOverflowing(overflow.width || overflow.height);
245-
}, [rotation, getOverflowState, getViewportElement]);
275+
}, [
276+
rotation,
277+
getBaseDimensions,
278+
getOverflowState,
279+
getViewportElement,
280+
setMinimumScale,
281+
]);
246282

247283
useEffect(() => {
248284
setIsOverflowing(false);
@@ -271,9 +307,20 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
271307
wheelElement.getBoundingClientRect();
272308

273309
const resizeObserver = new ResizeObserver(() => {
274-
cachedViewportRect =
275-
getViewportElement()?.getBoundingClientRect() ??
276-
wheelElement.getBoundingClientRect();
310+
const viewportElement = getViewportElement() ?? wheelElement;
311+
312+
cachedViewportRect = viewportElement.getBoundingClientRect();
313+
314+
const baseDimensions = getBaseDimensions();
315+
if (baseDimensions) {
316+
setMinimumScale(
317+
getMinimumScale(
318+
baseDimensions,
319+
cachedViewportRect.width,
320+
cachedViewportRect.height,
321+
),
322+
);
323+
}
277324
});
278325

279326
resizeObserver.observe(wheelElement);
@@ -306,13 +353,20 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
306353

307354
const zoomChange = -e.deltaY * multiplier * factor;
308355

356+
const baseDimensions = getBaseDimensions();
357+
if (!baseDimensions) return;
358+
359+
const minimumScale = getMinimumScale(
360+
baseDimensions,
361+
viewportRect.width,
362+
viewportRect.height,
363+
);
309364
const currentScale = transformState.scale;
310365
const desiredScale = Math.max(
311-
MIN_SCALE,
366+
minimumScale,
312367
Math.min(MAX_SCALE, currentScale + zoomChange),
313368
);
314-
const baseDimensions = getBaseDimensions();
315-
if (!baseDimensions) return;
369+
setMinimumScale(minimumScale);
316370

317371
const currentDimensions = {
318372
width: baseDimensions.width * currentScale,
@@ -354,7 +408,8 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
354408
);
355409

356410
const shouldRecenter =
357-
newScale === MIN_SCALE || (!newOverflow.width && !newOverflow.height);
411+
newScale <= minimumScale + SCALE_EPSILON ||
412+
(!newOverflow.width && !newOverflow.height);
358413

359414
const centeredX = getCenteredAxisPosition(
360415
viewportRect.width,
@@ -411,14 +466,15 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
411466
getOverflowState,
412467
getScaledDimensions,
413468
getViewportElement,
469+
setMinimumScale,
414470
]);
415471

416472
return (
417473
<div ref={wheelAreaRef} className="h-full w-full">
418474
<TransformWrapper
419475
ref={transformRef}
420-
initialScale={MIN_SCALE}
421-
minScale={MIN_SCALE}
476+
initialScale={minScale}
477+
minScale={minScale}
422478
maxScale={MAX_SCALE}
423479
centerOnInit
424480
limitToBounds={!isOverflowing}
@@ -461,12 +517,12 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
461517
const positionY = ref.state.positionY;
462518
const viewW = wrapper.clientWidth;
463519
const viewH = wrapper.clientHeight;
464-
const imgW = imageRef.current.clientWidth;
465-
const imgH = imageRef.current.clientHeight;
520+
const baseDimensions = getBaseDimensions();
521+
522+
if (!baseDimensions) return;
466523

467-
const effectiveDims = getEffectiveDimensions(imgW, imgH);
468-
const scaledW = effectiveDims.width * scale;
469-
const scaledH = effectiveDims.height * scale;
524+
const scaledW = baseDimensions.width * scale;
525+
const scaledH = baseDimensions.height * scale;
470526

471527
const finalX = getAxisPosition(
472528
positionX,
@@ -512,8 +568,8 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
512568
img.src = '/placeholder.svg';
513569
}}
514570
style={{
515-
maxWidth: '100vw',
516-
maxHeight: '100vh',
571+
maxWidth: 'none',
572+
maxHeight: 'none',
517573
objectFit: 'contain',
518574
zIndex: 50,
519575
transform: `rotate(${rotation}deg)`,

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,25 @@ describe('ZoomableImage wheel behavior', () => {
272272
expectLatestTransform(200, 150, 1);
273273
});
274274

275+
test('uses a fit-to-view minimum scale when the image is larger than the measured viewer', () => {
276+
mockTransformState.scale = 0.55;
277+
mockTransformState.positionX = -80;
278+
mockTransformState.positionY = -60;
279+
280+
const { wheelArea } = setupWheelScene({
281+
wrapperSize: { width: 500, height: 400 },
282+
imageSize: { width: 1000, height: 800 },
283+
});
284+
285+
fireEvent.wheel(wheelArea, {
286+
deltaY: 100,
287+
clientX: 450,
288+
clientY: 350,
289+
});
290+
291+
expectLatestTransform(0, 0, 0.5);
292+
});
293+
275294
test('clamps wheel zoom targets so the image cannot be pulled offscreen', () => {
276295
mockTransformState.scale = 2;
277296
mockTransformState.positionX = 2000;

0 commit comments

Comments
 (0)