@astratra/native-ui 0.1.1 → 0.2.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 +137 -1
- package/package.json +6 -4
- package/src/components/AutoRatioImage.js +237 -0
- package/src/components/ImageShimmer.js +91 -0
- package/src/components/ImageViewer.js +609 -0
- package/src/index.d.ts +117 -1
- package/src/index.js +3 -0
- package/src/logic/index.d.ts +104 -0
- package/src/logic/index.js +2 -1
- package/src/logic/picture.js +369 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pictures full screen, the way Photos shows them: black around the picture,
|
|
3
|
+
* round glass controls that a tap hides and shows again.
|
|
4
|
+
*
|
|
5
|
+
* - Swipe sideways from one picture to the next.
|
|
6
|
+
* - Pinch to zoom, or double-tap where you want to look closer; double-tap
|
|
7
|
+
* again to come back. While zoomed, the finger moves around the picture and
|
|
8
|
+
* the pages stay put.
|
|
9
|
+
* - Pull the picture down to close: the black fades as it goes.
|
|
10
|
+
* - At the top: close, where you are ("3 of 12", the app's words), and the
|
|
11
|
+
* details when the app gives some.
|
|
12
|
+
* - At the bottom: the title, then what can be done — share (the app's hook),
|
|
13
|
+
* and the app's own actions (save, remove…). An action may answer with a
|
|
14
|
+
* short notice ("Saved"), shown a moment over the picture.
|
|
15
|
+
*
|
|
16
|
+
* Zoom is native on iOS (a zooming ScrollView: its bounce, its deceleration).
|
|
17
|
+
* Android's ScrollView does not zoom — the first version simply had no pinch
|
|
18
|
+
* there — so Android gets a pinch of its own: two fingers scale around the
|
|
19
|
+
* point between them, one finger moves the zoomed picture, and on release it
|
|
20
|
+
* settles back inside its bounds (logic/picture.js).
|
|
21
|
+
*
|
|
22
|
+
* Nothing here is worded or fetched: labels, icons, actions and how a picture
|
|
23
|
+
* becomes a source (`resolveSource`, typically @astratra/native's
|
|
24
|
+
* `createPictureSources().resolve`, which alone decides whether a token goes
|
|
25
|
+
* with it) all come from the app. "Reduce Motion": no fades, no springs, no
|
|
26
|
+
* shrinking pull; the viewer opens and closes at once.
|
|
27
|
+
*/
|
|
28
|
+
const { React, h, RN, Reanimated, AnimatedView, useReducedMotion } = require('./runtime');
|
|
29
|
+
const { GlassButton } = require('./GlassButton');
|
|
30
|
+
const { GlassSurface } = require('./GlassSurface');
|
|
31
|
+
const {
|
|
32
|
+
VIEWER_GESTURES,
|
|
33
|
+
clampIndex,
|
|
34
|
+
clampPan,
|
|
35
|
+
isDoubleTap,
|
|
36
|
+
isPullToClose,
|
|
37
|
+
pageFromOffset,
|
|
38
|
+
pinchTransform,
|
|
39
|
+
pullEffect,
|
|
40
|
+
settleTransform,
|
|
41
|
+
shouldClose,
|
|
42
|
+
touchCentre,
|
|
43
|
+
touchDistance,
|
|
44
|
+
zoomAt,
|
|
45
|
+
zoomRect
|
|
46
|
+
} = require('../logic/picture');
|
|
47
|
+
|
|
48
|
+
const { Easing, runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming } = Reanimated;
|
|
49
|
+
|
|
50
|
+
/* Always drawn on black: the viewer keeps its own colours whatever the app's
|
|
51
|
+
theme, as Photos does. */
|
|
52
|
+
const BLACK = '#000000';
|
|
53
|
+
const INK = '#ffffff';
|
|
54
|
+
const SOFT = 'rgba(255,255,255,0.72)';
|
|
55
|
+
const PULL_BACK = { damping: 20, stiffness: 260, mass: 1 };
|
|
56
|
+
const GAP = 12;
|
|
57
|
+
const GUTTER = 16;
|
|
58
|
+
|
|
59
|
+
function haptic(onHaptic, kind) {
|
|
60
|
+
if (typeof onHaptic === 'function') onHaptic(kind);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Taps told apart: a single tap waits out the double-tap window before counting. */
|
|
64
|
+
function useTaps(onSingle, onDouble) {
|
|
65
|
+
const last = React.useRef(0);
|
|
66
|
+
const timer = React.useRef(null);
|
|
67
|
+
const handlers = React.useRef({ onSingle, onDouble });
|
|
68
|
+
handlers.current = { onSingle, onDouble };
|
|
69
|
+
React.useEffect(() => () => clearTimeout(timer.current), []);
|
|
70
|
+
return React.useCallback((point) => {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
clearTimeout(timer.current);
|
|
73
|
+
timer.current = null;
|
|
74
|
+
if (isDoubleTap(last.current, now)) {
|
|
75
|
+
last.current = 0;
|
|
76
|
+
handlers.current.onDouble(point);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
last.current = now;
|
|
80
|
+
timer.current = setTimeout(() => {
|
|
81
|
+
timer.current = null;
|
|
82
|
+
handlers.current.onSingle();
|
|
83
|
+
}, VIEWER_GESTURES.doubleTapMs);
|
|
84
|
+
}, []);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The source of one picture, resolved by the app; a failure is a state, not a spinner forever. */
|
|
88
|
+
function usePictureSource(picture, resolveSource) {
|
|
89
|
+
const [state, setState] = React.useState({ source: null, failed: false });
|
|
90
|
+
const resolver = React.useRef(resolveSource);
|
|
91
|
+
resolver.current = resolveSource;
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
let live = true;
|
|
94
|
+
setState({ source: null, failed: false });
|
|
95
|
+
Promise.resolve()
|
|
96
|
+
.then(() => (typeof resolver.current === 'function' ? resolver.current(picture) : picture.source))
|
|
97
|
+
.then(
|
|
98
|
+
(source) => {
|
|
99
|
+
if (!live) return;
|
|
100
|
+
setState(source && typeof source.uri === 'string' && source.uri ? { source, failed: false } : { source: null, failed: true });
|
|
101
|
+
},
|
|
102
|
+
() => live && setState({ source: null, failed: true })
|
|
103
|
+
);
|
|
104
|
+
return () => {
|
|
105
|
+
live = false;
|
|
106
|
+
};
|
|
107
|
+
/* A picture is its key: a new object for the same key is the same picture. */
|
|
108
|
+
}, [picture.key]);
|
|
109
|
+
const fail = React.useCallback(() => setState({ source: null, failed: true }), []);
|
|
110
|
+
return { ...state, fail };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One page: the picture, its zoom, its taps. */
|
|
114
|
+
function Page({ picture, width, height, resolveSource, ImageComponent, imageProps, failedIcon, onSingleTap, onZoomChange, reduceMotion, onHaptic, zoomHint }) {
|
|
115
|
+
const { source, failed, fail } = usePictureSource(picture, resolveSource);
|
|
116
|
+
const frame = { width, height };
|
|
117
|
+
const android = RN.Platform.OS === 'android';
|
|
118
|
+
const zoomed = React.useRef(false);
|
|
119
|
+
/* The gesture handlers are made once per frame size: they read the props
|
|
120
|
+
of the moment through this ref, never those of their creation. */
|
|
121
|
+
const live = React.useRef({ reduceMotion, onZoomChange });
|
|
122
|
+
live.current = { reduceMotion, onZoomChange };
|
|
123
|
+
const setZoomed = (next) => {
|
|
124
|
+
if (next === zoomed.current) return;
|
|
125
|
+
zoomed.current = next;
|
|
126
|
+
live.current.onZoomChange(next);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/* iOS: the ScrollView zooms. */
|
|
130
|
+
const scroll = React.useRef(null);
|
|
131
|
+
/* Android: a transform of our own, driven from the gesture. */
|
|
132
|
+
const scale = useSharedValue(1);
|
|
133
|
+
const tx = useSharedValue(0);
|
|
134
|
+
const ty = useSharedValue(0);
|
|
135
|
+
const current = React.useRef({ scale: 1, x: 0, y: 0 });
|
|
136
|
+
|
|
137
|
+
const moveTo = (next, animate) => {
|
|
138
|
+
current.current = next;
|
|
139
|
+
const glide = animate && !live.current.reduceMotion;
|
|
140
|
+
const timing = { duration: 240, easing: Easing.out(Easing.cubic) };
|
|
141
|
+
scale.value = glide ? withTiming(next.scale, timing) : next.scale;
|
|
142
|
+
tx.value = glide ? withTiming(next.x, timing) : next.x;
|
|
143
|
+
ty.value = glide ? withTiming(next.y, timing) : next.y;
|
|
144
|
+
setZoomed(next.scale > 1.01);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const onDouble = (point) => {
|
|
148
|
+
haptic(onHaptic, 'selection');
|
|
149
|
+
if (android) {
|
|
150
|
+
moveTo(zoomed.current ? { scale: 1, x: 0, y: 0 } : zoomAt(point, frame, VIEWER_GESTURES.zoomIn), true);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const target = zoomed.current ? { x: 0, y: 0, width, height } : zoomRect(point, frame, VIEWER_GESTURES.zoomIn);
|
|
154
|
+
if (scroll.current) scroll.current.scrollResponderZoomTo({ ...target, animated: !live.current.reduceMotion });
|
|
155
|
+
};
|
|
156
|
+
const tap = useTaps(onSingleTap, onDouble);
|
|
157
|
+
|
|
158
|
+
const gesture = React.useRef({ start: null, pinch: null, moved: false });
|
|
159
|
+
const responder = React.useMemo(
|
|
160
|
+
() =>
|
|
161
|
+
android
|
|
162
|
+
? RN.PanResponder.create({
|
|
163
|
+
onStartShouldSetPanResponder: () => true,
|
|
164
|
+
onMoveShouldSetPanResponder: (event) => zoomed.current || (event.nativeEvent.touches || []).length >= 2,
|
|
165
|
+
onPanResponderGrant: () => {
|
|
166
|
+
gesture.current = { start: { ...current.current }, pinch: null, moved: false };
|
|
167
|
+
},
|
|
168
|
+
onPanResponderMove: (event, move) => {
|
|
169
|
+
const touches = event.nativeEvent.touches || [];
|
|
170
|
+
const state = gesture.current;
|
|
171
|
+
if (Math.abs(move.dx) > VIEWER_GESTURES.tapSlop || Math.abs(move.dy) > VIEWER_GESTURES.tapSlop) state.moved = true;
|
|
172
|
+
if (touches.length >= 2) {
|
|
173
|
+
state.moved = true;
|
|
174
|
+
if (!state.pinch) {
|
|
175
|
+
state.pinch = { ...current.current, distance: touchDistance(touches), focus: touchCentre(touches) };
|
|
176
|
+
}
|
|
177
|
+
moveTo(pinchTransform(state.pinch, touchDistance(touches), touchCentre(touches), frame), false);
|
|
178
|
+
} else if (current.current.scale > 1.01 && !state.pinch) {
|
|
179
|
+
const pan = clampPan({ x: state.start.x + move.dx, y: state.start.y + move.dy }, current.current.scale, frame);
|
|
180
|
+
moveTo({ scale: current.current.scale, x: pan.x, y: pan.y }, false);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
onPanResponderRelease: (event) => {
|
|
184
|
+
const state = gesture.current;
|
|
185
|
+
if (!state.moved) {
|
|
186
|
+
tap({ x: event.nativeEvent.locationX, y: event.nativeEvent.locationY });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
moveTo(settleTransform(current.current, frame), true);
|
|
190
|
+
},
|
|
191
|
+
/* Not zoomed, the list may take a sideways swipe; zoomed, the finger is ours. */
|
|
192
|
+
onPanResponderTerminationRequest: () => current.current.scale <= 1.01,
|
|
193
|
+
onPanResponderTerminate: () => moveTo(settleTransform(current.current, frame), true)
|
|
194
|
+
})
|
|
195
|
+
: null,
|
|
196
|
+
// The frame is read through `frame` at each gesture; a rotation re-creates the page.
|
|
197
|
+
[android, width, height]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const transform = useAnimatedStyle(() => ({
|
|
201
|
+
transform: [{ translateX: tx.value }, { translateY: ty.value }, { scale: scale.value }]
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
const label = picture.accessibilityLabel || picture.title;
|
|
205
|
+
const body = failed
|
|
206
|
+
? failedIcon || null
|
|
207
|
+
: source
|
|
208
|
+
? h(ImageComponent || RN.Image, {
|
|
209
|
+
...(ImageComponent && ImageComponent !== RN.Image ? { contentFit: 'contain' } : { resizeMode: 'contain' }),
|
|
210
|
+
...(imageProps || {}),
|
|
211
|
+
source,
|
|
212
|
+
style: { width, height },
|
|
213
|
+
onError: fail,
|
|
214
|
+
accessibilityIgnoresInvertColors: true
|
|
215
|
+
})
|
|
216
|
+
: h(RN.ActivityIndicator, { color: SOFT });
|
|
217
|
+
|
|
218
|
+
const a11y = { accessible: true, accessibilityRole: 'image', accessibilityLabel: label, accessibilityHint: zoomHint };
|
|
219
|
+
|
|
220
|
+
if (android) {
|
|
221
|
+
return h(
|
|
222
|
+
RN.View,
|
|
223
|
+
{ style: { width, height, overflow: 'hidden' }, testID: `page-${picture.key}` },
|
|
224
|
+
h(
|
|
225
|
+
AnimatedView,
|
|
226
|
+
{ ...responder.panHandlers, ...a11y, style: [{ width, height, alignItems: 'center', justifyContent: 'center' }, transform] },
|
|
227
|
+
body
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return h(
|
|
233
|
+
RN.ScrollView,
|
|
234
|
+
{
|
|
235
|
+
ref: scroll,
|
|
236
|
+
testID: `page-${picture.key}`,
|
|
237
|
+
style: { width, height },
|
|
238
|
+
contentContainerStyle: { width, height },
|
|
239
|
+
maximumZoomScale: VIEWER_GESTURES.maxZoom,
|
|
240
|
+
minimumZoomScale: 1,
|
|
241
|
+
bouncesZoom: !reduceMotion,
|
|
242
|
+
centerContent: true,
|
|
243
|
+
scrollEventThrottle: 32,
|
|
244
|
+
onScroll: (event) => setZoomed((event.nativeEvent.zoomScale || 1) > 1.01),
|
|
245
|
+
showsHorizontalScrollIndicator: false,
|
|
246
|
+
showsVerticalScrollIndicator: false
|
|
247
|
+
},
|
|
248
|
+
h(
|
|
249
|
+
RN.Pressable,
|
|
250
|
+
{
|
|
251
|
+
...a11y,
|
|
252
|
+
onPress: (event) => tap({ x: event.nativeEvent.locationX, y: event.nativeEvent.locationY }),
|
|
253
|
+
style: { width, height, alignItems: 'center', justifyContent: 'center' }
|
|
254
|
+
},
|
|
255
|
+
body
|
|
256
|
+
)
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A round glass control on black. */
|
|
261
|
+
function Control({ icon, label, onPress, busy, disabled, testID }) {
|
|
262
|
+
return h(
|
|
263
|
+
GlassButton,
|
|
264
|
+
{ accessibilityLabel: label, onPress, size: 44, scheme: 'dark', disabled: Boolean(busy || disabled), testID },
|
|
265
|
+
busy ? h(RN.ActivityIndicator, { color: INK }) : icon
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function ImageViewer({
|
|
270
|
+
pictures,
|
|
271
|
+
start,
|
|
272
|
+
onClose,
|
|
273
|
+
onIndexChange,
|
|
274
|
+
resolveSource,
|
|
275
|
+
ImageComponent,
|
|
276
|
+
imageProps,
|
|
277
|
+
labels = {},
|
|
278
|
+
icons = {},
|
|
279
|
+
onShare,
|
|
280
|
+
actions,
|
|
281
|
+
onActionError,
|
|
282
|
+
renderDetails,
|
|
283
|
+
insets,
|
|
284
|
+
onHaptic,
|
|
285
|
+
testID
|
|
286
|
+
}) {
|
|
287
|
+
const list = Array.isArray(pictures) ? pictures : [];
|
|
288
|
+
const { width, height } = RN.useWindowDimensions();
|
|
289
|
+
const reduceMotion = useReducedMotion();
|
|
290
|
+
const open = start !== null && start !== undefined && list.length > 0;
|
|
291
|
+
const top = (insets && insets.top) || 0;
|
|
292
|
+
const bottom = (insets && insets.bottom) || 0;
|
|
293
|
+
|
|
294
|
+
const [index, setIndex] = React.useState(0);
|
|
295
|
+
const [chrome, setChrome] = React.useState(true);
|
|
296
|
+
const [details, setDetails] = React.useState(false);
|
|
297
|
+
const [zoomed, setZoomed] = React.useState(false);
|
|
298
|
+
const [busy, setBusy] = React.useState(null);
|
|
299
|
+
const [notice, setNotice] = React.useState(null);
|
|
300
|
+
const pull = useSharedValue(0);
|
|
301
|
+
const shown = useSharedValue(1);
|
|
302
|
+
const listRef = React.useRef(null);
|
|
303
|
+
const mounted = React.useRef(true);
|
|
304
|
+
React.useEffect(
|
|
305
|
+
() => () => {
|
|
306
|
+
mounted.current = false;
|
|
307
|
+
},
|
|
308
|
+
[]
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const at = Math.min(index, Math.max(0, list.length - 1));
|
|
312
|
+
const picture = open ? list[at] : null;
|
|
313
|
+
|
|
314
|
+
/* Opened on a picture: that one, controls shown, nothing pulled. */
|
|
315
|
+
React.useEffect(() => {
|
|
316
|
+
if (start === null || start === undefined) return;
|
|
317
|
+
setIndex(clampIndex(start, list.length) ?? 0);
|
|
318
|
+
setChrome(true);
|
|
319
|
+
setDetails(false);
|
|
320
|
+
setZoomed(false);
|
|
321
|
+
setNotice(null);
|
|
322
|
+
pull.value = 0;
|
|
323
|
+
// Only a new opening resets; the list changing under an open viewer does not.
|
|
324
|
+
}, [start]);
|
|
325
|
+
|
|
326
|
+
React.useEffect(() => {
|
|
327
|
+
shown.value = reduceMotion ? (chrome ? 1 : 0) : withTiming(chrome ? 1 : 0, { duration: VIEWER_GESTURES.chromeFadeMs });
|
|
328
|
+
}, [chrome, reduceMotion, shown]);
|
|
329
|
+
|
|
330
|
+
React.useEffect(() => {
|
|
331
|
+
if (!notice) return undefined;
|
|
332
|
+
if (RN.AccessibilityInfo && typeof RN.AccessibilityInfo.announceForAccessibility === 'function') {
|
|
333
|
+
RN.AccessibilityInfo.announceForAccessibility(notice);
|
|
334
|
+
}
|
|
335
|
+
const timer = setTimeout(() => setNotice(null), VIEWER_GESTURES.noticeMs);
|
|
336
|
+
return () => clearTimeout(timer);
|
|
337
|
+
}, [notice]);
|
|
338
|
+
|
|
339
|
+
/* The last picture removed while open: nothing left to show. */
|
|
340
|
+
const wasOpen = start !== null && start !== undefined;
|
|
341
|
+
React.useEffect(() => {
|
|
342
|
+
if (wasOpen && list.length === 0 && typeof onClose === 'function') onClose();
|
|
343
|
+
}, [wasOpen, list.length, onClose]);
|
|
344
|
+
|
|
345
|
+
/* A rotation changes the page width: the list is put back on the same picture. */
|
|
346
|
+
const lastWidth = React.useRef(width);
|
|
347
|
+
React.useEffect(() => {
|
|
348
|
+
if (lastWidth.current === width) return;
|
|
349
|
+
lastWidth.current = width;
|
|
350
|
+
if (open && listRef.current && typeof listRef.current.scrollToIndex === 'function') {
|
|
351
|
+
listRef.current.scrollToIndex({ index: at, animated: false });
|
|
352
|
+
}
|
|
353
|
+
}, [width, open, at]);
|
|
354
|
+
|
|
355
|
+
const latest = React.useRef({ zoomed, onClose, reduceMotion, height, onHaptic });
|
|
356
|
+
latest.current = { zoomed, onClose, reduceMotion, height, onHaptic };
|
|
357
|
+
|
|
358
|
+
const close = React.useCallback(() => {
|
|
359
|
+
if (typeof latest.current.onClose === 'function') latest.current.onClose();
|
|
360
|
+
}, []);
|
|
361
|
+
|
|
362
|
+
const dismiss = React.useMemo(
|
|
363
|
+
() =>
|
|
364
|
+
RN.PanResponder.create({
|
|
365
|
+
/* One finger, downward, not zoomed: a pull. Two fingers are a pinch. */
|
|
366
|
+
onMoveShouldSetPanResponderCapture: (event, gesture) => {
|
|
367
|
+
const touches = (event && event.nativeEvent && event.nativeEvent.touches) || [];
|
|
368
|
+
return touches.length < 2 && isPullToClose(gesture, latest.current.zoomed);
|
|
369
|
+
},
|
|
370
|
+
onPanResponderMove: (_, gesture) => {
|
|
371
|
+
pull.value = Math.max(0, gesture.dy);
|
|
372
|
+
},
|
|
373
|
+
onPanResponderRelease: (_, gesture) => {
|
|
374
|
+
const { reduceMotion: still, height: screen } = latest.current;
|
|
375
|
+
if (shouldClose(gesture)) {
|
|
376
|
+
haptic(latest.current.onHaptic, 'impact');
|
|
377
|
+
if (still) {
|
|
378
|
+
pull.value = 0;
|
|
379
|
+
close();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
pull.value = withTiming(screen, { duration: VIEWER_GESTURES.closeMs }, (finished) => {
|
|
383
|
+
if (finished) runOnJS(close)();
|
|
384
|
+
});
|
|
385
|
+
} else {
|
|
386
|
+
pull.value = still ? 0 : withSpring(0, PULL_BACK);
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
onPanResponderTerminate: () => {
|
|
390
|
+
pull.value = latest.current.reduceMotion ? 0 : withSpring(0, PULL_BACK);
|
|
391
|
+
}
|
|
392
|
+
}),
|
|
393
|
+
[pull, close]
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const backdrop = useAnimatedStyle(() => ({ opacity: pullEffect(pull.value, height, reduceMotion).backdrop }));
|
|
397
|
+
const carried = useAnimatedStyle(() => ({
|
|
398
|
+
transform: [{ translateY: pull.value }, { scale: pullEffect(pull.value, height, reduceMotion).scale }]
|
|
399
|
+
}));
|
|
400
|
+
const chromeStyle = useAnimatedStyle(() => ({ opacity: shown.value * pullEffect(pull.value, height, reduceMotion).backdrop }));
|
|
401
|
+
|
|
402
|
+
const run = async (key, handler) => {
|
|
403
|
+
if (!picture || busy) return;
|
|
404
|
+
setBusy(key);
|
|
405
|
+
try {
|
|
406
|
+
const result = await handler(picture);
|
|
407
|
+
if (typeof result === 'string' && result) {
|
|
408
|
+
haptic(onHaptic, 'success');
|
|
409
|
+
if (mounted.current) setNotice(result);
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
haptic(onHaptic, 'failure');
|
|
413
|
+
if (typeof onActionError === 'function') onActionError(error, key, picture);
|
|
414
|
+
} finally {
|
|
415
|
+
if (mounted.current) setBusy(null);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const allActions = [
|
|
420
|
+
...(typeof onShare === 'function' && icons.share ? [{ key: 'share', label: labels.share, icon: icons.share, onPress: onShare }] : []),
|
|
421
|
+
...(Array.isArray(actions) ? actions.filter((action) => action && action.key && typeof action.onPress === 'function') : [])
|
|
422
|
+
];
|
|
423
|
+
const canDetail = typeof renderDetails === 'function' && icons.details;
|
|
424
|
+
const hiddenFromReaders = chrome ? {} : { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' };
|
|
425
|
+
|
|
426
|
+
return h(
|
|
427
|
+
RN.Modal,
|
|
428
|
+
{
|
|
429
|
+
visible: open,
|
|
430
|
+
transparent: true,
|
|
431
|
+
animationType: reduceMotion ? 'none' : 'fade',
|
|
432
|
+
onRequestClose: onClose,
|
|
433
|
+
statusBarTranslucent: true,
|
|
434
|
+
supportedOrientations: ['portrait', 'landscape']
|
|
435
|
+
},
|
|
436
|
+
h(RN.StatusBar, { hidden: open && !chrome, barStyle: 'light-content', animated: !reduceMotion }),
|
|
437
|
+
h(
|
|
438
|
+
RN.View,
|
|
439
|
+
{ testID, style: { flex: 1 }, accessibilityViewIsModal: true, onAccessibilityEscape: onClose },
|
|
440
|
+
h(AnimatedView, { pointerEvents: 'none', style: [RN.StyleSheet.absoluteFill, { backgroundColor: BLACK }, backdrop] }),
|
|
441
|
+
h(
|
|
442
|
+
AnimatedView,
|
|
443
|
+
{ ...dismiss.panHandlers, style: [{ flex: 1 }, carried] },
|
|
444
|
+
open
|
|
445
|
+
? h(RN.FlatList, {
|
|
446
|
+
ref: listRef,
|
|
447
|
+
data: list,
|
|
448
|
+
keyExtractor: (item) => String(item.key),
|
|
449
|
+
horizontal: true,
|
|
450
|
+
pagingEnabled: true,
|
|
451
|
+
scrollEnabled: !zoomed,
|
|
452
|
+
initialScrollIndex: clampIndex(start, list.length) ?? 0,
|
|
453
|
+
getItemLayout: (_, position) => ({ length: width, offset: width * position, index: position }),
|
|
454
|
+
showsHorizontalScrollIndicator: false,
|
|
455
|
+
onMomentumScrollEnd: (event) => {
|
|
456
|
+
const next = pageFromOffset(event.nativeEvent.contentOffset.x, width, list.length);
|
|
457
|
+
if (next === at) return;
|
|
458
|
+
setIndex(next);
|
|
459
|
+
setDetails(false);
|
|
460
|
+
haptic(onHaptic, 'selection');
|
|
461
|
+
if (typeof onIndexChange === 'function') onIndexChange(next);
|
|
462
|
+
},
|
|
463
|
+
renderItem: ({ item }) =>
|
|
464
|
+
h(Page, {
|
|
465
|
+
picture: item,
|
|
466
|
+
width,
|
|
467
|
+
height,
|
|
468
|
+
resolveSource,
|
|
469
|
+
ImageComponent,
|
|
470
|
+
imageProps,
|
|
471
|
+
failedIcon: icons.failed,
|
|
472
|
+
zoomHint: labels.zoomHint,
|
|
473
|
+
reduceMotion,
|
|
474
|
+
onHaptic,
|
|
475
|
+
onZoomChange: setZoomed,
|
|
476
|
+
onSingleTap: () => {
|
|
477
|
+
setChrome((visible) => !visible);
|
|
478
|
+
setDetails(false);
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
: null
|
|
483
|
+
),
|
|
484
|
+
picture
|
|
485
|
+
? [
|
|
486
|
+
/* The top: close, where you are, the details. */
|
|
487
|
+
h(
|
|
488
|
+
AnimatedView,
|
|
489
|
+
{
|
|
490
|
+
key: 'top',
|
|
491
|
+
...hiddenFromReaders,
|
|
492
|
+
pointerEvents: chrome ? 'box-none' : 'none',
|
|
493
|
+
style: [
|
|
494
|
+
{ position: 'absolute', top: top + 8, left: GUTTER, right: GUTTER, flexDirection: 'row', alignItems: 'center' },
|
|
495
|
+
chromeStyle
|
|
496
|
+
]
|
|
497
|
+
},
|
|
498
|
+
h(Control, { icon: icons.close, label: labels.close, onPress: onClose, testID: 'viewer-close' }),
|
|
499
|
+
h(
|
|
500
|
+
RN.View,
|
|
501
|
+
{ style: { flex: 1, alignItems: 'center' } },
|
|
502
|
+
list.length > 1 && typeof labels.counter === 'function'
|
|
503
|
+
? h(
|
|
504
|
+
GlassSurface,
|
|
505
|
+
{ scheme: 'dark', style: { borderRadius: 999 } },
|
|
506
|
+
h(
|
|
507
|
+
RN.Text,
|
|
508
|
+
{
|
|
509
|
+
style: {
|
|
510
|
+
color: INK,
|
|
511
|
+
fontSize: 13,
|
|
512
|
+
fontWeight: '600',
|
|
513
|
+
fontVariant: ['tabular-nums'],
|
|
514
|
+
paddingHorizontal: 12,
|
|
515
|
+
paddingVertical: 6
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
labels.counter(at + 1, list.length)
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
: null
|
|
522
|
+
),
|
|
523
|
+
canDetail
|
|
524
|
+
? h(Control, {
|
|
525
|
+
icon: details && icons.detailsActive ? icons.detailsActive : icons.details,
|
|
526
|
+
label: labels.details,
|
|
527
|
+
onPress: () => setDetails((visible) => !visible),
|
|
528
|
+
testID: 'viewer-details'
|
|
529
|
+
})
|
|
530
|
+
: h(RN.View, { style: { width: 44 } })
|
|
531
|
+
),
|
|
532
|
+
/* The bottom: the details when asked, else the title; then what can be done. */
|
|
533
|
+
h(
|
|
534
|
+
AnimatedView,
|
|
535
|
+
{
|
|
536
|
+
key: 'bottom',
|
|
537
|
+
...hiddenFromReaders,
|
|
538
|
+
pointerEvents: chrome ? 'box-none' : 'none',
|
|
539
|
+
style: [{ position: 'absolute', left: GUTTER, right: GUTTER, bottom: bottom + GAP, gap: GAP }, chromeStyle]
|
|
540
|
+
},
|
|
541
|
+
details && canDetail
|
|
542
|
+
? h(
|
|
543
|
+
GlassSurface,
|
|
544
|
+
{ scheme: 'dark', style: { borderRadius: 24 } },
|
|
545
|
+
h(RN.ScrollView, { style: { maxHeight: height * 0.3 }, contentContainerStyle: { padding: GUTTER, gap: 8 } }, renderDetails(picture))
|
|
546
|
+
)
|
|
547
|
+
: picture.title
|
|
548
|
+
? h(
|
|
549
|
+
RN.Text,
|
|
550
|
+
{
|
|
551
|
+
numberOfLines: 2,
|
|
552
|
+
style: {
|
|
553
|
+
color: INK,
|
|
554
|
+
fontSize: 17,
|
|
555
|
+
fontWeight: '600',
|
|
556
|
+
textAlign: 'center',
|
|
557
|
+
textShadowColor: 'rgba(0,0,0,0.6)',
|
|
558
|
+
textShadowRadius: 8
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
picture.title
|
|
562
|
+
)
|
|
563
|
+
: null,
|
|
564
|
+
allActions.length
|
|
565
|
+
? h(
|
|
566
|
+
RN.View,
|
|
567
|
+
{ style: { flexDirection: 'row', justifyContent: 'center', gap: GUTTER } },
|
|
568
|
+
allActions.map((action) =>
|
|
569
|
+
h(Control, {
|
|
570
|
+
key: action.key,
|
|
571
|
+
icon: action.icon,
|
|
572
|
+
label: action.label,
|
|
573
|
+
busy: busy === action.key,
|
|
574
|
+
disabled: busy !== null && busy !== action.key,
|
|
575
|
+
onPress: () => void run(action.key, action.onPress),
|
|
576
|
+
testID: `viewer-action-${action.key}`
|
|
577
|
+
})
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
: null
|
|
581
|
+
),
|
|
582
|
+
notice
|
|
583
|
+
? h(
|
|
584
|
+
RN.View,
|
|
585
|
+
{
|
|
586
|
+
key: 'notice',
|
|
587
|
+
pointerEvents: 'none',
|
|
588
|
+
accessibilityLiveRegion: 'polite',
|
|
589
|
+
style: { position: 'absolute', top: height / 2 - 40, left: 0, right: 0, alignItems: 'center' }
|
|
590
|
+
},
|
|
591
|
+
h(
|
|
592
|
+
GlassSurface,
|
|
593
|
+
{ scheme: 'dark', style: { borderRadius: 24 } },
|
|
594
|
+
h(
|
|
595
|
+
RN.View,
|
|
596
|
+
{ style: { alignItems: 'center', gap: 8, paddingHorizontal: 24, paddingVertical: GUTTER } },
|
|
597
|
+
icons.notice || null,
|
|
598
|
+
h(RN.Text, { style: { color: INK, fontSize: 15, fontWeight: '600' } }, notice)
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
: null
|
|
603
|
+
]
|
|
604
|
+
: null
|
|
605
|
+
)
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
module.exports = { ImageViewer };
|