@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
@@ -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,166 @@ 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
+ const doubleTapGesture = useMemo(
573
+ () =>
574
+ Gesture.Tap()
575
+ .numberOfTaps(2)
576
+ .onEnd((event) => {
577
+ runOnJS(zoomToPoint)(event.x, event.y);
578
+ }),
579
+ [zoomToPoint]
580
+ );
581
+
582
+ // Single tap dismisses — but only when un-zoomed (a tap while zoomed is inert;
583
+ // double-tap/pinch own un-zooming). Made exclusive with the double-tap below so
584
+ // the first tap of a double-tap never dismisses.
585
+ const singleTapDismissGesture = useMemo(
586
+ () =>
587
+ Gesture.Tap()
588
+ .numberOfTaps(1)
589
+ .onEnd(() => {
590
+ if (zoomScale.value > 1) return;
591
+ runOnJS(handleDismiss)();
592
+ }),
593
+ [handleDismiss, zoomScale]
594
+ );
595
+
596
+ const pinchGesture = useMemo(
597
+ () =>
598
+ Gesture.Pinch()
599
+ .onStart(() => {
600
+ baseZoomScale.value = zoomScale.value;
601
+ runOnJS(setIsZoomed)(true);
602
+ })
603
+ .onUpdate((event) => {
604
+ zoomScale.value = Math.max(
605
+ MIN_ZOOM_SCALE,
606
+ Math.min(MAX_ZOOM_SCALE, baseZoomScale.value * (event.scale || 1))
607
+ );
608
+ })
609
+ .onEnd(() => {
610
+ if (zoomScale.value < ZOOM_SNAP_THRESHOLD) {
611
+ zoomScale.value = withSpring(1);
612
+ zoomTranslateX.value = withSpring(0);
613
+ zoomTranslateY.value = withSpring(0);
614
+ savedZoomTranslateX.value = 0;
615
+ savedZoomTranslateY.value = 0;
616
+ baseZoomScale.value = 1;
617
+ runOnJS(setIsZoomed)(false);
618
+ } else {
619
+ runOnJS(setIsZoomed)(true);
620
+ }
621
+ }),
622
+ [baseZoomScale, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]
623
+ );
624
+
625
+ const panWhileZoomedGesture = useMemo(
626
+ () =>
627
+ Gesture.Pan()
628
+ .enabled(isZoomed)
629
+ .minPointers(1)
630
+ .maxPointers(1)
631
+ .onStart(() => {
632
+ if (zoomScale.value <= 1) return;
633
+ savedZoomTranslateX.value = zoomTranslateX.value;
634
+ savedZoomTranslateY.value = zoomTranslateY.value;
635
+ })
636
+ .onUpdate((event) => {
637
+ if (zoomScale.value <= 1) return;
638
+ const nextX = savedZoomTranslateX.value + event.translationX * ZOOM_PAN_DAMPING;
639
+ const nextY = savedZoomTranslateY.value + event.translationY * ZOOM_PAN_DAMPING;
640
+ const maxTranslation = ZOOM_PAN_MAX_BASE * zoomScale.value;
641
+ zoomTranslateX.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextX));
642
+ zoomTranslateY.value = Math.max(-maxTranslation, Math.min(maxTranslation, nextY));
643
+ })
644
+ .onEnd(() => {
645
+ if (zoomScale.value <= 1) return;
646
+ savedZoomTranslateX.value = zoomTranslateX.value;
647
+ savedZoomTranslateY.value = zoomTranslateY.value;
648
+ }),
649
+ [isZoomed, savedZoomTranslateX, savedZoomTranslateY, zoomScale, zoomTranslateX, zoomTranslateY]
650
+ );
651
+
652
+ // Active page gesture: double-tap zoom + single-tap dismiss are mutually
653
+ // exclusive (single waits for the double to fail); both run simultaneously
654
+ // with pinch + pan-while-zoomed.
655
+ const activePageGesture = useMemo(
656
+ () =>
657
+ Gesture.Simultaneous(
658
+ Gesture.Exclusive(doubleTapGesture, singleTapDismissGesture),
659
+ pinchGesture,
660
+ panWhileZoomedGesture
661
+ ),
662
+ [doubleTapGesture, singleTapDismissGesture, pinchGesture, panWhileZoomedGesture]
663
+ );
664
+
665
+ // Share the active image's URI: the OS share sheet on native; the Web Share API
666
+ // on supporting browsers (the button is only rendered where it exists).
667
+ const handleShare = useCallback(async () => {
668
+ const uri = images[activeIndexRef.current]?.uri;
669
+ if (!uri) return;
670
+ try {
671
+ if (Platform.OS === 'web') {
672
+ if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
673
+ await navigator.share({ url: uri });
674
+ }
675
+ return;
676
+ }
677
+ await Share.share({ url: uri });
678
+ } catch (err) {
679
+ // A user-cancelled share sheet rejects (AbortError on web) — that's
680
+ // expected, not a failure. Anything else is a real error worth surfacing.
681
+ if (err instanceof Error && err.name === 'AbortError') return;
682
+ if (typeof console !== 'undefined' && console.error) console.error('Image share failed:', err);
683
+ }
684
+ }, [images]);
685
+
686
+ // Keyboard navigation (web only), scoped to the open lifetime like Dialog.web's
687
+ // Escape handler. Reads the live index from the ref so it need not re-subscribe
688
+ // on every page change. Escape flies back via `handleDismiss` (not a raw close).
689
+ useEffect(() => {
690
+ if (!isOpen || Platform.OS !== 'web' || typeof document === 'undefined') return;
691
+ const handler = (e: KeyboardEvent) => {
692
+ if (e.key === 'Escape') {
693
+ e.stopPropagation();
694
+ handleDismiss();
695
+ } else if (e.key === 'ArrowLeft') {
696
+ e.stopPropagation();
697
+ pageTo(activeIndexRef.current - 1);
698
+ } else if (e.key === 'ArrowRight') {
699
+ e.stopPropagation();
700
+ pageTo(activeIndexRef.current + 1);
701
+ }
702
+ };
703
+ document.addEventListener('keydown', handler);
704
+ return () => document.removeEventListener('keydown', handler);
705
+ }, [handleDismiss, isOpen, pageTo]);
706
+
468
707
  const renderContent = () => (
469
708
  <GestureHandlerRootView style={styles.modalContainer}>
470
709
  <Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} hitSlop={0}>
