@oxyhq/bloom 0.33.1 → 0.34.1

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.
Files changed (31) hide show
  1. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +389 -20
  2. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  3. package/lib/commonjs/zoomable-image-gallery/constants.js +16 -1
  4. package/lib/commonjs/zoomable-image-gallery/constants.js.map +1 -1
  5. package/lib/commonjs/zoomable-image-gallery/index.js +15 -0
  6. package/lib/commonjs/zoomable-image-gallery/index.js.map +1 -1
  7. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +382 -13
  8. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  9. package/lib/module/zoomable-image-gallery/constants.js +15 -0
  10. package/lib/module/zoomable-image-gallery/constants.js.map +1 -1
  11. package/lib/module/zoomable-image-gallery/index.js +1 -0
  12. package/lib/module/zoomable-image-gallery/index.js.map +1 -1
  13. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  14. package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts +14 -0
  15. package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts.map +1 -1
  16. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts +1 -0
  17. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts.map +1 -1
  18. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts +6 -0
  19. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts.map +1 -1
  20. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  21. package/lib/typescript/module/zoomable-image-gallery/constants.d.ts +14 -0
  22. package/lib/typescript/module/zoomable-image-gallery/constants.d.ts.map +1 -1
  23. package/lib/typescript/module/zoomable-image-gallery/index.d.ts +1 -0
  24. package/lib/typescript/module/zoomable-image-gallery/index.d.ts.map +1 -1
  25. package/lib/typescript/module/zoomable-image-gallery/types.d.ts +6 -0
  26. package/lib/typescript/module/zoomable-image-gallery/types.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +435 -19
  29. package/src/zoomable-image-gallery/constants.ts +15 -0
  30. package/src/zoomable-image-gallery/index.ts +1 -0
  31. 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 _index = require("../image-aspect-ratio-cache/index.js");
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)(_index.DEFAULT_ASPECT_RATIO);
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 : _index.DEFAULT_ASPECT_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, _index.getAspectRatio)(uri);
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, _index.fetchAspectRatio)(uri).then(ratio => {
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, _index.getAspectRatio)(target.uri);
265
- const ratio = knownRatio ?? _index.DEFAULT_ASPECT_RATIO;
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, _index.getAspectRatio)(img.uri);
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, _index.fetchAspectRatio)(target.uri).then(resolved => {
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().enabled(isOpen)
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,138 @@ 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
+
527
+ // Double-tap-to-zoom is a touch convention — disabled on web, where neither
528
+ // Twitter's nor Instagram's lightbox gates its dismiss click behind tap-count
529
+ // discrimination. Kept native-only.
530
+ const doubleTapGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Tap().numberOfTaps(2).enabled(_reactNative.Platform.OS !== 'web').onEnd(event => {
531
+ (0, _reactNativeReanimated.runOnJS)(zoomToPoint)(event.x, event.y);
532
+ }), [zoomToPoint]);
533
+
534
+ // Single tap dismisses — but only when un-zoomed (a tap while zoomed is inert;
535
+ // double-tap/pinch own un-zooming). On native this is made exclusive with the
536
+ // double-tap above so the first tap of a double-tap never dismisses; on web
537
+ // (see `tapGesture` below) it fires immediately, matching standard web
538
+ // lightbox click-to-close.
539
+ const singleTapDismissGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Tap().numberOfTaps(1).onEnd(() => {
540
+ if (zoomScale.value > 1) return;
541
+ (0, _reactNativeReanimated.runOnJS)(handleDismiss)();
542
+ }), [handleDismiss, zoomScale]);
543
+
544
+ // On web, don't gate the dismiss tap behind double-tap-failure at all — that
545
+ // coupling requires react-native-gesture-handler's web tap-exclusivity timing
546
+ // (a ~500ms wait for the double-tap to time out) to resolve correctly for
547
+ // every single click, which is both slower than every standard web lightbox
548
+ // and the newest, least-exercised gesture code path here. Native keeps the
549
+ // Exclusive composition since double-tap-to-zoom is the expected touch
550
+ // convention there.
551
+ const tapGesture = (0, _react.useMemo)(() => _reactNative.Platform.OS === 'web' ? singleTapDismissGesture : _reactNativeGestureHandler.Gesture.Exclusive(doubleTapGesture, singleTapDismissGesture), [doubleTapGesture, singleTapDismissGesture]);
552
+ const pinchGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Pinch().onStart(() => {
553
+ baseZoomScale.value = zoomScale.value;
554
+ (0, _reactNativeReanimated.runOnJS)(setIsZoomed)(true);
555
+ }).onUpdate(event => {
556
+ zoomScale.value = Math.max(_constants.MIN_ZOOM_SCALE, Math.min(_constants.MAX_ZOOM_SCALE, baseZoomScale.value * (event.scale || 1)));
557
+ }).onEnd(() => {
558
+ if (zoomScale.value < _constants.ZOOM_SNAP_THRESHOLD) {
559
+ zoomScale.value = (0, _reactNativeReanimated.withSpring)(1);
560
+ zoomTranslateX.value = (0, _reactNativeReanimated.withSpring)(0);
561
+ zoomTranslateY.value = (0, _reactNativeReanimated.withSpring)(0);
562
+ savedZoomTranslateX.value = 0;
563
+ savedZoomTranslateY.value = 0;
564
+ baseZoomScale.value = 1;
565
+ (0, _reactNativeReanimated.runOnJS)(setIsZoomed)(false);
566
+ } else {
567
+ (0, _reactNativeReanimated.runOnJS)(setIsZoomed)(true);
568
+ }
569
+ }), [baseZoomScale, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
570
+ const panWhileZoomedGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Pan().enabled(isZoomed).minPointers(1).maxPointers(1).onStart(() => {
571
+ if (zoomScale.value <= 1) return;
572
+ savedZoomTranslateX.value = zoomTranslateX.value;
573
+ savedZoomTranslateY.value = zoomTranslateY.value;
574
+ }).onUpdate(event => {
575
+ if (zoomScale.value <= 1) return;
576
+ const nextX = savedZoomTranslateX.value + event.translationX * _constants.ZOOM_PAN_DAMPING;
577
+ const nextY = savedZoomTranslateY.value + event.translationY * _constants.ZOOM_PAN_DAMPING;
578
+ const maxTranslation = _constants.ZOOM_PAN_MAX_BASE * zoomScale.value;
579
+ zoomTranslateX.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextX));
580
+ zoomTranslateY.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextY));
581
+ }).onEnd(() => {
582
+ if (zoomScale.value <= 1) return;
583
+ savedZoomTranslateX.value = zoomTranslateX.value;
584
+ savedZoomTranslateY.value = zoomTranslateY.value;
585
+ }), [isZoomed, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
586
+
587
+ // Active page gesture: the tap gesture (see above) runs simultaneously with
588
+ // pinch + pan-while-zoomed.
589
+ const activePageGesture = (0, _react.useMemo)(() => _reactNativeGestureHandler.Gesture.Simultaneous(tapGesture, pinchGesture, panWhileZoomedGesture), [tapGesture, pinchGesture, panWhileZoomedGesture]);
590
+
591
+ // Share the active image's URI: the OS share sheet on native; the Web Share API
592
+ // on supporting browsers (the button is only rendered where it exists).
593
+ const handleShare = (0, _react.useCallback)(async () => {
594
+ const uri = images[activeIndexRef.current]?.uri;
595
+ if (!uri) return;
596
+ try {
597
+ if (_reactNative.Platform.OS === 'web') {
598
+ if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
599
+ await navigator.share({
600
+ url: uri
601
+ });
602
+ }
603
+ return;
604
+ }
605
+ await _reactNative.Share.share({
606
+ url: uri
607
+ });
608
+ } catch (err) {
609
+ // A user-cancelled share sheet rejects (AbortError on web) — that's
610
+ // expected, not a failure. Anything else is a real error worth surfacing.
611
+ if (err instanceof Error && err.name === 'AbortError') return;
612
+ if (typeof console !== 'undefined' && console.error) console.error('Image share failed:', err);
613
+ }
614
+ }, [images]);
615
+
616
+ // Keyboard navigation (web only), scoped to the open lifetime like Dialog.web's
617
+ // Escape handler. Reads the live index from the ref so it need not re-subscribe
618
+ // on every page change. Escape flies back via `handleDismiss` (not a raw close).
619
+ (0, _react.useEffect)(() => {
620
+ if (!isOpen || _reactNative.Platform.OS !== 'web' || typeof document === 'undefined') return;
621
+ const handler = e => {
622
+ if (e.key === 'Escape') {
623
+ e.stopPropagation();
624
+ handleDismiss();
625
+ } else if (e.key === 'ArrowLeft') {
626
+ e.stopPropagation();
627
+ pageTo(activeIndexRef.current - 1);
628
+ } else if (e.key === 'ArrowRight') {
629
+ e.stopPropagation();
630
+ pageTo(activeIndexRef.current + 1);
631
+ }
632
+ };
633
+ document.addEventListener('keydown', handler);
634
+ return () => document.removeEventListener('keydown', handler);
635
+ }, [handleDismiss, isOpen, pageTo]);
432
636
  const renderContent = () => /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeGestureHandler.GestureHandlerRootView, {
433
637
  style: styles.modalContainer,
434
638
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
@@ -477,7 +681,11 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
477
681
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
478
682
  ref: pagerRef,
479
683
  horizontal: true,
480
- pagingEnabled: true,
684
+ pagingEnabled: true
685
+ // Frozen while zoomed so a horizontal drag pans the zoomed image
686
+ // instead of paging away from it.
687
+ ,
688
+ scrollEnabled: !isZoomed,
481
689
  showsHorizontalScrollIndicator: false,
482
690
  contentOffset: {
483
691
  x: pendingIndexRef.current * SCREEN_WIDTH,
@@ -491,14 +699,43 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
491
699
  scrollEventThrottle: 16,
492
700
  style: _reactNative.StyleSheet.absoluteFill,
493
701
  children: images.map((img, idx) => {
494
- const ratio = pageRatios[idx] ?? (0, _index.getAspectRatio)(img.uri) ?? _index.DEFAULT_ASPECT_RATIO;
702
+ const ratio = pageRatios[idx] ?? (0, _index3.getAspectRatio)(img.uri) ?? _index3.DEFAULT_ASPECT_RATIO;
495
703
  const fit = fitForRatio(ratio);
496
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
497
- onPress: handleDismiss,
704
+ // Only the active page is zoomable + gesture-wrapped (double-tap
705
+ // / single-tap dismiss / pinch / pan). Off-screen pages stay a
706
+ // plain image; the active image handles its own tap-to-dismiss
707
+ // through the gesture, so it isn't wrapped in a Pressable.
708
+ if (idx === activeIndex) {
709
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
710
+ style: [styles.page, {
711
+ width: SCREEN_WIDTH,
712
+ height: SCREEN_HEIGHT
713
+ }, webPointerStyle],
714
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, {
715
+ gesture: activePageGesture,
716
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(AnimatedImage, {
717
+ source: {
718
+ uri: img.uri
719
+ },
720
+ contentFit: "contain",
721
+ style: [{
722
+ width: fit.width,
723
+ height: fit.height,
724
+ borderRadius: cornerRadius
725
+ }, zoomImageStyle],
726
+ transition: 0,
727
+ ...(_reactNative.Platform.OS === 'web' ? {
728
+ draggable: false
729
+ } : {})
730
+ })
731
+ })
732
+ }, `${img.uri}-${idx}`);
733
+ }
734
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
498
735
  style: [styles.page, {
499
736
  width: SCREEN_WIDTH,
500
737
  height: SCREEN_HEIGHT
501
- }, webPointerStyle],
738
+ }],
502
739
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_expoImage.Image, {
503
740
  source: {
504
741
  uri: img.uri
@@ -519,19 +756,75 @@ const ZoomableImageGalleryInner = /*#__PURE__*/_react.default.forwardRef(({
519
756
  })
520
757
  }), pagerReady && images.length > 1 && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeReanimated.default.View, {
521
758
  style: [styles.indicatorWrap, backdropStyle],
522
- pointerEvents: "none",
759
+ pointerEvents: "box-none",
523
760
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
524
761
  style: styles.counterPill,
762
+ pointerEvents: "none",
525
763
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
526
764
  style: styles.counterText,
527
765
  children: `${activeIndex + 1} / ${images.length}`
528
766
  })
