@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
@@ -1,16 +1,18 @@
1
1
  "use strict";
2
2
 
3
- import React, { useCallback, useMemo, useRef, useState } from 'react';
4
- import { View, Text, StyleSheet, Pressable, Platform, ScrollView, useWindowDimensions } from 'react-native';
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { View, Text, StyleSheet, Pressable, Platform, ScrollView, Share, useWindowDimensions } from 'react-native';
5
5
  import { Image } from 'expo-image';
6
6
  import { BlurView } from 'expo-blur';
7
7
  import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, runOnJS, Easing } from 'react-native-reanimated';
8
8
  import { GestureHandlerRootView, Gesture, GestureDetector } from 'react-native-gesture-handler';
9
9
  import { useTheme } from "../theme/use-theme.js";
10
10
  import { Portal } from '../portal';
11
+ import { PressableWithHover } from "../pressable-with-hover/index.js";
12
+ import { ArrowLeft_Stroke2_Corner0_Rounded, ArrowRight_Stroke2_Corner0_Rounded, ArrowOutOfBox_Stroke2_Corner0_Rounded } from "../icons/index.js";
11
13
  import { Z_INDEX } from "../styles/z-index.js";
12
14
  import { getAspectRatio, fetchAspectRatio, DEFAULT_ASPECT_RATIO } from "../image-aspect-ratio-cache/index.js";
13
- import { FIT_FRACTION, MAX_DRAG_FRACTION, SCALE_DRAG_FRACTION, MIN_DRAG_SCALE, DISMISS_FRACTION, AXIS_DECISION_OFFSET, OPEN_DURATION_WEB, CLOSE_DURATION_WEB, OPACITY_DURATION, OPEN_SPRING, CLOSE_SPRING, SNAP_BACK_SPRING, DEFAULT_CORNER_RADIUS } from "./constants.js";
15
+ import { FIT_FRACTION, MAX_DRAG_FRACTION, SCALE_DRAG_FRACTION, MIN_DRAG_SCALE, DISMISS_FRACTION, AXIS_DECISION_OFFSET, OPEN_DURATION_WEB, CLOSE_DURATION_WEB, OPACITY_DURATION, OPEN_SPRING, CLOSE_SPRING, SNAP_BACK_SPRING, DEFAULT_CORNER_RADIUS, MIN_ZOOM_SCALE, MAX_ZOOM_SCALE, ZOOM_SNAP_THRESHOLD, DOUBLE_TAP_ZOOM_SCALE, ZOOM_PAN_DAMPING, ZOOM_PAN_MAX_BASE, THUMBNAIL_STRIP_TILE_SIZE } from "./constants.js";
14
16
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
17
  const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
16
18
  const AnimatedImage = Animated.createAnimatedComponent(Image);
