@oxyhq/bloom 0.33.0 → 0.34.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/lib/commonjs/command/Command.js +49 -2
- package/lib/commonjs/command/Command.js.map +1 -1
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +375 -20
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/commonjs/zoomable-image-gallery/constants.js +16 -1
- package/lib/commonjs/zoomable-image-gallery/constants.js.map +1 -1
- package/lib/commonjs/zoomable-image-gallery/index.js +15 -0
- package/lib/commonjs/zoomable-image-gallery/index.js.map +1 -1
- package/lib/module/command/Command.js +50 -3
- package/lib/module/command/Command.js.map +1 -1
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +368 -13
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/module/zoomable-image-gallery/constants.js +15 -0
- package/lib/module/zoomable-image-gallery/constants.js.map +1 -1
- package/lib/module/zoomable-image-gallery/index.js +1 -0
- package/lib/module/zoomable-image-gallery/index.js.map +1 -1
- package/lib/typescript/commonjs/command/Command.d.ts +14 -0
- package/lib/typescript/commonjs/command/Command.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts +14 -0
- package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts +1 -0
- package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts +6 -0
- package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts.map +1 -1
- package/lib/typescript/module/command/Command.d.ts +14 -0
- package/lib/typescript/module/command/Command.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/constants.d.ts +14 -0
- package/lib/typescript/module/zoomable-image-gallery/constants.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/index.d.ts +1 -0
- package/lib/typescript/module/zoomable-image-gallery/index.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/types.d.ts +6 -0
- package/lib/typescript/module/zoomable-image-gallery/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/Command.test.tsx +122 -1
- package/src/command/Command.tsx +50 -3
- package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +420 -19
- package/src/zoomable-image-gallery/constants.ts +15 -0
- package/src/zoomable-image-gallery/index.ts +1 -0
- package/src/zoomable-image-gallery/types.ts +6 -0
|
@@ -12,8 +12,10 @@ var _reactNativeReanimated = _interopRequireWildcard(require("react-native-reani
|
|
|
12
12
|
var _reactNativeGestureHandler = require("react-native-gesture-handler");
|
|
13
13
|
var _useTheme = require("../theme/use-theme.js");
|
|
14
14
|
var _portal = require("../portal");
|
|
15
|
+
var _index = require("../pressable-with-hover/index.js");
|
|
16
|
+
var _index2 = require("../icons/index.js");
|
|
15
17
|
var _zIndex = require("../styles/z-index.js");
|
|
16
|
-
var
|
|
18
|
+
var _index3 = require("../image-aspect-ratio-cache/index.js");
|
|
17
19
|
var _constants = require("./constants.js");
|
|
18
20
|
var _jsxRuntime = require("react/jsx-runtime");
|
|
19
21
|
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
@@ -56,7 +58,8 @@ const webUserSelectNoneStyle = _reactNative.Platform.select({
|
|
|
56
58
|
*/
|
|
57
59
|
const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
58
60
|
measureThumb,
|
|
59
|
-
cornerRadius = _constants.DEFAULT_CORNER_RADIUS
|
|
61
|
+
cornerRadius = _constants.DEFAULT_CORNER_RADIUS,
|
|
62
|
+
indicatorVariant = 'dots'
|
|
60
63
|
}, ref) => {
|
|
61
64
|
const theme = (0, _useTheme.useTheme)();
|
|
62
65
|
const {
|
|
@@ -68,15 +71,37 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
68
71
|
const [pagerReady, setPagerReady] = (0, _react.useState)(false);
|
|
69
72
|
const [images, setImages] = (0, _react.useState)([]);
|
|
70
73
|
const [activeIndex, setActiveIndex] = (0, _react.useState)(0);
|
|
74
|
+
// Whether the active image is currently pinched/double-tapped past its base
|
|
75
|
+
// size. Drives the pan-while-zoomed gesture, disables the pager's horizontal
|
|
76
|
+
// paging + the dismiss drag so a pan moves the zoomed image instead.
|
|
77
|
+
const [isZoomed, setIsZoomed] = (0, _react.useState)(false);
|
|
71
78
|
// Aspect ratio of the OPENING image (drives the open-animation fit box).
|
|
72
|
-
const [openRatio, setOpenRatio] = (0, _react.useState)(
|
|
79
|
+
const [openRatio, setOpenRatio] = (0, _react.useState)(_index3.DEFAULT_ASPECT_RATIO);
|
|
73
80
|
// Per-image aspect ratios for the pager pages (index-aligned with `images`).
|
|
74
81
|
const [pageRatios, setPageRatios] = (0, _react.useState)({});
|
|
82
|
+
|
|
83
|
+
// Native `Share` is always available; web exposes a share button only when the
|
|
84
|
+
// browser implements `navigator.share`. Computed once (capability is static).
|
|
85
|
+
const [canShare] = (0, _react.useState)(() => {
|
|
86
|
+
if (_reactNative.Platform.OS !== 'web') return true;
|
|
87
|
+
return typeof navigator !== 'undefined' && typeof navigator.share === 'function';
|
|
88
|
+
});
|
|
75
89
|
const scale = (0, _reactNativeReanimated.useSharedValue)(1);
|
|
76
90
|
const translateX = (0, _reactNativeReanimated.useSharedValue)(0);
|
|
77
91
|
const translateY = (0, _reactNativeReanimated.useSharedValue)(0);
|
|
78
92
|
const opacity = (0, _reactNativeReanimated.useSharedValue)(0);
|
|
79
93
|
|
|
94
|
+
// Zoom transform of the active image, kept SEPARATE from the open/close +
|
|
95
|
+
// dismiss-drag transform above (reusing those would collide two meanings).
|
|
96
|
+
// `zoom*` are the live/persisted values; `baseZoomScale` is captured at pinch
|
|
97
|
+
// start and `savedZoomTranslate*` at pan start for incremental bookkeeping.
|
|
98
|
+
const zoomScale = (0, _reactNativeReanimated.useSharedValue)(1);
|
|
99
|
+
const zoomTranslateX = (0, _reactNativeReanimated.useSharedValue)(0);
|
|
100
|
+
const zoomTranslateY = (0, _reactNativeReanimated.useSharedValue)(0);
|
|
101
|
+
const baseZoomScale = (0, _reactNativeReanimated.useSharedValue)(1);
|
|
102
|
+
const savedZoomTranslateX = (0, _reactNativeReanimated.useSharedValue)(0);
|
|
103
|
+
const savedZoomTranslateY = (0, _reactNativeReanimated.useSharedValue)(0);
|
|
104
|
+
|
|
80
105
|
// Origin (offset from screen center) of the tapped image.
|
|
81
106
|
const originX = (0, _reactNativeReanimated.useSharedValue)(0);
|
|
82
107
|
const originY = (0, _reactNativeReanimated.useSharedValue)(0);
|
|
@@ -103,6 +128,19 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
103
128
|
setActiveIndex(prev => prev === next ? prev : next);
|
|
104
129
|
}, []);
|
|
105
130
|
|
|
131
|
+
// Snap the active image back to its un-zoomed baseline (no animation) and clear
|
|
132
|
+
// all zoom bookkeeping. Called on every path that changes the active page so a
|
|
133
|
+
// freshly-viewed image always starts un-zoomed.
|
|
134
|
+
const resetZoom = (0, _react.useCallback)(() => {
|
|
135
|
+
zoomScale.value = 1;
|
|
136
|
+
zoomTranslateX.value = 0;
|
|
137
|
+
zoomTranslateY.value = 0;
|
|
138
|
+
savedZoomTranslateX.value = 0;
|
|
139
|
+
savedZoomTranslateY.value = 0;
|
|
140
|
+
baseZoomScale.value = 1;
|
|
141
|
+
setIsZoomed(false);
|
|
142
|
+
}, [baseZoomScale, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
|
|
143
|
+
|
|
106
144
|
// Box the fitted image must fit inside.
|
|
107
145
|
const fitBox = (0, _react.useMemo)(() => ({
|
|
108
146
|
width: SCREEN_WIDTH * _constants.FIT_FRACTION,
|
|
@@ -112,7 +150,7 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
112
150
|
// Compute the largest width/height for `ratio` that fits inside `fitBox`
|
|
113
151
|
// without cropping (contain).
|
|
114
152
|
const fitForRatio = (0, _react.useCallback)(ratio => {
|
|
115
|
-
const safeRatio = ratio > 0 && Number.isFinite(ratio) ? ratio :
|
|
153
|
+
const safeRatio = ratio > 0 && Number.isFinite(ratio) ? ratio : _index3.DEFAULT_ASPECT_RATIO;
|
|
116
154
|
let width = fitBox.width;
|
|
117
155
|
let height = width / safeRatio;
|
|
118
156
|
if (height > fitBox.height) {
|
|
@@ -136,7 +174,7 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
136
174
|
// as a caption at the bottom of the viewer (Bluesky-style lightbox footer).
|
|
137
175
|
const activeAlt = images[activeIndex]?.alt?.trim() || undefined;
|
|
138
176
|
const ensureRatio = (0, _react.useCallback)((index, uri) => {
|
|
139
|
-
const cached = (0,
|
|
177
|
+
const cached = (0, _index3.getAspectRatio)(uri);
|
|
140
178
|
if (cached !== undefined) {
|
|
141
179
|
setPageRatios(prev => prev[index] === cached ? prev : {
|
|
142
180
|
...prev,
|
|
@@ -144,7 +182,7 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
144
182
|
});
|
|
145
183
|
return;
|
|
146
184
|
}
|
|
147
|
-
void (0,
|
|
185
|
+
void (0, _index3.fetchAspectRatio)(uri).then(ratio => {
|
|
148
186
|
setPageRatios(prev => prev[index] === ratio ? prev : {
|
|
149
187
|
...prev,
|
|
150
188
|
[index]: ratio
|
|
@@ -261,8 +299,8 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
261
299
|
const safeIndex = Math.min(Math.max(index, 0), nextImages.length - 1);
|
|
262
300
|
const target = nextImages[safeIndex];
|
|
263
301
|
if (!target) return;
|
|
264
|
-
const knownRatio = target.aspectRatio ?? (0,
|
|
265
|
-
const ratio = knownRatio ??
|
|
302
|
+
const knownRatio = target.aspectRatio ?? (0, _index3.getAspectRatio)(target.uri);
|
|
303
|
+
const ratio = knownRatio ?? _index3.DEFAULT_ASPECT_RATIO;
|
|
266
304
|
setImages(nextImages);
|
|
267
305
|
setActiveIndexBoth(safeIndex);
|
|
268
306
|
setOpenRatio(ratio);
|
|
@@ -271,7 +309,7 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
271
309
|
// a genuinely-unknown image falls through to `ensureRatio`'s async probe.
|
|
272
310
|
const initialRatios = {};
|
|
273
311
|
nextImages.forEach((img, i) => {
|
|
274
|
-
const r = img.aspectRatio ?? (0,
|
|
312
|
+
const r = img.aspectRatio ?? (0, _index3.getAspectRatio)(img.uri);
|
|
275
313
|
if (r !== undefined) initialRatios[i] = r;
|
|
276
314
|
});
|
|
277
315
|
setPageRatios(initialRatios);
|
|
@@ -279,7 +317,7 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
279
317
|
|
|
280
318
|
// Resolve the opening ratio if it was not yet known, then re-fit.
|
|
281
319
|
if (knownRatio === undefined) {
|
|
282
|
-
void (0,
|
|
320
|
+
void (0, _index3.fetchAspectRatio)(target.uri).then(resolved => {
|
|
283
321
|
setOpenRatio(resolved);
|
|
284
322
|
setPageRatios(prev => ({
|
|
285
323
|
...prev,
|
|
@@ -346,7 +384,10 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
346
384
|
_react.default.useImperativeHandle(ref, () => ({
|
|
347
385
|
open
|
|
348
386
|
}), [open]);
|
|
349
|
-
const panGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Pan()
|
|
387
|
+
const panGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Pan()
|
|
388
|
+
// Disabled while zoomed so a drag pans the zoomed image (via the
|
|
389
|
+
// per-page pan gesture) instead of trying to dismiss the whole viewer.
|
|
390
|
+
.enabled(isOpen && !isZoomed)
|
|
350
391
|
// Only claim drags that are decided to be vertical; horizontal drags
|
|
351
392
|
// fall through to the pager's ScrollView so swiping changes pages.
|
|
352
393
|
.activeOffsetY([-_constants.AXIS_DECISION_OFFSET, _constants.AXIS_DECISION_OFFSET]).failOffsetX([-_constants.AXIS_DECISION_OFFSET, _constants.AXIS_DECISION_OFFSET]).onStart(() => {
|
|
@@ -373,7 +414,7 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
373
414
|
duration: _constants.OPACITY_DURATION
|
|
374
415
|
});
|
|
375
416
|
}
|
|
376
|
-
}), [handleDismiss, isOpen, SCREEN_HEIGHT, opacity, scale, startScale, startX, startY, translateX, translateY]);
|
|
417
|
+
}), [handleDismiss, isOpen, isZoomed, SCREEN_HEIGHT, opacity, scale, startScale, startX, startY, translateX, translateY]);
|
|
377
418
|
const backdropStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({
|
|
378
419
|
opacity: opacity.value
|
|
379
420
|
}));
|
|
@@ -401,6 +442,18 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
401
442
|
}]
|
|
402
443
|
}));
|
|
403
444
|
|
|
445
|
+
// Pinch/double-tap zoom transform, layered on TOP of the active page image's
|
|
446
|
+
// `fit`-based sizing (composed, never replacing it).
|
|
447
|
+
const zoomImageStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({
|
|
448
|
+
transform: [{
|
|
449
|
+
scale: zoomScale.value
|
|
450
|
+
}, {
|
|
451
|
+
translateX: zoomTranslateX.value
|
|
452
|
+
}, {
|
|
453
|
+
translateY: zoomTranslateY.value
|
|
454
|
+
}]
|
|
455
|
+
}));
|
|
456
|
+
|
|
404
457
|
// Derive the current page from the scroll offset, clamp into range, and update
|
|
405
458
|
// `activeIndex` only when it actually changes (drives the live indicator + the
|
|
406
459
|
// close fly-back target). Shared by `onMomentumScrollEnd` (native) and `onScroll`
|
|
@@ -410,10 +463,29 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
410
463
|
if (lastIndex < 0) return;
|
|
411
464
|
const next = Math.min(Math.max(Math.round(offsetX / SCREEN_WIDTH), 0), lastIndex);
|
|
412
465
|
if (next === activeIndexRef.current) return;
|
|
466
|
+
// Swiping to a new page always starts it un-zoomed (and clears the zoom on
|
|
467
|
+
// the page scrolling away).
|
|
468
|
+
resetZoom();
|
|
413
469
|
setActiveIndexBoth(next);
|
|
414
470
|
const img = images[next];
|
|
415
471
|
if (img) ensureRatio(next, img.uri);
|
|
416
|
-
}, [ensureRatio, images, setActiveIndexBoth, SCREEN_WIDTH]);
|
|
472
|
+
}, [ensureRatio, images, resetZoom, setActiveIndexBoth, SCREEN_WIDTH]);
|
|
473
|
+
|
|
474
|
+
// Programmatically page to `index` (arrow buttons, keyboard, thumbnail taps).
|
|
475
|
+
// Clamps into range and lets the existing scroll handlers own the index/ratio
|
|
476
|
+
// state-sync — this only triggers the scroll. No-op until the pager is mounted.
|
|
477
|
+
const pageTo = (0, _react.useCallback)(index => {
|
|
478
|
+
if (!pagerReady) return;
|
|
479
|
+
const lastIndex = images.length - 1;
|
|
480
|
+
if (lastIndex < 0) return;
|
|
481
|
+
const clamped = Math.min(Math.max(index, 0), lastIndex);
|
|
482
|
+
resetZoom();
|
|
483
|
+
pagerRef.current?.scrollTo({
|
|
484
|
+
x: clamped * SCREEN_WIDTH,
|
|
485
|
+
y: 0,
|
|
486
|
+
animated: true
|
|
487
|
+
});
|
|
488
|
+
}, [images.length, pagerReady, resetZoom, SCREEN_WIDTH]);
|
|
417
489
|
const onPagerScroll = (0, _react.useCallback)(event => {
|
|
418
490
|
updateIndexFromOffset(event.nativeEvent.contentOffset.x);
|
|
419
491
|
}, [updateIndexFromOffset]);
|
|
@@ -429,6 +501,124 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
429
501
|
});
|
|
430
502
|
}
|
|
431
503
|
}, [SCREEN_WIDTH]);
|
|
504
|
+
|
|
505
|
+
// Double-tap toggles zoom: reset when already zoomed, else zoom to the tapped
|
|
506
|
+
// point (biased toward it, clamped near the image center). `x`/`y` are local
|
|
507
|
+
// to the active image, so its own fitted box gives the center + clamp bounds.
|
|
508
|
+
const zoomToPoint = (0, _react.useCallback)((x, y) => {
|
|
509
|
+
if (zoomScale.value > 1) {
|
|
510
|
+
resetZoom();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const centerX = activeFit.width / 2;
|
|
514
|
+
const centerY = activeFit.height / 2;
|
|
515
|
+
const maxOffset = Math.max(activeFit.width, activeFit.height) * 0.2;
|
|
516
|
+
const offsetX = Math.max(-maxOffset, Math.min(maxOffset, (centerX - x) * 0.5));
|
|
517
|
+
const offsetY = Math.max(-maxOffset, Math.min(maxOffset, (centerY - y) * 0.5));
|
|
518
|
+
zoomScale.value = (0, _reactNativeReanimated.withSpring)(_constants.DOUBLE_TAP_ZOOM_SCALE);
|
|
519
|
+
zoomTranslateX.value = (0, _reactNativeReanimated.withSpring)(offsetX);
|
|
520
|
+
zoomTranslateY.value = (0, _reactNativeReanimated.withSpring)(offsetY);
|
|
521
|
+
savedZoomTranslateX.value = offsetX;
|
|
522
|
+
savedZoomTranslateY.value = offsetY;
|
|
523
|
+
baseZoomScale.value = _constants.DOUBLE_TAP_ZOOM_SCALE;
|
|
524
|
+
setIsZoomed(true);
|
|
525
|
+
}, [activeFit, baseZoomScale, resetZoom, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
|
|
526
|
+
const doubleTapGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Tap().numberOfTaps(2).onEnd(event => {
|
|
527
|
+
(0, _reactNativeReanimated.runOnJS)(zoomToPoint)(event.x, event.y);
|
|
528
|
+
}), [zoomToPoint]);
|
|
529
|
+
|
|
530
|
+
// Single tap dismisses — but only when un-zoomed (a tap while zoomed is inert;
|
|
531
|
+
// double-tap/pinch own un-zooming). Made exclusive with the double-tap below so
|
|
532
|
+
// the first tap of a double-tap never dismisses.
|
|
533
|
+
const singleTapDismissGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Tap().numberOfTaps(1).onEnd(() => {
|
|
534
|
+
if (zoomScale.value > 1) return;
|
|
535
|
+
(0, _reactNativeReanimated.runOnJS)(handleDismiss)();
|
|
536
|
+
}), [handleDismiss, zoomScale]);
|
|
537
|
+
const pinchGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Pinch().onStart(() => {
|
|
538
|
+
baseZoomScale.value = zoomScale.value;
|
|
539
|
+
(0, _reactNativeReanimated.runOnJS)(setIsZoomed)(true);
|
|
540
|
+
}).onUpdate(event => {
|
|
541
|
+
zoomScale.value = Math.max(_constants.MIN_ZOOM_SCALE, Math.min(_constants.MAX_ZOOM_SCALE, baseZoomScale.value * (event.scale || 1)));
|
|
542
|
+
}).onEnd(() => {
|
|
543
|
+
if (zoomScale.value < _constants.ZOOM_SNAP_THRESHOLD) {
|
|
544
|
+
zoomScale.value = (0, _reactNativeReanimated.withSpring)(1);
|
|
545
|
+
zoomTranslateX.value = (0, _reactNativeReanimated.withSpring)(0);
|
|
546
|
+
zoomTranslateY.value = (0, _reactNativeReanimated.withSpring)(0);
|
|
547
|
+
savedZoomTranslateX.value = 0;
|
|
548
|
+
savedZoomTranslateY.value = 0;
|
|
549
|
+
baseZoomScale.value = 1;
|
|
550
|
+
(0, _reactNativeReanimated.runOnJS)(setIsZoomed)(false);
|
|
551
|
+
} else {
|
|
552
|
+
(0, _reactNativeReanimated.runOnJS)(setIsZoomed)(true);
|
|
553
|
+
}
|
|
554
|
+
}), [baseZoomScale, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
|
|
555
|
+
const panWhileZoomedGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Pan().enabled(isZoomed).minPointers(1).maxPointers(1).onStart(() => {
|
|
556
|
+
if (zoomScale.value <= 1) return;
|
|
557
|
+
savedZoomTranslateX.value = zoomTranslateX.value;
|
|
558
|
+
savedZoomTranslateY.value = zoomTranslateY.value;
|
|
559
|
+
}).onUpdate(event => {
|
|
560
|
+
if (zoomScale.value <= 1) return;
|
|
561
|
+
const nextX = savedZoomTranslateX.value + event.translationX * _constants.ZOOM_PAN_DAMPING;
|
|
562
|
+
const nextY = savedZoomTranslateY.value + event.translationY * _constants.ZOOM_PAN_DAMPING;
|
|
563
|
+
const maxTranslation = _constants.ZOOM_PAN_MAX_BASE * zoomScale.value;
|
|
564
|
+
zoomTranslateX.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextX));
|
|
565
|
+
zoomTranslateY.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextY));
|
|
566
|
+
}).onEnd(() => {
|
|
567
|
+
if (zoomScale.value <= 1) return;
|
|
568
|
+
savedZoomTranslateX.value = zoomTranslateX.value;
|
|
569
|
+
savedZoomTranslateY.value = zoomTranslateY.value;
|
|
570
|
+
}), [isZoomed, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
|
|
571
|
+
|
|
572
|
+
// Active page gesture: double-tap zoom + single-tap dismiss are mutually
|
|
573
|
+
// exclusive (single waits for the double to fail); both run simultaneously
|
|
574
|
+
// with pinch + pan-while-zoomed.
|
|
575
|
+
const activePageGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Simultaneous(_reactNativeGestureHandler.Gesture.Exclusive(doubleTapGesture, singleTapDismissGesture), pinchGesture, panWhileZoomedGesture), [doubleTapGesture, singleTapDismissGesture, pinchGesture, panWhileZoomedGesture]);
|
|
576
|
+
|
|
577
|
+
// Share the active image's URI: the OS share sheet on native; the Web Share API
|
|
578
|
+
// on supporting browsers (the button is only rendered where it exists).
|
|
579
|
+
const handleShare = (0, _react.useCallback)(async () => {
|
|
580
|
+
const uri = images[activeIndexRef.current]?.uri;
|
|
581
|
+
if (!uri) return;
|
|
582
|
+
try {
|
|
583
|
+
if (_reactNative.Platform.OS === 'web') {
|
|
584
|
+
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
|
585
|
+
await navigator.share({
|
|
586
|
+
url: uri
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
await _reactNative.Share.share({
|
|
592
|
+
url: uri
|
|
593
|
+
});
|
|
594
|
+
} catch (err) {
|
|
595
|
+
// A user-cancelled share sheet rejects (AbortError on web) — that's
|
|
596
|
+
// expected, not a failure. Anything else is a real error worth surfacing.
|
|
597
|
+
if (err instanceof Error && err.name === 'AbortError') return;
|
|
598
|
+
if (typeof console !== 'undefined' && console.error) console.error('Image share failed:', err);
|
|
599
|
+
}
|
|
600
|
+
}, [images]);
|
|
601
|
+
|
|
602
|
+
// Keyboard navigation (web only), scoped to the open lifetime like Dialog.web's
|
|
603
|
+
// Escape handler. Reads the live index from the ref so it need not re-subscribe
|
|
604
|
+
// on every page change. Escape flies back via `handleDismiss` (not a raw close).
|
|
605
|
+
(0, _react.useEffect)(() => {
|
|
606
|
+
if (!isOpen || _reactNative.Platform.OS !== 'web' || typeof document === 'undefined') return;
|
|
607
|
+
const handler = e => {
|
|
608
|
+
if (e.key === 'Escape') {
|
|
609
|
+
e.stopPropagation();
|
|
610
|
+
handleDismiss();
|
|
611
|
+
} else if (e.key === 'ArrowLeft') {
|
|
612
|
+
e.stopPropagation();
|
|
613
|
+
pageTo(activeIndexRef.current - 1);
|
|
614
|
+
} else if (e.key === 'ArrowRight') {
|
|
615
|
+
e.stopPropagation();
|
|
616
|
+
pageTo(activeIndexRef.current + 1);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
document.addEventListener('keydown', handler);
|
|
620
|
+
return () => document.removeEventListener('keydown', handler);
|
|
621
|
+
}, [handleDismiss, isOpen, pageTo]);
|
|
432
622
|
const renderContent = () => /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeGestureHandler.GestureHandlerRootView, {
|
|
433
623
|
style: styles.modalContainer,
|
|
434
624
|
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
|
|
@@ -477,7 +667,11 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
477
667
|
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
|
|
478
668
|
ref: pagerRef,
|
|
479
669
|
horizontal: true,
|
|
480
|
-
pagingEnabled: true
|
|
670
|
+
pagingEnabled: true
|
|
671
|
+
// Frozen while zoomed so a horizontal drag pans the zoomed image
|
|
672
|
+
// instead of paging away from it.
|
|
673
|
+
,
|
|
674
|
+
scrollEnabled: !isZoomed,
|
|
481
675
|
showsHorizontalScrollIndicator: false,
|
|
482
676
|
contentOffset: {
|
|
483
677
|
x: pendingIndexRef.current * SCREEN_WIDTH,
|
|
@@ -491,14 +685,43 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
491
685
|
scrollEventThrottle: 16,
|
|
492
686
|
style: _reactNative.StyleSheet.absoluteFill,
|
|
493
687
|
children: images.map((img, idx) => {
|
|
494
|
-
const ratio = pageRatios[idx] ?? (0,
|
|
688
|
+
const ratio = pageRatios[idx] ?? (0, _index3.getAspectRatio)(img.uri) ?? _index3.DEFAULT_ASPECT_RATIO;
|
|
495
689
|
const fit = fitForRatio(ratio);
|
|
496
|
-
|
|
497
|
-
|
|
690
|
+
// Only the active page is zoomable + gesture-wrapped (double-tap
|
|
691
|
+
// / single-tap dismiss / pinch / pan). Off-screen pages stay a
|
|
692
|
+
// plain image; the active image handles its own tap-to-dismiss
|
|
693
|
+
// through the gesture, so it isn't wrapped in a Pressable.
|
|
694
|
+
if (idx === activeIndex) {
|
|
695
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
696
|
+
style: [styles.page, {
|
|
697
|
+
width: SCREEN_WIDTH,
|
|
698
|
+
height: SCREEN_HEIGHT
|
|
699
|
+
}, webPointerStyle],
|
|
700
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, {
|
|
701
|
+
gesture: activePageGesture,
|
|
702
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(AnimatedImage, {
|
|
703
|
+
source: {
|
|
704
|
+
uri: img.uri
|
|
705
|
+
},
|
|
706
|
+
contentFit: "contain",
|
|
707
|
+
style: [{
|
|
708
|
+
width: fit.width,
|
|
709
|
+
height: fit.height,
|
|
710
|
+
borderRadius: cornerRadius
|
|
711
|
+
}, zoomImageStyle],
|
|
712
|
+
transition: 0,
|
|
713
|
+
...(_reactNative.Platform.OS === 'web' ? {
|
|
714
|
+
draggable: false
|
|
715
|
+
} : {})
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
}, `${img.uri}-${idx}`);
|
|
719
|
+
}
|
|
720
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
498
721
|
style: [styles.page, {
|
|
499
722
|
width: SCREEN_WIDTH,
|
|
500
723
|
height: SCREEN_HEIGHT
|
|
501
|
-
}
|
|
724
|
+
}],
|
|
502
725
|
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_expoImage.Image, {
|
|
503
726
|
source: {
|
|
504
727
|
uri: img.uri
|
|
@@ -519,19 +742,75 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
|
|
|
519
742
|
})
|
|
520
743
|
}), pagerReady && images.length > 1 && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeReanimated.default.View, {
|
|
521
744
|
style: [styles.indicatorWrap, backdropStyle],
|
|
522
|
-
pointerEvents: "none",
|
|
745
|
+
pointerEvents: "box-none",
|
|
523
746
|
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
524
747
|
style: styles.counterPill,
|
|
748
|
+
pointerEvents: "none",
|
|
525
749
|
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
|
|
526
750
|
style: styles.counterText,
|
|
527
751
|
children: `${activeIndex + 1} / ${images.length}`
|
|
528
752
|
})
|
|
529
|
-
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.
|
|
753
|
+
}), indicatorVariant === 'thumbnails' ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
|
|
754
|
+
horizontal: true,
|
|
755
|
+
showsHorizontalScrollIndicator: false,
|
|
756
|
+
style: styles.thumbStripScroll,
|
|
757
|
+
contentContainerStyle: styles.thumbStripContent,
|
|
758
|
+
children: images.map((img, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
|
|
759
|
+
onPress: () => pageTo(idx),
|
|
760
|
+
accessibilityRole: "button",
|
|
761
|
+
accessibilityLabel: `Go to image ${idx + 1} of ${images.length}`,
|
|
762
|
+
style: [styles.thumbTile, idx === activeIndex ? styles.thumbTileActive : styles.thumbTileInactive, webPointerStyle],
|
|
763
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_expoImage.Image, {
|
|
764
|
+
source: {
|
|
765
|
+
uri: img.uri
|
|
766
|
+
},
|
|
767
|
+
contentFit: "cover",
|
|
768
|
+
style: styles.thumbTileImage,
|
|
769
|
+
transition: 0,
|
|
770
|
+
...(_reactNative.Platform.OS === 'web' ? {
|
|
771
|
+
draggable: false
|
|
772
|
+
} : {})
|
|
773
|
+
})
|
|
774
|
+
}, `thumb-${img.uri}-${idx}`))
|
|
775
|
+
}) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
530
776
|
style: styles.dotsRow,
|
|
777
|
+
pointerEvents: "none",
|
|
531
778
|
children: images.map((img, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
532
779
|
style: [styles.dot, idx === activeIndex ? styles.dotActive : styles.dotInactive]
|
|
533
780
|
}, `dot-${img.uri}-${idx}`))
|
|
534
781
|
})]
|
|
782
|
+
}), _reactNative.Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_index.PressableWithHover, {
|
|
783
|
+
onPress: () => pageTo(activeIndex - 1),
|
|
784
|
+
accessibilityRole: "button",
|
|
785
|
+
accessibilityLabel: "Previous image",
|
|
786
|
+
hitSlop: 8,
|
|
787
|
+
style: [styles.navArrow, styles.navArrowLeft, webPointerStyle],
|
|
788
|
+
hoverStyle: styles.navArrowHoverLeft,
|
|
789
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.ArrowLeft_Stroke2_Corner0_Rounded, {
|
|
790
|
+
fill: "#fff",
|
|
791
|
+
size: "lg"
|
|
792
|
+
})
|
|
793
|
+
}), _reactNative.Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex < images.length - 1 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_index.PressableWithHover, {
|
|
794
|
+
onPress: () => pageTo(activeIndex + 1),
|
|
795
|
+
accessibilityRole: "button",
|
|
796
|
+
accessibilityLabel: "Next image",
|
|
797
|
+
hitSlop: 8,
|
|
798
|
+
style: [styles.navArrow, styles.navArrowRight, webPointerStyle],
|
|
799
|
+
hoverStyle: styles.navArrowHoverRight,
|
|
800
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.ArrowRight_Stroke2_Corner0_Rounded, {
|
|
801
|
+
fill: "#fff",
|
|
802
|
+
size: "lg"
|
|
803
|
+
})
|
|
804
|
+
}), canShare && pagerReady && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
|
|
805
|
+
onPress: handleShare,
|
|
806
|
+
accessibilityRole: "button",
|
|
807
|
+
accessibilityLabel: "Share image",
|
|
808
|
+
hitSlop: 8,
|
|
809
|
+
style: [styles.shareButton, webPointerStyle],
|
|
810
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.ArrowOutOfBox_Stroke2_Corner0_Rounded, {
|
|
811
|
+
fill: "#fff",
|
|
812
|
+
size: "md"
|
|
813
|
+
})
|
|
535
814
|
}), activeAlt ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
|
|
536
815
|
style: [styles.altCaptionWrap, images.length > 1 && styles.altCaptionWrapWithIndicator, backdropStyle],
|
|
537
816
|
pointerEvents: "none",
|
|
@@ -635,6 +914,82 @@ const styles = _reactNative.StyleSheet.create({
|
|
|
635
914
|
dotInactive: {
|
|
636
915
|
backgroundColor: 'rgba(255,255,255,0.4)'
|
|
637
916
|
},
|
|
917
|
+
thumbStripScroll: {
|
|
918
|
+
maxWidth: '100%',
|
|
919
|
+
flexGrow: 0
|
|
920
|
+
},
|
|
921
|
+
// Centers the tiles when they don't fill the width, scrolls when they overflow.
|
|
922
|
+
thumbStripContent: {
|
|
923
|
+
flexGrow: 1,
|
|
924
|
+
justifyContent: 'center',
|
|
925
|
+
alignItems: 'center',
|
|
926
|
+
gap: 8,
|
|
927
|
+
paddingHorizontal: 16
|
|
928
|
+
},
|
|
929
|
+
thumbTile: {
|
|
930
|
+
width: _constants.THUMBNAIL_STRIP_TILE_SIZE,
|
|
931
|
+
height: _constants.THUMBNAIL_STRIP_TILE_SIZE,
|
|
932
|
+
borderRadius: 8,
|
|
933
|
+
overflow: 'hidden',
|
|
934
|
+
borderWidth: 2
|
|
935
|
+
},
|
|
936
|
+
thumbTileActive: {
|
|
937
|
+
borderColor: '#fff'
|
|
938
|
+
},
|
|
939
|
+
thumbTileInactive: {
|
|
940
|
+
borderColor: 'rgba(255,255,255,0.25)'
|
|
941
|
+
},
|
|
942
|
+
thumbTileImage: {
|
|
943
|
+
width: '100%',
|
|
944
|
+
height: '100%'
|
|
945
|
+
},
|
|
946
|
+
navArrow: {
|
|
947
|
+
position: 'absolute',
|
|
948
|
+
top: '50%',
|
|
949
|
+
width: 44,
|
|
950
|
+
height: 44,
|
|
951
|
+
borderRadius: 999,
|
|
952
|
+
alignItems: 'center',
|
|
953
|
+
justifyContent: 'center',
|
|
954
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
955
|
+
transform: [{
|
|
956
|
+
translateY: -22
|
|
957
|
+
}]
|
|
958
|
+
},
|
|
959
|
+
navArrowLeft: {
|
|
960
|
+
left: 16
|
|
961
|
+
},
|
|
962
|
+
navArrowRight: {
|
|
963
|
+
right: 16
|
|
964
|
+
},
|
|
965
|
+
// Hover styles fully replace the base `transform`, so they repeat `translateY`.
|
|
966
|
+
navArrowHoverLeft: {
|
|
967
|
+
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
968
|
+
transform: [{
|
|
969
|
+
translateY: -22
|
|
970
|
+
}, {
|
|
971
|
+
scale: 1.08
|
|
972
|
+
}]
|
|
973
|
+
},
|
|
974
|
+
navArrowHoverRight: {
|
|
975
|
+
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
976
|
+
transform: [{
|
|
977
|
+
translateY: -22
|
|
978
|
+
}, {
|
|
979
|
+
scale: 1.08
|
|
980
|
+
}]
|
|
981
|
+
},
|
|
982
|
+
shareButton: {
|
|
983
|
+
position: 'absolute',
|
|
984
|
+
top: 16,
|
|
985
|
+
right: 16,
|
|
986
|
+
width: 40,
|
|
987
|
+
height: 40,
|
|
988
|
+
borderRadius: 999,
|
|
989
|
+
alignItems: 'center',
|
|
990
|
+
justifyContent: 'center',
|
|
991
|
+
backgroundColor: 'rgba(0,0,0,0.45)'
|
|
992
|
+
},
|
|
638
993
|
altCaptionWrap: {
|
|
639
994
|
position: 'absolute',
|
|
640
995
|
bottom: 48,
|