use-zoom-pinch 0.1.0 → 0.4.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/CHANGELOG.md +83 -0
- package/LICENSE +1 -1
- package/README.md +471 -24
- package/dist/index.cjs +1 -189
- package/dist/index.d.cts +211 -6
- package/dist/index.d.ts +211 -6
- package/dist/index.js +1 -187
- package/package.json +38 -11
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
For pre-1.0 releases (`0.x.y`), minor version bumps may include breaking changes.
|
|
8
|
+
|
|
9
|
+
## [0.4.0] — 2026-07-13
|
|
10
|
+
|
|
11
|
+
### Breaking
|
|
12
|
+
|
|
13
|
+
These changes affect `resetView`, the keyboard `0` key, and `doubleTap` modes
|
|
14
|
+
`"reset"` / `"toggle"`. Apps that relied on the previous hardcoded reset target
|
|
15
|
+
should adjust.
|
|
16
|
+
|
|
17
|
+
- **`resetView()` now returns to `initialViewState`** instead of the hardcoded
|
|
18
|
+
`{ x: 0, y: 0, zoom: 1, rotation: 0 }`. If `initialViewState` is not provided,
|
|
19
|
+
the default `{ 0, 0, 1 }` is still used.
|
|
20
|
+
- **Keyboard `0`** resets to `initialViewState` (was: hardcoded reset).
|
|
21
|
+
- **`doubleTap` `"reset"` and `"toggle"`** modes animate back to `initialViewState`,
|
|
22
|
+
preserving the initial `x` / `y` / `zoom` / `rotation`. Previously they reset to
|
|
23
|
+
`{ 0, 0, 1 }` and silently dropped rotation.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`setView(view, { skipConstraints: true })`** — bypasses bounds clamping, axis
|
|
28
|
+
locking, and snap-to-grid for precise programmatic positioning (even outside
|
|
29
|
+
bounds). Only affects `setView`; gestures are always constrained.
|
|
30
|
+
- **`activationKeys.rotate`** is now honored (previously declared in the type but
|
|
31
|
+
ignored). Rotation via touch pinch, the Safari `GestureEvent`, and keyboard
|
|
32
|
+
`[` / `]` is gated by the configured activation key.
|
|
33
|
+
- **`cursor.zooming`** is now applied while wheel-zooming, and the cursor restores
|
|
34
|
+
to `idle` once the debounced zoom-end fires (previously the option was unused).
|
|
35
|
+
- **Exported math helpers** `clamp`, `distance`, `angleBetween` for manual
|
|
36
|
+
coordinate conversion.
|
|
37
|
+
- **Debounced `ResizeObserver`** (150ms) for `fitToContent` auto-resize, so window
|
|
38
|
+
resize no longer yanks the viewport on every intermediate frame.
|
|
39
|
+
- **Unified `onTransformEnd`** now fires after `doubleTap` and after **inertia
|
|
40
|
+
fully settles** (previously it fired only after pan/zoom/pinch end, and during
|
|
41
|
+
inertia it fired too early at pointer-up).
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **Wheel `preventDefault` ordering** — the wheel handler no longer calls
|
|
46
|
+
`preventDefault()` when it will not handle the event. Previously, with
|
|
47
|
+
`activationKeys.zoom` set but the key not held, or with the relevant gesture
|
|
48
|
+
disabled, the hook still suppressed the browser's native wheel behavior
|
|
49
|
+
(page scroll, ctrl+wheel page zoom).
|
|
50
|
+
- **`animateTo` with `duration <= 0`** no longer poisons the view with `NaN`.
|
|
51
|
+
It now jumps to the target immediately.
|
|
52
|
+
- **`activationKeys` no longer "stick"** when the user alt-tabs while holding a
|
|
53
|
+
modifier — pressed keys are cleared on `window blur` and `visibilitychange`.
|
|
54
|
+
- **`contextmenu` is suppressed** when `panButton: 2` (right mouse), making
|
|
55
|
+
right-button panning usable.
|
|
56
|
+
|
|
57
|
+
### Tests
|
|
58
|
+
|
|
59
|
+
- Added `src/__tests__/bugfixes.test.ts` with 27 tests covering all fixes above,
|
|
60
|
+
including wheel `preventDefault` ordering, the duration guard, `skipConstraints`,
|
|
61
|
+
blur-clear, and `onTransformEnd` consistency after double-tap and inertia.
|
|
62
|
+
- Fixed a dead test for rotation snap (it had no assertions); replaced with a real
|
|
63
|
+
pinch-gesture-based assertion.
|
|
64
|
+
- Updated existing tests to match the new `resetView` / `initialViewState` semantics.
|
|
65
|
+
|
|
66
|
+
## [0.3.0]
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- Bounds bounce (rubber-band overscroll with snap-back), axis locking, cursor
|
|
71
|
+
management, `wheelMode`, rotation snap levels, activation keys, `onTransformEnd`,
|
|
72
|
+
navigation helpers (`panTo`, `panBy`, `zoomTo`, `fitToRect`), double-tap zoom,
|
|
73
|
+
inertia, animated transitions, controlled mode.
|
|
74
|
+
- Test coverage thresholds and `eslint-plugin-react-hooks`.
|
|
75
|
+
- README improvements and bundle-size badge.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- Initial release bug fixes.
|
|
80
|
+
|
|
81
|
+
## [0.1.0]
|
|
82
|
+
|
|
83
|
+
- Initial release: `useZoomPinch` React hook.
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,19 +1,46 @@
|
|
|
1
1
|
# useZoomPinch
|
|
2
2
|
|
|
3
|
-
Lightweight React hook for **pan**, **pinch-to-zoom**, and **scroll zoom** with trackpad and touch support. Zero dependencies beyond React.
|
|
3
|
+
Lightweight React hook for **pan**, **pinch-to-zoom**, **rotation**, and **scroll zoom** with trackpad and touch support. Zero dependencies beyond React.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/use-zoom-pinch)
|
|
6
|
+
[](https://github.com/NemeZZiZZ/use-zoom-pinch/actions/workflows/ci.yml)
|
|
7
|
+
[](https://bundlephobia.com/package/use-zoom-pinch)
|
|
4
8
|
|
|
5
9
|
[**Live Demo**](https://nemezzizz.github.io/use-zoom-pinch/)
|
|
10
|
+
[**Changelog**](./CHANGELOG.md)
|
|
6
11
|
|
|
7
12
|
## Features
|
|
8
13
|
|
|
9
14
|
- **Scroll to pan** — mouse wheel and trackpad two-finger scroll
|
|
10
15
|
- **Pinch to zoom** — trackpad pinch (via `ctrlKey` + wheel) and multi-touch pinch on mobile
|
|
11
16
|
- **Pointer drag** — mouse drag and single-touch drag for panning
|
|
17
|
+
- **Rotation** — two-finger rotation with `rotateTo`/`rotateBy` methods
|
|
18
|
+
- **Gesture toggles** — enable/disable pan, zoom, and rotate independently
|
|
19
|
+
- **Double-tap zoom** — configurable toggle/zoomIn/reset modes
|
|
20
|
+
- **Inertia** — momentum-based pan with configurable friction
|
|
21
|
+
- **Animated transitions** — smooth easing for programmatic view changes
|
|
22
|
+
- **Bounds** — constrain panning within rectangular limits
|
|
23
|
+
- **Keyboard navigation** — arrow keys, +/-, rotation with [ / ]
|
|
24
|
+
- **Coordinate conversion** — `screenToContent` / `contentToScreen`
|
|
25
|
+
- **Zoom snap levels** — snap to predefined zoom levels on gesture end
|
|
26
|
+
- **Snap to grid** — snap positions to a grid (continuous or on end)
|
|
27
|
+
- **Auto-fit content** — `fitToContent` with ResizeObserver auto-resize
|
|
28
|
+
- **Configurable pan button** — left, middle, or right mouse button
|
|
29
|
+
- **Bounce at bounds** — rubber-band overscroll with snap-back animation
|
|
30
|
+
- **Axis locking** — restrict pan to horizontal or vertical only
|
|
31
|
+
- **Cursor management** — automatic grab/grabbing cursor on drag
|
|
32
|
+
- **Wheel mode** — swap default: wheel zooms, ctrl+wheel pans
|
|
33
|
+
- **Rotation constraints** — min/max angle and rotation snap levels
|
|
34
|
+
- **Activation keys** — require Shift/Alt/Ctrl for specific gestures
|
|
35
|
+
- **`onTransformEnd`** — unified callback after any gesture ends
|
|
36
|
+
- **Navigation** — `panTo`, `panBy`, `zoomTo`, `fitToRect` for precise viewport control
|
|
12
37
|
- **Controlled & uncontrolled** modes
|
|
38
|
+
- **Granular events** — `onPanStart/End`, `onZoomStart/End`, `onPinchStart/End`, `onRotateStart/End`
|
|
39
|
+
- **Event filtering** — `shouldHandleEvent` to exclude interactive elements
|
|
13
40
|
- **Stable listeners** — config changes don't re-register event listeners
|
|
14
41
|
- **TypeScript-first** with full type exports
|
|
15
42
|
- **Tree-shakeable** ESM + CJS dual build
|
|
16
|
-
- **~
|
|
43
|
+
- **~5.8 KB** minified + gzipped
|
|
17
44
|
|
|
18
45
|
## Installation
|
|
19
46
|
|
|
@@ -59,7 +86,7 @@ function ControlledCanvas() {
|
|
|
59
86
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
60
87
|
const [viewState, setViewState] = useState<ViewState>({ x: 0, y: 0, zoom: 1 })
|
|
61
88
|
|
|
62
|
-
const { view,
|
|
89
|
+
const { view, zoomIn, zoomOut, resetView } = useZoomPinch({
|
|
63
90
|
containerRef,
|
|
64
91
|
viewState,
|
|
65
92
|
onViewStateChange: setViewState,
|
|
@@ -67,9 +94,9 @@ function ControlledCanvas() {
|
|
|
67
94
|
|
|
68
95
|
return (
|
|
69
96
|
<div>
|
|
70
|
-
<button onClick={() =>
|
|
71
|
-
<button onClick={() =>
|
|
72
|
-
<button onClick={resetView}>Reset</button>
|
|
97
|
+
<button onClick={() => zoomIn(1.5, { animate: true })}>Zoom In</button>
|
|
98
|
+
<button onClick={() => zoomOut(1.5, { animate: true })}>Zoom Out</button>
|
|
99
|
+
<button onClick={() => resetView({ animate: true })}>Reset</button>
|
|
73
100
|
|
|
74
101
|
<div
|
|
75
102
|
ref={containerRef}
|
|
@@ -89,43 +116,463 @@ function ControlledCanvas() {
|
|
|
89
116
|
}
|
|
90
117
|
```
|
|
91
118
|
|
|
119
|
+
## Animated Transitions
|
|
120
|
+
|
|
121
|
+
All programmatic methods accept an optional `AnimationOptions` parameter:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
const {
|
|
125
|
+
setView,
|
|
126
|
+
centerZoom,
|
|
127
|
+
resetView,
|
|
128
|
+
zoomIn,
|
|
129
|
+
zoomOut,
|
|
130
|
+
zoomToElement,
|
|
131
|
+
panTo,
|
|
132
|
+
panBy,
|
|
133
|
+
zoomTo,
|
|
134
|
+
fitToRect,
|
|
135
|
+
} = useZoomPinch({
|
|
136
|
+
containerRef,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Instant (default, backward-compatible)
|
|
140
|
+
setView({ x: 100, y: 200, zoom: 2 })
|
|
141
|
+
|
|
142
|
+
// Animated
|
|
143
|
+
setView({ x: 100, y: 200, zoom: 2 }, { animate: true, duration: 300 })
|
|
144
|
+
|
|
145
|
+
// Skip bounds/axis/snap constraints (precise positioning even outside bounds)
|
|
146
|
+
setView({ x: 1000, y: 1000, zoom: 2 }, { skipConstraints: true })
|
|
147
|
+
|
|
148
|
+
// With custom easing
|
|
149
|
+
import { easeInOut } from "use-zoom-pinch"
|
|
150
|
+
resetView({ animate: true, duration: 500, easing: easeInOut })
|
|
151
|
+
|
|
152
|
+
// Zoom to a specific element
|
|
153
|
+
const elRef = useRef<HTMLDivElement>(null)
|
|
154
|
+
zoomToElement(elRef.current!, 2, { animate: true })
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Built-in Easings
|
|
158
|
+
|
|
159
|
+
- `linear` — constant speed
|
|
160
|
+
- `easeOut` — fast start, slow end (default)
|
|
161
|
+
- `easeInOut` — slow start, fast middle, slow end
|
|
162
|
+
|
|
163
|
+
### Skipping Constraints
|
|
164
|
+
|
|
165
|
+
`setView` normally applies bounds clamping, axis locking, and snap-to-grid. Pass `skipConstraints: true` to bypass them when you need precise programmatic positioning (even outside bounds):
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
setView({ x: 9999, y: 9999, zoom: 2 }, { skipConstraints: true })
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Exported Helpers
|
|
172
|
+
|
|
173
|
+
The library also exports a few pure math helpers for manual coordinate work:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { clamp, distance, angleBetween } from "use-zoom-pinch"
|
|
177
|
+
|
|
178
|
+
clamp(15, 0, 10) // 10
|
|
179
|
+
distance(0, 0, 3, 4) // 5
|
|
180
|
+
angleBetween(0, 0, 0, 10) // 90 (degrees)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Navigation
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
const { panTo, panBy, zoomTo, fitToRect } = useZoomPinch({ containerRef })
|
|
187
|
+
|
|
188
|
+
// Center on a point in content space
|
|
189
|
+
panTo(500, 300, { animate: true })
|
|
190
|
+
|
|
191
|
+
// Shift viewport by 100px right, 50px down (screen-space delta)
|
|
192
|
+
panBy(100, 50)
|
|
193
|
+
|
|
194
|
+
// Zoom to 3x centered on a content-space point
|
|
195
|
+
zoomTo(3, { x: 500, y: 300 }, { animate: true })
|
|
196
|
+
|
|
197
|
+
// Fit a region into view with padding
|
|
198
|
+
fitToRect({ x: 100, y: 100, width: 400, height: 300 }, { animate: true, padding: 20 })
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Rotation
|
|
202
|
+
|
|
203
|
+
Two-finger rotation is detected during pinch gestures. Disabled by default — enable via `gestures`:
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
const { view, rotateTo, rotateBy } = useZoomPinch({
|
|
207
|
+
containerRef,
|
|
208
|
+
gestures: { rotate: true },
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Apply with CSS:
|
|
212
|
+
// transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom}) rotate(${view.rotation ?? 0}deg)`
|
|
213
|
+
|
|
214
|
+
// Programmatic rotation
|
|
215
|
+
rotateTo(45, { animate: true })
|
|
216
|
+
rotateBy(90, { animate: true })
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Gesture Toggles
|
|
220
|
+
|
|
221
|
+
Enable or disable individual gesture types:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
// Only zoom, no pan or rotate
|
|
225
|
+
useZoomPinch({ containerRef, gestures: { pan: false, zoom: true, rotate: false } })
|
|
226
|
+
|
|
227
|
+
// Only rotate (e.g. image rotation tool)
|
|
228
|
+
useZoomPinch({ containerRef, gestures: { pan: false, zoom: false, rotate: true } })
|
|
229
|
+
|
|
230
|
+
// All gestures (pan + zoom + rotate)
|
|
231
|
+
useZoomPinch({ containerRef, gestures: { rotate: true } })
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Imperative methods (`setView`, `panTo`, `rotateTo`, etc.) always work regardless of gesture toggles.
|
|
235
|
+
|
|
236
|
+
## Double-Tap Zoom
|
|
237
|
+
|
|
238
|
+
Double-tap (or double-click) zooms to the tap point. Enabled by default in `toggle` mode.
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
// Default: toggle between 1x and 2x zoom
|
|
242
|
+
useZoomPinch({ containerRef })
|
|
243
|
+
|
|
244
|
+
// Customize behavior
|
|
245
|
+
useZoomPinch({
|
|
246
|
+
containerRef,
|
|
247
|
+
doubleTap: { mode: "zoomIn", step: 3 }, // always zoom in by 3x
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// Disable double-tap
|
|
251
|
+
useZoomPinch({ containerRef, doubleTap: false })
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Modes
|
|
255
|
+
|
|
256
|
+
- `"toggle"` (default) — zoom in if at 1x, reset if zoomed in
|
|
257
|
+
- `"zoomIn"` — always zoom in by `step`
|
|
258
|
+
- `"reset"` — always reset to `{ x: 0, y: 0, zoom: 1 }`
|
|
259
|
+
|
|
260
|
+
## Inertia
|
|
261
|
+
|
|
262
|
+
Pan with momentum after releasing the pointer. Enabled by default.
|
|
263
|
+
|
|
264
|
+
```tsx
|
|
265
|
+
// Default: friction 0.92
|
|
266
|
+
useZoomPinch({ containerRef })
|
|
267
|
+
|
|
268
|
+
// Custom friction (lower = more friction, faster stop)
|
|
269
|
+
useZoomPinch({ containerRef, inertia: { friction: 0.85 } })
|
|
270
|
+
|
|
271
|
+
// Disable inertia
|
|
272
|
+
useZoomPinch({ containerRef, inertia: false })
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Event Filtering
|
|
276
|
+
|
|
277
|
+
Exclude interactive elements (buttons, inputs) from gesture handling:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
useZoomPinch({
|
|
281
|
+
containerRef,
|
|
282
|
+
shouldHandleEvent: (e) => !(e.target as HTMLElement).closest(".no-pan"),
|
|
283
|
+
})
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Granular Events
|
|
287
|
+
|
|
288
|
+
```tsx
|
|
289
|
+
useZoomPinch({
|
|
290
|
+
containerRef,
|
|
291
|
+
onPanStart: (view) => console.log("Pan started", view),
|
|
292
|
+
onPanEnd: (view) => console.log("Pan ended", view),
|
|
293
|
+
onZoomStart: (view) => console.log("Zoom started", view),
|
|
294
|
+
onZoomEnd: (view) => console.log("Zoom ended", view),
|
|
295
|
+
onPinchStart: (view) => console.log("Pinch started", view),
|
|
296
|
+
onPinchEnd: (view) => console.log("Pinch ended", view),
|
|
297
|
+
onRotateStart: (view) => console.log("Rotate started", view),
|
|
298
|
+
onRotateEnd: (view) => console.log("Rotate ended", view),
|
|
299
|
+
})
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Bounds
|
|
303
|
+
|
|
304
|
+
Constrain panning within rectangular limits:
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
useZoomPinch({
|
|
308
|
+
containerRef,
|
|
309
|
+
bounds: { minX: -500, maxX: 500, minY: -300, maxY: 300 },
|
|
310
|
+
})
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Keyboard Navigation
|
|
314
|
+
|
|
315
|
+
Enable keyboard controls with arrow keys, zoom (+/-), reset (0), and rotation ([ / ]):
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
// Enable with defaults (panStep: 50, zoomStep: 1.5, rotateStep: 15)
|
|
319
|
+
useZoomPinch({ containerRef, keyboard: true })
|
|
320
|
+
|
|
321
|
+
// Custom steps
|
|
322
|
+
useZoomPinch({
|
|
323
|
+
containerRef,
|
|
324
|
+
keyboard: { enabled: true, panStep: 100, zoomStep: 2 },
|
|
325
|
+
})
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Coordinate Conversion
|
|
329
|
+
|
|
330
|
+
Convert between screen and content coordinates:
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
const { screenToContent, contentToScreen } = useZoomPinch({ containerRef })
|
|
334
|
+
|
|
335
|
+
// Map a click to content space
|
|
336
|
+
const handleClick = (e: MouseEvent) => {
|
|
337
|
+
const pos = screenToContent(e.clientX, e.clientY)
|
|
338
|
+
console.log("Clicked at content position:", pos)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Position a tooltip over content
|
|
342
|
+
const screenPos = contentToScreen(itemX, itemY)
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Zoom Snap Levels
|
|
346
|
+
|
|
347
|
+
Snap zoom to predefined levels on gesture end:
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
useZoomPinch({
|
|
351
|
+
containerRef,
|
|
352
|
+
zoomSnapLevels: [0.5, 1, 2, 4],
|
|
353
|
+
})
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Snap to Grid
|
|
357
|
+
|
|
358
|
+
Snap pan position to a grid:
|
|
359
|
+
|
|
360
|
+
```tsx
|
|
361
|
+
// Snap on gesture end (with animation)
|
|
362
|
+
useZoomPinch({ containerRef, snapToGrid: { size: 50 } })
|
|
363
|
+
|
|
364
|
+
// Snap continuously during gestures
|
|
365
|
+
useZoomPinch({ containerRef, snapToGrid: { size: 50, mode: "always" } })
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## Fit to Content
|
|
369
|
+
|
|
370
|
+
Auto-fit content to container on mount and resize:
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
const { fitToContent } = useZoomPinch({
|
|
374
|
+
containerRef,
|
|
375
|
+
contentRect: { width: 1920, height: 1080 }, // auto-fits on mount + resize
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// Manual fit with padding
|
|
379
|
+
fitToContent({ animate: true, padding: 20 })
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Pan Button
|
|
383
|
+
|
|
384
|
+
Configure which mouse button triggers panning:
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
// Use middle mouse button for pan (frees left click for selection)
|
|
388
|
+
useZoomPinch({ containerRef, panButton: 1 })
|
|
389
|
+
```
|
|
390
|
+
|
|
92
391
|
## API
|
|
93
392
|
|
|
94
393
|
### `useZoomPinch(options)`
|
|
95
394
|
|
|
96
395
|
#### Options
|
|
97
396
|
|
|
98
|
-
| Option | Type | Default
|
|
99
|
-
| ------------------- | -------------------------------- |
|
|
100
|
-
| `containerRef` | `RefObject<HTMLElement \| null>` | _required_
|
|
101
|
-
| `minScale` | `number` | `0.1`
|
|
102
|
-
| `maxScale` | `number` | `50`
|
|
103
|
-
| `panSpeed` | `number` | `1`
|
|
104
|
-
| `zoomSpeed` | `number` | `1`
|
|
105
|
-
| `initialViewState` | `ViewState` | `{ x: 0, y: 0, zoom: 1 }`
|
|
106
|
-
| `viewState` | `ViewState` | —
|
|
107
|
-
| `onViewStateChange` | `(view: ViewState) => void` | —
|
|
108
|
-
| `enabled` | `boolean` | `true`
|
|
397
|
+
| Option | Type | Default | Description |
|
|
398
|
+
| ------------------- | -------------------------------- | ------------------------------------------ | ------------------------------------------------ |
|
|
399
|
+
| `containerRef` | `RefObject<HTMLElement \| null>` | _required_ | Ref to the container element |
|
|
400
|
+
| `minScale` | `number` | `0.1` | Minimum zoom level |
|
|
401
|
+
| `maxScale` | `number` | `50` | Maximum zoom level |
|
|
402
|
+
| `panSpeed` | `number` | `1` | Pan speed multiplier (mouse wheel) |
|
|
403
|
+
| `zoomSpeed` | `number` | `1` | Zoom speed multiplier (mouse wheel) |
|
|
404
|
+
| `initialViewState` | `ViewState` | `{ x: 0, y: 0, zoom: 1 }` | Initial view for uncontrolled mode |
|
|
405
|
+
| `viewState` | `ViewState` | — | Controlled view state |
|
|
406
|
+
| `onViewStateChange` | `(view: ViewState) => void` | — | Callback on view change |
|
|
407
|
+
| `enabled` | `boolean` | `true` | Enable/disable gesture handling |
|
|
408
|
+
| `gestures` | `GesturesOptions` | `{ pan: true, zoom: true, rotate: false }` | Toggle individual gesture types |
|
|
409
|
+
| `panButton` | `0 \| 1 \| 2` | `0` | Mouse button for pan (0=left, 1=middle, 2=right) |
|
|
410
|
+
| `bounds` | `BoundsOptions` | — | Constrain panning within bounds |
|
|
411
|
+
| `keyboard` | `KeyboardOptions \| boolean` | `false` | Keyboard navigation (pass `true` for defaults) |
|
|
412
|
+
| `zoomSnapLevels` | `number[]` | — | Snap zoom to nearest level on gesture end |
|
|
413
|
+
| `snapToGrid` | `SnapToGridOptions \| false` | `false` | Snap position to grid |
|
|
414
|
+
| `contentRect` | `{ width; height }` | — | Content size for `fitToContent` + auto-fit |
|
|
415
|
+
| `rotation` | `RotationOptions` | — | Min/max angle and rotation snap levels |
|
|
416
|
+
| `wheelMode` | `"pan" \| "zoom"` | `"pan"` | Default wheel behavior |
|
|
417
|
+
| `cursor` | `CursorOptions \| false` | enabled | Auto cursor (grab/grabbing). `false` to disable |
|
|
418
|
+
| `axis` | `"x" \| "y"` | — | Restrict gestures to a single axis |
|
|
419
|
+
| `activationKeys` | `ActivationKeyOptions` | — | Require key held for specific gestures |
|
|
420
|
+
| `shouldHandleEvent` | `(event) => boolean` | — | Filter which events are handled |
|
|
421
|
+
| `doubleTap` | `DoubleTapOptions \| false` | `{ mode: "toggle", step: 2 }` | Double-tap zoom config, or `false` to disable |
|
|
422
|
+
| `inertia` | `InertiaOptions \| false` | `{ friction: 0.92 }` | Pan inertia config, or `false` to disable |
|
|
423
|
+
| `onPanStart` | `(view: ViewState) => void` | — | Fired when drag starts |
|
|
424
|
+
| `onPanEnd` | `(view: ViewState) => void` | — | Fired when drag ends |
|
|
425
|
+
| `onZoomStart` | `(view: ViewState) => void` | — | Fired when wheel zoom starts |
|
|
426
|
+
| `onZoomEnd` | `(view: ViewState) => void` | — | Fired when wheel zoom ends (150ms debounce) |
|
|
427
|
+
| `onPinchStart` | `(view: ViewState) => void` | — | Fired when pinch starts |
|
|
428
|
+
| `onPinchEnd` | `(view: ViewState) => void` | — | Fired when pinch ends |
|
|
429
|
+
| `onRotateStart` | `(view: ViewState) => void` | — | Fired when rotation starts |
|
|
430
|
+
| `onRotateEnd` | `(view: ViewState) => void` | — | Fired when rotation ends |
|
|
431
|
+
| `onTransformEnd` | `(view: ViewState) => void` | — | Fired after any gesture ends |
|
|
109
432
|
|
|
110
433
|
#### Returns
|
|
111
434
|
|
|
112
|
-
| Property
|
|
113
|
-
|
|
|
114
|
-
| `view`
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
435
|
+
| Property | Type | Description |
|
|
436
|
+
| ----------------- | ---------------------------------- | ----------------------------------------------------- |
|
|
437
|
+
| `view` | `ViewState` | Current view state |
|
|
438
|
+
| `isAnimating` | `boolean` | Whether an animation is running |
|
|
439
|
+
| `setView` | `(view, options?) => void` | Imperatively set the view |
|
|
440
|
+
| `centerZoom` | `(zoom, options?) => void` | Zoom to level, centered in container |
|
|
441
|
+
| `resetView` | `(options?) => void` | Reset to `initialViewState` (or `{0,0,1,0}` if unset) |
|
|
442
|
+
| `zoomIn` | `(step?, options?) => void` | Zoom in by step (default 1.5x) |
|
|
443
|
+
| `zoomOut` | `(step?, options?) => void` | Zoom out by step (default 1.5x) |
|
|
444
|
+
| `zoomToElement` | `(el, scale?, options?) => void` | Zoom and center on an element |
|
|
445
|
+
| `panTo` | `(x, y, options?) => void` | Center viewport on content-space point |
|
|
446
|
+
| `panBy` | `(dx, dy, options?) => void` | Shift viewport by relative offset (screen px) |
|
|
447
|
+
| `zoomTo` | `(zoom, point?, options?) => void` | Zoom to level, optionally anchored |
|
|
448
|
+
| `fitToRect` | `(rect, options?) => void` | Fit a content-space rectangle into view |
|
|
449
|
+
| `rotateTo` | `(angle, options?) => void` | Set rotation to absolute angle (degrees) |
|
|
450
|
+
| `rotateBy` | `(delta, options?) => void` | Rotate by relative delta (degrees) |
|
|
451
|
+
| `screenToContent` | `(screenX, screenY) => { x, y }` | Convert screen to content coordinates |
|
|
452
|
+
| `contentToScreen` | `(contentX, contentY) => { x, y }` | Convert content to screen coordinates |
|
|
453
|
+
| `fitToContent` | `(options?) => void` | Fit content to container (needs `contentRect`) |
|
|
454
|
+
| `snapZoom` | `(options?) => void` | Snap zoom to nearest `zoomSnapLevels` |
|
|
118
455
|
|
|
119
|
-
###
|
|
456
|
+
### Types
|
|
120
457
|
|
|
121
458
|
```ts
|
|
122
459
|
interface ViewState {
|
|
123
460
|
x: number // horizontal offset in pixels
|
|
124
461
|
y: number // vertical offset in pixels
|
|
125
462
|
zoom: number // scale factor (1 = 100%)
|
|
463
|
+
rotation?: number // angle in degrees (default 0)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
interface GesturesOptions {
|
|
467
|
+
pan?: boolean // default true
|
|
468
|
+
zoom?: boolean // default true
|
|
469
|
+
rotate?: boolean // default false
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
interface AnimationOptions {
|
|
473
|
+
animate?: boolean // default false
|
|
474
|
+
duration?: number // default 300ms
|
|
475
|
+
easing?: EasingFunction // default easeOut
|
|
476
|
+
skipConstraints?: boolean // default false (setView only)
|
|
126
477
|
}
|
|
478
|
+
|
|
479
|
+
type EasingFunction = (t: number) => number
|
|
480
|
+
|
|
481
|
+
interface DoubleTapOptions {
|
|
482
|
+
enabled?: boolean // default true
|
|
483
|
+
mode?: "zoomIn" | "reset" | "toggle" // default "toggle"
|
|
484
|
+
step?: number // default 2
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
interface InertiaOptions {
|
|
488
|
+
enabled?: boolean // default true
|
|
489
|
+
friction?: number // 0–1, default 0.92
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
interface BoundsOptions {
|
|
493
|
+
minX?: number
|
|
494
|
+
maxX?: number
|
|
495
|
+
minY?: number
|
|
496
|
+
maxY?: number
|
|
497
|
+
mode?: "clamp" | "bounce" // default "clamp"
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
interface KeyboardOptions {
|
|
501
|
+
enabled?: boolean // default false
|
|
502
|
+
panStep?: number // default 50
|
|
503
|
+
zoomStep?: number // default 1.5
|
|
504
|
+
rotateStep?: number // default 15
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
interface SnapToGridOptions {
|
|
508
|
+
size: number
|
|
509
|
+
mode?: "end" | "always" // default "end"
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
type ZoomSnapLevel = number
|
|
513
|
+
|
|
514
|
+
interface RotationOptions {
|
|
515
|
+
minAngle?: number
|
|
516
|
+
maxAngle?: number
|
|
517
|
+
snapLevels?: number[] // e.g. [0, 90, 180, 270]
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
interface CursorOptions {
|
|
521
|
+
enabled?: boolean // default true
|
|
522
|
+
idle?: string // default "grab"
|
|
523
|
+
dragging?: string // default "grabbing"
|
|
524
|
+
zooming?: string // default "zoom-in"
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
interface ActivationKeyOptions {
|
|
528
|
+
pan?: string // e.g. "Shift"
|
|
529
|
+
zoom?: string // e.g. "Alt"
|
|
530
|
+
rotate?: string // e.g. "Control"
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
## Container Setup
|
|
535
|
+
|
|
536
|
+
The container element **must** have `touchAction: "none"` to prevent the browser from intercepting touch gestures (scroll, pinch-zoom) before the hook can handle them:
|
|
537
|
+
|
|
538
|
+
```tsx
|
|
539
|
+
<div ref={containerRef} style={{ touchAction: "none", overflow: "hidden" }}>
|
|
127
540
|
```
|
|
128
541
|
|
|
542
|
+
Without `touchAction: "none"`, pinch-to-zoom and touch drag will trigger native browser behavior instead of your custom handling. The `overflow: "hidden"` prevents content from leaking outside the viewport.
|
|
543
|
+
|
|
544
|
+
## Browser Compatibility
|
|
545
|
+
|
|
546
|
+
The hook uses standard Web APIs available in all modern browsers:
|
|
547
|
+
|
|
548
|
+
| Feature | Chrome | Firefox | Safari | Edge | iOS Safari | Android Chrome |
|
|
549
|
+
| --------------------- | ------ | ------- | ------ | ---- | ---------- | -------------- |
|
|
550
|
+
| Pointer Events | 55+ | 59+ | 13+ | 12+ | 13+ | 55+ |
|
|
551
|
+
| Wheel Events | 31+ | 17+ | 7+ | 12+ | 7+ | 31+ |
|
|
552
|
+
| Touch Events | 22+ | 52+ | 10+ | 12+ | 10+ | 22+ |
|
|
553
|
+
| ResizeObserver | 64+ | 69+ | 13.1+ | 79+ | 13.4+ | 64+ |
|
|
554
|
+
| requestAnimationFrame | 10+ | 23+ | 6+ | 12+ | 6+ | 10+ |
|
|
555
|
+
|
|
556
|
+
**Minimum:** Chrome/Edge 64+, Firefox 69+, Safari 13.1+, iOS 13.4+.
|
|
557
|
+
|
|
558
|
+
> Safari Gesture Events (`GestureEvent`) are used when available for native trackpad pinch-zoom on macOS.
|
|
559
|
+
|
|
560
|
+
## Comparison
|
|
561
|
+
|
|
562
|
+
| Feature | useZoomPinch | react-zoom-pan-pinch | @use-gesture/react | motion/react |
|
|
563
|
+
| --------------------- | ------------ | -------------------- | ------------------ | ------------------- |
|
|
564
|
+
| Size (min+gzip) | **~5.8 KB** | ~13.2 KB | ~8.9 KB | ~41.6 KB |
|
|
565
|
+
| Approach | Hook | Components + hook | Gesture primitives | Animation + gesture |
|
|
566
|
+
| Controlled mode | ✅ Native | ❌ | ❌ | ❌ |
|
|
567
|
+
| DOM wrappers | ✅ None | +2 divs | ✅ None | +1 div |
|
|
568
|
+
| Bounds / constraints | ✅ | ✅ | 🔧 Manual | 🔧 dragConstraints |
|
|
569
|
+
| Keyboard navigation | ✅ | ❌ | ❌ | ❌ |
|
|
570
|
+
| Coordinate conversion | ✅ | ❌ | ❌ | ❌ |
|
|
571
|
+
| Snap to grid | ✅ | ❌ | ❌ | ❌ |
|
|
572
|
+
| Zoom snap levels | ✅ | ❌ | ❌ | ❌ |
|
|
573
|
+
| Rotation gestures | ✅ | ❌ | 🔧 Manual | ❌ |
|
|
574
|
+
| Ready-to-use zoom/pan | ✅ | ✅ | 🔧 Manual | 🔧 Manual |
|
|
575
|
+
|
|
129
576
|
## License
|
|
130
577
|
|
|
131
578
|
MIT
|