Skip to content

Commit 5b8af33

Browse files
committed
Tighten media viewer scroll zoom bounds
1 parent e84a26b commit 5b8af33

2 files changed

Lines changed: 288 additions & 89 deletions

File tree

frontend/src/components/Media/ZoomableImage.tsx

Lines changed: 134 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,16 @@ const ZOOM_FACTOR = 0.001;
1717
const LINE_HEIGHT_MULTIPLIER = 33;
1818
const MAX_SCALE = 8;
1919
const MIN_SCALE = 1;
20-
const PAN_PADDING = 20;
20+
21+
type Size = {
22+
width: number;
23+
height: number;
24+
};
25+
26+
type OverflowState = {
27+
width: boolean;
28+
height: boolean;
29+
};
2130

2231
type AnimationType =
2332
| 'easeOut'
@@ -48,6 +57,36 @@ export interface ZoomableImageRef {
4857
reset: () => void;
4958
}
5059

60+
const getCenteredAxisPosition = (viewportSize: number, scaledSize: number) =>
61+
(viewportSize - scaledSize) / 2;
62+
63+
const clamp = (value: number, min: number, max: number) =>
64+
Math.min(Math.max(value, min), max);
65+
66+
const clampOverflowAxisPosition = (
67+
position: number,
68+
viewportSize: number,
69+
scaledSize: number,
70+
) => {
71+
const minPosition = viewportSize - scaledSize;
72+
const maxPosition = 0;
73+
74+
return clamp(position, minPosition, maxPosition);
75+
};
76+
77+
const getAxisPosition = (
78+
anchoredPosition: number,
79+
viewportSize: number,
80+
scaledSize: number,
81+
isOverflowingAxis: boolean,
82+
) => {
83+
const centeredPosition = getCenteredAxisPosition(viewportSize, scaledSize);
84+
85+
return isOverflowingAxis
86+
? clampOverflowAxisPosition(anchoredPosition, viewportSize, scaledSize)
87+
: centeredPosition;
88+
};
89+
5190
export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
5291
({ imagePath, alt, rotation, resetSignal }, ref) => {
5392
const transformRef = useRef<ReactZoomPanPinchRef>(null);
@@ -72,29 +111,46 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
72111
[],
73112
);
74113

