@lotics/ui 5.8.0 → 5.10.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 +184 -56
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_convert.tsx +11 -23
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +6 -5
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +19 -6
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +227 -139
- package/src/file_grid.tsx +184 -0
- package/src/file_preview.web.tsx +349 -12
- package/src/file_preview_types.ts +49 -0
- package/src/file_row.tsx +118 -0
- package/src/file_rows.tsx +109 -0
- package/src/file_thumbnail.tsx +132 -81
- package/src/file_thumbnail_grid.tsx +135 -55
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +14 -3
- package/src/image_gallery.tsx +8 -14
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/pdfjs_worker.d.ts +5 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/uploading_thumbnail.tsx +212 -0
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/hover_action.tsx +0 -54
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
package/src/file_preview.web.tsx
CHANGED
|
@@ -15,17 +15,25 @@ 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.
|
|
27
35
|
*/
|
|
28
|
-
export function FilePreview({ file, labels, onError, rotation }: FilePreviewProps) {
|
|
36
|
+
export function FilePreview({ file, labels, onError, rotation, credentials }: FilePreviewProps) {
|
|
29
37
|
const l = resolveLabels(labels);
|
|
30
38
|
|
|
31
39
|
if (isImageMimeType(file.mimeType)) {
|
|
@@ -39,10 +47,10 @@ 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} credentials={credentials} />;
|
|
43
51
|
}
|
|
44
52
|
if (isDocxMimeType(file.mimeType)) {
|
|
45
|
-
return <WordPreview file={file} labels={l} onError={onError} />;
|
|
53
|
+
return <WordPreview file={file} labels={l} onError={onError} credentials={credentials} />;
|
|
46
54
|
}
|
|
47
55
|
if (isVideoMimeType(file.mimeType)) {
|
|
48
56
|
return <video controls preload="metadata" src={file.url} style={mediaElementStyle} />;
|
|
@@ -56,7 +64,7 @@ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProp
|
|
|
56
64
|
);
|
|
57
65
|
}
|
|
58
66
|
if (isPreviewableMimeType(file.mimeType)) {
|
|
59
|
-
return <SpreadsheetView file={file} labels={l} onError={onError} />;
|
|
67
|
+
return <SpreadsheetView file={file} labels={l} onError={onError} credentials={credentials} />;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
return (
|
|
@@ -81,10 +89,12 @@ function WordPreview({
|
|
|
81
89
|
file,
|
|
82
90
|
labels,
|
|
83
91
|
onError,
|
|
92
|
+
credentials,
|
|
84
93
|
}: {
|
|
85
94
|
file: FilePreviewProps["file"];
|
|
86
95
|
labels: PreviewLabels;
|
|
87
96
|
onError: FilePreviewProps["onError"];
|
|
97
|
+
credentials: FilePreviewProps["credentials"];
|
|
88
98
|
}) {
|
|
89
99
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
90
100
|
const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
|
|
@@ -98,7 +108,7 @@ function WordPreview({
|
|
|
98
108
|
setState({ loading: true, error: undefined });
|
|
99
109
|
|
|
100
110
|
const run = async () => {
|
|
101
|
-
const response = await fetch(file.url, { signal: controller.signal });
|
|
111
|
+
const response = await fetch(file.url, { signal: controller.signal, credentials });
|
|
102
112
|
if (!response.ok) throw new Error(`Document fetch failed (${response.status})`);
|
|
103
113
|
const arrayBuffer = await response.arrayBuffer();
|
|
104
114
|
const bytes = new Uint8Array(arrayBuffer);
|
|
@@ -128,7 +138,7 @@ function WordPreview({
|
|
|
128
138
|
controller.abort();
|
|
129
139
|
loadedView?.destroy();
|
|
130
140
|
};
|
|
131
|
-
}, [file.url, file.id, file.mimeType, labels.loadFailed, onError]);
|
|
141
|
+
}, [file.url, file.id, file.mimeType, labels.loadFailed, onError, credentials]);
|
|
132
142
|
|
|
133
143
|
return (
|
|
134
144
|
<div style={wordContainerStyle}>
|
|
@@ -149,7 +159,7 @@ function WordPreview({
|
|
|
149
159
|
icon="download"
|
|
150
160
|
title={labels.download}
|
|
151
161
|
color="secondary"
|
|
152
|
-
onPress={() => void downloadFileFromUrl(file.url, file.filename)}
|
|
162
|
+
onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
|
|
153
163
|
/>
|
|
154
164
|
</View>
|
|
155
165
|
</div>
|
|
@@ -159,12 +169,339 @@ function WordPreview({
|
|
|
159
169
|
);
|
|
160
170
|
}
|
|
161
171
|
|
|
172
|
+
/**
|
|
173
|
+
* pdf.js runs on the MAIN THREAD (no Web Worker): importing the worker entry for
|
|
174
|
+
* its side effect registers `globalThis.pdfjsWorker`, which pdf.js's `PDFWorker`
|
|
175
|
+
* reads to skip real-worker setup entirely — no worker-URL resolution, no
|
|
176
|
+
* `new Worker(...)`, so it bundles identically under Vite (apps) and Metro
|
|
177
|
+
* (frontend). One document at a time, on demand — the same tradeoff `@lotics/xlsx`
|
|
178
|
+
* and `@lotics/docx` already make. Cached so the engine loads once per session.
|
|
179
|
+
*/
|
|
180
|
+
let pdfjsModule: Promise<typeof import("pdfjs-dist")> | null = null;
|
|
181
|
+
function loadPdfjs(): Promise<typeof import("pdfjs-dist")> {
|
|
182
|
+
if (!pdfjsModule) {
|
|
183
|
+
pdfjsModule = (async () => {
|
|
184
|
+
const [pdfjs] = await Promise.all([
|
|
185
|
+
import("pdfjs-dist"),
|
|
186
|
+
import("pdfjs-dist/build/pdf.worker.min.mjs"),
|
|
187
|
+
]);
|
|
188
|
+
return pdfjs;
|
|
189
|
+
})().catch((err) => {
|
|
190
|
+
// Don't cache a rejected import — a transient failure would otherwise
|
|
191
|
+
// poison every later PDF preview. Reset so the next open retries.
|
|
192
|
+
pdfjsModule = null;
|
|
193
|
+
throw err;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return pdfjsModule;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// pdf.js's TextLayer overlays transparent, selectable text spans aligned to the
|
|
200
|
+
// canvas. The spans position themselves off `--total-scale-factor` (set per page
|
|
201
|
+
// below); these are the minimal rules from pdf.js's shipped viewer CSS that make
|
|
202
|
+
// the text invisible, selectable, and correctly placed. Injected once, globally
|
|
203
|
+
// (the `.textLayer` class is pdf.js-specific) so no consumer has to import a CSS
|
|
204
|
+
// file — a half-applied stylesheet would render the spans as visible black text.
|
|
205
|
+
const PDF_TEXT_LAYER_CSS = `
|
|
206
|
+
.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))}
|
|
207
|
+
.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}
|
|
208
|
+
.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))}
|
|
209
|
+
.textLayer .markedContent{display:contents}
|
|
210
|
+
.textLayer span[role="img"]{-webkit-user-select:none;-moz-user-select:none;user-select:none;cursor:default}
|
|
211
|
+
.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}
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
let pdfTextLayerStylesInjected = false;
|
|
215
|
+
function ensurePdfTextLayerStyles(): void {
|
|
216
|
+
if (pdfTextLayerStylesInjected || typeof document === "undefined") return;
|
|
217
|
+
pdfTextLayerStylesInjected = true;
|
|
218
|
+
const style = document.createElement("style");
|
|
219
|
+
style.setAttribute("data-lotics-pdf-text-layer", "");
|
|
220
|
+
style.textContent = PDF_TEXT_LAYER_CSS;
|
|
221
|
+
document.head.appendChild(style);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const PDF_MAX_PAGE_WIDTH = 900;
|
|
225
|
+
const PDF_PAGE_GUTTER = 16;
|
|
226
|
+
|
|
227
|
+
function isPdfPasswordError(err: unknown): boolean {
|
|
228
|
+
return (
|
|
229
|
+
typeof err === "object" &&
|
|
230
|
+
err !== null &&
|
|
231
|
+
"name" in err &&
|
|
232
|
+
(err as { name: unknown }).name === "PasswordException"
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface RenderedPdf {
|
|
237
|
+
destroy: () => void;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Fetch a PDF, parse it with pdf.js, and paint each page to a canvas inside
|
|
242
|
+
* `pages` — lazily, via an IntersectionObserver, so a 200-page document doesn't
|
|
243
|
+
* render every page up front. Each page also gets a transparent text layer for
|
|
244
|
+
* selection/search. Returns a handle that tears everything down on unmount.
|
|
245
|
+
*/
|
|
246
|
+
async function renderPdf(opts: {
|
|
247
|
+
url: string;
|
|
248
|
+
scroll: HTMLDivElement;
|
|
249
|
+
pages: HTMLDivElement;
|
|
250
|
+
signal: AbortSignal;
|
|
251
|
+
credentials: RequestCredentials | undefined;
|
|
252
|
+
/** Reports a per-page render failure (the page degrades to a blank slot, but
|
|
253
|
+
* the error is never swallowed). */
|
|
254
|
+
reportError: (err: unknown) => void;
|
|
255
|
+
}): Promise<RenderedPdf> {
|
|
256
|
+
const { url, scroll, pages, signal, credentials, reportError } = opts;
|
|
257
|
+
const pdfjs = await loadPdfjs();
|
|
258
|
+
|
|
259
|
+
const response = await fetch(url, { signal, credentials });
|
|
260
|
+
if (!response.ok) throw new Error(`PDF fetch failed (${response.status})`);
|
|
261
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
262
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
263
|
+
|
|
264
|
+
const loadingTask = pdfjs.getDocument({ data });
|
|
265
|
+
const doc = await loadingTask.promise;
|
|
266
|
+
if (signal.aborted) {
|
|
267
|
+
void loadingTask.destroy();
|
|
268
|
+
throw new DOMException("Aborted", "AbortError");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
pages.replaceChildren();
|
|
272
|
+
const targetWidth = Math.min(
|
|
273
|
+
Math.max((scroll.clientWidth || 800) - PDF_PAGE_GUTTER * 2, 240),
|
|
274
|
+
PDF_MAX_PAGE_WIDTH,
|
|
275
|
+
);
|
|
276
|
+
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
277
|
+
|
|
278
|
+
const rendered = new Set<number>();
|
|
279
|
+
const renderTasks: Array<{ cancel: () => void }> = [];
|
|
280
|
+
|
|
281
|
+
const renderPage = async (pageNum: number, wrapper: HTMLDivElement): Promise<void> => {
|
|
282
|
+
if (rendered.has(pageNum) || signal.aborted) return;
|
|
283
|
+
rendered.add(pageNum);
|
|
284
|
+
const page = await doc.getPage(pageNum);
|
|
285
|
+
if (signal.aborted) return;
|
|
286
|
+
|
|
287
|
+
const base = page.getViewport({ scale: 1 });
|
|
288
|
+
const scale = targetWidth / base.width;
|
|
289
|
+
const viewport = page.getViewport({ scale });
|
|
290
|
+
|
|
291
|
+
// Backing store at device-pixel resolution; CSS size stays at the layout
|
|
292
|
+
// scale, so the page is crisp on HiDPI. pdf.js does NOT size the canvas from
|
|
293
|
+
// the render viewport — set the backing-store dimensions explicitly.
|
|
294
|
+
const renderViewport = page.getViewport({ scale: scale * dpr });
|
|
295
|
+
const canvas = document.createElement("canvas");
|
|
296
|
+
canvas.width = Math.floor(renderViewport.width);
|
|
297
|
+
canvas.height = Math.floor(renderViewport.height);
|
|
298
|
+
canvas.style.display = "block";
|
|
299
|
+
canvas.style.width = `${viewport.width}px`;
|
|
300
|
+
canvas.style.height = `${viewport.height}px`;
|
|
301
|
+
|
|
302
|
+
wrapper.style.height = `${viewport.height}px`;
|
|
303
|
+
wrapper.style.minHeight = "0px";
|
|
304
|
+
wrapper.replaceChildren(canvas);
|
|
305
|
+
|
|
306
|
+
const task = page.render({ canvas, viewport: renderViewport });
|
|
307
|
+
renderTasks.push(task);
|
|
308
|
+
await task.promise;
|
|
309
|
+
if (signal.aborted) return;
|
|
310
|
+
|
|
311
|
+
const textLayerDiv = document.createElement("div");
|
|
312
|
+
textLayerDiv.className = "textLayer";
|
|
313
|
+
textLayerDiv.style.width = `${viewport.width}px`;
|
|
314
|
+
textLayerDiv.style.height = `${viewport.height}px`;
|
|
315
|
+
textLayerDiv.style.setProperty("--total-scale-factor", String(scale));
|
|
316
|
+
textLayerDiv.style.setProperty("--scale-factor", String(scale));
|
|
317
|
+
const textLayer = new pdfjs.TextLayer({
|
|
318
|
+
textContentSource: page.streamTextContent(),
|
|
319
|
+
container: textLayerDiv,
|
|
320
|
+
viewport,
|
|
321
|
+
});
|
|
322
|
+
await textLayer.render();
|
|
323
|
+
if (signal.aborted) return;
|
|
324
|
+
wrapper.appendChild(textLayerDiv);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const observer = new IntersectionObserver(
|
|
328
|
+
(entries) => {
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
if (!entry.isIntersecting) continue;
|
|
331
|
+
const wrapper = entry.target as HTMLDivElement;
|
|
332
|
+
observer.unobserve(wrapper);
|
|
333
|
+
// A page-level failure leaves the reserved slot blank but lets the rest
|
|
334
|
+
// of the document scroll — report it (never swallow), don't throw.
|
|
335
|
+
void renderPage(Number(wrapper.dataset.page), wrapper).catch((err: unknown) => {
|
|
336
|
+
if (signal.aborted) return;
|
|
337
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
338
|
+
reportError(err);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
{ root: scroll, rootMargin: "300px 0px" },
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
for (let pageNum = 1; pageNum <= doc.numPages; pageNum += 1) {
|
|
346
|
+
const wrapper = document.createElement("div");
|
|
347
|
+
wrapper.dataset.page = String(pageNum);
|
|
348
|
+
wrapper.style.position = "relative";
|
|
349
|
+
wrapper.style.width = `${targetWidth}px`;
|
|
350
|
+
// Reserve height (letter ratio) so pages don't collapse before they paint.
|
|
351
|
+
wrapper.style.minHeight = `${Math.round(targetWidth * 1.294)}px`;
|
|
352
|
+
wrapper.style.backgroundColor = colors.white;
|
|
353
|
+
wrapper.style.boxShadow = "0 1px 4px rgba(0, 0, 0, 0.18)";
|
|
354
|
+
wrapper.style.borderRadius = "2px";
|
|
355
|
+
wrapper.style.overflow = "hidden";
|
|
356
|
+
pages.appendChild(wrapper);
|
|
357
|
+
observer.observe(wrapper);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
destroy: () => {
|
|
362
|
+
observer.disconnect();
|
|
363
|
+
for (const task of renderTasks) task.cancel();
|
|
364
|
+
void loadingTask.destroy();
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function PdfPreview({
|
|
370
|
+
file,
|
|
371
|
+
labels,
|
|
372
|
+
onError,
|
|
373
|
+
credentials,
|
|
374
|
+
}: {
|
|
375
|
+
file: FilePreviewProps["file"];
|
|
376
|
+
labels: PreviewLabels;
|
|
377
|
+
onError: FilePreviewProps["onError"];
|
|
378
|
+
credentials: FilePreviewProps["credentials"];
|
|
379
|
+
}) {
|
|
380
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
381
|
+
const pagesRef = useRef<HTMLDivElement>(null);
|
|
382
|
+
const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
|
|
383
|
+
loading: true,
|
|
384
|
+
error: undefined,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
const scroll = scrollRef.current;
|
|
389
|
+
const pages = pagesRef.current;
|
|
390
|
+
if (!scroll || !pages) return;
|
|
391
|
+
|
|
392
|
+
const controller = new AbortController();
|
|
393
|
+
let rendered: RenderedPdf | null = null;
|
|
394
|
+
setState({ loading: true, error: undefined });
|
|
395
|
+
ensurePdfTextLayerStyles();
|
|
396
|
+
|
|
397
|
+
renderPdf({
|
|
398
|
+
url: file.url,
|
|
399
|
+
scroll,
|
|
400
|
+
pages,
|
|
401
|
+
signal: controller.signal,
|
|
402
|
+
credentials,
|
|
403
|
+
reportError: (err) => onError?.(err, { fileId: file.id, mimeType: file.mimeType }),
|
|
404
|
+
}).then(
|
|
405
|
+
(result) => {
|
|
406
|
+
if (controller.signal.aborted) {
|
|
407
|
+
result.destroy();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
rendered = result;
|
|
411
|
+
setState({ loading: false, error: undefined });
|
|
412
|
+
},
|
|
413
|
+
(err: unknown) => {
|
|
414
|
+
if (controller.signal.aborted) return;
|
|
415
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
416
|
+
const passwordProtected = isPdfPasswordError(err);
|
|
417
|
+
onError?.(err, { fileId: file.id, mimeType: file.mimeType });
|
|
418
|
+
setState({
|
|
419
|
+
loading: false,
|
|
420
|
+
error: passwordProtected ? labels.passwordProtected : labels.loadFailed,
|
|
421
|
+
});
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
return () => {
|
|
426
|
+
controller.abort();
|
|
427
|
+
rendered?.destroy();
|
|
428
|
+
};
|
|
429
|
+
}, [
|
|
430
|
+
file.url,
|
|
431
|
+
file.id,
|
|
432
|
+
file.mimeType,
|
|
433
|
+
labels.loadFailed,
|
|
434
|
+
labels.passwordProtected,
|
|
435
|
+
onError,
|
|
436
|
+
credentials,
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<div style={pdfContainerStyle}>
|
|
441
|
+
<div ref={scrollRef} style={pdfScrollStyle}>
|
|
442
|
+
<div ref={pagesRef} style={pdfPagesStyle} />
|
|
443
|
+
</div>
|
|
444
|
+
{state.loading && (
|
|
445
|
+
<div style={pdfOverlayStyle}>
|
|
446
|
+
<Text size="sm" color="muted">
|
|
447
|
+
…
|
|
448
|
+
</Text>
|
|
449
|
+
</div>
|
|
450
|
+
)}
|
|
451
|
+
{state.error !== undefined && (
|
|
452
|
+
<div style={pdfOverlayStyle}>
|
|
453
|
+
<Text size="sm" color="muted">
|
|
454
|
+
{state.error}
|
|
455
|
+
</Text>
|
|
456
|
+
<View style={{ marginTop: 12 }}>
|
|
457
|
+
<Button
|
|
458
|
+
icon="download"
|
|
459
|
+
title={labels.download}
|
|
460
|
+
color="secondary"
|
|
461
|
+
onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
|
|
462
|
+
/>
|
|
463
|
+
</View>
|
|
464
|
+
</div>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
162
470
|
const styles = StyleSheet.create({
|
|
163
471
|
placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
|
|
164
472
|
placeholderText: { marginTop: 8 },
|
|
165
473
|
});
|
|
166
474
|
|
|
167
|
-
const
|
|
475
|
+
const pdfContainerStyle: React.CSSProperties = {
|
|
476
|
+
position: "relative",
|
|
477
|
+
display: "flex",
|
|
478
|
+
flexDirection: "column",
|
|
479
|
+
flex: 1,
|
|
480
|
+
minHeight: 0,
|
|
481
|
+
backgroundColor: "#f4f4f5",
|
|
482
|
+
};
|
|
483
|
+
const pdfScrollStyle: React.CSSProperties = {
|
|
484
|
+
flex: 1,
|
|
485
|
+
width: "100%",
|
|
486
|
+
overflowY: "auto",
|
|
487
|
+
overflowX: "hidden",
|
|
488
|
+
};
|
|
489
|
+
const pdfPagesStyle: React.CSSProperties = {
|
|
490
|
+
display: "flex",
|
|
491
|
+
flexDirection: "column",
|
|
492
|
+
alignItems: "center",
|
|
493
|
+
gap: 16,
|
|
494
|
+
padding: 16,
|
|
495
|
+
};
|
|
496
|
+
const pdfOverlayStyle: React.CSSProperties = {
|
|
497
|
+
position: "absolute",
|
|
498
|
+
inset: 0,
|
|
499
|
+
display: "flex",
|
|
500
|
+
flexDirection: "column",
|
|
501
|
+
alignItems: "center",
|
|
502
|
+
justifyContent: "center",
|
|
503
|
+
pointerEvents: "none",
|
|
504
|
+
};
|
|
168
505
|
const mediaElementStyle: React.CSSProperties = {
|
|
169
506
|
width: "100%",
|
|
170
507
|
height: "100%",
|
|
@@ -30,8 +30,57 @@ export interface FilePreviewProps {
|
|
|
30
30
|
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
31
31
|
/** View rotation in degrees (0/90/180/270) — applied to image previews only. */
|
|
32
32
|
rotation?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Credentials mode for the byte fetches the PDF / Word / spreadsheet previews
|
|
35
|
+
* make. The host frontend serves files from the auth-gated proxy on a sibling
|
|
36
|
+
* subdomain (`api.lotics.ai`); the session cookie only rides that cross-origin
|
|
37
|
+
* fetch with `"include"`. Custom-code apps fetch presigned URLs — a credentialed
|
|
38
|
+
* request would trip the bucket's CORS — so they omit this and take the default.
|
|
39
|
+
*/
|
|
40
|
+
credentials?: RequestCredentials;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
|
|
36
44
|
return labels ? { ...defaultPreviewLabels, ...labels } : defaultPreviewLabels;
|
|
37
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strings for the full-screen `FileGalleryModal` chrome (toolbar + nav), on top
|
|
49
|
+
* of the inner `PreviewLabels`. Same i18n-free contract: host passes translated
|
|
50
|
+
* strings, English is the fallback.
|
|
51
|
+
*/
|
|
52
|
+
export interface GalleryLabels extends PreviewLabels {
|
|
53
|
+
close: string;
|
|
54
|
+
previous: string;
|
|
55
|
+
next: string;
|
|
56
|
+
/** Accessibility label for the per-row ⋯ menu in `FileRows`. */
|
|
57
|
+
actions: string;
|
|
58
|
+
openExternal: string;
|
|
59
|
+
remove: string;
|
|
60
|
+
removeConfirmTitle: string;
|
|
61
|
+
removeConfirmMessage: (filename: string) => string;
|
|
62
|
+
cancel: string;
|
|
63
|
+
rotateLeft: string;
|
|
64
|
+
rotateRight: string;
|
|
65
|
+
saveRotation: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const defaultGalleryLabels: GalleryLabels = {
|
|
69
|
+
...defaultPreviewLabels,
|
|
70
|
+
close: "Close",
|
|
71
|
+
previous: "Previous",
|
|
72
|
+
next: "Next",
|
|
73
|
+
actions: "Actions",
|
|
74
|
+
openExternal: "Open in new tab",
|
|
75
|
+
remove: "Remove",
|
|
76
|
+
removeConfirmTitle: "Remove file",
|
|
77
|
+
removeConfirmMessage: (filename) => `Remove "${filename}"?`,
|
|
78
|
+
cancel: "Cancel",
|
|
79
|
+
rotateLeft: "Rotate left",
|
|
80
|
+
rotateRight: "Rotate right",
|
|
81
|
+
saveRotation: "Save rotation",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function resolveGalleryLabels(labels: Partial<GalleryLabels> | undefined): GalleryLabels {
|
|
85
|
+
return labels ? { ...defaultGalleryLabels, ...labels } : defaultGalleryLabels;
|
|
86
|
+
}
|
package/src/file_row.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
import { FOCUS_RING } from "./control_surface";
|
|
7
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
8
|
+
|
|
9
|
+
export interface FileRowProps {
|
|
10
|
+
/** The file / document name — the primary line. */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Secondary line: size, status, timestamp, etc. */
|
|
13
|
+
meta?: string;
|
|
14
|
+
/** File type → the FileBadge glyph + color. Omit (with `placeholder`) for an
|
|
15
|
+
* expected-but-not-yet-provided document. */
|
|
16
|
+
mimeType?: string;
|
|
17
|
+
/** Ghost badge — an expected document that hasn't been provided yet. */
|
|
18
|
+
placeholder?: boolean;
|
|
19
|
+
/** Marks the badge as a template (TMPL). */
|
|
20
|
+
isTemplate?: boolean;
|
|
21
|
+
/** When set, the WHOLE row is a pressable "door" (e.g. open the file), with a
|
|
22
|
+
* hover/press wash. Omit for a static line whose only interaction is `trailing`. */
|
|
23
|
+
onPress?: () => void;
|
|
24
|
+
/** Trailing slot — a status `Badge`, an action `Button`, a timestamp, a remove
|
|
25
|
+
* control. An independently-interactive SIBLING: never swallowed by the row
|
|
26
|
+
* press (a button never nests in the door button). */
|
|
27
|
+
trailing?: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A horizontal file / document LINE: a `FileBadge` (or a same-footprint
|
|
32
|
+
* placeholder) + the full name and an optional meta line, with a composable
|
|
33
|
+
* `trailing` slot. For document checklists, requirement lists, and attachment
|
|
34
|
+
* rows where the name must be readable.
|
|
35
|
+
*
|
|
36
|
+
* `onPress` makes the whole row a pressable door; `trailing` stays a sibling, so
|
|
37
|
+
* a button/menu in it is pressed independently (the door + sibling a11y pattern —
|
|
38
|
+
* see `PressableRow`). For square thumbnail tiles use `FileThumbnailGrid` / `FileGrid`.
|
|
39
|
+
*/
|
|
40
|
+
export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress, trailing }: FileRowProps) {
|
|
41
|
+
const [hovered, setHovered] = useState(false);
|
|
42
|
+
const [pressed, setPressed] = useState(false);
|
|
43
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
44
|
+
// Hover via the DOM (not Pressability): RN-Web hands a parent's hover to the
|
|
45
|
+
// innermost nested pressable, which would stop the wash short of `trailing`.
|
|
46
|
+
const mouseProps = {
|
|
47
|
+
onMouseEnter: () => setHovered(true),
|
|
48
|
+
onMouseLeave: () => setHovered(false),
|
|
49
|
+
} as object;
|
|
50
|
+
|
|
51
|
+
const content = (
|
|
52
|
+
<>
|
|
53
|
+
<FileBadge size={30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
|
|
54
|
+
<View style={styles.text}>
|
|
55
|
+
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
56
|
+
{name}
|
|
57
|
+
</Text>
|
|
58
|
+
{meta ? (
|
|
59
|
+
<Text size="xs" color="muted" numberOfLines={1}>
|
|
60
|
+
{meta}
|
|
61
|
+
</Text>
|
|
62
|
+
) : null}
|
|
63
|
+
</View>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (!onPress) {
|
|
68
|
+
return (
|
|
69
|
+
<View style={styles.row}>
|
|
70
|
+
{content}
|
|
71
|
+
{trailing}
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<View
|
|
78
|
+
style={[styles.pressableRow, hovered && styles.rowHovered, pressed && styles.rowPressed]}
|
|
79
|
+
{...mouseProps}
|
|
80
|
+
>
|
|
81
|
+
<Pressable
|
|
82
|
+
onPress={onPress}
|
|
83
|
+
{...focusProps}
|
|
84
|
+
onPressIn={() => setPressed(true)}
|
|
85
|
+
onPressOut={() => setPressed(false)}
|
|
86
|
+
accessibilityRole="button"
|
|
87
|
+
accessibilityLabel={`Open ${name}`}
|
|
88
|
+
style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
89
|
+
>
|
|
90
|
+
{content}
|
|
91
|
+
</Pressable>
|
|
92
|
+
{trailing}
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const styles = StyleSheet.create({
|
|
98
|
+
row: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
99
|
+
pressableRow: {
|
|
100
|
+
flexDirection: "row",
|
|
101
|
+
alignItems: "center",
|
|
102
|
+
gap: 12,
|
|
103
|
+
// Negative horizontal margin cancels the padding for the CONTENT's position
|
|
104
|
+
// (badge stays aligned with static rows / the container edge) while the hover
|
|
105
|
+
// wash bleeds 8px outward into the parent's padding — a hit area with no
|
|
106
|
+
// layout shift. The parent is expected to have ≥8px horizontal padding.
|
|
107
|
+
marginHorizontal: -8,
|
|
108
|
+
paddingHorizontal: 8,
|
|
109
|
+
paddingVertical: 6,
|
|
110
|
+
borderRadius: 8,
|
|
111
|
+
...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
|
|
112
|
+
},
|
|
113
|
+
rowHovered: { backgroundColor: colors.zinc["50"] },
|
|
114
|
+
rowPressed: { backgroundColor: colors.zinc["100"] },
|
|
115
|
+
// The accessible "Open" door — fills the row left of the trailing sibling.
|
|
116
|
+
door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
|
|
117
|
+
text: { flex: 1, gap: 1 },
|
|
118
|
+
});
|