@oxyhq/bloom 0.32.0 → 0.33.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 (115) hide show
  1. package/lib/commonjs/button/Button.web.js +6 -29
  2. package/lib/commonjs/button/Button.web.js.map +1 -1
  3. package/lib/commonjs/fab/Fab.web.js +11 -2
  4. package/lib/commonjs/fab/Fab.web.js.map +1 -1
  5. package/lib/commonjs/hooks/index.js +13 -0
  6. package/lib/commonjs/hooks/index.js.map +1 -1
  7. package/lib/commonjs/hooks/useImagePreload.js +52 -0
  8. package/lib/commonjs/hooks/useImagePreload.js.map +1 -0
  9. package/lib/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.js +65 -0
  10. package/lib/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.js.map +1 -0
  11. package/lib/commonjs/image-aspect-ratio-cache/index.js +37 -0
  12. package/lib/commonjs/image-aspect-ratio-cache/index.js.map +1 -0
  13. package/lib/commonjs/index.js +15 -0
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/index.web.js +15 -0
  16. package/lib/commonjs/index.web.js.map +1 -1
  17. package/lib/commonjs/styles/flatten-web-style.js +49 -0
  18. package/lib/commonjs/styles/flatten-web-style.js.map +1 -0
  19. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +664 -0
  20. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -0
  21. package/lib/commonjs/zoomable-image-gallery/constants.js +41 -0
  22. package/lib/commonjs/zoomable-image-gallery/constants.js.map +1 -0
  23. package/lib/commonjs/zoomable-image-gallery/index.js +19 -0
  24. package/lib/commonjs/zoomable-image-gallery/index.js.map +1 -0
  25. package/lib/commonjs/zoomable-image-gallery/types.js +2 -0
  26. package/lib/commonjs/zoomable-image-gallery/types.js.map +1 -0
  27. package/lib/module/button/Button.web.js +6 -29
  28. package/lib/module/button/Button.web.js.map +1 -1
  29. package/lib/module/fab/Fab.web.js +11 -2
  30. package/lib/module/fab/Fab.web.js.map +1 -1
  31. package/lib/module/hooks/index.js +1 -0
  32. package/lib/module/hooks/index.js.map +1 -1
  33. package/lib/module/hooks/useImagePreload.js +48 -0
  34. package/lib/module/hooks/useImagePreload.js.map +1 -0
  35. package/lib/module/image-aspect-ratio-cache/imageAspectRatioCache.js +57 -0
  36. package/lib/module/image-aspect-ratio-cache/imageAspectRatioCache.js.map +1 -0
  37. package/lib/module/image-aspect-ratio-cache/index.js +4 -0
  38. package/lib/module/image-aspect-ratio-cache/index.js.map +1 -0
  39. package/lib/module/index.js +2 -0
  40. package/lib/module/index.js.map +1 -1
  41. package/lib/module/index.web.js +2 -0
  42. package/lib/module/index.web.js.map +1 -1
  43. package/lib/module/styles/flatten-web-style.js +45 -0
  44. package/lib/module/styles/flatten-web-style.js.map +1 -0
  45. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +659 -0
  46. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -0
  47. package/lib/module/zoomable-image-gallery/constants.js +38 -0
  48. package/lib/module/zoomable-image-gallery/constants.js.map +1 -0
  49. package/lib/module/zoomable-image-gallery/index.js +4 -0
  50. package/lib/module/zoomable-image-gallery/index.js.map +1 -0
  51. package/lib/module/zoomable-image-gallery/types.js +2 -0
  52. package/lib/module/zoomable-image-gallery/types.js.map +1 -0
  53. package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/hooks/index.d.ts +1 -0
  56. package/lib/typescript/commonjs/hooks/index.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/hooks/useImagePreload.d.ts +10 -0
  58. package/lib/typescript/commonjs/hooks/useImagePreload.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.d.ts +14 -0
  60. package/lib/typescript/commonjs/image-aspect-ratio-cache/imageAspectRatioCache.d.ts.map +1 -0
  61. package/lib/typescript/commonjs/image-aspect-ratio-cache/index.d.ts +2 -0
  62. package/lib/typescript/commonjs/image-aspect-ratio-cache/index.d.ts.map +1 -0
  63. package/lib/typescript/commonjs/index.d.ts +1 -0
  64. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/index.web.d.ts +1 -0
  66. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/styles/flatten-web-style.d.ts +6 -0
  68. package/lib/typescript/commonjs/styles/flatten-web-style.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts +5 -0
  70. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts +33 -0
  72. package/lib/typescript/commonjs/zoomable-image-gallery/constants.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts +3 -0
  74. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts.map +1 -0
  75. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts +46 -0
  76. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts.map +1 -0
  77. package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
  78. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  79. package/lib/typescript/module/hooks/index.d.ts +1 -0
  80. package/lib/typescript/module/hooks/index.d.ts.map +1 -1
  81. package/lib/typescript/module/hooks/useImagePreload.d.ts +10 -0
  82. package/lib/typescript/module/hooks/useImagePreload.d.ts.map +1 -0
  83. package/lib/typescript/module/image-aspect-ratio-cache/imageAspectRatioCache.d.ts +14 -0
  84. package/lib/typescript/module/image-aspect-ratio-cache/imageAspectRatioCache.d.ts.map +1 -0
  85. package/lib/typescript/module/image-aspect-ratio-cache/index.d.ts +2 -0
  86. package/lib/typescript/module/image-aspect-ratio-cache/index.d.ts.map +1 -0
  87. package/lib/typescript/module/index.d.ts +1 -0
  88. package/lib/typescript/module/index.d.ts.map +1 -1
  89. package/lib/typescript/module/index.web.d.ts +1 -0
  90. package/lib/typescript/module/index.web.d.ts.map +1 -1
  91. package/lib/typescript/module/styles/flatten-web-style.d.ts +6 -0
  92. package/lib/typescript/module/styles/flatten-web-style.d.ts.map +1 -0
  93. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts +5 -0
  94. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -0
  95. package/lib/typescript/module/zoomable-image-gallery/constants.d.ts +33 -0
  96. package/lib/typescript/module/zoomable-image-gallery/constants.d.ts.map +1 -0
  97. package/lib/typescript/module/zoomable-image-gallery/index.d.ts +3 -0
  98. package/lib/typescript/module/zoomable-image-gallery/index.d.ts.map +1 -0
  99. package/lib/typescript/module/zoomable-image-gallery/types.d.ts +46 -0
  100. package/lib/typescript/module/zoomable-image-gallery/types.d.ts.map +1 -0
  101. package/package.json +27 -1
  102. package/src/__tests__/Fab.web.test.tsx +62 -0
  103. package/src/button/Button.web.tsx +6 -36
  104. package/src/fab/Fab.web.tsx +11 -2
  105. package/src/hooks/index.ts +1 -0
  106. package/src/hooks/useImagePreload.ts +51 -0
  107. package/src/image-aspect-ratio-cache/imageAspectRatioCache.ts +62 -0
  108. package/src/image-aspect-ratio-cache/index.ts +7 -0
  109. package/src/index.ts +1 -0
  110. package/src/index.web.ts +1 -0
  111. package/src/styles/flatten-web-style.ts +58 -0
  112. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +695 -0
  113. package/src/zoomable-image-gallery/constants.ts +25 -0
  114. package/src/zoomable-image-gallery/index.ts +8 -0
  115. package/src/zoomable-image-gallery/types.ts +49 -0
