@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,4 +1,4 @@
1
- import React, { useCallback, useMemo, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -6,6 +6,7 @@ import {
6
6
  Pressable,
7
7
  Platform,
8
8
  ScrollView,
9
+ Share,
9
10
  useWindowDimensions,
10
11
  NativeSyntheticEvent,
11
12
  NativeScrollEvent,
@@ -28,6 +29,12 @@ import {
28
29
  } from 'react-native-gesture-handler';
29
30
  import { useTheme } from '../theme/use-theme';
30
31
  import { Portal } from '../portal';
32
+ import { PressableWithHover } from '../pressable-with-hover';
33
+ import {
34
+ ArrowLeft_Stroke2_Corner0_Rounded,
35
+ ArrowRight_Stroke2_Corner0_Rounded,
36
+ ArrowOutOfBox_Stroke2_Corner0_Rounded,
37
+ } from '../icons';
31
38
  import { Z_INDEX } from '../styles/z-index';
32
39
  import {
33
40
  getAspectRatio,
@@ -48,6 +55,13 @@ import {
48
55
  CLOSE_SPRING,
49
56
  SNAP_BACK_SPRING,
50
57
  DEFAULT_CORNER_RADIUS,
58
+ MIN_ZOOM_SCALE,
59
+ MAX_ZOOM_SCALE,
60
+ ZOOM_SNAP_THRESHOLD,
61
+ DOUBLE_TAP_ZOOM_SCALE,
62
+ ZOOM_PAN_DAMPING,
63
+ ZOOM_PAN_MAX_BASE,
64
+ THUMBNAIL_STRIP_TILE_SIZE,
51
65
  } from './constants';
52
66
  import type {
53
67
  GalleryImage,
@@ -94,7 +108,7 @@ interface FittedSize {
94
108
  * `Gesture.Pan` (`activeOffsetY` + `failOffsetX`) owns drag-to-dismiss, so the
95
109
  * two never fight.
96
110
  */
97
- const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, ZoomableImageGalleryProps>(({ measureThumb, cornerRadius = DEFAULT_CORNER_RADIUS }, ref) => {
111
+ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, ZoomableImageGalleryProps>(({ measureThumb, cornerRadius = DEFAULT_CORNER_RADIUS, indicatorVariant = 'dots' }, ref) => {
98
112
  const theme = useTheme();
99
113
  const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = useWindowDimensions();
100
114
 
@@ -103,16 +117,38 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
103
117
  const [pagerReady, setPagerReady] = useState(false);
104
118
  const [images, setImages] = useState<GalleryImage[]>([]);
105
119
  const [activeIndex, setActiveIndex] = useState(0);
120
+ // Whether the active image is currently pinched/double-tapped past its base
121
+ // size. Drives the pan-while-zoomed gesture, disables the pager's horizontal
122
+ // paging + the dismiss drag so a pan moves the zoomed image instead.
123
+ const [isZoomed, setIsZoomed] = useState(false);
106
124
  // Aspect ratio of the OPENING image (drives the open-animation fit box).
107
125
  const [openRatio, setOpenRatio] = useState<number>(DEFAULT_ASPECT_RATIO);
108
126
  // Per-image aspect ratios for the pager pages (index-aligned with `images`).
109
127
  const [pageRatios, setPageRatios] = useState<Record<number, number>>({});
110
128
 
129
+ // Native `Share` is always available; web exposes a share button only when the
130
+ // browser implements `navigator.share`. Computed once (capability is static).
131
+ const [canShare] = useState(() => {
132
+ if (Platform.OS !== 'web') return true;
133
+ return typeof navigator !== 'undefined' && typeof navigator.share === 'function';
134
+ });
135
+
111
136
  const scale = useSharedValue(1);
112
137
  const translateX = useSharedValue(0);
113
138
  const translateY = useSharedValue(0);
114
139
  const opacity = useSharedValue(0);
115
140
 
141
+ // Zoom transform of the active image, kept SEPARATE from the open/close +
142
+ // dismiss-drag transform above (reusing those would collide two meanings).
143
+ // `zoom*` are the live/persisted values; `baseZoomScale` is captured at pinch
144
+ // start and `savedZoomTranslate*` at pan start for incremental bookkeeping.
145
+ const zoomScale = useSharedValue(1);
146
+ const zoomTranslateX = useSharedValue(0);
147
+ const zoomTranslateY = useSharedValue(0);
148
+ const baseZoomScale = useSharedValue(1);
149
+ const savedZoomTranslateX = useSharedValue(0);
150
+ const savedZoomTranslateY = useSharedValue(0);
151
+
116
152
  // Origin (offset from screen center) of the tapped image.
117
153
  const originX = useSharedValue(0);
118
154
  const originY = useSharedValue(0);
@@ -141,6 +177,19 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
141
177
  setActiveIndex((prev) => (prev === next ? prev : next));
142
178
  }, []);
143
179
 
180
+ // Snap the active image back to its un-zoomed baseline (no animation) and clear
181
+ // all zoom bookkeeping. Called on every path that changes the active page so a
182
+ // freshly-viewed image always starts un-zoomed.
183
+ const resetZoom = useCallback(() => {
184
+ zoomScale.value = 1;
185
+ zoomTranslateX.value = 0;
186
+ zoomTranslateY.value = 0;
187
+ savedZoomTranslateX.value = 0;
188
+ savedZoomTranslateY.value = 0;
189
+ baseZoomScale.value = 1;
190
+ setIsZoomed(false);
191
+ }, [baseZoomScale, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]);
192
+
144
193
  // Box the fitted image must fit inside.
145
194
  const fitBox = useMemo<FittedSize>(
146
195
  () => ({ width: SCREEN_WIDTH * FIT_FRACTION, height: SCREEN_HEIGHT * FIT_FRACTION }),
@@ -375,7 +424,9 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
375
424
  const panGesture = useMemo(
376
425
  () =>
377
426
  Gesture.Pan()
378
- .enabled(isOpen)
427
+ // Disabled while zoomed so a drag pans the zoomed image (via the
428
+ // per-page pan gesture) instead of trying to dismiss the whole viewer.
429
+ .enabled(isOpen && !isZoomed)
379
430
  // Only claim drags that are decided to be vertical; horizontal drags
380
431
  // fall through to the pager's ScrollView so swiping changes pages.
381
432
  .activeOffsetY([-AXIS_DECISION_OFFSET, AXIS_DECISION_OFFSET])
@@ -408,7 +459,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
408
459
  opacity.value = withTiming(1, { duration: OPACITY_DURATION });
409
460
  }
410
461
  }),
411
- [handleDismiss, isOpen, SCREEN_HEIGHT, opacity, scale, startScale, startX, startY, translateX, translateY]
462
+ [handleDismiss, isOpen, isZoomed, SCREEN_HEIGHT, opacity, scale, startScale, startX, startY, translateX, translateY]
412
463
  );
413
464
 
414
465
  const backdropStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
@@ -432,6 +483,16 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
432
483
  ],
433
484
  }));
434
485
 
486
+ // Pinch/double-tap zoom transform, layered on TOP of the active page image's
487
+ // `fit`-based sizing (composed, never replacing it).
488
+ const zoomImageStyle = useAnimatedStyle(() => ({
489
+ transform: [
490
+ { scale: zoomScale.value },
491
+ { translateX: zoomTranslateX.value },
492
+ { translateY: zoomTranslateY.value },
493
+ ],
494
+ }));
495
+
435
496
  // Derive the current page from the scroll offset, clamp into range, and update
436
497
  // `activeIndex` only when it actually changes (drives the live indicator + the
437
498
  // close fly-back target). Shared by `onMomentumScrollEnd` (native) and `onScroll`
@@ -442,11 +503,29 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
442
503
  if (lastIndex < 0) return;
443
504
  const next = Math.min(Math.max(Math.round(offsetX / SCREEN_WIDTH), 0), lastIndex);
444
505
  if (next === activeIndexRef.current) return;
506
+ // Swiping to a new page always starts it un-zoomed (and clears the zoom on
507
+ // the page scrolling away).
508
+ resetZoom();
445
509
  setActiveIndexBoth(next);
446
510
  const img = images[next];
447
511
  if (img) ensureRatio(next, img.uri);
448
512
  },