@@ -51,7 +53,8 @@ const webUserSelectNoneStyle = Platform.select({
51
53
  */
52
54
  const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
53
55
  measureThumb,
54
- cornerRadius = DEFAULT_CORNER_RADIUS
56
+ cornerRadius = DEFAULT_CORNER_RADIUS,
57
+ indicatorVariant = 'dots'
55
58
  }, ref) => {
56
59
  const theme = useTheme();
57
60
  const {
@@ -63,15 +66,37 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
63
66
  const [pagerReady, setPagerReady] = useState(false);
64
67
  const [images, setImages] = useState([]);
65
68
  const [activeIndex, setActiveIndex] = useState(0);
69
+ // Whether the active image is currently pinched/double-tapped past its base
70
+ // size. Drives the pan-while-zoomed gesture, disables the pager's horizontal
71
+ // paging + the dismiss drag so a pan moves the zoomed image instead.
72
+ const [isZoomed, setIsZoomed] = useState(false);
66
73
  // Aspect ratio of the OPENING image (drives the open-animation fit box).
67
74
  const [openRatio, setOpenRatio] = useState(DEFAULT_ASPECT_RATIO);
68
75
  // Per-image aspect ratios for the pager pages (index-aligned with `images`).
69
76
  const [pageRatios, setPageRatios] = useState({});
77
+
78
+ // Native `Share` is always available; web exposes a share button only when the
79
+ // browser implements `navigator.share`. Computed once (capability is static).
80
+ const [canShare] = useState(() => {
81
+ if (Platform.OS !== 'web') return true;
82
+ return typeof navigator !== 'undefined' && typeof navigator.share === 'function';
83
+ });
70
84
  const scale = useSharedValue(1);
71
85
  const translateX = useSharedValue(0);
72
86
  const translateY = useSharedValue(0);
73
87
  const opacity = useSharedValue(0);
74
88
 
89
+ // Zoom transform of the active image, kept SEPARATE from the open/close +
90
+ // dismiss-drag transform above (reusing those would collide two meanings).
91
+ // `zoom*` are the live/persisted values; `baseZoomScale` is captured at pinch
92
+ // start and `savedZoomTranslate*` at pan start for incremental bookkeeping.
93
+ const zoomScale = useSharedValue(1);
94
+ const zoomTranslateX = useSharedValue(0);
95
+ const zoomTranslateY = useSharedValue(0);
96
+ const baseZoomScale = useSharedValue(1);
97
+ const savedZoomTranslateX = useSharedValue(0);
98
+ const savedZoomTranslateY = useSharedValue(0);
99
+
75
100
  // Origin (offset from screen center) of the tapped image.
76
101
  const originX = useSharedValue(0);
77
102
  const originY = useSharedValue(0);
@@ -98,6 +123,19 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
98
123
  setActiveIndex(prev => prev === next ? prev : next);
99
124
  }, []);
100
125
 
126
+ // Snap the active image back to its un-zoomed baseline (no animation) and clear
127
+ // all zoom bookkeeping. Called on every path that changes the active page so a
128
+ // freshly-viewed image always starts un-zoomed.
129
+ const resetZoom = useCallback(() => {
130
+ zoomScale.value = 1;
131
+ zoomTranslateX.value = 0;
132
+ zoomTranslateY.value = 0;
133
+ savedZoomTranslateX.value = 0;
134
+ savedZoomTranslateY.value = 0;
135
+ baseZoomScale.value = 1;
136
+ setIsZoomed(false);
137
+ }, [baseZoomScale, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
138
+
101
139
  // Box the fitted image must fit inside.
102
140
  const fitBox = useMemo(() => ({
103
141
  width: SCREEN_WIDTH * FIT_FRACTION,
@@ -341,7 +379,10 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
341
379
  React.useImperativeHandle(ref, () => ({
342
380
  open
343
381
  }), [open]);
344
- const panGesture = useMemo(() => Gesture.Pan().enabled(isOpen)
382
+ const panGesture = useMemo(() => Gesture.Pan()
383
+ // Disabled while zoomed so a drag pans the zoomed image (via the
384
+ // per-page pan gesture) instead of trying to dismiss the whole viewer.
385
+ .enabled(isOpen && !isZoomed)
345
386
  // Only claim drags that are decided to be vertical; horizontal drags
346
387
  // fall through to the pager's ScrollView so swiping changes pages.
347
388
  .activeOffsetY([-AXIS_DECISION_OFFSET, AXIS_DECISION_OFFSET]).failOffsetX([-AXIS_DECISION_OFFSET, AXIS_DECISION_OFFSET]).onStart(() => {
@@ -368,7 +409,7 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
368
409
  duration: OPACITY_DURATION
369
410
  });
370
411
  }
371
- }), [handleDismiss, isOpen, SCREEN_HEIGHT, opacity, scale, startScale, startX, startY, translateX, translateY]);
412
+ }), [handleDismiss, isOpen, isZoomed, SCREEN_HEIGHT, opacity, scale, startScale, startX, startY, translateX, translateY]);
372
413
  const backdropStyle = useAnimatedStyle(() => ({
373
414
  opacity: opacity.value
374
415
  }));
@@ -396,6 +437,18 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
396
437
  }]
397
438
  }));
398
439
 