@@ -0,0 +1,659 @@
1
+ "use strict";
2
+
3
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
4
+ import { View, Text, StyleSheet, Pressable, Platform, ScrollView, useWindowDimensions } from 'react-native';
5
+ import { Image } from 'expo-image';
6
+ import { BlurView } from 'expo-blur';
7
+ import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, runOnJS, Easing } from 'react-native-reanimated';
8
+ import { GestureHandlerRootView, Gesture, GestureDetector } from 'react-native-gesture-handler';
9
+ import { useTheme } from "../theme/use-theme.js";
10
+ import { Portal } from '../portal';
11
+ import { Z_INDEX } from "../styles/z-index.js";
12
+ 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";
14
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
+ const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
16
+ const AnimatedImage = Animated.createAnimatedComponent(Image);
17
+
18
+ // Web-only interaction hints (no-ops on native): the zoom surfaces are
19
+ // tap-to-dismiss (`cursor: pointer`) and must not select text or drag the
20
+ // image. `Platform.select` keeps them off native; the `as ViewStyle` cast
21
+ // mirrors Bloom's other web-only style casts (e.g. Dialog.web's `animation`).
22
+ const webPointerStyle = Platform.select({
23
+ web: {
24
+ userSelect: 'none',
25
+ cursor: 'pointer'
26
+ },
27
+ default: undefined
28
+ });
29
+ const webUserSelectNoneStyle = Platform.select({
30
+ web: {
31
+ userSelect: 'none'
32
+ },
33
+ default: undefined
34
+ });
35
+ /**
36
+ * Fullscreen, swipeable image viewer that replicates the profile avatar's
37
+ * measured-origin zoom transition (`ZoomableAvatar`) for rectangular post media:
38
+ *
39
+ * - Open/close feel is identical to the avatar (same spring configs, web
40
+ * timing/easing, blur backdrop, and the measure-origin technique). The viewer
41
+ * renders through the Bloom `Portal` on BOTH platforms (RN's `Modal` is not
42
+ * used on native — on the New Architecture / Fabric Android its host views
43
+ * mount full-screen but never composite, leaving the viewer invisible).
44
+ * - The OPENING (tapped) image animates from its measured rect to a centered,
45
+ * aspect-ratio-preserving fit within {@link FIT_FRACTION} of the screen. Once
46
+ * the open animation settles, a horizontal paging `ScrollView` mounts seeded at
47
+ * the tapped index so the user can swipe between every image in the post.
48
+ * - Gesture disambiguation: the pager owns horizontal swipes; a vertical-only
49
+ * `Gesture.Pan` (`activeOffsetY` + `failOffsetX`) owns drag-to-dismiss, so the
50
+ * two never fight.
51
+ */
52
+ const ZoomableImageGalleryInner = /*#__PURE__*/React.forwardRef(({
53
+ measureThumb,
54
+ cornerRadius = DEFAULT_CORNER_RADIUS
55
+ }, ref) => {
56
+ const theme = useTheme();
57
+ const {
58
+ width: SCREEN_WIDTH,
59
+ height: SCREEN_HEIGHT
60
+ } = useWindowDimensions();
61
+ const [isOpen, setIsOpen] = useState(false);
62
+ // Once true, the swipeable pager is mounted and the single open-image hidden.
63
+ const [pagerReady, setPagerReady] = useState(false);
64
+ const [images, setImages] = useState([]);
65
+ const [activeIndex, setActiveIndex] = useState(0);
66
+ // Aspect ratio of the OPENING image (drives the open-animation fit box).
67
+ const [openRatio, setOpenRatio] = useState(DEFAULT_ASPECT_RATIO);
68
+ // Per-image aspect ratios for the pager pages (index-aligned with `images`).
69
+ const [pageRatios, setPageRatios] = useState({});
70
+ const scale = useSharedValue(1);
71
+ const translateX = useSharedValue(0);
72
+ const translateY = useSharedValue(0);
73
+ const opacity = useSharedValue(0);
74
+
75
+ // Origin (offset from screen center) of the tapped image.
76
+ const originX = useSharedValue(0);
77
+ const originY = useSharedValue(0);
78
+ const startX = useSharedValue(0);
79
+ const startY = useSharedValue(0);
80
+ // Per-drag baseline captured at pan start (transient).
81
+ const startScale = useSharedValue(1);
82
+ // Persistent scale of the open-image at its thumbnail origin (thumbWidth /
83
+ // fittedWidth). The open animation grows from this to 1; the close animation
84
+ // shrinks back to it. Never overwritten by the drag gesture.
85
+ const originScale = useSharedValue(1);
86
+ const pagerRef = useRef(null);
87
+ // Latch the index the pager must land on once it has mounted + laid out.
88
+ const pendingIndexRef = useRef(0);
89
+ // Mirror of `activeIndex` readable synchronously from callbacks/worklets
90
+ // (`handleDismiss` runs via `runOnJS` and from `Pressable.onPress`, where the
91
+ // state closure can be stale). Kept in lockstep by `setActiveIndexBoth`.
92
+ const activeIndexRef = useRef(0);
93
+
94
+ // Single writer for the current index: updates state (drives indicator + open
95
+ // image) and the synchronous mirror together, and only when it changes.
96
+ const setActiveIndexBoth = useCallback(next => {
97
+ activeIndexRef.current = next;
98
+ setActiveIndex(prev => prev === next ? prev : next);
99
+ }, []);
100
+
101
+ // Box the fitted image must fit inside.
102
+ const fitBox = useMemo(() => ({
103
+ width: SCREEN_WIDTH * FIT_FRACTION,
104
+ height: SCREEN_HEIGHT * FIT_FRACTION
105
+ }), [SCREEN_WIDTH, SCREEN_HEIGHT]);
106
+
107
+ // Compute the largest width/height for `ratio` that fits inside `fitBox`
108
+ // without cropping (contain).
109
+ const fitForRatio = useCallback(ratio => {
110
+ const safeRatio = ratio > 0 && Number.isFinite(ratio) ? ratio : DEFAULT_ASPECT_RATIO;
111
+ let width = fitBox.width;
112
+ let height = width / safeRatio;
113
+ if (height > fitBox.height) {
114
+ height = fitBox.height;
115
+ width = height * safeRatio;
116
+ }
117
+ return {
118
+ width,
119
+ height
120
+ };
121
+ }, [fitBox]);
122
+
123
+ // Fitted size of the single open-image for the CURRENT page. On open this is
124
+ // the opened image's fit (active index == opened index, ratio == `openRatio`);
125
+ // after swiping it tracks the viewed image so the collapse-on-dismiss renders
126
+ // and flies back the image actually on screen.
127
+ const activeRatio = pageRatios[activeIndex] ?? openRatio;
128
+ const activeFit = useMemo(() => fitForRatio(activeRatio), [fitForRatio, activeRatio]);
129
+
130
+ // Alt text (accessibility description) of the image currently on screen, shown
131
+ // as a caption at the bottom of the viewer (Bluesky-style lightbox footer).
132
+ const activeAlt = images[activeIndex]?.alt?.trim() || undefined;
133
+ const ensureRatio = useCallback((index, uri) => {
134
+ const cached = getAspectRatio(uri);
135
+ if (cached !== undefined) {
136
+ setPageRatios(prev => prev[index] === cached ? prev : {
137
+ ...prev,
138
+ [index]: cached
139
+ });
140
+ return;
141
+ }
142
+ void fetchAspectRatio(uri).then(ratio => {
143
+ setPageRatios(prev => prev[index] === ratio ? prev : {
144
+ ...prev,
145
+ [index]: ratio
146
+ });
147
+ });
148
+ }, []);
149
+
150
+ // Unmount + reset all animation values to their neutral baseline. Shared by
151
+ // every dismiss path (fly-back and fade-out fallback).
152
+ const finalizeDismiss = useCallback(() => {
153
+ setIsOpen(false);
154
+ scale.value = 1;
155
+ translateX.value = 0;
156
+ translateY.value = 0;
157
+ opacity.value = 0;
158
+ }, [opacity, scale, translateX, translateY]);
159
+
160
+ // Fly the (possibly dragged) image back toward `target` — the rect of the
161
+ // thumbnail currently being viewed — shrinking to its footprint. Uses the
162
+ // EXACT same close spring (native) / timing (web) as the avatar transition.
163
+ const flyBackTo = useCallback(target => {
164
+ if (Platform.OS === 'web') {
165
+ const duration = CLOSE_DURATION_WEB;
166
+ const easing = Easing.in(Easing.cubic);
167
+ scale.value = withTiming(target.scale, {
168
+ duration,
169
+ easing
170
+ });
171
+ translateX.value = withTiming(target.x, {
172
+ duration,
173
+ easing
174
+ });
175
+ translateY.value = withTiming(target.y, {
176
+ duration,
177
+ easing
178
+ });
179
+ opacity.value = withTiming(0, {
180
+ duration,
181
+ easing
182
+ });
183
+ setTimeout(finalizeDismiss, duration + 20);
184
+ } else {
185
+ scale.value = withSpring(target.scale, CLOSE_SPRING);
186
+ translateX.value = withSpring(target.x, CLOSE_SPRING);
187
+ translateY.value = withSpring(target.y, CLOSE_SPRING);
188
+ opacity.value = withTiming(0, {
189
+ duration: OPACITY_DURATION
190
+ });
191
+ setTimeout(finalizeDismiss, CLOSE_DURATION_WEB);
192
+ }
193
+ }, [finalizeDismiss, opacity, scale, translateX, translateY]);
194
+
195
+ // Fallback when the current thumbnail cannot be measured (ref missing /
196
+ // unmounted / virtualized): a plain center fade-out with a slight scale-down,
197
+ // then unmount. Keeps the same web timing / native spring feel.
198
+ const fadeOutCenter = useCallback(() => {
199
+ if (Platform.OS === 'web') {
200
+ const duration = CLOSE_DURATION_WEB;
201
+ const easing = Easing.in(Easing.cubic);
202
+ scale.value = withTiming(MIN_DRAG_SCALE, {
203
+ duration,
204
+ easing
205
+ });
206
+ opacity.value = withTiming(0, {
207
+ duration,
208
+ easing
209
+ });
210
+ setTimeout(finalizeDismiss, duration + 20);
211
+ } else {
212
+ scale.value = withSpring(MIN_DRAG_SCALE, CLOSE_SPRING);
213
+ opacity.value = withTiming(0, {
214
+ duration: OPACITY_DURATION
215
+ });
216
+ setTimeout(finalizeDismiss, CLOSE_DURATION_WEB);
217
+ }
218
+ }, [finalizeDismiss, opacity, scale]);
219
+ const handleDismiss = useCallback(() => {
220
+ // Collapse the pager back to the single open-image so the fly-back animates
221
+ // one image (the current one) rather than the whole scrolled strip.
222
+ setPagerReady(false);
223
+ const index = activeIndexRef.current;
224
+ const current = images[index];
225
+ // Recompute the fly-back target from the CURRENT image: the live rect of its
226
+ // thumbnail + the same fitted box (`activeFit`) the open-image is rendered
227
+ // at, using the same screen-center math as `open`. Falls back to a center
228
+ // fade-out when the thumbnail can't be measured.
229
+ if (measureThumb && current) {
230
+ const centerX = SCREEN_WIDTH / 2;
231
+ const centerY = SCREEN_HEIGHT / 2;
232
+ void measureThumb(index).then(rect => {
233
+ if (rect && rect.width > 0 && activeFit.width > 0) {
234
+ flyBackTo({
235
+ x: rect.x + rect.width / 2 - centerX,
236
+ y: rect.y + rect.height / 2 - centerY,
237
+ scale: rect.width / activeFit.width
238
+ });
239
+ } else {
240
+ fadeOutCenter();
241
+ }
242
+ });
243
+ return;
244
+ }
245
+ fadeOutCenter();
246
+ }, [activeFit, fadeOutCenter, flyBackTo, images, measureThumb, SCREEN_HEIGHT, SCREEN_WIDTH]);
247
+
248
+ // Reveal the swipeable pager once the open animation has settled. The index it
249
+ // lands on is held in `pendingIndexRef` (set synchronously in `open`) and
250
+ // applied in `onPagerLayout`.
251
+ const revealPager = useCallback(() => {
252
+ setPagerReady(true);
253
+ }, []);
254
+ const open = useCallback((nextImages, index, rect) => {
255
+ if (isOpen || nextImages.length === 0) return;
256
+ const safeIndex = Math.min(Math.max(index, 0), nextImages.length - 1);
257
+ const target = nextImages[safeIndex];
258
+ if (!target) return;
259
+ const knownRatio = target.aspectRatio ?? getAspectRatio(target.uri);
260
+ const ratio = knownRatio ?? DEFAULT_ASPECT_RATIO;
261
+ setImages(nextImages);
262
+ setActiveIndexBoth(safeIndex);
263
+ setOpenRatio(ratio);
264
+ // Seed every page whose ratio is already known (consumer-provided metadata
265
+ // or a previously-cached probe) so swiping never hits the same snap — only
266
+ // a genuinely-unknown image falls through to `ensureRatio`'s async probe.
267
+ const initialRatios = {};
268
+ nextImages.forEach((img, i) => {
269
+ const r = img.aspectRatio ?? getAspectRatio(img.uri);
270
+ if (r !== undefined) initialRatios[i] = r;
271
+ });
272
+ setPageRatios(initialRatios);
273
+ pendingIndexRef.current = safeIndex;
274
+
275
+ // Resolve the opening ratio if it was not yet known, then re-fit.
276
+ if (knownRatio === undefined) {
277
+ void fetchAspectRatio(target.uri).then(resolved => {
278
+ setOpenRatio(resolved);
279
+ setPageRatios(prev => ({
280
+ ...prev,
281
+ [safeIndex]: resolved
282
+ }));
283
+ });
284
+ }
285
+
286
+ // The open-image box is rendered at its FINAL fitted size; the open
287
+ // animation grows it from the thumbnail's footprint (scale < 1) up to 1,
288
+ // mirroring the avatar's small→big scale. The initial scale is the ratio
289
+ // of the thumbnail width to the fitted width.
290
+ const fitted = fitForRatio(ratio);
291
+ const centerX = SCREEN_WIDTH / 2;
292
+ const centerY = SCREEN_HEIGHT / 2;
293
+ if (rect && rect.width > 0) {
294
+ originX.value = rect.x + rect.width / 2 - centerX;
295
+ originY.value = rect.y + rect.height / 2 - centerY;
296
+ originScale.value = rect.width / fitted.width;
297
+ } else {
298
+ originX.value = 0;
299
+ originY.value = 0;
300
+ originScale.value = 1;
301
+ }
302
+ setIsOpen(true);
303
+ translateX.value = originX.value;
304
+ translateY.value = originY.value;
305
+ scale.value = originScale.value;
306
+ opacity.value = 0;
307
+ if (Platform.OS === 'web') {
308
+ requestAnimationFrame(() => {
309
+ const duration = OPEN_DURATION_WEB;
310
+ const easing = Easing.out(Easing.cubic);
311
+ scale.value = withTiming(1, {
312
+ duration,
313
+ easing
314
+ });
315
+ translateX.value = withTiming(0, {
316
+ duration,
317
+ easing
318
+ });
319
+ translateY.value = withTiming(0, {
320
+ duration,
321
+ easing
322
+ });
323
+ opacity.value = withTiming(1, {
324
+ duration,
325
+ easing
326
+ });
327
+ setTimeout(revealPager, duration);
328
+ });
329
+ } else {
330
+ setTimeout(() => {
331
+ scale.value = withSpring(1, OPEN_SPRING);
332
+ translateX.value = withSpring(0, OPEN_SPRING);
333
+ translateY.value = withSpring(0, OPEN_SPRING);
334
+ opacity.value = withTiming(1, {
335
+ duration: OPACITY_DURATION
336
+ });
337
+ setTimeout(revealPager, OPEN_DURATION_WEB);
338
+ }, 0);
339
+ }
340
+ }, [isOpen, SCREEN_WIDTH, SCREEN_HEIGHT, fitForRatio, opacity, originScale, originX, originY, revealPager, scale, setActiveIndexBoth, translateX, translateY]);
341
+ React.useImperativeHandle(ref, () => ({
342
+ open
343
+ }), [open]);
344
+ const panGesture = useMemo(() => Gesture.Pan().enabled(isOpen)
345
+ // Only claim drags that are decided to be vertical; horizontal drags
346
+ // fall through to the pager's ScrollView so swiping changes pages.
347
+ .activeOffsetY([-AXIS_DECISION_OFFSET, AXIS_DECISION_OFFSET]).failOffsetX([-AXIS_DECISION_OFFSET, AXIS_DECISION_OFFSET]).onStart(() => {
348
+ startX.value = translateX.value;
349
+ startY.value = translateY.value;
350
+ startScale.value = scale.value;
351
+ }).onUpdate(event => {
352
+ translateX.value = startX.value + event.translationX;
353
+ translateY.value = startY.value + event.translationY;
354
+ const dragDistance = Math.sqrt(event.translationX ** 2 + event.translationY ** 2);
355
+ const maxDrag = SCREEN_HEIGHT * MAX_DRAG_FRACTION;
356
+ opacity.value = Math.max(0, 1 - dragDistance / maxDrag);
357
+ const scaleReduction = Math.max(MIN_DRAG_SCALE, 1 - dragDistance / (SCREEN_HEIGHT * SCALE_DRAG_FRACTION));
358
+ scale.value = startScale.value * scaleReduction;
359
+ }).onEnd(event => {
360
+ const dragDistance = Math.sqrt(event.translationX ** 2 + event.translationY ** 2);
361
+ if (dragDistance > SCREEN_HEIGHT * DISMISS_FRACTION) {
362
+ runOnJS(handleDismiss)();
363
+ } else {
364
+ scale.value = withSpring(1, SNAP_BACK_SPRING);
365
+ translateX.value = withSpring(0, SNAP_BACK_SPRING);
366
+ translateY.value = withSpring(0, SNAP_BACK_SPRING);
367
+ opacity.value = withTiming(1, {
368
+ duration: OPACITY_DURATION
369
+ });
370
+ }
371
+ }), [handleDismiss, isOpen, SCREEN_HEIGHT, opacity, scale, startScale, startX, startY, translateX, translateY]);
372
+ const backdropStyle = useAnimatedStyle(() => ({
373
+ opacity: opacity.value
374
+ }));
375
+
376
+ // The single open-image animates from origin → fitted center.
377
+ const openImageStyle = useAnimatedStyle(() => ({
378
+ transform: [{
379
+ translateX: translateX.value
380
+ }, {
381
+ translateY: translateY.value
382
+ }, {
383
+ scale: scale.value
384
+ }]
385
+ }));
386
+
387
+ // While dragging to dismiss, the whole pager follows the finger + fades.
388
+ const pagerContainerStyle = useAnimatedStyle(() => ({
389
+ opacity: opacity.value,
390
+ transform: [{
391
+ translateX: translateX.value
392
+ }, {
393
+ translateY: translateY.value
394
+ }, {
395
+ scale: scale.value
396
+ }]
397
+ }));
398
+
399
+ // Derive the current page from the scroll offset, clamp into range, and update
400
+ // `activeIndex` only when it actually changes (drives the live indicator + the
401
+ // close fly-back target). Shared by `onMomentumScrollEnd` (native) and `onScroll`
402
+ // (web, where paging may not fire a reliable momentum-end).
403
+ const updateIndexFromOffset = useCallback(offsetX => {
404
+ const lastIndex = images.length - 1;
405
+ if (lastIndex < 0) return;
406
+ const next = Math.min(Math.max(Math.round(offsetX / SCREEN_WIDTH), 0), lastIndex);
407
+ if (next === activeIndexRef.current) return;
408
+ setActiveIndexBoth(next);
409
+ const img = images[next];
410
+ if (img) ensureRatio(next, img.uri);
411
+ }, [ensureRatio, images, setActiveIndexBoth, SCREEN_WIDTH]);
412
+ const onPagerScroll = useCallback(event => {
413
+ updateIndexFromOffset(event.nativeEvent.contentOffset.x);
414
+ }, [updateIndexFromOffset]);
415
+
416
+ // When the pager mounts, jump it to the open index without animation so the
417
+ // swap from the open-image to the pager is seamless.
418
+ const onPagerLayout = useCallback(() => {
419
+ const idx = pendingIndexRef.current;
420
+ if (idx > 0) {
421
+ pagerRef.current?.scrollTo({
422
+ x: idx * SCREEN_WIDTH,
423
+ animated: false
424
+ });
425
+ }
426
+ }, [SCREEN_WIDTH]);
427
+ const renderContent = () => /*#__PURE__*/_jsxs(GestureHandlerRootView, {
428
+ style: styles.modalContainer,
429
+ children: [/*#__PURE__*/_jsx(Pressable, {
430
+ style: StyleSheet.absoluteFill,
431
+ onPress: handleDismiss,
432
+ hitSlop: 0,
433
+ children: /*#__PURE__*/_jsx(AnimatedBlurView, {
434
+ intensity: 80,
435
+ tint: theme.isDark ? 'dark' : 'light',
436
+ experimentalBlurMethod: "dimezisBlurView",
437
+ style: [StyleSheet.absoluteFill, backdropStyle],
438
+ children: /*#__PURE__*/_jsx(Animated.View, {
439
+ style: [StyleSheet.absoluteFill, {
440
+ backgroundColor: theme.colors.overlay
441
+ }, backdropStyle]
442
+ })
443
+ })
444
+ }), /*#__PURE__*/_jsx(GestureDetector, {
445
+ gesture: panGesture,
446
+ children: /*#__PURE__*/_jsxs(Animated.View, {
447
+ style: [StyleSheet.absoluteFill, styles.zoomContainer, {
448
+ width: SCREEN_WIDTH,
449
+ height: SCREEN_HEIGHT
450
+ }, webUserSelectNoneStyle],
451
+ pointerEvents: "box-none",
452
+ children: [!pagerReady && /*#__PURE__*/_jsx(Pressable, {
453
+ onPress: handleDismiss,
454
+ style: webPointerStyle,
455
+ children: /*#__PURE__*/_jsx(AnimatedImage, {
456
+ source: {
457
+ uri: images[activeIndex]?.uri
458
+ },
459
+ contentFit: "contain",
460
+ style: [{
461
+ width: activeFit.width,
462
+ height: activeFit.height,
463
+ borderRadius: cornerRadius
464
+ }, openImageStyle],
465
+ transition: 0,
466
+ ...(Platform.OS === 'web' ? {
467
+ draggable: false
468
+ } : {})
469
+ })
470
+ }), pagerReady && /*#__PURE__*/_jsx(Animated.View, {
471
+ style: [StyleSheet.absoluteFill, pagerContainerStyle],
472
+ children: /*#__PURE__*/_jsx(ScrollView, {
473
+ ref: pagerRef,
474
+ horizontal: true,
475
+ pagingEnabled: true,
476
+ showsHorizontalScrollIndicator: false,
477
+ contentOffset: {
478
+ x: pendingIndexRef.current * SCREEN_WIDTH,
479
+ y: 0
480
+ },
481
+ onLayout: onPagerLayout,
482
+ onMomentumScrollEnd: onPagerScroll,
483
+ ...(Platform.OS === 'web' ? {
484
+ onScroll: onPagerScroll
485
+ } : {}),
486
+ scrollEventThrottle: 16,
487
+ style: StyleSheet.absoluteFill,
488
+ children: images.map((img, idx) => {
489
+ const ratio = pageRatios[idx] ?? getAspectRatio(img.uri) ?? DEFAULT_ASPECT_RATIO;
490
+ const fit = fitForRatio(ratio);
491
+ return /*#__PURE__*/_jsx(Pressable, {
492
+ onPress: handleDismiss,
493
+ style: [styles.page, {
494
+ width: SCREEN_WIDTH,
495
+ height: SCREEN_HEIGHT
496
+ }, webPointerStyle],
497
+ children: /*#__PURE__*/_jsx(Image, {
498
+ source: {
499
+ uri: img.uri
500
+ },
501
+ contentFit: "contain",
502
+ style: {
503
+ width: fit.width,
504
+ height: fit.height,
505
+ borderRadius: cornerRadius
506
+ },
507
+ transition: 0,
508
+ ...(Platform.OS === 'web' ? {
509
+ draggable: false
510
+ } : {})
511
+ })
512
+ }, `${img.uri}-${idx}`);
513
+ })
514
+ })
515
+ }), pagerReady && images.length > 1 && /*#__PURE__*/_jsxs(Animated.View, {
516
+ style: [styles.indicatorWrap, backdropStyle],
517
+ pointerEvents: "none",
518
+ children: [/*#__PURE__*/_jsx(View, {
519
+ style: styles.counterPill,
520
+ children: /*#__PURE__*/_jsx(Text, {
521
+ style: styles.counterText,
522
+ children: `${activeIndex + 1} / ${images.length}`
523
+ })
524
+ }), /*#__PURE__*/_jsx(View, {
525
+ style: styles.dotsRow,
526
+ children: images.map((img, idx) => /*#__PURE__*/_jsx(View, {
527
+ style: [styles.dot, idx === activeIndex ? styles.dotActive : styles.dotInactive]
528
+ }, `dot-${img.uri}-${idx}`))
529
+ })]
530
+ }), activeAlt ? /*#__PURE__*/_jsx(Animated.View, {
531
+ style: [styles.altCaptionWrap, images.length > 1 && styles.altCaptionWrapWithIndicator, backdropStyle],
532
+ pointerEvents: "none",
533
+ children: /*#__PURE__*/_jsx(View, {
534
+ style: styles.altCaptionPill,
535
+ children: /*#__PURE__*/_jsx(Text, {
536
+ style: styles.altCaptionText,
537
+ numberOfLines: 4,
538
+ children: activeAlt
539
+ })
540
+ })
541
+ }) : null]
542
+ })
543
+ })]
544
+ });
545
+ if (!isOpen) return null;
546
+
547
+ // Both platforms render through the Bloom `Portal` so the viewer overlays the
548
+ // whole app from the root `Outlet`. RN's `Modal` is intentionally NOT used on
549
+ // native: on the New Architecture (Fabric) Android its host views mount
550
+ // full-screen in the tree but never composite to the screen, leaving the
551
+ // entire viewer (blur backdrop + zoomed image) invisible — tapping appeared to
552
+ // "do nothing". The Portal path is the same one the working web build uses.
553
+ return /*#__PURE__*/_jsx(Portal, {
554
+ children: renderContent()
555
+ });
556
+ });
557
+ ZoomableImageGalleryInner.displayName = 'ZoomableImageGallery';
558
+ export const ZoomableImageGallery = ZoomableImageGalleryInner;
559
+ const styles = StyleSheet.create({
560
+ modalContainer: {
561
+ justifyContent: 'center',
562
+ alignItems: 'center',
563
+ ...Platform.select({
564
+ web: {
565
+ // Full-screen fixed overlay (web). Mirrors Bloom's Portal/Dialog.web
566
+ // `position: 'fixed' as 'absolute'` pattern; the fullscreen z-index
567
+ // token keeps the viewer above the app chrome.
568
+ position: 'fixed',
569
+ top: 0,
570
+ left: 0,
571
+ right: 0,
572
+ bottom: 0,
573
+ zIndex: Z_INDEX.fullscreen
574
+ },
575
+ default: {
576
+ // Mounted through the Bloom Portal `Outlet` (which renders its content
577
+ // inline at the app root on native, NOT wrapped in a full-screen view),
578
+ // so the gallery root must absolutely fill the screen itself to overlay
579
+ // the app. RN's `Modal` is not used on native: on the New Architecture
580
+ // (Fabric) Android its host views mount full-screen but never paint
581
+ // (the entire viewer — backdrop + image — stays invisible).
582
+ position: 'absolute',
583
+ top: 0,
584
+ left: 0,
585
+ right: 0,
586
+ bottom: 0,
587
+ backgroundColor: 'transparent'
588
+ }
589
+ })
590
+ },
591
+ zoomContainer: {
592
+ justifyContent: 'center',
593
+ alignItems: 'center'
594
+ },
595
+ page: {
596
+ justifyContent: 'center',
597
+ alignItems: 'center'
598
+ },
599
+ indicatorWrap: {
600
+ position: 'absolute',
601
+ bottom: 48,
602
+ left: 0,
603
+ right: 0,
604
+ alignItems: 'center',
605
+ gap: 10
606
+ },
607
+ counterPill: {
608
+ paddingHorizontal: 12,
609
+ paddingVertical: 4,
610
+ borderRadius: 999,
611
+ backgroundColor: 'rgba(0,0,0,0.55)'
612
+ },
613
+ counterText: {
614
+ color: '#fff',
615
+ fontSize: 13,
616
+ fontWeight: '600'
617
+ },
618
+ dotsRow: {
619
+ flexDirection: 'row',
620
+ gap: 6
621
+ },
622
+ dot: {
623
+ width: 7,
624
+ height: 7,
625
+ borderRadius: 999
626
+ },
627
+ dotActive: {
628
+ backgroundColor: '#fff'
629
+ },
630
+ dotInactive: {
631
+ backgroundColor: 'rgba(255,255,255,0.4)'
632
+ },
633
+ altCaptionWrap: {
634
+ position: 'absolute',
635
+ bottom: 48,
636
+ left: 0,
637
+ right: 0,
638
+ alignItems: 'center',
639
+ paddingHorizontal: 24
640
+ },
641
+ // Lifted above the page counter + dots when the multi-image indicator shows.
642
+ altCaptionWrapWithIndicator: {
643
+ bottom: 110
644
+ },
645
+ altCaptionPill: {
646
+ maxWidth: 600,
647
+ paddingHorizontal: 14,
648
+ paddingVertical: 8,
649
+ borderRadius: 12,
650
+ backgroundColor: 'rgba(0,0,0,0.6)'
651
+ },
652
+ altCaptionText: {
653
+ color: '#fff',
654
+ fontSize: 13,
655
+ lineHeight: 18
656
+ }
657
+ });
658
+ export default ZoomableImageGallery;
659
+ //# sourceMappingURL=ZoomableImageGallery.js.map