Skip to content

Commit 3b2ff19

Browse files
committed
feat: center image zooming on the cursor
1 parent fcff9ef commit 3b2ff19

5 files changed

Lines changed: 180 additions & 57 deletions

File tree

.changeset/pr-602-centered-zoom.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: patch
3+
---
4+
5+
Image zooming is now centered on the cursor position
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: patch
3+
---
4+
5+
Image zooming is now multiplicative instead of additive, resulting in a smoother experience.

src/app/components/Pdf-viewer/PdfViewer.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ export const PdfViewer = as<'div', PdfViewerProps>(
3939
const containerRef = useRef<HTMLDivElement>(null);
4040
const scrollRef = useRef<HTMLDivElement>(null);
4141

42-
const { zoom, zoomIn, zoomOut, setZoom, onPointerDown } = useImageGestures(true, 0.2);
42+
const {
43+
transforms: { zoom },
44+
zoomIn,
45+
zoomOut,
46+
setZoom,
47+
onPointerDown,
48+
} = useImageGestures(true, 0.2);
4349

4450
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
4551
const [docState, loadPdfDocument] = usePdfDocumentLoader(

src/app/components/image-viewer/ImageViewer.tsx

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { WheelEvent } from 'react';
21
import FileSaver from 'file-saver';
32
import classNames from 'classnames';
43
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
@@ -14,28 +13,14 @@ export type ImageViewerProps = {
1413

1514
export const ImageViewer = as<'div', ImageViewerProps>(
1615
({ className, alt, src, requestClose, ...props }, ref) => {
17-
const { zoom, pan, cursor, onPointerDown, setZoom, zoomIn, zoomOut } = useImageGestures(
18-
true,
19-
0.2
20-
);
16+
const { transforms, cursor, handleWheel, onPointerDown, resetTransforms, zoomIn, zoomOut } =
17+
useImageGestures(true, 0.2);
2118

2219
const handleDownload = async () => {
2320
const fileContent = await downloadMedia(src);
2421
FileSaver.saveAs(fileContent, alt);
2522
};
2623

27-
const handleWheel = (e: WheelEvent) => {
28-
const { deltaY } = e;
29-
// Mouse wheel scrolls only by integer delta values, therefore
30-
// If deltaY is an integer, then it's a mouse wheel action
31-
if (Number.isInteger(deltaY)) {
32-
if (deltaY < 0) {
33-
zoomIn();
34-
} else zoomOut();
35-
}
36-
// If it's not an integer, then it's a touchpad action, do nothing and let the browser handle the zooming
37-
};
38-
3924
return (
4025
<Box
4126
className={classNames(css.ImageViewer, className)}
@@ -54,21 +39,21 @@ export const ImageViewer = as<'div', ImageViewerProps>(
5439
</Box>
5540
<Box shrink="No" alignItems="Center" gap="200">
5641
<IconButton
57-
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
58-
outlined={zoom < 1}
42+
variant={transforms.zoom < 1 ? 'Success' : 'SurfaceVariant'}
43+
outlined={transforms.zoom < 1}
5944
size="300"
6045
radii="Pill"
6146
onClick={zoomOut}
6247
aria-label="Zoom Out"
6348
>
6449
<Icon size="50" src={Icons.Minus} />
6550
</IconButton>
66-
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
67-
<Text size="B300">{Math.round(zoom * 100)}%</Text>
51+
<Chip variant="SurfaceVariant" radii="Pill" onClick={resetTransforms}>
52+
<Text size="B300">{Math.round(transforms.zoom * 100)}%</Text>
6853
</Chip>
6954
<IconButton
70-
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
71-
outlined={zoom > 1}
55+
variant={transforms.zoom > 1 ? 'Success' : 'SurfaceVariant'}
56+
outlined={transforms.zoom > 1}
7257
size="300"
7358
radii="Pill"
7459
onClick={zoomIn}
@@ -104,7 +89,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
10489
userSelect: 'none',
10590
touchAction: 'none',
10691
willChange: 'transform',
107-
transform: `translate(${pan.translateX}px, ${pan.translateY}px) scale(${zoom})`,
92+
transform: `translate(${transforms.pan.x}px, ${transforms.pan.y}px) scale(${transforms.zoom})`,
10893
}}
10994
src={src}
11095
alt={alt}

src/app/hooks/useImageGestures.ts

Lines changed: 154 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
11
import { useState, useCallback, useRef, useEffect } from 'react';
22

3+
interface Vector2 {
4+
x: number;
5+
y: number;
6+
}
7+
8+
interface Transforms {
9+
zoom: number;
10+
pan: Vector2;
11+
}
12+
13+
// calculate pointer position relative to the image center
14+
//
15+
// use container rect & manually apply transforms as if we get two+ events quickly,
16+
// the second one might use an outdated image rect (before new transforms are applied)
17+
function getCursorOffsetFromImageCenter(
18+
event: React.MouseEvent,
19+
containerRect: DOMRect,
20+
pan: Vector2
21+
): Vector2 {
22+
return {
23+
x: containerRect.width / 2 - (event.clientX - containerRect.x - pan.x),
24+
y: containerRect.height / 2 - (event.clientY - containerRect.y - pan.y),
25+
};
26+
}
27+
328
export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5) => {
4-
const [zoom, setZoom] = useState<number>(1);
5-
const [pan, setPan] = useState({ translateX: 0, translateY: 0 });
29+
const [transforms, setTransforms] = useState<Transforms>({
30+
zoom: 1,
31+
pan: { x: 0, y: 0 },
32+
});
633
const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>(
734
active ? 'grab' : 'initial'
835
);
@@ -11,29 +38,82 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5
1138
const initialDist = useRef<number>(0);
1239
const lastTapRef = useRef<number>(0);
1340

14-
const onPointerDown = (e: React.PointerEvent) => {
15-
if (!active) return;
16-
17-
e.stopPropagation();
18-
(e.target as HTMLElement).setPointerCapture(e.pointerId);
19-
20-
const now = Date.now();
21-
if (now - lastTapRef.current < 300) {
22-
setZoom(zoom === 1 ? 2 : 1);
23-
setPan({ translateX: 0, translateY: 0 });
24-
lastTapRef.current = 0;
25-
return;
26-
}
27-
lastTapRef.current = now;
41+
const setZoom = useCallback((next: number | ((prev: number) => number)) => {
42+
setTransforms((prev) => {
43+
if (typeof next === 'function') {
44+
return {
45+
...prev,
46+
zoom: next(prev.zoom),
47+
};
48+
}
49+
return {
50+
...prev,
51+
zoom: next,
52+
};
53+
});
54+
}, []);
55+
56+
const setPan = useCallback((next: Vector2 | ((prev: Vector2) => Vector2)) => {
57+
setTransforms((prev) => {
58+
if (typeof next === 'function') {
59+
return {
60+
...prev,
61+
pan: next(prev.pan),
62+
};
63+
}
64+
return {
65+
...prev,
66+
pan: next,
67+
};
68+
});
69+
}, []);
70+
71+
const resetTransforms = useCallback(() => {
72+
setTransforms({ zoom: 1, pan: { x: 0, y: 0 } });
73+
}, []);
74+
75+
const onPointerDown = useCallback(
76+
(e: React.PointerEvent) => {
77+
if (!active) return;
78+
79+
e.stopPropagation();
80+
const target = e.target as HTMLElement;
81+
target.setPointerCapture(e.pointerId);
82+
83+
const now = Date.now();
84+
if (now - lastTapRef.current < 300) {
85+
const container = target.parentElement ?? target;
86+
const containerRect = container.getBoundingClientRect();
87+
setTransforms((prev) => {
88+
if (prev.zoom !== 1) {
89+
return { zoom: 1, pan: { x: 0, y: 0 } };
90+
}
91+
92+
// pan using the pointer's offset relative to the center of the image
93+
const offset = getCursorOffsetFromImageCenter(e, containerRect, prev.pan);
94+
return {
95+
zoom: 2,
96+
pan: {
97+
x: offset.x + prev.pan.x,
98+
y: offset.y + prev.pan.y,
99+
},
100+
};
101+
});
102+
lastTapRef.current = 0;
103+
return;
104+
}
105+
lastTapRef.current = now;
28106

29-
activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
30-
setCursor('grabbing');
107+
activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
108+
setCursor('grabbing');
31109

32-
if (activePointers.current.size === 2) {
33-
const points = Array.from(activePointers.current.values());
34-
initialDist.current = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
35-
}
36-
};
110+
if (activePointers.current.size === 2) {
111+
const points = Array.from(activePointers.current.values());
112+
initialDist.current = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
113+
}
114+
},
115+
[active]
116+
);
37117

38118
const handlePointerMove = useCallback(
39119
(e: PointerEvent) => {
@@ -53,12 +133,12 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5
53133

54134
if (activePointers.current.size === 1) {
55135
setPan((p) => ({
56-
translateX: p.translateX + e.movementX,
57-
translateY: p.translateY + e.movementY,
136+
x: p.x + e.movementX,
137+
y: p.y + e.movementY,
58138
}));
59139
}
60140
},
61-
[min, max]
141+
[setZoom, min, max, setPan]
62142
);
63143

64144
const handlePointerUp = useCallback(
@@ -86,20 +166,62 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5
86166
}, [handlePointerMove, handlePointerUp]);
87167

88168
const zoomIn = useCallback(() => {
89-
setZoom((z) => Math.min(z + step, max));
90-
}, [step, max]);
169+
setZoom((z) => Math.min(z * (1 + step), max));
170+
}, [setZoom, step, max]);
91171

92172
const zoomOut = useCallback(() => {
93-
setZoom((z) => Math.max(z - step, min));
94-
}, [step, min]);
173+
setZoom((z) => Math.max(z / (1 + step), min));
174+
}, [setZoom, step, min]);
175+
176+
const handleWheel = useCallback(
177+
(e: React.WheelEvent) => {
178+
const { deltaY } = e;
179+
// Mouse wheel scrolls only by integer delta values, therefore
180+
// If deltaY is an integer, then it's a mouse wheel action
181+
if (!Number.isInteger(deltaY)) {
182+
// If it's not an integer, then it's a touchpad action, do nothing and let the browser handle the zooming
183+
return;
184+
}
185+
186+
// the wheel handler is attached to the container element, not the image
187+
const containerRect = e.currentTarget.getBoundingClientRect();
188+
189+
setTransforms((prev) => {
190+
// calculate multiplicative zoom
191+
const newZoom =
192+
deltaY < 0
193+
? Math.min(prev.zoom * (1 + step), max)
194+
: Math.max(prev.zoom / (1 + step), min);
195+
const zoomMult = newZoom / prev.zoom - 1;
196+
197+
// calculate pointer position relative to the image center
198+
//
199+
// manually apply transforms as if we get two+ wheel events quickly,
200+
// the second one might use an outdated image rect (before new transforms are applied)
201+
const offset = getCursorOffsetFromImageCenter(e, containerRect, prev.pan);
202+
203+
return {
204+
zoom: newZoom,
205+
// magic math that happens to do what i want it to do
206+
pan: {
207+
x: offset.x * zoomMult + prev.pan.x,
208+
y: offset.y * zoomMult + prev.pan.y,
209+
},
210+
};
211+
});
212+
},
213+
[max, min, step]
214+
);
95215

96216
return {
97-
zoom,
98-
pan,
217+
transforms,
99218
cursor,
100219
onPointerDown,
220+
handleWheel,
101221
setZoom,
102222
setPan,
223+
setTransforms,
224+
resetTransforms,
103225
zoomIn,
104226
zoomOut,
105227
};

0 commit comments

Comments
 (0)