449
- [ensureRatio, images, setActiveIndexBoth, SCREEN_WIDTH]
513
+ [ensureRatio, images, resetZoom, setActiveIndexBoth, SCREEN_WIDTH]
514
+ );
515
+
516
+ // Programmatically page to `index` (arrow buttons, keyboard, thumbnail taps).
517
+ // Clamps into range and lets the existing scroll handlers own the index/ratio
518
+ // state-sync — this only triggers the scroll. No-op until the pager is mounted.
519
+ const pageTo = useCallback(
520
+ (index: number) => {
521
+ if (!pagerReady) return;
522
+ const lastIndex = images.length - 1;
523
+ if (lastIndex < 0) return;
524
+ const clamped = Math.min(Math.max(index, 0), lastIndex);
525
+ resetZoom();
526
+ pagerRef.current?.scrollTo({ x: clamped * SCREEN_WIDTH, y: 0, animated: true });
527
+ },
528
+ [images.length, pagerReady, resetZoom, SCREEN_WIDTH]
450
529
  );
451
530
 
452
531
  const onPagerScroll = useCallback(
@@ -465,6 +544,181 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
465
544
  }
466
545
  }, [SCREEN_WIDTH]);
467
546
 
547
+ // Double-tap toggles zoom: reset when already zoomed, else zoom to the tapped
548
+ // point (biased toward it, clamped near the image center). `x`/`y` are local
549
+ // to the active image, so its own fitted box gives the center + clamp bounds.
550
+ const zoomToPoint = useCallback(
551
+ (x: number, y: number) => {
552
+ if (zoomScale.value > 1) {
553
+ resetZoom();
554
+ return;
555
+ }
556
+ const centerX = activeFit.width / 2;
557
+ const centerY = activeFit.height / 2;
558
+ const maxOffset = Math.max(activeFit.width, activeFit.height) * 0.2;
559
+ const offsetX = Math.max(-maxOffset, Math.min(maxOffset, (centerX - x) * 0.5));
560
+ const offsetY = Math.max(-maxOffset, Math.min(maxOffset, (centerY - y) * 0.5));
561
+ zoomScale.value = withSpring(DOUBLE_TAP_ZOOM_SCALE);
562
+ zoomTranslateX.value = withSpring(offsetX);
563
+ zoomTranslateY.value = withSpring(offsetY);
564
+ savedZoomTranslateX.value = offsetX;
565
+ savedZoomTranslateY.value = offsetY;
566
+ baseZoomScale.value = DOUBLE_TAP_ZOOM_SCALE;
567
+ setIsZoomed(true);
568
+ },
569
+ [activeFit, baseZoomScale, resetZoom, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]
570
+ );
571
+
572
+ // Double-tap-to-zoom is a touch convention — disabled on web, where neither
573
+ // Twitter's nor Instagram's lightbox gates its dismiss click behind tap-count
574
+ // discrimination. Kept native-only.
575
+ const doubleTapGesture = useMemo(
576
+ () =>
577
+ Gesture.Tap()
578
+ .numberOfTaps(2)
579
+ .enabled(Platform.OS !== 'web')
580
+ .onEnd((event) => {
581
+ runOnJS(zoomToPoint)(event.x, event.y);
582
+ }),
583
+ [zoomToPoint]
584
+ );
585
+
586
+ // Single tap dismisses — but only when un-zoomed (a tap while zoomed is inert;
587
+ // double-tap/pinch own un-zooming). On native this is made exclusive with the
588
+ // double-tap above so the first tap of a double-tap never dismisses; on web
589
+ // (see `tapGesture` below) it fires immediately, matching standard web
590
+ // lightbox click-to-close.
591
+ const singleTapDismissGesture = useMemo(
592
+ () =>
593
+ Gesture.Tap()
594
+ .numberOfTaps(1)
595
+ .onEnd(() => {
596
+ if (zoomScale.value > 1) return;
597
+ runOnJS(handleDismiss)();
598
+ }),
599
+ [handleDismiss, zoomScale]
600
+ );
601
+
602
+ // On web, don't gate the dismiss tap behind double-tap-failure at all — that
603
+ // coupling requires react-native-gesture-handler's web tap-exclusivity timing
604
+ // (a ~500ms wait for the double-tap to time out) to resolve correctly for
605
+ // every single click, which is both slower than every standard web lightbox
606
+ // and the newest, least-exercised gesture code path here. Native keeps the
607
+ // Exclusive composition since double-tap-to-zoom is the expected touch
608
+ // convention there.
609
+ const tapGesture = useMemo(
610
+ () =>
611
+ Platform.OS === 'web'
612
+ ? singleTapDismissGesture
613
+ : Gesture.Exclusive(doubleTapGesture, singleTapDismissGesture),
614
+ [doubleTapGesture, singleTapDismissGesture]
615
+ );
616
+
617
+ const pinchGesture = useMemo(
618
+ () =>
619
+ Gesture.Pinch()
620
+ .onStart(() => {
621
+ baseZoomScale.value = zoomScale.value;
622
+ runOnJS(setIsZoomed)(true);
623
+ })
624
+ .onUpdate((event) => {
625
+ zoomScale.value = Math.max(
626
+ MIN_ZOOM_SCALE,
627
+ Math.min(MAX_ZOOM_SCALE, baseZoomScale.value * (event.scale || 1))
628
+ );
629
+ })
630
+ .onEnd(() => {
631
+ if (zoomScale.value < ZOOM_SNAP_THRESHOLD) {
632
+ zoomScale.value = withSpring(1);
633
+ zoomTranslateX.value = withSpring(0);
634
+ zoomTranslateY.value = withSpring(0);
635
+ savedZoomTranslateX.value = 0;
636
+ savedZoomTranslateY.value = 0;
637
+ baseZoomScale.value = 1;
638
+ runOnJS(setIsZoomed)(false);
639
+ } else {
640
+ runOnJS(setIsZoomed)(true);
641
+ }
642
+ }),
643
+ [baseZoomScale, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]
644
+ );
645
+
646
+ const panWhileZoomedGesture = useMemo(
647
+ () =>
648
+ Gesture.Pan()
649
+ .enabled(isZoomed)
650
+ .minPointers(1)
651
+ .maxPointers(1)
652
+ .onStart(() => {
653
+ if (zoomScale.value <= 1) return;
654
+ savedZoomTranslateX.value = zoomTranslateX.value;
655
+ savedZoomTranslateY.value = zoomTranslateY.value;
656
+ })
657
+ .onUpdate((event) => {
658
+ if (zoomScale.value <= 1) return;
659
+ const nextX = savedZoomTranslateX.value + event.translationX * ZOOM_PAN_DAMPING;
660
+ const nextY = savedZoomTranslateY.value + event.translationY * ZOOM_PAN_DAMPING;
661
+ const maxTranslation = ZOOM_PAN_MAX_BASE * zoomScale.value;
662
+ zoomTranslateX.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextX));
663
+ zoomTranslateY.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextY));
664
+ })
665
+ .onEnd(() => {
666
+ if (zoomScale.value <= 1) return;
667
+ savedZoomTranslateX.value = zoomTranslateX.value;
668
+ savedZoomTranslateY.value = zoomTranslateY.value;
669
+ }),
670
+ [isZoomed, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]
671
+ );
672
+
673
+ // Active page gesture: the tap gesture (see above) runs simultaneously with
674
+ // pinch + pan-while-zoomed.
675
+ const activePageGesture = useMemo(
676
+ () => Gesture.Simultaneous(tapGesture, pinchGesture, panWhileZoomedGesture),
677
+ [tapGesture, pinchGesture, panWhileZoomedGesture]
678
+ );
679
+
680
+ // Share the active image's URI: the OS share sheet on native; the Web Share API
681
+ // on supporting browsers (the button is only rendered where it exists).
682
+ const handleShare = useCallback(async () => {
683
+ const uri = images[activeIndexRef.current]?.uri;
684
+ if (!uri) return;
685
+ try {
686
+ if (Platform.OS === 'web') {
687
+ if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
688
+ await navigator.share({ url: uri });
689
+ }
690
+ return;
691
+ }
692
+ await Share.share({ url: uri });
693
+ } catch (err) {
694
+ // A user-cancelled share sheet rejects (AbortError on web) — that's
695
+ // expected, not a failure. Anything else is a real error worth surfacing.
696
+ if (err instanceof Error && err.name === 'AbortError') return;
697
+ if (typeof console !== 'undefined' && console.error) console.error('Image share failed:', err);
698
+ }
699
+ }, [images]);
700
+
701
+ // Keyboard navigation (web only), scoped to the open lifetime like Dialog.web's
702
+ // Escape handler. Reads the live index from the ref so it need not re-subscribe
703
+ // on every page change. Escape flies back via `handleDismiss` (not a raw close).
704
+ useEffect(() => {
705
+ if (!isOpen || Platform.OS !== 'web' || typeof document === 'undefined') return;
706
+ const handler = (e: KeyboardEvent) => {
707
+ if (e.key === 'Escape') {
708
+ e.stopPropagation();
709
+ handleDismiss();
710
+ } else if (e.key === 'ArrowLeft') {
711
+ e.stopPropagation();
712
+ pageTo(activeIndexRef.current - 1);
713
+ } else if (e.key === 'ArrowRight') {
714
+ e.stopPropagation();
715
+ pageTo(activeIndexRef.current + 1);
716
+ }
717
+ };
718
+ document.addEventListener('keydown', handler);
719
+ return () => document.removeEventListener('keydown', handler);
720
+ }, [handleDismiss, isOpen, pageTo]);
721
+
468
722
  const renderContent = () => (
469
723
  <GestureHandlerRootView style={styles.modalContainer}>
470
724
  <Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} hitSlop={0}>
