@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.
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +375 -20
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/commonjs/zoomable-image-gallery/constants.js +16 -1
- package/lib/commonjs/zoomable-image-gallery/constants.js.map +1 -1
- package/lib/commonjs/zoomable-image-gallery/index.js +15 -0
- package/lib/commonjs/zoomable-image-gallery/index.js.map +1 -1
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +368 -13
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/module/zoomable-image-gallery/constants.js +15 -0
- package/lib/module/zoomable-image-gallery/constants.js.map +1 -1
- package/lib/module/zoomable-image-gallery/index.js +1 -0
- package/lib/module/zoomable-image-gallery/index.js.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts +14 -0
- package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts +1 -0
- package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts +6 -0
- package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/constants.d.ts +14 -0
- package/lib/typescript/module/zoomable-image-gallery/constants.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/index.d.ts +1 -0
- package/lib/typescript/module/zoomable-image-gallery/index.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/types.d.ts +6 -0
- package/lib/typescript/module/zoomable-image-gallery/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +420 -19
- package/src/zoomable-image-gallery/constants.ts +15 -0
- package/src/zoomable-image-gallery/index.ts +1 -0
- 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
|
-
|
|
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
|
-
<
|
|
793
|
+
<View
|
|
527
794
|
key={`${img.uri}-${idx}`}
|
|
528
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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;
|
|
@@ -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
|
}
|