Skip to content

Commit 44e9493

Browse files
committed
Replace usePanAndZoom hook with component
1 parent 9fec53a commit 44e9493

5 files changed

Lines changed: 223 additions & 224 deletions

File tree

src/components/PanAndZoom.tsx

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import classNames from 'classnames';
2+
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; // prettier-ignore
3+
import { clamp, getRelativeMousePosition, getWheelDirection } from '../utils';
4+
5+
interface PanAndZoomState {
6+
panX: number;
7+
panY: number;
8+
zoom: number;
9+
}
10+
11+
interface PanAndZoomContextData {
12+
state: PanAndZoomState;
13+
panning: React.RefObject<boolean>;
14+
}
15+
16+
const delay = (1 / 16) * 1000;
17+
18+
const defaultState: PanAndZoomState = {
19+
panX: 0,
20+
panY: 0,
21+
zoom: 1,
22+
};
23+
24+
const defaultSettings = {
25+
minZoom: 0.5,
26+
maxZoom: 2,
27+
zoomSpeed: 0.1,
28+
touchSupport: true,
29+
};
30+
31+
const PanAndZoomContext = createContext<PanAndZoomContextData>({
32+
state: defaultState,
33+
panning: { current: null },
34+
});
35+
36+
export type PanAndZoomProviderProps = {
37+
children: (
38+
state: PanAndZoomState,
39+
panning: React.RefObject<boolean>,
40+
resetMap: () => void,
41+
) => React.ReactNode;
42+
settings?: typeof defaultSettings;
43+
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'children'>;
44+
45+
export default React.forwardRef(function PanAndZoomProvider(
46+
{ children, settings = defaultSettings, ...rest }: PanAndZoomProviderProps,
47+
ref: React.ForwardedRef<HTMLDivElement>,
48+
) {
49+
const [state, setState] = useState<PanAndZoomState>(defaultState);
50+
const mousedown = useRef<boolean>(false);
51+
const panning = useRef<boolean>(false);
52+
const timeout = useRef<NodeJS.Timeout>(undefined);
53+
const touchIdentifier = useRef(-1);
54+
const touchPosition = useRef({ x: 0, y: 0 });
55+
56+
const resetMap = useCallback(() => {
57+
if (timeout.current) {
58+
clearTimeout(timeout.current);
59+
}
60+
timeout.current = undefined;
61+
mousedown.current = false;
62+
panning.current = false;
63+
touchIdentifier.current = -1;
64+
touchPosition.current = { x: 0, y: 0 };
65+
setState(defaultState);
66+
}, []);
67+
68+
useEffect(() => {
69+
if (typeof window === 'undefined') return;
70+
71+
const handleMouseUp = (e: MouseEvent) => {
72+
if (e.button === 0 || e.button === 1) {
73+
mousedown.current = false;
74+
if (timeout.current) {
75+
clearTimeout(timeout.current);
76+
}
77+
if (panning.current) {
78+
timeout.current = setTimeout(() => {
79+
panning.current = false;
80+
}, delay);
81+
}
82+
}
83+
};
84+
85+
const handlePan = (e: MouseEvent) => {
86+
if (mousedown.current) {
87+
panning.current = true;
88+
setState(state => ({
89+
panX: state.panX + e.movementX,
90+
panY: state.panY + e.movementY,
91+
zoom: state.zoom,
92+
}));
93+
}
94+
};
95+
96+
const handleTouchEnd = (e: TouchEvent) => {
97+
if (e.touches.length === 0) {
98+
touchIdentifier.current = -1;
99+
mousedown.current = false;
100+
if (timeout.current) {
101+
clearTimeout(timeout.current);
102+
}
103+
if (panning.current) {
104+
timeout.current = setTimeout(() => {
105+
panning.current = false;
106+
}, delay);
107+
}
108+
}
109+
};
110+
111+
const handleTouchPan = (e: TouchEvent) => {
112+
if (mousedown.current && e.touches.length === 1) {
113+
const touch = e.touches[0];
114+
if (touch.identifier === touchIdentifier.current) {
115+
const x = touch.clientX;
116+
const y = touch.clientY;
117+
const dx = x - touchPosition.current.x;
118+
const dy = y - touchPosition.current.y;
119+
touchPosition.current = { x, y };
120+
panning.current = true;
121+
setState(state => ({
122+
panX: state.panX + dx,
123+
panY: state.panY + dy,
124+
zoom: state.zoom,
125+
}));
126+
}
127+
}
128+
};
129+
130+
document.body.addEventListener('mouseup', handleMouseUp);
131+
document.body.addEventListener('mousemove', handlePan);
132+
133+
if (settings.touchSupport) {
134+
document.body.addEventListener('touchend', handleTouchEnd);
135+
document.body.addEventListener('touchmove', handleTouchPan);
136+
}
137+
138+
return () => {
139+
document.body.removeEventListener('mouseup', handleMouseUp);
140+
document.body.removeEventListener('mousemove', handlePan);
141+
document.body.removeEventListener('touchend', handleTouchEnd);
142+
document.body.removeEventListener('touchmove', handleTouchPan);
143+
};
144+
}, [settings.touchSupport]);
145+
146+
return (
147+
<PanAndZoomContext.Provider value={{ state, panning }}>
148+
<div
149+
{...rest}
150+
ref={ref}
151+
onMouseDown={e => {
152+
if (e.button === 0 || e.button === 1) {
153+
if (timeout.current) {
154+
clearTimeout(timeout.current);
155+
}
156+
timeout.current = setTimeout(() => {
157+
mousedown.current = true;
158+
}, delay);
159+
}
160+
}}
161+
onTouchStart={e => {
162+
if (e.touches.length === 1) {
163+
const touch = e.touches[0];
164+
touchIdentifier.current = touch.identifier;
165+
touchPosition.current = {
166+
x: touch.clientX,
167+
y: touch.clientY,
168+
};
169+
if (timeout.current) {
170+
clearTimeout(timeout.current);
171+
}
172+
timeout.current = setTimeout(() => {
173+
mousedown.current = true;
174+
}, delay);
175+
}
176+
}}
177+
onWheel={e => {
178+
setState(state => {
179+
const zoomDirection = getWheelDirection(e.nativeEvent);
180+
const zoomChange = zoomDirection * settings.zoomSpeed;
181+
const zoom = clamp(
182+
state.zoom + zoomChange,
183+
settings.minZoom,
184+
settings.maxZoom,
185+
);
186+
const { x, y } = getRelativeMousePosition(
187+
e.nativeEvent,
188+
e.currentTarget,
189+
);
190+
const panX = x - (x - state.panX) * (zoom / state.zoom);
191+
const panY = y - (y - state.panY) * (zoom / state.zoom);
192+
return { panX, panY, zoom };
193+
});
194+
}}
195+
>
196+
{children(state, panning, resetMap)}
197+
</div>
198+
</PanAndZoomContext.Provider>
199+
);
200+
});
201+
202+
export function PanAndZoomTransform({
203+
children,
204+
className,
205+
style,
206+
...rest
207+
}: React.ComponentPropsWithoutRef<'div'>) {
208+
const { state } = useContext(PanAndZoomContext);
209+
return (
210+
<div
211+
className={classNames(className, 'w-full h-full')}
212+
style={{
213+
...style,
214+
transform: `translate(${state.panX}px, ${state.panY}px) scale(${state.zoom})`,
215+
transformOrigin: '0 0',
216+
}}
217+
{...rest}
218+
>
219+
{children}
220+
</div>
221+
);
222+
}

src/components/PanAndZoomTransform.tsx

Lines changed: 0 additions & 28 deletions
This file was deleted.

src/components/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export { default as NavMenu, type NavMenuProps } from './NavMenu'; // prettier-i
2626
export { default as Overlay, type OverlayProps } from './Overlay'; // prettier-ignore
2727
export { default as PageBanner, type PageBannerProps } from './PageBanner'; // prettier-ignore
2828
export { default as Pagination, type PaginationProps } from './Pagination'; // prettier-ignore
29-
export { default as PanAndZoomTransform, type PanAndZoomTransformProps } from './PanAndZoomTransform'; // prettier-ignore
29+
export { default as PanAndZoomProvider, PanAndZoomTransform, type PanAndZoomProviderProps } from './PanAndZoom'; // prettier-ignore
3030
export { default as ProgressiveImage, type ProgressiveImageProps } from './ProgressiveImage'; // prettier-ignore
3131
export { default as ReactPortal, type ReactPortalProps } from './ReactPortal'; // prettier-ignore
3232
export { default as Row, type RowProps } from './Row'; // prettier-ignore

src/hooks/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ export * from './useKeyboardEvent';
88
export * from './useLocalStorage';
99
export * from './useMediaQuery';
1010
export * from './usePagination';
11-
export * from './usePanAndZoom';
1211
export * from './useSearch';
1312
export * from './useSmoothDamp';
1413
export * from './useTheme';

0 commit comments

Comments
 (0)