@@ -511,6 +765,9 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
511
765
  ref={pagerRef}
512
766
  horizontal
513
767
  pagingEnabled
768
+ // Frozen while zoomed so a horizontal drag pans the zoomed image
769
+ // instead of paging away from it.
770
+ scrollEnabled={!isZoomed}
514
771
  showsHorizontalScrollIndicator={false}
515
772
  contentOffset={{ x: pendingIndexRef.current * SCREEN_WIDTH, y: 0 }}
516
773
  onLayout={onPagerLayout}
@@ -522,11 +779,35 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
522
779
  {images.map((img, idx) => {
523
780
  const ratio = pageRatios[idx] ?? getAspectRatio(img.uri) ?? DEFAULT_ASPECT_RATIO;
524
781
  const fit = fitForRatio(ratio);
782
+ // Only the active page is zoomable + gesture-wrapped (double-tap
783
+ // / single-tap dismiss / pinch / pan). Off-screen pages stay a
784
+ // plain image; the active image handles its own tap-to-dismiss
785
+ // through the gesture, so it isn't wrapped in a Pressable.
786
+ if (idx === activeIndex) {
787
+ return (
788
+ <View
789
+ key={`${img.uri}-${idx}`}
790
+ style={[styles.page, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }, webPointerStyle]}
791
+ >
792
+ <GestureDetector gesture={activePageGesture}>
793
+ <AnimatedImage
794
+ source={{ uri: img.uri }}
795
+ contentFit="contain"
796
+ style={[
797
+ { width: fit.width, height: fit.height, borderRadius: cornerRadius },
798
+ zoomImageStyle,
799
+ ]}
800
+ transition={0}
801
+ {...(Platform.OS === 'web' ? { draggable: false } : {})}
802
+ />
803
+ </GestureDetector>
804
+ </View>
805
+ );
806
+ }
525
807
  return (
526
- <Pressable
808
+ <View
527
809
  key={`${img.uri}-${idx}`}
528
- onPress={handleDismiss}
529
- style={[styles.page, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }, webPointerStyle]}
810
+ style={[styles.page, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }]}
530
811
  >
531
812
  <Image
532
813
  source={{ uri: img.uri }}
@@ -535,7 +816,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
535
816
  transition={0}
536
817
  {...(Platform.OS === 'web' ? { draggable: false } : {})}
537
818
  />
538
- </Pressable>
819
+ </View>
539
820
  );
