@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 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 & Technical Reference**](https://sitebytom.github.io/use-zoom-pan/)
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 tap to toggle, swipe to navigate.
15
- - **Smart Bounds**: Prevents over-panning with configurable buffer zones.
16
- - **Mobile First**: Optimized touch thresholds and native-feeling gestures.
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` | `4` | Maximum zoom level. |
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
- /** Reference to the container element (div, section, etc.) */
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
- /** Reference to the container element (div, section, etc.) */
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 { xLimit: 0, yLimit: 0 };
28
- const containerWidth = container.clientWidth;
29
- const containerHeight = container.clientHeight;
30
- const elementWidth = element.offsetWidth || containerWidth;
31
- const elementHeight = element.offsetHeight || containerHeight;
32
- const scaledWidth = elementWidth * targetScale;
33
- const scaledHeight = elementHeight * targetScale;
34
- const xLimit = (scaledWidth <= containerWidth ? 0 : (scaledWidth - containerWidth) / 2) + boundsBuffer;
35
- const yLimit = (scaledHeight <= containerHeight ? 0 : (scaledHeight - containerHeight) / 2) + boundsBuffer;
36
- return { xLimit, yLimit };
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 { xLimit, yLimit } = calculateBounds(targetScale, container, element, boundsBuffer);
41
+ const b = calculateBounds(targetScale, container, element, boundsBuffer);
40
42
  return {
41
- x: Math.max(-xLimit, Math.min(xLimit, pos.x)),
42
- y: Math.max(-yLimit, Math.min(yLimit, pos.y))
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
- if (!container) return;
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, updateBoundsAndClamp, getContentElement]);
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 === config2.minScale) {
172
- setScale(config2.minScale);
173
- setPosition({ x: 0, y: 0 });
174
- } else {
175
- const container = containerRef.current;
176
- const content = getContentElement();
177
- if (container && content) {
178
- const rect = container.getBoundingClientRect();
179
- const containerWidth = rect.width;
180
- const containerHeight = rect.height;
181
- const centerX = containerWidth / 2;
182
- const centerY = containerHeight / 2;
183
- const mouseX = e.clientX - (rect.left + centerX);
184
- const mouseY = e.clientY - (rect.top + centerY);
185
- const contentX = (mouseX - currentPosition.x) / currentScale;
186
- const contentY = (mouseY - currentPosition.y) / currentScale;
187
- const newPosition = {
188
- x: mouseX - contentX * newScale,
189
- y: mouseY - contentY * newScale
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, config.zoomSensitivity, config.minScale, config.maxScale]
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
- setPosition({ x: 0, y: 0 });
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 target = e.currentTarget;
224
- if (!container || !target) return;
289
+ const content = e.currentTarget;
290
+ if (!container || !content) return;
225
291
  const rect = container.getBoundingClientRect();
226
- const containerWidth = container.clientWidth;
227
- const containerHeight = container.clientHeight;
228
- const centerX = containerWidth / 2;
229
- const centerY = containerHeight / 2;
230
- const mouseX = e.clientX - (rect.left + centerX);
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 * (1 - config.clickZoomScale),
234
- y: mouseY * (1 - config.clickZoomScale)
298
+ x: mouseX - px * targetScale,
299
+ y: mouseY - py * targetScale
235
300
  };
236
- const clampedPosition = getClampedPosition(newPosition, config.clickZoomScale, target);
237
- setScale(config.clickZoomScale);
238
- setPosition(clampedPosition);
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
- [getClampedPosition, config.clickZoomScale, containerRef]
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 containerCenterX = containerRect.width / 2;
270
- const containerCenterY = containerRect.height / 2;
271
- const currentPinchX = centerX - (containerRect.left + containerCenterX);
272
- const currentPinchY = centerY - (containerRect.top + containerCenterY);
273
- const startPinchX = startX - (containerRect.left + containerCenterX);
274
- const startPinchY = startY - (containerRect.top + containerCenterY);
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 - pinchImageX * scaleRatio,
280
- y: currentPinchY - pinchImageY * scaleRatio
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: container?.getBoundingClientRect()
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
- setPosition({ x: 0, y: 0 });
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: "center",
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
- maxWidth: "100%",
622
- maxHeight: "100%",
709
+ position: "absolute",
710
+ top: 0,
711
+ left: 0,
623
712
  ...contentStyle
624
713
  };
625
714
  return /* @__PURE__ */ React__default.default.createElement(