@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.
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +389 -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 +382 -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 +435 -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,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()
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
}
|
|
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(
|
|
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,
|