540
821
  })}
541
822
  </ScrollView>
@@ -543,21 +824,90 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
543
824
  )}
544
825
 
545
826
  {pagerReady && images.length > 1 && (
546
- <Animated.View style={[styles.indicatorWrap, backdropStyle]} pointerEvents="none">
547
- <View style={styles.counterPill}>
827
+ <Animated.View style={[styles.indicatorWrap, backdropStyle]} pointerEvents="box-none">
828
+ <View style={styles.counterPill} pointerEvents="none">
548
829
  <Text style={styles.counterText}>{`${activeIndex + 1} / ${images.length}`}</Text>
549
830
  </View>
550
- <View style={styles.dotsRow}>
551
- {images.map((img, idx) => (
552
- <View
553
- key={`dot-${img.uri}-${idx}`}
554
- style={[styles.dot, idx === activeIndex ? styles.dotActive : styles.dotInactive]}
555
- />
556
- ))}
557
- </View>
831
+ {indicatorVariant === 'thumbnails' ? (
832
+ <ScrollView
833
+ horizontal
834
+ showsHorizontalScrollIndicator={false}
835
+ style={styles.thumbStripScroll}
836
+ contentContainerStyle={styles.thumbStripContent}
837
+ >
838
+ {images.map((img, idx) => (
839
+ <Pressable
840
+ key={`thumb-${img.uri}-${idx}`}
841
+ onPress={() => pageTo(idx)}
842
+ accessibilityRole="button"
843
+ accessibilityLabel={`Go to image ${idx + 1} of ${images.length}`}
844
+ style={[
845
+ styles.thumbTile,
846
+ idx === activeIndex ? styles.thumbTileActive : styles.thumbTileInactive,
847
+ webPointerStyle,
848
+ ]}
849
+ >
850
+ <Image
851
+ source={{ uri: img.uri }}
852
+ contentFit="cover"
853
+ style={styles.thumbTileImage}
854
+ transition={0}
855
+ {...(Platform.OS === 'web' ? { draggable: false } : {})}
856
+ />
857
+ </Pressable>
858
+ ))}
859
+ </ScrollView>
860
+ ) : (
861
+ <View style={styles.dotsRow} pointerEvents="none">
862
+ {images.map((img, idx) => (
863
+ <View
864
+ key={`dot-${img.uri}-${idx}`}
865
+ style={[styles.dot, idx === activeIndex ? styles.dotActive : styles.dotInactive]}
866
+ />
867
+ ))}
868
+ </View>
869
+ )}
558
870
  </Animated.View>
559
871
  )}
