@lotics/ui 5.7.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.
- package/AGENTS.md +102 -42
- package/examples/tpl_approvals.tsx +10 -10
- package/examples/tpl_convert.tsx +11 -23
- package/examples/tpl_crm_desk.tsx +3 -3
- package/examples/tpl_dieline.tsx +7 -6
- package/examples/tpl_directory.tsx +3 -3
- package/examples/tpl_dossier.tsx +7 -7
- package/examples/tpl_draft.tsx +4 -4
- package/examples/tpl_extract.tsx +7 -7
- package/examples/tpl_inventory.tsx +3 -3
- package/examples/tpl_lookup.tsx +4 -3
- package/examples/tpl_match.tsx +1 -1
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_ratedesk.tsx +12 -12
- package/examples/tpl_record.tsx +1 -1
- package/examples/tpl_report.tsx +3 -3
- package/examples/tpl_stock.tsx +3 -3
- package/examples/tpl_triage.tsx +1 -1
- package/package.json +12 -4
- package/src/agent_progress.tsx +14 -12
- package/src/agent_run.tsx +253 -66
- package/src/back_button.tsx +3 -2
- package/src/badge.tsx +2 -1
- package/src/button.tsx +11 -32
- package/src/button_colors.ts +23 -0
- package/src/calendar/calendar_view.tsx +2 -2
- package/src/card_select_item.tsx +2 -1
- package/src/change_review.tsx +5 -6
- package/src/{pill_button.tsx → chip.tsx} +7 -7
- package/src/chip_group.tsx +2 -2
- package/src/column_filter.tsx +4 -4
- package/src/combobox.tsx +2 -9
- package/src/composer.tsx +4 -3
- package/src/control_surface.ts +15 -15
- package/src/date_field.tsx +2 -1
- package/src/date_filter.tsx +2 -1
- package/src/date_range_filter_field.tsx +2 -1
- package/src/dialog.tsx +2 -2
- package/src/discrepancy.tsx +1 -1
- package/src/drawer.tsx +1 -2
- package/src/file_gallery_modal.tsx +220 -133
- package/src/file_grid.tsx +184 -0
- package/src/file_preview.web.tsx +329 -6
- package/src/file_preview_types.ts +40 -0
- package/src/file_row.tsx +114 -0
- package/src/file_rows.tsx +105 -0
- package/src/file_thumbnail.tsx +121 -79
- package/src/file_thumbnail_grid.tsx +130 -55
- package/src/{filter_pill.tsx → filter_chip.tsx} +8 -8
- package/src/finding.tsx +1 -1
- package/src/gantt/gantt_view.tsx +2 -2
- package/src/icon_button.tsx +49 -14
- package/src/image_gallery.tsx +0 -4
- package/src/inline_edit.tsx +2 -2
- package/src/link_button.tsx +2 -1
- package/src/menu_button.tsx +2 -1
- package/src/menu_list_item.tsx +2 -1
- package/src/number_input.tsx +2 -1
- package/src/pdfjs_worker.d.ts +5 -0
- package/src/picker.tsx +3 -3
- package/src/popover_header.tsx +2 -2
- package/src/radio_picker.tsx +2 -1
- package/src/range_slider.tsx +1 -1
- package/src/review_card.tsx +2 -2
- package/src/scan_field.tsx +2 -1
- package/src/scored_option.tsx +0 -1
- package/src/search_input.tsx +3 -2
- package/src/segmented_control.tsx +2 -1
- package/src/status_badge.tsx +2 -1
- package/src/stepper.tsx +3 -2
- package/src/switch_button.tsx +2 -1
- package/src/switcher.tsx +3 -2
- package/src/tabs.tsx +2 -1
- package/src/tag_input.tsx +2 -1
- package/src/text_input_field.tsx +2 -1
- package/src/time_field.tsx +2 -1
- package/src/time_picker.tsx +2 -1
- package/src/triage_row.tsx +3 -3
- package/src/uploading_thumbnail.tsx +212 -0
- package/src/hover_action.tsx +0 -54
package/src/file_preview.web.tsx
CHANGED
|
@@ -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,
|
|
21
|
-
*
|
|
22
|
-
* renderer; Excel/CSV via the read-only `@lotics/xlsx`
|
|
23
|
-
* dynamic-imported so non-document files never pay
|
|
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 <
|
|
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
|
|
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
|
+
}
|
package/src/file_row.tsx
ADDED
|
@@ -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
|
+
}
|