440
+ // Pinch/double-tap zoom transform, layered on TOP of the active page image's
441
+ // `fit`-based sizing (composed, never replacing it).
442
+ const zoomImageStyle = useAnimatedStyle(() => ({
443
+ transform: [{
444
+ scale: zoomScale.value
445
+ }, {
446
+ translateX: zoomTranslateX.value
447
+ }, {
448
+ translateY: zoomTranslateY.value
449
+ }]
450
+ }));
451
+
399
452
  // Derive the current page from the scroll offset, clamp into range, and update
400
453
  // `activeIndex` only when it actually changes (drives the live indicator + the
401
454
  // close fly-back target). Shared by `onMomentumScrollEnd` (native) and `onScroll`
@@ -405,10 +458,29 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
405
458
  if (lastIndex < 0) return;
406
459
  const next = Math.min(Math.max(Math.round(offsetX / SCREEN_WIDTH), 0), lastIndex);
407
460
  if (next === activeIndexRef.current) return;
461
+ // Swiping to a new page always starts it un-zoomed (and clears the zoom on
462
+ // the page scrolling away).
463
+ resetZoom();
408
464
  setActiveIndexBoth(next);
409
465
  const img = images[next];
410
466
  if (img) ensureRatio(next, img.uri);
411
- }, [ensureRatio, images, setActiveIndexBoth, SCREEN_WIDTH]);
467
+ }, [ensureRatio, images, resetZoom, setActiveIndexBoth, SCREEN_WIDTH]);
468
+
469
+ // Programmatically page to `index` (arrow buttons, keyboard, thumbnail taps).
470
+ // Clamps into range and lets the existing scroll handlers own the index/ratio
471
+ // state-sync — this only triggers the scroll. No-op until the pager is mounted.
472
+ const pageTo = useCallback(index => {
473
+ if (!pagerReady) return;
474
+ const lastIndex = images.length - 1;
475
+ if (lastIndex < 0) return;
476
+ const clamped = Math.min(Math.max(index, 0), lastIndex);
477
+ resetZoom();
478
+ pagerRef.current?.scrollTo({
479
+ x: clamped * SCREEN_WIDTH,
480
+ y: 0,
481
+ animated: true
482
+ });
483
+ }, [images.length, pagerReady, resetZoom, SCREEN_WIDTH]);
412
484
  const onPagerScroll = useCallback(event => {
413
485
  updateIndexFromOffset(event.nativeEvent.contentOffset.x);
414
486
  }, [updateIndexFromOffset]);
@@ -424,6 +496,138 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
424
496
  });
425
497
  }
426
498
  }, [SCREEN_WIDTH]);