560
872
 
873
+ {Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex > 0 && (
874
+ <PressableWithHover
875
+ onPress={() => pageTo(activeIndex - 1)}
876
+ accessibilityRole="button"
877
+ accessibilityLabel="Previous image"
878
+ hitSlop={8}
879
+ style={[styles.navArrow, styles.navArrowLeft, webPointerStyle]}
880
+ hoverStyle={styles.navArrowHoverLeft}
881
+ >
882
+ <ArrowLeft_Stroke2_Corner0_Rounded fill="#fff" size="lg" />
883
+ </PressableWithHover>
884
+ )}
885
+
886
+ {Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex < images.length - 1 && (
887
+ <PressableWithHover
888
+ onPress={() => pageTo(activeIndex + 1)}
889
+ accessibilityRole="button"
890
+ accessibilityLabel="Next image"
891
+ hitSlop={8}
892
+ style={[styles.navArrow, styles.navArrowRight, webPointerStyle]}
893
+ hoverStyle={styles.navArrowHoverRight}
894
+ >
895
+ <ArrowRight_Stroke2_Corner0_Rounded fill="#fff" size="lg" />
896
+ </PressableWithHover>
897
+ )}
898
+
899
+ {canShare && pagerReady && (
900
+ <Pressable
901
+ onPress={handleShare}
902
+ accessibilityRole="button"
903
+ accessibilityLabel="Share image"
904
+ hitSlop={8}
905
+ style={[styles.shareButton, webPointerStyle]}
906
+ >
907
+ <ArrowOutOfBox_Stroke2_Corner0_Rounded fill="#fff" size="md" />
908
+ </Pressable>
909
+ )}
910
+
561
911
  {activeAlt ? (
562
912
  <Animated.View
563
913
  style={[
@@ -666,6 +1016,72 @@ const styles = StyleSheet.create({
666
1016
  dotInactive: {
667
1017
  backgroundColor: 'rgba(255,255,255,0.4)',
668
1018
  },
1019
+ thumbStripScroll: {
1020
+ maxWidth: '100%',
1021
+ flexGrow: 0,
1022
+ },
1023
+ // Centers the tiles when they don't fill the width, scrolls when they overflow.
1024
+ thumbStripContent: {
1025
+ flexGrow: 1,
1026
+ justifyContent: 'center',
1027
+ alignItems: 'center',
1028
+ gap: 8,
1029
+ paddingHorizontal: 16,
1030
+ },
1031
+ thumbTile: {
1032
+ width: THUMBNAIL_STRIP_TILE_SIZE,
1033
+ height: THUMBNAIL_STRIP_TILE_SIZE,
1034
+ borderRadius: 8,
1035
+ overflow: 'hidden',
1036
+ borderWidth: 2,
1037
+ },
1038
+ thumbTileActive: {
1039
+ borderColor: '#fff',
1040
+ },
1041
+ thumbTileInactive: {
1042
+ borderColor: 'rgba(255,255,255,0.25)',
1043
+ },
1044
+ thumbTileImage: {
1045
+ width: '100%',
1046
+ height: '100%',
1047
+ },
1048
+ navArrow: {
1049
+ position: 'absolute',
1050
+ top: '50%',
1051
+ width: 44,
1052
+ height: 44,
1053
+ borderRadius: 999,
1054
+ alignItems: 'center',
1055
+ justifyContent: 'center',
1056
+ backgroundColor: 'rgba(0,0,0,0.45)',
1057
+ transform: [{ translateY: -22 }],
1058
+ },
1059
+ navArrowLeft: {
1060
+ left: 16,
1061
+ },
1062
+ navArrowRight: {
1063
+ right: 16,
1064
+ },
1065
+ // Hover styles fully replace the base `transform`, so they repeat `translateY`.
1066
+ navArrowHoverLeft: {
1067
+ backgroundColor: 'rgba(0,0,0,0.7)',
1068
+ transform: [{ translateY: -22 }, { scale: 1.08 }],
1069
+ },
1070
+ navArrowHoverRight: {
1071
+ backgroundColor: 'rgba(0,0,0,0.7)',
1072
+ transform: [{ translateY: -22 }, { scale: 1.08 }],
1073
+ },
1074
+ shareButton: {
1075
+ position: 'absolute',
1076
+ top: 16,
1077
+ right: 16,
1078
+ width: 40,
1079
+ height: 40,
1080
+ borderRadius: 999,
1081
+ alignItems: 'center',
1082
+ justifyContent: 'center',
1083
+ backgroundColor: 'rgba(0,0,0,0.45)',
1084
+ },
669
1085
  altCaptionWrap: {
670
1086
  position: 'absolute',
671
1087
  bottom: 48,
@@ -23,3 +23,18 @@ export const SNAP_BACK_SPRING = { damping: 20, stiffness: 400, mass: 0.4 } as co
23
23
 
24
24
  /** Default corner radius for the zoomed images — Bloom's `radius-12` token. */
25
25
  export const DEFAULT_CORNER_RADIUS = RADIUS['radius-12'];
26
+
27
+ /** Minimum pinch-zoom scale of the active image (its un-zoomed size). */
28
+ export const MIN_ZOOM_SCALE = 1;
29
+ /** Maximum pinch-zoom scale of the active image. */
30
+ export const MAX_ZOOM_SCALE = 3;
31
+ /** Below this final pinch scale, the zoom snaps back to 1 (treated as un-zoomed). */
32
+ export const ZOOM_SNAP_THRESHOLD = 1.1;
33
+ /** Scale a double-tap zooms the active image to when it is currently un-zoomed. */
34
+ export const DOUBLE_TAP_ZOOM_SCALE = 2;
35
+ /** Fraction of finger travel applied while panning a zoomed image. */
36
+ export const ZOOM_PAN_DAMPING = 0.8;
37
+ /** Base clamp (px, multiplied by the current zoom) bounding a zoomed image's pan. */
38
+ export const ZOOM_PAN_MAX_BASE = 100;
39
+ /** Edge length (px) of each tile in the `thumbnails` indicator variant. */
40
+ export const THUMBNAIL_STRIP_TILE_SIZE = 52;
@@ -6,3 +6,4 @@ export type {
6
6
  MeasureThumb,
7
7
  MeasuredRect,
8
8
  } from './types';
9
+ export * from './constants';
@@ -46,4 +46,10 @@ export interface ZoomableImageGalleryProps {
46
46
  measureThumb?: MeasureThumb;
47
47
  /** Corner radius applied to the zoomed images. Defaults to `DEFAULT_CORNER_RADIUS`. */
48
48
  cornerRadius?: number;
49
+ /**
50
+ * Bottom page indicator for multi-image galleries. `'dots'` (default) renders the
51
+ * compact dot row; `'thumbnails'` renders a horizontal strip of tappable image
52
+ * tiles. The counter pill shows in both. Ignored for single-image galleries.
53
+ */
54
+ indicatorVariant?: 'dots' | 'thumbnails';
49
55
  }