@tangle-network/ui 11.25.0 → 11.26.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.26.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 950b7ca: `./files` gains document primitives. `PdfViewer` renders a PDF's pages to canvas with pdf.js, so a file reads the same on Android Chrome and iOS Safari as on a desktop: a scrolling column of pages with page navigation, zoom, fit-to-width and Download, one toolbar row at 360px, and only the pages near the screen holding a canvas. `FilePreview` and `FileArtifactPane` now show PDFs through it. `pdfjs-dist` (6.x) is a new optional peer, reached only through a caught dynamic import; without it, or when a file will not open, the viewer says why and falls back to the browser's `<object>` viewer and then a download card. `configurePdfViewer({ workerSrc, assetsUrl })` sets the page-wide worker and the decoder assets that scanned pages need; with neither, pdf.js runs on the main thread and no CDN is contacted. `FileCard` describes one file with its kind, size, date, uploader and actions, and replaces FilePreview's private download card. `FileList` is a flat document list whose rows open, download or delete a file and hand the host's own record back.
8
+
3
9
  ## 11.25.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -131,6 +131,28 @@ A TypeScript consumer resolves these entries through the emitted declarations, a
131
131
 
132
132
  Because the peers now resolve at first render rather than at build time, a missing one surfaces as a thrown error while React renders. Wrap the editors in an error boundary, so the install list reaches a surface you control instead of unmounting the tree.
133
133
 
134
+ ### PDF pages
135
+
136
+ `pdfjs-dist` backs `PdfViewer` in `./files`, which `FilePreview` and `FileArtifactPane` use for PDFs. It follows the editor's rule: the entry reaches it only through a caught dynamic `import()`, so a consumer without it builds under Vite and esbuild. Without it, `PdfViewer` says so and shows the browser's own viewer through `<object>`, which Android Chrome does not have, so a reader there gets a download card instead of pages.
137
+
138
+ ```sh
139
+ pnpm add pdfjs-dist
140
+ ```
141
+
142
+ `PdfViewer` imports pdf.js's legacy build, which carries the polyfills that phone browsers a year or two old need. Two settings are page-wide, because pdf.js's own worker setting is. Set them once, before the first viewer renders:
143
+
144
+ ```ts
145
+ import { configurePdfViewer } from "@tangle-network/ui/files";
146
+ import workerSrc from "pdfjs-dist/legacy/build/pdf.worker.min.mjs?url"; // Vite
147
+
148
+ configurePdfViewer({ workerSrc, assetsUrl: "/pdfjs/" });
149
+ ```
150
+
151
+ - `workerSrc` moves parsing into a worker. Without it, the viewer runs pdf.js's worker code on the main thread, which needs no bundler setup and no CDN, at the cost of scrolling while a large file parses.
152
+ - `assetsUrl` is where the app serves the installed pdfjs-dist's `wasm/`, `cmaps/`, `standard_fonts/` and `iccs/` directories. pdf.js 6 decodes JBIG2, CCITT fax and JPEG 2000 images with the wasm modules, and black-and-white scans use the first two, so without it a scanned page renders blank. The viewer warns once in the console when it is missing. Copy the directories at build time; they must come from the same pdfjs-dist version as the worker.
153
+
154
+ An http(s) `src` on another origin needs CORS, as any `fetch` does. When it fails, the viewer falls back to `<object>`, which does not.
155
+
134
156
  `nanostores` and `@nanostores/react` back `./stores`, and `react-router` backs `./nav`.
135
157
  Those entries create values at module scope, so consumers must install their peers before importing them.
136
158
  The package root also re-exports `./stores`, so a root import requires both nanostores peers.
@@ -1,3 +1,4 @@
1
+ import { isMissingPeerError, rethrow } from "../lib/optional-peer.js";
1
2
  //#region src/editor/editor-peers.ts
2
3
  const DOCUMENT_PEERS_MISSING = "@tangle-network/ui/editor needs its optional editor peers. Install @tiptap/react and @tiptap/starter-kit.";
3
4
  const PROVIDER_PEERS_MISSING = "@tangle-network/ui/editor needs its optional collaboration transport peers. Install @hocuspocus/provider and yjs.";
