@lotics/ui 5.8.0 → 5.9.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.
@@ -15,12 +15,20 @@ import { type FilePreviewProps, resolveLabels, type PreviewLabels } from "./file
15
15
  import { SpreadsheetView } from "./spreadsheet_view";
16
16
  import { RotatableImage } from "./rotatable_image";
17
17
  import { downloadFileFromUrl } from "./download";
18
+ import { colors } from "./colors";
18
19
 
19
20
  /**
20
- * Universal inline file preview (web). Renders by MIME type: images, PDF, video
21
- * and audio use browser-native elements; Word via the lazy-loaded `@lotics/docx`
22
- * renderer; Excel/CSV via the read-only `@lotics/xlsx` canvas. Heavy engines are
23
- * dynamic-imported so non-document files never pay their bundle cost.
21
+ * Universal inline file preview (web). Renders by MIME type: images, video and
22
+ * audio use browser-native elements; PDF via the lazy-loaded `pdfjs-dist` canvas
23
+ * renderer; Word via `@lotics/docx`; Excel/CSV via the read-only `@lotics/xlsx`
24
+ * canvas. Heavy engines are dynamic-imported so non-document files never pay
25
+ * their bundle cost.
26
+ *
27
+ * PDF renders bytes to a canvas rather than embedding a native `<iframe>` viewer:
28
+ * a nested PDF browsing context is blocked inside the sandboxed, cross-origin
29
+ * custom-code app iframe (and degrades in mobile webviews), whereas a fetched-and-
30
+ * painted canvas works everywhere — the same in-DOM approach the Word/Excel
31
+ * engines already use.
24
32
  *
25
33
  * Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
26
34
  * (purity test). Lifted from the frontend gallery so both share one renderer.
@@ -39,7 +47,7 @@ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProp
39
47
  );
40
48
  }
41
49
  if (isPdfMimeType(file.mimeType)) {
42
- return <iframe src={file.url} title={file.filename} style={fullFrameStyle} />;
50
+ return <PdfPreview file={file} labels={l} onError={onError} />;
43
51
  }
44
52
  if (isDocxMimeType(file.mimeType)) {
45
53
  return <WordPreview file={file} labels={l} onError={onError} />;
@@ -159,12 +167,327 @@ function WordPreview({
159
167
  );
160
168
  }
161
169
 
170
+ /**
171
+ * pdf.js runs on the MAIN THREAD (no Web Worker): importing the worker entry for
172
+ * its side effect registers `globalThis.pdfjsWorker`, which pdf.js's `PDFWorker`
173
+ * reads to skip real-worker setup entirely — no worker-URL resolution, no
174
+ * `new Worker(...)`, so it bundles identically under Vite (apps) and Metro
175
+ * (frontend). One document at a time, on demand — the same tradeoff `@lotics/xlsx`
176
+ * and `@lotics/docx` already make. Cached so the engine loads once per session.
177
+ */
178
+ let pdfjsModule: Promise<typeof import("pdfjs-dist")> | null = null;
179
+ function loadPdfjs(): Promise<typeof import("pdfjs-dist")> {
180
+ if (!pdfjsModule) {
181
+ pdfjsModule = (async () => {
182
+ const [pdfjs] = await Promise.all([
183
+ import("pdfjs-dist"),
184
+ import("pdfjs-dist/build/pdf.worker.min.mjs"),
185
+ ]);
186
+ return pdfjs;
187
+ })().catch((err) => {
188
+ // Don't cache a rejected import — a transient failure would otherwise
189
+ // poison every later PDF preview. Reset so the next open retries.
190
+ pdfjsModule = null;
191
+ throw err;
192
+ });
193
+ }
194
+ return pdfjsModule;
195
+ }
196
+
197
+ // pdf.js's TextLayer overlays transparent, selectable text spans aligned to the
198
+ // canvas. The spans position themselves off `--total-scale-factor` (set per page
199
+ // below); these are the minimal rules from pdf.js's shipped viewer CSS that make
200
+ // the text invisible, selectable, and correctly placed. Injected once, globally
201
+ // (the `.textLayer` class is pdf.js-specific) so no consumer has to import a CSS
202
+ // file — a half-applied stylesheet would render the spans as visible black text.
203
+ const PDF_TEXT_LAYER_CSS = `
204
+ .textLayer{position:absolute;text-align:initial;inset:0;overflow:clip;opacity:1;line-height:1;-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none;forced-color-adjust:none;transform-origin:0 0;z-index:0;--min-font-size:1;--text-scale-factor:calc(var(--total-scale-factor) * var(--min-font-size));--min-font-size-inv:calc(1 / var(--min-font-size))}
205
+ .textLayer :is(span,br){color:transparent;position:absolute;white-space:pre;cursor:text;transform-origin:0 0;-webkit-user-select:text;-moz-user-select:text;user-select:text}
206
+ .textLayer > :not(.markedContent),.textLayer .markedContent span:not(.markedContent){z-index:1;--font-height:0;font-size:calc(var(--text-scale-factor) * var(--font-height));--scale-x:1;--rotate:0deg;transform:rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv))}
207
+ .textLayer .markedContent{display:contents}
208
+ .textLayer span[role="img"]{-webkit-user-select:none;-moz-user-select:none;user-select:none;cursor:default}
209
+ .textLayer .endOfContent{display:block;position:absolute;inset:100% 0 0;z-index:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;user-select:none}
210
+ `;
211
+
212
+ let pdfTextLayerStylesInjected = false;
213
+ function ensurePdfTextLayerStyles(): void {
214
+ if (pdfTextLayerStylesInjected || typeof document === "undefined") return;
215
+ pdfTextLayerStylesInjected = true;
216
+ const style = document.createElement("style");
217
+ style.setAttribute("data-lotics-pdf-text-layer", "");
218
+ style.textContent = PDF_TEXT_LAYER_CSS;
219
+ document.head.appendChild(style);
220
+ }
221
+
222
+ const PDF_MAX_PAGE_WIDTH = 900;
223
+ const PDF_PAGE_GUTTER = 16;
224
+
225
+ function isPdfPasswordError(err: unknown): boolean {
226
+ return (
227
+ typeof err === "object" &&
228
+ err !== null &&
229
+ "name" in err &&
230
+ (err as { name: unknown }).name === "PasswordException"
231
+ );
232
+ }
233
+
234
+ interface RenderedPdf {
235
+ destroy: () => void;
236
+ }
237
+
238
+ /**
239
+ * Fetch a PDF, parse it with pdf.js, and paint each page to a canvas inside
240
+ * `pages` — lazily, via an IntersectionObserver, so a 200-page document doesn't
241
+ * render every page up front. Each page also gets a transparent text layer for
242
+ * selection/search. Returns a handle that tears everything down on unmount.
243
+ */
244
+ async function renderPdf(opts: {
245
+ url: string;
246
+ scroll: HTMLDivElement;
247
+ pages: HTMLDivElement;
248
+ signal: AbortSignal;
249
+ /** Reports a per-page render failure (the page degrades to a blank slot, but
250
+ * the error is never swallowed). */
251
+ reportError: (err: unknown) => void;
252
+ }): Promise<RenderedPdf> {
253
+ const { url, scroll, pages, signal, reportError } = opts;
254
+ const pdfjs = await loadPdfjs();
255
+
256
+ const response = await fetch(url, { signal });
257
+ if (!response.ok) throw new Error(`PDF fetch failed (${response.status})`);
258
+ const data = new Uint8Array(await response.arrayBuffer());
259
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
260
+
261
+ const loadingTask = pdfjs.getDocument({ data });
262
+ const doc = await loadingTask.promise;
263
+ if (signal.aborted) {
264
+ void loadingTask.destroy();
265
+ throw new DOMException("Aborted", "AbortError");
266
+ }
267
+
268
+ pages.replaceChildren();
269
+ const targetWidth = Math.min(
270
+ Math.max((scroll.clientWidth || 800) - PDF_PAGE_GUTTER * 2, 240),
271
+ PDF_MAX_PAGE_WIDTH,
272
+ );
273
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
274
+
275
+ const rendered = new Set<number>();
276
+ const renderTasks: Array<{ cancel: () => void }> = [];
277
+
278
+ const renderPage = async (pageNum: number, wrapper: HTMLDivElement): Promise<void> => {
279
+ if (rendered.has(pageNum) || signal.aborted) return;
280
+ rendered.add(pageNum);
281
+ const page = await doc.getPage(pageNum);
282
+ if (signal.aborted) return;
283
+
284
+ const base = page.getViewport({ scale: 1 });
285
+ const scale = targetWidth / base.width;
286
+ const viewport = page.getViewport({ scale });
287
+
288
+ // Backing store at device-pixel resolution; CSS size stays at the layout
289
+ // scale, so the page is crisp on HiDPI. pdf.js does NOT size the canvas from
290
+ // the render viewport — set the backing-store dimensions explicitly.
291
+ const renderViewport = page.getViewport({ scale: scale * dpr });
292
+ const canvas = document.createElement("canvas");
293
+ canvas.width = Math.floor(renderViewport.width);
294
+ canvas.height = Math.floor(renderViewport.height);
295
+ canvas.style.display = "block";
296
+ canvas.style.width = `${viewport.width}px`;
297
+ canvas.style.height = `${viewport.height}px`;
298
+
299
+ wrapper.style.height = `${viewport.height}px`;
300
+ wrapper.style.minHeight = "0px";
301
+ wrapper.replaceChildren(canvas);
302
+
303
+ const task = page.render({ canvas, viewport: renderViewport });
304
+ renderTasks.push(task);
305
+ await task.promise;
306
+ if (signal.aborted) return;
307
+
308
+ const textLayerDiv = document.createElement("div");
309
+ textLayerDiv.className = "textLayer";
310
+ textLayerDiv.style.width = `${viewport.width}px`;
311
+ textLayerDiv.style.height = `${viewport.height}px`;
312
+ textLayerDiv.style.setProperty("--total-scale-factor", String(scale));
313
+ textLayerDiv.style.setProperty("--scale-factor", String(scale));
314
+ const textLayer = new pdfjs.TextLayer({
315
+ textContentSource: page.streamTextContent(),
316
+ container: textLayerDiv,
317
+ viewport,
318
+ });
319
+ await textLayer.render();
320
+ if (signal.aborted) return;
321
+ wrapper.appendChild(textLayerDiv);
322
+ };
323
+
324
+ const observer = new IntersectionObserver(
325
+ (entries) => {
326
+ for (const entry of entries) {
327
+ if (!entry.isIntersecting) continue;
328
+ const wrapper = entry.target as HTMLDivElement;
329
+ observer.unobserve(wrapper);
330
+ // A page-level failure leaves the reserved slot blank but lets the rest
331
+ // of the document scroll — report it (never swallow), don't throw.
332
+ void renderPage(Number(wrapper.dataset.page), wrapper).catch((err: unknown) => {
333
+ if (signal.aborted) return;
334
+ if (err instanceof DOMException && err.name === "AbortError") return;
335
+ reportError(err);
336
+ });
337
+ }
338
+ },
339
+ { root: scroll, rootMargin: "300px 0px" },
340
+ );
341
+
342
+ for (let pageNum = 1; pageNum <= doc.numPages; pageNum += 1) {
343
+ const wrapper = document.createElement("div");
344
+ wrapper.dataset.page = String(pageNum);
345
+ wrapper.style.position = "relative";
346
+ wrapper.style.width = `${targetWidth}px`;
347
+ // Reserve height (letter ratio) so pages don't collapse before they paint.
348
+ wrapper.style.minHeight = `${Math.round(targetWidth * 1.294)}px`;
349
+ wrapper.style.backgroundColor = colors.white;
350
+ wrapper.style.boxShadow = "0 1px 4px rgba(0, 0, 0, 0.18)";
351
+ wrapper.style.borderRadius = "2px";
352
+ wrapper.style.overflow = "hidden";
353
+ pages.appendChild(wrapper);
354
+ observer.observe(wrapper);
355
+ }
356
+
357
+ return {
358
+ destroy: () => {
359
+ observer.disconnect();
360
+ for (const task of renderTasks) task.cancel();
361
+ void loadingTask.destroy();
362
+ },
363
+ };
364
+ }
365
+
366
+ function PdfPreview({
367
+ file,
368
+ labels,
369
+ onError,
370
+ }: {
371
+ file: FilePreviewProps["file"];
372
+ labels: PreviewLabels;
373
+ onError: FilePreviewProps["onError"];
374
+ }) {
375
+ const scrollRef = useRef<HTMLDivElement>(null);
376
+ const pagesRef = useRef<HTMLDivElement>(null);
377
+ const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
378
+ loading: true,
379
+ error: undefined,
380
+ });
381
+
382
+ useEffect(() => {
383
+ const scroll = scrollRef.current;
384
+ const pages = pagesRef.current;
385
+ if (!scroll || !pages) return;
386
+
387
+ const controller = new AbortController();
388
+ let rendered: RenderedPdf | null = null;
389
+ setState({ loading: true, error: undefined });
390
+ ensurePdfTextLayerStyles();
391
+
392
+ renderPdf({
393
+ url: file.url,
394
+ scroll,
395
+ pages,
396
+ signal: controller.signal,
397
+ reportError: (err) => onError?.(err, { fileId: file.id, mimeType: file.mimeType }),
398
+ }).then(
399
+ (result) => {
400
+ if (controller.signal.aborted) {
401
+ result.destroy();
402
+ return;
403
+ }
404
+ rendered = result;
405
+ setState({ loading: false, error: undefined });
406
+ },
407
+ (err: unknown) => {
408
+ if (controller.signal.aborted) return;
409
+ if (err instanceof DOMException && err.name === "AbortError") return;
410
+ const passwordProtected = isPdfPasswordError(err);
411
+ onError?.(err, { fileId: file.id, mimeType: file.mimeType });
412
+ setState({
413
+ loading: false,
414
+ error: passwordProtected ? labels.passwordProtected : labels.loadFailed,
415
+ });
416
+ },
417
+ );
418
+
419
+ return () => {
420
+ controller.abort();
421
+ rendered?.destroy();
422
+ };
423
+ }, [file.url, file.id, file.mimeType, labels.loadFailed, labels.passwordProtected, onError]);
424
+
425
+ return (
426
+ <div style={pdfContainerStyle}>
427
+ <div ref={scrollRef} style={pdfScrollStyle}>
428
+ <div ref={pagesRef} style={pdfPagesStyle} />
429
+ </div>
430
+ {state.loading && (
431
+ <div style={pdfOverlayStyle}>
432
+ <Text size="sm" color="muted">
433
+
434
+ </Text>
435
+ </div>
436
+ )}
437
+ {state.error !== undefined && (
438
+ <div style={pdfOverlayStyle}>
439
+ <Text size="sm" color="muted">
440
+ {state.error}
441
+ </Text>
442
+ <View style={{ marginTop: 12 }}>
443
+ <Button
444
+ icon="download"
445
+ title={labels.download}
446
+ color="secondary"
447
+ onPress={() => void downloadFileFromUrl(file.url, file.filename)}
448
+ />
449
+ </View>
450
+ </div>
451
+ )}
452
+ </div>
453
+ );
454
+ }
455
+
162
456
  const styles = StyleSheet.create({
163
457
  placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
164
458
  placeholderText: { marginTop: 8 },
165
459
  });
