use-zoom-pinch 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/index.cjs +189 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +46 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +187 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 nemezzizz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# useZoomPinch
|
|
2
|
+
|
|
3
|
+
Lightweight React hook for **pan**, **pinch-to-zoom**, and **scroll zoom** with trackpad and touch support. Zero dependencies beyond React.
|
|
4
|
+
|
|
5
|
+
[**Live Demo**](https://nemezzizz.github.io/use-zoom-pinch/)
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Scroll to pan** — mouse wheel and trackpad two-finger scroll
|
|
10
|
+
- **Pinch to zoom** — trackpad pinch (via `ctrlKey` + wheel) and multi-touch pinch on mobile
|
|
11
|
+
- **Pointer drag** — mouse drag and single-touch drag for panning
|
|
12
|
+
- **Controlled & uncontrolled** modes
|
|
13
|
+
- **Stable listeners** — config changes don't re-register event listeners
|
|
14
|
+
- **TypeScript-first** with full type exports
|
|
15
|
+
- **Tree-shakeable** ESM + CJS dual build
|
|
16
|
+
- **~2 KB** minified + gzipped
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install use-zoom-pinch
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { useRef } from "react"
|
|
28
|
+
import { useZoomPinch } from "use-zoom-pinch"
|
|
29
|
+
|
|
30
|
+
function Canvas() {
|
|
31
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
32
|
+
const { view } = useZoomPinch({ containerRef })
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
ref={containerRef}
|
|
37
|
+
style={{ width: "100%", height: "100vh", overflow: "hidden", touchAction: "none" }}
|
|
38
|
+
>
|
|
39
|
+
<div
|
|
40
|
+
style={{
|
|
41
|
+
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
|
|
42
|
+
transformOrigin: "0 0",
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{/* Your zoomable content here */}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Controlled Mode
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { useRef, useState } from "react"
|
|
56
|
+
import { useZoomPinch, type ViewState } from "use-zoom-pinch"
|
|
57
|
+
|
|
58
|
+
function ControlledCanvas() {
|
|
59
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
60
|
+
const [viewState, setViewState] = useState<ViewState>({ x: 0, y: 0, zoom: 1 })
|
|
61
|
+
|
|
62
|
+
const { view, centerZoom, resetView } = useZoomPinch({
|
|
63
|
+
containerRef,
|
|
64
|
+
viewState,
|
|
65
|
+
onViewStateChange: setViewState,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div>
|
|
70
|
+
<button onClick={() => centerZoom(view.zoom * 1.5)}>Zoom In</button>
|
|
71
|
+
<button onClick={() => centerZoom(view.zoom / 1.5)}>Zoom Out</button>
|
|
72
|
+
<button onClick={resetView}>Reset</button>
|
|
73
|
+
|
|
74
|
+
<div
|
|
75
|
+
ref={containerRef}
|
|
76
|
+
style={{ width: "100%", height: "100vh", overflow: "hidden", touchAction: "none" }}
|
|
77
|
+
>
|
|
78
|
+
<div
|
|
79
|
+
style={{
|
|
80
|
+
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
|
|
81
|
+
transformOrigin: "0 0",
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{/* Your content */}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
### `useZoomPinch(options)`
|
|
95
|
+
|
|
96
|
+
#### Options
|
|
97
|
+
|
|
98
|
+
| Option | Type | Default | Description |
|
|
99
|
+
| ------------------- | -------------------------------- | ------------------------- | ----------------------------------- |
|
|
100
|
+
| `containerRef` | `RefObject<HTMLElement \| null>` | _required_ | Ref to the container element |
|
|
101
|
+
| `minScale` | `number` | `0.1` | Minimum zoom level |
|
|
102
|
+
| `maxScale` | `number` | `50` | Maximum zoom level |
|
|
103
|
+
| `panSpeed` | `number` | `1` | Pan speed multiplier (mouse wheel) |
|
|
104
|
+
| `zoomSpeed` | `number` | `1` | Zoom speed multiplier (mouse wheel) |
|
|
105
|
+
| `initialViewState` | `ViewState` | `{ x: 0, y: 0, zoom: 1 }` | Initial view for uncontrolled mode |
|
|
106
|
+
| `viewState` | `ViewState` | — | Controlled view state |
|
|
107
|
+
| `onViewStateChange` | `(view: ViewState) => void` | — | Callback on view change |
|
|
108
|
+
| `enabled` | `boolean` | `true` | Enable/disable gesture handling |
|
|
109
|
+
|
|
110
|
+
#### Returns
|
|
111
|
+
|
|
112
|
+
| Property | Type | Description |
|
|
113
|
+
| ------------ | --------------------------- | ------------------------------------ |
|
|
114
|
+
| `view` | `ViewState` | Current view state |
|
|
115
|
+
| `setView` | `(view: ViewState) => void` | Imperatively set the view |
|
|
116
|
+
| `centerZoom` | `(zoom: number) => void` | Zoom to level, centered in container |
|
|
117
|
+
| `resetView` | `() => void` | Reset to `{ x: 0, y: 0, zoom: 1 }` |
|
|
118
|
+
|
|
119
|
+
### `ViewState`
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
interface ViewState {
|
|
123
|
+
x: number // horizontal offset in pixels
|
|
124
|
+
y: number // vertical offset in pixels
|
|
125
|
+
zoom: number // scale factor (1 = 100%)
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
|
|
5
|
+
// src/useZoomPinch.ts
|
|
6
|
+
var DEFAULT_VIEW = { x: 0, y: 0, zoom: 1 };
|
|
7
|
+
function useZoomPinch({
|
|
8
|
+
containerRef,
|
|
9
|
+
minScale = 0.1,
|
|
10
|
+
maxScale = 50,
|
|
11
|
+
panSpeed = 1,
|
|
12
|
+
zoomSpeed = 1,
|
|
13
|
+
initialViewState = DEFAULT_VIEW,
|
|
14
|
+
viewState,
|
|
15
|
+
onViewStateChange,
|
|
16
|
+
enabled = true
|
|
17
|
+
}) {
|
|
18
|
+
const [internalView, setInternalView] = react.useState(initialViewState);
|
|
19
|
+
const view = viewState ?? internalView;
|
|
20
|
+
const viewRef = react.useRef(view);
|
|
21
|
+
viewRef.current = view;
|
|
22
|
+
const enabledRef = react.useRef(enabled);
|
|
23
|
+
enabledRef.current = enabled;
|
|
24
|
+
const onViewStateChangeRef = react.useRef(onViewStateChange);
|
|
25
|
+
onViewStateChangeRef.current = onViewStateChange;
|
|
26
|
+
const configRef = react.useRef({ minScale, maxScale, panSpeed, zoomSpeed });
|
|
27
|
+
configRef.current = { minScale, maxScale, panSpeed, zoomSpeed };
|
|
28
|
+
const updateView = react.useCallback((updater) => {
|
|
29
|
+
const prev = viewRef.current;
|
|
30
|
+
const next = updater(prev);
|
|
31
|
+
if (next.x === prev.x && next.y === prev.y && next.zoom === prev.zoom) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (onViewStateChangeRef.current) {
|
|
35
|
+
onViewStateChangeRef.current(next);
|
|
36
|
+
} else {
|
|
37
|
+
setInternalView(next);
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
react.useEffect(() => {
|
|
41
|
+
const el = containerRef.current;
|
|
42
|
+
if (!el) return;
|
|
43
|
+
const getRelCoords = (clientX, clientY) => {
|
|
44
|
+
const rect = el.getBoundingClientRect();
|
|
45
|
+
return { px: clientX - rect.left, py: clientY - rect.top };
|
|
46
|
+
};
|
|
47
|
+
const onWheel = (e) => {
|
|
48
|
+
if (!enabledRef.current) return;
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
let deltaX = e.deltaX;
|
|
51
|
+
let deltaY = e.deltaY;
|
|
52
|
+
const { ctrlKey, clientX, clientY } = e;
|
|
53
|
+
const isTrackpad = e.deltaMode === 0 && (Math.abs(deltaY) < 100 || !Number.isInteger(deltaY));
|
|
54
|
+
if (!isTrackpad) {
|
|
55
|
+
const LINE_STEP = 120;
|
|
56
|
+
if (Math.abs(deltaY) >= 100) deltaY = Math.sign(deltaY) * (Math.abs(deltaY) / LINE_STEP);
|
|
57
|
+
if (Math.abs(deltaX) >= 100) deltaX = Math.sign(deltaX) * (Math.abs(deltaX) / LINE_STEP);
|
|
58
|
+
}
|
|
59
|
+
const { minScale: minScale2, maxScale: maxScale2, panSpeed: panSpeed2, zoomSpeed: zoomSpeed2 } = configRef.current;
|
|
60
|
+
updateView((prev) => {
|
|
61
|
+
if (ctrlKey) {
|
|
62
|
+
const speed = isTrackpad ? 0.01 : 0.1 * zoomSpeed2;
|
|
63
|
+
const factor = 1 - deltaY * speed;
|
|
64
|
+
const newZoom = clamp(prev.zoom * factor, minScale2, maxScale2);
|
|
65
|
+
if (newZoom === prev.zoom) return prev;
|
|
66
|
+
const { px, py } = getRelCoords(clientX, clientY);
|
|
67
|
+
const s = newZoom / prev.zoom;
|
|
68
|
+
return { zoom: newZoom, x: px - (px - prev.x) * s, y: py - (py - prev.y) * s };
|
|
69
|
+
}
|
|
70
|
+
const multiplier = isTrackpad ? 1 : 25 * panSpeed2;
|
|
71
|
+
return { ...prev, x: prev.x - deltaX * multiplier, y: prev.y - deltaY * multiplier };
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
let isDragging = false;
|
|
75
|
+
let lastPointer = { x: 0, y: 0 };
|
|
76
|
+
const activePointers = /* @__PURE__ */ new Set();
|
|
77
|
+
const onPointerDown = (e) => {
|
|
78
|
+
if (!enabledRef.current) return;
|
|
79
|
+
activePointers.add(e.pointerId);
|
|
80
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
81
|
+
if (activePointers.size >= 2) {
|
|
82
|
+
isDragging = false;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
isDragging = true;
|
|
86
|
+
lastPointer = { x: e.clientX, y: e.clientY };
|
|
87
|
+
};
|
|
88
|
+
const onPointerMove = (e) => {
|
|
89
|
+
if (!isDragging) return;
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
const dx = e.clientX - lastPointer.x;
|
|
92
|
+
const dy = e.clientY - lastPointer.y;
|
|
93
|
+
lastPointer = { x: e.clientX, y: e.clientY };
|
|
94
|
+
updateView((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }));
|
|
95
|
+
};
|
|
96
|
+
const onPointerUp = (e) => {
|
|
97
|
+
activePointers.delete(e.pointerId);
|
|
98
|
+
if (activePointers.size === 0) isDragging = false;
|
|
99
|
+
};
|
|
100
|
+
let pinchState = { zoom: 1, x: 0, y: 0 };
|
|
101
|
+
let initialDist = 0;
|
|
102
|
+
let initialCenter = { x: 0, y: 0 };
|
|
103
|
+
let isPinching = false;
|
|
104
|
+
const onTouchStart = (e) => {
|
|
105
|
+
if (!enabledRef.current || e.touches.length < 2) return;
|
|
106
|
+
isDragging = false;
|
|
107
|
+
isPinching = true;
|
|
108
|
+
const t1 = e.touches[0];
|
|
109
|
+
const t2 = e.touches[1];
|
|
110
|
+
initialDist = distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY);
|
|
111
|
+
initialCenter = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 };
|
|
112
|
+
pinchState = { ...viewRef.current };
|
|
113
|
+
};
|
|
114
|
+
const onTouchMove = (e) => {
|
|
115
|
+
if (e.touches.length < 2 || !isPinching) return;
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
const t1 = e.touches[0];
|
|
118
|
+
const t2 = e.touches[1];
|
|
119
|
+
const { minScale: minScale2, maxScale: maxScale2 } = configRef.current;
|
|
120
|
+
const currentDist = distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY);
|
|
121
|
+
const currentCenter = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 };
|
|
122
|
+
const newZoom = clamp(pinchState.zoom * (currentDist / initialDist), minScale2, maxScale2);
|
|
123
|
+
const s = newZoom / pinchState.zoom;
|
|
124
|
+
const { px, py } = getRelCoords(initialCenter.x, initialCenter.y);
|
|
125
|
+
updateView(() => ({
|
|
126
|
+
zoom: newZoom,
|
|
127
|
+
x: px - (px - pinchState.x) * s + (currentCenter.x - initialCenter.x),
|
|
128
|
+
y: py - (py - pinchState.y) * s + (currentCenter.y - initialCenter.y)
|
|
129
|
+
}));
|
|
130
|
+
};
|
|
131
|
+
const onTouchEnd = (e) => {
|
|
132
|
+
if (e.touches.length < 2) isPinching = false;
|
|
133
|
+
};
|
|
134
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
135
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
136
|
+
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
137
|
+
window.addEventListener("pointerup", onPointerUp);
|
|
138
|
+
window.addEventListener("pointercancel", onPointerUp);
|
|
139
|
+
el.addEventListener("touchstart", onTouchStart, { passive: false });
|
|
140
|
+
el.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
141
|
+
el.addEventListener("touchend", onTouchEnd);
|
|
142
|
+
el.addEventListener("touchcancel", onTouchEnd);
|
|
143
|
+
return () => {
|
|
144
|
+
el.removeEventListener("wheel", onWheel);
|
|
145
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
146
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
147
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
148
|
+
window.removeEventListener("pointercancel", onPointerUp);
|
|
149
|
+
el.removeEventListener("touchstart", onTouchStart);
|
|
150
|
+
el.removeEventListener("touchmove", onTouchMove);
|
|
151
|
+
el.removeEventListener("touchend", onTouchEnd);
|
|
152
|
+
el.removeEventListener("touchcancel", onTouchEnd);
|
|
153
|
+
};
|
|
154
|
+
}, [containerRef, updateView]);
|
|
155
|
+
const setView = react.useCallback((v) => {
|
|
156
|
+
if (onViewStateChangeRef.current) {
|
|
157
|
+
onViewStateChangeRef.current(v);
|
|
158
|
+
} else {
|
|
159
|
+
setInternalView(v);
|
|
160
|
+
}
|
|
161
|
+
}, []);
|
|
162
|
+
const centerZoom = react.useCallback(
|
|
163
|
+
(targetZoom) => {
|
|
164
|
+
updateView((prev) => {
|
|
165
|
+
const { minScale: minScale2, maxScale: maxScale2 } = configRef.current;
|
|
166
|
+
const newZoom = clamp(targetZoom, minScale2, maxScale2);
|
|
167
|
+
const cx = (containerRef.current?.offsetWidth ?? 0) / 2;
|
|
168
|
+
const cy = (containerRef.current?.offsetHeight ?? 0) / 2;
|
|
169
|
+
const s = newZoom / prev.zoom;
|
|
170
|
+
return { zoom: newZoom, x: cx - (cx - prev.x) * s, y: cy - (cy - prev.y) * s };
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
[containerRef, updateView]
|
|
174
|
+
);
|
|
175
|
+
const resetView = react.useCallback(() => setView(DEFAULT_VIEW), [setView]);
|
|
176
|
+
return { view, setView, centerZoom, resetView };
|
|
177
|
+
}
|
|
178
|
+
function clamp(value, min, max) {
|
|
179
|
+
return Math.max(min, Math.min(value, max));
|
|
180
|
+
}
|
|
181
|
+
function distance(x1, y1, x2, y2) {
|
|
182
|
+
const dx = x1 - x2;
|
|
183
|
+
const dy = y1 - y2;
|
|
184
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
exports.useZoomPinch = useZoomPinch;
|
|
188
|
+
//# sourceMappingURL=index.cjs.map
|
|
189
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/useZoomPinch.ts"],"names":["useState","useRef","useCallback","useEffect","minScale","maxScale","panSpeed","zoomSpeed"],"mappings":"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","file":"index.cjs","sourcesContent":["import { type RefObject, useCallback, useEffect, useRef, useState } from \"react\"\n\nexport interface ViewState {\n x: number\n y: number\n zoom: number\n}\n\nexport interface UseZoomPinchOptions {\n /** Ref to the container element that receives gesture events. */\n containerRef: RefObject<HTMLElement | null>\n /** Minimum allowed zoom level. @default 0.1 */\n minScale?: number\n /** Maximum allowed zoom level. @default 50 */\n maxScale?: number\n /** Multiplier for pan speed (mouse wheel only). @default 1 */\n panSpeed?: number\n /** Multiplier for zoom speed (mouse wheel only). @default 1 */\n zoomSpeed?: number\n /** Initial view state used when uncontrolled. @default \\{ x: 0, y: 0, zoom: 1 \\} */\n initialViewState?: ViewState\n /** Controlled view state. When provided, the hook becomes controlled. */\n viewState?: ViewState\n /** Callback fired on every view change. Required for controlled mode. */\n onViewStateChange?: (view: ViewState) => void\n /** Enable or disable all gesture handling. @default true */\n enabled?: boolean\n}\n\nexport interface UseZoomPinchReturn {\n /** Current view state (position + zoom). */\n view: ViewState\n /** Imperatively set the view state. */\n setView: (view: ViewState) => void\n /** Zoom to a target level, keeping the container center as anchor. */\n centerZoom: (targetZoom: number) => void\n /** Reset view to \\{ x: 0, y: 0, zoom: 1 \\}. */\n resetView: () => void\n}\n\nconst DEFAULT_VIEW: ViewState = { x: 0, y: 0, zoom: 1 }\n\n/**\n * React hook for pan, pinch-to-zoom, and scroll-zoom gestures.\n *\n * Supports mouse wheel (discrete + trackpad), pointer drag, and multi-touch pinch.\n * Works in both controlled (`viewState` + `onViewStateChange`) and uncontrolled modes.\n */\nexport function useZoomPinch({\n containerRef,\n minScale = 0.1,\n maxScale = 50,\n panSpeed = 1,\n zoomSpeed = 1,\n initialViewState = DEFAULT_VIEW,\n viewState,\n onViewStateChange,\n enabled = true,\n}: UseZoomPinchOptions): UseZoomPinchReturn {\n const [internalView, setInternalView] = useState<ViewState>(initialViewState)\n\n const view = viewState ?? internalView\n\n // All mutable values accessed from listeners live in refs\n // so the useEffect never re-runs on prop changes.\n const viewRef = useRef(view)\n viewRef.current = view\n\n const enabledRef = useRef(enabled)\n enabledRef.current = enabled\n\n const onViewStateChangeRef = useRef(onViewStateChange)\n onViewStateChangeRef.current = onViewStateChange\n\n const configRef = useRef({ minScale, maxScale, panSpeed, zoomSpeed })\n configRef.current = { minScale, maxScale, panSpeed, zoomSpeed }\n\n const updateView = useCallback((updater: (prev: ViewState) => ViewState) => {\n const prev = viewRef.current\n const next = updater(prev)\n if (next.x === prev.x && next.y === prev.y && next.zoom === prev.zoom) {\n return\n }\n if (onViewStateChangeRef.current) {\n onViewStateChangeRef.current(next)\n } else {\n setInternalView(next)\n }\n }, [])\n\n useEffect(() => {\n const el = containerRef.current\n if (!el) return\n\n const getRelCoords = (clientX: number, clientY: number) => {\n const rect = el.getBoundingClientRect()\n return { px: clientX - rect.left, py: clientY - rect.top }\n }\n\n // ── Wheel (scroll-to-pan / pinch-to-zoom on trackpad) ──────────\n\n const onWheel = (e: WheelEvent) => {\n if (!enabledRef.current) return\n e.preventDefault()\n\n let deltaX = e.deltaX\n let deltaY = e.deltaY\n const { ctrlKey, clientX, clientY } = e\n\n // Trackpad heuristic: pixel-mode deltas (deltaMode === 0) with\n // non-line-step values strongly suggest a trackpad.\n const isTrackpad = e.deltaMode === 0 && (Math.abs(deltaY) < 100 || !Number.isInteger(deltaY))\n\n if (!isTrackpad) {\n const LINE_STEP = 120\n if (Math.abs(deltaY) >= 100) deltaY = Math.sign(deltaY) * (Math.abs(deltaY) / LINE_STEP)\n if (Math.abs(deltaX) >= 100) deltaX = Math.sign(deltaX) * (Math.abs(deltaX) / LINE_STEP)\n }\n\n const { minScale, maxScale, panSpeed, zoomSpeed } = configRef.current\n\n updateView((prev) => {\n if (ctrlKey) {\n const speed = isTrackpad ? 0.01 : 0.1 * zoomSpeed\n const factor = 1 - deltaY * speed\n const newZoom = clamp(prev.zoom * factor, minScale, maxScale)\n if (newZoom === prev.zoom) return prev\n\n const { px, py } = getRelCoords(clientX, clientY)\n const s = newZoom / prev.zoom\n return { zoom: newZoom, x: px - (px - prev.x) * s, y: py - (py - prev.y) * s }\n }\n\n const multiplier = isTrackpad ? 1 : 25 * panSpeed\n return { ...prev, x: prev.x - deltaX * multiplier, y: prev.y - deltaY * multiplier }\n })\n }\n\n // ── Pointer drag (mouse + single-touch panning) ────────────────\n\n let isDragging = false\n let lastPointer = { x: 0, y: 0 }\n const activePointers = new Set<number>()\n\n const onPointerDown = (e: PointerEvent) => {\n if (!enabledRef.current) return\n activePointers.add(e.pointerId)\n if (e.pointerType === \"mouse\" && e.button !== 0) return\n\n if (activePointers.size >= 2) {\n isDragging = false\n return\n }\n\n isDragging = true\n lastPointer = { x: e.clientX, y: e.clientY }\n }\n\n const onPointerMove = (e: PointerEvent) => {\n if (!isDragging) return\n e.preventDefault()\n\n const dx = e.clientX - lastPointer.x\n const dy = e.clientY - lastPointer.y\n lastPointer = { x: e.clientX, y: e.clientY }\n\n updateView((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }))\n }\n\n const onPointerUp = (e: PointerEvent) => {\n activePointers.delete(e.pointerId)\n if (activePointers.size === 0) isDragging = false\n }\n\n // ── Multi-touch pinch (mobile) ─────────────────────────────────\n\n let pinchState = { zoom: 1, x: 0, y: 0 }\n let initialDist = 0\n let initialCenter = { x: 0, y: 0 }\n let isPinching = false\n\n const onTouchStart = (e: TouchEvent) => {\n if (!enabledRef.current || e.touches.length < 2) return\n\n isDragging = false\n isPinching = true\n\n const t1 = e.touches[0]!\n const t2 = e.touches[1]!\n\n initialDist = distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY)\n initialCenter = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 }\n pinchState = { ...viewRef.current }\n }\n\n const onTouchMove = (e: TouchEvent) => {\n if (e.touches.length < 2 || !isPinching) return\n e.preventDefault()\n\n const t1 = e.touches[0]!\n const t2 = e.touches[1]!\n\n const { minScale, maxScale } = configRef.current\n const currentDist = distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY)\n const currentCenter = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 }\n\n const newZoom = clamp(pinchState.zoom * (currentDist / initialDist), minScale, maxScale)\n const s = newZoom / pinchState.zoom\n const { px, py } = getRelCoords(initialCenter.x, initialCenter.y)\n\n updateView(() => ({\n zoom: newZoom,\n x: px - (px - pinchState.x) * s + (currentCenter.x - initialCenter.x),\n y: py - (py - pinchState.y) * s + (currentCenter.y - initialCenter.y),\n }))\n }\n\n const onTouchEnd = (e: TouchEvent) => {\n if (e.touches.length < 2) isPinching = false\n }\n\n // ── Register listeners ─────────────────────────────────────────\n\n el.addEventListener(\"wheel\", onWheel, { passive: false })\n el.addEventListener(\"pointerdown\", onPointerDown)\n window.addEventListener(\"pointermove\", onPointerMove, { passive: false })\n window.addEventListener(\"pointerup\", onPointerUp)\n window.addEventListener(\"pointercancel\", onPointerUp)\n el.addEventListener(\"touchstart\", onTouchStart, { passive: false })\n el.addEventListener(\"touchmove\", onTouchMove, { passive: false })\n el.addEventListener(\"touchend\", onTouchEnd)\n el.addEventListener(\"touchcancel\", onTouchEnd)\n\n return () => {\n el.removeEventListener(\"wheel\", onWheel)\n el.removeEventListener(\"pointerdown\", onPointerDown)\n window.removeEventListener(\"pointermove\", onPointerMove)\n window.removeEventListener(\"pointerup\", onPointerUp)\n window.removeEventListener(\"pointercancel\", onPointerUp)\n el.removeEventListener(\"touchstart\", onTouchStart)\n el.removeEventListener(\"touchmove\", onTouchMove)\n el.removeEventListener(\"touchend\", onTouchEnd)\n el.removeEventListener(\"touchcancel\", onTouchEnd)\n }\n }, [containerRef, updateView])\n\n const setView = useCallback((v: ViewState) => {\n if (onViewStateChangeRef.current) {\n onViewStateChangeRef.current(v)\n } else {\n setInternalView(v)\n }\n }, [])\n\n const centerZoom = useCallback(\n (targetZoom: number) => {\n updateView((prev) => {\n const { minScale, maxScale } = configRef.current\n const newZoom = clamp(targetZoom, minScale, maxScale)\n const cx = (containerRef.current?.offsetWidth ?? 0) / 2\n const cy = (containerRef.current?.offsetHeight ?? 0) / 2\n const s = newZoom / prev.zoom\n return { zoom: newZoom, x: cx - (cx - prev.x) * s, y: cy - (cy - prev.y) * s }\n })\n },\n [containerRef, updateView],\n )\n\n const resetView = useCallback(() => setView(DEFAULT_VIEW), [setView])\n\n return { view, setView, centerZoom, resetView }\n}\n\n// ── Helpers ──────────────────────────────────────────────────────────\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.max(min, Math.min(value, max))\n}\n\nfunction distance(x1: number, y1: number, x2: number, y2: number): number {\n const dx = x1 - x2\n const dy = y1 - y2\n return Math.sqrt(dx * dx + dy * dy)\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ViewState {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
zoom: number;
|
|
7
|
+
}
|
|
8
|
+
interface UseZoomPinchOptions {
|
|
9
|
+
/** Ref to the container element that receives gesture events. */
|
|
10
|
+
containerRef: RefObject<HTMLElement | null>;
|
|
11
|
+
/** Minimum allowed zoom level. @default 0.1 */
|
|
12
|
+
minScale?: number;
|
|
13
|
+
/** Maximum allowed zoom level. @default 50 */
|
|
14
|
+
maxScale?: number;
|
|
15
|
+
/** Multiplier for pan speed (mouse wheel only). @default 1 */
|
|
16
|
+
panSpeed?: number;
|
|
17
|
+
/** Multiplier for zoom speed (mouse wheel only). @default 1 */
|
|
18
|
+
zoomSpeed?: number;
|
|
19
|
+
/** Initial view state used when uncontrolled. @default \{ x: 0, y: 0, zoom: 1 \} */
|
|
20
|
+
initialViewState?: ViewState;
|
|
21
|
+
/** Controlled view state. When provided, the hook becomes controlled. */
|
|
22
|
+
viewState?: ViewState;
|
|
23
|
+
/** Callback fired on every view change. Required for controlled mode. */
|
|
24
|
+
onViewStateChange?: (view: ViewState) => void;
|
|
25
|
+
/** Enable or disable all gesture handling. @default true */
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
interface UseZoomPinchReturn {
|
|
29
|
+
/** Current view state (position + zoom). */
|
|
30
|
+
view: ViewState;
|
|
31
|
+
/** Imperatively set the view state. */
|
|
32
|
+
setView: (view: ViewState) => void;
|
|
33
|
+
/** Zoom to a target level, keeping the container center as anchor. */
|
|
34
|
+
centerZoom: (targetZoom: number) => void;
|
|
35
|
+
/** Reset view to \{ x: 0, y: 0, zoom: 1 \}. */
|
|
36
|
+
resetView: () => void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* React hook for pan, pinch-to-zoom, and scroll-zoom gestures.
|
|
40
|
+
*
|
|
41
|
+
* Supports mouse wheel (discrete + trackpad), pointer drag, and multi-touch pinch.
|
|
42
|
+
* Works in both controlled (`viewState` + `onViewStateChange`) and uncontrolled modes.
|
|
43
|
+
*/
|
|
44
|
+
declare function useZoomPinch({ containerRef, minScale, maxScale, panSpeed, zoomSpeed, initialViewState, viewState, onViewStateChange, enabled, }: UseZoomPinchOptions): UseZoomPinchReturn;
|
|
45
|
+
|
|
46
|
+
export { type UseZoomPinchOptions, type UseZoomPinchReturn, type ViewState, useZoomPinch };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ViewState {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
zoom: number;
|
|
7
|
+
}
|
|
8
|
+
interface UseZoomPinchOptions {
|
|
9
|
+
/** Ref to the container element that receives gesture events. */
|
|
10
|
+
containerRef: RefObject<HTMLElement | null>;
|
|
11
|
+
/** Minimum allowed zoom level. @default 0.1 */
|
|
12
|
+
minScale?: number;
|
|
13
|
+
/** Maximum allowed zoom level. @default 50 */
|
|
14
|
+
maxScale?: number;
|
|
15
|
+
/** Multiplier for pan speed (mouse wheel only). @default 1 */
|
|
16
|
+
panSpeed?: number;
|
|
17
|
+
/** Multiplier for zoom speed (mouse wheel only). @default 1 */
|
|
18
|
+
zoomSpeed?: number;
|
|
19
|
+
/** Initial view state used when uncontrolled. @default \{ x: 0, y: 0, zoom: 1 \} */
|
|
20
|
+
initialViewState?: ViewState;
|
|
21
|
+
/** Controlled view state. When provided, the hook becomes controlled. */
|
|
22
|
+
viewState?: ViewState;
|
|
23
|
+
/** Callback fired on every view change. Required for controlled mode. */
|
|
24
|
+
onViewStateChange?: (view: ViewState) => void;
|
|
25
|
+
/** Enable or disable all gesture handling. @default true */
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
interface UseZoomPinchReturn {
|
|
29
|
+
/** Current view state (position + zoom). */
|
|
30
|
+
view: ViewState;
|
|
31
|
+
/** Imperatively set the view state. */
|
|
32
|
+
setView: (view: ViewState) => void;
|
|
33
|
+
/** Zoom to a target level, keeping the container center as anchor. */
|
|
34
|
+
centerZoom: (targetZoom: number) => void;
|
|
35
|
+
/** Reset view to \{ x: 0, y: 0, zoom: 1 \}. */
|
|
36
|
+
resetView: () => void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* React hook for pan, pinch-to-zoom, and scroll-zoom gestures.
|
|
40
|
+
*
|
|
41
|
+
* Supports mouse wheel (discrete + trackpad), pointer drag, and multi-touch pinch.
|
|
42
|
+
* Works in both controlled (`viewState` + `onViewStateChange`) and uncontrolled modes.
|
|
43
|
+
*/
|
|
44
|
+
declare function useZoomPinch({ containerRef, minScale, maxScale, panSpeed, zoomSpeed, initialViewState, viewState, onViewStateChange, enabled, }: UseZoomPinchOptions): UseZoomPinchReturn;
|
|
45
|
+
|
|
46
|
+
export { type UseZoomPinchOptions, type UseZoomPinchReturn, type ViewState, useZoomPinch };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
// src/useZoomPinch.ts
|
|
4
|
+
var DEFAULT_VIEW = { x: 0, y: 0, zoom: 1 };
|
|
5
|
+
function useZoomPinch({
|
|
6
|
+
containerRef,
|
|
7
|
+
minScale = 0.1,
|
|
8
|
+
maxScale = 50,
|
|
9
|
+
panSpeed = 1,
|
|
10
|
+
zoomSpeed = 1,
|
|
11
|
+
initialViewState = DEFAULT_VIEW,
|
|
12
|
+
viewState,
|
|
13
|
+
onViewStateChange,
|
|
14
|
+
enabled = true
|
|
15
|
+
}) {
|
|
16
|
+
const [internalView, setInternalView] = useState(initialViewState);
|
|
17
|
+
const view = viewState ?? internalView;
|
|
18
|
+
const viewRef = useRef(view);
|
|
19
|
+
viewRef.current = view;
|
|
20
|
+
const enabledRef = useRef(enabled);
|
|
21
|
+
enabledRef.current = enabled;
|
|
22
|
+
const onViewStateChangeRef = useRef(onViewStateChange);
|
|
23
|
+
onViewStateChangeRef.current = onViewStateChange;
|
|
24
|
+
const configRef = useRef({ minScale, maxScale, panSpeed, zoomSpeed });
|
|
25
|
+
configRef.current = { minScale, maxScale, panSpeed, zoomSpeed };
|
|
26
|
+
const updateView = useCallback((updater) => {
|
|
27
|
+
const prev = viewRef.current;
|
|
28
|
+
const next = updater(prev);
|
|
29
|
+
if (next.x === prev.x && next.y === prev.y && next.zoom === prev.zoom) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (onViewStateChangeRef.current) {
|
|
33
|
+
onViewStateChangeRef.current(next);
|
|
34
|
+
} else {
|
|
35
|
+
setInternalView(next);
|
|
36
|
+
}
|
|
37
|
+
}, []);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const el = containerRef.current;
|
|
40
|
+
if (!el) return;
|
|
41
|
+
const getRelCoords = (clientX, clientY) => {
|
|
42
|
+
const rect = el.getBoundingClientRect();
|
|
43
|
+
return { px: clientX - rect.left, py: clientY - rect.top };
|
|
44
|
+
};
|
|
45
|
+
const onWheel = (e) => {
|
|
46
|
+
if (!enabledRef.current) return;
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
let deltaX = e.deltaX;
|
|
49
|
+
let deltaY = e.deltaY;
|
|
50
|
+
const { ctrlKey, clientX, clientY } = e;
|
|
51
|
+
const isTrackpad = e.deltaMode === 0 && (Math.abs(deltaY) < 100 || !Number.isInteger(deltaY));
|
|
52
|
+
if (!isTrackpad) {
|
|
53
|
+
const LINE_STEP = 120;
|
|
54
|
+
if (Math.abs(deltaY) >= 100) deltaY = Math.sign(deltaY) * (Math.abs(deltaY) / LINE_STEP);
|
|
55
|
+
if (Math.abs(deltaX) >= 100) deltaX = Math.sign(deltaX) * (Math.abs(deltaX) / LINE_STEP);
|
|
56
|
+
}
|
|
57
|
+
const { minScale: minScale2, maxScale: maxScale2, panSpeed: panSpeed2, zoomSpeed: zoomSpeed2 } = configRef.current;
|
|
58
|
+
updateView((prev) => {
|
|
59
|
+
if (ctrlKey) {
|
|
60
|
+
const speed = isTrackpad ? 0.01 : 0.1 * zoomSpeed2;
|
|
61
|
+
const factor = 1 - deltaY * speed;
|
|
62
|
+
const newZoom = clamp(prev.zoom * factor, minScale2, maxScale2);
|
|
63
|
+
if (newZoom === prev.zoom) return prev;
|
|
64
|
+
const { px, py } = getRelCoords(clientX, clientY);
|
|
65
|
+
const s = newZoom / prev.zoom;
|
|
66
|
+
return { zoom: newZoom, x: px - (px - prev.x) * s, y: py - (py - prev.y) * s };
|
|
67
|
+
}
|
|
68
|
+
const multiplier = isTrackpad ? 1 : 25 * panSpeed2;
|
|
69
|
+
return { ...prev, x: prev.x - deltaX * multiplier, y: prev.y - deltaY * multiplier };
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
let isDragging = false;
|
|
73
|
+
let lastPointer = { x: 0, y: 0 };
|
|
74
|
+
const activePointers = /* @__PURE__ */ new Set();
|
|
75
|
+
const onPointerDown = (e) => {
|
|
76
|
+
if (!enabledRef.current) return;
|
|
77
|
+
activePointers.add(e.pointerId);
|
|
78
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
79
|
+
if (activePointers.size >= 2) {
|
|
80
|
+
isDragging = false;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
isDragging = true;
|
|
84
|
+
lastPointer = { x: e.clientX, y: e.clientY };
|
|
85
|
+
};
|
|
86
|
+
const onPointerMove = (e) => {
|
|
87
|
+
if (!isDragging) return;
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
const dx = e.clientX - lastPointer.x;
|
|
90
|
+
const dy = e.clientY - lastPointer.y;
|
|
91
|
+
lastPointer = { x: e.clientX, y: e.clientY };
|
|
92
|
+
updateView((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }));
|
|
93
|
+
};
|
|
94
|
+
const onPointerUp = (e) => {
|
|
95
|
+
activePointers.delete(e.pointerId);
|
|
96
|
+
if (activePointers.size === 0) isDragging = false;
|
|
97
|
+
};
|
|
98
|
+
let pinchState = { zoom: 1, x: 0, y: 0 };
|
|
99
|
+
let initialDist = 0;
|
|
100
|
+
let initialCenter = { x: 0, y: 0 };
|
|
101
|
+
let isPinching = false;
|
|
102
|
+
const onTouchStart = (e) => {
|
|
103
|
+
if (!enabledRef.current || e.touches.length < 2) return;
|
|
104
|
+
isDragging = false;
|
|
105
|
+
isPinching = true;
|
|
106
|
+
const t1 = e.touches[0];
|
|
107
|
+
const t2 = e.touches[1];
|
|
108
|
+
initialDist = distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY);
|
|
109
|
+
initialCenter = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 };
|
|
110
|
+
pinchState = { ...viewRef.current };
|
|
111
|
+
};
|
|
112
|
+
const onTouchMove = (e) => {
|
|
113
|
+
if (e.touches.length < 2 || !isPinching) return;
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
const t1 = e.touches[0];
|
|
116
|
+
const t2 = e.touches[1];
|
|
117
|
+
const { minScale: minScale2, maxScale: maxScale2 } = configRef.current;
|
|
118
|
+
const currentDist = distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY);
|
|
119
|
+
const currentCenter = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 };
|
|
120
|
+
const newZoom = clamp(pinchState.zoom * (currentDist / initialDist), minScale2, maxScale2);
|
|
121
|
+
const s = newZoom / pinchState.zoom;
|
|
122
|
+
const { px, py } = getRelCoords(initialCenter.x, initialCenter.y);
|
|
123
|
+
updateView(() => ({
|
|
124
|
+
zoom: newZoom,
|
|
125
|
+
x: px - (px - pinchState.x) * s + (currentCenter.x - initialCenter.x),
|
|
126
|
+
y: py - (py - pinchState.y) * s + (currentCenter.y - initialCenter.y)
|
|
127
|
+
}));
|
|
128
|
+
};
|
|
129
|
+
const onTouchEnd = (e) => {
|
|
130
|
+
if (e.touches.length < 2) isPinching = false;
|
|
131
|
+
};
|
|
132
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
133
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
134
|
+
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
135
|
+
window.addEventListener("pointerup", onPointerUp);
|
|
136
|
+
window.addEventListener("pointercancel", onPointerUp);
|
|
137
|
+
el.addEventListener("touchstart", onTouchStart, { passive: false });
|
|
138
|
+
el.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
139
|
+
el.addEventListener("touchend", onTouchEnd);
|
|
140
|
+
el.addEventListener("touchcancel", onTouchEnd);
|
|
141
|
+
return () => {
|
|
142
|
+
el.removeEventListener("wheel", onWheel);
|
|
143
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
144
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
145
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
146
|
+
window.removeEventListener("pointercancel", onPointerUp);
|
|
147
|
+
el.removeEventListener("touchstart", onTouchStart);
|
|
148
|
+
el.removeEventListener("touchmove", onTouchMove);
|
|
149
|
+
el.removeEventListener("touchend", onTouchEnd);
|
|
150
|
+
el.removeEventListener("touchcancel", onTouchEnd);
|
|
151
|
+
};
|
|
152
|
+
}, [containerRef, updateView]);
|
|
153
|
+
const setView = useCallback((v) => {
|
|
154
|
+
if (onViewStateChangeRef.current) {
|
|
155
|
+
onViewStateChangeRef.current(v);
|
|
156
|
+
} else {
|
|
157
|
+
setInternalView(v);
|
|
158
|
+
}
|
|
159
|
+
}, []);
|
|
160
|
+
const centerZoom = useCallback(
|
|
161
|
+
(targetZoom) => {
|
|
162
|
+
updateView((prev) => {
|
|
163
|
+
const { minScale: minScale2, maxScale: maxScale2 } = configRef.current;
|
|
164
|
+
const newZoom = clamp(targetZoom, minScale2, maxScale2);
|
|
165
|
+
const cx = (containerRef.current?.offsetWidth ?? 0) / 2;
|
|
166
|
+
const cy = (containerRef.current?.offsetHeight ?? 0) / 2;
|
|
167
|
+
const s = newZoom / prev.zoom;
|
|
168
|
+
return { zoom: newZoom, x: cx - (cx - prev.x) * s, y: cy - (cy - prev.y) * s };
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
[containerRef, updateView]
|
|
172
|
+
);
|
|
173
|
+
const resetView = useCallback(() => setView(DEFAULT_VIEW), [setView]);
|
|
174
|
+
return { view, setView, centerZoom, resetView };
|
|
175
|
+
}
|
|
176
|
+
function clamp(value, min, max) {
|
|
177
|
+
return Math.max(min, Math.min(value, max));
|
|
178
|
+
}
|
|
179
|
+
function distance(x1, y1, x2, y2) {
|
|
180
|
+
const dx = x1 - x2;
|
|
181
|
+
const dy = y1 - y2;
|
|
182
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export { useZoomPinch };
|
|
186
|
+
//# sourceMappingURL=index.js.map
|
|
187
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/useZoomPinch.ts"],"names":["minScale","maxScale","panSpeed","zoomSpeed"],"mappings":"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","file":"index.js","sourcesContent":["import { type RefObject, useCallback, useEffect, useRef, useState } from \"react\"\n\nexport interface ViewState {\n x: number\n y: number\n zoom: number\n}\n\nexport interface UseZoomPinchOptions {\n /** Ref to the container element that receives gesture events. */\n containerRef: RefObject<HTMLElement | null>\n /** Minimum allowed zoom level. @default 0.1 */\n minScale?: number\n /** Maximum allowed zoom level. @default 50 */\n maxScale?: number\n /** Multiplier for pan speed (mouse wheel only). @default 1 */\n panSpeed?: number\n /** Multiplier for zoom speed (mouse wheel only). @default 1 */\n zoomSpeed?: number\n /** Initial view state used when uncontrolled. @default \\{ x: 0, y: 0, zoom: 1 \\} */\n initialViewState?: ViewState\n /** Controlled view state. When provided, the hook becomes controlled. */\n viewState?: ViewState\n /** Callback fired on every view change. Required for controlled mode. */\n onViewStateChange?: (view: ViewState) => void\n /** Enable or disable all gesture handling. @default true */\n enabled?: boolean\n}\n\nexport interface UseZoomPinchReturn {\n /** Current view state (position + zoom). */\n view: ViewState\n /** Imperatively set the view state. */\n setView: (view: ViewState) => void\n /** Zoom to a target level, keeping the container center as anchor. */\n centerZoom: (targetZoom: number) => void\n /** Reset view to \\{ x: 0, y: 0, zoom: 1 \\}. */\n resetView: () => void\n}\n\nconst DEFAULT_VIEW: ViewState = { x: 0, y: 0, zoom: 1 }\n\n/**\n * React hook for pan, pinch-to-zoom, and scroll-zoom gestures.\n *\n * Supports mouse wheel (discrete + trackpad), pointer drag, and multi-touch pinch.\n * Works in both controlled (`viewState` + `onViewStateChange`) and uncontrolled modes.\n */\nexport function useZoomPinch({\n containerRef,\n minScale = 0.1,\n maxScale = 50,\n panSpeed = 1,\n zoomSpeed = 1,\n initialViewState = DEFAULT_VIEW,\n viewState,\n onViewStateChange,\n enabled = true,\n}: UseZoomPinchOptions): UseZoomPinchReturn {\n const [internalView, setInternalView] = useState<ViewState>(initialViewState)\n\n const view = viewState ?? internalView\n\n // All mutable values accessed from listeners live in refs\n // so the useEffect never re-runs on prop changes.\n const viewRef = useRef(view)\n viewRef.current = view\n\n const enabledRef = useRef(enabled)\n enabledRef.current = enabled\n\n const onViewStateChangeRef = useRef(onViewStateChange)\n onViewStateChangeRef.current = onViewStateChange\n\n const configRef = useRef({ minScale, maxScale, panSpeed, zoomSpeed })\n configRef.current = { minScale, maxScale, panSpeed, zoomSpeed }\n\n const updateView = useCallback((updater: (prev: ViewState) => ViewState) => {\n const prev = viewRef.current\n const next = updater(prev)\n if (next.x === prev.x && next.y === prev.y && next.zoom === prev.zoom) {\n return\n }\n if (onViewStateChangeRef.current) {\n onViewStateChangeRef.current(next)\n } else {\n setInternalView(next)\n }\n }, [])\n\n useEffect(() => {\n const el = containerRef.current\n if (!el) return\n\n const getRelCoords = (clientX: number, clientY: number) => {\n const rect = el.getBoundingClientRect()\n return { px: clientX - rect.left, py: clientY - rect.top }\n }\n\n // ── Wheel (scroll-to-pan / pinch-to-zoom on trackpad) ──────────\n\n const onWheel = (e: WheelEvent) => {\n if (!enabledRef.current) return\n e.preventDefault()\n\n let deltaX = e.deltaX\n let deltaY = e.deltaY\n const { ctrlKey, clientX, clientY } = e\n\n // Trackpad heuristic: pixel-mode deltas (deltaMode === 0) with\n // non-line-step values strongly suggest a trackpad.\n const isTrackpad = e.deltaMode === 0 && (Math.abs(deltaY) < 100 || !Number.isInteger(deltaY))\n\n if (!isTrackpad) {\n const LINE_STEP = 120\n if (Math.abs(deltaY) >= 100) deltaY = Math.sign(deltaY) * (Math.abs(deltaY) / LINE_STEP)\n if (Math.abs(deltaX) >= 100) deltaX = Math.sign(deltaX) * (Math.abs(deltaX) / LINE_STEP)\n }\n\n const { minScale, maxScale, panSpeed, zoomSpeed } = configRef.current\n\n updateView((prev) => {\n if (ctrlKey) {\n const speed = isTrackpad ? 0.01 : 0.1 * zoomSpeed\n const factor = 1 - deltaY * speed\n const newZoom = clamp(prev.zoom * factor, minScale, maxScale)\n if (newZoom === prev.zoom) return prev\n\n const { px, py } = getRelCoords(clientX, clientY)\n const s = newZoom / prev.zoom\n return { zoom: newZoom, x: px - (px - prev.x) * s, y: py - (py - prev.y) * s }\n }\n\n const multiplier = isTrackpad ? 1 : 25 * panSpeed\n return { ...prev, x: prev.x - deltaX * multiplier, y: prev.y - deltaY * multiplier }\n })\n }\n\n // ── Pointer drag (mouse + single-touch panning) ────────────────\n\n let isDragging = false\n let lastPointer = { x: 0, y: 0 }\n const activePointers = new Set<number>()\n\n const onPointerDown = (e: PointerEvent) => {\n if (!enabledRef.current) return\n activePointers.add(e.pointerId)\n if (e.pointerType === \"mouse\" && e.button !== 0) return\n\n if (activePointers.size >= 2) {\n isDragging = false\n return\n }\n\n isDragging = true\n lastPointer = { x: e.clientX, y: e.clientY }\n }\n\n const onPointerMove = (e: PointerEvent) => {\n if (!isDragging) return\n e.preventDefault()\n\n const dx = e.clientX - lastPointer.x\n const dy = e.clientY - lastPointer.y\n lastPointer = { x: e.clientX, y: e.clientY }\n\n updateView((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }))\n }\n\n const onPointerUp = (e: PointerEvent) => {\n activePointers.delete(e.pointerId)\n if (activePointers.size === 0) isDragging = false\n }\n\n // ── Multi-touch pinch (mobile) ─────────────────────────────────\n\n let pinchState = { zoom: 1, x: 0, y: 0 }\n let initialDist = 0\n let initialCenter = { x: 0, y: 0 }\n let isPinching = false\n\n const onTouchStart = (e: TouchEvent) => {\n if (!enabledRef.current || e.touches.length < 2) return\n\n isDragging = false\n isPinching = true\n\n const t1 = e.touches[0]!\n const t2 = e.touches[1]!\n\n initialDist = distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY)\n initialCenter = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 }\n pinchState = { ...viewRef.current }\n }\n\n const onTouchMove = (e: TouchEvent) => {\n if (e.touches.length < 2 || !isPinching) return\n e.preventDefault()\n\n const t1 = e.touches[0]!\n const t2 = e.touches[1]!\n\n const { minScale, maxScale } = configRef.current\n const currentDist = distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY)\n const currentCenter = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 }\n\n const newZoom = clamp(pinchState.zoom * (currentDist / initialDist), minScale, maxScale)\n const s = newZoom / pinchState.zoom\n const { px, py } = getRelCoords(initialCenter.x, initialCenter.y)\n\n updateView(() => ({\n zoom: newZoom,\n x: px - (px - pinchState.x) * s + (currentCenter.x - initialCenter.x),\n y: py - (py - pinchState.y) * s + (currentCenter.y - initialCenter.y),\n }))\n }\n\n const onTouchEnd = (e: TouchEvent) => {\n if (e.touches.length < 2) isPinching = false\n }\n\n // ── Register listeners ─────────────────────────────────────────\n\n el.addEventListener(\"wheel\", onWheel, { passive: false })\n el.addEventListener(\"pointerdown\", onPointerDown)\n window.addEventListener(\"pointermove\", onPointerMove, { passive: false })\n window.addEventListener(\"pointerup\", onPointerUp)\n window.addEventListener(\"pointercancel\", onPointerUp)\n el.addEventListener(\"touchstart\", onTouchStart, { passive: false })\n el.addEventListener(\"touchmove\", onTouchMove, { passive: false })\n el.addEventListener(\"touchend\", onTouchEnd)\n el.addEventListener(\"touchcancel\", onTouchEnd)\n\n return () => {\n el.removeEventListener(\"wheel\", onWheel)\n el.removeEventListener(\"pointerdown\", onPointerDown)\n window.removeEventListener(\"pointermove\", onPointerMove)\n window.removeEventListener(\"pointerup\", onPointerUp)\n window.removeEventListener(\"pointercancel\", onPointerUp)\n el.removeEventListener(\"touchstart\", onTouchStart)\n el.removeEventListener(\"touchmove\", onTouchMove)\n el.removeEventListener(\"touchend\", onTouchEnd)\n el.removeEventListener(\"touchcancel\", onTouchEnd)\n }\n }, [containerRef, updateView])\n\n const setView = useCallback((v: ViewState) => {\n if (onViewStateChangeRef.current) {\n onViewStateChangeRef.current(v)\n } else {\n setInternalView(v)\n }\n }, [])\n\n const centerZoom = useCallback(\n (targetZoom: number) => {\n updateView((prev) => {\n const { minScale, maxScale } = configRef.current\n const newZoom = clamp(targetZoom, minScale, maxScale)\n const cx = (containerRef.current?.offsetWidth ?? 0) / 2\n const cy = (containerRef.current?.offsetHeight ?? 0) / 2\n const s = newZoom / prev.zoom\n return { zoom: newZoom, x: cx - (cx - prev.x) * s, y: cy - (cy - prev.y) * s }\n })\n },\n [containerRef, updateView],\n )\n\n const resetView = useCallback(() => setView(DEFAULT_VIEW), [setView])\n\n return { view, setView, centerZoom, resetView }\n}\n\n// ── Helpers ──────────────────────────────────────────────────────────\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.max(min, Math.min(value, max))\n}\n\nfunction distance(x1: number, y1: number, x2: number, y2: number): number {\n const dx = x1 - x2\n const dy = y1 - y2\n return Math.sqrt(dx * dx + dy * dy)\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "use-zoom-pinch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight React hook for pan, pinch-to-zoom, and scroll zoom with trackpad and touch support",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"hook",
|
|
8
|
+
"zoom",
|
|
9
|
+
"pinch",
|
|
10
|
+
"pan",
|
|
11
|
+
"gesture",
|
|
12
|
+
"trackpad",
|
|
13
|
+
"touch",
|
|
14
|
+
"canvas",
|
|
15
|
+
"infinite-canvas"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "nemezzizz",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/NemeZZiZZ/use-zoom-pinch.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/NemeZZiZZ/use-zoom-pinch#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/NemeZZiZZ/use-zoom-pinch/issues"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"import": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"require": {
|
|
35
|
+
"types": "./dist/index.d.cts",
|
|
36
|
+
"default": "./dist/index.cjs"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"main": "./dist/index.cjs",
|
|
41
|
+
"module": "./dist/index.js",
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"LICENSE",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"dev": "tsup --watch",
|
|
51
|
+
"lint": "eslint . --max-warnings 0",
|
|
52
|
+
"format": "prettier --write .",
|
|
53
|
+
"format:check": "prettier --check .",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"prepublishOnly": "npm run build"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"react": ">=18.0.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/react": "^19.0.0",
|
|
62
|
+
"eslint": "^9.0.0",
|
|
63
|
+
"@eslint/js": "^9.0.0",
|
|
64
|
+
"typescript-eslint": "^8.0.0",
|
|
65
|
+
"prettier": "^3.0.0",
|
|
66
|
+
"tsup": "^8.0.0",
|
|
67
|
+
"typescript": "^5.5.0",
|
|
68
|
+
"react": "^19.0.0"
|
|
69
|
+
},
|
|
70
|
+
"sideEffects": false,
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": ">=18"
|
|
73
|
+
}
|
|
74
|
+
}
|