@jekrch/react-viewport-lightbox 0.3.0 → 0.4.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 +438 -273
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -2
- package/dist/index.d.ts +20 -2
- package/dist/index.js +438 -273
- package/dist/index.js.map +1 -1
- package/dist/styles.css +27 -2
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,29 @@ function zoomToPoint(prevScale, nextScale, prev, focal, viewport) {
|
|
|
30
30
|
y: relY * (1 - k) + k * prev.y
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
+
function computeZoomTransform({
|
|
34
|
+
prevScale,
|
|
35
|
+
nextScale,
|
|
36
|
+
prev,
|
|
37
|
+
focal,
|
|
38
|
+
viewport,
|
|
39
|
+
baseDims,
|
|
40
|
+
zoomToCursor,
|
|
41
|
+
focalPan
|
|
42
|
+
}) {
|
|
43
|
+
if (nextScale <= 1) return { x: 0, y: 0 };
|
|
44
|
+
if (zoomToCursor) {
|
|
45
|
+
const f = zoomToPoint(prevScale, nextScale, prev, focal, viewport);
|
|
46
|
+
return clampTranslate(
|
|
47
|
+
f.x + (focalPan?.x ?? 0),
|
|
48
|
+
f.y + (focalPan?.y ?? 0),
|
|
49
|
+
nextScale,
|
|
50
|
+
baseDims,
|
|
51
|
+
viewport
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return clampTranslate(prev.x, prev.y, nextScale, baseDims, viewport);
|
|
55
|
+
}
|
|
33
56
|
function resolveSlideDirection({
|
|
34
57
|
offset,
|
|
35
58
|
elapsedMs,
|
|
@@ -50,6 +73,16 @@ function resolveSlideDirection({
|
|
|
50
73
|
// src/hooks/useImageZoomPan.ts
|
|
51
74
|
var MIN_SCALE = 1;
|
|
52
75
|
var MAX_SCALE = 5;
|
|
76
|
+
var DOUBLE_CLICK_ZOOM_SCALE = 1.8;
|
|
77
|
+
function resetWrapperStyles(wrapper) {
|
|
78
|
+
wrapper.style.transform = "none";
|
|
79
|
+
wrapper.style.position = "";
|
|
80
|
+
wrapper.style.inset = "";
|
|
81
|
+
wrapper.style.zIndex = "";
|
|
82
|
+
wrapper.style.backgroundColor = "";
|
|
83
|
+
wrapper.style.cursor = "";
|
|
84
|
+
wrapper.style.willChange = "";
|
|
85
|
+
}
|
|
53
86
|
function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCursor = true) {
|
|
54
87
|
const imgRef = react.useRef(null);
|
|
55
88
|
const [displayScale, setDisplayScale] = react.useState(1);
|
|
@@ -59,14 +92,9 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
|
|
|
59
92
|
(t, animate = false) => {
|
|
60
93
|
const wrapper = imgWrapperRef.current;
|
|
61
94
|
if (!wrapper) return;
|
|
62
|
-
wrapper.style.transition = animate ? "transform 0.2s ease-out" : "none";
|
|
95
|
+
wrapper.style.transition = typeof animate === "string" ? animate : animate ? "transform 0.2s ease-out" : "none";
|
|
63
96
|
if (t.scale <= 1) {
|
|
64
|
-
wrapper
|
|
65
|
-
wrapper.style.position = "";
|
|
66
|
-
wrapper.style.inset = "";
|
|
67
|
-
wrapper.style.zIndex = "";
|
|
68
|
-
wrapper.style.backgroundColor = "";
|
|
69
|
-
wrapper.style.cursor = "";
|
|
97
|
+
resetWrapperStyles(wrapper);
|
|
70
98
|
} else {
|
|
71
99
|
wrapper.style.transform = `scale(${t.scale}) translate(${t.x / t.scale}px, ${t.y / t.scale}px)`;
|
|
72
100
|
wrapper.style.position = "absolute";
|
|
@@ -74,6 +102,7 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
|
|
|
74
102
|
wrapper.style.zIndex = "30";
|
|
75
103
|
wrapper.style.backgroundColor = "black";
|
|
76
104
|
wrapper.style.cursor = "grab";
|
|
105
|
+
wrapper.style.willChange = "transform";
|
|
77
106
|
}
|
|
78
107
|
setDisplayScale(t.scale);
|
|
79
108
|
},
|
|
@@ -95,6 +124,9 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
|
|
|
95
124
|
if (!img) return;
|
|
96
125
|
baseDimsRef.current = { width: img.offsetWidth, height: img.offsetHeight };
|
|
97
126
|
}, []);
|
|
127
|
+
const ensureBaseDims = react.useCallback(() => {
|
|
128
|
+
if (baseDimsRef.current.width === 0) measureBaseDims();
|
|
129
|
+
}, [measureBaseDims]);
|
|
98
130
|
const clampTranslate2 = react.useCallback(
|
|
99
131
|
(x, y, scale) => clampTranslate(x, y, scale, baseDimsRef.current, {
|
|
100
132
|
width: window.innerWidth,
|
|
@@ -106,12 +138,7 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
|
|
|
106
138
|
const wrapper = imgWrapperRef.current;
|
|
107
139
|
if (wrapper) {
|
|
108
140
|
wrapper.style.transition = "none";
|
|
109
|
-
wrapper
|
|
110
|
-
wrapper.style.position = "";
|
|
111
|
-
wrapper.style.inset = "";
|
|
112
|
-
wrapper.style.zIndex = "";
|
|
113
|
-
wrapper.style.backgroundColor = "";
|
|
114
|
-
wrapper.style.cursor = "";
|
|
141
|
+
resetWrapperStyles(wrapper);
|
|
115
142
|
}
|
|
116
143
|
transformRef.current = { scale: 1, x: 0, y: 0 };
|
|
117
144
|
}, [currentIndex, imgWrapperRef]);
|
|
@@ -124,10 +151,7 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
|
|
|
124
151
|
if (!wrapper) return;
|
|
125
152
|
const handleWheel = (e) => {
|
|
126
153
|
e.preventDefault();
|
|
127
|
-
|
|
128
|
-
const img = imgRef.current;
|
|
129
|
-
if (img) baseDimsRef.current = { width: img.offsetWidth, height: img.offsetHeight };
|
|
130
|
-
}
|
|
154
|
+
ensureBaseDims();
|
|
131
155
|
const t = transformRef.current;
|
|
132
156
|
let dy = e.deltaY;
|
|
133
157
|
if (e.deltaMode === 1) dy *= 16;
|
|
@@ -136,41 +160,32 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
|
|
|
136
160
|
const step = -(normalized / 100) * 0.05;
|
|
137
161
|
const factor = 1 + step;
|
|
138
162
|
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, t.scale * factor));
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
);
|
|
150
|
-
clamped = clampTranslate2(focal.x, focal.y, nextScale);
|
|
151
|
-
} else {
|
|
152
|
-
clamped = clampTranslate2(t.x, t.y, nextScale);
|
|
153
|
-
}
|
|
154
|
-
setTransform({ scale: nextScale, ...clamped });
|
|
163
|
+
const clamped = computeZoomTransform({
|
|
164
|
+
prevScale: t.scale,
|
|
165
|
+
nextScale,
|
|
166
|
+
prev: { x: t.x, y: t.y },
|
|
167
|
+
focal: { x: e.clientX, y: e.clientY },
|
|
168
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
169
|
+
baseDims: baseDimsRef.current,
|
|
170
|
+
zoomToCursor
|
|
171
|
+
});
|
|
172
|
+
setTransform({ scale: nextScale, ...clamped }, "transform 0.1s ease-out");
|
|
155
173
|
};
|
|
156
174
|
wrapper.addEventListener("wheel", handleWheel, { passive: false });
|
|
157
175
|
return () => wrapper.removeEventListener("wheel", handleWheel);
|
|
158
|
-
}, [imgWrapperRef, setTransform,
|
|
176
|
+
}, [imgWrapperRef, setTransform, enabled, zoomToCursor, ensureBaseDims]);
|
|
159
177
|
const handleDoubleClick = react.useCallback(
|
|
160
178
|
(e) => {
|
|
161
179
|
if (!enabled) return;
|
|
162
180
|
e.stopPropagation();
|
|
163
|
-
|
|
164
|
-
const img = imgRef.current;
|
|
165
|
-
if (img) baseDimsRef.current = { width: img.offsetWidth, height: img.offsetHeight };
|
|
166
|
-
}
|
|
181
|
+
ensureBaseDims();
|
|
167
182
|
if (transformRef.current.scale > 1) {
|
|
168
183
|
resetTransform();
|
|
169
184
|
} else {
|
|
170
|
-
setTransform({ scale:
|
|
185
|
+
setTransform({ scale: DOUBLE_CLICK_ZOOM_SCALE, x: 0, y: 0 }, true);
|
|
171
186
|
}
|
|
172
187
|
},
|
|
173
|
-
[resetTransform, setTransform, enabled]
|
|
188
|
+
[resetTransform, setTransform, enabled, ensureBaseDims]
|
|
174
189
|
);
|
|
175
190
|
return {
|
|
176
191
|
imgRef,
|
|
@@ -186,13 +201,36 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
|
|
|
186
201
|
handleDoubleClick
|
|
187
202
|
};
|
|
188
203
|
}
|
|
204
|
+
function measureSlideDistance(track) {
|
|
205
|
+
const vw = track?.clientWidth || window.innerWidth;
|
|
206
|
+
const img = track?.querySelector(".rvl-img-wrapper > img");
|
|
207
|
+
const imgW = img?.offsetWidth ?? 0;
|
|
208
|
+
if (!imgW) return vw;
|
|
209
|
+
return Math.min(vw, (vw + imgW) / 2);
|
|
210
|
+
}
|
|
211
|
+
function onTransitionEndOnce(el, cb, fallbackMs) {
|
|
212
|
+
if (!el) return;
|
|
213
|
+
let done = false;
|
|
214
|
+
const run = () => {
|
|
215
|
+
if (done) return;
|
|
216
|
+
done = true;
|
|
217
|
+
el.removeEventListener("transitionend", run);
|
|
218
|
+
cb();
|
|
219
|
+
};
|
|
220
|
+
el.addEventListener("transitionend", run, { once: true });
|
|
221
|
+
setTimeout(run, fallbackMs);
|
|
222
|
+
}
|
|
189
223
|
function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop = false) {
|
|
190
224
|
const slideTrackRef = react.useRef(null);
|
|
191
225
|
const swipeOffsetRef = react.useRef(0);
|
|
192
226
|
const [swipeOffset, setSwipeOffset] = react.useState(0);
|
|
193
227
|
const [slideAnimating, setSlideAnimating] = react.useState(false);
|
|
194
228
|
const [slideActive, setSlideActive] = react.useState(false);
|
|
229
|
+
const [slideDistance, setSlideDistance] = react.useState(0);
|
|
195
230
|
const commitLockRef = react.useRef(false);
|
|
231
|
+
const refreshSlideDistance = react.useCallback(() => {
|
|
232
|
+
setSlideDistance(measureSlideDistance(slideTrackRef.current));
|
|
233
|
+
}, []);
|
|
196
234
|
const hasPrev = loop ? items.length > 1 : currentIndex > 0;
|
|
197
235
|
const hasNext = loop ? items.length > 1 : currentIndex < items.length - 1;
|
|
198
236
|
const applySlideOffset = react.useCallback((offset, animate = false) => {
|
|
@@ -207,19 +245,14 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop
|
|
|
207
245
|
const snapBack = react.useCallback(() => {
|
|
208
246
|
setSlideAnimating(true);
|
|
209
247
|
applySlideOffset(0, true);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
};
|
|
219
|
-
if (track) {
|
|
220
|
-
track.addEventListener("transitionend", onEnd, { once: true });
|
|
221
|
-
setTimeout(onEnd, 350);
|
|
222
|
-
}
|
|
248
|
+
onTransitionEndOnce(
|
|
249
|
+
slideTrackRef.current,
|
|
250
|
+
() => {
|
|
251
|
+
setSlideAnimating(false);
|
|
252
|
+
setSlideActive(false);
|
|
253
|
+
},
|
|
254
|
+
350
|
|
255
|
+
);
|
|
223
256
|
}, [applySlideOffset]);
|
|
224
257
|
const readyRef = react.useRef(true);
|
|
225
258
|
const commitSlide = react.useCallback(
|
|
@@ -227,43 +260,38 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop
|
|
|
227
260
|
if (commitLockRef.current || !readyRef.current) return;
|
|
228
261
|
commitLockRef.current = true;
|
|
229
262
|
readyRef.current = false;
|
|
230
|
-
const
|
|
231
|
-
|
|
263
|
+
const distance = measureSlideDistance(slideTrackRef.current);
|
|
264
|
+
setSlideDistance(distance);
|
|
265
|
+
const targetOffset = direction === "prev" ? distance : -distance;
|
|
232
266
|
setSlideActive(true);
|
|
233
267
|
setSlideAnimating(true);
|
|
234
268
|
onSlideStart?.(direction);
|
|
235
269
|
requestAnimationFrame(() => {
|
|
236
270
|
applySlideOffset(targetOffset, true);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
};
|
|
262
|
-
const onTransitionEnd = () => cleanup();
|
|
263
|
-
if (track) {
|
|
264
|
-
track.addEventListener("transitionend", onTransitionEnd, { once: true });
|
|
265
|
-
setTimeout(cleanup, 400);
|
|
266
|
-
}
|
|
271
|
+
onTransitionEndOnce(
|
|
272
|
+
slideTrackRef.current,
|
|
273
|
+
() => {
|
|
274
|
+
let newIndex = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
|
275
|
+
if (loop) newIndex = (newIndex + items.length) % items.length;
|
|
276
|
+
if (newIndex < 0 || newIndex >= items.length) {
|
|
277
|
+
commitLockRef.current = false;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const newItem = items[newIndex];
|
|
281
|
+
const preload = new Image();
|
|
282
|
+
preload.src = newItem.src;
|
|
283
|
+
const doNavigate = () => onNavigate(newIndex);
|
|
284
|
+
const timeout = setTimeout(doNavigate, 300);
|
|
285
|
+
preload.decode().then(() => {
|
|
286
|
+
clearTimeout(timeout);
|
|
287
|
+
doNavigate();
|
|
288
|
+
}).catch(() => {
|
|
289
|
+
clearTimeout(timeout);
|
|
290
|
+
doNavigate();
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
400
|
|
294
|
+
);
|
|
267
295
|
});
|
|
268
296
|
},
|
|
269
297
|
[applySlideOffset, currentIndex, items, onNavigate, onSlideStart, loop]
|
|
@@ -292,11 +320,11 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop
|
|
|
292
320
|
}
|
|
293
321
|
swipeOffsetRef.current = 0;
|
|
294
322
|
commitLockRef.current = false;
|
|
295
|
-
}, [currentIndex]);
|
|
296
|
-
react.useEffect(() => {
|
|
297
323
|
setSwipeOffset(0);
|
|
298
324
|
setSlideAnimating(false);
|
|
299
325
|
setSlideActive(false);
|
|
326
|
+
}, [currentIndex]);
|
|
327
|
+
react.useEffect(() => {
|
|
300
328
|
readyRef.current = true;
|
|
301
329
|
}, [currentIndex]);
|
|
302
330
|
return {
|
|
@@ -306,16 +334,33 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop
|
|
|
306
334
|
swipeOffset,
|
|
307
335
|
swipeOffsetRef,
|
|
308
336
|
commitLockRef,
|
|
337
|
+
slideDistance,
|
|
309
338
|
applySlideOffset,
|
|
310
339
|
commitSlide,
|
|
311
340
|
snapBack,
|
|
312
341
|
resolveSlide,
|
|
313
|
-
setSlideActive
|
|
342
|
+
setSlideActive,
|
|
343
|
+
refreshSlideDistance
|
|
314
344
|
};
|
|
315
345
|
}
|
|
346
|
+
var DOUBLE_TAP_ZOOM_SCALE = 2.5;
|
|
316
347
|
function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true, zoomToCursor = true) {
|
|
317
|
-
const {
|
|
318
|
-
|
|
348
|
+
const {
|
|
349
|
+
transformRef,
|
|
350
|
+
baseDimsRef,
|
|
351
|
+
clampTranslate: clampTranslate2,
|
|
352
|
+
setTransform,
|
|
353
|
+
applyTransform,
|
|
354
|
+
resetTransform
|
|
355
|
+
} = zoomPan;
|
|
356
|
+
const {
|
|
357
|
+
applySlideOffset,
|
|
358
|
+
resolveSlide,
|
|
359
|
+
snapBack,
|
|
360
|
+
setSlideActive,
|
|
361
|
+
swipeOffsetRef,
|
|
362
|
+
refreshSlideDistance
|
|
363
|
+
} = slide;
|
|
319
364
|
const panRef = react.useRef({
|
|
320
365
|
isDragging: false,
|
|
321
366
|
pointerStart: { x: 0, y: 0 },
|
|
@@ -338,9 +383,11 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
338
383
|
x: 0,
|
|
339
384
|
y: 0
|
|
340
385
|
});
|
|
386
|
+
const gestureMovedRef = react.useRef(false);
|
|
341
387
|
const beginSlide = react.useCallback(
|
|
342
388
|
(x, y) => {
|
|
343
389
|
setSlideActive(true);
|
|
390
|
+
refreshSlideDistance();
|
|
344
391
|
const sg = slideRef.current;
|
|
345
392
|
sg.active = true;
|
|
346
393
|
sg.startX = x;
|
|
@@ -349,7 +396,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
349
396
|
sg.locked = false;
|
|
350
397
|
sg.rejected = false;
|
|
351
398
|
},
|
|
352
|
-
[setSlideActive]
|
|
399
|
+
[setSlideActive, refreshSlideDistance]
|
|
353
400
|
);
|
|
354
401
|
const updateSlide = react.useCallback(
|
|
355
402
|
(clientX, clientY, lockThreshold, angleBias) => {
|
|
@@ -361,6 +408,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
361
408
|
const absDx = Math.abs(dx);
|
|
362
409
|
const absDy = Math.abs(dy);
|
|
363
410
|
if (absDx < lockThreshold && absDy < lockThreshold) return;
|
|
411
|
+
gestureMovedRef.current = true;
|
|
364
412
|
if (absDy > absDx * angleBias) {
|
|
365
413
|
sg.rejected = true;
|
|
366
414
|
return;
|
|
@@ -392,6 +440,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
392
440
|
const handlePointerDown = react.useCallback(
|
|
393
441
|
(e) => {
|
|
394
442
|
if (e.pointerType === "touch") return;
|
|
443
|
+
gestureMovedRef.current = false;
|
|
395
444
|
if (transformRef.current.scale > 1) {
|
|
396
445
|
e.preventDefault();
|
|
397
446
|
const p = panRef.current;
|
|
@@ -411,6 +460,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
411
460
|
if (p.isDragging && transformRef.current.scale > 1) {
|
|
412
461
|
const dx = e.clientX - p.pointerStart.x;
|
|
413
462
|
const dy = e.clientY - p.pointerStart.y;
|
|
463
|
+
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) gestureMovedRef.current = true;
|
|
414
464
|
const t = transformRef.current;
|
|
415
465
|
const clamped = clampTranslate2(p.translateStart.x + dx, p.translateStart.y + dy, t.scale);
|
|
416
466
|
setTransform({ scale: t.scale, ...clamped });
|
|
@@ -432,6 +482,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
432
482
|
(e) => {
|
|
433
483
|
const p = panRef.current;
|
|
434
484
|
if (e.touches.length === 2 && zoomEnabled) {
|
|
485
|
+
gestureMovedRef.current = true;
|
|
435
486
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
436
487
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
437
488
|
p.pinchStartDist = Math.hypot(dx, dy);
|
|
@@ -446,6 +497,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
446
497
|
snapBack();
|
|
447
498
|
}
|
|
448
499
|
} else if (e.touches.length === 1) {
|
|
500
|
+
gestureMovedRef.current = false;
|
|
449
501
|
if (transformRef.current.scale > 1) {
|
|
450
502
|
p.lastTouchPos = {
|
|
451
503
|
x: e.touches[0].clientX,
|
|
@@ -466,25 +518,26 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
466
518
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
467
519
|
const dist = Math.hypot(dx, dy);
|
|
468
520
|
const ratio = dist / p.pinchStartDist;
|
|
469
|
-
const nextScale = Math.min(
|
|
521
|
+
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, p.pinchStartScale * ratio));
|
|
470
522
|
const t = transformRef.current;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
523
|
+
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
524
|
+
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
525
|
+
const prevMid = p.pinchMidpoint;
|
|
526
|
+
const focalPan = {
|
|
527
|
+
x: prevMid ? midX - prevMid.x : 0,
|
|
528
|
+
y: prevMid ? midY - prevMid.y : 0
|
|
529
|
+
};
|
|
530
|
+
p.pinchMidpoint = { x: midX, y: midY };
|
|
531
|
+
const clamped = computeZoomTransform({
|
|
532
|
+
prevScale: t.scale,
|
|
533
|
+
nextScale,
|
|
534
|
+
prev: { x: t.x, y: t.y },
|
|
535
|
+
focal: { x: midX, y: midY },
|
|
536
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
537
|
+
baseDims: baseDimsRef.current,
|
|
538
|
+
zoomToCursor,
|
|
539
|
+
focalPan
|
|
540
|
+
});
|
|
488
541
|
const next = { scale: nextScale, ...clamped };
|
|
489
542
|
transformRef.current = next;
|
|
490
543
|
applyTransform(next);
|
|
@@ -492,6 +545,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
492
545
|
const touch = e.touches[0];
|
|
493
546
|
const dx = touch.clientX - p.lastTouchPos.x;
|
|
494
547
|
const dy = touch.clientY - p.lastTouchPos.y;
|
|
548
|
+
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) gestureMovedRef.current = true;
|
|
495
549
|
p.lastTouchPos = { x: touch.clientX, y: touch.clientY };
|
|
496
550
|
const t = transformRef.current;
|
|
497
551
|
const clamped = clampTranslate2(t.x + dx, t.y + dy, t.scale);
|
|
@@ -503,7 +557,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
503
557
|
updateSlide(touch.clientX, touch.clientY, 6, 0.8);
|
|
504
558
|
}
|
|
505
559
|
},
|
|
506
|
-
[transformRef, clampTranslate2, applyTransform, updateSlide, zoomToCursor]
|
|
560
|
+
[transformRef, baseDimsRef, clampTranslate2, applyTransform, updateSlide, zoomToCursor]
|
|
507
561
|
);
|
|
508
562
|
const handleTouchEnd = react.useCallback(
|
|
509
563
|
(e) => {
|
|
@@ -543,7 +597,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
543
597
|
if (transformRef.current.scale > 1) {
|
|
544
598
|
resetTransform();
|
|
545
599
|
} else {
|
|
546
|
-
setTransform({ scale:
|
|
600
|
+
setTransform({ scale: DOUBLE_TAP_ZOOM_SCALE, x: 0, y: 0 }, true);
|
|
547
601
|
}
|
|
548
602
|
} else {
|
|
549
603
|
lastTapRef.current = { time: now, x: touch.clientX, y: touch.clientY };
|
|
@@ -570,7 +624,8 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
|
|
|
570
624
|
handlePointerUp,
|
|
571
625
|
handleTouchStart,
|
|
572
626
|
handleTouchMove,
|
|
573
|
-
handleTouchEnd
|
|
627
|
+
handleTouchEnd,
|
|
628
|
+
gestureMovedRef
|
|
574
629
|
};
|
|
575
630
|
}
|
|
576
631
|
function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
|
|
@@ -669,6 +724,143 @@ function useFocusTrap(containerRef, active) {
|
|
|
669
724
|
};
|
|
670
725
|
}, [containerRef, active]);
|
|
671
726
|
}
|
|
727
|
+
var ANIM_MS = 250;
|
|
728
|
+
var IMG_PADDING = 44;
|
|
729
|
+
var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
730
|
+
function prefersReducedMotion() {
|
|
731
|
+
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
732
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
733
|
+
}
|
|
734
|
+
function flipTransform(from, to) {
|
|
735
|
+
const sx = to.width / from.width;
|
|
736
|
+
const sy = to.height / from.height;
|
|
737
|
+
const dx = to.left - from.left;
|
|
738
|
+
const dy = to.top - from.top;
|
|
739
|
+
return `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
|
|
740
|
+
}
|
|
741
|
+
function canAnimate(el) {
|
|
742
|
+
return !!el && typeof el.animate === "function";
|
|
743
|
+
}
|
|
744
|
+
function isRectInViewport(rect) {
|
|
745
|
+
if (typeof window === "undefined") return true;
|
|
746
|
+
return rect.top < window.innerHeight && rect.top + rect.height > 0 && rect.left < window.innerWidth && rect.left + rect.width > 0;
|
|
747
|
+
}
|
|
748
|
+
function useSharedElementZoom({
|
|
749
|
+
getOriginRect,
|
|
750
|
+
index,
|
|
751
|
+
isZoomed,
|
|
752
|
+
imgRef,
|
|
753
|
+
imgWrapperRef,
|
|
754
|
+
bottomBarRef,
|
|
755
|
+
measureBaseDims
|
|
756
|
+
}) {
|
|
757
|
+
const zoomTransition = !!getOriginRect;
|
|
758
|
+
const reduceMotion = prefersReducedMotion();
|
|
759
|
+
const gateEntry = zoomTransition && !reduceMotion;
|
|
760
|
+
const [fullLoaded, setFullLoaded] = react.useState(false);
|
|
761
|
+
const [showSpinner, setShowSpinner] = react.useState(false);
|
|
762
|
+
const [collapsing, setCollapsing] = react.useState(false);
|
|
763
|
+
const entryStartedRef = react.useRef(false);
|
|
764
|
+
const entryCleanupRef = react.useRef(null);
|
|
765
|
+
const runZoomEntry = react.useCallback(() => {
|
|
766
|
+
if (entryStartedRef.current) return;
|
|
767
|
+
if (!getOriginRect || prefersReducedMotion()) return;
|
|
768
|
+
const img = imgRef.current;
|
|
769
|
+
const thumb = getOriginRect(index);
|
|
770
|
+
if (!thumb || !canAnimate(img)) return;
|
|
771
|
+
const bottomH = bottomBarRef.current?.offsetHeight ?? 0;
|
|
772
|
+
const lockedMaxHeight = `calc(100vh - ${bottomH + IMG_PADDING * 2}px)`;
|
|
773
|
+
img.style.maxHeight = lockedMaxHeight;
|
|
774
|
+
const imgRect = img.getBoundingClientRect();
|
|
775
|
+
if (imgRect.width === 0 || imgRect.height === 0) {
|
|
776
|
+
img.style.maxHeight = "";
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
entryStartedRef.current = true;
|
|
780
|
+
const startTransform = flipTransform(imgRect, thumb);
|
|
781
|
+
img.style.transformOrigin = "top left";
|
|
782
|
+
img.style.transform = startTransform;
|
|
783
|
+
const wrapper = imgWrapperRef.current;
|
|
784
|
+
if (wrapper) wrapper.style.overflow = "visible";
|
|
785
|
+
const anim = img.animate(
|
|
786
|
+
[
|
|
787
|
+
{ transformOrigin: "top left", transform: startTransform },
|
|
788
|
+
{ transformOrigin: "top left", transform: "none" }
|
|
789
|
+
],
|
|
790
|
+
{ duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
|
|
791
|
+
);
|
|
792
|
+
const cleanup = () => {
|
|
793
|
+
img.style.transform = "";
|
|
794
|
+
img.style.transformOrigin = "";
|
|
795
|
+
if (wrapper) wrapper.style.overflow = "";
|
|
796
|
+
img.style.maxHeight = lockedMaxHeight;
|
|
797
|
+
anim.cancel();
|
|
798
|
+
entryCleanupRef.current = null;
|
|
799
|
+
};
|
|
800
|
+
entryCleanupRef.current = cleanup;
|
|
801
|
+
anim.onfinish = cleanup;
|
|
802
|
+
}, [getOriginRect, index, imgRef, imgWrapperRef, bottomBarRef]);
|
|
803
|
+
const onImageLoad = react.useCallback(() => {
|
|
804
|
+
measureBaseDims();
|
|
805
|
+
const img = imgRef.current;
|
|
806
|
+
if (img && typeof img.decode === "function") {
|
|
807
|
+
img.decode().then(
|
|
808
|
+
() => setFullLoaded(true),
|
|
809
|
+
() => setFullLoaded(true)
|
|
810
|
+
);
|
|
811
|
+
} else {
|
|
812
|
+
setFullLoaded(true);
|
|
813
|
+
}
|
|
814
|
+
}, [measureBaseDims, imgRef]);
|
|
815
|
+
const onImageError = react.useCallback(() => setFullLoaded(true), []);
|
|
816
|
+
react.useLayoutEffect(() => {
|
|
817
|
+
const img = imgRef.current;
|
|
818
|
+
if (img && img.complete && img.naturalWidth > 0) onImageLoad();
|
|
819
|
+
}, []);
|
|
820
|
+
react.useLayoutEffect(() => {
|
|
821
|
+
if (fullLoaded) runZoomEntry();
|
|
822
|
+
}, [fullLoaded]);
|
|
823
|
+
react.useEffect(() => {
|
|
824
|
+
if (!gateEntry || fullLoaded) {
|
|
825
|
+
setShowSpinner(false);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const t = setTimeout(() => setShowSpinner(true), 500);
|
|
829
|
+
return () => clearTimeout(t);
|
|
830
|
+
}, [gateEntry, fullLoaded]);
|
|
831
|
+
const settleEntry = react.useCallback(() => {
|
|
832
|
+
entryCleanupRef.current?.();
|
|
833
|
+
}, []);
|
|
834
|
+
const playCollapse = react.useCallback(() => {
|
|
835
|
+
const reduce = prefersReducedMotion();
|
|
836
|
+
const origin = !reduce && !isZoomed ? getOriginRect?.(index) ?? null : null;
|
|
837
|
+
const thumb = origin && isRectInViewport(origin) ? origin : null;
|
|
838
|
+
const img = imgRef.current;
|
|
839
|
+
if (!thumb || !canAnimate(img)) return;
|
|
840
|
+
const imgRect = img.getBoundingClientRect();
|
|
841
|
+
const wrapper = imgWrapperRef.current;
|
|
842
|
+
if (wrapper) wrapper.style.overflow = "visible";
|
|
843
|
+
setCollapsing(true);
|
|
844
|
+
img.animate(
|
|
845
|
+
[
|
|
846
|
+
{ transformOrigin: "top left", transform: "none" },
|
|
847
|
+
{ transformOrigin: "top left", transform: flipTransform(imgRect, thumb) }
|
|
848
|
+
],
|
|
849
|
+
{ duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
|
|
850
|
+
);
|
|
851
|
+
}, [getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
|
|
852
|
+
return {
|
|
853
|
+
gateEntry,
|
|
854
|
+
zoomTransition,
|
|
855
|
+
fullLoaded,
|
|
856
|
+
showSpinner,
|
|
857
|
+
collapsing,
|
|
858
|
+
onImageLoad,
|
|
859
|
+
onImageError,
|
|
860
|
+
settleEntry,
|
|
861
|
+
playCollapse
|
|
862
|
+
};
|
|
863
|
+
}
|
|
672
864
|
function base(props) {
|
|
673
865
|
return {
|
|
674
866
|
width: 18,
|
|
@@ -739,27 +931,29 @@ function NavButton({ direction, enabled, onClick, icon, className }) {
|
|
|
739
931
|
}
|
|
740
932
|
);
|
|
741
933
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
934
|
+
function ChromeButton({
|
|
935
|
+
onClick,
|
|
936
|
+
title,
|
|
937
|
+
ariaLabel,
|
|
938
|
+
className,
|
|
939
|
+
children
|
|
940
|
+
}) {
|
|
941
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
942
|
+
"button",
|
|
943
|
+
{
|
|
944
|
+
type: "button",
|
|
945
|
+
className: cx("rvl-btn", className),
|
|
946
|
+
onClick: (e) => {
|
|
947
|
+
e.stopPropagation();
|
|
948
|
+
onClick();
|
|
949
|
+
},
|
|
950
|
+
title,
|
|
951
|
+
"aria-label": ariaLabel,
|
|
952
|
+
children
|
|
953
|
+
}
|
|
954
|
+
);
|
|
762
955
|
}
|
|
956
|
+
var GHOST_CLICK_MS = 700;
|
|
763
957
|
function ImageViewer({
|
|
764
958
|
items,
|
|
765
959
|
index,
|
|
@@ -791,9 +985,13 @@ function ImageViewer({
|
|
|
791
985
|
}) {
|
|
792
986
|
const [visible, setVisible] = react.useState(false);
|
|
793
987
|
const [closing, setClosing] = react.useState(false);
|
|
794
|
-
const [collapsing, setCollapsing] = react.useState(false);
|
|
795
988
|
const [isTouchDevice, setIsTouchDevice] = react.useState(false);
|
|
796
989
|
const [contentShift, setContentShiftState] = react.useState({ transform: null, animate: true });
|
|
990
|
+
const openedAtRef = react.useRef(Date.now());
|
|
991
|
+
const isGhostMouseEvent = react.useCallback(
|
|
992
|
+
() => Date.now() - openedAtRef.current < GHOST_CLICK_MS,
|
|
993
|
+
[]
|
|
994
|
+
);
|
|
797
995
|
const containerRef = react.useRef(null);
|
|
798
996
|
const imgWrapperRef = react.useRef(null);
|
|
799
997
|
const topBarRef = react.useRef(null);
|
|
@@ -824,8 +1022,27 @@ function ImageViewer({
|
|
|
824
1022
|
handleDoubleClick
|
|
825
1023
|
} = zoomPan;
|
|
826
1024
|
const slide = useSlideNavigation(items, index, onIndexChange, onNavigate, loop);
|
|
827
|
-
const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
|
|
1025
|
+
const { slideTrackRef, slideActive, slideAnimating, swipeOffset, slideDistance, commitSlide } = slide;
|
|
828
1026
|
const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
|
|
1027
|
+
const {
|
|
1028
|
+
gateEntry,
|
|
1029
|
+
zoomTransition,
|
|
1030
|
+
fullLoaded,
|
|
1031
|
+
showSpinner,
|
|
1032
|
+
collapsing,
|
|
1033
|
+
onImageLoad,
|
|
1034
|
+
onImageError,
|
|
1035
|
+
settleEntry,
|
|
1036
|
+
playCollapse
|
|
1037
|
+
} = useSharedElementZoom({
|
|
1038
|
+
getOriginRect,
|
|
1039
|
+
index,
|
|
1040
|
+
isZoomed,
|
|
1041
|
+
imgRef,
|
|
1042
|
+
imgWrapperRef,
|
|
1043
|
+
bottomBarRef,
|
|
1044
|
+
measureBaseDims
|
|
1045
|
+
});
|
|
829
1046
|
react.useEffect(() => {
|
|
830
1047
|
if (typeof window === "undefined" || !window.matchMedia) {
|
|
831
1048
|
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
|
@@ -846,101 +1063,53 @@ function ImageViewer({
|
|
|
846
1063
|
const raf = requestAnimationFrame(() => setVisible(true));
|
|
847
1064
|
return () => cancelAnimationFrame(raf);
|
|
848
1065
|
}, []);
|
|
849
|
-
const zoomTransition = !!getOriginRect;
|
|
850
|
-
const reduceMotion = prefersReducedMotion();
|
|
851
|
-
const gateEntry = zoomTransition && !reduceMotion;
|
|
852
|
-
const [fullLoaded, setFullLoaded] = react.useState(false);
|
|
853
|
-
const [showSpinner, setShowSpinner] = react.useState(false);
|
|
854
|
-
const entryStartedRef = react.useRef(false);
|
|
855
|
-
const entryCleanupRef = react.useRef(null);
|
|
856
|
-
const runZoomEntry = react.useCallback(() => {
|
|
857
|
-
if (entryStartedRef.current) return;
|
|
858
|
-
if (!getOriginRect || prefersReducedMotion()) return;
|
|
859
|
-
const img = imgRef.current;
|
|
860
|
-
const thumb = getOriginRect(index);
|
|
861
|
-
if (!thumb || !canAnimate(img)) return;
|
|
862
|
-
const bottomH = bottomBarRef.current?.offsetHeight ?? 0;
|
|
863
|
-
const lockedMaxHeight = `calc(100vh - ${bottomH + IMG_PADDING * 2}px)`;
|
|
864
|
-
img.style.maxHeight = lockedMaxHeight;
|
|
865
|
-
const imgRect = img.getBoundingClientRect();
|
|
866
|
-
if (imgRect.width === 0 || imgRect.height === 0) {
|
|
867
|
-
img.style.maxHeight = "";
|
|
868
|
-
return;
|
|
869
|
-
}
|
|
870
|
-
entryStartedRef.current = true;
|
|
871
|
-
const startTransform = flipTransform(imgRect, thumb);
|
|
872
|
-
img.style.transformOrigin = "top left";
|
|
873
|
-
img.style.transform = startTransform;
|
|
874
|
-
const wrapper = imgWrapperRef.current;
|
|
875
|
-
if (wrapper) wrapper.style.overflow = "visible";
|
|
876
|
-
const anim = img.animate(
|
|
877
|
-
[
|
|
878
|
-
{ transformOrigin: "top left", transform: startTransform },
|
|
879
|
-
{ transformOrigin: "top left", transform: "none" }
|
|
880
|
-
],
|
|
881
|
-
{ duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
|
|
882
|
-
);
|
|
883
|
-
const cleanup = () => {
|
|
884
|
-
img.style.transform = "";
|
|
885
|
-
img.style.transformOrigin = "";
|
|
886
|
-
if (wrapper) wrapper.style.overflow = "";
|
|
887
|
-
img.style.maxHeight = lockedMaxHeight;
|
|
888
|
-
anim.cancel();
|
|
889
|
-
entryCleanupRef.current = null;
|
|
890
|
-
};
|
|
891
|
-
entryCleanupRef.current = cleanup;
|
|
892
|
-
anim.onfinish = cleanup;
|
|
893
|
-
}, [getOriginRect, index, imgRef, imgWrapperRef, bottomBarRef]);
|
|
894
|
-
const markFullLoaded = react.useCallback(() => {
|
|
895
|
-
measureBaseDims();
|
|
896
|
-
const img = imgRef.current;
|
|
897
|
-
if (img && typeof img.decode === "function") {
|
|
898
|
-
img.decode().then(
|
|
899
|
-
() => setFullLoaded(true),
|
|
900
|
-
() => setFullLoaded(true)
|
|
901
|
-
);
|
|
902
|
-
} else {
|
|
903
|
-
setFullLoaded(true);
|
|
904
|
-
}
|
|
905
|
-
}, [measureBaseDims, imgRef]);
|
|
906
|
-
react.useLayoutEffect(() => {
|
|
907
|
-
const img = imgRef.current;
|
|
908
|
-
if (img && img.complete && img.naturalWidth > 0) markFullLoaded();
|
|
909
|
-
}, []);
|
|
910
|
-
react.useLayoutEffect(() => {
|
|
911
|
-
if (fullLoaded) runZoomEntry();
|
|
912
|
-
}, [fullLoaded]);
|
|
913
|
-
react.useEffect(() => {
|
|
914
|
-
if (!gateEntry || fullLoaded) {
|
|
915
|
-
setShowSpinner(false);
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
const t = setTimeout(() => setShowSpinner(true), 500);
|
|
919
|
-
return () => clearTimeout(t);
|
|
920
|
-
}, [gateEntry, fullLoaded]);
|
|
921
1066
|
const handleClose = react.useCallback(() => {
|
|
922
1067
|
const reduce = prefersReducedMotion();
|
|
923
|
-
|
|
924
|
-
const thumb = origin && isRectInViewport(origin) ? origin : null;
|
|
925
|
-
const img = imgRef.current;
|
|
926
|
-
entryCleanupRef.current?.();
|
|
1068
|
+
settleEntry();
|
|
927
1069
|
setClosing(true);
|
|
928
1070
|
setVisible(false);
|
|
929
|
-
|
|
930
|
-
const imgRect = img.getBoundingClientRect();
|
|
931
|
-
const wrapper = imgWrapperRef.current;
|
|
932
|
-
if (wrapper) wrapper.style.overflow = "visible";
|
|
933
|
-
setCollapsing(true);
|
|
934
|
-
img.animate(
|
|
935
|
-
[
|
|
936
|
-
{ transformOrigin: "top left", transform: "none" },
|
|
937
|
-
{ transformOrigin: "top left", transform: flipTransform(imgRect, thumb) }
|
|
938
|
-
],
|
|
939
|
-
{ duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
|
|
940
|
-
);
|
|
941
|
-
}
|
|
1071
|
+
playCollapse();
|
|
942
1072
|
setTimeout(onClose, reduce ? 0 : ANIM_MS);
|
|
943
|
-
}, [onClose,
|
|
1073
|
+
}, [onClose, settleEntry, playCollapse]);
|
|
1074
|
+
const handleBackdropTouchEnd = react.useCallback(
|
|
1075
|
+
(e) => {
|
|
1076
|
+
if (e.target !== e.currentTarget) return;
|
|
1077
|
+
e.preventDefault();
|
|
1078
|
+
handleClose();
|
|
1079
|
+
},
|
|
1080
|
+
[handleClose]
|
|
1081
|
+
);
|
|
1082
|
+
const handleTrackTouchEnd = react.useCallback(
|
|
1083
|
+
(e) => {
|
|
1084
|
+
gestures.handleTouchEnd(e);
|
|
1085
|
+
if (!closeOnBackdropClick) return;
|
|
1086
|
+
if (e.target !== e.currentTarget) return;
|
|
1087
|
+
if (gestures.gestureMovedRef.current) return;
|
|
1088
|
+
e.preventDefault();
|
|
1089
|
+
handleClose();
|
|
1090
|
+
},
|
|
1091
|
+
[gestures, closeOnBackdropClick, handleClose]
|
|
1092
|
+
);
|
|
1093
|
+
const handleTrackClick = react.useCallback(
|
|
1094
|
+
(e) => {
|
|
1095
|
+
if (e.target !== e.currentTarget) return;
|
|
1096
|
+
if (isGhostMouseEvent()) return;
|
|
1097
|
+
if (gestures.gestureMovedRef.current) return;
|
|
1098
|
+
handleClose();
|
|
1099
|
+
},
|
|
1100
|
+
[gestures, isGhostMouseEvent, handleClose]
|
|
1101
|
+
);
|
|
1102
|
+
const handleDoubleClickGuarded = react.useCallback(
|
|
1103
|
+
(e) => {
|
|
1104
|
+
if (isGhostMouseEvent()) return;
|
|
1105
|
+
handleDoubleClick(e);
|
|
1106
|
+
},
|
|
1107
|
+
[handleDoubleClick, isGhostMouseEvent]
|
|
1108
|
+
);
|
|
1109
|
+
const handleBackdropClick = react.useCallback(() => {
|
|
1110
|
+
if (isGhostMouseEvent()) return;
|
|
1111
|
+
handleClose();
|
|
1112
|
+
}, [handleClose, isGhostMouseEvent]);
|
|
944
1113
|
const navigate = react.useCallback(
|
|
945
1114
|
(dir) => {
|
|
946
1115
|
if (dir === "prev") {
|
|
@@ -1020,7 +1189,9 @@ function ImageViewer({
|
|
|
1020
1189
|
const prevItem = prevIndex >= 0 ? items[prevIndex] : null;
|
|
1021
1190
|
const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
|
|
1022
1191
|
const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
|
|
1023
|
-
const
|
|
1192
|
+
const adjacentOffset = slideDistance || viewportWidth;
|
|
1193
|
+
const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (adjacentOffset * 0.8 || 1));
|
|
1194
|
+
const adjacentTransition = slideAnimating ? "opacity 0.28s cubic-bezier(0.2, 0, 0, 1)" : "none";
|
|
1024
1195
|
const showZoomCtrls = zoom && !isTouchDevice && showZoomControls && !contentShift.transform;
|
|
1025
1196
|
const headerActions = renderHeaderActions?.(ctx);
|
|
1026
1197
|
const navStart = renderNavStart?.(ctx);
|
|
@@ -1042,7 +1213,8 @@ function ImageViewer({
|
|
|
1042
1213
|
"div",
|
|
1043
1214
|
{
|
|
1044
1215
|
className: cx("rvl-backdrop", cn("backdrop")),
|
|
1045
|
-
onClick: closeOnBackdropClick ?
|
|
1216
|
+
onClick: closeOnBackdropClick ? handleBackdropClick : void 0,
|
|
1217
|
+
onTouchEnd: closeOnBackdropClick ? handleBackdropTouchEnd : void 0,
|
|
1046
1218
|
"aria-hidden": "true"
|
|
1047
1219
|
}
|
|
1048
1220
|
),
|
|
@@ -1051,16 +1223,12 @@ function ImageViewer({
|
|
|
1051
1223
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-header-actions", children: [
|
|
1052
1224
|
headerActions,
|
|
1053
1225
|
showZoomCtrls && isZoomed && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
1054
|
-
|
|
1226
|
+
ChromeButton,
|
|
1055
1227
|
{
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
onClick: (e) => {
|
|
1059
|
-
e.stopPropagation();
|
|
1060
|
-
resetTransform();
|
|
1061
|
-
},
|
|
1228
|
+
className: cx("rvl-btn-scale", cn("button")),
|
|
1229
|
+
onClick: resetTransform,
|
|
1062
1230
|
title: "Reset zoom",
|
|
1063
|
-
|
|
1231
|
+
ariaLabel: "Reset zoom",
|
|
1064
1232
|
children: [
|
|
1065
1233
|
Math.round(displayScale * 100),
|
|
1066
1234
|
"%"
|
|
@@ -1068,44 +1236,32 @@ function ImageViewer({
|
|
|
1068
1236
|
}
|
|
1069
1237
|
),
|
|
1070
1238
|
showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
|
|
1071
|
-
|
|
1239
|
+
ChromeButton,
|
|
1072
1240
|
{
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
onClick: (e) => {
|
|
1076
|
-
e.stopPropagation();
|
|
1077
|
-
ctx.zoomIn();
|
|
1078
|
-
},
|
|
1241
|
+
className: cn("button"),
|
|
1242
|
+
onClick: ctx.zoomIn,
|
|
1079
1243
|
title: "Zoom in",
|
|
1080
|
-
|
|
1244
|
+
ariaLabel: "Zoom in",
|
|
1081
1245
|
children: mergedIcons.zoomIn
|
|
1082
1246
|
}
|
|
1083
1247
|
),
|
|
1084
1248
|
showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
|
|
1085
|
-
|
|
1249
|
+
ChromeButton,
|
|
1086
1250
|
{
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
onClick: (e) => {
|
|
1090
|
-
e.stopPropagation();
|
|
1091
|
-
ctx.zoomOut();
|
|
1092
|
-
},
|
|
1251
|
+
className: cn("button"),
|
|
1252
|
+
onClick: ctx.zoomOut,
|
|
1093
1253
|
title: "Zoom out",
|
|
1094
|
-
|
|
1254
|
+
ariaLabel: "Zoom out",
|
|
1095
1255
|
children: mergedIcons.zoomOut
|
|
1096
1256
|
}
|
|
1097
1257
|
),
|
|
1098
1258
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1099
|
-
|
|
1259
|
+
ChromeButton,
|
|
1100
1260
|
{
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
onClick: (e) => {
|
|
1104
|
-
e.stopPropagation();
|
|
1105
|
-
handleClose();
|
|
1106
|
-
},
|
|
1261
|
+
className: cn("button"),
|
|
1262
|
+
onClick: handleClose,
|
|
1107
1263
|
title: "Close (Esc)",
|
|
1108
|
-
|
|
1264
|
+
ariaLabel: "Close",
|
|
1109
1265
|
children: mergedIcons.close
|
|
1110
1266
|
}
|
|
1111
1267
|
)
|
|
@@ -1115,9 +1271,6 @@ function ImageViewer({
|
|
|
1115
1271
|
"div",
|
|
1116
1272
|
{
|
|
1117
1273
|
className: "rvl-stage",
|
|
1118
|
-
onClick: closeOnBackdropClick ? (e) => {
|
|
1119
|
-
if (e.target === e.currentTarget) handleClose();
|
|
1120
|
-
} : void 0,
|
|
1121
1274
|
style: {
|
|
1122
1275
|
transform: contentShift.transform ?? "translateY(0)",
|
|
1123
1276
|
// animate=false snaps with no transition (overrides the CSS transition)
|
|
@@ -1127,8 +1280,19 @@ function ImageViewer({
|
|
|
1127
1280
|
"div",
|
|
1128
1281
|
{
|
|
1129
1282
|
ref: slideTrackRef,
|
|
1283
|
+
onPointerDown: gestures.handlePointerDown,
|
|
1284
|
+
onPointerMove: gestures.handlePointerMove,
|
|
1285
|
+
onPointerUp: gestures.handlePointerUp,
|
|
1286
|
+
onPointerLeave: gestures.handlePointerUp,
|
|
1287
|
+
onTouchStart: gestures.handleTouchStart,
|
|
1288
|
+
onTouchMove: gestures.handleTouchMove,
|
|
1289
|
+
onTouchEnd: handleTrackTouchEnd,
|
|
1290
|
+
onClick: closeOnBackdropClick ? handleTrackClick : void 0,
|
|
1130
1291
|
className: cx(
|
|
1131
1292
|
"rvl-track",
|
|
1293
|
+
// Promote the track only while a swipe is live (drag + commit/snap
|
|
1294
|
+
// animation), then release the layer. Matches `showAdjacent`.
|
|
1295
|
+
showAdjacent && "rvl-track-swiping",
|
|
1132
1296
|
// During a thumbnail zoom the track is opaque from the first frame
|
|
1133
1297
|
// (the image itself is hidden until the zoom starts), so the picture
|
|
1134
1298
|
// flies in crisply instead of cross-fading. On close it only stays
|
|
@@ -1141,7 +1305,11 @@ function ImageViewer({
|
|
|
1141
1305
|
"div",
|
|
1142
1306
|
{
|
|
1143
1307
|
className: "rvl-adjacent",
|
|
1144
|
-
style: {
|
|
1308
|
+
style: {
|
|
1309
|
+
transform: `translateX(${-adjacentOffset}px)`,
|
|
1310
|
+
opacity: swipeOffset > 0 ? adjacentOpacity : 0,
|
|
1311
|
+
transition: adjacentTransition
|
|
1312
|
+
},
|
|
1145
1313
|
children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1146
1314
|
"img",
|
|
1147
1315
|
{
|
|
@@ -1160,14 +1328,7 @@ function ImageViewer({
|
|
|
1160
1328
|
ref: imgWrapperRef,
|
|
1161
1329
|
className: "rvl-img-wrapper",
|
|
1162
1330
|
onClick: (e) => e.stopPropagation(),
|
|
1163
|
-
onDoubleClick:
|
|
1164
|
-
onPointerDown: gestures.handlePointerDown,
|
|
1165
|
-
onPointerMove: gestures.handlePointerMove,
|
|
1166
|
-
onPointerUp: gestures.handlePointerUp,
|
|
1167
|
-
onPointerLeave: gestures.handlePointerUp,
|
|
1168
|
-
onTouchStart: gestures.handleTouchStart,
|
|
1169
|
-
onTouchMove: gestures.handleTouchMove,
|
|
1170
|
-
onTouchEnd: gestures.handleTouchEnd,
|
|
1331
|
+
onDoubleClick: handleDoubleClickGuarded,
|
|
1171
1332
|
children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1172
1333
|
"img",
|
|
1173
1334
|
{
|
|
@@ -1177,8 +1338,8 @@ function ImageViewer({
|
|
|
1177
1338
|
className: cx("rvl-img", cn("image")),
|
|
1178
1339
|
style: imgStyle,
|
|
1179
1340
|
draggable: false,
|
|
1180
|
-
onLoad:
|
|
1181
|
-
onError:
|
|
1341
|
+
onLoad: onImageLoad,
|
|
1342
|
+
onError: onImageError
|
|
1182
1343
|
}
|
|
1183
1344
|
)
|
|
1184
1345
|
}
|
|
@@ -1188,7 +1349,11 @@ function ImageViewer({
|
|
|
1188
1349
|
"div",
|
|
1189
1350
|
{
|
|
1190
1351
|
className: "rvl-adjacent",
|
|
1191
|
-
style: {
|
|
1352
|
+
style: {
|
|
1353
|
+
transform: `translateX(${adjacentOffset}px)`,
|
|
1354
|
+
opacity: swipeOffset < 0 ? adjacentOpacity : 0,
|
|
1355
|
+
transition: adjacentTransition
|
|
1356
|
+
},
|
|
1192
1357
|
children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1193
1358
|
"img",
|
|
1194
1359
|
{
|