75-
const getOverflowState = useCallback(
76-
(scale: number, viewportWidth: number, viewportHeight: number) => {
77-
if (!imageRef.current) return { width: false, height: false };
114+
const getScaledDimensions = useCallback(
115+
(scale: number): Size | null => {
116+
if (!imageRef.current) return null;
117+
118+
const renderedWidth = imageRef.current.clientWidth;
119+
const renderedHeight = imageRef.current.clientHeight;
78120

79-
const imgElement = imageRef.current;
80-
const renderedWidth = imgElement.clientWidth;
81-
const renderedHeight = imgElement.clientHeight;
121+
if (!renderedWidth || !renderedHeight) return null;
82122

83123
const effectiveDims = getEffectiveDimensions(
84124
renderedWidth,
85125
renderedHeight,
86126
);
87-
const scaledWidth = effectiveDims.width * scale;
88-
const scaledHeight = effectiveDims.height * scale;
89127

90128
return {
91-
width: scaledWidth > viewportWidth,
92-
height: scaledHeight > viewportHeight,
129+
width: effectiveDims.width * scale,
130+
height: effectiveDims.height * scale,
93131
};
94132
},
95133
[getEffectiveDimensions],
96134
);
97135

136+
const getOverflowState = useCallback(
137+
(
138+
scale: number,
139+
viewportWidth: number,
140+
viewportHeight: number,
141+
): OverflowState => {
142+
const scaledDimensions = getScaledDimensions(scale);
143+
144+
if (!scaledDimensions) return { width: false, height: false };
145+
146+
return {
147+
width: scaledDimensions.width > viewportWidth,
148+
height: scaledDimensions.height > viewportHeight,
149+
};
150+
},
151+
[getScaledDimensions],
152+
);
153+
98154
const getViewportElement = useCallback(
99155
() =>
100156
transformRef.current?.instance?.wrapperComponent ??
@@ -130,8 +186,8 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
130186
renderedW = renderedH * imgAspect;
131187
}
132188

133-
const centerX = (wrapperRect.width - renderedW) / 2;
134-
const centerY = (wrapperRect.height - renderedH) / 2;
189+
const centerX = getCenteredAxisPosition(wrapperRect.width, renderedW);
190+
const centerY = getCenteredAxisPosition(wrapperRect.height, renderedH);
135191

136192
transformRef.current.setTransform(
137193
centerX,
@@ -186,24 +242,33 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
186242
}, [imagePath, handleReset]);
187243

188244
useEffect(() => {
189-
const wrapperElement = wheelAreaRef.current;
190-
if (!wrapperElement) return;
245+
const wheelElement = wheelAreaRef.current;
246+
if (!wheelElement) return;
191247

192-
let cachedWrapperRect = wrapperElement.getBoundingClientRect();
248+
let cachedViewportRect =
249+
getViewportElement()?.getBoundingClientRect() ??
250+
wheelElement.getBoundingClientRect();
193251

194252
const resizeObserver = new ResizeObserver(() => {
195-
cachedWrapperRect = wrapperElement.getBoundingClientRect();
253+
cachedViewportRect =
254+
getViewportElement()?.getBoundingClientRect() ??
255+
wheelElement.getBoundingClientRect();
196256
});
197257

198-
resizeObserver.observe(wrapperElement);
258+
resizeObserver.observe(wheelElement);
199259

200260
const handleWheelInterceptor = (e: WheelEvent) => {
261+
e.preventDefault();
262+
e.stopPropagation();
263+
e.stopImmediatePropagation();
264+
201265
if (!imageRef.current || !transformRef.current) return;
202266

203267
const transformState = transformRef.current.instance.transformState;
268+
const viewportElement = getViewportElement() ?? wheelElement;
204269

205-
cachedWrapperRect = wrapperElement.getBoundingClientRect();
206-
const wrapperRect = cachedWrapperRect;
270+
cachedViewportRect = viewportElement.getBoundingClientRect();
271+
const viewportRect = cachedViewportRect;
207272
const imageRect = imageRef.current.getBoundingClientRect();
208273
const mouseX = e.clientX - imageRect.left;
209274
const mouseY = e.clientY - imageRect.top;
@@ -226,54 +291,65 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
226291
Math.min(MAX_SCALE, currentScale + zoomChange),
227292
);
228293

229-
const baseW = imageRef.current.clientWidth;
230-
const baseH = imageRef.current.clientHeight;
231-
const effectiveDims = getEffectiveDimensions(baseW, baseH);
232-
const nextW = effectiveDims.width * newScale;
233-
const nextH = effectiveDims.height * newScale;
294+
const scaledDimensions = getScaledDimensions(newScale);
295+
if (!scaledDimensions) return;
234296

235297
const newOverflow = getOverflowState(
236298
newScale,
237-
wrapperRect.width,
238-
wrapperRect.height,
299+
viewportRect.width,
300+
viewportRect.height,
239301
);
240302

241-
const centeredX = (wrapperRect.width - nextW) / 2;
242-
const centeredY = (wrapperRect.height - nextH) / 2;
303+
const shouldRecenter =
304+
newScale === MIN_SCALE || (!newOverflow.width && !newOverflow.height);
305+
306+
const centeredX = getCenteredAxisPosition(
307+
viewportRect.width,
308+
scaledDimensions.width,
309+
);
310+
const centeredY = getCenteredAxisPosition(
311+
viewportRect.height,
312+
scaledDimensions.height,
313+
);
243314
const ratio = currentScale > 0 ? newScale / currentScale : 1;
244-
const mouseViewportX = e.clientX - wrapperRect.left;
245-
const mouseViewportY = e.clientY - wrapperRect.top;
315+
const mouseViewportX = e.clientX - viewportRect.left;
316+
const mouseViewportY = e.clientY - viewportRect.top;
246317
const anchoredX =
247318
mouseViewportX - (mouseViewportX - transformState.positionX) * ratio;
248319
const anchoredY =
249320
mouseViewportY - (mouseViewportY - transformState.positionY) * ratio;
250321

251-
const targetX =
252-
isOverImage && newOverflow.width ? anchoredX : centeredX;
253-
const targetY =
254-
isOverImage && newOverflow.height ? anchoredY : centeredY;
255-
256-
e.preventDefault();
257-
e.stopPropagation();
322+
const targetX = shouldRecenter
323+
? centeredX
324+
: getAxisPosition(
325+
isOverImage ? anchoredX : centeredX,
326+
viewportRect.width,
327+
scaledDimensions.width,
328+
newOverflow.width,
329+
);
330+
const targetY = shouldRecenter
331+
? centeredY
332+
: getAxisPosition(
333+
isOverImage ? anchoredY : centeredY,
334+
viewportRect.height,
335+
scaledDimensions.height,
336+
newOverflow.height,
337+
);
258338

259339
setIsOverflowing(newOverflow.width || newOverflow.height);
260340
transformRef.current.setTransform(targetX, targetY, newScale, 0);
261341
};
262342

263-
wrapperElement.addEventListener('wheel', handleWheelInterceptor, {
343+
wheelElement.addEventListener('wheel', handleWheelInterceptor, {
264344
passive: false,
265345
capture: true,
266346
});
267347

268348
return () => {
269349
resizeObserver.disconnect();
270-
wrapperElement.removeEventListener(
271-
'wheel',
272-
handleWheelInterceptor,
273-
true,
274-
);
350+
wheelElement.removeEventListener('wheel', handleWheelInterceptor, true);
275351
};
276-
}, [getEffectiveDimensions, getOverflowState]);
352+
}, [getOverflowState, getScaledDimensions, getViewportElement]);
277353