529
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
767
+ }), indicatorVariant === 'thumbnails' ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
768
+ horizontal: true,
769
+ showsHorizontalScrollIndicator: false,
770
+ style: styles.thumbStripScroll,
771
+ contentContainerStyle: styles.thumbStripContent,
772
+ children: images.map((img, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
773
+ onPress: () => pageTo(idx),
774
+ accessibilityRole: "button",
775
+ accessibilityLabel: `Go to image ${idx + 1} of ${images.length}`,
776
+ style: [styles.thumbTile, idx === activeIndex ? styles.thumbTileActive : styles.thumbTileInactive, webPointerStyle],
777
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_expoImage.Image, {
778
+ source: {
779
+ uri: img.uri
780
+ },
781
+ contentFit: "cover",
782
+ style: styles.thumbTileImage,
783
+ transition: 0,
784
+ ...(_reactNative.Platform.OS === 'web' ? {
785
+ draggable: false
786
+ } : {})
787
+ })
788
+ }, `thumb-${img.uri}-${idx}`))
789
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
530
790
  style: styles.dotsRow,
791
+ pointerEvents: "none",
531
792
  children: images.map((img, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
532
793
  style: [styles.dot, idx === activeIndex ? styles.dotActive : styles.dotInactive]
533
794
  }, `dot-${img.uri}-${idx}`))
