panelui-native 0.97.0 → 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/lib/module/components/image-viewer/image-viewer-geometry.js +203 -0
- package/lib/module/components/image-viewer/image-viewer-geometry.js.map +1 -0
- package/lib/module/components/image-viewer/index.js +1100 -0
- package/lib/module/components/image-viewer/index.js.map +1 -0
- package/lib/module/components/stack-card/index.js +1204 -0
- package/lib/module/components/stack-card/index.js.map +1 -0
- package/lib/module/components/stack-card/stack-card-geometry.js +261 -0
- package/lib/module/components/stack-card/stack-card-geometry.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/scrim.js +65 -1
- package/lib/module/primitives/scrim.js.map +1 -1
- package/lib/typescript/src/components/image-viewer/image-viewer-geometry.d.ts +83 -0
- package/lib/typescript/src/components/image-viewer/image-viewer-geometry.d.ts.map +1 -0
- package/lib/typescript/src/components/image-viewer/index.d.ts +148 -0
- package/lib/typescript/src/components/image-viewer/index.d.ts.map +1 -0
- package/lib/typescript/src/components/stack-card/index.d.ts +472 -0
- package/lib/typescript/src/components/stack-card/index.d.ts.map +1 -0
- package/lib/typescript/src/components/stack-card/stack-card-geometry.d.ts +126 -0
- package/lib/typescript/src/components/stack-card/stack-card-geometry.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/scrim.d.ts +11 -1
- package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
- package/package.json +8 -1
- package/src/components/image-viewer/image-viewer-geometry.ts +205 -0
- package/src/components/image-viewer/index.tsx +1329 -0
- package/src/components/stack-card/index.tsx +1423 -0
- package/src/components/stack-card/stack-card-geometry.ts +279 -0
- package/src/index.ts +19 -0
- package/src/primitives/scrim.tsx +96 -1
|
@@ -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
|