@sitebytom/use-zoom-pan 1.0.1 → 1.0.3
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/README.md +23 -11
- package/dist/index.d.mts +19 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +191 -102
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +191 -102
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,16 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
A **zero-dependency**, ultra-lightweight React hook and component for implementing smooth, high-performance zoom and pan interactions. Ideal for image viewers, galleries, maps, diagrams, and custom interactive canvases where you want full control without heavy dependencies.
|
|
6
6
|
|
|
7
|
-
[**Live Demo &
|
|
7
|
+
[**Live Demo & Documentation**](https://sitebytom.github.io/use-zoom-pan/)
|
|
8
|
+
|
|
9
|
+
### Documentation
|
|
10
|
+
- [Quick Start](https://sitebytom.github.io/use-zoom-pan/#getting-started)
|
|
11
|
+
- [API Reference](https://sitebytom.github.io/use-zoom-pan/#api-reference)
|
|
12
|
+
- [Gestures & Controls](https://sitebytom.github.io/use-zoom-pan/#interactions)
|
|
13
|
+
- [Performance Optimization](https://sitebytom.github.io/use-zoom-pan/#performance)
|
|
14
|
+
|
|
15
|
+
### Interactive Examples
|
|
16
|
+
- [Simple Image Viewer](https://sitebytom.github.io/use-zoom-pan/#simple)
|
|
17
|
+
- [Hook & Live Playground](https://sitebytom.github.io/use-zoom-pan/#hook)
|
|
18
|
+
- [Gallery with Swipe Navigation](https://sitebytom.github.io/use-zoom-pan/#gallery)
|
|
19
|
+
- [Interactive Map Pins](https://sitebytom.github.io/use-zoom-pan/#pins)
|
|
20
|
+
- [SVG Blueprint Visualization](https://sitebytom.github.io/use-zoom-pan/#svg)
|
|
21
|
+
- [Rich HTML Content Zoom](https://sitebytom.github.io/use-zoom-pan/#content)
|
|
8
22
|
|
|
9
23
|
## Features
|
|
10
24
|
|
|
11
25
|
- **Ultra-Lightweight**: Zero dependencies, minimal bundle size.
|
|
12
26
|
- **High Performance**: Uses ref-based interaction tracking and minimal state updates to keep zoom and pan interactions smooth and responsive.
|
|
13
27
|
- **Mouse Controls**: Scroll to zoom at cursor, click to toggle zoom, drag to pan.
|
|
14
|
-
- **Touch Controls**: Pinch to zoom at center, double
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
28
|
+
- **Touch Controls**: Pinch to zoom at center, double-tap to zoom/reset, swipe to navigate.
|
|
29
|
+
- **Precision Focal Math**: Implemented with a top-left origin for rock-solid cursor tracking during zoom.
|
|
30
|
+
- **Smart Bounds**: Prevents over-panning with configurable buffer zones and symmetrical clamping.
|
|
31
|
+
- **Mobile First**: Optimized touch thresholds, double-tap gestures, and native-feeling interactions.
|
|
17
32
|
- **Content Agnostic**: Works with images, SVG, canvas, or any HTML content.
|
|
18
33
|
|
|
19
34
|
## Quick Start
|
|
@@ -88,7 +103,7 @@ Spread `containerProps` onto your container element if you want the hook to mana
|
|
|
88
103
|
| Option | Type | Default | Description |
|
|
89
104
|
|--------|------|---------|-------------|
|
|
90
105
|
| `minScale` | `number` | `1` | Minimum zoom level. |
|
|
91
|
-
| `maxScale` | `number` | `
|
|
106
|
+
| `maxScale` | `number` | `6` | Maximum zoom level. |
|
|
92
107
|
| `zoomSensitivity` | `number` | `0.002` | Scaling multiplier for scroll wheel. |
|
|
93
108
|
| `clickZoomScale` | `number` | `2.5` | Snap-to scale on double click/tap. |
|
|
94
109
|
| `dragThresholdTouch` | `number` | `10` | Pixels to move before panning triggers (touch). |
|
|
@@ -109,7 +124,8 @@ The `useZoomPan` hook returns an object containing the current state and necessa
|
|
|
109
124
|
| `scale` | `number` | Current zoom level (1-4 by default). |
|
|
110
125
|
| `position` | `object` | Current pan coordinates `{ x, y }`. |
|
|
111
126
|
| `isDragging` | `boolean` | True when the user is actively panning. |
|
|
112
|
-
| `reset` | `function` | Resets zoom and pan to defaults. |
|
|
127
|
+
| `reset` | `function` | Resets zoom and pan to centered defaults. |
|
|
128
|
+
| `zoomTo` | `function` | Imperative zoom to `(x, y, scale)` in container coords. |
|
|
113
129
|
| `contentProps` | `object` | Event handlers to spread on the zoomable content. |
|
|
114
130
|
| `containerProps` | `object` | Event handlers to spread on the container element. |
|
|
115
131
|
|
|
@@ -138,8 +154,4 @@ The hook is designed to be **highly optimized** for smooth interactions on high-
|
|
|
138
154
|
|
|
139
155
|
### Design Philosophy
|
|
140
156
|
|
|
141
|
-
The hook avoids continuous `requestAnimationFrame` loops and animation libraries, relying instead on native pointer events and direct transform updates for a predictable, lightweight, and low-latency experience.
|
|
142
|
-
|
|
143
|
-
---
|
|
144
|
-
|
|
145
|
-
[@sitebytom](https://github.com/sitebytom)
|
|
157
|
+
The hook avoids continuous `requestAnimationFrame` loops and animation libraries, relying instead on native pointer events and direct transform updates for a predictable, lightweight, and low-latency experience.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { CSSProperties } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Represents a 2D coordinate or translation offset */
|
|
3
4
|
interface Position {
|
|
4
5
|
x: number;
|
|
5
6
|
y: number;
|
|
@@ -30,12 +31,20 @@ interface ZoomPanOptions {
|
|
|
30
31
|
/** Whether to enable swipe navigation (default: true) */
|
|
31
32
|
enableSwipe?: boolean;
|
|
32
33
|
}
|
|
34
|
+
/** Properties for the useZoomPan hook */
|
|
33
35
|
interface ZoomPanProps$1 {
|
|
34
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Reference to the container element that will host the zoomable content.
|
|
38
|
+
* The first child of this container is assumed to be the content unless contentRef is used.
|
|
39
|
+
*/
|
|
35
40
|
containerRef: React.RefObject<HTMLElement | null>;
|
|
41
|
+
/** Whether to enable zoom interactions (default: true) */
|
|
36
42
|
enableZoom?: boolean;
|
|
43
|
+
/** Callback triggered on swipe-left (next) */
|
|
37
44
|
onNext?: () => void;
|
|
45
|
+
/** Callback triggered on swipe-right (prev) */
|
|
38
46
|
onPrev?: () => void;
|
|
47
|
+
/** Configuration options for zoom and pan behavior */
|
|
39
48
|
options?: ZoomPanOptions;
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
@@ -70,14 +79,23 @@ declare const useZoomPan: ({ containerRef, enableZoom, onNext, onPrev, options }
|
|
|
70
79
|
};
|
|
71
80
|
|
|
72
81
|
interface ZoomPanProps {
|
|
82
|
+
/** The content to be made zoomable and pannable (e.g., an <img> or <svg>) */
|
|
73
83
|
children: React.ReactNode;
|
|
84
|
+
/** Optional CSS class for the wrapper container */
|
|
74
85
|
className?: string;
|
|
86
|
+
/** Optional inline styles for the wrapper container */
|
|
75
87
|
style?: CSSProperties;
|
|
88
|
+
/** Optional CSS class for the inner content wrapper */
|
|
76
89
|
contentClassName?: string;
|
|
90
|
+
/** Optional inline styles for the inner content wrapper */
|
|
77
91
|
contentStyle?: CSSProperties;
|
|
92
|
+
/** Whether to enable zoom/pan interactions (default: true) */
|
|
78
93
|
enableZoom?: boolean;
|
|
94
|
+
/** Callback triggered when a swipe-left (next) is detected on the container */
|
|
79
95
|
onNext?: () => void;
|
|
96
|
+
/** Callback triggered when a swipe-right (prev) is detected on the container */
|
|
80
97
|
onPrev?: () => void;
|
|
98
|
+
/** Advanced configuration for zoom sensitivity, bounds, etc. */
|
|
81
99
|
options?: ZoomPanOptions;
|
|
82
100
|
}
|
|
83
101
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { CSSProperties } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Represents a 2D coordinate or translation offset */
|
|
3
4
|
interface Position {
|
|
4
5
|
x: number;
|
|
5
6
|
y: number;
|
|
@@ -30,12 +31,20 @@ interface ZoomPanOptions {
|
|
|
30
31
|
/** Whether to enable swipe navigation (default: true) */
|
|
31
32
|
enableSwipe?: boolean;
|
|
32
33
|
}
|
|
34
|
+
/** Properties for the useZoomPan hook */
|
|
33
35
|
interface ZoomPanProps$1 {
|
|
34
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Reference to the container element that will host the zoomable content.
|
|
38
|
+
* The first child of this container is assumed to be the content unless contentRef is used.
|
|
39
|
+
*/
|
|
35
40
|
containerRef: React.RefObject<HTMLElement | null>;
|
|
41
|
+
/** Whether to enable zoom interactions (default: true) */
|
|
36
42
|
enableZoom?: boolean;
|
|
43
|
+
/** Callback triggered on swipe-left (next) */
|
|
37
44
|
onNext?: () => void;
|
|
45
|
+
/** Callback triggered on swipe-right (prev) */
|
|
38
46
|
onPrev?: () => void;
|
|
47
|
+
/** Configuration options for zoom and pan behavior */
|
|
39
48
|
options?: ZoomPanOptions;
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
@@ -70,14 +79,23 @@ declare const useZoomPan: ({ containerRef, enableZoom, onNext, onPrev, options }
|
|
|
70
79
|
};
|
|
71
80
|
|
|
72
81
|
interface ZoomPanProps {
|
|
82
|
+
/** The content to be made zoomable and pannable (e.g., an <img> or <svg>) */
|
|
73
83
|
children: React.ReactNode;
|
|
84
|
+
/** Optional CSS class for the wrapper container */
|
|
74
85
|
className?: string;
|
|
86
|
+
/** Optional inline styles for the wrapper container */
|
|
75
87
|
style?: CSSProperties;
|
|
88
|
+
/** Optional CSS class for the inner content wrapper */
|
|
76
89
|
contentClassName?: string;
|
|
90
|
+
/** Optional inline styles for the inner content wrapper */
|
|
77
91
|
contentStyle?: CSSProperties;
|
|
92
|
+
/** Whether to enable zoom/pan interactions (default: true) */
|
|
78
93
|
enableZoom?: boolean;
|
|
94
|
+
/** Callback triggered when a swipe-left (next) is detected on the container */
|
|
79
95
|
onNext?: () => void;
|
|
96
|
+
/** Callback triggered when a swipe-right (prev) is detected on the container */
|
|
80
97
|
onPrev?: () => void;
|
|
98
|
+
/** Advanced configuration for zoom sensitivity, bounds, etc. */
|
|
81
99
|
options?: ZoomPanOptions;
|
|
82
100
|
}
|
|
83
101
|
/**
|
package/dist/index.js
CHANGED
|
@@ -24,22 +24,24 @@ var DEFAULT_OPTIONS = {
|
|
|
24
24
|
var TRANSITION_DURATION = 400;
|
|
25
25
|
var TRANSITION_CURVE = "cubic-bezier(0.2, 0, 0, 1)";
|
|
26
26
|
var calculateBounds = (targetScale, container, element, boundsBuffer) => {
|
|
27
|
-
if (!container || !element) return {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
27
|
+
if (!container || !element) return { minX: 0, maxX: 0, minY: 0, maxY: 0 };
|
|
28
|
+
const cw = container.clientWidth;
|
|
29
|
+
const ch = container.clientHeight;
|
|
30
|
+
const ew = element.offsetWidth || cw;
|
|
31
|
+
const eh = element.offsetHeight || ch;
|
|
32
|
+
const sw = ew * targetScale;
|
|
33
|
+
const sh = eh * targetScale;
|
|
34
|
+
const minX = sw <= cw ? (cw - sw) / 2 - boundsBuffer : cw - sw - boundsBuffer;
|
|
35
|
+
const maxX = sw <= cw ? (cw - sw) / 2 + boundsBuffer : boundsBuffer;
|
|
36
|
+
const minY = sh <= ch ? (ch - sh) / 2 - boundsBuffer : ch - sh - boundsBuffer;
|
|
37
|
+
const maxY = sh <= ch ? (ch - sh) / 2 + boundsBuffer : boundsBuffer;
|
|
38
|
+
return { minX, maxX, minY, maxY };
|
|
37
39
|
};
|
|
38
40
|
var clampPosition = (pos, targetScale, container, element, boundsBuffer) => {
|
|
39
|
-
const
|
|
41
|
+
const b = calculateBounds(targetScale, container, element, boundsBuffer);
|
|
40
42
|
return {
|
|
41
|
-
x: Math.max(
|
|
42
|
-
y: Math.max(
|
|
43
|
+
x: Math.max(b.minX, Math.min(b.maxX, pos.x)),
|
|
44
|
+
y: Math.max(b.minY, Math.min(b.maxY, pos.y))
|
|
43
45
|
};
|
|
44
46
|
};
|
|
45
47
|
var normalizeWheelDelta = (e, sensitivity) => {
|
|
@@ -93,6 +95,8 @@ var useZoomPan = ({
|
|
|
93
95
|
});
|
|
94
96
|
const touchStartXRef = React.useRef(0);
|
|
95
97
|
const swipeBlockedRef = React.useRef(false);
|
|
98
|
+
const lastTapTimeRef = React.useRef(0);
|
|
99
|
+
const cachedRectRef = React.useRef(null);
|
|
96
100
|
const stateRef = React.useRef({ scale, position, enableZoom, isDragging, config });
|
|
97
101
|
React.useEffect(() => {
|
|
98
102
|
stateRef.current = { scale, position, enableZoom, isDragging, config };
|
|
@@ -102,9 +106,11 @@ var useZoomPan = ({
|
|
|
102
106
|
if (stateRef.current.isDragging) {
|
|
103
107
|
setIsDragging(false);
|
|
104
108
|
}
|
|
109
|
+
cachedRectRef.current = null;
|
|
105
110
|
};
|
|
106
111
|
const handleBlur = () => {
|
|
107
112
|
setIsDragging(false);
|
|
113
|
+
cachedRectRef.current = null;
|
|
108
114
|
};
|
|
109
115
|
window.addEventListener("mouseup", handleGlobalUp);
|
|
110
116
|
window.addEventListener("touchend", handleGlobalUp);
|
|
@@ -115,6 +121,7 @@ var useZoomPan = ({
|
|
|
115
121
|
window.removeEventListener("touchend", handleGlobalUp);
|
|
116
122
|
window.removeEventListener("touchcancel", handleGlobalUp);
|
|
117
123
|
window.removeEventListener("blur", handleBlur);
|
|
124
|
+
cachedRectRef.current = null;
|
|
118
125
|
setIsDragging(false);
|
|
119
126
|
dragStartRef.current.hasDragged = false;
|
|
120
127
|
setIsTransitioning(false);
|
|
@@ -132,12 +139,23 @@ var useZoomPan = ({
|
|
|
132
139
|
}, [containerRef, config.boundsBuffer, getContentElement]);
|
|
133
140
|
React__default.default.useLayoutEffect(() => {
|
|
134
141
|
const container = containerRef.current;
|
|
135
|
-
|
|
142
|
+
const content = getContentElement();
|
|
143
|
+
if (!container || !content) return;
|
|
144
|
+
if (position.x === 0 && position.y === 0 && scale === (config.initialScale ?? config.minScale)) {
|
|
145
|
+
const cw = container.clientWidth;
|
|
146
|
+
const ch = container.clientHeight;
|
|
147
|
+
const iw = content.offsetWidth || cw;
|
|
148
|
+
const ih = content.offsetHeight || ch;
|
|
149
|
+
const s = scale;
|
|
150
|
+
setPosition({
|
|
151
|
+
x: (cw - iw * s) / 2,
|
|
152
|
+
y: (ch - ih * s) / 2
|
|
153
|
+
});
|
|
154
|
+
}
|
|
136
155
|
const observer = new ResizeObserver(() => {
|
|
137
156
|
updateBoundsAndClamp();
|
|
138
157
|
});
|
|
139
158
|
observer.observe(container);
|
|
140
|
-
const content = getContentElement();
|
|
141
159
|
if (content instanceof HTMLImageElement && !content.complete) {
|
|
142
160
|
content.addEventListener("load", updateBoundsAndClamp);
|
|
143
161
|
}
|
|
@@ -147,7 +165,7 @@ var useZoomPan = ({
|
|
|
147
165
|
content.removeEventListener("load", updateBoundsAndClamp);
|
|
148
166
|
}
|
|
149
167
|
};
|
|
150
|
-
}, [containerRef,
|
|
168
|
+
}, [containerRef, getContentElement, config.initialScale, config.minScale, scale, position.x, position.y, updateBoundsAndClamp]);
|
|
151
169
|
const getClampedPosition = React.useCallback(
|
|
152
170
|
(pos, targetScale, element) => {
|
|
153
171
|
return clampPosition(
|
|
@@ -168,33 +186,45 @@ var useZoomPan = ({
|
|
|
168
186
|
const { scale: currentScale, position: currentPosition, config: config2 } = stateRef.current;
|
|
169
187
|
const delta = normalizeWheelDelta(e, config2.zoomSensitivity);
|
|
170
188
|
const newScale = Math.min(Math.max(config2.minScale, currentScale + delta), config2.maxScale);
|
|
171
|
-
if (newScale ===
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
if (newScale === currentScale) return;
|
|
190
|
+
const container = containerRef.current;
|
|
191
|
+
const content = getContentElement();
|
|
192
|
+
if (container && content) {
|
|
193
|
+
let rect = cachedRectRef.current;
|
|
194
|
+
if (!rect || !container) {
|
|
195
|
+
rect = container?.getBoundingClientRect() ?? null;
|
|
196
|
+
cachedRectRef.current = rect;
|
|
197
|
+
}
|
|
198
|
+
if (!rect) return;
|
|
199
|
+
const mouseX = e.clientX - rect.left;
|
|
200
|
+
const mouseY = e.clientY - rect.top;
|
|
201
|
+
const px = (mouseX - currentPosition.x) / currentScale;
|
|
202
|
+
const py = (mouseY - currentPosition.y) / currentScale;
|
|
203
|
+
const newPosition = {
|
|
204
|
+
x: mouseX - px * newScale,
|
|
205
|
+
y: mouseY - py * newScale
|
|
206
|
+
};
|
|
207
|
+
if (isNaN(newPosition.x) || isNaN(newPosition.y) || !isFinite(newPosition.x) || !isFinite(newPosition.y)) {
|
|
208
|
+
console.warn("Invalid zoom position calculated");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (newScale === config2.minScale) {
|
|
212
|
+
const cw = rect.width;
|
|
213
|
+
const ch = rect.height;
|
|
214
|
+
const iw = content.offsetWidth || cw;
|
|
215
|
+
const ih = content.offsetHeight || ch;
|
|
216
|
+
setPosition({
|
|
217
|
+
x: (cw - iw * newScale) / 2,
|
|
218
|
+
y: (ch - ih * newScale) / 2
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
191
221
|
const clampedPosition = getClampedPosition(newPosition, newScale, content);
|
|
192
222
|
setPosition(clampedPosition);
|
|
193
|
-
setScale(newScale);
|
|
194
223
|
}
|
|
224
|
+
setScale(newScale);
|
|
195
225
|
}
|
|
196
226
|
},
|
|
197
|
-
[getClampedPosition, containerRef, getContentElement
|
|
227
|
+
[getClampedPosition, containerRef, getContentElement]
|
|
198
228
|
);
|
|
199
229
|
React.useEffect(() => {
|
|
200
230
|
const container = containerRef.current;
|
|
@@ -205,9 +235,23 @@ var useZoomPan = ({
|
|
|
205
235
|
const reset = React.useCallback(() => {
|
|
206
236
|
setIsTransitioning(true);
|
|
207
237
|
setScale(config.minScale);
|
|
208
|
-
|
|
238
|
+
const container = containerRef.current;
|
|
239
|
+
const content = getContentElement();
|
|
240
|
+
if (container && content) {
|
|
241
|
+
const cw = container.clientWidth;
|
|
242
|
+
const ch = container.clientHeight;
|
|
243
|
+
const iw = content.offsetWidth || cw;
|
|
244
|
+
const ih = content.offsetHeight || ch;
|
|
245
|
+
setPosition({
|
|
246
|
+
x: (cw - iw * config.minScale) / 2,
|
|
247
|
+
y: (ch - ih * config.minScale) / 2
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
setPosition({ x: 0, y: 0 });
|
|
251
|
+
}
|
|
209
252
|
setIsDragging(false);
|
|
210
253
|
dragStartRef.current.hasDragged = false;
|
|
254
|
+
cachedRectRef.current = null;
|
|
211
255
|
pinchRef.current = {
|
|
212
256
|
startDist: 0,
|
|
213
257
|
initialScale: config.minScale,
|
|
@@ -215,29 +259,53 @@ var useZoomPan = ({
|
|
|
215
259
|
startY: 0,
|
|
216
260
|
startPos: { x: 0, y: 0 }
|
|
217
261
|
};
|
|
218
|
-
}, [config.minScale]);
|
|
262
|
+
}, [config.minScale, containerRef, getContentElement]);
|
|
263
|
+
const zoomTo = React.useCallback(
|
|
264
|
+
(x, y, targetScale) => {
|
|
265
|
+
setIsTransitioning(true);
|
|
266
|
+
const container = containerRef.current;
|
|
267
|
+
const content = getContentElement();
|
|
268
|
+
if (!container || !content) return;
|
|
269
|
+
const ts = targetScale ?? config.clickZoomScale;
|
|
270
|
+
const px = (x - position.x) / scale;
|
|
271
|
+
const py = (y - position.y) / scale;
|
|
272
|
+
const np = {
|
|
273
|
+
x: x - px * ts,
|
|
274
|
+
y: y - py * ts
|
|
275
|
+
};
|
|
276
|
+
if (isNaN(np.x) || isNaN(np.y) || !isFinite(np.x) || !isFinite(np.y)) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const clamped = getClampedPosition(np, ts, content);
|
|
280
|
+
setScale(ts);
|
|
281
|
+
setPosition(clamped);
|
|
282
|
+
},
|
|
283
|
+
[containerRef, scale, position, config.clickZoomScale, getClampedPosition, getContentElement]
|
|
284
|
+
);
|
|
219
285
|
const handleFocalZoom = React.useCallback(
|
|
220
286
|
(e) => {
|
|
221
287
|
setIsTransitioning(true);
|
|
222
288
|
const container = containerRef.current;
|
|
223
|
-
const
|
|
224
|
-
if (!container || !
|
|
289
|
+
const content = e.currentTarget;
|
|
290
|
+
if (!container || !content) return;
|
|
225
291
|
const rect = container.getBoundingClientRect();
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
const mouseY = e.clientY - (rect.top + centerY);
|
|
292
|
+
const mouseX = e.clientX - rect.left;
|
|
293
|
+
const mouseY = e.clientY - rect.top;
|
|
294
|
+
const px = (mouseX - position.x) / scale;
|
|
295
|
+
const py = (mouseY - position.y) / scale;
|
|
296
|
+
const targetScale = config.clickZoomScale;
|
|
232
297
|
const newPosition = {
|
|
233
|
-
x: mouseX
|
|
234
|
-
y: mouseY
|
|
298
|
+
x: mouseX - px * targetScale,
|
|
299
|
+
y: mouseY - py * targetScale
|
|
235
300
|
};
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
301
|
+
if (isNaN(newPosition.x) || isNaN(newPosition.y) || !isFinite(newPosition.x) || !isFinite(newPosition.y)) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const clamped = getClampedPosition(newPosition, targetScale, content);
|
|
305
|
+
setScale(targetScale);
|
|
306
|
+
setPosition(clamped);
|
|
239
307
|
},
|
|
240
|
-
[
|
|
308
|
+
[containerRef, scale, position, config.clickZoomScale, getClampedPosition]
|
|
241
309
|
);
|
|
242
310
|
const onImageClick = React.useCallback(
|
|
243
311
|
(e) => {
|
|
@@ -266,18 +334,15 @@ var useZoomPan = ({
|
|
|
266
334
|
const getPinchPosition = React.useCallback((centerX, centerY, newScale) => {
|
|
267
335
|
const { containerRect, startX, startY, initialScale, startPos } = pinchRef.current;
|
|
268
336
|
if (!containerRect) return { x: 0, y: 0 };
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
const scaleRatio = newScale / initialScale;
|
|
276
|
-
const pinchImageX = startPinchX - startPos.x;
|
|
277
|
-
const pinchImageY = startPinchY - startPos.y;
|
|
337
|
+
const currentPinchX = centerX - containerRect.left;
|
|
338
|
+
const currentPinchY = centerY - containerRect.top;
|
|
339
|
+
const startPinchX = startX - containerRect.left;
|
|
340
|
+
const startPinchY = startY - containerRect.top;
|
|
341
|
+
const contentUnderStartX = (startPinchX - startPos.x) / initialScale;
|
|
342
|
+
const contentUnderStartY = (startPinchY - startPos.y) / initialScale;
|
|
278
343
|
return {
|
|
279
|
-
x: currentPinchX -
|
|
280
|
-
y: currentPinchY -
|
|
344
|
+
x: currentPinchX - contentUnderStartX * newScale,
|
|
345
|
+
y: currentPinchY - contentUnderStartY * newScale
|
|
281
346
|
};
|
|
282
347
|
}, []);
|
|
283
348
|
const onImageTouchStart = React.useCallback(
|
|
@@ -292,26 +357,55 @@ var useZoomPan = ({
|
|
|
292
357
|
);
|
|
293
358
|
const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
294
359
|
const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
360
|
+
const containerRect = container?.getBoundingClientRect() ?? null;
|
|
361
|
+
cachedRectRef.current = containerRect;
|
|
295
362
|
pinchRef.current = {
|
|
296
363
|
startDist: dist,
|
|
297
364
|
initialScale: scale,
|
|
298
365
|
startX: centerX,
|
|
299
366
|
startY: centerY,
|
|
300
367
|
startPos: { x: position.x, y: position.y },
|
|
301
|
-
containerRect
|
|
302
|
-
};
|
|
303
|
-
} else if (e.touches.length === 1 && scale > config.minScale) {
|
|
304
|
-
setIsDragging(true);
|
|
305
|
-
dragStartRef.current = {
|
|
306
|
-
x: e.touches[0].clientX - position.x,
|
|
307
|
-
y: e.touches[0].clientY - position.y,
|
|
308
|
-
hasDragged: false,
|
|
309
|
-
startX: e.touches[0].clientX,
|
|
310
|
-
startY: e.touches[0].clientY
|
|
368
|
+
containerRect
|
|
311
369
|
};
|
|
370
|
+
} else if (e.touches.length === 1) {
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
const DOUBLE_TAP_MS = 300;
|
|
373
|
+
if (now - lastTapTimeRef.current < DOUBLE_TAP_MS) {
|
|
374
|
+
e.preventDefault();
|
|
375
|
+
if (scale > config.minScale) {
|
|
376
|
+
reset();
|
|
377
|
+
} else {
|
|
378
|
+
const touch = e.touches[0];
|
|
379
|
+
const container = containerRef.current;
|
|
380
|
+
const rect = container?.getBoundingClientRect();
|
|
381
|
+
if (rect) {
|
|
382
|
+
zoomTo(
|
|
383
|
+
touch.clientX - rect.left,
|
|
384
|
+
touch.clientY - rect.top,
|
|
385
|
+
config.clickZoomScale
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
setTimeout(() => {
|
|
390
|
+
lastTapTimeRef.current = 0;
|
|
391
|
+
}, 100);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
lastTapTimeRef.current = now;
|
|
395
|
+
if (scale > config.minScale) {
|
|
396
|
+
setIsDragging(true);
|
|
397
|
+
cachedRectRef.current = containerRef.current?.getBoundingClientRect() ?? null;
|
|
398
|
+
dragStartRef.current = {
|
|
399
|
+
x: e.touches[0].clientX - position.x,
|
|
400
|
+
y: e.touches[0].clientY - position.y,
|
|
401
|
+
hasDragged: false,
|
|
402
|
+
startX: e.touches[0].clientX,
|
|
403
|
+
startY: e.touches[0].clientY
|
|
404
|
+
};
|
|
405
|
+
}
|
|
312
406
|
}
|
|
313
407
|
},
|
|
314
|
-
[scale, position, config.minScale, containerRef]
|
|
408
|
+
[scale, position, config.minScale, config.clickZoomScale, containerRef, reset, zoomTo, getContentElement, getClampedPosition]
|
|
315
409
|
);
|
|
316
410
|
const onImageTouchMove = React.useCallback(
|
|
317
411
|
(e) => {
|
|
@@ -331,6 +425,9 @@ var useZoomPan = ({
|
|
|
331
425
|
centerY,
|
|
332
426
|
newScale
|
|
333
427
|
);
|
|
428
|
+
if (isNaN(newPosition.x) || isNaN(newPosition.y) || !isFinite(newPosition.x) || !isFinite(newPosition.y)) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
334
431
|
const clampedPosition = getClampedPosition(
|
|
335
432
|
newPosition,
|
|
336
433
|
newScale,
|
|
@@ -338,7 +435,20 @@ var useZoomPan = ({
|
|
|
338
435
|
);
|
|
339
436
|
setPosition(clampedPosition);
|
|
340
437
|
} else {
|
|
341
|
-
|
|
438
|
+
const container = containerRef.current;
|
|
439
|
+
const content = getContentElement();
|
|
440
|
+
if (container && content) {
|
|
441
|
+
const cw = pinchRef.current.containerRect?.width ?? container?.clientWidth ?? 0;
|
|
442
|
+
const ch = pinchRef.current.containerRect?.height ?? container?.clientHeight ?? 0;
|
|
443
|
+
const iw = content.offsetWidth || cw;
|
|
444
|
+
const ih = content.offsetHeight || ch;
|
|
445
|
+
setPosition({
|
|
446
|
+
x: (cw - iw * newScale) / 2,
|
|
447
|
+
y: (ch - ih * newScale) / 2
|
|
448
|
+
});
|
|
449
|
+
} else {
|
|
450
|
+
setPosition({ x: 0, y: 0 });
|
|
451
|
+
}
|
|
342
452
|
}
|
|
343
453
|
setScale(newScale);
|
|
344
454
|
} else if (e.touches.length === 1 && isDragging && scale > config.minScale) {
|
|
@@ -491,25 +601,6 @@ var useZoomPan = ({
|
|
|
491
601
|
},
|
|
492
602
|
[scale, onNext, onPrev, config.minScale, config.swipeThreshold]
|
|
493
603
|
);
|
|
494
|
-
const zoomTo = React.useCallback(
|
|
495
|
-
(x, y, targetScale) => {
|
|
496
|
-
setIsTransitioning(true);
|
|
497
|
-
const container = containerRef.current;
|
|
498
|
-
const content = contentRef.current || container?.firstElementChild;
|
|
499
|
-
if (!container || !content) return;
|
|
500
|
-
const scaleToUse = targetScale ?? config.clickZoomScale;
|
|
501
|
-
const contentWidth = content.offsetWidth;
|
|
502
|
-
const contentHeight = content.offsetHeight;
|
|
503
|
-
const newPosition = {
|
|
504
|
-
x: (contentWidth / 2 - x) * scaleToUse,
|
|
505
|
-
y: (contentHeight / 2 - y) * scaleToUse
|
|
506
|
-
};
|
|
507
|
-
const clampedPosition = getClampedPosition(newPosition, scaleToUse, content);
|
|
508
|
-
setScale(scaleToUse);
|
|
509
|
-
setPosition(clampedPosition);
|
|
510
|
-
},
|
|
511
|
-
[getClampedPosition, config.clickZoomScale, containerRef]
|
|
512
|
-
);
|
|
513
604
|
React.useEffect(() => {
|
|
514
605
|
if (!isTransitioning) return;
|
|
515
606
|
const timer = setTimeout(() => setIsTransitioning(false), TRANSITION_DURATION);
|
|
@@ -517,7 +608,7 @@ var useZoomPan = ({
|
|
|
517
608
|
}, [isTransitioning]);
|
|
518
609
|
const contentStyle = React__default.default.useMemo(() => {
|
|
519
610
|
const style = {
|
|
520
|
-
transformOrigin: "
|
|
611
|
+
transformOrigin: "0 0",
|
|
521
612
|
transition: isTransitioning ? `transform ${TRANSITION_DURATION}ms ${TRANSITION_CURVE}` : "none",
|
|
522
613
|
touchAction: "none",
|
|
523
614
|
userSelect: "none",
|
|
@@ -605,9 +696,6 @@ var ZoomPan = ({
|
|
|
605
696
|
width: "100%",
|
|
606
697
|
height: "100%",
|
|
607
698
|
overflow: "hidden",
|
|
608
|
-
display: "flex",
|
|
609
|
-
alignItems: "center",
|
|
610
|
-
justifyContent: "center",
|
|
611
699
|
cursor: scale > 1 ? "grab" : enableZoom ? "zoom-in" : "default",
|
|
612
700
|
position: "relative",
|
|
613
701
|
...style
|
|
@@ -618,8 +706,9 @@ var ZoomPan = ({
|
|
|
618
706
|
userSelect: "none",
|
|
619
707
|
WebkitUserSelect: "none",
|
|
620
708
|
touchAction: "none",
|
|
621
|
-
|
|
622
|
-
|
|
709
|
+
position: "absolute",
|
|
710
|
+
top: 0,
|
|
711
|
+
left: 0,
|
|
623
712
|
...contentStyle
|
|
624
713
|
};
|
|
625
714
|
return /* @__PURE__ */ React__default.default.createElement(
|