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