@@ -20,27 +21,6 @@ var MissingEditorPeersError = class extends Error {
20
21
  function isMissingEditorPeersError(error) {
21
22
  return error instanceof Error && error.name === "MissingEditorPeersError";
22
23
  }
23
- /** The messages a bundler or a runtime gives for a module it cannot resolve. */
24
- const RESOLUTION_FAILURE = /could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
25
- /**
26
- * True when the rejection says the package is not installed. Only such an
27
- * error gets the install list: a transient chunk-fetch failure that reads as
28
- * "install the peers" sends the reader to the wrong fix. An error this
29
- * predicate does not match keeps its own message, so it can only
30
- * under-report.
31
- */
32
- function isMissingPeerError(error) {
33
- return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
34
- }
35
- /**
36
- * Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
37
- * literal `import()` as a build error and defers it to run time only when the
38
- * call carries a `.catch()`, so every peer import attaches this handler. It
39
- * changes nothing at run time.
40
- */
41
- function rethrow(error) {
42
- throw error;
43
- }
44
24
  /**
45
25
  * Every optional peer the `./editor` entry needs at run time. The loaders
46
26
  * import all but `@tiptap/core`, which the tiptap packages need in turn: a
@@ -0,0 +1,30 @@
1
+ import "../primitives/icon-tile.js";
2
+ import * as React from "react";
3
+ //#region src/files/file-card.d.ts
4
+ /** Who added a file, as a reader of the list should see them. */
5
+ export interface FileUploader {
6
+ name: string;
7
+ /** Their relationship to the work, e.g. "Client" or "Attorney". */
8
+ role?: string;
9
+ }
10
+ /** What FileCard and FileList show about one file. */
11
+ export interface FileDetails {
12
+ filename: string;
13
+ /** Wins over the extension when choosing the kind, as in FilePreview. */
14
+ mimeType?: string;
15
+ /** Size in bytes. */
16
+ size?: number;
17
+ /** When the file was added or last changed. */
18
+ date?: Date | string | number;
19
+ uploader?: FileUploader;
20
+ }
21
+ export interface FileCardProps extends FileDetails, Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
22
+ /** One sentence under the details, such as why there is no preview. */
23
+ description?: React.ReactNode;
24
+ /** Adds a Download button. */
25
+ onDownload?: () => void;
26
+ /** Further buttons after Download, such as Open or Delete. */
27
+ actions?: React.ReactNode;
28
+ }
29
+ export declare const FileCard: React.ForwardRefExoticComponent<FileCardProps & React.RefAttributes<HTMLDivElement>>;
30
+ //#endregion
@@ -0,0 +1,162 @@
1
+ import { cn } from "../lib/utils.js";
2
+ import { Button } from "../primitives/button.js";
3
+ import { IconTile } from "../primitives/icon-tile.js";
4
+ import { formatBytes } from "../utils/format.js";
5
+ import { getFormatLabel, resolveFilePreviewKind } from "./file-format.js";
6
+ import * as React from "react";
7
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
+ import { Download, File, FileAudio, FileCode, FileImage, FileJson, FileSpreadsheet, FileText, FileVideo } from "lucide-react";
9
+ //#region src/files/file-card.tsx
10
+ /**
11
+ * FileCard — one file, described: its kind, name, size, date and uploader,
12
+ * with Download and any actions the host adds. FilePreview shows it for a file
13
+ * it cannot render inline; a host can show it anywhere a file stands on its own.
14
+ */
15
+ const KIND_ICON = {
16
+ pdf: {
17
+ icon: FileText,
18
+ tone: "orange"
19
+ },
20
+ image: {
21
+ icon: FileImage,
22
+ tone: "pink"
23
+ },
24
+ video: {
25
+ icon: FileVideo,
26
+ tone: "violet"
27
+ },
28
+ audio: {
29
+ icon: FileAudio,
30
+ tone: "cyan"
31
+ },
32
+ csv: {
33
+ icon: FileSpreadsheet,
34
+ tone: "lime"
35
+ },
36
+ spreadsheet: {
37
+ icon: FileSpreadsheet,
38
+ tone: "lime"
39
+ },
40
+ code: {
41
+ icon: FileCode,
42
+ tone: "blue"
43
+ },
44
+ json: {
45
+ icon: FileJson,
46
+ tone: "blue"
47
+ },
48
+ yaml: {
49
+ icon: FileCode,
50
+ tone: "blue"
51
+ },
52
+ markdown: {
53
+ icon: FileText,
54
+ tone: "teal"
55
+ },
56
+ text: {
57
+ icon: FileText,
58
+ tone: "teal"
59
+ },
60
+ binary: {
61
+ icon: File,
62
+ tone: "brown"
63
+ }
64
+ };
65
+ /** The kind's icon on its category tone. Decorative: the filename names the file. */
66
+ function FileKindTile({ filename, mimeType, size = "md", className }) {
67
+ const { icon: Icon, tone } = KIND_ICON[resolveFilePreviewKind(filename, mimeType)];
68
+ return /* @__PURE__ */ jsx(IconTile, {
69
+ tone,
70
+ size,
71
+ icon: /* @__PURE__ */ jsx(Icon, {}),
72
+ className
73
+ });
74
+ }
75
+ function toDate(value) {
76
+ if (value === void 0) return void 0;
77
+ const date = value instanceof Date ? value : new Date(value);
78
+ return Number.isNaN(date.getTime()) ? void 0 : date;
79
+ }
80
+ const DATE_FORMAT = new Intl.DateTimeFormat(void 0, { dateStyle: "medium" });
81
+ /**
82
+ * A dot between facts for the eye, a comma for a screen reader, which would
83
+ * otherwise read the facts as one run-on word or announce "middle dot".
84
+ */
85
+ function FactSeparator() {
86
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
87
+ "aria-hidden": "true",
88
+ children: " · "
89
+ }), /* @__PURE__ */ jsx("span", {
90
+ className: "sr-only",
91
+ children: ", "
92
+ })] });
93
+ }
94
+ /**
95
+ * The kind, size and date as one muted line, each part only when known.
96
+ * The date follows the reader's locale, which a server render may not share,
97
+ * so the element tolerates a hydration difference.
98
+ */
99
+ function FileFacts({ filename, mimeType, size, date, showKind = false, className }) {
100
+ const parsed = toDate(date);
101
+ const parts = [];
102
+ if (showKind) parts.push(getFormatLabel(resolveFilePreviewKind(filename, mimeType)));
103
+ if (size !== void 0) parts.push(formatBytes(size));
104
+ if (parsed) parts.push(/* @__PURE__ */ jsx("time", {
105
+ dateTime: parsed.toISOString(),
106
+ suppressHydrationWarning: true,
107
+ children: DATE_FORMAT.format(parsed)
108
+ }, "date"));
109
+ if (parts.length === 0) return null;
110
+ return /* @__PURE__ */ jsx("span", {
111
+ className: cn("text-xs text-muted-foreground", className),
112
+ children: parts.map((part, index) => /* @__PURE__ */ jsxs(React.Fragment, { children: [index > 0 && /* @__PURE__ */ jsx(FactSeparator, {}), part] }, index))
113
+ });
114
+ }
115
+ /** "Dana Ruiz (Client)", or the name alone. */
116
+ function uploaderLabel(uploader) {
117
+ return uploader.role ? `${uploader.name} (${uploader.role})` : uploader.name;
118
+ }
119
+ const FileCard = React.forwardRef(({ filename, mimeType, size, date, uploader, description, onDownload, actions, className, ...props }, ref) => /* @__PURE__ */ jsxs("div", {
120
+ ref,
121
+ className: cn("flex min-w-0 flex-col items-center justify-center rounded-[var(--radius-md)] border border-border bg-card px-6 py-12 text-center", className),
122
+ ...props,
123
+ children: [
124
+ /* @__PURE__ */ jsx(FileKindTile, {
125
+ filename,
126
+ mimeType,
127
+ size: "lg"
128
+ }),
129
+ /* @__PURE__ */ jsx("p", {
130
+ className: "mt-3 line-clamp-2 max-w-full text-sm font-medium text-foreground [overflow-wrap:anywhere]",
131
+ title: filename,
132
+ children: filename
133
+ }),
134
+ /* @__PURE__ */ jsx(FileFacts, {
135
+ filename,
136
+ size,
137
+ date,
138
+ className: "mt-1"
139
+ }),
140
+ uploader && /* @__PURE__ */ jsxs("p", {
141
+ className: "mt-0.5 max-w-full truncate text-xs text-muted-foreground",
142
+ children: ["Uploaded by ", uploaderLabel(uploader)]
143
+ }),
144
+ description && /* @__PURE__ */ jsx("div", {
145
+ className: "mt-3 max-w-md text-sm text-muted-foreground",
146
+ children: description
147
+ }),
148
+ (onDownload || actions) && /* @__PURE__ */ jsxs("div", {
149
+ className: "mt-5 flex flex-wrap items-center justify-center gap-2",
150
+ children: [onDownload && /* @__PURE__ */ jsxs(Button, {
151
+ type: "button",
152
+ variant: "outline",
153
+ size: "sm",
154
+ onClick: onDownload,
155
+ children: [/* @__PURE__ */ jsx(Download, {}), "Download"]
156
+ }), actions]
157
+ })
158
+ ]
159
+ }));
160
+ FileCard.displayName = "FileCard";
161
+ //#endregion
162
+ export { FactSeparator, FileCard, FileFacts, FileKindTile, uploaderLabel };
@@ -0,0 +1,25 @@
1
+ import { FileDetails } from "./file-card.js";
2
+ import * as React from "react";
3
+ //#region src/files/file-list.d.ts
4
+ export interface FileListItem extends FileDetails {
5
+ /** Stable key, and what `activeId` matches. */
6
+ id: string;
7
+ }
8
+ export interface FileListProps<T extends FileListItem = FileListItem> extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
9
+ files: readonly T[];
10
+ /** Opens a file, usually in a preview. Makes each row a button. */
11
+ onOpen?: (file: T) => void;
12
+ /** Adds a Download button to each row. */
13
+ onDownload?: (file: T) => void;
14
+ /**
15
+ * Adds a Delete button to each row. It calls this at once, so a host that
16
+ * wants a confirmation asks for it here.
17
+ */
18
+ onDelete?: (file: T) => void;
19
+ /** The open file. Its row is marked as the current one. */
20
+ activeId?: string;
21
+ /** Shown in place of the list when `files` is empty. */
22
+ empty?: React.ReactNode;
23
+ }
24
+ export declare function FileList<T extends FileListItem>({ files, onOpen, onDownload, onDelete, activeId, empty, className, ...props }: FileListProps<T>): React.JSX.Element;
25
+ //#endregion
@@ -0,0 +1,88 @@
1
+ import { focusRingInset } from "../lib/focus.js";
2
+ import { cn } from "../lib/utils.js";
3
+ import { Button } from "../primitives/button.js";
4
+ import { FactSeparator, FileFacts, FileKindTile, uploaderLabel } from "./file-card.js";
5
+ import "react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
+ import { Download, Trash2 } from "lucide-react";
8
+ //#region src/files/file-list.tsx
9
+ function FileList({ files, onOpen, onDownload, onDelete, activeId, empty = "No files yet.", className, ...props }) {
10
+ if (files.length === 0) return /* @__PURE__ */ jsx("div", {
11
+ className: cn("rounded-[var(--radius-md)] border border-border bg-card px-4 py-10 text-center text-sm text-muted-foreground", className),
12
+ ...props,
13
+ children: empty
14
+ });
15
+ return /* @__PURE__ */ jsx("ul", {
16
+ className: cn("divide-y divide-border overflow-hidden rounded-[var(--radius-md)] border border-border bg-card", className),
17
+ ...props,
18
+ children: files.map((file) => {
19
+ const active = file.id === activeId;
20
+ const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FileKindTile, {
21
+ filename: file.filename,
22
+ mimeType: file.mimeType
23
+ }), /* @__PURE__ */ jsxs("span", {
24
+ className: "grid min-w-0 flex-1",
25
+ children: [
26
+ /* @__PURE__ */ jsx("span", {
27
+ className: "line-clamp-2 text-sm font-medium text-foreground [overflow-wrap:anywhere] sm:line-clamp-1",
28
+ children: file.filename
29
+ }),
30
+ /* @__PURE__ */ jsxs("span", {
31
+ className: "truncate text-xs text-muted-foreground",
32
+ children: [/* @__PURE__ */ jsx(FileFacts, {
33
+ filename: file.filename,
34
+ mimeType: file.mimeType,
35
+ size: file.size,
36
+ date: file.date,
37
+ showKind: true
38
+ }), file.uploader && /* @__PURE__ */ jsxs("span", {
39
+ className: "hidden sm:inline",
40
+ children: [/* @__PURE__ */ jsx(FactSeparator, {}), uploaderLabel(file.uploader)]
41
+ })]
42
+ }),
43
+ file.uploader && /* @__PURE__ */ jsx("span", {
44
+ className: "truncate text-xs text-muted-foreground sm:hidden",
45
+ children: uploaderLabel(file.uploader)
46
+ })
47
+ ]
48
+ })] });
49
+ return /* @__PURE__ */ jsxs("li", {
50
+ className: cn("flex items-center gap-1 pr-2", active ? "bg-primary/10" : onOpen && "hover:bg-[var(--bg-hover)]"),
51
+ children: [
52
+ onOpen ? /* @__PURE__ */ jsx("button", {
53
+ type: "button",
54
+ onClick: () => onOpen(file),
55
+ "aria-current": active ? "true" : void 0,
56
+ className: cn("flex min-w-0 flex-1 items-center gap-3 py-2.5 pl-3 text-left", focusRingInset),
57
+ children: body
58
+ }) : /* @__PURE__ */ jsx("div", {
59
+ "aria-current": active ? "true" : void 0,
60
+ className: "flex min-w-0 flex-1 items-center gap-3 py-2.5 pl-3",
61
+ children: body
62
+ }),
63
+ onDownload && /* @__PURE__ */ jsx(Button, {
64
+ type: "button",
65
+ variant: "ghost",
66
+ size: "icon",
67
+ "aria-label": `Download ${file.filename}`,
68
+ title: "Download",
69
+ onClick: () => onDownload(file),
70
+ children: /* @__PURE__ */ jsx(Download, {})
71
+ }),
72
+ onDelete && /* @__PURE__ */ jsx(Button, {
73
+ type: "button",
74
+ variant: "ghost",
75
+ size: "icon",
76
+ "aria-label": `Delete ${file.filename}`,
77
+ title: "Delete",
78
+ className: "hover:text-[var(--surface-danger-text)]",
79
+ onClick: () => onDelete(file),
80
+ children: /* @__PURE__ */ jsx(Trash2, {})
81
+ })
82
+ ]
83
+ }, file.id);
84
+ })
85
+ });
86
+ }
87
+ //#endregion
88
+ export { FileList };
@@ -1,19 +1,19 @@
1
1
  import { focusRingInset } from "../lib/focus.js";