534
795
  })]
796
+ }), _reactNative.Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_index.PressableWithHover, {
797
+ onPress: () => pageTo(activeIndex - 1),
798
+ accessibilityRole: "button",
799
+ accessibilityLabel: "Previous image",
800
+ hitSlop: 8,
801
+ style: [styles.navArrow, styles.navArrowLeft, webPointerStyle],
802
+ hoverStyle: styles.navArrowHoverLeft,
803
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.ArrowLeft_Stroke2_Corner0_Rounded, {
804
+ fill: "#fff",
805
+ size: "lg"
806
+ })
807
+ }), _reactNative.Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex < images.length - 1 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_index.PressableWithHover, {
808
+ onPress: () => pageTo(activeIndex + 1),
809
+ accessibilityRole: "button",
810
+ accessibilityLabel: "Next image",
811
+ hitSlop: 8,
812
+ style: [styles.navArrow, styles.navArrowRight, webPointerStyle],
813
+ hoverStyle: styles.navArrowHoverRight,
814
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.ArrowRight_Stroke2_Corner0_Rounded, {
815
+ fill: "#fff",
816
+ size: "lg"
817
+ })
818
+ }), canShare && pagerReady && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
819
+ onPress: handleShare,
820
+ accessibilityRole: "button",
821
+ accessibilityLabel: "Share image",
822
+ hitSlop: 8,
823
+ style: [styles.shareButton, webPointerStyle],
824
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.ArrowOutOfBox_Stroke2_Corner0_Rounded, {
825
+ fill: "#fff",
826
+ size: "md"
827
+ })
535
828
  }), activeAlt ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
