panelui-native 0.98.0 → 0.100.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 (41) hide show
  1. package/lib/module/components/animated-badge/index.js +24 -2
  2. package/lib/module/components/animated-badge/index.js.map +1 -1
  3. package/lib/module/components/bump-chart/index.js +1236 -0
  4. package/lib/module/components/bump-chart/index.js.map +1 -0
  5. package/lib/module/components/image-viewer/image-viewer-geometry.js +203 -0
  6. package/lib/module/components/image-viewer/image-viewer-geometry.js.map +1 -0
  7. package/lib/module/components/image-viewer/index.js +1100 -0
  8. package/lib/module/components/image-viewer/index.js.map +1 -0
  9. package/lib/module/components/mirror-area-chart/index.js +1113 -0
  10. package/lib/module/components/mirror-area-chart/index.js.map +1 -0
  11. package/lib/module/index.js +3 -0
  12. package/lib/module/index.js.map +1 -1
  13. package/lib/module/primitives/scrim.js +65 -1
  14. package/lib/module/primitives/scrim.js.map +1 -1
  15. package/lib/module/utils/chart.js +70 -0
  16. package/lib/module/utils/chart.js.map +1 -1
  17. package/lib/typescript/src/components/animated-badge/index.d.ts +5 -0
  18. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -1
  19. package/lib/typescript/src/components/bump-chart/index.d.ts +267 -0
  20. package/lib/typescript/src/components/bump-chart/index.d.ts.map +1 -0
  21. package/lib/typescript/src/components/image-viewer/image-viewer-geometry.d.ts +83 -0
  22. package/lib/typescript/src/components/image-viewer/image-viewer-geometry.d.ts.map +1 -0
  23. package/lib/typescript/src/components/image-viewer/index.d.ts +148 -0
  24. package/lib/typescript/src/components/image-viewer/index.d.ts.map +1 -0
  25. package/lib/typescript/src/components/mirror-area-chart/index.d.ts +263 -0
  26. package/lib/typescript/src/components/mirror-area-chart/index.d.ts.map +1 -0
  27. package/lib/typescript/src/index.d.ts +3 -0
  28. package/lib/typescript/src/index.d.ts.map +1 -1
  29. package/lib/typescript/src/primitives/scrim.d.ts +11 -1
  30. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
  31. package/lib/typescript/src/utils/chart.d.ts +23 -0
  32. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/components/animated-badge/index.tsx +35 -2
  35. package/src/components/bump-chart/index.tsx +1455 -0
  36. package/src/components/image-viewer/image-viewer-geometry.ts +205 -0
  37. package/src/components/image-viewer/index.tsx +1329 -0
  38. package/src/components/mirror-area-chart/index.tsx +1333 -0
  39. package/src/index.ts +42 -0
  40. package/src/primitives/scrim.tsx +96 -1
  41. package/src/utils/chart.ts +70 -0