@@ -511,6 +750,9 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
511
750
  ref={pagerRef}
512
751
  horizontal
513
752
  pagingEnabled
753
+ // Frozen while zoomed so a horizontal drag pans the zoomed image
754
+ // instead of paging away from it.
755
+ scrollEnabled={!isZoomed}
514
756
  showsHorizontalScrollIndicator={false}
515
757
  contentOffset={{ x: pendingIndexRef.current * SCREEN_WIDTH, y: 0 }}
516
758
  onLayout={onPagerLayout}
@@ -522,11 +764,35 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
522
764
  {images.map((img, idx) => {
523
765
  const ratio = pageRatios[idx] ?? getAspectRatio(img.uri) ?? DEFAULT_ASPECT_RATIO;
524
766
  const fit = fitForRatio(ratio);
767
+ // Only the active page is zoomable + gesture-wrapped (double-tap
768
+ // / single-tap dismiss / pinch / pan). Off-screen pages stay a
769
+ // plain image; the active image handles its own tap-to-dismiss
770
+ // through the gesture, so it isn't wrapped in a Pressable.
771
+ if (idx === activeIndex) {
772
+ return (
773
+ <View
774
+ key={`${img.uri}-${idx}`}
775
+ style={[styles.page, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }, webPointerStyle]}
776
+ >
777
+ <GestureDetector gesture={activePageGesture}>
778
+ <AnimatedImage
779
+ source={{ uri: img.uri }}
780
+ contentFit="contain"
781
+ style={[
782
+ { width: fit.width, height: fit.height, borderRadius: cornerRadius },
783
+ zoomImageStyle,
784
+ ]}
785
+ transition={0}
786
+ {...(Platform.OS === 'web' ? { draggable: false } : {})}
787
+ />
788
+ </GestureDetector>
789
+ </View>
790
+ );
791
+ }
525
792
  return (
526
- <Pressable
793
+ <View
527
794
  key={`${img.uri}-${idx}`}
528
- onPress={handleDismiss}
529
- style={[styles.page, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }, webPointerStyle]}
795
+ style={[styles.page, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }]}
530
796
  >
531
797
  <Image
532
798
  source={{ uri: img.uri }}
@@ -535,7 +801,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
535
801
  transition={0}
536
802
  {...(Platform.OS === 'web' ? { draggable: false } : {})}
537
803
  />
538
- </Pressable>
804
+ </View>
539
805
  );