536
829
  style: [styles.altCaptionWrap, images.length > 1 && styles.altCaptionWrapWithIndicator, backdropStyle],
537
830
  pointerEvents: "none",
@@ -635,6 +928,82 @@ const styles = _reactNative.StyleSheet.create({
635
928
  dotInactive: {
636
929
  backgroundColor: 'rgba(255,255,255,0.4)'
637
930
  },
931
+ thumbStripScroll: {
932
+ maxWidth: '100%',
933
+ flexGrow: 0
934
+ },
935
+ // Centers the tiles when they don't fill the width, scrolls when they overflow.
936
+ thumbStripContent: {
937
+ flexGrow: 1,
938
+ justifyContent: 'center',
939
+ alignItems: 'center',
940
+ gap: 8,
941
+ paddingHorizontal: 16
942
+ },
943
+ thumbTile: {
944
+ width: _constants.THUMBNAIL_STRIP_TILE_SIZE,
945
+ height: _constants.THUMBNAIL_STRIP_TILE_SIZE,
946
+ borderRadius: 8,
947
+ overflow: 'hidden',
948
+ borderWidth: 2
949
+ },
950
+ thumbTileActive: {
951
+ borderColor: '#fff'
952
+ },
953
+ thumbTileInactive: {
954
+ borderColor: 'rgba(255,255,255,0.25)'
955
+ },
956
+ thumbTileImage: {
957
+ width: '100%',
958
+ height: '100%'
959
+ },
960
+ navArrow: {
961
+ position: 'absolute',
962
+ top: '50%',
963
+ width: 44,
964
+ height: 44,
965
+ borderRadius: 999,
966
+ alignItems: 'center',
967
+ justifyContent: 'center',
968
+ backgroundColor: 'rgba(0,0,0,0.45)',
969
+ transform: [{
970
+ translateY: -22
971
+ }]
972
+ },
973
+ navArrowLeft: {
974
+ left: 16
975
+ },
976
+ navArrowRight: {
977
+ right: 16
978
+ },
979
+ // Hover styles fully replace the base `transform`, so they repeat `translateY`.
980
+ navArrowHoverLeft: {
981
+ backgroundColor: 'rgba(0,0,0,0.7)',
982
+ transform: [{
983
+ translateY: -22
984
+ }, {
985
+ scale: 1.08
986
+ }]
987
+ },
988
+ navArrowHoverRight: {
989
+ backgroundColor: 'rgba(0,0,0,0.7)',
990
+ transform: [{
991
+ translateY: -22
992
+ }, {
993
+ scale: 1.08
994
+ }]
995
+ },
996
+ shareButton: {
997
+ position: 'absolute',
998
+ top: 16,
999
+ right: 16,
1000
+ width: 40,
1001
+ height: 40,
1002
+ borderRadius: 999,
1003
+ alignItems: 'center',
1004
+ justifyContent: 'center',
1005
+ backgroundColor: 'rgba(0,0,0,0.45)'
1006
+ },
638
1007
  altCaptionWrap: {
639
1008
  position: 'absolute',
640
1009
  bottom: 48,