@@ -0,0 +1,1100 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * ImageViewer — a picture that opens out of the page to fill the screen.
5
+ *
6
+ * Pressing a trigger lifts its image from where it sits, grows it to the whole
7
+ * picture in the middle of the screen, and blurs the page behind it. From there
8
+ * it can be pinched and double-tapped to zoom, swiped sideways to the next
9
+ * image under the same root, and dragged away to put it back.
10
+ *
11
+ * ```tsx
12
+ * <ImageViewer>
13
+ * {photos.map((photo) => (
14
+ * <ImageViewer.Trigger
15
+ * key={photo.id}
16
+ * source={photo.source}
17
+ * alt={photo.alt}
18
+ * radius={12}
19
+ * className="h-40 flex-1 rounded-xl"
20
+ * />
21
+ * ))}
22
+ * </ImageViewer>
23
+ * ```
24
+ *
25
+ * ## The image travels; nothing appears
26
+ *
27
+ * The viewer never fades a second copy of the picture in over the first. The
28
+ * thumbnail is hidden and the same image is drawn in a frame that starts at the
29
+ * thumbnail's exact rectangle and corner radius and ends at the fitted one. The
30
+ * picture inside that frame is always drawn at the size that *covers* it, so the
31
+ * crop a thumbnail shows opens out into the whole image as the frame grows — and
32
+ * closes back into the crop on the way home. That continuity is the point: the
33
+ * picture on screen is the one that was pressed, not a picture of it.
34
+ *
35
+ * The frame animates its bounds rather than a transform, because a transform
36
+ * cannot change a crop. It is one view with one child, so the layout pass it
37
+ * costs per frame is small.
38
+ *
39
+ * ## Every trigger under one root is a page
40
+ *
41
+ * Triggers register themselves with the root as they mount, in render order or
42
+ * by an explicit `index`. Opening one opens the gallery at its page, and the
43
+ * thumbnail hidden underneath follows the page — close on the third image and it
44
+ * flies back into the third thumbnail.
45
+ *
46
+ * ## One gesture surface, decided at the start
47
+ *
48
+ * Pinching, panning a zoomed image, turning the page and dragging to dismiss all
49
+ * begin as a one- or two-finger drag, and which one it is gets decided the
50
+ * moment it starts: two fingers zoom, one finger on a zoomed image pans it, one
51
+ * finger moving sideways across a gallery turns the page, and anything else
52
+ * drags the image away. Deciding once keeps a drag from changing its mind
53
+ * halfway, which is what makes a viewer feel loose.
54
+ */
55
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
56
+ import { Image, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
57
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
58
+ import Animated, { cancelAnimation, runOnJS, runOnUI, useAnimatedProps, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withDecay, withDelay, withSpring, withTiming } from 'react-native-reanimated';
59
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
60
+ import { useCSSVariable } from 'uniwind';
61
+ import { XIcon } from "../../icons/index.js";
62
+ import { useControllableState } from "../../primitives/controllable-state.js";
63
+ import { ModalPortal } from "../../primitives/portal.js";
64
+ import { Scrim } from "../../primitives/scrim.js";
65
+ import { Text, textChildren } from "../../primitives/text.js";
66
+ import { useBackHandler } from "../../hooks/use-back-handler.js";
67
+ import { useThemeMode } from "../../theme/use-theme.js";
68
+ import { cn } from "../../utils/cn.js";
69
+ import { selectionTick } from "../../utils/haptics.js";
70
+ import { clamp, coverSize, dragFade, dragScale, fitWithin, focalTranslation, hitsRect, lerp, lerpRect, panBound, rubberBandClamp, shouldDismiss, snapPage } from "./image-viewer-geometry.js";
71
+
72
+ /** Space between two pages of a gallery, so a page turn reads as two pictures. */
73
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
74
+ const PAGE_GAP = 16;
75
+
76
+ /**
77
+ * Out of the page. Just short of critically damped: enough give that the
78
+ * picture seems to arrive rather than stop, not enough to wobble.
79
+ */
80
+ const OPEN_SPRING = {
81
+ damping: 30,
82
+ stiffness: 260,
83
+ mass: 1
84
+ };
85
+
86
+ /**
87
+ * Back into the page. Clamped, because a frame that overshoots its thumbnail
88
+ * shrinks past it and grows back — a visible bounce at the one moment the image
89
+ * is supposed to be settling into place.
90
+ */
91
+ const CLOSE_SPRING = {
92
+ damping: 32,
93
+ stiffness: 300,
94
+ mass: 1,
95
+ overshootClamping: true
96
+ };
97
+
98
+ /** Zoom settling and page turns. Firm, so a flick lands without drifting. */
99
+ const SETTLE_SPRING = {
100
+ damping: 34,
101
+ stiffness: 320,
102
+ mass: 1
103
+ };
104
+
105
+ /**
106
+ * Margin between the open picture and the screen, in points. Enough to show the
107
+ * blur on all four sides, so the picture reads as held up over the page rather
108
+ * than as a new screen that happens to be a photograph.
109
+ */
110
+ const DEFAULT_INSET = 16;
111
+
112
+ /** Corners of the open picture, matching the library's large surfaces. */
113
+ const DEFAULT_CORNER_RADIUS = 16;
114
+
115
+ /** How strongly the blur comes up behind the picture. */
116
+ const BLUR_INTENSITY = 50;
117
+ const PHASE_OPENING = 0;
118
+ const PHASE_OPEN = 1;
119
+ const PHASE_CLOSING = 2;
120
+ const MODE_NONE = 0;
121
+ const MODE_PAN = 1;
122
+ const MODE_PAGE = 2;
123
+ const MODE_DISMISS = 3;
124
+
125
+ /* -------------------------------------------------------------------------- */
126
+ /* Registry */
127
+ /* -------------------------------------------------------------------------- */
128
+
129
+ /**
130
+ * The triggers under one root, in page order.
131
+ *
132
+ * An external store rather than state on the root, because a trigger updating
133
+ * its caption should re-render the open viewer and nothing else — root state
134
+ * would re-render every trigger, which would register again, which would
135
+ * re-render the root.
136
+ */
137
+ class ViewerStore {
138
+ items = new Map();
139
+ listeners = new Set();
140
+ counter = 0;
141
+ version = 0;
142
+ sorted = [];
143
+ nextOrder = () => this.counter++;
144
+ set = item => {
145
+ this.items.set(item.id, item);
146
+ this.emit();
147
+ };
148
+ delete = id => {
149
+ if (this.items.delete(id)) this.emit();
150
+ };
151
+ list = () => this.sorted;
152
+ subscribe = listener => {
153
+ this.listeners.add(listener);
154
+ return () => {
155
+ this.listeners.delete(listener);
156
+ };
157
+ };
158
+ getSnapshot = () => this.version;
159
+ emit() {
160
+ this.version += 1;
161
+ this.sorted = [...this.items.values()].sort((a, b) => (a.index ?? a.order) - (b.index ?? b.order));
162
+ this.listeners.forEach(listener => listener());
163
+ }
164
+ }
165
+ const ViewerContext = /*#__PURE__*/createContext(null);
166
+ function useViewer(part) {
167
+ const context = useContext(ViewerContext);
168
+ if (!context) throw new Error(`${part} must be used inside <ImageViewer>.`);
169
+ return context;
170
+ }
171
+
172
+ /* -------------------------------------------------------------------------- */
173
+ /* Root */
174
+ /* -------------------------------------------------------------------------- */
175
+
176
+ function ImageViewerRoot({
177
+ children,
178
+ open,
179
+ defaultOpen = false,
180
+ onOpenChange,
181
+ index,
182
+ defaultIndex = 0,
183
+ onIndexChange,
184
+ blur = true,
185
+ maxScale = 4,
186
+ doubleTapScale = 2.5,
187
+ haptics = true,
188
+ showClose = true,
189
+ closeLabel = 'Close',
190
+ inset = DEFAULT_INSET,
191
+ cornerRadius = DEFAULT_CORNER_RADIUS
192
+ }) {
193
+ const storeRef = useRef(null);
194
+ storeRef.current ??= new ViewerStore();
195
+ const store = storeRef.current;
196
+ const {
197
+ value: isOpen,
198
+ setValue: setOpen
199
+ } = useControllableState({
200
+ value: open,
201
+ defaultValue: defaultOpen,
202
+ onChange: onOpenChange
203
+ });
204
+ const {
205
+ value: page,
206
+ setValue: setPage
207
+ } = useControllableState({
208
+ value: index,
209
+ defaultValue: defaultIndex,
210
+ onChange: onIndexChange
211
+ });
212
+
213
+ // Mounted from the moment it opens until the picture is back in the page,
214
+ // which is after `open` has already gone false.
215
+ const [mounted, setMounted] = useState(isOpen);
216
+ if (isOpen && !mounted) setMounted(true);
217
+ const [hiddenId, setHiddenId] = useState(null);
218
+ const openItem = useCallback(id => {
219
+ const at = store.list().findIndex(item => item.id === id);
220
+ if (at < 0) return;
221
+ setPage(at);
222
+ setOpen(true);
223
+ }, [setOpen, setPage, store]);
224
+ const requestClose = useCallback(() => setOpen(false), [setOpen]);
225
+ const exited = useCallback(() => {
226
+ setMounted(false);
227
+ setHiddenId(null);
228
+ }, []);
229
+ useBackHandler(isOpen, requestClose);
230
+ const context = useMemo(() => ({
231
+ store,
232
+ hiddenId,
233
+ openItem
234
+ }), [store, hiddenId, openItem]);
235
+ return /*#__PURE__*/_jsxs(ViewerContext.Provider, {
236
+ value: context,
237
+ children: [children, mounted ? /*#__PURE__*/_jsx(ModalPortal, {
238
+ children: /*#__PURE__*/_jsx(ViewerOverlay, {
239
+ store: store,
240
+ open: isOpen,
241
+ index: page,
242
+ onIndexChange: setPage,
243
+ onRequestClose: requestClose,
244
+ onExited: exited,
245
+ onShow: setHiddenId,
246
+ blur: blur,
247
+ maxScale: maxScale,
248
+ doubleTapScale: doubleTapScale,
249
+ haptics: haptics,
250
+ showClose: showClose,
251
+ closeLabel: closeLabel,
252
+ inset: inset,
253
+ cornerRadius: cornerRadius
254
+ })
255
+ }) : null]
256
+ });
257
+ }
258
+
259
+ /* -------------------------------------------------------------------------- */
260
+ /* Trigger */
261
+ /* -------------------------------------------------------------------------- */
262
+
263
+ /** The image's proportions from what can be known without loading it. */
264
+ function knownSize(source, width, height) {
265
+ if (width && height) return {
266
+ width,
267
+ height
268
+ };
269
+ if (typeof source === 'number') {
270
+ const asset = Image.resolveAssetSource(source);
271
+ if (asset?.width && asset?.height) return {
272
+ width: asset.width,
273
+ height: asset.height
274
+ };
275
+ }
276
+ return null;
277
+ }
278
+ function uriOf(source) {
279
+ if (source && typeof source === 'object' && !Array.isArray(source)) {
280
+ return source.uri;
281
+ }
282
+ return undefined;
283
+ }
284
+
285
+ /**
286
+ * The thing pressed to open the viewer.
287
+ *
288
+ * It registers its picture with the root, so the gallery is simply every
289
+ * trigger under that root. While its picture is out on the screen it is drawn
290
+ * transparent — not removed, so the page does not reflow under the blur and the
291
+ * flight home has somewhere to land.
292
+ */
293
+ function ImageViewerTrigger({
294
+ className,
295
+ source,
296
+ fullSource,
297
+ alt,
298
+ caption,
299
+ index,
300
+ radius = 0,
301
+ width,
302
+ height,
303
+ disabled = false,
304
+ children,
305
+ onPress,
306
+ onLayout,
307
+ ...props
308
+ }) {
309
+ const {
310
+ store,
311
+ hiddenId,
312
+ openItem
313
+ } = useViewer('ImageViewer.Trigger');
314
+ const id = useId();
315
+ const ref = useRef(null);
316
+ const orderRef = useRef(null);
317
+ orderRef.current ??= store.nextOrder();
318
+ const [size, setSize] = useState(() => knownSize(source, width, height));
319
+ const [box, setBox] = useState(null);
320
+ const uri = uriOf(source);
321
+ useEffect(() => {
322
+ const known = knownSize(source, width, height);
323
+ if (known) {
324
+ setSize(known);
325
+ return;
326
+ }
327
+ if (!uri) return;
328
+ let live = true;
329
+ Image.getSize(uri, (w, h) => {
330
+ if (live) setSize({
331
+ width: w,
332
+ height: h
333
+ });
334
+ }, () => {});
335
+ return () => {
336
+ live = false;
337
+ };
338
+ // `source` is compared through its uri; an inline `{ uri }` is new every render.
339
+ // eslint-disable-next-line react-hooks/exhaustive-deps
340
+ }, [uri, width, height]);
341
+ const measure = useCallback(done => {
342
+ const node = ref.current;
343
+ if (!node) {
344
+ done(null);
345
+ return;
346
+ }
347
+ node.measureInWindow((x, y, w, h) => {
348
+ done(w > 0 && h > 0 ? {
349
+ x,
350
+ y,
351
+ width: w,
352
+ height: h
353
+ } : null);
354
+ });
355
+ }, []);
356
+ useLayoutEffect(() => {
357
+ store.set({
358
+ id,
359
+ order: orderRef.current ?? 0,
360
+ index,
361
+ source,
362
+ fullSource,
363
+ alt,
364
+ caption,
365
+ radius,
366
+ // The box's own proportions stand in until the image's are known, which
367
+ // is right for a thumbnail that shows the picture uncropped and close
368
+ // enough for one that does not.
369
+ size: size ?? box,
370
+ measure
371
+ });
372
+ }, [store, id, index, source, fullSource, alt, caption, radius, size, box, measure]);
373
+ useLayoutEffect(() => () => store.delete(id), [store, id]);
374
+ const handleLayout = event => {
375
+ onLayout?.(event);
376
+ const {
377
+ width: w,
378
+ height: h
379
+ } = event.nativeEvent.layout;
380
+ if (w > 0 && h > 0) setBox({
381
+ width: w,
382
+ height: h
383
+ });
384
+ };
385
+ return /*#__PURE__*/_jsx(Pressable, {
386
+ ref: ref,
387
+ collapsable: false,
388
+ accessibilityRole: "imagebutton",
389
+ accessibilityLabel: alt,
390
+ accessibilityHint: "Opens the image",
391
+ accessibilityState: {
392
+ disabled
393
+ },
394
+ disabled: disabled,
395
+ onPress: event => {
396
+ onPress?.(event);
397
+ openItem(id);
398
+ },
399
+ onLayout: handleLayout,
400
+ className: cn('overflow-hidden', className),
401
+ style: [radius ? {
402
+ borderRadius: radius
403
+ } : null, {
404
+ opacity: hiddenId === id ? 0 : 1
405
+ }],
406
+ ...props,
407
+ children: children ?? /*#__PURE__*/_jsx(Image, {
408
+ source: source,
409
+ resizeMode: "cover",
410
+ accessible: false,
411
+ onLoad: event => {
412
+ if (size) return;
413
+ const loaded = event.nativeEvent.source;
414
+ if (loaded?.width && loaded?.height) {
415
+ setSize({
416
+ width: loaded.width,
417
+ height: loaded.height
418
+ });
419
+ }
420
+ },
421
+ style: StyleSheet.absoluteFill
422
+ })
423
+ });
424
+ }
425
+
426
+ /* -------------------------------------------------------------------------- */
427
+ /* Overlay */
428
+ /* -------------------------------------------------------------------------- */
429
+
430
+ function ViewerOverlay({
431
+ store,
432
+ open,
433
+ index,
434
+ onIndexChange,
435
+ onRequestClose,
436
+ onExited,
437
+ onShow,
438
+ blur,
439
+ maxScale,
440
+ doubleTapScale,
441
+ haptics,
442
+ showClose,
443
+ closeLabel,
444
+ inset,
445
+ cornerRadius
446
+ }) {
447
+ const version = useSyncExternalStore(store.subscribe, store.getSnapshot);
448
+ // eslint-disable-next-line react-hooks/exhaustive-deps
449
+ const items = useMemo(() => store.list(), [store, version]);
450
+ const count = items.length;
451
+ const page = count ? clamp(index, 0, count - 1) : 0;
452
+ const item = items[page];
453
+ const {
454
+ width: W,
455
+ height: H
456
+ } = useWindowDimensions();
457
+ const insets = useSafeAreaInsets();
458
+ const reduced = useReducedMotion();
459
+ const {
460
+ mode
461
+ } = useThemeMode();
462
+ const foreground = useCSSVariable('--color-foreground');
463
+ const pageWidth = W + PAGE_GAP;
464
+
465
+ /*
466
+ * The area the picture is fitted into. The same margin on both sides of each
467
+ * axis, so the fitted picture is centred on the screen — the zoom and pan
468
+ * arithmetic is measured from the screen's centre and relies on that.
469
+ */
470
+ const box = useMemo(() => {
471
+ const vertical = Math.max(insets.top, insets.bottom) + inset;
472
+ return {
473
+ x: inset,
474
+ y: vertical,
475
+ width: Math.max(W - inset * 2, 1),
476
+ height: Math.max(H - vertical * 2, 1)
477
+ };
478
+ }, [H, W, inset, insets.bottom, insets.top]);
479
+ const fit = useMemo(() => fitWithin(item?.size ?? box, box), [item?.size, box]);
480
+
481
+ // The flight, 0 in the page and 1 on the screen.
482
+ const t = useSharedValue(0);
483
+ const phase = useSharedValue(PHASE_OPENING);
484
+ const origin = useSharedValue(null);
485
+ const radius = useSharedValue(0);
486
+
487
+ // Zoom of the current page, measured from the screen's centre.
488
+ const scale = useSharedValue(1);
489
+ const tx = useSharedValue(0);
490
+ const ty = useSharedValue(0);
491
+ const startScale = useSharedValue(1);
492
+ const startX = useSharedValue(0);
493
+ const startY = useSharedValue(0);
494
+ const focalOffsetX = useSharedValue(0);
495
+ const focalOffsetY = useSharedValue(0);
496
+ const lastFocalX = useSharedValue(0);
497
+ const lastFocalY = useSharedValue(0);
498
+ const pinching = useSharedValue(false);
499
+
500
+ // Dragging the picture away.
501
+ const dragX = useSharedValue(0);
502
+ const dragY = useSharedValue(0);
503
+ const shrink = useSharedValue(1);
504
+ const fade = useSharedValue(1);
505
+
506
+ // The gallery.
507
+ const pagerX = useSharedValue(-page * pageWidth);
508
+ const pagerStart = useSharedValue(0);
509
+ const activePage = useSharedValue(page);
510
+ const gestureMode = useSharedValue(MODE_NONE);
511
+ const chrome = useSharedValue(0);
512
+ const chromeOn = useSharedValue(true);
513
+ const backdrop = useDerivedValue(() => clamp(t.value, 0, 1) * fade.value);
514
+ const itemRef = useRef(item);
515
+ itemRef.current = item;
516
+
517
+ /**
518
+ * Where the current page's thumbnail is now. Measured again on every page
519
+ * change and before every close, because the flight home has to land where
520
+ * the thumbnail actually is. A thumbnail that has left the screen gives no
521
+ * rectangle, and the picture fades instead of flying somewhere invisible.
522
+ */
523
+ const measureCurrent = useCallback(then => {
524
+ const current = itemRef.current;
525
+ if (!current) {
526
+ origin.value = null;
527
+ then?.();
528
+ return;
529
+ }
530
+ current.measure(rect => {
531
+ const onScreen = rect !== null && rect.y + rect.height > 0 && rect.y < H && rect.x + rect.width > 0 && rect.x < W;
532
+ origin.value = onScreen ? rect : null;
533
+ radius.value = current.radius;
534
+ then?.();
535
+ });
536
+ }, [H, W, origin, radius]);
537
+ const startClose = useCallback((velocityX, velocityY) => {
538
+ 'worklet';
539
+
540
+ phase.value = PHASE_CLOSING;
541
+ gestureMode.value = MODE_NONE;
542
+ chrome.value = withTiming(0, {
543
+ duration: 120
544
+ });
545
+ const done = finished => {
546
+ 'worklet';
547
+
548
+ if (finished && phase.value === PHASE_CLOSING) runOnJS(onExited)();
549
+ };
550
+ if (reduced) {
551
+ t.value = withTiming(0, {
552
+ duration: 180
553
+ }, done);
554
+ return;
555
+ }
556
+ t.value = withSpring(0, CLOSE_SPRING, done);
557
+ dragX.value = withSpring(0, {
558
+ ...CLOSE_SPRING,
559
+ velocity: velocityX
560
+ });
561
+ dragY.value = withSpring(0, {
562
+ ...CLOSE_SPRING,
563
+ velocity: velocityY
564
+ });
565
+ shrink.value = withSpring(1, CLOSE_SPRING);
566
+ scale.value = withSpring(1, CLOSE_SPRING);
567
+ tx.value = withSpring(0, CLOSE_SPRING);
568
+ ty.value = withSpring(0, CLOSE_SPRING);
569
+ }, [chrome, dragX, dragY, gestureMode, onExited, phase, reduced, scale, shrink, t, tx, ty]);
570
+ const settleOpen = useCallback(() => {
571
+ phase.value = PHASE_OPENING;
572
+ const done = finished => {
573
+ 'worklet';
574
+
575
+ if (finished) phase.value = PHASE_OPEN;
576
+ };
577
+ t.value = reduced ? withTiming(1, {
578
+ duration: 200
579
+ }, done) : withSpring(1, OPEN_SPRING, done);
580
+ fade.value = withTiming(1, {
581
+ duration: 150
582
+ });
583
+ chrome.value = withDelay(120, withTiming(chromeOn.value ? 1 : 0, {
584
+ duration: 200
585
+ }));
586
+ }, [chrome, chromeOn, fade, phase, reduced, t]);
587
+
588
+ // Opening and closing follow `open`, whoever changed it.
589
+ const hasOpened = useRef(false);
590
+ useEffect(() => {
591
+ if (!itemRef.current) {
592
+ onRequestClose();
593
+ onExited();
594
+ return;
595
+ }
596
+ if (open) {
597
+ if (hasOpened.current) {
598
+ settleOpen();
599
+ return;
600
+ }
601
+ hasOpened.current = true;
602
+ activePage.value = page;
603
+ pagerX.value = -page * pageWidth;
604
+ measureCurrent(() => {
605
+ onShow(itemRef.current?.id ?? null);
606
+ settleOpen();
607
+ });
608
+ return;
609
+ }
610
+ // A gesture that closed the viewer has already started the flight home.
611
+ if (phase.value === PHASE_CLOSING) return;
612
+ measureCurrent(() => runOnUI(startClose)(0, 0));
613
+ // eslint-disable-next-line react-hooks/exhaustive-deps
614
+ }, [open]);
615
+
616
+ // A page change from anywhere: the pager, the accessibility actions, or the owner.
617
+ const firstPage = useRef(true);
618
+ useEffect(() => {
619
+ if (firstPage.current) {
620
+ firstPage.current = false;
621
+ return;
622
+ }
623
+ if (!itemRef.current) return;
624
+ if (activePage.value !== page) {
625
+ activePage.value = page;
626
+ scale.value = 1;
627
+ tx.value = 0;
628
+ ty.value = 0;
629
+ pagerX.value = reduced ? -page * pageWidth : withSpring(-page * pageWidth, SETTLE_SPRING);
630
+ }
631
+ measureCurrent();
632
+ onShow(itemRef.current.id);
633
+ // eslint-disable-next-line react-hooks/exhaustive-deps
634
+ }, [page]);
635
+
636
+ // A rotation re-fits everything, and a zoom that no longer means anything goes.
637
+ const firstSize = useRef(true);
638
+ useEffect(() => {
639
+ if (firstSize.current) {
640
+ firstSize.current = false;
641
+ return;
642
+ }
643
+ cancelAnimation(pagerX);
644
+ pagerX.value = -activePage.value * pageWidth;
645
+ scale.value = 1;
646
+ tx.value = 0;
647
+ ty.value = 0;
648
+ // eslint-disable-next-line react-hooks/exhaustive-deps
649
+ }, [W, H]);
650
+ const settleZoom = useCallback(() => {
651
+ 'worklet';
652
+
653
+ const target = clamp(scale.value, 1, maxScale);
654
+ if (target <= 1.01) {
655
+ scale.value = withSpring(1, SETTLE_SPRING);
656
+ tx.value = withSpring(0, SETTLE_SPRING);
657
+ ty.value = withSpring(0, SETTLE_SPRING);
658
+ chrome.value = withTiming(chromeOn.value ? 1 : 0, {
659
+ duration: 180
660
+ });
661
+ return;
662
+ }
663
+ const nextX = target === scale.value ? tx.value : focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, target);
664
+ const nextY = target === scale.value ? ty.value : focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, target);
665
+ const boundX = panBound(fit.width, target, W);
666
+ const boundY = panBound(fit.height, target, H);
667
+ scale.value = withSpring(target, SETTLE_SPRING);
668
+ tx.value = withSpring(clamp(nextX, -boundX, boundX), SETTLE_SPRING);
669
+ ty.value = withSpring(clamp(nextY, -boundY, boundY), SETTLE_SPRING);
670
+ }, [H, W, chrome, chromeOn, fit.height, fit.width, focalOffsetX, focalOffsetY, lastFocalX, lastFocalY, maxScale, scale, startScale, tx, ty]);
671
+ const gesture = useMemo(() => {
672
+ const pinch = Gesture.Pinch().onStart(event => {
673
+ 'worklet';
674
+
675
+ if (phase.value !== PHASE_OPEN) return;
676
+ pinching.value = true;
677
+ cancelAnimation(scale);
678
+ cancelAnimation(tx);
679
+ cancelAnimation(ty);
680
+ startScale.value = scale.value;
681
+ lastFocalX.value = event.focalX - W / 2;
682
+ lastFocalY.value = event.focalY - H / 2;
683
+ focalOffsetX.value = lastFocalX.value - tx.value;
684
+ focalOffsetY.value = lastFocalY.value - ty.value;
685
+ chrome.value = withTiming(0, {
686
+ duration: 120
687
+ });
688
+ }).onUpdate(event => {
689
+ 'worklet';
690
+
691
+ if (!pinching.value) return;
692
+ const next = rubberBandClamp(startScale.value * event.scale, 1, maxScale, maxScale);
693
+ lastFocalX.value = event.focalX - W / 2;
694
+ lastFocalY.value = event.focalY - H / 2;
695
+ scale.value = next;
696
+ tx.value = focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, next);
697
+ ty.value = focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, next);
698
+ }).onEnd(() => {
699
+ 'worklet';
700
+
701
+ if (!pinching.value) return;
702
+ pinching.value = false;
703
+ settleZoom();
704
+ });
705
+ const pan = Gesture.Pan().maxPointers(1).minDistance(8).onStart(event => {
706
+ 'worklet';
707
+
708
+ if (phase.value !== PHASE_OPEN || pinching.value) {
709
+ gestureMode.value = MODE_NONE;
710
+ return;
711
+ }
712
+ if (scale.value > 1.01) {
713
+ gestureMode.value = MODE_PAN;
714
+ cancelAnimation(tx);
715
+ cancelAnimation(ty);
716
+ startX.value = tx.value;
717
+ startY.value = ty.value;
718
+ return;
719
+ }
720
+ if (count > 1 && Math.abs(event.translationX) > Math.abs(event.translationY)) {
721
+ gestureMode.value = MODE_PAGE;
722
+ cancelAnimation(pagerX);
723
+ pagerStart.value = pagerX.value;
724
+ return;
725
+ }
726
+ gestureMode.value = MODE_DISMISS;
727
+ chrome.value = withTiming(0, {
728
+ duration: 120
729
+ });
730
+ if (haptics) runOnJS(selectionTick)();
731
+ }).onUpdate(event => {
732
+ 'worklet';
733
+
734
+ if (gestureMode.value === MODE_PAN) {
735
+ const boundX = panBound(fit.width, scale.value, W);
736
+ const boundY = panBound(fit.height, scale.value, H);
737
+ tx.value = rubberBandClamp(startX.value + event.translationX, -boundX, boundX, W);
738
+ ty.value = rubberBandClamp(startY.value + event.translationY, -boundY, boundY, H);
739
+ } else if (gestureMode.value === MODE_PAGE) {
740
+ pagerX.value = rubberBandClamp(pagerStart.value + event.translationX, -(count - 1) * pageWidth, 0, W);
741
+ } else if (gestureMode.value === MODE_DISMISS) {
742
+ dragX.value = event.translationX;
743
+ dragY.value = event.translationY;
744
+ shrink.value = dragScale(event.translationY, H);
745
+ fade.value = dragFade(event.translationY, H);
746
+ }
747
+ }).onEnd((event, success) => {
748
+ 'worklet';
749
+
750
+ const current = gestureMode.value;
751
+ gestureMode.value = MODE_NONE;
752
+ if (current === MODE_PAN) {
753
+ const boundX = panBound(fit.width, scale.value, W);
754
+ const boundY = panBound(fit.height, scale.value, H);
755
+ if (boundX === 0 || Math.abs(tx.value) > boundX) {
756
+ tx.value = withSpring(clamp(tx.value, -boundX, boundX), SETTLE_SPRING);
757
+ } else {
758
+ tx.value = withDecay({
759
+ velocity: event.velocityX,
760
+ clamp: [-boundX, boundX]
761
+ });
762
+ }
763
+ if (boundY === 0 || Math.abs(ty.value) > boundY) {
764
+ ty.value = withSpring(clamp(ty.value, -boundY, boundY), SETTLE_SPRING);
765
+ } else {
766
+ ty.value = withDecay({
767
+ velocity: event.velocityY,
768
+ clamp: [-boundY, boundY]
769
+ });
770
+ }
771
+ } else if (current === MODE_PAGE) {
772
+ const target = snapPage(pagerX.value, event.velocityX, activePage.value, count, pageWidth);
773
+ pagerX.value = withSpring(-target * pageWidth, {
774
+ ...SETTLE_SPRING,
775
+ velocity: event.velocityX
776
+ });
777
+ if (target !== activePage.value) {
778
+ activePage.value = target;
779
+ runOnJS(onIndexChange)(target);
780
+ }
781
+ } else if (current === MODE_DISMISS) {
782
+ if (success && shouldDismiss(event.translationY, event.velocityY)) {
783
+ startClose(event.velocityX, event.velocityY);
784
+ runOnJS(onRequestClose)();
785
+ return;
786
+ }
787
+ dragX.value = withSpring(0, SETTLE_SPRING);
788
+ dragY.value = withSpring(0, SETTLE_SPRING);
789
+ shrink.value = withSpring(1, SETTLE_SPRING);
790
+ fade.value = withTiming(1, {
791
+ duration: 180
792
+ });
793
+ chrome.value = withTiming(chromeOn.value ? 1 : 0, {
794
+ duration: 180
795
+ });
796
+ }
797
+ });
798
+ const doubleTap = Gesture.Tap().numberOfTaps(2).maxDelay(250).maxDistance(24).onEnd((event, success) => {
799
+ 'worklet';
800
+
801
+ if (!success || phase.value !== PHASE_OPEN) return;
802
+ if (scale.value > 1.01) {
803
+ scale.value = withSpring(1, SETTLE_SPRING);
804
+ tx.value = withSpring(0, SETTLE_SPRING);
805
+ ty.value = withSpring(0, SETTLE_SPRING);
806
+ chrome.value = withTiming(chromeOn.value ? 1 : 0, {
807
+ duration: 180
808
+ });
809
+ return;
810
+ }
811
+ const target = Math.min(doubleTapScale, maxScale);
812
+ const focalX = event.x - W / 2;
813
+ const focalY = event.y - H / 2;
814
+ const boundX = panBound(fit.width, target, W);
815
+ const boundY = panBound(fit.height, target, H);
816
+ scale.value = withSpring(target, SETTLE_SPRING);
817
+ tx.value = withSpring(clamp(focalTranslation(focalX, focalX, 1, target), -boundX, boundX), SETTLE_SPRING);
818
+ ty.value = withSpring(clamp(focalTranslation(focalY, focalY, 1, target), -boundY, boundY), SETTLE_SPRING);
819
+ chrome.value = withTiming(0, {
820
+ duration: 120
821
+ });
822
+ });
823
+ const singleTap = Gesture.Tap().maxDistance(10).onEnd((event, success) => {
824
+ 'worklet';
825
+
826
+ if (!success || phase.value !== PHASE_OPEN) return;
827
+ const onImage = hitsRect(event.x, event.y, fit, scale.value, tx.value, ty.value);
828
+ if (!onImage) {
829
+ startClose(0, 0);
830
+ runOnJS(onRequestClose)();
831
+ return;
832
+ }
833
+ chromeOn.value = !chromeOn.value;
834
+ chrome.value = withTiming(chromeOn.value ? 1 : 0, {
835
+ duration: 180
836
+ });
837
+ });
838
+ return Gesture.Race(Gesture.Simultaneous(pinch, pan), Gesture.Exclusive(doubleTap, singleTap));
839
+ }, [H, W, activePage, chrome, chromeOn, count, doubleTapScale, dragX, dragY, fade, fit, focalOffsetX, focalOffsetY, gestureMode, haptics, lastFocalX, lastFocalY, maxScale, onIndexChange, onRequestClose, pageWidth, pagerStart, pagerX, phase, pinching, scale, settleZoom, shrink, startClose, startScale, startX, startY, tx, ty]);
840
+ const pagerStyle = useAnimatedStyle(() => ({
841
+ transform: [{
842
+ translateX: pagerX.value
843
+ }]
844
+ }));
845
+ const chromeStyle = useAnimatedStyle(() => ({
846
+ opacity: chrome.value * clamp(t.value, 0, 1) * fade.value
847
+ }));
848
+ const chromeProps = useAnimatedProps(() => ({
849
+ pointerEvents: chrome.value > 0.5 ? 'box-none' : 'none'
850
+ }));
851
+ const onAccessibilityAction = event => {
852
+ const name = event.nativeEvent.actionName;
853
+ if (name === 'increment' && page < count - 1) onIndexChange(page + 1);else if (name === 'decrement' && page > 0) onIndexChange(page - 1);else if (name === 'escape' || name === 'magicTap') onRequestClose();
854
+ };
855
+ if (!item) return null;
856
+ const visiblePages = items.map((entry, at) => ({
857
+ entry,
858
+ at
859
+ })).filter(({
860
+ at
861
+ }) => Math.abs(at - page) <= 1);
862
+ const iconColour = typeof foreground === 'string' ? foreground : '#0a0a0a';
863
+ const caption = item.caption;
864
+ return /*#__PURE__*/_jsxs(View, {
865
+ style: StyleSheet.absoluteFill,
866
+ accessibilityViewIsModal: true,
867
+ onAccessibilityEscape: onRequestClose,
868
+ children: [/*#__PURE__*/_jsx(Scrim, {
869
+ blur: blur,
870
+ progress: backdrop,
871
+ intensity: BLUR_INTENSITY,
872
+ tint: mode === 'dark' ? 'dark' : 'light',
873
+ dimClassName: "bg-black/60",
874
+ pointerEvents: "none"
875
+ }), /*#__PURE__*/_jsx(GestureDetector, {
876
+ gesture: gesture,
877
+ children: /*#__PURE__*/_jsx(View, {
878
+ style: StyleSheet.absoluteFill,
879
+ collapsable: false,
880
+ accessible: true,
881
+ accessibilityRole: count > 1 ? 'adjustable' : 'image',
882
+ accessibilityLabel: item.alt,
883
+ accessibilityValue: count > 1 ? {
884
+ text: `${page + 1} of ${count}`
885
+ } : undefined,
886
+ accessibilityActions: [...(count > 1 ? [{
887
+ name: 'increment'
888
+ }, {
889
+ name: 'decrement'
890
+ }] : []), {
891
+ name: 'escape'
892
+ }, {
893
+ name: 'magicTap'
894
+ }],
895
+ onAccessibilityAction: onAccessibilityAction,
896
+ children: /*#__PURE__*/_jsx(Animated.View, {
897
+ pointerEvents: "none",
898
+ style: [StyleSheet.absoluteFill, pagerStyle],
899
+ children: visiblePages.map(({
900
+ entry,
901
+ at
902
+ }) => /*#__PURE__*/_jsx(ViewerPage, {
903
+ item: entry,
904
+ at: at,
905
+ left: at * pageWidth,
906
+ viewport: {
907
+ width: W,
908
+ height: H
909
+ },
910
+ box: box,
911
+ cornerRadius: cornerRadius,
912
+ reduced: reduced,
913
+ activePage: activePage,
914
+ t: t,
915
+ origin: origin,
916
+ radius: radius,
917
+ scale: scale,
918
+ tx: tx,
919
+ ty: ty,
920
+ dragX: dragX,
921
+ dragY: dragY,
922
+ shrink: shrink
923
+ }, entry.id))
924
+ })
925
+ })
926
+ }), /*#__PURE__*/_jsxs(Animated.View, {
927
+ style: [StyleSheet.absoluteFill, chromeStyle],
928
+ animatedProps: chromeProps,
929
+ children: [/*#__PURE__*/_jsxs(View, {
930
+ pointerEvents: "box-none",
931
+ className: "absolute inset-x-4 flex-row items-center justify-between",
932
+ style: {
933
+ top: insets.top + 8
934
+ },
935
+ children: [showClose ? /*#__PURE__*/_jsx(Pressable, {
936
+ accessibilityRole: "button",
937
+ accessibilityLabel: closeLabel,
938
+ hitSlop: 8,
939
+ onPress: onRequestClose,
940
+ className: "h-10 w-10 items-center justify-center rounded-full bg-background/80",
941
+ children: /*#__PURE__*/_jsx(XIcon, {
942
+ size: 18,
943
+ color: iconColour
944
+ })
945
+ }) : /*#__PURE__*/_jsx(View, {
946
+ className: "h-10 w-10"
947
+ }), count > 1 ? /*#__PURE__*/_jsx(View, {
948
+ accessibilityElementsHidden: true,
949
+ importantForAccessibility: "no-hide-descendants",
950
+ className: "rounded-full bg-background/80 px-3 py-1.5",
951
+ children: /*#__PURE__*/_jsx(Text, {
952
+ size: "sm",
953
+ weight: "medium",
954
+ className: "tabular-nums text-foreground",
955
+ children: `${page + 1} of ${count}`
956
+ })
957
+ }) : null, /*#__PURE__*/_jsx(View, {
958
+ className: "h-10 w-10"
959
+ })]
960
+ }), caption !== undefined && caption !== null ? /*#__PURE__*/_jsx(View, {
961
+ pointerEvents: "box-none",
962
+ className: "absolute inset-x-4 rounded-2xl bg-background/80 px-4 py-3",
963
+ style: {
964
+ bottom: insets.bottom + 16
965
+ },
966
+ children: textChildren(caption, text => /*#__PURE__*/_jsx(Text, {
967
+ size: "sm",
968
+ className: "text-foreground",
969
+ children: text
970
+ }))
971
+ }) : null]
972
+ })]
973
+ });
974
+ }
975
+ /**
976
+ * One picture in the pager.
977
+ *
978
+ * Only the current page flies, zooms and drags; its neighbours sit fitted and
979
+ * still, waiting to be turned to. That is also why one set of zoom values is
980
+ * enough for the whole gallery — a page is reset before it stops being current.
981
+ */
982
+ function ViewerPage({
983
+ item,
984
+ at,
985
+ left,
986
+ viewport,
987
+ box,
988
+ cornerRadius,
989
+ reduced,
990
+ activePage,
991
+ t,
992
+ origin,
993
+ radius,
994
+ scale,
995
+ tx,
996
+ ty,
997
+ dragX,
998
+ dragY,
999
+ shrink
1000
+ }) {
1001
+ const size = item.size ?? box;
1002
+ const fit = fitWithin(size, box);
1003
+ const [fullLoaded, setFullLoaded] = useState(false);
1004
+ const frameStyle = useAnimatedStyle(() => {
1005
+ if (activePage.value !== at) {
1006
+ return {
1007
+ left: fit.x,
1008
+ top: fit.y,
1009
+ width: fit.width,
1010
+ height: fit.height,
1011
+ borderRadius: cornerRadius,
1012
+ opacity: 1,
1013
+ transform: [{
1014
+ translateX: 0
1015
+ }, {
1016
+ translateY: 0
1017
+ }, {
1018
+ scale: 1
1019
+ }]
1020
+ };
1021
+ }
1022
+ const progress = t.value;
1023
+ const from = origin.value;
1024
+ const flies = from !== null && !reduced;
1025
+ const rect = flies ? lerpRect(from, fit, progress) : fit;
1026
+ const settled = clamp(progress, 0, 1);
1027
+ const visualScale = scale.value * shrink.value * (flies ? 1 : lerp(0.92, 1, settled));
1028
+ // The radius is drawn inside the scale, so it is divided back out: a
1029
+ // picture zoomed to four times keeps corners the size they were, rather
1030
+ // than rounding into a pill.
1031
+ const corner = flies ? lerp(radius.value, cornerRadius, progress) : cornerRadius;
1032
+ return {
1033
+ left: rect.x,
1034
+ top: rect.y,
1035
+ width: Math.max(rect.width, 1),
1036
+ height: Math.max(rect.height, 1),
1037
+ borderRadius: Math.max(0, corner) / Math.max(visualScale, 0.01),
1038
+ opacity: flies ? 1 : settled,
1039
+ transform: [{
1040
+ translateX: tx.value + dragX.value
1041
+ }, {
1042
+ translateY: ty.value + dragY.value
1043
+ }, {
1044
+ scale: visualScale
1045
+ }]
1046
+ };
1047
+ });
1048
+ const pictureStyle = useAnimatedStyle(() => {
1049
+ const active = activePage.value === at;
1050
+ const from = origin.value;
1051
+ const flies = active && from !== null && !reduced;
1052
+ const rect = flies ? lerpRect(from, fit, t.value) : fit;
1053
+ const cover = coverSize(size, rect);
1054
+ return {
1055
+ left: (rect.width - cover.width) / 2,
1056
+ top: (rect.height - cover.height) / 2,
1057
+ width: cover.width,
1058
+ height: cover.height
1059
+ };
1060
+ });
1061
+ return /*#__PURE__*/_jsx(View, {
1062
+ pointerEvents: "none",
1063
+ style: {
1064
+ position: 'absolute',
1065
+ left,
1066
+ top: 0,
1067
+ width: viewport.width,
1068
+ height: viewport.height
1069
+ },
1070
+ children: /*#__PURE__*/_jsx(Animated.View, {
1071
+ style: [{
1072
+ position: 'absolute',
1073
+ overflow: 'hidden'
1074
+ }, frameStyle],
1075
+ children: /*#__PURE__*/_jsxs(Animated.View, {
1076
+ style: [{
1077
+ position: 'absolute'
1078
+ }, pictureStyle],
1079
+ children: [/*#__PURE__*/_jsx(Image, {
1080
+ source: item.source,
1081
+ resizeMode: "cover",
1082
+ accessible: false,
1083
+ style: StyleSheet.absoluteFill
1084
+ }), item.fullSource ? /*#__PURE__*/_jsx(Image, {
1085
+ source: item.fullSource,
1086
+ resizeMode: "cover",
1087
+ accessible: false,
1088
+ onLoad: () => setFullLoaded(true),
1089
+ style: [StyleSheet.absoluteFill, {
1090
+ opacity: fullLoaded ? 1 : 0
1091
+ }]
1092
+ }) : null]
1093
+ })
1094
+ })
1095
+ });
1096
+ }
1097
+ export const ImageViewer = Object.assign(ImageViewerRoot, {
1098
+ Trigger: ImageViewerTrigger
1099
+ });
1100
+ //# sourceMappingURL=index.js.map