540
806
  })}
541
807
  </ScrollView>
@@ -543,21 +809,90 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
543
809
  )}
544
810
 
545
811
  {pagerReady && images.length > 1 && (
546
- <Animated.View style={[styles.indicatorWrap, backdropStyle]} pointerEvents="none">
547
- <View style={styles.counterPill}>
812
+ <Animated.View style={[styles.indicatorWrap, backdropStyle]} pointerEvents="box-none">
813
+ <View style={styles.counterPill} pointerEvents="none">
548
814
  <Text style={styles.counterText}>{`${activeIndex + 1} / ${images.length}`}</Text>
549
815
  </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>
816
+ {indicatorVariant === 'thumbnails' ? (
817
+ <ScrollView
818
+ horizontal
819
+ showsHorizontalScrollIndicator={false}
820
+ style={styles.thumbStripScroll}
821
+ contentContainerStyle={styles.thumbStripContent}
822
+ >
823
+ {images.map((img, idx) => (
824
+ <Pressable
825
+ key={`thumb-${img.uri}-${idx}`}
826
+ onPress={() => pageTo(idx)}
827
+ accessibilityRole="button"
828
+ accessibilityLabel={`Go to image ${idx + 1} of ${images.length}`}
829
+ style={[
830
+ styles.thumbTile,
831
+ idx === activeIndex ? styles.thumbTileActive : styles.thumbTileInactive,
832
+ webPointerStyle,
833
+ ]}
834
+ >
835
+ <Image
836
+ source={{ uri: img.uri }}
837
+ contentFit="cover"
838
+ style={styles.thumbTileImage}
839
+ transition={0}
840
+ {...(Platform.OS === 'web' ? { draggable: false } : {})}
841
+ />
842
+ </Pressable>
843
+ ))}
844
+ </ScrollView>
845
+ ) : (
846
+ <View style={styles.dotsRow} pointerEvents="none">
847
+ {images.map((img, idx) => (
848
+ <View
849
+ key={`dot-${img.uri}-${idx}`}
850
+ style={[styles.dot, idx === activeIndex ? styles.dotActive : styles.dotInactive]}
851
+ />
852
+ ))}
853
+ </View>
854
+ )}
558
855
  </Animated.View>
559
856
  )}
560
857
 
