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