2
2
  import { cn } from "../lib/utils.js";
3
- import { Button } from "../primitives/button.js";
4
3
  import { CodeBlock, CopyButton } from "../markdown/code-block.js";
5
- import { formatBytes } from "../utils/format.js";
6
4
  import { fileExtension, getCodeLanguage, getFormatLabel, resolveFilePreviewKind } from "./file-format.js";
7
5
  import { Markdown } from "../markdown/markdown.js";
6
+ import { FileCard } from "./file-card.js";
7
+ import { PdfViewer } from "./pdf-viewer.js";
8
8
  import { useState } from "react";
9
9
  import { jsx, jsxs } from "react/jsx-runtime";
10
- import { Download, FileSpreadsheet, FileText, Music, X } from "lucide-react";
10
+ import { Download, Music, X } from "lucide-react";
11
11
  //#region src/files/file-preview.tsx
12
12
  /**
13
13
  * FilePreview — universal file renderer.
14
14
  *
15
15
  * Renders any file type:
16
- * - PDF: embedded viewer at full pane height
16
+ * - PDF: PdfViewer's rendered pages at full pane height
17
17
  * - Images: fit to the pane on a checker ground; click toggles natural size
18
18
  * - Video / audio: native players
19
19
  * - CSV: sticky-header table, capped at CSV_PREVIEW_ROW_LIMIT rows
@@ -146,21 +146,6 @@ function ImagePreview({ src, filename }) {
146
146
  })
147
147
  });
148
148
  }
149
- function PdfPreview({ blobUrl, filename, size, onDownload }) {
150
- return /* @__PURE__ */ jsx("object", {
151
- data: blobUrl,
152
- type: "application/pdf",
153
- title: filename,
154
- className: "min-h-[24rem] w-full flex-1 rounded-[var(--radius-md)]",
155
- children: /* @__PURE__ */ jsx(DownloadCard, {
156
- filename,
157
- size,
158
- sentence: "This browser does not show PDFs inline.",
159
- onDownload,
160
- className: "h-full"
161
- })
162
- });
163
- }
164
149
  function VideoPreview({ src, filename }) {
165
150
  return /* @__PURE__ */ jsx("div", {
166
151
  className: "flex min-h-[12rem] flex-1 items-center justify-center overflow-hidden rounded-[var(--radius-md)] border border-border bg-background",
@@ -207,55 +192,23 @@ function MarkdownPreview({ content, urlTransform }) {
207
192
  })
208
193
  });