858
+ {Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex > 0 && (
859
+ <PressableWithHover
860
+ onPress={() => pageTo(activeIndex - 1)}
861
+ accessibilityRole="button"
862
+ accessibilityLabel="Previous image"
863
+ hitSlop={8}
864
+ style={[styles.navArrow, styles.navArrowLeft, webPointerStyle]}
865
+ hoverStyle={styles.navArrowHoverLeft}
866
+ >
867
+ <ArrowLeft_Stroke2_Corner0_Rounded fill="#fff" size="lg" />
868
+ </PressableWithHover>
869
+ )}
870
+
871
+ {Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex < images.length - 1 && (
872
+ <PressableWithHover
873
+ onPress={() => pageTo(activeIndex + 1)}
874
+ accessibilityRole="button"
875
+ accessibilityLabel="Next image"
876
+ hitSlop={8}
877
+ style={[styles.navArrow, styles.navArrowRight, webPointerStyle]}
878
+ hoverStyle={styles.navArrowHoverRight}
879
+ >
880
+ <ArrowRight_Stroke2_Corner0_Rounded fill="#fff" size="lg" />
881
+ </PressableWithHover>
882
+ )}
883
+
884
+ {canShare && pagerReady && (
885
+ <Pressable
886
+ onPress={handleShare}
887
+ accessibilityRole="button"
888
+ accessibilityLabel="Share image"
889
+ hitSlop={8}
890
+ style={[styles.shareButton, webPointerStyle]}
891
+ >
892
+ <ArrowOutOfBox_Stroke2_Corner0_Rounded fill="#fff" size="md" />
893
+ </Pressable>
894
+ )}
895
+
561
896
  {activeAlt ? (
562
897
  <Animated.View
563
898
  style={[
@@ -666,6 +1001,72 @@ const styles = StyleSheet.create({
666
1001
  dotInactive: {
667
1002
  backgroundColor: 'rgba(255,255,255,0.4)',
668
1003
  },
1004
+ thumbStripScroll: {
1005
+ maxWidth: '100%',
1006
+ flexGrow: 0,
1007
+ },
1008
+ // Centers the tiles when they don't fill the width, scrolls when they overflow.
1009
+ thumbStripContent: {
1010
+ flexGrow: 1,
1011
+ justifyContent: 'center',
1012
+ alignItems: 'center',
1013
+ gap: 8,
1014
+ paddingHorizontal: 16,
1015
+ },
1016
+ thumbTile: {
1017
+ width: THUMBNAIL_STRIP_TILE_SIZE,
1018
+ height: THUMBNAIL_STRIP_TILE_SIZE,
1019
+ borderRadius: 8,
1020
+ overflow: 'hidden',
1021
+ borderWidth: 2,
1022
+ },
1023
+ thumbTileActive: {
1024
+ borderColor: '#fff',
1025
+ },
1026
+ thumbTileInactive: {
1027
+ borderColor: 'rgba(255,255,255,0.25)',
1028
+ },
1029
+ thumbTileImage: {
1030
+ width: '100%',
1031
+ height: '100%',
1032
+ },
1033
+ navArrow: {
1034
+ position: 'absolute',
1035
+ top: '50%',
1036
+ width: 44,
1037
+ height: 44,
1038
+ borderRadius: 999,
1039
+ alignItems: 'center',
1040
+ justifyContent: 'center',
1041
+ backgroundColor: 'rgba(0,0,0,0.45)',
1042
+ transform: [{ translateY: -22 }],
1043
+ },
1044
+ navArrowLeft: {
1045
+ left: 16,
1046
+ },
1047
+ navArrowRight: {
1048
+ right: 16,
1049
+ },
1050
+ // Hover styles fully replace the base `transform`, so they repeat `translateY`.
1051
+ navArrowHoverLeft: {
1052
+ backgroundColor: 'rgba(0,0,0,0.7)',
1053
+ transform: [{ translateY: -22 }, { scale: 1.08 }],
1054
+ },
1055
+ navArrowHoverRight: {
1056
+ backgroundColor: 'rgba(0,0,0,0.7)',
1057
+ transform: [{ translateY: -22 }, { scale: 1.08 }],
1058
+ },
1059
+ shareButton: {
1060
+ position: 'absolute',
1061
+ top: 16,
1062
+ right: 16,
1063
+ width: 40,
1064
+ height: 40,
1065
+ borderRadius: 999,
1066
+ alignItems: 'center',
1067
+ justifyContent: 'center',
1068
+ backgroundColor: 'rgba(0,0,0,0.45)',
1069
+ },
669
1070
  altCaptionWrap: {
670
1071
  position: 'absolute',
671
1072
  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
  }