Skip to content

Commit ab80d33

Browse files
authored
feat: center image zooming on the cursor (SableClient#602)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description This PR makes the image zooming via double-click or mouse wheel centered on the cursor position rather than the center of the image. Clicking the zoom % label (between + and - buttons) now resets the image zoom and pan. Zooming was also changed from being additive to multiplicative, which makes the "zooming speed" feel consistent. In the process of doing this I merged the zoom and pan state into one, making it possible to change them at the same time. The `setPan` and `setZoom` functions were recreated for compatibility. `resetTransforms` was added for convenience. `handleWheel` was moved from the ImageViewer into the image gestures hook. #### Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. -->
2 parents f04fff4 + 2e2c8a7 commit ab80d33

File tree

6 files changed

+206
-56
lines changed

6 files changed

+206
-56
lines changed

.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 consistent "zooming speed".

.changeset/pr-602-zoom-buttons.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 zoom buttons now zoom towards the center of the screen

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: 175 additions & 31 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,84 @@ 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));
169+
setTransforms((prev) => {
170+
const newZoom = Math.min(prev.zoom * (1 + step), max);
171+
const zoomMult = newZoom / prev.zoom;
172+
173+
return {
174+
zoom: newZoom,
175+
pan: {
176+
x: prev.pan.x * zoomMult,
177+
y: prev.pan.y * zoomMult,
178+
},
179+
};
180+
});
90181
}, [step, max]);
91182

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

96238
return {
97-
zoom,
98-
pan,
239+
transforms,
99240
cursor,
100241
onPointerDown,
242+
handleWheel,
101243
setZoom,
102244
setPan,
245+
setTransforms,
246+
resetTransforms,
103247
zoomIn,
104248
zoomOut,
105249
};

0 commit comments

Comments
 (0)