Skip to content

Commit 9d4e8c4

Browse files
authored
feat(preview): add pinch-to-zoom and keyboard zoom shortcuts   (#453)
1 parent 1fd4745 commit 9d4e8c4

1 file changed

Lines changed: 84 additions & 22 deletions

File tree

apps/preview/app/src/components/canvas/use-canvas-zoom.ts

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react';
22

33
import { isZoomExcludedTarget } from '../../helpers/canvas-positioning';
4-
import { isZoomKey, nextZoom } from '../../helpers/zoom';
4+
import { isZoomKey, maxZoom, minZoom, nextZoom } from '../../helpers/zoom';
55

66
interface UseCanvasZoomOptions {
77
canvasRef: RefObject<HTMLElement | null>;
@@ -13,6 +13,8 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions
1313
const pendingZoomAnchor = useRef<ZoomAnchor | null>(null);
1414
const [isZoomKeyDown, setIsZoomKeyDown] = useState(false);
1515
const [isAltDown, setIsAltDown] = useState(false);
16+
const zoomRef = useRef(zoom);
17+
zoomRef.current = zoom;
1618

1719
useEffect(() => {
1820
const root = document.documentElement;
@@ -30,26 +32,82 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions
3032
event.preventDefault();
3133
event.stopPropagation();
3234
event.stopImmediatePropagation();
33-
changeZoom(event.altKey || isAltDown ? -1 : 1, { x: event.clientX, y: event.clientY });
35+
changeZoom(event.altKey || isAltDown ? -1 : 1, {
36+
x: event.clientX,
37+
y: event.clientY
38+
});
3439
}
3540

3641
document.addEventListener('click', handleDocumentClick, true);
3742
return () => document.removeEventListener('click', handleDocumentClick, true);
38-
}, [isAltDown, isZoomKeyDown, zoom]);
43+
}, [isAltDown, isZoomKeyDown]);
44+
45+
useEffect(() => {
46+
const canvas = canvasRef.current;
47+
if (!canvas) return undefined;
48+
49+
function handleWheel(event: WheelEvent) {
50+
const { ctrlKey, metaKey } = event;
51+
if (!ctrlKey && !metaKey) return;
52+
if (isZoomExcludedTarget(event.target)) return;
53+
54+
event.preventDefault();
55+
56+
const { current } = zoomRef;
57+
const anchor = { x: event.clientX, y: event.clientY };
58+
const delta = -event.deltaY;
59+
60+
if (ctrlKey) {
61+
// Pinch / Ctrl+scroll — continuous
62+
const next = Math.round(Math.min(maxZoom, Math.max(minZoom, current + delta * 0.5)));
63+
if (next !== current) {
64+
setZoomAnchor(current, anchor);
65+
setZoom(next);
66+
}
67+
} else {
68+
// Cmd+scroll — step zoom
69+
changeZoom(delta > 0 ? 1 : -1, anchor);
70+
}
71+
}
72+
73+
canvas.addEventListener('wheel', handleWheel, { passive: false });
74+
return () => canvas.removeEventListener('wheel', handleWheel);
75+
}, [canvasRef]);
3976

4077
useEffect(() => {
4178
function handleZoomEvent(event: Event) {
4279
const direction = Number((event as CustomEvent<number>).detail);
4380
if (direction === -1 || direction === 1) changeZoom(direction);
4481
}
82+
4583
function handleKeyDown(event: KeyboardEvent) {
4684
setIsAltDown(event.altKey);
4785
if (isZoomKey(event)) setIsZoomKeyDown(true);
86+
87+
const isMod = event.metaKey || event.ctrlKey;
88+
if (!isMod) return;
89+
90+
if (event.key === '=' || event.key === '+') {
91+
event.preventDefault();
92+
changeZoom(1);
93+
} else if (event.key === '-') {
94+
event.preventDefault();
95+
changeZoom(-1);
96+
} else if (event.key === '0') {
97+
event.preventDefault();
98+
const { current } = zoomRef;
99+
if (current !== 100) {
100+
setZoomAnchor(current);
101+
setZoom(100);
102+
}
103+
}
48104
}
105+
49106
function handleKeyUp(event: KeyboardEvent) {
50107
setIsAltDown(event.altKey);
51108
if (isZoomKey(event)) setIsZoomKeyDown(false);
52109
}
110+
53111
function handleBlur() {
54112
setIsZoomKeyDown(false);
55113
setIsAltDown(false);
@@ -66,7 +124,7 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions
66124
window.removeEventListener('keyup', handleKeyUp);
67125
window.removeEventListener('blur', handleBlur);
68126
};
69-
}, [zoom]);
127+
}, []);
70128

71129
useLayoutEffect(() => {
72130
const anchor = pendingZoomAnchor.current;
@@ -87,27 +145,31 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions
87145
pendingZoomAnchor.current = null;
88146
}, [canvasRef, zoom]);
89147

90-
function changeZoom(direction: number, anchorPoint?: { x: number; y: number }) {
91-
const next = nextZoom(zoom, direction);
92-
if (next === zoom) return;
148+
function setZoomAnchor(currentZoom: number, anchorPoint?: { x: number; y: number }) {
93149
const canvas = canvasRef.current;
94150
const content = canvas?.firstElementChild;
151+
if (!canvas || !content) return;
152+
153+
const canvasRect = canvas.getBoundingClientRect();
154+
const contentRect = content.getBoundingClientRect();
155+
const scale = currentZoom / 100;
156+
const anchorX = anchorPoint?.x ?? canvasRect.left + canvasRect.width / 2;
157+
const anchorY = anchorPoint?.y ?? canvasRect.top + canvasRect.height / 2;
158+
159+
pendingZoomAnchor.current = {
160+
localX: (anchorX - contentRect.left) / scale,
161+
localY: (anchorY - contentRect.top) / scale,
162+
scale,
163+
scrollLeft: canvas.scrollLeft,
164+
scrollTop: canvas.scrollTop
165+
};
166+
}
95167

96-
if (canvas && content) {
97-
const canvasRect = canvas.getBoundingClientRect();
98-
const contentRect = content.getBoundingClientRect();
99-
const scale = zoom / 100;
100-
const anchorX = anchorPoint?.x ?? canvasRect.left + canvasRect.width / 2;
101-
const anchorY = anchorPoint?.y ?? canvasRect.top + canvasRect.height / 2;
102-
pendingZoomAnchor.current = {
103-
localX: (anchorX - contentRect.left) / scale,
104-
localY: (anchorY - contentRect.top) / scale,
105-
scale,
106-
scrollLeft: canvas.scrollLeft,
107-
scrollTop: canvas.scrollTop
108-
};
109-
}
110-
168+
function changeZoom(direction: number, anchorPoint?: { x: number; y: number }) {
169+
const { current } = zoomRef;
170+
const next = nextZoom(current, direction);
171+
if (next === current) return;
172+
setZoomAnchor(current, anchorPoint);
111173
setZoom(next);
112174
}
113175

0 commit comments

Comments
 (0)