166
460
 
167
- const fullFrameStyle: React.CSSProperties = { width: "100%", height: "100%", border: "none" };
461
+ const pdfContainerStyle: React.CSSProperties = {
462
+ position: "relative",
463
+ display: "flex",
464
+ flexDirection: "column",
465
+ flex: 1,
466
+ minHeight: 0,
467
+ backgroundColor: "#f4f4f5",
468
+ };
469
+ const pdfScrollStyle: React.CSSProperties = {
470
+ flex: 1,
471
+ width: "100%",
472
+ overflowY: "auto",
473
+ overflowX: "hidden",
474
+ };
475
+ const pdfPagesStyle: React.CSSProperties = {
476
+ display: "flex",
477
+ flexDirection: "column",
478
+ alignItems: "center",
479
+ gap: 16,
480
+ padding: 16,
481
+ };
482
+ const pdfOverlayStyle: React.CSSProperties = {
483
+ position: "absolute",
484
+ inset: 0,
485
+ display: "flex",
486
+ flexDirection: "column",
487
+ alignItems: "center",
488
+ justifyContent: "center",
489
+ pointerEvents: "none",
490
+ };
168
491
  const mediaElementStyle: React.CSSProperties = {
169
492
  width: "100%",
170
493
  height: "100%",
@@ -35,3 +35,43 @@ export interface FilePreviewProps {
35
35
  export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
36
36
  return labels ? { ...defaultPreviewLabels, ...labels } : defaultPreviewLabels;
37
37
  }
38
+
39
+ /**
40
+ * Strings for the full-screen `FileGalleryModal` chrome (toolbar + nav), on top
41
+ * of the inner `PreviewLabels`. Same i18n-free contract: host passes translated
42
+ * strings, English is the fallback.
43
+ */
44
+ export interface GalleryLabels extends PreviewLabels {
45
+ close: string;
46
+ previous: string;
47
+ next: string;
48
+ actions: string;
49
+ openExternal: string;
50
+ remove: string;
51
+ removeConfirmTitle: string;
52
+ removeConfirmMessage: (filename: string) => string;
53
+ cancel: string;
54
+ rotateLeft: string;
55
+ rotateRight: string;
56
+ saveRotation: string;
57
+ }
58
+
59
+ export const defaultGalleryLabels: GalleryLabels = {
60
+ ...defaultPreviewLabels,
61
+ close: "Close",
62
+ previous: "Previous",
63
+ next: "Next",
64
+ actions: "Actions",
65
+ openExternal: "Open in new tab",
66
+ remove: "Remove",
67
+ removeConfirmTitle: "Remove file",
68
+ removeConfirmMessage: (filename) => `Remove "${filename}"?`,
69
+ cancel: "Cancel",
70
+ rotateLeft: "Rotate left",
71
+ rotateRight: "Rotate right",
72
+ saveRotation: "Save rotation",
73
+ };
74
+
75
+ export function resolveGalleryLabels(labels: Partial<GalleryLabels> | undefined): GalleryLabels {
76
+ return labels ? { ...defaultGalleryLabels, ...labels } : defaultGalleryLabels;
77
+ }
@@ -0,0 +1,114 @@
1
+ import { useState } from "react";
2
+ import { Pressable, StyleSheet, View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { colors } from "./colors";
5
+ import { FileBadge } from "./file_badge";
6
+
7
+ export interface FileRowProps {
8
+ /** The file / document name — the primary line. */
9
+ name: string;
10
+ /** Secondary line: size, status, timestamp, etc. */
11
+ meta?: string;
12
+ /** File type → the FileBadge glyph + color. Omit (with `placeholder`) for an
13
+ * expected-but-not-yet-provided document. */
14
+ mimeType?: string;
15
+ /** Ghost badge — an expected document that hasn't been provided yet. */
16
+ placeholder?: boolean;
17
+ /** Marks the badge as a template (TMPL). */
18
+ isTemplate?: boolean;
19
+ /** When set, the WHOLE row is a pressable "door" (e.g. open the file), with a
20
+ * hover/press wash. Omit for a static line whose only interaction is `trailing`. */
21
+ onPress?: () => void;
22
+ /** Trailing slot — a status `Badge`, an action `Button`, a timestamp, a remove
23
+ * control. An independently-interactive SIBLING: never swallowed by the row
24
+ * press (a button never nests in the door button). */
25
+ trailing?: React.ReactNode;
26
+ }
27
+
28
+ /**
29
+ * A horizontal file / document LINE: a `FileBadge` (or a same-footprint
30
+ * placeholder) + the full name and an optional meta line, with a composable
31
+ * `trailing` slot. For document checklists, requirement lists, and attachment
32
+ * rows where the name must be readable.
33
+ *
34
+ * `onPress` makes the whole row a pressable door; `trailing` stays a sibling, so
35
+ * a button/menu in it is pressed independently (the door + sibling a11y pattern —
36
+ * see `PressableRow`). For square thumbnail tiles use `FileThumbnailGrid` / `FileGrid`.
37
+ */
38
+ export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress, trailing }: FileRowProps) {
39
+ const [hovered, setHovered] = useState(false);
40
+ const [pressed, setPressed] = useState(false);
41
+ // Hover via the DOM (not Pressability): RN-Web hands a parent's hover to the
42
+ // innermost nested pressable, which would stop the wash short of `trailing`.
43
+ const mouseProps = {
44
+ onMouseEnter: () => setHovered(true),
45
+ onMouseLeave: () => setHovered(false),
46
+ } as object;
47
+
48
+ const content = (
49
+ <>
50
+ <FileBadge size={30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
51
+ <View style={styles.text}>
52
+ <Text size="sm" weight="medium" numberOfLines={1}>
53
+ {name}
54
+ </Text>
55
+ {meta ? (
56
+ <Text size="xs" color="muted" numberOfLines={1}>
57
+ {meta}
58
+ </Text>
59
+ ) : null}
60
+ </View>
61
+ </>
62
+ );
63
+
64
+ if (!onPress) {
65
+ return (
66
+ <View style={styles.row}>
67
+ {content}
68
+ {trailing}
69
+ </View>
70
+ );
71
+ }
72
+
73
+ return (
74
+ <View
75
+ style={[styles.pressableRow, hovered && styles.rowHovered, pressed && styles.rowPressed]}
76
+ {...mouseProps}
77
+ >
78
+ <Pressable
79
+ onPress={onPress}
80
+ onPressIn={() => setPressed(true)}
81
+ onPressOut={() => setPressed(false)}
82
+ accessibilityRole="button"
83
+ accessibilityLabel={`Open ${name}`}
84
+ style={styles.door}
85
+ >
86
+ {content}
87
+ </Pressable>
88
+ {trailing}
89
+ </View>
90
+ );
91
+ }
92
+
93
+ const styles = StyleSheet.create({
94
+ row: { flexDirection: "row", alignItems: "center", gap: 12 },
95
+ pressableRow: {
96
+ flexDirection: "row",
97
+ alignItems: "center",
98
+ gap: 12,
99
+ // Negative horizontal margin cancels the padding for the CONTENT's position
100
+ // (badge stays aligned with static rows / the container edge) while the hover
101
+ // wash bleeds 8px outward into the parent's padding — a hit area with no
102
+ // layout shift. The parent is expected to have ≥8px horizontal padding.
103
+ marginHorizontal: -8,
104
+ paddingHorizontal: 8,
105
+ paddingVertical: 6,
106
+ borderRadius: 8,
107
+ ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
108
+ },
109
+ rowHovered: { backgroundColor: colors.zinc["50"] },
110
+ rowPressed: { backgroundColor: colors.zinc["100"] },
111
+ // The accessible "Open" door — fills the row left of the trailing sibling.
112
+ door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
113
+ text: { flex: 1, gap: 1 },
114
+ });
@@ -0,0 +1,105 @@
1
+ // FileRows — the common "show a list of files" surface, batteries included: each
2
+ // file is a FileRow whose whole-row press opens the built-in full-screen
3
+ // FileGalleryModal, with a ⋯ menu (Download · Open in new tab · Remove) on the
4
+ // trailing edge. Pure kit composition (FileRow + ActionMenu + FileGalleryModal) —
5
+ // reach for the pieces directly only when you need a different shape.
6
+
7
+ import { useState } from "react";
8
+ import { View } from "react-native";
9
+ import { Alert } from "./alert";
10
+ import { FileRow } from "./file_row";
11
+ import { ActionMenu, type ActionMenuItem } from "./action_menu";
12
+ import { FileGalleryModal } from "./file_gallery_modal";
13
+ import { resolveMime } from "./file_badge";
14
+ import { downloadFileFromUrl } from "./download";
15
+ import { resolveGalleryLabels, type GalleryLabels } from "./file_preview_types";
16
+ import type { DisplayFile } from "./file_thumbnail";
17
+
18
+ export interface FileRowsProps {
19
+ files: DisplayFile[];
20
+ /** Secondary line per file. Default: the file-type label (e.g. "PDF"). */
21
+ meta?: (file: DisplayFile) => string | undefined;
22
+ /** Adds a (confirmed) "Remove" to each ⋯ menu and the gallery toolbar. The host
23
+ * drops the file from its own state. */
24
+ onRemove?: (file: DisplayFile) => void;
25
+ /** Open a file outside the app (a new tab on the frontend, the SDK's
26
+ * `openExternal` in a sandboxed app). Adds "Open in new tab". */
27
+ onOpenExternal?: (file: DisplayFile) => void;
28
+ /** Override the Download action (default: fetch + save the URL). The host
29
+ * frontend passes its own to send `credentials` for auth-gated proxy URLs. */
30
+ onDownload?: (file: DisplayFile) => void;
31
+ /** Reported when a previewed file fails to render (host wires its logger). */
32
+ onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
33
+ /** Translated chrome — Download / Remove / Open-external / gallery + confirm. */
34
+ labels?: Partial<GalleryLabels>;
35
+ /** Gap between rows. Default 8. */
36
+ gap?: number;
37
+ }
38
+
39
+ /**
40
+ * A file LIST that just works: tap a row to preview it full-screen, ⋯ for quick
41
+ * actions. The default surface for "here are some files" — saved attachments, a
42
+ * record's documents, a message's files. For an ADD/upload surface use `FileGrid`;
43
+ * for bare square tiles use `FileThumbnailGrid`.
44
+ */
45
+ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8 }: FileRowsProps) {
46
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
47
+ const l = resolveGalleryLabels(labels);
48
+
49
+ if (files.length === 0) return null;
50
+
51
+ const download = (file: DisplayFile) => {
52
+ if (onDownload) {
53
+ onDownload(file);
54
+ return;
55
+ }
56
+ void downloadFileFromUrl(file.url, file.filename);
57
+ };
58
+
59
+ const confirmRemove = (file: DisplayFile) => {
60
+ if (!onRemove) return;
61
+ Alert.alert(l.removeConfirmTitle, l.removeConfirmMessage(file.filename), [
62
+ { text: l.cancel, style: "cancel" },
63
+ { text: l.remove, style: "destructive", onPress: () => onRemove(file) },
64
+ ]);
65
+ };
66
+
67
+ return (
68
+ <View style={{ gap }}>
69
+ {files.map((file, index) => {
70
+ const items: ActionMenuItem[] = [
71
+ { key: "download", label: l.download, icon: "download", onPress: () => download(file) },
72
+ ];
73
+ if (onOpenExternal) {
74
+ items.push({ key: "open", label: l.openExternal, icon: "external-link", onPress: () => onOpenExternal(file) });
75
+ }
76
+ if (onRemove) {
77
+ items.push({ key: "remove", label: l.remove, icon: "trash", danger: true, onPress: () => confirmRemove(file) });
78
+ }
79
+ return (
80
+ <FileRow
81
+ key={file.id}
82
+ name={file.filename}
83
+ meta={meta ? meta(file) : resolveMime(file.mimeType).label}
84
+ mimeType={file.mimeType}
85
+ onPress={() => setActiveIndex(index)}
86
+ trailing={<ActionMenu accessibilityLabel={`${l.actions}: ${file.filename}`} items={items} />}
87
+ />
88
+ );
89
+ })}
90
+ <FileGalleryModal
91
+ files={files}
92
+ activeIndex={activeIndex}
93
+ onIndexChange={setActiveIndex}
94
+ onDownload={onDownload}
95
+ onOpenExternal={onOpenExternal}
96
+ onRemove={onRemove ? (fileId) => {
97
+ const file = files.find((f) => f.id === fileId);
98
+ if (file) onRemove(file);
99
+ } : undefined}
100
+ onError={onError}
101
+ labels={labels}
102
+ />
103
+ </View>
104
+ );
105
+ }