499
+
500
+ // Double-tap toggles zoom: reset when already zoomed, else zoom to the tapped
501
+ // point (biased toward it, clamped near the image center). `x`/`y` are local
502
+ // to the active image, so its own fitted box gives the center + clamp bounds.
503
+ const zoomToPoint = useCallback((x, y) => {
504
+ if (zoomScale.value > 1) {
505
+ resetZoom();
506
+ return;
507
+ }
508
+ const centerX = activeFit.width / 2;
509
+ const centerY = activeFit.height / 2;
510
+ const maxOffset = Math.max(activeFit.width, activeFit.height) * 0.2;
511
+ const offsetX = Math.max(-maxOffset, Math.min(maxOffset, (centerX - x) * 0.5));
512
+ const offsetY = Math.max(-maxOffset, Math.min(maxOffset, (centerY - y) * 0.5));
513
+ zoomScale.value = withSpring(DOUBLE_TAP_ZOOM_SCALE);
514
+ zoomTranslateX.value = withSpring(offsetX);
515
+ zoomTranslateY.value = withSpring(offsetY);
516
+ savedZoomTranslateX.value = offsetX;
517
+ savedZoomTranslateY.value = offsetY;
518
+ baseZoomScale.value = DOUBLE_TAP_ZOOM_SCALE;
519
+ setIsZoomed(true);
520
+ }, [activeFit, baseZoomScale, resetZoom, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
521
+
522
+ // Double-tap-to-zoom is a touch convention — disabled on web, where neither
523
+ // Twitter's nor Instagram's lightbox gates its dismiss click behind tap-count
524
+ // discrimination. Kept native-only.
525
+ const doubleTapGesture = useMemo(() => Gesture.Tap().numberOfTaps(2).enabled(Platform.OS !== 'web').onEnd(event => {
526
+ runOnJS(zoomToPoint)(event.x, event.y);
527
+ }), [zoomToPoint]);
528
+
529
+ // Single tap dismisses — but only when un-zoomed (a tap while zoomed is inert;
530
+ // double-tap/pinch own un-zooming). On native this is made exclusive with the
531
+ // double-tap above so the first tap of a double-tap never dismisses; on web
532
+ // (see `tapGesture` below) it fires immediately, matching standard web
533
+ // lightbox click-to-close.
534
+ const singleTapDismissGesture = useMemo(() => Gesture.Tap().numberOfTaps(1).onEnd(() => {
535
+ if (zoomScale.value > 1) return;
536
+ runOnJS(handleDismiss)();
537
+ }), [handleDismiss, zoomScale]);
538
+
539
+ // On web, don't gate the dismiss tap behind double-tap-failure at all — that
540
+ // coupling requires react-native-gesture-handler's web tap-exclusivity timing
541
+ // (a ~500ms wait for the double-tap to time out) to resolve correctly for
542
+ // every single click, which is both slower than every standard web lightbox
543
+ // and the newest, least-exercised gesture code path here. Native keeps the
544
+ // Exclusive composition since double-tap-to-zoom is the expected touch
545
+ // convention there.
546
+ const tapGesture = useMemo(() => Platform.OS === 'web' ? singleTapDismissGesture : Gesture.Exclusive(doubleTapGesture, singleTapDismissGesture), [doubleTapGesture, singleTapDismissGesture]);
547
+ const pinchGesture = useMemo(() => Gesture.Pinch().onStart(() => {
548
+ baseZoomScale.value = zoomScale.value;
549
+ runOnJS(setIsZoomed)(true);
550
+ }).onUpdate(event => {
551
+ zoomScale.value = Math.max(MIN_ZOOM_SCALE, Math.min(MAX_ZOOM_SCALE, baseZoomScale.value * (event.scale || 1)));
552
+ }).onEnd(() => {
553
+ if (zoomScale.value < ZOOM_SNAP_THRESHOLD) {
554
+ zoomScale.value = withSpring(1);
555
+ zoomTranslateX.value = withSpring(0);
556
+ zoomTranslateY.value = withSpring(0);
557
+ savedZoomTranslateX.value = 0;
558
+ savedZoomTranslateY.value = 0;
559
+ baseZoomScale.value = 1;
560
+ runOnJS(setIsZoomed)(false);
561
+ } else {
562
+ runOnJS(setIsZoomed)(true);
563
+ }
564
+ }), [baseZoomScale, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
565
+ const panWhileZoomedGesture = useMemo(() => Gesture.Pan().enabled(isZoomed).minPointers(1).maxPointers(1).onStart(() => {
566
+ if (zoomScale.value <= 1) return;
567
+ savedZoomTranslateX.value = zoomTranslateX.value;
568
+ savedZoomTranslateY.value = zoomTranslateY.value;
569
+ }).onUpdate(event => {
570
+ if (zoomScale.value <= 1) return;
571
+ const nextX = savedZoomTranslateX.value + event.translationX * ZOOM_PAN_DAMPING;
572
+ const nextY = savedZoomTranslateY.value + event.translationY * ZOOM_PAN_DAMPING;
573
+ const maxTranslation = ZOOM_PAN_MAX_BASE * zoomScale.value;
574
+ zoomTranslateX.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextX));
575
+ zoomTranslateY.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextY));
576
+ }).onEnd(() => {
577
+ if (zoomScale.value <= 1) return;
578
+ savedZoomTranslateX.value = zoomTranslateX.value;
579
+ savedZoomTranslateY.value = zoomTranslateY.value;
580
+ }), [isZoomed, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
581
+
582
+ // Active page gesture: the tap gesture (see above) runs simultaneously with
583
+ // pinch + pan-while-zoomed.
584
+ const activePageGesture = useMemo(() => Gesture.Simultaneous(tapGesture, pinchGesture, panWhileZoomedGesture), [tapGesture, pinchGesture, panWhileZoomedGesture]);
585
+
586
+ // Share the active image's URI: the OS share sheet on native; the Web Share API
587
+ // on supporting browsers (the button is only rendered where it exists).
588
+ const handleShare = useCallback(async () => {
589
+ const uri = images[activeIndexRef.current]?.uri;
590
+ if (!uri) return;
591
+ try {
592
+ if (Platform.OS === 'web') {
593
+ if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
594
+ await navigator.share({
595
+ url: uri
596
+ });
597
+ }
598
+ return;
599
+ }
600
+ await Share.share({
601
+ url: uri
602
+ });
603
+ } catch (err) {
604
+ // A user-cancelled share sheet rejects (AbortError on web) — that's
605
+ // expected, not a failure. Anything else is a real error worth surfacing.
606
+ if (err instanceof Error && err.name === 'AbortError') return;
607
+ if (typeof console !== 'undefined' && console.error) console.error('Image share failed:', err);
608
+ }
609
+ }, [images]);
610
+
611
+ // Keyboard navigation (web only), scoped to the open lifetime like Dialog.web's
612
+ // Escape handler. Reads the live index from the ref so it need not re-subscribe
613
+ // on every page change. Escape flies back via `handleDismiss` (not a raw close).
614
+ useEffect(() => {
615
+ if (!isOpen || Platform.OS !== 'web' || typeof document === 'undefined') return;
616
+ const handler = e => {
617
+ if (e.key === 'Escape') {
618
+ e.stopPropagation();
619
+ handleDismiss();
620
+ } else if (e.key === 'ArrowLeft') {
621
+ e.stopPropagation();
622
+ pageTo(activeIndexRef.current - 1);
623
+ } else if (e.key === 'ArrowRight') {
624
+ e.stopPropagation();
625
+ pageTo(activeIndexRef.current + 1);
626
+ }
627
+ };
628
+ document.addEventListener('keydown', handler);
629
+ return () => document.removeEventListener('keydown', handler);
630
+ }, [handleDismiss, isOpen, pageTo]);
427
631
  const renderContent = () => /*#__PURE__*/_jsxs(GestureHandlerRootView, {
428
632
  style: styles.modalContainer,
429
633
  children: [/*#__PURE__*/_jsx(Pressable, {
@@ -472,7 +676,11 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
472
676
  children: /*#__PURE__*/_jsx(ScrollView, {
473
677
  ref: pagerRef,
474
678
  horizontal: true,
475
- pagingEnabled: true,
679
+ pagingEnabled: true
680
+ // Frozen while zoomed so a horizontal drag pans the zoomed image
681
+ // instead of paging away from it.
682
+ ,
683
+ scrollEnabled: !isZoomed,
476
684
  showsHorizontalScrollIndicator: false,
477
685
  contentOffset: {
478
686
  x: pendingIndexRef.current * SCREEN_WIDTH,
@@ -488,12 +696,41 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
488
696
  children: images.map((img, idx) => {
489
697
  const ratio = pageRatios[idx] ?? getAspectRatio(img.uri) ?? DEFAULT_ASPECT_RATIO;
490
698
  const fit = fitForRatio(ratio);
491
- return /*#__PURE__*/_jsx(Pressable, {
492
- onPress: handleDismiss,
699
+ // Only the active page is zoomable + gesture-wrapped (double-tap
700
+ // / single-tap dismiss / pinch / pan). Off-screen pages stay a
701
+ // plain image; the active image handles its own tap-to-dismiss
702
+ // through the gesture, so it isn't wrapped in a Pressable.
703
+ if (idx === activeIndex) {
704
+ return /*#__PURE__*/_jsx(View, {
705
+ style: [styles.page, {
706
+ width: SCREEN_WIDTH,
707
+ height: SCREEN_HEIGHT
708
+ }, webPointerStyle],
709
+ children: /*#__PURE__*/_jsx(GestureDetector, {
710
+ gesture: activePageGesture,
711
+ children: /*#__PURE__*/_jsx(AnimatedImage, {
712
+ source: {
713
+ uri: img.uri
714
+ },
715
+ contentFit: "contain",
716
+ style: [{
717
+ width: fit.width,
718
+ height: fit.height,
719
+ borderRadius: cornerRadius
720
+ }, zoomImageStyle],
721
+ transition: 0,
722
+ ...(Platform.OS === 'web' ? {
723
+ draggable: false
724
+ } : {})
725
+ })
726
+ })
727
+ }, `${img.uri}-${idx}`);
728
+ }
729
+ return /*#__PURE__*/_jsx(View, {
493
730
  style: [styles.page, {
494
731
  width: SCREEN_WIDTH,
495
732
  height: SCREEN_HEIGHT
496
- }, webPointerStyle],
733
+ }],
497
734
  children: /*#__PURE__*/_jsx(Image, {
498
735
  source: {
499
736
  uri: img.uri
@@ -514,19 +751,75 @@ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
514
751
  })
515
752
  }), pagerReady && images.length > 1 && /*#__PURE__*/_jsxs(Animated.View, {
516
753
  style: [styles.indicatorWrap, backdropStyle],
517
- pointerEvents: "none",
754
+ pointerEvents: "box-none",
518
755
  children: [/*#__PURE__*/_jsx(View, {
519
756
  style: styles.counterPill,
757
+ pointerEvents: "none",
520
758
  children: /*#__PURE__*/_jsx(Text, {
521
759
  style: styles.counterText,
522
760
  children: `${activeIndex + 1} / ${images.length}`
523
761
  })
524
- }), /*#__PURE__*/_jsx(View, {
762
+ }), indicatorVariant === 'thumbnails' ? /*#__PURE__*/_jsx(ScrollView, {
763
+ horizontal: true,
764
+ showsHorizontalScrollIndicator: false,
765
+ style: styles.thumbStripScroll,
766
+ contentContainerStyle: styles.thumbStripContent,
767
+ children: images.map((img, idx) => /*#__PURE__*/_jsx(Pressable, {
768
+ onPress: () => pageTo(idx),
769
+ accessibilityRole: "button",
770
+ accessibilityLabel: `Go to image ${idx + 1} of ${images.length}`,
771
+ style: [styles.thumbTile, idx === activeIndex ? styles.thumbTileActive : styles.thumbTileInactive, webPointerStyle],
772
+ children: /*#__PURE__*/_jsx(Image, {
773
+ source: {
774
+ uri: img.uri
775
+ },
776
+ contentFit: "cover",
777
+ style: styles.thumbTileImage,
778
+ transition: 0,
779
+ ...(Platform.OS === 'web' ? {
780
+ draggable: false
781
+ } : {})
782
+ })
783
+ }, `thumb-${img.uri}-${idx}`))
784
+ }) : /*#__PURE__*/_jsx(View, {
525
785
  style: styles.dotsRow,
786
+ pointerEvents: "none",
526
787
  children: images.map((img, idx) => /*#__PURE__*/_jsx(View, {
527
788
  style: [styles.dot, idx === activeIndex ? styles.dotActive : styles.dotInactive]
528
789
  }, `dot-${img.uri}-${idx}`))
529
790
  })]
791
+ }), Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex > 0 && /*#__PURE__*/_jsx(PressableWithHover, {
792
+ onPress: () => pageTo(activeIndex - 1),
793
+ accessibilityRole: "button",
794
+ accessibilityLabel: "Previous image",
795
+ hitSlop: 8,
796
+ style: [styles.navArrow, styles.navArrowLeft, webPointerStyle],
797
+ hoverStyle: styles.navArrowHoverLeft,
798
+ children: /*#__PURE__*/_jsx(ArrowLeft_Stroke2_Corner0_Rounded, {
799
+ fill: "#fff",
800
+ size: "lg"
801
+ })
802
+ }), Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex < images.length - 1 && /*#__PURE__*/_jsx(PressableWithHover, {
803
+ onPress: () => pageTo(activeIndex + 1),
804
+ accessibilityRole: "button",
805
+ accessibilityLabel: "Next image",
806
+ hitSlop: 8,
807
+ style: [styles.navArrow, styles.navArrowRight, webPointerStyle],
808
+ hoverStyle: styles.navArrowHoverRight,
809
+ children: /*#__PURE__*/_jsx(ArrowRight_Stroke2_Corner0_Rounded, {
810
+ fill: "#fff",
811
+ size: "lg"
812
+ })
813
+ }), canShare && pagerReady && /*#__PURE__*/_jsx(Pressable, {
814
+ onPress: handleShare,
815
+ accessibilityRole: "button",
816
+ accessibilityLabel: "Share image",
817
+ hitSlop: 8,
818
+ style: [styles.shareButton, webPointerStyle],
819
+ children: /*#__PURE__*/_jsx(ArrowOutOfBox_Stroke2_Corner0_Rounded, {
820
+ fill: "#fff",
821
+ size: "md"
822
+ })
530
823
  }), activeAlt ? /*#__PURE__*/_jsx(Animated.View, {
531
824
  style: [styles.altCaptionWrap, images.length > 1 && styles.altCaptionWrapWithIndicator, backdropStyle],
532
825
  pointerEvents: "none",
@@ -630,6 +923,82 @@ const styles = StyleSheet.create({
630
923
  dotInactive: {
631
924
  backgroundColor: 'rgba(255,255,255,0.4)'
632
925
  },
926
+ thumbStripScroll: {
927
+ maxWidth: '100%',
928
+ flexGrow: 0
929
+ },
930
+ // Centers the tiles when they don't fill the width, scrolls when they overflow.
931
+ thumbStripContent: {
932
+ flexGrow: 1,
933
+ justifyContent: 'center',
934
+ alignItems: 'center',
935
+ gap: 8,
936
+ paddingHorizontal: 16
937
+ },
938
+ thumbTile: {
939
+ width: THUMBNAIL_STRIP_TILE_SIZE,
940
+ height: THUMBNAIL_STRIP_TILE_SIZE,
941
+ borderRadius: 8,
942
+ overflow: 'hidden',
943
+ borderWidth: 2
944
+ },
945
+ thumbTileActive: {
946
+ borderColor: '#fff'
947
+ },
948
+ thumbTileInactive: {
949
+ borderColor: 'rgba(255,255,255,0.25)'
950
+ },
951
+ thumbTileImage: {
952
+ width: '100%',
953
+ height: '100%'
954
+ },
955
+ navArrow: {
956
+ position: 'absolute',
957
+ top: '50%',
958
+ width: 44,
959
+ height: 44,
960
+ borderRadius: 999,
961
+ alignItems: 'center',
962
+ justifyContent: 'center',
963
+ backgroundColor: 'rgba(0,0,0,0.45)',
964
+ transform: [{
965
+ translateY: -22
966
+ }]
967
+ },
968
+ navArrowLeft: {
969
+ left: 16
970
+ },
971
+ navArrowRight: {
972
+ right: 16
973
+ },
974
+ // Hover styles fully replace the base `transform`, so they repeat `translateY`.
975
+ navArrowHoverLeft: {
976
+ backgroundColor: 'rgba(0,0,0,0.7)',
977
+ transform: [{
978
+ translateY: -22
979
+ }, {
980
+ scale: 1.08
981
+ }]
982
+ },
983
+ navArrowHoverRight: {
984
+ backgroundColor: 'rgba(0,0,0,0.7)',
985
+ transform: [{
986
+ translateY: -22
987
+ }, {
988
+ scale: 1.08
989
+ }]
990
+ },
991
+ shareButton: {
992
+ position: 'absolute',
993
+ top: 16,
994
+ right: 16,
995
+ width: 40,
996
+ height: 40,
997
+ borderRadius: 999,
998
+ alignItems: 'center',
999
+ justifyContent: 'center',
1000
+ backgroundColor: 'rgba(0,0,0,0.45)'
1001
+ },
633
1002
  altCaptionWrap: {
634
1003
  position: 'absolute',
635
1004
  bottom: 48,