uix-image-magnify 0.1.3 → 0.1.4

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/dist/index.cjs CHANGED
@@ -5,18 +5,28 @@ var jsxRuntime = require('react/jsx-runtime');
5
5
 
6
6
  // src/components/ImageMagnify.tsx
7
7
  var clamp = (v, min, max) => Math.max(min, Math.min(max, v));
8
+ var useIsoLayoutEffect = typeof window !== "undefined" ? react.useLayoutEffect : react.useEffect;
8
9
  function useImageMagnify(options = {}) {
9
10
  const {
10
11
  src,
11
12
  zoomSrc,
12
13
  zoomScale = 2,
14
+ minZoomScale = 1.5,
15
+ maxZoomScale = 5,
13
16
  zoomType = "hover",
17
+ mode = "side-by-side",
18
+ dragMode = "follow",
14
19
  lensSize = 150,
20
+ followOffset = 0,
15
21
  zoomPreload = true,
22
+ disabled = false,
23
+ resetOnLeave = false,
16
24
  onZoomStart,
17
- onZoomEnd
25
+ onZoomEnd,
26
+ onZoomChange
18
27
  } = options;
19
28
  const [isZoomed, setIsZoomed] = react.useState(false);
29
+ const [currentScale, setCurrentScale] = react.useState(zoomScale);
20
30
  const isZoomedRef = react.useRef(false);
21
31
  const containerRef = react.useRef(null);
22
32
  const imageRef = react.useRef(null);
@@ -25,18 +35,60 @@ function useImageMagnify(options = {}) {
25
35
  const cursorRef = react.useRef(null);
26
36
  const pointerRef = react.useRef({ x: 0, y: 0 });
27
37
  const rafRef = react.useRef(0);
28
- const lastTouchRef = react.useRef(0);
38
+ const scaleRef = react.useRef(zoomScale);
39
+ const modeRef = react.useRef(mode);
40
+ const lensSizeRef = react.useRef(lensSize);
41
+ const followOffsetRef = react.useRef(followOffset);
42
+ const dragModeRef = react.useRef(dragMode);
43
+ const txRef = react.useRef(0);
44
+ const tyRef = react.useRef(0);
45
+ const dragStartRef = react.useRef(null);
29
46
  const bgImage = zoomSrc || src;
30
- const isRecentTouch = () => Date.now() - lastTouchRef.current < 700;
47
+ useIsoLayoutEffect(() => {
48
+ scaleRef.current = zoomScale;
49
+ setCurrentScale(zoomScale);
50
+ }, [zoomScale]);
51
+ useIsoLayoutEffect(() => {
52
+ modeRef.current = mode;
53
+ }, [mode]);
54
+ useIsoLayoutEffect(() => {
55
+ lensSizeRef.current = lensSize;
56
+ }, [lensSize]);
57
+ useIsoLayoutEffect(() => {
58
+ followOffsetRef.current = followOffset;
59
+ }, [followOffset]);
60
+ useIsoLayoutEffect(() => {
61
+ dragModeRef.current = dragMode;
62
+ }, [dragMode]);
63
+ const updatePointer = react.useCallback((clientX, clientY) => {
64
+ const container = containerRef.current;
65
+ if (!container) return;
66
+ const rect = container.getBoundingClientRect();
67
+ if (!rect.width || !rect.height) return;
68
+ pointerRef.current = {
69
+ x: clamp(clientX - rect.left, 0, rect.width),
70
+ y: clamp(clientY - rect.top, 0, rect.height)
71
+ };
72
+ }, []);
31
73
  const setZoomed = react.useCallback(
32
74
  (z) => {
75
+ if (disabled) return;
33
76
  if (isZoomedRef.current === z) return;
34
77
  isZoomedRef.current = z;
35
78
  setIsZoomed(z);
36
- if (z) onZoomStart?.();
37
- else onZoomEnd?.();
79
+ if (z) {
80
+ onZoomStart?.();
81
+ } else {
82
+ if (zoomType === "wheel") {
83
+ scaleRef.current = zoomScale;
84
+ setCurrentScale(zoomScale);
85
+ }
86
+ txRef.current = 0;
87
+ tyRef.current = 0;
88
+ onZoomEnd?.();
89
+ }
38
90
  },
39
- [onZoomStart, onZoomEnd]
91
+ [disabled, onZoomStart, onZoomEnd, zoomType, zoomScale]
40
92
  );
41
93
  const apply = react.useCallback(() => {
42
94
  const container = containerRef.current;
@@ -44,27 +96,51 @@ function useImageMagnify(options = {}) {
44
96
  const rect = container.getBoundingClientRect();
45
97
  if (!rect.width || !rect.height) return;
46
98
  const { x, y } = pointerRef.current;
47
- const xPct = clamp(x / rect.width, 0, 1);
48
- const yPct = clamp(y / rect.height, 0, 1);
99
+ const scale = scaleRef.current;
100
+ const ls = lensSizeRef.current;
101
+ const md = modeRef.current;
102
+ const fo = followOffsetRef.current;
103
+ const img = imageRef.current;
104
+ if (md === "inner" && img) {
105
+ if (!isZoomedRef.current) {
106
+ img.style.transform = "";
107
+ } else {
108
+ const minTx = rect.width * (1 - scale);
109
+ const minTy = rect.height * (1 - scale);
110
+ let tx;
111
+ let ty;
112
+ if (dragModeRef.current === "pan") {
113
+ tx = clamp(txRef.current, minTx, 0);
114
+ ty = clamp(tyRef.current, minTy, 0);
115
+ } else {
116
+ tx = x * (1 - scale);
117
+ ty = y * (1 - scale);
118
+ tx = clamp(tx, minTx, 0);
119
+ ty = clamp(ty, minTy, 0);
120
+ }
121
+ txRef.current = tx;
122
+ tyRef.current = ty;
123
+ img.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`;
124
+ }
125
+ }
49
126
  const lens = lensRef.current;
50
127
  if (lens) {
51
- const maxX = Math.max(0, rect.width - lensSize);
52
- const maxY = Math.max(0, rect.height - lensSize);
53
- const lx = clamp(x - lensSize / 2, 0, maxX);
54
- const ly = clamp(y - lensSize / 2, 0, maxY);
55
- const cx = lx + lensSize / 2;
56
- const cy = ly + lensSize / 2;
128
+ const maxX = Math.max(0, rect.width - ls);
129
+ const maxY = Math.max(0, rect.height - ls);
130
+ const offset = md === "follow" ? fo : 0;
131
+ const lx = clamp(x - ls / 2 + offset, 0, maxX);
132
+ const ly = clamp(y - ls / 2 + offset, 0, maxY);
57
133
  lens.style.transform = `translate3d(${lx}px, ${ly}px, 0)`;
58
- lens.style.backgroundPosition = `${lensSize / 2 - cx * zoomScale}px ${lensSize / 2 - cy * zoomScale}px`;
134
+ lens.style.backgroundPosition = `${ls / 2 - x * scale}px ${ls / 2 - y * scale}px`;
59
135
  }
60
136
  const pane = paneRef.current;
61
137
  if (pane) {
62
138
  const pw = pane.offsetWidth;
63
139
  const ph = pane.offsetHeight;
64
- const bgW = rect.width * zoomScale;
65
- const bgH = rect.height * zoomScale;
66
- let bgX = pw / 2 - xPct * bgW;
67
- let bgY = ph / 2 - yPct * bgH;
140
+ const bgW = rect.width * scale;
141
+ const bgH = rect.height * scale;
142
+ let bgX = pw / 2 - x / rect.width * bgW;
143
+ let bgY = ph / 2 - y / rect.height * bgH;
68
144
  if (bgW > pw) bgX = clamp(bgX, pw - bgW, 0);
69
145
  else bgX = (pw - bgW) / 2;
70
146
  if (bgH > ph) bgY = clamp(bgY, ph - bgH, 0);
@@ -75,7 +151,7 @@ function useImageMagnify(options = {}) {
75
151
  if (cursor) {
76
152
  cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`;
77
153
  }
78
- }, [zoomScale, lensSize]);
154
+ }, []);
79
155
  const schedule = react.useCallback(() => {
80
156
  if (rafRef.current) return;
81
157
  rafRef.current = requestAnimationFrame(() => {
@@ -83,9 +159,42 @@ function useImageMagnify(options = {}) {
83
159
  apply();
84
160
  });
85
161
  }, [apply]);
162
+ const setScale = react.useCallback(
163
+ (next) => {
164
+ const clamped = clamp(next, minZoomScale, maxZoomScale);
165
+ if (scaleRef.current === clamped) return;
166
+ scaleRef.current = clamped;
167
+ setCurrentScale(clamped);
168
+ onZoomChange?.(clamped);
169
+ const container = containerRef.current;
170
+ if (container) {
171
+ const rect = container.getBoundingClientRect();
172
+ if (rect.width && rect.height) {
173
+ const size = `${rect.width * clamped}px ${rect.height * clamped}px`;
174
+ if (lensRef.current) lensRef.current.style.backgroundSize = size;
175
+ if (paneRef.current) paneRef.current.style.backgroundSize = size;
176
+ }
177
+ }
178
+ schedule();
179
+ },
180
+ [minZoomScale, maxZoomScale, onZoomChange, schedule]
181
+ );
86
182
  react.useEffect(() => {
87
- if (isZoomed) schedule();
88
- }, [isZoomed, schedule]);
183
+ if (isZoomed) {
184
+ schedule();
185
+ } else if (mode === "inner" && imageRef.current) {
186
+ imageRef.current.style.transform = "";
187
+ txRef.current = 0;
188
+ tyRef.current = 0;
189
+ }
190
+ }, [isZoomed, schedule, mode]);
191
+ react.useEffect(() => {
192
+ if (disabled && isZoomedRef.current) {
193
+ isZoomedRef.current = false;
194
+ setIsZoomed(false);
195
+ onZoomEnd?.();
196
+ }
197
+ }, [disabled, onZoomEnd]);
89
198
  react.useEffect(() => {
90
199
  if (!bgImage) return;
91
200
  const url = `url("${bgImage}")`;
@@ -98,7 +207,8 @@ function useImageMagnify(options = {}) {
98
207
  const update = () => {
99
208
  const rect = container.getBoundingClientRect();
100
209
  if (!rect.width || !rect.height) return;
101
- const size = `${rect.width * zoomScale}px ${rect.height * zoomScale}px`;
210
+ const s = scaleRef.current;
211
+ const size = `${rect.width * s}px ${rect.height * s}px`;
102
212
  if (lensRef.current) lensRef.current.style.backgroundSize = size;
103
213
  if (paneRef.current) paneRef.current.style.backgroundSize = size;
104
214
  };
@@ -106,7 +216,7 @@ function useImageMagnify(options = {}) {
106
216
  const ro = new ResizeObserver(update);
107
217
  ro.observe(container);
108
218
  return () => ro.disconnect();
109
- }, [zoomScale, isZoomed]);
219
+ }, [isZoomed, zoomScale]);
110
220
  react.useEffect(() => {
111
221
  if (!zoomPreload || !bgImage) return;
112
222
  const img = new Image();
@@ -118,85 +228,155 @@ function useImageMagnify(options = {}) {
118
228
  },
119
229
  []
120
230
  );
121
- const updatePointer = (clientX, clientY) => {
231
+ react.useEffect(() => {
232
+ if (zoomType !== "wheel" || disabled) return;
122
233
  const container = containerRef.current;
123
234
  if (!container) return;
124
- const rect = container.getBoundingClientRect();
125
- if (!rect.width || !rect.height) return;
126
- pointerRef.current = {
127
- x: clamp(clientX - rect.left, 0, rect.width),
128
- y: clamp(clientY - rect.top, 0, rect.height)
235
+ const onWheel = (e) => {
236
+ e.preventDefault();
237
+ updatePointer(e.clientX, e.clientY);
238
+ const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
239
+ setScale(scaleRef.current * factor);
240
+ setZoomed(true);
241
+ };
242
+ container.addEventListener("wheel", onWheel, { passive: false });
243
+ return () => container.removeEventListener("wheel", onWheel);
244
+ }, [zoomType, disabled, setScale, setZoomed, updatePointer]);
245
+ const beginDrag = (e) => {
246
+ if (modeRef.current !== "inner" || dragModeRef.current !== "pan") return;
247
+ if (!isZoomedRef.current) return;
248
+ const el = e.currentTarget;
249
+ el.setPointerCapture?.(e.pointerId);
250
+ const rect = el.getBoundingClientRect();
251
+ const s = scaleRef.current;
252
+ dragStartRef.current = {
253
+ cursorX: e.clientX,
254
+ cursorY: e.clientY,
255
+ tx: clamp(txRef.current, rect.width * (1 - s), 0),
256
+ ty: clamp(tyRef.current, rect.height * (1 - s), 0)
129
257
  };
130
258
  };
131
259
  const handlers = {
132
- onMouseEnter: (e) => {
133
- if (isRecentTouch()) return;
260
+ onPointerEnter: (e) => {
261
+ if (disabled || e.pointerType === "touch") return;
134
262
  updatePointer(e.clientX, e.clientY);
135
263
  if (zoomType === "hover") setZoomed(true);
136
264
  schedule();
137
265
  },
138
- onMouseMove: (e) => {
139
- if (isRecentTouch()) return;
140
- updatePointer(e.clientX, e.clientY);
141
- if (isZoomedRef.current) schedule();
142
- },
143
- onMouseLeave: () => {
144
- if (isRecentTouch()) return;
266
+ onPointerLeave: (e) => {
267
+ if (disabled || e.pointerType === "touch") return;
268
+ if (dragStartRef.current) return;
145
269
  if (zoomType === "hover") setZoomed(false);
270
+ else if (resetOnLeave) setZoomed(false);
146
271
  },
147
- onClick: (e) => {
148
- if (isRecentTouch()) return;
149
- if (zoomType !== "click") return;
272
+ onPointerDown: (e) => {
273
+ if (disabled) return;
150
274
  updatePointer(e.clientX, e.clientY);
151
- setZoomed(!isZoomedRef.current);
275
+ const isTouch = e.pointerType === "touch";
276
+ if (isZoomedRef.current) {
277
+ beginDrag(e);
278
+ if (dragStartRef.current) return;
279
+ }
280
+ if (zoomType === "hold") {
281
+ setZoomed(true);
282
+ } else if (isTouch) {
283
+ setZoomed(!isZoomedRef.current);
284
+ } else if (zoomType === "click") {
285
+ setZoomed(!isZoomedRef.current);
286
+ } else if (zoomType === "wheel") {
287
+ setZoomed(!isZoomedRef.current);
288
+ }
152
289
  schedule();
153
290
  },
154
- onDoubleClick: (e) => {
155
- if (isRecentTouch()) return;
156
- if (zoomType !== "double-click") return;
291
+ onPointerMove: (e) => {
292
+ if (disabled) return;
293
+ if (dragStartRef.current) {
294
+ const container = containerRef.current;
295
+ if (!container) return;
296
+ const rect = container.getBoundingClientRect();
297
+ const s = scaleRef.current;
298
+ const dx = e.clientX - dragStartRef.current.cursorX;
299
+ const dy = e.clientY - dragStartRef.current.cursorY;
300
+ txRef.current = clamp(
301
+ dragStartRef.current.tx + dx,
302
+ rect.width * (1 - s),
303
+ 0
304
+ );
305
+ tyRef.current = clamp(
306
+ dragStartRef.current.ty + dy,
307
+ rect.height * (1 - s),
308
+ 0
309
+ );
310
+ schedule();
311
+ return;
312
+ }
157
313
  updatePointer(e.clientX, e.clientY);
158
- setZoomed(!isZoomedRef.current);
159
- schedule();
314
+ if (isZoomedRef.current) schedule();
160
315
  },
161
- onTouchStart: (e) => {
162
- lastTouchRef.current = Date.now();
163
- const t = e.touches[0];
164
- if (!t) return;
165
- updatePointer(t.clientX, t.clientY);
166
- if (zoomType === "hover") setZoomed(true);
167
- else setZoomed(!isZoomedRef.current);
168
- schedule();
316
+ onPointerUp: (e) => {
317
+ if (dragStartRef.current) {
318
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
319
+ dragStartRef.current = null;
320
+ }
321
+ if (zoomType === "hold") setZoomed(false);
169
322
  },
170
- onTouchMove: (e) => {
171
- lastTouchRef.current = Date.now();
172
- const t = e.touches[0];
173
- if (!t) return;
174
- updatePointer(t.clientX, t.clientY);
175
- schedule();
323
+ onPointerCancel: () => {
324
+ dragStartRef.current = null;
325
+ if (zoomType === "hold") setZoomed(false);
176
326
  },
177
- onTouchEnd: () => {
178
- lastTouchRef.current = Date.now();
179
- if (zoomType === "hover") setZoomed(false);
327
+ onDoubleClick: (e) => {
328
+ if (disabled || zoomType !== "double-click") return;
329
+ updatePointer(e.clientX, e.clientY);
330
+ setZoomed(!isZoomedRef.current);
331
+ schedule();
180
332
  },
181
333
  onKeyDown: (e) => {
334
+ if (disabled) return;
182
335
  const container = containerRef.current;
183
336
  if (!container) return;
184
337
  const rect = container.getBoundingClientRect();
185
338
  const step = 20;
186
- const { x, y } = pointerRef.current;
339
+ if (!isZoomedRef.current && pointerRef.current.x === 0 && pointerRef.current.y === 0) {
340
+ pointerRef.current.x = rect.width / 2;
341
+ pointerRef.current.y = rect.height / 2;
342
+ }
187
343
  let handled = true;
188
344
  switch (e.key) {
189
345
  case "ArrowLeft":
190
- pointerRef.current.x = clamp(x - step, 0, rect.width);
346
+ pointerRef.current.x = clamp(
347
+ pointerRef.current.x - step,
348
+ 0,
349
+ rect.width
350
+ );
191
351
  break;
192
352
  case "ArrowRight":
193
- pointerRef.current.x = clamp(x + step, 0, rect.width);
353
+ pointerRef.current.x = clamp(
354
+ pointerRef.current.x + step,
355
+ 0,
356
+ rect.width
357
+ );
194
358
  break;
195
359
  case "ArrowUp":
196
- pointerRef.current.y = clamp(y - step, 0, rect.height);
360
+ pointerRef.current.y = clamp(
361
+ pointerRef.current.y - step,
362
+ 0,
363
+ rect.height
364
+ );
197
365
  break;
198
366
  case "ArrowDown":
199
- pointerRef.current.y = clamp(y + step, 0, rect.height);
367
+ pointerRef.current.y = clamp(
368
+ pointerRef.current.y + step,
369
+ 0,
370
+ rect.height
371
+ );
372
+ break;
373
+ case "+":
374
+ case "=":
375
+ if (zoomType === "wheel") setScale(scaleRef.current * 1.2);
376
+ break;
377
+ case "-":
378
+ case "_":
379
+ if (zoomType === "wheel") setScale(scaleRef.current / 1.2);
200
380
  break;
201
381
  case "Enter":
202
382
  case " ":
@@ -214,7 +394,7 @@ function useImageMagnify(options = {}) {
214
394
  }
215
395
  },
216
396
  onFocus: () => {
217
- if (isRecentTouch()) return;
397
+ if (disabled) return;
218
398
  if (zoomType === "hover") setZoomed(true);
219
399
  },
220
400
  onBlur: () => {
@@ -223,6 +403,8 @@ function useImageMagnify(options = {}) {
223
403
  };
224
404
  return {
225
405
  isZoomed,
406
+ zoomScale: currentScale,
407
+ bgImage,
226
408
  containerRef,
227
409
  imageRef,
228
410
  lensRef,
@@ -253,18 +435,29 @@ var ImageMagnify = react.forwardRef(
253
435
  lensSize = 150,
254
436
  lensShape = "circle",
255
437
  zoomType = "hover",
438
+ disabled,
439
+ imageProps,
440
+ onLoad,
441
+ onError,
256
442
  ...hookOptions
257
443
  } = props;
258
444
  const {
259
445
  isZoomed,
446
+ bgImage,
260
447
  containerRef,
261
448
  imageRef,
262
449
  lensRef,
263
450
  paneRef,
264
451
  cursorRef,
265
452
  handlers
266
- } = useImageMagnify({ src, zoomType, lensSize, ...hookOptions });
267
- const bgImage = props.zoomSrc || src;
453
+ } = useImageMagnify({
454
+ src,
455
+ zoomType,
456
+ lensSize,
457
+ mode,
458
+ disabled,
459
+ ...hookOptions
460
+ });
268
461
  const paneOffset = {};
269
462
  if (panePosition === "right") {
270
463
  paneOffset.left = "calc(100% + 12px)";
@@ -279,6 +472,11 @@ var ImageMagnify = react.forwardRef(
279
472
  paneOffset.left = 0;
280
473
  paneOffset.top = "calc(100% + 12px)";
281
474
  }
475
+ const isInner = mode === "inner";
476
+ const showLens = (mode === "lens" || mode === "follow") && isZoomed;
477
+ const showPane = mode === "side-by-side" && isZoomed;
478
+ const showCursor = mode === "side-by-side" && isZoomed;
479
+ const cursorStyle = disabled ? "default" : zoomType === "hover" || zoomType === "hold" || zoomType === "wheel" ? "crosshair" : isZoomed ? "zoom-out" : "zoom-in";
282
480
  return /* @__PURE__ */ jsxRuntime.jsxs(
283
481
  "div",
284
482
  {
@@ -299,12 +497,14 @@ var ImageMagnify = react.forwardRef(
299
497
  tabIndex: 0,
300
498
  role: "img",
301
499
  "aria-label": alt,
500
+ "aria-disabled": disabled || void 0,
302
501
  style: {
303
502
  position: "relative",
304
503
  overflow: "hidden",
305
504
  width: "100%",
306
- cursor: zoomType === "hover" ? "crosshair" : isZoomed ? "zoom-out" : "zoom-in",
307
- touchAction: "none",
505
+ cursor: cursorStyle,
506
+ // Only block scrolling while zoomed so page scrolling still works.
507
+ touchAction: isZoomed ? "none" : "pan-y",
308
508
  outline: "none",
309
509
  lineHeight: 0
310
510
  },
@@ -313,27 +513,34 @@ var ImageMagnify = react.forwardRef(
313
513
  /* @__PURE__ */ jsxRuntime.jsx(
314
514
  "img",
315
515
  {
516
+ ...imageProps,
316
517
  ref: imageRef,
317
518
  src,
318
- alt,
519
+ alt: "",
520
+ "aria-hidden": "true",
319
521
  loading: lazy ? "lazy" : void 0,
320
522
  draggable: false,
321
523
  className: imageClassName,
524
+ onLoad,
525
+ onError,
322
526
  style: {
323
527
  display: "block",
324
528
  width: "100%",
325
529
  height: "auto",
326
530
  userSelect: "none",
327
531
  pointerEvents: "none",
532
+ transformOrigin: "0 0",
533
+ willChange: isInner ? "transform" : void 0,
328
534
  ...imageStyle
329
535
  }
330
536
  }
331
537
  ),
332
- mode === "lens" && isZoomed && /* @__PURE__ */ jsxRuntime.jsx(
538
+ showLens && /* @__PURE__ */ jsxRuntime.jsx(
333
539
  "div",
334
540
  {
335
541
  ref: lensRef,
336
542
  className: lensClassName,
543
+ "aria-hidden": "true",
337
544
  style: {
338
545
  position: "absolute",
339
546
  top: 0,
@@ -351,10 +558,11 @@ var ImageMagnify = react.forwardRef(
351
558
  }
352
559
  }
353
560
  ),
354
- mode === "side-by-side" && isZoomed && /* @__PURE__ */ jsxRuntime.jsx(
561
+ showCursor && /* @__PURE__ */ jsxRuntime.jsx(
355
562
  "div",
356
563
  {
357
564
  ref: cursorRef,
565
+ "aria-hidden": "true",
358
566
  style: {
359
567
  position: "absolute",
360
568
  top: 0,
@@ -375,7 +583,7 @@ var ImageMagnify = react.forwardRef(
375
583
  ]
376
584
  }
377
585
  ),
378
- mode === "side-by-side" && isZoomed && /* @__PURE__ */ jsxRuntime.jsx(
586
+ showPane && /* @__PURE__ */ jsxRuntime.jsx(
379
587
  "div",
380
588
  {
381
589
  ref: paneRef,