@jekrch/react-viewport-lightbox 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/dist/index.cjs ADDED
@@ -0,0 +1,1078 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var jsxRuntime = require('react/jsx-runtime');
5
+
6
+ // src/components/ImageViewer.tsx
7
+
8
+ // src/hooks/math.ts
9
+ function clampTranslate(x, y, scale, baseDims, viewport) {
10
+ if (scale <= 1) return { x: 0, y: 0 };
11
+ const { width: baseW, height: baseH } = baseDims;
12
+ if (baseW === 0 || baseH === 0) return { x: 0, y: 0 };
13
+ const scaledHalfW = baseW * scale / 2;
14
+ const scaledHalfH = baseH * scale / 2;
15
+ const vpHalfW = viewport.width / 2;
16
+ const vpHalfH = viewport.height / 2;
17
+ const maxX = Math.max(0, scaledHalfW - vpHalfW);
18
+ const maxY = Math.max(0, scaledHalfH - vpHalfH);
19
+ return {
20
+ x: Math.max(-maxX, Math.min(maxX, x)),
21
+ y: Math.max(-maxY, Math.min(maxY, y))
22
+ };
23
+ }
24
+ function resolveSlideDirection({
25
+ offset,
26
+ elapsedMs,
27
+ viewportWidth,
28
+ hasPrev,
29
+ hasNext,
30
+ distanceThreshold = 0.25,
31
+ velocityThreshold = 0.4
32
+ }) {
33
+ const velocity = Math.abs(offset) / Math.max(elapsedMs, 1);
34
+ const threshold = viewportWidth * distanceThreshold;
35
+ const committed = Math.abs(offset) > threshold || velocity > velocityThreshold;
36
+ if (offset > 0 && hasPrev && committed) return "prev";
37
+ if (offset < 0 && hasNext && committed) return "next";
38
+ return "snap";
39
+ }
40
+
41
+ // src/hooks/useImageZoomPan.ts
42
+ var MIN_SCALE = 1;
43
+ var MAX_SCALE = 5;
44
+ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true) {
45
+ const imgRef = react.useRef(null);
46
+ const [displayScale, setDisplayScale] = react.useState(1);
47
+ const transformRef = react.useRef({ scale: 1, x: 0, y: 0 });
48
+ const baseDimsRef = react.useRef({ width: 0, height: 0 });
49
+ const applyTransform = react.useCallback(
50
+ (t, animate = false) => {
51
+ const wrapper = imgWrapperRef.current;
52
+ if (!wrapper) return;
53
+ wrapper.style.transition = animate ? "transform 0.2s ease-out" : "none";
54
+ if (t.scale <= 1) {
55
+ wrapper.style.transform = "none";
56
+ wrapper.style.position = "";
57
+ wrapper.style.inset = "";
58
+ wrapper.style.zIndex = "";
59
+ wrapper.style.backgroundColor = "";
60
+ wrapper.style.cursor = "";
61
+ } else {
62
+ wrapper.style.transform = `scale(${t.scale}) translate(${t.x / t.scale}px, ${t.y / t.scale}px)`;
63
+ wrapper.style.position = "absolute";
64
+ wrapper.style.inset = "0";
65
+ wrapper.style.zIndex = "30";
66
+ wrapper.style.backgroundColor = "black";
67
+ wrapper.style.cursor = "grab";
68
+ }
69
+ setDisplayScale(t.scale);
70
+ },
71
+ [imgWrapperRef]
72
+ );
73
+ const setTransform = react.useCallback(
74
+ (t, animate = false) => {
75
+ transformRef.current = t;
76
+ applyTransform(t, animate);
77
+ setDisplayScale(t.scale);
78
+ },
79
+ [applyTransform]
80
+ );
81
+ const resetTransform = react.useCallback(() => {
82
+ setTransform({ scale: 1, x: 0, y: 0 }, true);
83
+ }, [setTransform]);
84
+ const measureBaseDims = react.useCallback(() => {
85
+ const img = imgRef.current;
86
+ if (!img) return;
87
+ baseDimsRef.current = { width: img.offsetWidth, height: img.offsetHeight };
88
+ }, []);
89
+ const clampTranslate2 = react.useCallback(
90
+ (x, y, scale) => clampTranslate(x, y, scale, baseDimsRef.current, {
91
+ width: window.innerWidth,
92
+ height: window.innerHeight
93
+ }),
94
+ []
95
+ );
96
+ react.useLayoutEffect(() => {
97
+ const wrapper = imgWrapperRef.current;
98
+ if (wrapper) {
99
+ wrapper.style.transition = "none";
100
+ wrapper.style.transform = "none";
101
+ wrapper.style.position = "";
102
+ wrapper.style.inset = "";
103
+ wrapper.style.zIndex = "";
104
+ wrapper.style.backgroundColor = "";
105
+ wrapper.style.cursor = "";
106
+ }
107
+ transformRef.current = { scale: 1, x: 0, y: 0 };
108
+ }, [currentIndex, imgWrapperRef]);
109
+ react.useEffect(() => {
110
+ setDisplayScale(1);
111
+ }, [currentIndex]);
112
+ react.useEffect(() => {
113
+ if (!enabled) return;
114
+ const wrapper = imgWrapperRef.current;
115
+ if (!wrapper) return;
116
+ const handleWheel = (e) => {
117
+ e.preventDefault();
118
+ if (baseDimsRef.current.width === 0) {
119
+ const img = imgRef.current;
120
+ if (img) baseDimsRef.current = { width: img.offsetWidth, height: img.offsetHeight };
121
+ }
122
+ const t = transformRef.current;
123
+ let dy = e.deltaY;
124
+ if (e.deltaMode === 1) dy *= 16;
125
+ if (e.deltaMode === 2) dy *= 100;
126
+ const normalized = Math.max(-100, Math.min(100, dy));
127
+ const step = -(normalized / 100) * 0.05;
128
+ const factor = 1 + step;
129
+ const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, t.scale * factor));
130
+ const clamped = nextScale <= 1 ? { x: 0, y: 0 } : clampTranslate2(t.x, t.y, nextScale);
131
+ setTransform({ scale: nextScale, ...clamped });
132
+ };
133
+ wrapper.addEventListener("wheel", handleWheel, { passive: false });
134
+ return () => wrapper.removeEventListener("wheel", handleWheel);
135
+ }, [imgWrapperRef, setTransform, clampTranslate2, enabled]);
136
+ const handleDoubleClick = react.useCallback(
137
+ (e) => {
138
+ if (!enabled) return;
139
+ e.stopPropagation();
140
+ if (baseDimsRef.current.width === 0) {
141
+ const img = imgRef.current;
142
+ if (img) baseDimsRef.current = { width: img.offsetWidth, height: img.offsetHeight };
143
+ }
144
+ if (transformRef.current.scale > 1) {
145
+ resetTransform();
146
+ } else {
147
+ setTransform({ scale: 1.8, x: 0, y: 0 }, true);
148
+ }
149
+ },
150
+ [resetTransform, setTransform, enabled]
151
+ );
152
+ return {
153
+ imgRef,
154
+ displayScale,
155
+ isZoomed: displayScale > 1,
156
+ transformRef,
157
+ baseDimsRef,
158
+ resetTransform,
159
+ setTransform,
160
+ applyTransform,
161
+ clampTranslate: clampTranslate2,
162
+ measureBaseDims,
163
+ handleDoubleClick
164
+ };
165
+ }
166
+ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart) {
167
+ const slideTrackRef = react.useRef(null);
168
+ const swipeOffsetRef = react.useRef(0);
169
+ const [swipeOffset, setSwipeOffset] = react.useState(0);
170
+ const [slideAnimating, setSlideAnimating] = react.useState(false);
171
+ const [slideActive, setSlideActive] = react.useState(false);
172
+ const commitLockRef = react.useRef(false);
173
+ const hasPrev = currentIndex > 0;
174
+ const hasNext = currentIndex < items.length - 1;
175
+ const applySlideOffset = react.useCallback((offset, animate = false) => {
176
+ swipeOffsetRef.current = offset;
177
+ const track = slideTrackRef.current;
178
+ if (track) {
179
+ track.style.transition = animate ? "transform 0.28s cubic-bezier(0.2, 0, 0, 1)" : "none";
180
+ track.style.transform = `translateX(${offset}px)`;
181
+ }
182
+ setSwipeOffset(offset);
183
+ }, []);
184
+ const snapBack = react.useCallback(() => {
185
+ setSlideAnimating(true);
186
+ applySlideOffset(0, true);
187
+ const track = slideTrackRef.current;
188
+ let done = false;
189
+ const onEnd = () => {
190
+ if (done) return;
191
+ done = true;
192
+ track?.removeEventListener("transitionend", onEnd);
193
+ setSlideAnimating(false);
194
+ setSlideActive(false);
195
+ };
196
+ if (track) {
197
+ track.addEventListener("transitionend", onEnd, { once: true });
198
+ setTimeout(onEnd, 350);
199
+ }
200
+ }, [applySlideOffset]);
201
+ const readyRef = react.useRef(true);
202
+ const commitSlide = react.useCallback(
203
+ (direction) => {
204
+ if (commitLockRef.current || !readyRef.current) return;
205
+ commitLockRef.current = true;
206
+ readyRef.current = false;
207
+ const vw = window.innerWidth;
208
+ const targetOffset = direction === "prev" ? vw : -vw;
209
+ setSlideActive(true);
210
+ setSlideAnimating(true);
211
+ onSlideStart?.(direction);
212
+ requestAnimationFrame(() => {
213
+ applySlideOffset(targetOffset, true);
214
+ const track = slideTrackRef.current;
215
+ let cleaned = false;
216
+ const cleanup = () => {
217
+ if (cleaned) return;
218
+ cleaned = true;
219
+ track?.removeEventListener("transitionend", onTransitionEnd);
220
+ const newIndex = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
221
+ if (newIndex < 0 || newIndex >= items.length) {
222
+ commitLockRef.current = false;
223
+ return;
224
+ }
225
+ const newItem = items[newIndex];
226
+ const preload = new Image();
227
+ preload.src = newItem.src;
228
+ const doNavigate = () => onNavigate(newIndex);
229
+ const timeout = setTimeout(doNavigate, 300);
230
+ preload.decode().then(() => {
231
+ clearTimeout(timeout);
232
+ doNavigate();
233
+ }).catch(() => {
234
+ clearTimeout(timeout);
235
+ doNavigate();
236
+ });
237
+ };
238
+ const onTransitionEnd = () => cleanup();
239
+ if (track) {
240
+ track.addEventListener("transitionend", onTransitionEnd, { once: true });
241
+ setTimeout(cleanup, 400);
242
+ }
243
+ });
244
+ },
245
+ [applySlideOffset, currentIndex, items, onNavigate, onSlideStart]
246
+ );
247
+ const resolveSlide = react.useCallback(
248
+ (gestureStartTime) => {
249
+ const action = resolveSlideDirection({
250
+ offset: swipeOffsetRef.current,
251
+ elapsedMs: Date.now() - gestureStartTime,
252
+ viewportWidth: window.innerWidth,
253
+ hasPrev,
254
+ hasNext
255
+ });
256
+ if (action === "prev") commitSlide("prev");
257
+ else if (action === "next") commitSlide("next");
258
+ else snapBack();
259
+ },
260
+ [hasPrev, hasNext, commitSlide, snapBack]
261
+ );
262
+ react.useLayoutEffect(() => {
263
+ const track = slideTrackRef.current;
264
+ if (track) {
265
+ track.style.transition = "none";
266
+ track.offsetHeight;
267
+ track.style.transform = "translateX(0px)";
268
+ }
269
+ swipeOffsetRef.current = 0;
270
+ commitLockRef.current = false;
271
+ }, [currentIndex]);
272
+ react.useEffect(() => {
273
+ setSwipeOffset(0);
274
+ setSlideAnimating(false);
275
+ setSlideActive(false);
276
+ readyRef.current = true;
277
+ }, [currentIndex]);
278
+ return {
279
+ slideTrackRef,
280
+ slideActive,
281
+ slideAnimating,
282
+ swipeOffset,
283
+ swipeOffsetRef,
284
+ commitLockRef,
285
+ applySlideOffset,
286
+ commitSlide,
287
+ snapBack,
288
+ resolveSlide,
289
+ setSlideActive
290
+ };
291
+ }
292
+ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true) {
293
+ const { transformRef, clampTranslate: clampTranslate2, setTransform, applyTransform, resetTransform } = zoomPan;
294
+ const { applySlideOffset, resolveSlide, snapBack, setSlideActive, swipeOffsetRef } = slide;
295
+ const panRef = react.useRef({
296
+ isDragging: false,
297
+ pointerStart: { x: 0, y: 0 },
298
+ translateStart: { x: 0, y: 0 },
299
+ pinchStartDist: null,
300
+ pinchStartScale: 1,
301
+ pinchMidpoint: null,
302
+ lastTouchPos: null
303
+ });
304
+ const slideRef = react.useRef({
305
+ active: false,
306
+ startX: 0,
307
+ startY: 0,
308
+ startTime: 0,
309
+ locked: false,
310
+ rejected: false
311
+ });
312
+ const lastTapRef = react.useRef({
313
+ time: 0,
314
+ x: 0,
315
+ y: 0
316
+ });
317
+ const beginSlide = react.useCallback(
318
+ (x, y) => {
319
+ setSlideActive(true);
320
+ const sg = slideRef.current;
321
+ sg.active = true;
322
+ sg.startX = x;
323
+ sg.startY = y;
324
+ sg.startTime = Date.now();
325
+ sg.locked = false;
326
+ sg.rejected = false;
327
+ },
328
+ [setSlideActive]
329
+ );
330
+ const updateSlide = react.useCallback(
331
+ (clientX, clientY, lockThreshold, angleBias) => {
332
+ const sg = slideRef.current;
333
+ if (!sg.active || sg.rejected) return;
334
+ const dx = clientX - sg.startX;
335
+ const dy = clientY - sg.startY;
336
+ if (!sg.locked) {
337
+ const absDx = Math.abs(dx);
338
+ const absDy = Math.abs(dy);
339
+ if (absDx < lockThreshold && absDy < lockThreshold) return;
340
+ if (absDy > absDx * angleBias) {
341
+ sg.rejected = true;
342
+ return;
343
+ }
344
+ sg.locked = true;
345
+ }
346
+ let offset = dx;
347
+ if (offset > 0 && !hasPrev || offset < 0 && !hasNext) {
348
+ offset *= 0.2;
349
+ }
350
+ applySlideOffset(offset);
351
+ },
352
+ [hasPrev, hasNext, applySlideOffset]
353
+ );
354
+ const endSlide = react.useCallback(
355
+ (allowResolve) => {
356
+ const sg = slideRef.current;
357
+ if (sg.active && sg.locked && !sg.rejected && allowResolve) {
358
+ const startTime = sg.startTime;
359
+ sg.active = false;
360
+ resolveSlide(startTime);
361
+ } else {
362
+ sg.active = false;
363
+ if (swipeOffsetRef.current === 0) setSlideActive(false);
364
+ }
365
+ },
366
+ [resolveSlide, setSlideActive, swipeOffsetRef]
367
+ );
368
+ const handlePointerDown = react.useCallback(
369
+ (e) => {
370
+ if (e.pointerType === "touch") return;
371
+ if (transformRef.current.scale > 1) {
372
+ e.preventDefault();
373
+ const p = panRef.current;
374
+ p.isDragging = true;
375
+ p.pointerStart = { x: e.clientX, y: e.clientY };
376
+ p.translateStart = { x: transformRef.current.x, y: transformRef.current.y };
377
+ } else {
378
+ beginSlide(e.clientX, e.clientY);
379
+ }
380
+ },
381
+ [transformRef, beginSlide]
382
+ );
383
+ const handlePointerMove = react.useCallback(
384
+ (e) => {
385
+ if (e.pointerType === "touch") return;
386
+ const p = panRef.current;
387
+ if (p.isDragging && transformRef.current.scale > 1) {
388
+ const dx = e.clientX - p.pointerStart.x;
389
+ const dy = e.clientY - p.pointerStart.y;
390
+ const t = transformRef.current;
391
+ const clamped = clampTranslate2(p.translateStart.x + dx, p.translateStart.y + dy, t.scale);
392
+ setTransform({ scale: t.scale, ...clamped });
393
+ return;
394
+ }
395
+ updateSlide(e.clientX, e.clientY, 4, 1);
396
+ },
397
+ [transformRef, clampTranslate2, setTransform, updateSlide]
398
+ );
399
+ const handlePointerUp = react.useCallback(
400
+ (e) => {
401
+ if (e.pointerType === "touch") return;
402
+ panRef.current.isDragging = false;
403
+ endSlide(true);
404
+ },
405
+ [endSlide]
406
+ );
407
+ const handleTouchStart = react.useCallback(
408
+ (e) => {
409
+ const p = panRef.current;
410
+ if (e.touches.length === 2 && zoomEnabled) {
411
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
412
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
413
+ p.pinchStartDist = Math.hypot(dx, dy);
414
+ p.pinchStartScale = transformRef.current.scale;
415
+ p.pinchMidpoint = {
416
+ x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
417
+ y: (e.touches[0].clientY + e.touches[1].clientY) / 2
418
+ };
419
+ p.lastTouchPos = null;
420
+ if (slideRef.current.active) {
421
+ slideRef.current.active = false;
422
+ snapBack();
423
+ }
424
+ } else if (e.touches.length === 1) {
425
+ if (transformRef.current.scale > 1) {
426
+ p.lastTouchPos = {
427
+ x: e.touches[0].clientX,
428
+ y: e.touches[0].clientY
429
+ };
430
+ } else {
431
+ beginSlide(e.touches[0].clientX, e.touches[0].clientY);
432
+ }
433
+ }
434
+ },
435
+ [transformRef, snapBack, beginSlide, zoomEnabled]
436
+ );
437
+ const handleTouchMove = react.useCallback(
438
+ (e) => {
439
+ const p = panRef.current;
440
+ if (e.touches.length === 2 && p.pinchStartDist !== null) {
441
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
442
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
443
+ const dist = Math.hypot(dx, dy);
444
+ const ratio = dist / p.pinchStartDist;
445
+ const nextScale = Math.min(5, Math.max(1, p.pinchStartScale * ratio));
446
+ const t = transformRef.current;
447
+ const clamped = nextScale <= 1 ? { x: 0, y: 0 } : clampTranslate2(t.x, t.y, nextScale);
448
+ const next = { scale: nextScale, ...clamped };
449
+ transformRef.current = next;
450
+ applyTransform(next);
451
+ } else if (e.touches.length === 1 && p.lastTouchPos && transformRef.current.scale > 1) {
452
+ const touch = e.touches[0];
453
+ const dx = touch.clientX - p.lastTouchPos.x;
454
+ const dy = touch.clientY - p.lastTouchPos.y;
455
+ p.lastTouchPos = { x: touch.clientX, y: touch.clientY };
456
+ const t = transformRef.current;
457
+ const clamped = clampTranslate2(t.x + dx, t.y + dy, t.scale);
458
+ const next = { scale: t.scale, ...clamped };
459
+ transformRef.current = next;
460
+ applyTransform(next);
461
+ } else if (e.touches.length === 1) {
462
+ const touch = e.touches[0];
463
+ updateSlide(touch.clientX, touch.clientY, 6, 0.8);
464
+ }
465
+ },
466
+ [transformRef, clampTranslate2, applyTransform, updateSlide]
467
+ );
468
+ const handleTouchEnd = react.useCallback(
469
+ (e) => {
470
+ const p = panRef.current;
471
+ const wasPinch = p.pinchStartDist !== null;
472
+ p.pinchStartDist = null;
473
+ p.pinchMidpoint = null;
474
+ if (e.touches.length === 0 && transformRef.current.scale <= 1) {
475
+ const sg2 = slideRef.current;
476
+ if (sg2.active && sg2.locked && !sg2.rejected) {
477
+ const startTime = sg2.startTime;
478
+ sg2.active = false;
479
+ resolveSlide(startTime);
480
+ transformRef.current = { scale: 1, x: 0, y: 0 };
481
+ return;
482
+ }
483
+ sg2.active = false;
484
+ resetTransform();
485
+ }
486
+ if (e.touches.length === 1 && transformRef.current.scale > 1) {
487
+ p.lastTouchPos = {
488
+ x: e.touches[0].clientX,
489
+ y: e.touches[0].clientY
490
+ };
491
+ } else {
492
+ p.lastTouchPos = null;
493
+ }
494
+ const sg = slideRef.current;
495
+ if (zoomEnabled && e.touches.length === 0 && e.changedTouches.length === 1 && !wasPinch && !sg.locked) {
496
+ const touch = e.changedTouches[0];
497
+ const now = Date.now();
498
+ const last = lastTapRef.current;
499
+ const timeDelta = now - last.time;
500
+ const distDelta = Math.hypot(touch.clientX - last.x, touch.clientY - last.y);
501
+ if (timeDelta < 300 && distDelta < 30) {
502
+ lastTapRef.current = { time: 0, x: 0, y: 0 };
503
+ if (transformRef.current.scale > 1) {
504
+ resetTransform();
505
+ } else {
506
+ setTransform({ scale: 2.5, x: 0, y: 0 }, true);
507
+ }
508
+ } else {
509
+ lastTapRef.current = { time: now, x: touch.clientX, y: touch.clientY };
510
+ }
511
+ }
512
+ if (e.touches.length === 0 && sg.active && !sg.locked) {
513
+ sg.active = false;
514
+ if (swipeOffsetRef.current === 0) setSlideActive(false);
515
+ }
516
+ },
517
+ [
518
+ transformRef,
519
+ resetTransform,
520
+ setTransform,
521
+ resolveSlide,
522
+ setSlideActive,
523
+ swipeOffsetRef,
524
+ zoomEnabled
525
+ ]
526
+ );
527
+ return {
528
+ handlePointerDown,
529
+ handlePointerMove,
530
+ handlePointerUp,
531
+ handleTouchStart,
532
+ handleTouchMove,
533
+ handleTouchEnd
534
+ };
535
+ }
536
+ function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
537
+ const [topBarH, setTopBarH] = react.useState(0);
538
+ const [bottomBarH, setBottomBarH] = react.useState(0);
539
+ react.useEffect(() => {
540
+ const measure = () => {
541
+ if (topBarRef.current) setTopBarH(topBarRef.current.offsetHeight);
542
+ if (bottomBarRef.current) setBottomBarH(bottomBarRef.current.offsetHeight);
543
+ };
544
+ measure();
545
+ const ro = new ResizeObserver(measure);
546
+ if (topBarRef.current) ro.observe(topBarRef.current);
547
+ if (bottomBarRef.current) ro.observe(bottomBarRef.current);
548
+ return () => ro.disconnect();
549
+ }, [measureKey]);
550
+ return { topBarH, bottomBarH };
551
+ }
552
+ var lockCount = 0;
553
+ var previousOverflow = "";
554
+ var previousPaddingRight = "";
555
+ function useBodyScrollLock(isLocked) {
556
+ react.useEffect(() => {
557
+ if (!isLocked) return;
558
+ if (typeof document === "undefined") return;
559
+ if (lockCount === 0) {
560
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
561
+ previousOverflow = document.body.style.overflow;
562
+ previousPaddingRight = document.body.style.paddingRight;
563
+ document.body.style.overflow = "hidden";
564
+ if (scrollbarWidth > 0) {
565
+ const currentPaddingRight = parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
566
+ document.body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
567
+ }
568
+ }
569
+ lockCount += 1;
570
+ return () => {
571
+ lockCount -= 1;
572
+ if (lockCount === 0) {
573
+ document.body.style.overflow = previousOverflow;
574
+ document.body.style.paddingRight = previousPaddingRight;
575
+ }
576
+ };
577
+ }, [isLocked]);
578
+ }
579
+ var FOCUSABLE = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
580
+ function useFocusTrap(containerRef, active) {
581
+ react.useEffect(() => {
582
+ if (!active) return;
583
+ if (typeof document === "undefined") return;
584
+ const previouslyFocused = document.activeElement;
585
+ const container = containerRef.current;
586
+ container?.focus();
587
+ const onKeyDown = (e) => {
588
+ if (e.key !== "Tab" || !container) return;
589
+ const focusable = Array.from(container.querySelectorAll(FOCUSABLE)).filter(
590
+ (el) => el.offsetParent !== null || el === document.activeElement
591
+ );
592
+ if (focusable.length === 0) {
593
+ e.preventDefault();
594
+ container.focus();
595
+ return;
596
+ }
597
+ const first = focusable[0];
598
+ const last = focusable[focusable.length - 1];
599
+ const activeEl = document.activeElement;
600
+ if (e.shiftKey && (activeEl === first || activeEl === container)) {
601
+ e.preventDefault();
602
+ last.focus();
603
+ } else if (!e.shiftKey && activeEl === last) {
604
+ e.preventDefault();
605
+ first.focus();
606
+ }
607
+ };
608
+ document.addEventListener("keydown", onKeyDown);
609
+ return () => {
610
+ document.removeEventListener("keydown", onKeyDown);
611
+ previouslyFocused?.focus?.();
612
+ };
613
+ }, [containerRef, active]);
614
+ }
615
+ function base(props) {
616
+ return {
617
+ width: 18,
618
+ height: 18,
619
+ viewBox: "0 0 24 24",
620
+ fill: "none",
621
+ stroke: "currentColor",
622
+ strokeWidth: 1.75,
623
+ strokeLinecap: "round",
624
+ strokeLinejoin: "round",
625
+ "aria-hidden": true,
626
+ focusable: false,
627
+ ...props
628
+ };
629
+ }
630
+ function CloseIcon(props) {
631
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { ...base(props), children: [
632
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M18 6 6 18" }),
633
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m6 6 12 12" })
634
+ ] });
635
+ }
636
+ function ZoomInIcon(props) {
637
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { ...base(props), children: [
638
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "11", cy: "11", r: "8" }),
639
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m21 21-4.3-4.3" }),
640
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M11 8v6" }),
641
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M8 11h6" })
642
+ ] });
643
+ }
644
+ function ZoomOutIcon(props) {
645
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { ...base(props), children: [
646
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "11", cy: "11", r: "8" }),
647
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m21 21-4.3-4.3" }),
648
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M8 11h6" })
649
+ ] });
650
+ }
651
+ function ChevronLeftIcon(props) {
652
+ return /* @__PURE__ */ jsxRuntime.jsx("svg", { ...base({ width: 36, height: 36, strokeWidth: 1.5, ...props }), children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m15 18-6-6 6-6" }) });
653
+ }
654
+ function ChevronRightIcon(props) {
655
+ return /* @__PURE__ */ jsxRuntime.jsx("svg", { ...base({ width: 36, height: 36, strokeWidth: 1.5, ...props }), children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m9 18 6-6-6-6" }) });
656
+ }
657
+ var defaultIcons = {
658
+ close: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon, {}),
659
+ zoomIn: /* @__PURE__ */ jsxRuntime.jsx(ZoomInIcon, {}),
660
+ zoomOut: /* @__PURE__ */ jsxRuntime.jsx(ZoomOutIcon, {}),
661
+ prev: /* @__PURE__ */ jsxRuntime.jsx(ChevronLeftIcon, {}),
662
+ next: /* @__PURE__ */ jsxRuntime.jsx(ChevronRightIcon, {})
663
+ };
664
+
665
+ // src/components/cx.ts
666
+ function cx(...parts) {
667
+ return parts.filter(Boolean).join(" ");
668
+ }
669
+ function NavButton({ direction, enabled, onClick, icon, className }) {
670
+ return /* @__PURE__ */ jsxRuntime.jsx(
671
+ "button",
672
+ {
673
+ type: "button",
674
+ onClick: (e) => {
675
+ e.stopPropagation();
676
+ if (enabled) onClick();
677
+ },
678
+ disabled: !enabled,
679
+ className: cx("rvl-nav-btn", className),
680
+ "aria-label": direction === "prev" ? "Previous image" : "Next image",
681
+ children: icon
682
+ }
683
+ );
684
+ }
685
+ var ANIM_MS = 250;
686
+ var IMG_PADDING = 44;
687
+ function prefersReducedMotion() {
688
+ if (typeof window === "undefined" || !window.matchMedia) return false;
689
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
690
+ }
691
+ function ImageViewer({
692
+ items,
693
+ index,
694
+ onIndexChange,
695
+ onNavigate,
696
+ onClose,
697
+ zoom = true,
698
+ showCounter = true,
699
+ loop = false,
700
+ renderHeader,
701
+ renderHeaderActions,
702
+ renderNavStart,
703
+ renderNavEnd,
704
+ renderFooter,
705
+ renderOverlay,
706
+ classNames,
707
+ icons,
708
+ ariaLabel
709
+ }) {
710
+ const [visible, setVisible] = react.useState(false);
711
+ const [closing, setClosing] = react.useState(false);
712
+ const [isTouchDevice, setIsTouchDevice] = react.useState(false);
713
+ const [contentShift, setContentShiftState] = react.useState({ transform: null, animate: true });
714
+ const containerRef = react.useRef(null);
715
+ const imgWrapperRef = react.useRef(null);
716
+ const topBarRef = react.useRef(null);
717
+ const bottomBarRef = react.useRef(null);
718
+ const [viewportWidth, setViewportWidth] = react.useState(
719
+ () => typeof window === "undefined" ? 0 : window.innerWidth
720
+ );
721
+ const item = items[index];
722
+ const hasPrevLinear = index > 0;
723
+ const hasNextLinear = index < items.length - 1;
724
+ const hasPrev = loop ? items.length > 1 : hasPrevLinear;
725
+ const hasNext = loop ? items.length > 1 : hasNextLinear;
726
+ const mergedIcons = react.useMemo(() => ({ ...defaultIcons, ...icons }), [icons]);
727
+ const cn = (slot) => classNames?.[slot];
728
+ useBodyScrollLock(true);
729
+ useFocusTrap(containerRef, visible && !closing);
730
+ const { topBarH, bottomBarH } = useBarMeasure(topBarRef, bottomBarRef, index);
731
+ const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom);
732
+ const {
733
+ imgRef,
734
+ displayScale,
735
+ isZoomed,
736
+ transformRef,
737
+ resetTransform,
738
+ setTransform,
739
+ clampTranslate: clampTranslate2,
740
+ measureBaseDims,
741
+ handleDoubleClick
742
+ } = zoomPan;
743
+ const slide = useSlideNavigation(items, index, onIndexChange, onNavigate);
744
+ const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
745
+ const gestures = useGestureHandler(zoomPan, slide, hasPrevLinear, hasNextLinear, zoom);
746
+ react.useEffect(() => {
747
+ setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
748
+ }, []);
749
+ react.useEffect(() => {
750
+ const onResize = () => setViewportWidth(window.innerWidth);
751
+ window.addEventListener("resize", onResize);
752
+ return () => window.removeEventListener("resize", onResize);
753
+ }, []);
754
+ react.useEffect(() => {
755
+ const raf = requestAnimationFrame(() => setVisible(true));
756
+ return () => cancelAnimationFrame(raf);
757
+ }, []);
758
+ const handleClose = react.useCallback(() => {
759
+ setClosing(true);
760
+ setVisible(false);
761
+ const delay = prefersReducedMotion() ? 0 : ANIM_MS;
762
+ setTimeout(onClose, delay);
763
+ }, [onClose]);
764
+ const navigate = react.useCallback(
765
+ (dir) => {
766
+ if (dir === "prev") {
767
+ if (hasPrevLinear) commitSlide("prev");
768
+ else if (loop) {
769
+ onNavigate?.("prev");
770
+ onIndexChange(items.length - 1);
771
+ }
772
+ } else {
773
+ if (hasNextLinear) commitSlide("next");
774
+ else if (loop) {
775
+ onNavigate?.("next");
776
+ onIndexChange(0);
777
+ }
778
+ }
779
+ },
780
+ [hasPrevLinear, hasNextLinear, loop, commitSlide, onIndexChange, onNavigate, items.length]
781
+ );
782
+ react.useEffect(() => {
783
+ const handler = (e) => {
784
+ if (e.key === "Escape") {
785
+ handleClose();
786
+ return;
787
+ }
788
+ if (displayScale > 1) return;
789
+ if (e.key === "ArrowLeft" && hasPrev) navigate("prev");
790
+ if (e.key === "ArrowRight" && hasNext) navigate("next");
791
+ };
792
+ window.addEventListener("keydown", handler);
793
+ return () => window.removeEventListener("keydown", handler);
794
+ }, [handleClose, hasPrev, hasNext, displayScale, navigate]);
795
+ const setContentShift = react.useCallback((transform, animate = true) => {
796
+ setContentShiftState({ transform, animate });
797
+ }, []);
798
+ const ctx = {
799
+ items,
800
+ index,
801
+ item,
802
+ total: items.length,
803
+ hasPrev,
804
+ hasNext,
805
+ goPrev: () => navigate("prev"),
806
+ goNext: () => navigate("next"),
807
+ goTo: (i) => {
808
+ if (i !== index && i >= 0 && i < items.length) onIndexChange(i);
809
+ },
810
+ close: handleClose,
811
+ isZoomed,
812
+ displayScale,
813
+ zoomIn: () => {
814
+ const t = transformRef.current;
815
+ const next = Math.min(MAX_SCALE, t.scale * 1.3);
816
+ const clamped = clampTranslate2(t.x, t.y, next);
817
+ setTransform({ scale: next, ...clamped }, true);
818
+ },
819
+ zoomOut: () => {
820
+ const t = transformRef.current;
821
+ const next = Math.max(MIN_SCALE, t.scale / 1.3);
822
+ const clamped = next <= 1 ? { x: 0, y: 0 } : clampTranslate2(t.x, t.y, next);
823
+ setTransform({ scale: next, ...clamped }, true);
824
+ },
825
+ resetZoom: resetTransform,
826
+ isTouchDevice,
827
+ topBarHeight: topBarH,
828
+ bottomBarHeight: bottomBarH,
829
+ setContentShift
830
+ };
831
+ if (!item) return null;
832
+ const reservedH = bottomBarH + IMG_PADDING * 2;
833
+ const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
834
+ const imgStyle = { maxHeight: imgMaxHeight };
835
+ const totalDigits = String(items.length).length;
836
+ const counterMinWidth = `${totalDigits * 2 * 0.6 + 1.5}em`;
837
+ const prevItem = hasPrevLinear ? items[index - 1] : null;
838
+ const nextItem = hasNextLinear ? items[index + 1] : null;
839
+ const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
840
+ const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
841
+ const showZoomControls = zoom && !isTouchDevice;
842
+ const headerActions = renderHeaderActions?.(ctx);
843
+ const navStart = renderNavStart?.(ctx);
844
+ const navEnd = renderNavEnd?.(ctx);
845
+ const hasNavGroup = hasPrev || hasNext;
846
+ const showNavRow = !isZoomed && (hasNavGroup || navStart != null || navEnd != null);
847
+ return /* @__PURE__ */ jsxRuntime.jsxs(
848
+ "div",
849
+ {
850
+ ref: containerRef,
851
+ className: cx("rvl-root", visible && !closing && "rvl-visible", cn("root")),
852
+ role: "dialog",
853
+ "aria-modal": "true",
854
+ "aria-label": ariaLabel ?? item.alt ?? "Image viewer",
855
+ tabIndex: -1,
856
+ children: [
857
+ /* @__PURE__ */ jsxRuntime.jsx(
858
+ "div",
859
+ {
860
+ className: cx("rvl-backdrop", cn("backdrop")),
861
+ onClick: handleClose,
862
+ "aria-hidden": "true"
863
+ }
864
+ ),
865
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: topBarRef, className: cx("rvl-bar", "rvl-top-bar", cn("topBar")), children: [
866
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-header", cn("topBar")), children: renderHeader?.(ctx) }),
867
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-header-actions", children: [
868
+ headerActions,
869
+ showZoomControls && isZoomed && /* @__PURE__ */ jsxRuntime.jsxs(
870
+ "button",
871
+ {
872
+ type: "button",
873
+ className: cx("rvl-btn", "rvl-btn-scale", cn("button")),
874
+ onClick: (e) => {
875
+ e.stopPropagation();
876
+ resetTransform();
877
+ },
878
+ title: "Reset zoom",
879
+ "aria-label": "Reset zoom",
880
+ children: [
881
+ Math.round(displayScale * 100),
882
+ "%"
883
+ ]
884
+ }
885
+ ),
886
+ showZoomControls && /* @__PURE__ */ jsxRuntime.jsx(
887
+ "button",
888
+ {
889
+ type: "button",
890
+ className: cx("rvl-btn", cn("button")),
891
+ onClick: (e) => {
892
+ e.stopPropagation();
893
+ ctx.zoomIn();
894
+ },
895
+ title: "Zoom in",
896
+ "aria-label": "Zoom in",
897
+ children: mergedIcons.zoomIn
898
+ }
899
+ ),
900
+ showZoomControls && /* @__PURE__ */ jsxRuntime.jsx(
901
+ "button",
902
+ {
903
+ type: "button",
904
+ className: cx("rvl-btn", cn("button")),
905
+ onClick: (e) => {
906
+ e.stopPropagation();
907
+ ctx.zoomOut();
908
+ },
909
+ title: "Zoom out",
910
+ "aria-label": "Zoom out",
911
+ children: mergedIcons.zoomOut
912
+ }
913
+ ),
914
+ /* @__PURE__ */ jsxRuntime.jsx(
915
+ "button",
916
+ {
917
+ type: "button",
918
+ className: cx("rvl-btn", cn("button")),
919
+ onClick: (e) => {
920
+ e.stopPropagation();
921
+ handleClose();
922
+ },
923
+ title: "Close (Esc)",
924
+ "aria-label": "Close",
925
+ children: mergedIcons.close
926
+ }
927
+ )
928
+ ] })
929
+ ] }),
930
+ /* @__PURE__ */ jsxRuntime.jsx(
931
+ "div",
932
+ {
933
+ className: "rvl-stage",
934
+ style: {
935
+ transform: contentShift.transform ?? "translateY(0)",
936
+ // animate=false snaps with no transition (overrides the CSS transition)
937
+ transition: contentShift.animate ? void 0 : "none"
938
+ },
939
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
940
+ "div",
941
+ {
942
+ ref: slideTrackRef,
943
+ className: cx("rvl-track", visible && !closing && "rvl-track-visible"),
944
+ children: [
945
+ showAdjacent && prevItem && /* @__PURE__ */ jsxRuntime.jsx(
946
+ "div",
947
+ {
948
+ className: "rvl-adjacent",
949
+ style: { transform: `translateX(-${viewportWidth}px)`, opacity: adjacentOpacity },
950
+ children: /* @__PURE__ */ jsxRuntime.jsx(
951
+ "img",
952
+ {
953
+ src: prevItem.src,
954
+ alt: "",
955
+ className: cx("rvl-img", cn("image")),
956
+ style: imgStyle,
957
+ draggable: false
958
+ }
959
+ )
960
+ }
961
+ ),
962
+ /* @__PURE__ */ jsxRuntime.jsx(
963
+ "div",
964
+ {
965
+ ref: imgWrapperRef,
966
+ className: "rvl-img-wrapper",
967
+ onClick: (e) => e.stopPropagation(),
968
+ onDoubleClick: handleDoubleClick,
969
+ onPointerDown: gestures.handlePointerDown,
970
+ onPointerMove: gestures.handlePointerMove,
971
+ onPointerUp: gestures.handlePointerUp,
972
+ onPointerLeave: gestures.handlePointerUp,
973
+ onTouchStart: gestures.handleTouchStart,
974
+ onTouchMove: gestures.handleTouchMove,
975
+ onTouchEnd: gestures.handleTouchEnd,
976
+ children: /* @__PURE__ */ jsxRuntime.jsx(
977
+ "img",
978
+ {
979
+ ref: imgRef,
980
+ src: item.src,
981
+ alt: item.alt ?? "",
982
+ className: cx("rvl-img", cn("image")),
983
+ style: imgStyle,
984
+ draggable: false,
985
+ onLoad: measureBaseDims
986
+ }
987
+ )
988
+ }
989
+ ),
990
+ showAdjacent && nextItem && /* @__PURE__ */ jsxRuntime.jsx(
991
+ "div",
992
+ {
993
+ className: "rvl-adjacent",
994
+ style: { transform: `translateX(${viewportWidth}px)`, opacity: adjacentOpacity },
995
+ children: /* @__PURE__ */ jsxRuntime.jsx(
996
+ "img",
997
+ {
998
+ src: nextItem.src,
999
+ alt: "",
1000
+ className: cx("rvl-img", cn("image")),
1001
+ style: imgStyle,
1002
+ draggable: false
1003
+ }
1004
+ )
1005
+ }
1006
+ )
1007
+ ]
1008
+ }
1009
+ )
1010
+ }
1011
+ ),
1012
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: bottomBarRef, className: cx("rvl-bar", "rvl-bottom-bar", cn("bottomBar")), children: [
1013
+ showNavRow && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-nav-inner", children: [
1014
+ navStart != null && /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-nav-start", cn("navStart")), children: navStart }),
1015
+ hasNavGroup && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-nav-group", children: [
1016
+ /* @__PURE__ */ jsxRuntime.jsx(
1017
+ NavButton,
1018
+ {
1019
+ direction: "prev",
1020
+ enabled: hasPrev,
1021
+ onClick: () => navigate("prev"),
1022
+ icon: mergedIcons.prev,
1023
+ className: cn("navButton")
1024
+ }
1025
+ ),
1026
+ showCounter && /* @__PURE__ */ jsxRuntime.jsxs(
1027
+ "span",
1028
+ {
1029
+ className: cx("rvl-counter", cn("counter")),
1030
+ style: { minWidth: counterMinWidth },
1031
+ children: [
1032
+ index + 1,
1033
+ " / ",
1034
+ items.length
1035
+ ]
1036
+ }
1037
+ ),
1038
+ /* @__PURE__ */ jsxRuntime.jsx(
1039
+ NavButton,
1040
+ {
1041
+ direction: "next",
1042
+ enabled: hasNext,
1043
+ onClick: () => navigate("next"),
1044
+ icon: mergedIcons.next,
1045
+ className: cn("navButton")
1046
+ }
1047
+ )
1048
+ ] }),
1049
+ navEnd != null && /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-nav-end", cn("navEnd")), children: navEnd })
1050
+ ] }) }),
1051
+ renderFooter && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvl-footer", children: renderFooter(ctx) })
1052
+ ] }),
1053
+ renderOverlay && /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-overlay", cn("overlay")), children: renderOverlay(ctx) })
1054
+ ]
1055
+ }
1056
+ );
1057
+ }
1058
+
1059
+ exports.ChevronLeftIcon = ChevronLeftIcon;
1060
+ exports.ChevronRightIcon = ChevronRightIcon;
1061
+ exports.CloseIcon = CloseIcon;
1062
+ exports.ImageViewer = ImageViewer;
1063
+ exports.MAX_SCALE = MAX_SCALE;
1064
+ exports.MIN_SCALE = MIN_SCALE;
1065
+ exports.NavButton = NavButton;
1066
+ exports.ZoomInIcon = ZoomInIcon;
1067
+ exports.ZoomOutIcon = ZoomOutIcon;
1068
+ exports.clampTranslate = clampTranslate;
1069
+ exports.defaultIcons = defaultIcons;
1070
+ exports.resolveSlideDirection = resolveSlideDirection;
1071
+ exports.useBarMeasure = useBarMeasure;
1072
+ exports.useBodyScrollLock = useBodyScrollLock;
1073
+ exports.useFocusTrap = useFocusTrap;
1074
+ exports.useGestureHandler = useGestureHandler;
1075
+ exports.useImageZoomPan = useImageZoomPan;
1076
+ exports.useSlideNavigation = useSlideNavigation;
1077
+ //# sourceMappingURL=index.cjs.map
1078
+ //# sourceMappingURL=index.cjs.map