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