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 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"]}
@@ -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 };
@@ -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
+ }