209
194
  }
210
- /**
211
- * One muted sentence plus the filename, its size when known, and a Download
212
- * button when the host wires `onDownload`. Serves spreadsheets, binaries, and
213
- * every kind whose source (blob or text) is missing.
214
- */
215
- function DownloadCard({ filename, size, sentence, icon: Icon = FileText, onDownload, className }) {
216
- return /* @__PURE__ */ jsxs("div", {
217
- className: cn("flex flex-col items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-background px-6 py-16 text-center", className),
218
- children: [
219
- /* @__PURE__ */ jsx(Icon, { className: "mb-3 h-12 w-12 text-muted-foreground opacity-40" }),
220
- /* @__PURE__ */ jsx("p", {
221
- className: "max-w-full truncate text-sm font-medium text-foreground",
222
- children: filename
223
- }),
224
- size !== void 0 && /* @__PURE__ */ jsx("p", {
225
- className: "mt-1 text-xs text-muted-foreground",
226
- children: formatBytes(size)
227
- }),
228
- /* @__PURE__ */ jsx("p", {
229
- className: "mt-3 max-w-md text-sm text-muted-foreground",
230
- children: sentence
231
- }),
232
- onDownload && /* @__PURE__ */ jsxs(Button, {
233
- type: "button",
234
- variant: "outline",
235
- size: "sm",
236
- className: "mt-5",
237
- onClick: onDownload,
238
- children: [/* @__PURE__ */ jsx(Download, {}), "Download"]
239
- })
240
- ]
241
- });
242
- }
243
- function PreviewBody({ kind, filename, content, blobUrl, size, urlTransform, onDownload }) {
195
+ function PreviewBody({ kind, filename, mimeType, content, blobUrl, size, urlTransform, onDownload }) {
244
196
  const hasText = typeof content === "string";
245
- const card = (sentence, icon) => /* @__PURE__ */ jsx(DownloadCard, {
197
+ const card = (sentence) => /* @__PURE__ */ jsx(FileCard, {
246
198
  filename,
199
+ mimeType,
247
200
  size,
248
- sentence,
249
- icon,
201
+ description: sentence,
250
202
  onDownload
251
203
  });
252
204
  switch (kind) {
253
205
  case "pdf":
254
- if (blobUrl) return /* @__PURE__ */ jsx(PdfPreview, {
255
- blobUrl,
206
+ if (blobUrl) return /* @__PURE__ */ jsx(PdfViewer, {
207
+ src: blobUrl,
256
208
  filename,
257
209
  size,
258
- onDownload
210
+ onDownload,
211
+ className: "min-h-[24rem] flex-1"
259
212
  });
260
213
  return card(NEEDS_DOWNLOAD_LINK);
261
214
  case "image":
@@ -280,10 +233,10 @@ function PreviewBody({ kind, filename, content, blobUrl, size, urlTransform, onD
280
233
  src: blobUrl,
281
234
  filename
282
235
  });
283
- return card(NEEDS_DOWNLOAD_LINK, Music);
236
+ return card(NEEDS_DOWNLOAD_LINK);
284
237
  case "csv":
285
238
  if (hasText) return /* @__PURE__ */ jsx(CsvPreview, { content });
286
- return card(NO_INLINE_CONTENT, FileSpreadsheet);
239
+ return card(NO_INLINE_CONTENT);
287
240
  case "code":
288
241
  case "json":
289
242
  case "yaml":
@@ -302,7 +255,7 @@ function PreviewBody({ kind, filename, content, blobUrl, size, urlTransform, onD
302
255
  case "text":
303
256
  if (hasText) return /* @__PURE__ */ jsx(TextPreview, { content });
304
257
  return card(NO_INLINE_CONTENT);
305
- case "spreadsheet": return card("Download to open this workbook in a spreadsheet app.", FileSpreadsheet);
258
+ case "spreadsheet": return card("Download to open this workbook in a spreadsheet app.");
306
259
  case "binary":
307
260
  if (hasText) return /* @__PURE__ */ jsx(TextPreview, { content });
308
261
  return card(blobUrl ? NO_INLINE_PREVIEW : NEEDS_DOWNLOAD_LINK);
@@ -346,6 +299,7 @@ function FilePreview({ filename, content, blobUrl, mimeType, size, urlTransform,
346
299
  children: /* @__PURE__ */ jsx(PreviewBody, {
347
300
  kind,
348
301
  filename,
302
+ mimeType,
349
303
  content,
350
304
  blobUrl,
351
305
  size,
@@ -0,0 +1,22 @@
1
+ import "pdfjs-dist";
2
+ //#region src/files/pdf-loader.d.ts
3
+ export interface PdfViewerConfig {
4
+ /**
5
+ * URL of `pdfjs-dist/legacy/build/pdf.worker.min.mjs` from the installed
6
+ * pdfjs-dist, served by the app. Moves PDF parsing off the main thread.
7
+ */
8
+ workerSrc?: string;
9
+ /**
10
+ * Base URL, ending in `/`, under which the app serves the `wasm/`, `cmaps/`,
11
+ * `standard_fonts/` and `iccs/` directories of the installed pdfjs-dist.
12
+ * Needed for scanned pages, CJK text and non-embedded fonts.
13
+ */
14
+ assetsUrl?: string;
15
+ }
16
+ /**
17
+ * Sets the page-wide pdf.js worker and asset locations. Call it once, before
18
+ * the first `PdfViewer` renders; a worker set afterwards applies only when no
19
+ * document has loaded the main-thread worker yet.
20
+ */
21
+ export declare function configurePdfViewer(next: PdfViewerConfig): void;
22
+ //#endregion
@@ -0,0 +1,82 @@
1
+ import { isMissingPeerError, rethrow } from "../lib/optional-peer.js";
2
+ //#region src/files/pdf-loader.ts
3
+ let config = {};
4
+ /**
5
+ * Sets the page-wide pdf.js worker and asset locations. Call it once, before
6
+ * the first `PdfViewer` renders; a worker set afterwards applies only when no
7
+ * document has loaded the main-thread worker yet.
8
+ */
9
+ function configurePdfViewer(next) {
10
+ config = { ...next };
11
+ }
12
+ /**
13
+ * The optional peer behind `PdfViewer`. `scripts/validate-dist.mjs` reads this
14
+ * list and requires every import of it to be dynamic and caught.
15
+ */
16
+ const DEFERRED_PEERS = ["pdfjs-dist"];
17
+ /** pdfjs-dist is absent, or resolved to a stub with none of its API. */
18
+ var MissingPdfjsError = class extends Error {
19
+ constructor(options) {
20
+ super("PdfViewer needs the optional peer pdfjs-dist. Install pdfjs-dist to render PDF pages.", options);
21
+ this.name = "MissingPdfjsError";
22
+ }
23
+ };
24
+ function isMissingPdfjsError(error) {
25
+ return error instanceof Error && error.name === "MissingPdfjsError";
26
+ }
27
+ /**
28
+ * Turns a resolution failure that names pdfjs-dist into `MissingPdfjsError`.
29
+ * Any other rejection, such as a chunk that did not download, passes through.
30
+ */
31
+ function asMissingPdfjsError(error) {
32
+ if (!isMissingPeerError(error)) return error;
33
+ const message = error.message;
34
+ return DEFERRED_PEERS.some((name) => message.includes(name)) ? new MissingPdfjsError({ cause: error }) : error;
35
+ }
36
+ async function importPdfjs() {
37
+ let pdfjs;
38
+ try {
39
+ pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs").catch(rethrow);
40
+ } catch (error) {
41
+ throw asMissingPdfjsError(error);
42
+ }
43
+ if (typeof pdfjs.getDocument !== "function") throw new MissingPdfjsError();
44
+ return pdfjs;
45
+ }
46
+ let pending = null;
47
+ let warnedAboutAssets = false;
48
+ /**
49
+ * Resolves pdf.js with its worker configured. A missing peer stays missing for
50
+ * the session, so its rejection is kept; any other failure, such as a chunk
51
+ * that did not download, is dropped so the next viewer tries again.
52
+ */
53
+ function loadPdfjs() {
54
+ pending ??= importPdfjs().catch((error) => {
55
+ if (!isMissingPdfjsError(error)) pending = null;
56
+ throw error;
57
+ });
58
+ return pending.then(async (pdfjs) => {
59
+ const { GlobalWorkerOptions } = pdfjs;
60
+ if (config.workerSrc) GlobalWorkerOptions.workerSrc = config.workerSrc;
61
+ else if (!GlobalWorkerOptions.workerSrc && !GlobalWorkerOptions.workerPort) await import("pdfjs-dist/legacy/build/pdf.worker.min.mjs").catch(rethrow);
62
+ if (!config.assetsUrl && !warnedAboutAssets) {
63
+ warnedAboutAssets = true;
64
+ console.warn("PdfViewer: no assetsUrl is configured, so JBIG2, CCITT fax and JPEG 2000 images (most black-and-white scans) will not render. See configurePdfViewer.");
65
+ }
66
+ return pdfjs;
67
+ });
68
+ }
69
+ /** `getDocument` asset options for the configured `assetsUrl`, or none. */
70
+ function pdfAssetParams() {
71
+ if (!config.assetsUrl) return {};
72
+ const base = new URL(config.assetsUrl, document.baseURI).href.replace(/\/?$/, "/");
73
+ return {
74
+ wasmUrl: `${base}wasm/`,
75
+ cMapUrl: `${base}cmaps/`,
76
+ cMapPacked: true,
77
+ standardFontDataUrl: `${base}standard_fonts/`,
78
+ iccUrl: `${base}iccs/`
79
+ };
80
+ }
81
+ //#endregion
82
+ export { MissingPdfjsError, asMissingPdfjsError, configurePdfViewer, isMissingPdfjsError, loadPdfjs, pdfAssetParams };
@@ -0,0 +1,32 @@
1
+ //#region src/files/pdf-viewer.d.ts
2
+ /**
3
+ * PdfViewer — renders a PDF's pages to canvas with pdf.js, so a document reads
4
+ * the same in every browser, phones included. A continuous column of pages
5
+ * with page navigation, zoom and fit-to-width, and Download.
6
+ *
7
+ * Only pages within a screen of the visible area hold a canvas; the rest keep
8
+ * their size and release theirs, so a long document stays inside the canvas
9
+ * memory a phone allows.
10
+ *
11
+ * When pdf.js is not installed or cannot open the file, the viewer says so
12
+ * and falls back to the browser's own viewer through `<object>`, and from
13
+ * there to a FileCard where the browser has none (Android Chrome).
14
+ */
15
+ export interface PdfViewerProps {
16
+ /**
17
+ * The PDF: an object URL, a data URL, or an http(s) URL. A URL on another
18
+ * origin must allow this one through CORS; when it does not, the viewer
19
+ * falls back to the browser's viewer.
20
+ */
21
+ src: string;
22
+ /** Names the document for assistive technology and the fallback card. */
23
+ filename?: string;
24
+ /** Size in bytes, shown on the fallback card. */
25
+ size?: number;
26
+ /** Adds a Download button to the toolbar and the fallback card. */
27
+ onDownload?: () => void;
28
+ /** Give the viewer a height; it scrolls its pages inside it. */
29
+ className?: string;
30
+ }
31
+ export declare function PdfViewer({ src, filename, size, onDownload, className }: PdfViewerProps): import("react").JSX.Element;
32
+ //#endregion