278354
return (
279355
<div ref={wheelAreaRef} className="h-full w-full">
@@ -330,38 +406,19 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
330406
const scaledW = effectiveDims.width * scale;
331407
const scaledH = effectiveDims.height * scale;
332408

333-
const limitLeft = -scaledW + PAN_PADDING;
334-
const limitRight = viewW - PAN_PADDING;
335-
const limitTop = -scaledH + PAN_PADDING;
336-
const limitBottom = viewH - PAN_PADDING;
337-
const centeredX = (viewW - scaledW) / 2;
338-
const centeredY = (viewH - scaledH) / 2;
339-
340-
let finalX = overflow.width ? positionX : centeredX;
341-
let finalY = overflow.height ? positionY : centeredY;
342-
let clamped =
343-
(!overflow.width && positionX !== centeredX) ||
344-
(!overflow.height && positionY !== centeredY);
345-
346-
if (overflow.width) {
347-
if (positionX < limitLeft) {
348-
finalX = limitLeft;
349-
clamped = true;
350-
} else if (positionX > limitRight) {
351-
finalX = limitRight;
352-
clamped = true;
353-
}
354-
}
355-
356-
if (overflow.height) {
357-
if (positionY < limitTop) {
358-
finalY = limitTop;
359-
clamped = true;
360-
} else if (positionY > limitBottom) {
361-
finalY = limitBottom;
362-
clamped = true;
363-
}
364-
}
409+
const finalX = getAxisPosition(
410+
positionX,
411+
viewW,
412+
scaledW,
413+
overflow.width,
414+
);
415+
const finalY = getAxisPosition(
416+
positionY,
417+
viewH,
418+
scaledH,
419+
overflow.height,
420+
);
421+
const clamped = positionX !== finalX || positionY !== finalY;
365422

366423
if (clamped) {
367424
ref.setTransform(finalX, finalY, scale, 0);

0 commit comments

Comments
 (0)