@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/src/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * through untouched.
8
8
  */
9
9
  import type { ComponentType, ReactElement, ReactNode } from 'react';
10
- import type { ColorScheme, GlassMode } from './logic';
10
+ import type { ColorScheme, GlassMode, PictureSize } from './logic';
11
11
 
12
12
  export * from './logic';
13
13
 
@@ -227,3 +227,119 @@ export interface MarkdownViewProps {
227
227
  export function MarkdownView(props: MarkdownViewProps): ReactElement | null;
228
228
  export function MarkdownTable(props: { header: string[]; rows: string[][] }): ReactElement;
229
229
  export const MARKDOWN_STYLES: Readonly<Record<string, NativeStyle>>;
230
+
231
+ /* ────────────────────────────── Pictures ────────────────────────────── */
232
+
233
+ /** What an image component takes (React Native's Image or expo-image). */
234
+ export interface ImageSourceLike {
235
+ uri: string;
236
+ headers?: Record<string, string>;
237
+ [prop: string]: unknown;
238
+ }
239
+
240
+ export interface ImageShimmerProps {
241
+ /** Width / height of the place held (1: a square). */
242
+ ratio?: number;
243
+ borderRadius?: number;
244
+ colors?: { base?: string; light?: string; caption?: string };
245
+ /** Read by the screen reader: the app's words ("Drawing the picture"). */
246
+ accessibilityLabel?: string;
247
+ caption?: string;
248
+ captionStyle?: NativeStyle;
249
+ passMs?: number;
250
+ scheme?: ColorScheme;
251
+ style?: NativeStyle;
252
+ testID?: string;
253
+ }
254
+ export function ImageShimmer(props: ImageShimmerProps): ReactElement;
255
+
256
+ export interface AutoRatioImageProps {
257
+ /** A ready source. */
258
+ source?: ImageSourceLike | null;
259
+ /** Or a source to fetch; resolving null or rejecting shows the error slot. */
260
+ load?: () => ImageSourceLike | null | undefined | Promise<ImageSourceLike | null | undefined>;
261
+ /** Names the picture (required with `load`): a new key starts over. Defaults to `source.uri`. */
262
+ sourceKey?: string | number;
263
+ /** React Native's Image by default; expo-image's Image fits. */
264
+ ImageComponent?: ComponentType<any>;
265
+ imageProps?: Record<string, unknown>;
266
+ fit?: 'contain' | 'cover';
267
+ /** The shape held until the picture is known (1). */
268
+ initialRatio?: number;
269
+ minRatio?: number;
270
+ maxRatio?: number;
271
+ borderRadius?: number;
272
+ surfaceColor?: string;
273
+ /** Replaces the default shimmer while loading; null for none. */
274
+ placeholder?: ReactNode;
275
+ loadingLabel?: string;
276
+ renderError?: (input: { retry: () => void }) => ReactNode;
277
+ accessibilityLabel?: string;
278
+ accessibilityHint?: string;
279
+ onPress?: () => void;
280
+ onLongPress?: () => void;
281
+ onLoad?: (size: PictureSize | null) => void;
282
+ onError?: (error: unknown) => void;
283
+ onRatio?: (ratio: number) => void;
284
+ revealMs?: number;
285
+ scheme?: ColorScheme;
286
+ style?: NativeStyle;
287
+ testID?: string;
288
+ }
289
+ export function AutoRatioImage(props: AutoRatioImageProps): ReactElement;
290
+
291
+ export interface ViewerPicture {
292
+ /** Stable across renders. */
293
+ key: string;
294
+ title?: string;
295
+ /** Read by the screen reader; defaults to the title. */
296
+ accessibilityLabel?: string;
297
+ /** Used when no `resolveSource` is given. */
298
+ source?: ImageSourceLike;
299
+ [prop: string]: unknown;
300
+ }
301
+
302
+ export interface ViewerAction<P extends ViewerPicture = ViewerPicture> {
303
+ key: string;
304
+ label: string;
305
+ icon: ReactNode;
306
+ /** May resolve a short notice to show ("Saved"); a rejection goes to onActionError. */
307
+ onPress: (picture: P) => void | string | Promise<void | string | null | undefined>;
308
+ }
309
+
310
+ export interface ImageViewerProps<P extends ViewerPicture = ViewerPicture> {
311
+ pictures: readonly P[];
312
+ /** The picture to open on; null: closed. */
313
+ start: number | null;
314
+ onClose: () => void;
315
+ onIndexChange?: (index: number) => void;
316
+ resolveSource?: (picture: P) => ImageSourceLike | null | undefined | Promise<ImageSourceLike | null | undefined>;
317
+ ImageComponent?: ComponentType<any>;
318
+ imageProps?: Record<string, unknown>;
319
+ labels: {
320
+ close: string;
321
+ share?: string;
322
+ details?: string;
323
+ /** "3 of 12": position from 1. */
324
+ counter?: (position: number, count: number) => string;
325
+ zoomHint?: string;
326
+ };
327
+ icons: {
328
+ close: ReactNode;
329
+ share?: ReactNode;
330
+ details?: ReactNode;
331
+ detailsActive?: ReactNode;
332
+ failed?: ReactNode;
333
+ notice?: ReactNode;
334
+ };
335
+ /** The share hook; shown with icons.share. */
336
+ onShare?: (picture: P) => void | string | Promise<void | string | null | undefined>;
337
+ actions?: readonly ViewerAction<P>[];
338
+ onActionError?: (error: unknown, key: string, picture: P) => void;
339
+ renderDetails?: (picture: P) => ReactNode;
340
+ /** The safe area (react-native-safe-area-context's insets). */
341
+ insets?: { top?: number; bottom?: number };
342
+ onHaptic?: (kind: 'selection' | 'impact' | 'success' | 'failure') => void;
343
+ testID?: string;
344
+ }
345
+ export function ImageViewer<P extends ViewerPicture>(props: ImageViewerProps<P>): ReactElement;
package/src/index.js CHANGED
@@ -16,5 +16,8 @@ module.exports = {
16
16
  ...require('./components/TabBar'),
17
17
  ...require('./components/CollapsibleHeader'),
18
18
  ...require('./components/MarkdownView'),
19
+ ...require('./components/ImageShimmer'),
20
+ ...require('./components/AutoRatioImage'),
21
+ ...require('./components/ImageViewer'),
19
22
  getGlassMode: require('./components/runtime').getGlassMode
20
23
  };
@@ -201,3 +201,107 @@ export function measureColumns(
201
201
  export const ANCHOR_MARGIN: number;
202
202
  export function anchorOffset(anchorY: number): number;
203
203
  export function reserveBelowQuestion(input: { viewportHeight: number; contentHeight: number; anchorY: number }): number;
204
+
205
+ /* ─────────────────────────────── Pictures ───────────────────────────── */
206
+
207
+ export interface PictureSize {
208
+ width: number;
209
+ height: number;
210
+ }
211
+
212
+ /** Past these ratios a picture is shown whole inside a bounded box (0.2 – 5). */
213
+ export const PICTURE_RATIO_LIMITS: Readonly<{ min: number; max: number }>;
214
+ /** 700 ms, from 0.96. */
215
+ export const PICTURE_REVEAL: Readonly<{ durationMs: number; fromScale: number }>;
216
+ /** One pass of the placeholder's light (1600 ms). */
217
+ export const SHIMMER_PASS_MS: number;
218
+
219
+ export interface ViewerGestures {
220
+ doubleTapMs: number;
221
+ zoomIn: number;
222
+ maxZoom: number;
223
+ closeDistance: number;
224
+ closeSpeed: number;
225
+ pullSlop: number;
226
+ pullSlant: number;
227
+ tapSlop: number;
228
+ chromeFadeMs: number;
229
+ noticeMs: number;
230
+ closeMs: number;
231
+ }
232
+ export const VIEWER_GESTURES: Readonly<ViewerGestures>;
233
+
234
+ export function naturalRatio(
235
+ size: Partial<PictureSize> | null | undefined,
236
+ options?: { fallback?: number; min?: number; max?: number }
237
+ ): number;
238
+ /** From expo-image's load event or React Native's. */
239
+ export function loadedSize(event: unknown): PictureSize | null;
240
+ export function revealStyle(
241
+ progress: number,
242
+ reveal?: { durationMs: number; fromScale: number }
243
+ ): { opacity: number; transform: [{ scale: number }] };
244
+ /** '-100%' → '100%'. */
245
+ export function shimmerTranslate(progress: number): string;
246
+
247
+ export interface Point {
248
+ x: number;
249
+ y: number;
250
+ }
251
+ export interface ZoomTransform {
252
+ scale: number;
253
+ x: number;
254
+ y: number;
255
+ }
256
+ export interface TouchLike {
257
+ pageX: number;
258
+ pageY: number;
259
+ }
260
+
261
+ export function isDoubleTap(previousAt: number | null | undefined, now: number, windowMs?: number): boolean;
262
+ export function zoomRect(point: Point, frame: PictureSize, zoom?: number): Point & PictureSize;
263
+ export function panLimits(scale: number, frame: PictureSize): Point;
264
+ export function clampPan(translation: Point, scale: number, frame: PictureSize): Point;
265
+ export function zoomAt(point: Point, frame: PictureSize, scale: number): ZoomTransform;
266
+ export function touchDistance(touches: readonly TouchLike[] | null | undefined): number;
267
+ export function touchCentre(touches: readonly TouchLike[] | null | undefined): Point;
268
+ export function pinchTransform(
269
+ start: ZoomTransform & { distance: number; focus: Point },
270
+ distance: number,
271
+ focus: Point,
272
+ frame: PictureSize,
273
+ limits?: { min?: number; max?: number; overshoot?: number }
274
+ ): ZoomTransform;
275
+ export function settleTransform(transform: ZoomTransform, frame: PictureSize, limits?: { min?: number; max?: number }): ZoomTransform;
276
+ export function isPullToClose(gesture: { dx: number; dy: number }, zoomed: boolean, rules?: ViewerGestures): boolean;
277
+ export function shouldClose(gesture: { dy: number; vy: number }, rules?: ViewerGestures): boolean;
278
+ export function pullEffect(pull: number, height: number, reduceMotion?: boolean): { backdrop: number; scale: number };
279
+ export function clampIndex(index: number | null | undefined, count: number): number | null;
280
+ export function pageFromOffset(offset: number, width: number, count: number): number;
281
+
282
+ export interface ArrivingSlot {
283
+ id: string;
284
+ /** 'working': on its way; 'ready': its job ended, the picture itself not here yet. */
285
+ state: 'working' | 'ready';
286
+ }
287
+ export interface PicturesArrivingState {
288
+ slots: readonly ArrivingSlot[];
289
+ }
290
+ export type PicturesArrivingEvent =
291
+ | { type: 'started'; id: string }
292
+ | { type: 'finished'; id: string; ok?: boolean }
293
+ | { type: 'arrived'; id?: string }
294
+ | { type: 'ended' }
295
+ | { type: 'reset' };
296
+
297
+ export const PICTURES_ARRIVING_EMPTY: PicturesArrivingState;
298
+ /** The same state object when an event changes nothing. */
299
+ export function reducePicturesArriving(
300
+ state: PicturesArrivingState | null | undefined,
301
+ event: PicturesArrivingEvent | null | undefined
302
+ ): PicturesArrivingState;
303
+ export function picturesArrivingCount(state: PicturesArrivingState | null | undefined): number;
304
+ export function countRunningSteps(
305
+ steps: ReadonlyArray<{ tool: string; state: string } | null> | null | undefined,
306
+ tool: string | readonly string[]
307
+ ): number;
@@ -12,5 +12,6 @@ module.exports = {
12
12
  ...require('./collapsibleHeader'),
13
13
  ...require('./markdown'),
14
14
  ...require('./tableColumns'),
15
- ...require('./anchorQuestion')
15
+ ...require('./anchorQuestion'),
16
+ ...require('./picture')
16
17
  };
@@ -0,0 +1,369 @@
1
+ /**
2
+ * The rules behind the picture components: a picture's own shape, how it
3
+ * comes up, the light that passes while it is being made, the gestures of the
4
+ * full-screen viewer, and the count of pictures still on their way.
5
+ *
6
+ * Pure — no react-native — so every threshold is tested in plain Node, and a
7
+ * component only renders what these functions decide.
8
+ */
9
+
10
+ /* The functions an animated style calls (revealStyle, shimmerTranslate,
11
+ pullEffect) carry the 'worklet' directive: they run on the UI thread, where
12
+ only worklets can. In Node the directive is an inert string. */
13
+
14
+ /* ─────────────────────────────── Shape ─────────────────────────────── */
15
+
16
+ /**
17
+ * The limits of a picture's shape on the page. A 1×10 000 strip must not make
18
+ * a view ten thousand points tall, nor a panorama a line one point high: past
19
+ * these ratios the picture is shown whole ('contain') inside the bounded box.
20
+ */
21
+ const PICTURE_RATIO_LIMITS = Object.freeze({ min: 0.2, max: 5 });
22
+
23
+ /**
24
+ * The width/height ratio to lay a picture out at: its own once known, the
25
+ * fallback until then (and for a size that is not one).
26
+ *
27
+ * @param {{width?: number, height?: number} | null | undefined} size
28
+ * @param {object} [options]
29
+ * @param {number} [options.fallback=1] A square until the picture is known.
30
+ * @param {number} [options.min]
31
+ * @param {number} [options.max]
32
+ */
33
+ function naturalRatio(size, options = {}) {
34
+ const min = Number.isFinite(options.min) && options.min > 0 ? options.min : PICTURE_RATIO_LIMITS.min;
35
+ const max = Number.isFinite(options.max) && options.max >= min ? options.max : PICTURE_RATIO_LIMITS.max;
36
+ const fallback = Number.isFinite(options.fallback) && options.fallback > 0 ? options.fallback : 1;
37
+ const width = size ? Number(size.width) : NaN;
38
+ const height = size ? Number(size.height) : NaN;
39
+ const ratio = width > 0 && height > 0 && Number.isFinite(width / height) ? width / height : fallback;
40
+ return Math.min(max, Math.max(min, ratio));
41
+ }
42
+
43
+ /**
44
+ * The picture's size from a load event, whichever image component sent it:
45
+ * expo-image (`{ source: { width, height } }`) or React Native's Image
46
+ * (`{ nativeEvent: { source: { width, height } } }`). Null when absent.
47
+ */
48
+ function loadedSize(event) {
49
+ const candidates = [
50
+ event && event.source,
51
+ event && event.nativeEvent && event.nativeEvent.source,
52
+ event && event.nativeEvent
53
+ ];
54
+ for (const candidate of candidates) {
55
+ if (candidate && Number(candidate.width) > 0 && Number(candidate.height) > 0) {
56
+ return { width: Number(candidate.width), height: Number(candidate.height) };
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /* ─────────────────────────────── Reveal ────────────────────────────── */
63
+
64
+ /**
65
+ * How a picture comes up once it is there: from a hair smaller and
66
+ * transparent to its place, on an ease-out — not all at once. With "Reduce
67
+ * Motion", at once.
68
+ */
69
+ const PICTURE_REVEAL = Object.freeze({ durationMs: 700, fromScale: 0.96 });
70
+
71
+ /** The reveal at `progress` (0 → 1), clamped. */
72
+ function revealStyle(progress, reveal = PICTURE_REVEAL) {
73
+ 'worklet';
74
+ const p = Math.min(1, Math.max(0, Number(progress) || 0));
75
+ return { opacity: p, transform: [{ scale: reveal.fromScale + (1 - reveal.fromScale) * p }] };
76
+ }
77
+
78
+ /* ─────────────────────────────── Shimmer ───────────────────────────── */
79
+
80
+ /** One pass of the light across the placeholder, left to right. */
81
+ const SHIMMER_PASS_MS = 1600;
82
+
83
+ /** Where the light is at `progress` (0 → 1): from a full width off the left edge to a full width off the right. */
84
+ function shimmerTranslate(progress) {
85
+ 'worklet';
86
+ const p = Math.min(1, Math.max(0, Number(progress) || 0));
87
+ return `${-100 + p * 200}%`;
88
+ }
89
+
90
+ /* ─────────────────────────────── Viewer ────────────────────────────── */
91
+
92
+ /**
93
+ * The viewer's gesture thresholds, as Photos behaves:
94
+ * - two taps closer than `doubleTapMs` zoom (a single tap waits that long
95
+ * before toggling the controls, so the two are told apart);
96
+ * - a double tap zooms to `zoomIn`, a pinch goes up to `maxZoom`;
97
+ * - a pull down closes past `closeDistance` points, or faster than
98
+ * `closeSpeed` points per millisecond;
99
+ * - a move is a pull only once it is `pullSlop` points down and clearly more
100
+ * vertical than horizontal (`pullSlant`) — a sideways swipe stays a page turn.
101
+ */
102
+ const VIEWER_GESTURES = Object.freeze({
103
+ doubleTapMs: 260,
104
+ zoomIn: 2.5,
105
+ maxZoom: 4,
106
+ closeDistance: 120,
107
+ closeSpeed: 0.9,
108
+ pullSlop: 10,
109
+ pullSlant: 1.5,
110
+ /* Under this movement, a release is a tap. */
111
+ tapSlop: 8,
112
+ /* How long the controls take to fade, and the "done" notice stays. */
113
+ chromeFadeMs: 180,
114
+ noticeMs: 1800,
115
+ closeMs: 180
116
+ });
117
+
118
+ /** Whether a tap at `now` is the second of a double tap. */
119
+ function isDoubleTap(previousAt, now, windowMs = VIEWER_GESTURES.doubleTapMs) {
120
+ return Number.isFinite(previousAt) && previousAt > 0 && now - previousAt >= 0 && now - previousAt < windowMs;
121
+ }
122
+
123
+ /**
124
+ * The rectangle to zoom into so that `point` ends up in the middle, kept
125
+ * inside the frame: a double tap near an edge zooms the edge, it does not
126
+ * scroll past it into black.
127
+ *
128
+ * @param {{x: number, y: number}} point In the frame's coordinates.
129
+ * @param {{width: number, height: number}} frame
130
+ * @param {number} zoom > 1.
131
+ */
132
+ function zoomRect(point, frame, zoom = VIEWER_GESTURES.zoomIn) {
133
+ const scale = Math.max(1, Number(zoom) || 1);
134
+ const width = frame.width / scale;
135
+ const height = frame.height / scale;
136
+ const x = Math.min(frame.width - width, Math.max(0, point.x - width / 2));
137
+ const y = Math.min(frame.height - height, Math.max(0, point.y - height / 2));
138
+ return { x, y, width, height };
139
+ }
140
+
141
+ /**
142
+ * The furthest a picture zoomed to `scale` may move: its enlarged edges stay
143
+ * on the frame's edges, never inside them.
144
+ */
145
+ function panLimits(scale, frame) {
146
+ const s = Math.max(1, scale);
147
+ return { x: ((s - 1) * frame.width) / 2, y: ((s - 1) * frame.height) / 2 };
148
+ }
149
+
150
+ /** A translation kept within the limits of `scale`. */
151
+ function clampPan(translation, scale, frame) {
152
+ const limit = panLimits(scale, frame);
153
+ const clamp = (value, bound) => Math.min(bound, Math.max(-bound, Number(value) || 0));
154
+ return { x: clamp(translation.x, limit.x), y: clamp(translation.y, limit.y) };
155
+ }
156
+
157
+ /**
158
+ * The transform that zooms to `scale` around `point` — the point under the
159
+ * finger stays under the finger — clamped to the frame. The transform is
160
+ * `translate` THEN `scale`, around the frame's centre (React Native's order).
161
+ *
162
+ * @param {{x: number, y: number}} point In the frame's coordinates.
163
+ * @param {{width: number, height: number}} frame
164
+ * @param {number} scale
165
+ * @returns {{scale: number, x: number, y: number}}
166
+ */
167
+ function zoomAt(point, frame, scale) {
168
+ const s = Math.max(1, scale);
169
+ const centre = { x: frame.width / 2, y: frame.height / 2 };
170
+ const pan = clampPan({ x: -(point.x - centre.x) * (s - 1), y: -(point.y - centre.y) * (s - 1) }, s, frame);
171
+ return { scale: s, x: pan.x, y: pan.y };
172
+ }
173
+
174
+ /** The distance between the first two touches, or 0 with fewer. */
175
+ function touchDistance(touches) {
176
+ if (!Array.isArray(touches) || touches.length < 2) return 0;
177
+ const [a, b] = touches;
178
+ return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
179
+ }
180
+
181
+ /** The point between the first two touches (the pinch's focus), or the only one. */
182
+ function touchCentre(touches) {
183
+ if (!Array.isArray(touches) || touches.length === 0) return { x: 0, y: 0 };
184
+ if (touches.length === 1) return { x: touches[0].pageX, y: touches[0].pageY };
185
+ return { x: (touches[0].pageX + touches[1].pageX) / 2, y: (touches[0].pageY + touches[1].pageY) / 2 };
186
+ }
187
+
188
+ /**
189
+ * The transform during a pinch: the scale grows with the fingers' spread, and
190
+ * the point that was under the fingers at the start follows them.
191
+ *
192
+ * @param {object} start `{ scale, x, y, distance, focus: {x, y} }` when the second finger came down.
193
+ * @param {number} distance The fingers' spread now.
194
+ * @param {{x: number, y: number}} focus Between the fingers now.
195
+ * @param {{width: number, height: number}} frame Its centre is the transform's origin.
196
+ * @param {object} [limits] `{ min = 1, max = maxZoom, overshoot = 1.15 }`: a
197
+ * pinch may go a little past the bounds (it springs back on release).
198
+ */
199
+ function pinchTransform(start, distance, focus, frame, limits = {}) {
200
+ const min = limits.min ?? 1;
201
+ const max = limits.max ?? VIEWER_GESTURES.maxZoom;
202
+ const overshoot = limits.overshoot ?? 1.15;
203
+ const raw = start.distance > 0 ? (start.scale * distance) / start.distance : start.scale;
204
+ const scale = Math.min(max * overshoot, Math.max(min / overshoot, raw));
205
+ const centre = { x: frame.width / 2, y: frame.height / 2 };
206
+ /* The picture point under the fingers at the start, in unscaled coordinates. */
207
+ const anchor = {
208
+ x: (start.focus.x - centre.x - start.x) / start.scale,
209
+ y: (start.focus.y - centre.y - start.y) / start.scale
210
+ };
211
+ return { scale, x: focus.x - centre.x - scale * anchor.x, y: focus.y - centre.y - scale * anchor.y };
212
+ }
213
+
214
+ /** Where a transform settles once the fingers lift: within the bounds, the edges on the frame. */
215
+ function settleTransform(transform, frame, limits = {}) {
216
+ const min = limits.min ?? 1;
217
+ const max = limits.max ?? VIEWER_GESTURES.maxZoom;
218
+ const scale = Math.min(max, Math.max(min, transform.scale));
219
+ if (scale <= 1.001) return { scale: 1, x: 0, y: 0 };
220
+ const pan = clampPan(transform, scale, frame);
221
+ return { scale, x: pan.x, y: pan.y };
222
+ }
223
+
224
+ /**
225
+ * Whether a move is the start of a pull to close: not while zoomed (the
226
+ * finger then moves around the picture), downward, and clearly vertical.
227
+ * @param {{dx: number, dy: number}} gesture
228
+ */
229
+ function isPullToClose(gesture, zoomed, rules = VIEWER_GESTURES) {
230
+ if (zoomed) return false;
231
+ return gesture.dy > rules.pullSlop && Math.abs(gesture.dy) > Math.abs(gesture.dx) * rules.pullSlant;
232
+ }
233
+
234
+ /** Whether a released pull closes the viewer: far enough, or fast enough. */
235
+ function shouldClose(gesture, rules = VIEWER_GESTURES) {
236
+ return gesture.dy > rules.closeDistance || gesture.vy > rules.closeSpeed;
237
+ }
238
+
239
+ /**
240
+ * What a pull does to the viewer: the black fades by half the screen, the
241
+ * picture shrinks to 80 % over the whole screen. "Reduce Motion": it only
242
+ * fades — nothing shrinks.
243
+ */
244
+ function pullEffect(pull, height, reduceMotion = false) {
245
+ 'worklet';
246
+ const h = Math.max(1, Number(height) || 1);
247
+ const p = Math.max(0, Number(pull) || 0);
248
+ return {
249
+ backdrop: Math.max(0, 1 - p / (h * 0.5)),
250
+ scale: reduceMotion ? 1 : Math.max(0.8, 1 - (0.2 * p) / h)
251
+ };
252
+ }
253
+
254
+ /** The index to open on, inside the list (null stays null: closed). */
255
+ function clampIndex(index, count) {
256
+ if (index === null || index === undefined || !(count > 0)) return null;
257
+ return Math.min(count - 1, Math.max(0, Math.round(Number(index) || 0)));
258
+ }
259
+
260
+ /** The page a horizontal list rests on, from its offset. */
261
+ function pageFromOffset(offset, width, count) {
262
+ if (!(width > 0) || !(count > 0)) return 0;
263
+ return Math.min(count - 1, Math.max(0, Math.round(offset / width)));
264
+ }
265
+
266
+ /* ────────────────────────── Pictures arriving ──────────────────────── */
267
+
268
+ /**
269
+ * The pictures still on their way (being drawn, generated, uploaded), each a
270
+ * place held on screen until the picture itself takes it.
271
+ *
272
+ * The first version counted the running "draw" steps of an agent. The count
273
+ * fell to zero as soon as the step finished — while the picture itself came
274
+ * in a later event: the placeholder vanished, the text below jumped up, and
275
+ * the picture then pushed it down again. Here a finished job keeps its place
276
+ * until the picture ARRIVES; only a failure, or the end of the stream, gives
277
+ * it back.
278
+ *
279
+ * Events:
280
+ * - `{ type: 'started', id }` a picture is on its way (a repeat is ignored);
281
+ * - `{ type: 'finished', id, ok }` its job ended: ok keeps the place until the
282
+ * picture arrives, a failure frees it;
283
+ * - `{ type: 'arrived', id? }` the picture is here: its place goes. Without
284
+ * an id, the oldest finished place goes (else
285
+ * the oldest place);
286
+ * - `{ type: 'ended' }` the stream is over: nothing else will come;
287
+ * - `{ type: 'reset' }` a new turn.
288
+ *
289
+ * The state is `{ slots: [{ id, state: 'working'|'ready' }] }`; the same object
290
+ * comes back when an event changes nothing (React skips the render).
291
+ */
292
+ const PICTURES_ARRIVING_EMPTY = Object.freeze({ slots: Object.freeze([]) });
293
+
294
+ function reducePicturesArriving(state, event) {
295
+ const current = state && Array.isArray(state.slots) ? state : PICTURES_ARRIVING_EMPTY;
296
+ if (!event || typeof event.type !== 'string') return current;
297
+ const slots = current.slots;
298
+ switch (event.type) {
299
+ case 'started':
300
+ if (typeof event.id !== 'string' || !event.id || slots.some((slot) => slot.id === event.id)) return current;
301
+ return { slots: [...slots, { id: event.id, state: 'working' }] };
302
+ case 'finished': {
303
+ const at = slots.findIndex((slot) => slot.id === event.id);
304
+ if (at === -1) return current;
305
+ if (event.ok === false) return { slots: slots.filter((_, index) => index !== at) };
306
+ if (slots[at].state === 'ready') return current;
307
+ return { slots: slots.map((slot, index) => (index === at ? { ...slot, state: 'ready' } : slot)) };
308
+ }
309
+ case 'arrived': {
310
+ let at = typeof event.id === 'string' ? slots.findIndex((slot) => slot.id === event.id) : -1;
311
+ if (at === -1 && typeof event.id !== 'string') {
312
+ at = slots.findIndex((slot) => slot.state === 'ready');
313
+ if (at === -1) at = 0;
314
+ }
315
+ if (at === -1 || slots.length === 0) return current;
316
+ return { slots: slots.filter((_, index) => index !== at) };
317
+ }
318
+ case 'ended':
319
+ case 'reset':
320
+ return slots.length === 0 ? current : PICTURES_ARRIVING_EMPTY;
321
+ default:
322
+ return current;
323
+ }
324
+ }
325
+
326
+ /** How many places to hold. */
327
+ function picturesArrivingCount(state) {
328
+ return state && Array.isArray(state.slots) ? state.slots.length : 0;
329
+ }
330
+
331
+ /**
332
+ * The same count read straight from a list of steps, for an app that keeps
333
+ * steps rather than events: the steps of `tool` still running.
334
+ * @param {ReadonlyArray<{tool: string, state: string}>} steps
335
+ * @param {string|readonly string[]} tool
336
+ */
337
+ function countRunningSteps(steps, tool) {
338
+ const tools = Array.isArray(tool) ? tool : [tool];
339
+ return (Array.isArray(steps) ? steps : []).filter((step) => step && tools.includes(step.tool) && step.state === 'running').length;
340
+ }
341
+
342
+ module.exports = {
343
+ PICTURE_RATIO_LIMITS,
344
+ PICTURE_REVEAL,
345
+ SHIMMER_PASS_MS,
346
+ VIEWER_GESTURES,
347
+ PICTURES_ARRIVING_EMPTY,
348
+ naturalRatio,
349
+ loadedSize,
350
+ revealStyle,
351
+ shimmerTranslate,
352
+ isDoubleTap,
353
+ zoomRect,
354
+ panLimits,
355
+ clampPan,
356
+ zoomAt,
357
+ touchDistance,
358
+ touchCentre,
359
+ pinchTransform,
360
+ settleTransform,
361
+ isPullToClose,
362
+ shouldClose,
363
+ pullEffect,
364
+ clampIndex,
365
+ pageFromOffset,
366
+ reducePicturesArriving,
367
+ picturesArrivingCount,
368
+ countRunningSteps
369
+ };