@oxyhq/bloom 0.33.1 → 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.
Files changed (31) hide show
  1. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +375 -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 +368 -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 +420 -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,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, _index.getAspectRatio)(img.uri) ?? _index.DEFAULT_ASPECT_RATIO;
688
+ const ratio = pageRatios[idx] ?? (0, _index3.getAspectRatio)(img.uri) ?? _index3.DEFAULT_ASPECT_RATIO;
495
689
  const fit = fitForRatio(ratio);
496
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Pressable, {
497
- onPress: handleDismiss,
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
- }, webPointerStyle],
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.View, {
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,