@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 +6 -0
- package/README.md +22 -0
- package/dist/editor/editor-peers.js +1 -21
- package/dist/files/file-card.d.ts +30 -0
- package/dist/files/file-card.js +162 -0
- package/dist/files/file-list.d.ts +25 -0
- package/dist/files/file-list.js +88 -0
- package/dist/files/file-preview.js +16 -62
- package/dist/files/pdf-loader.d.ts +22 -0
- package/dist/files/pdf-loader.js +82 -0
- package/dist/files/pdf-viewer.d.ts +32 -0
- package/dist/files/pdf-viewer.js +567 -0
- package/dist/files.d.ts +5 -1
- package/dist/files.js +5 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -1
- package/dist/lib/optional-peer.js +29 -0
- package/dist/primitives.d.ts +2 -2
- package/package.json +6 -1
- package/src/editor/editor-peers.ts +3 -25
- package/src/files/file-artifact-pane.test.tsx +12 -2
- package/src/files/file-card.test.tsx +62 -0
- package/src/files/file-card.tsx +210 -0
- package/src/files/file-list.stories.tsx +121 -0
- package/src/files/file-list.test.tsx +81 -0
- package/src/files/file-list.tsx +167 -0
- package/src/files/file-preview.tsx +25 -94
- package/src/files/index.ts +9 -0
- package/src/files/pdf-loader.test.ts +130 -0
- package/src/files/pdf-loader.ts +169 -0
- package/src/files/pdf-viewer.stories.tsx +81 -0
- package/src/files/pdf-viewer.test.tsx +245 -0
- package/src/files/pdf-viewer.tsx +677 -0
- package/src/files/pdfjs-worker.d.ts +3 -0
- package/src/lib/optional-peer.ts +30 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileList — a flat list of documents: each row names the file and its kind,
|
|
3
|
+
* size, date and uploader; selecting the row opens it, and the row's own
|
|
4
|
+
* buttons download or delete it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import { Download, Trash2 } from "lucide-react";
|
|
9
|
+
import { focusRingInset } from "../lib/focus";
|
|
10
|
+
import { cn } from "../lib/utils";
|
|
11
|
+
import { Button } from "../primitives/button";
|
|
12
|
+
import {
|
|
13
|
+
FactSeparator,
|
|
14
|
+
FileFacts,
|
|
15
|
+
FileKindTile,
|
|
16
|
+
uploaderLabel,
|
|
17
|
+
type FileDetails,
|
|
18
|
+
} from "./file-card";
|
|
19
|
+
|
|
20
|
+
export interface FileListItem extends FileDetails {
|
|
21
|
+
/** Stable key, and what `activeId` matches. */
|
|
22
|
+
id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FileListProps<T extends FileListItem = FileListItem>
|
|
26
|
+
extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
|
|
27
|
+
files: readonly T[];
|
|
28
|
+
/** Opens a file, usually in a preview. Makes each row a button. */
|
|
29
|
+
onOpen?: (file: T) => void;
|
|
30
|
+
/** Adds a Download button to each row. */
|
|
31
|
+
onDownload?: (file: T) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Adds a Delete button to each row. It calls this at once, so a host that
|
|
34
|
+
* wants a confirmation asks for it here.
|
|
35
|
+
*/
|
|
36
|
+
onDelete?: (file: T) => void;
|
|
37
|
+
/** The open file. Its row is marked as the current one. */
|
|
38
|
+
activeId?: string;
|
|
39
|
+
/** Shown in place of the list when `files` is empty. */
|
|
40
|
+
empty?: React.ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function FileList<T extends FileListItem>({
|
|
44
|
+
files,
|
|
45
|
+
onOpen,
|
|
46
|
+
onDownload,
|
|
47
|
+
onDelete,
|
|
48
|
+
activeId,
|
|
49
|
+
empty = "No files yet.",
|
|
50
|
+
className,
|
|
51
|
+
...props
|
|
52
|
+
}: FileListProps<T>) {
|
|
53
|
+
if (files.length === 0) {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className={cn(
|
|
57
|
+
"rounded-[var(--radius-md)] border border-border bg-card px-4 py-10 text-center text-sm text-muted-foreground",
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
>
|
|
62
|
+
{empty}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<ul
|
|
69
|
+
className={cn(
|
|
70
|
+
"divide-y divide-border overflow-hidden rounded-[var(--radius-md)] border border-border bg-card",
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
{...props}
|
|
74
|
+
>
|
|
75
|
+
{files.map((file) => {
|
|
76
|
+
const active = file.id === activeId;
|
|
77
|
+
const body = (
|
|
78
|
+
<>
|
|
79
|
+
<FileKindTile filename={file.filename} mimeType={file.mimeType} />
|
|
80
|
+
{/* On a phone the name may take two lines and the uploader takes
|
|
81
|
+
its own, since one line of facts cannot hold who sent the file. */}
|
|
82
|
+
<span className="grid min-w-0 flex-1">
|
|
83
|
+
<span className="line-clamp-2 text-sm font-medium text-foreground [overflow-wrap:anywhere] sm:line-clamp-1">
|
|
84
|
+
{file.filename}
|
|
85
|
+
</span>
|
|
86
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
87
|
+
<FileFacts
|
|
88
|
+
filename={file.filename}
|
|
89
|
+
mimeType={file.mimeType}
|
|
90
|
+
size={file.size}
|
|
91
|
+
date={file.date}
|
|
92
|
+
showKind
|
|
93
|
+
/>
|
|
94
|
+
{file.uploader && (
|
|
95
|
+
<span className="hidden sm:inline">
|
|
96
|
+
<FactSeparator />
|
|
97
|
+
{uploaderLabel(file.uploader)}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
</span>
|
|
101
|
+
{file.uploader && (
|
|
102
|
+
<span className="truncate text-xs text-muted-foreground sm:hidden">
|
|
103
|
+
{uploaderLabel(file.uploader)}
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
</span>
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
return (
|
|
110
|
+
<li
|
|
111
|
+
key={file.id}
|
|
112
|
+
className={cn(
|
|
113
|
+
"flex items-center gap-1 pr-2",
|
|
114
|
+
active ? "bg-primary/10" : onOpen && "hover:bg-[var(--bg-hover)]",
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{onOpen ? (
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => onOpen(file)}
|
|
121
|
+
aria-current={active ? "true" : undefined}
|
|
122
|
+
className={cn(
|
|
123
|
+
"flex min-w-0 flex-1 items-center gap-3 py-2.5 pl-3 text-left",
|
|
124
|
+
focusRingInset,
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
{body}
|
|
128
|
+
</button>
|
|
129
|
+
) : (
|
|
130
|
+
<div
|
|
131
|
+
aria-current={active ? "true" : undefined}
|
|
132
|
+
className="flex min-w-0 flex-1 items-center gap-3 py-2.5 pl-3"
|
|
133
|
+
>
|
|
134
|
+
{body}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
{onDownload && (
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
size="icon"
|
|
142
|
+
aria-label={`Download ${file.filename}`}
|
|
143
|
+
title="Download"
|
|
144
|
+
onClick={() => onDownload(file)}
|
|
145
|
+
>
|
|
146
|
+
<Download />
|
|
147
|
+
</Button>
|
|
148
|
+
)}
|
|
149
|
+
{onDelete && (
|
|
150
|
+
<Button
|
|
151
|
+
type="button"
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="icon"
|
|
154
|
+
aria-label={`Delete ${file.filename}`}
|
|
155
|
+
title="Delete"
|
|
156
|
+
className="hover:text-[var(--surface-danger-text)]"
|
|
157
|
+
onClick={() => onDelete(file)}
|
|
158
|
+
>
|
|
159
|
+
<Trash2 />
|
|
160
|
+
</Button>
|
|
161
|
+
)}
|
|
162
|
+
</li>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</ul>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* FilePreview — universal file renderer.
|
|
3
3
|
*
|
|
4
4
|
* Renders any file type:
|
|
5
|
-
* - PDF:
|
|
5
|
+
* - PDF: PdfViewer's rendered pages at full pane height
|
|
6
6
|
* - Images: fit to the pane on a checker ground; click toggles natural size
|
|
7
7
|
* - Video / audio: native players
|
|
8
8
|
* - CSV: sticky-header table, capped at CSV_PREVIEW_ROW_LIMIT rows
|
|
@@ -14,21 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { useState } from "react";
|
|
17
|
-
import {
|
|
18
|
-
Download,
|
|
19
|
-
FileSpreadsheet,
|
|
20
|
-
FileText,
|
|
21
|
-
Music,
|
|
22
|
-
X,
|
|
23
|
-
type LucideIcon,
|
|
24
|
-
} from "lucide-react";
|
|
17
|
+
import { Download, Music, X } from "lucide-react";
|
|
25
18
|
import type { UrlTransform } from "react-markdown";
|
|
26
19
|
import { focusRingInset } from "../lib/focus";
|
|
27
20
|
import { cn } from "../lib/utils";
|
|
28
|
-
import { Button } from "../primitives/button";
|
|
29
21
|
import { Markdown } from "../markdown/markdown";
|
|
30
22
|
import { CodeBlock, CopyButton } from "../markdown/code-block";
|
|
31
|
-
import {
|
|
23
|
+
import { FileCard } from "./file-card";
|
|
32
24
|
import {
|
|
33
25
|
fileExtension,
|
|
34
26
|
getCodeLanguage,
|
|
@@ -36,6 +28,7 @@ import {
|
|
|
36
28
|
resolveFilePreviewKind,
|
|
37
29
|
type FilePreviewKind,
|
|
38
30
|
} from "./file-format";
|
|
31
|
+
import { PdfViewer } from "./pdf-viewer";
|
|
39
32
|
|
|
40
33
|
export interface FilePreviewProps {
|
|
41
34
|
filename: string;
|
|
@@ -239,38 +232,6 @@ function ImagePreview({ src, filename }: { src: string; filename: string }) {
|
|
|
239
232
|
);
|
|
240
233
|
}
|
|
241
234
|
|
|
242
|
-
function PdfPreview({
|
|
243
|
-
blobUrl,
|
|
244
|
-
filename,
|
|
245
|
-
size,
|
|
246
|
-
onDownload,
|
|
247
|
-
}: {
|
|
248
|
-
blobUrl: string;
|
|
249
|
-
filename: string;
|
|
250
|
-
size?: number;
|
|
251
|
-
onDownload?: () => void;
|
|
252
|
-
}) {
|
|
253
|
-
// <object> lets a browser without an inline PDF viewer render the fallback
|
|
254
|
-
// card instead of a blank frame. The viewer draws its own chrome, so the
|
|
255
|
-
// object has no border; the card brings its own and fills the same height.
|
|
256
|
-
return (
|
|
257
|
-
<object
|
|
258
|
-
data={blobUrl}
|
|
259
|
-
type="application/pdf"
|
|
260
|
-
title={filename}
|
|
261
|
-
className="min-h-[24rem] w-full flex-1 rounded-[var(--radius-md)]"
|
|
262
|
-
>
|
|
263
|
-
<DownloadCard
|
|
264
|
-
filename={filename}
|
|
265
|
-
size={size}
|
|
266
|
-
sentence="This browser does not show PDFs inline."
|
|
267
|
-
onDownload={onDownload}
|
|
268
|
-
className="h-full"
|
|
269
|
-
/>
|
|
270
|
-
</object>
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
235
|
function VideoPreview({ src, filename }: { src: string; filename: string }) {
|
|
275
236
|
return (
|
|
276
237
|
<div className="flex min-h-[12rem] flex-1 items-center justify-center overflow-hidden rounded-[var(--radius-md)] border border-border bg-background">
|
|
@@ -318,52 +279,10 @@ function MarkdownPreview({
|
|
|
318
279
|
);
|
|
319
280
|
}
|
|
320
281
|
|
|
321
|
-
/**
|
|
322
|
-
* One muted sentence plus the filename, its size when known, and a Download
|
|
323
|
-
* button when the host wires `onDownload`. Serves spreadsheets, binaries, and
|
|
324
|
-
* every kind whose source (blob or text) is missing.
|
|
325
|
-
*/
|
|
326
|
-
function DownloadCard({
|
|
327
|
-
filename,
|
|
328
|
-
size,
|
|
329
|
-
sentence,
|
|
330
|
-
icon: Icon = FileText,
|
|
331
|
-
onDownload,
|
|
332
|
-
className,
|
|
333
|
-
}: {
|
|
334
|
-
filename: string;
|
|
335
|
-
size?: number;
|
|
336
|
-
sentence: string;
|
|
337
|
-
icon?: LucideIcon;
|
|
338
|
-
onDownload?: () => void;
|
|
339
|
-
className?: string;
|
|
340
|
-
}) {
|
|
341
|
-
return (
|
|
342
|
-
<div
|
|
343
|
-
className={cn(
|
|
344
|
-
"flex flex-col items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-background px-6 py-16 text-center",
|
|
345
|
-
className,
|
|
346
|
-
)}
|
|
347
|
-
>
|
|
348
|
-
<Icon className="mb-3 h-12 w-12 text-muted-foreground opacity-40" />
|
|
349
|
-
<p className="max-w-full truncate text-sm font-medium text-foreground">{filename}</p>
|
|
350
|
-
{size !== undefined && (
|
|
351
|
-
<p className="mt-1 text-xs text-muted-foreground">{formatBytes(size)}</p>
|
|
352
|
-
)}
|
|
353
|
-
<p className="mt-3 max-w-md text-sm text-muted-foreground">{sentence}</p>
|
|
354
|
-
{onDownload && (
|
|
355
|
-
<Button type="button" variant="outline" size="sm" className="mt-5" onClick={onDownload}>
|
|
356
|
-
<Download />
|
|
357
|
-
Download
|
|
358
|
-
</Button>
|
|
359
|
-
)}
|
|
360
|
-
</div>
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
282
|
function PreviewBody({
|
|
365
283
|
kind,
|
|
366
284
|
filename,
|
|
285
|
+
mimeType,
|
|
367
286
|
content,
|
|
368
287
|
blobUrl,
|
|
369
288
|
size,
|
|
@@ -372,6 +291,7 @@ function PreviewBody({
|
|
|
372
291
|
}: {
|
|
373
292
|
kind: FilePreviewKind;
|
|
374
293
|
filename: string;
|
|
294
|
+
mimeType?: string;
|
|
375
295
|
content?: string;
|
|
376
296
|
blobUrl?: string;
|
|
377
297
|
size?: number;
|
|
@@ -379,12 +299,14 @@ function PreviewBody({
|
|
|
379
299
|
onDownload?: () => void;
|
|
380
300
|
}) {
|
|
381
301
|
const hasText = typeof content === "string";
|
|
382
|
-
|
|
383
|
-
|
|
302
|
+
// The card takes its icon from the kind, so a spreadsheet or a recording
|
|
303
|
+
// reads as one even when it cannot be shown.
|
|
304
|
+
const card = (sentence: string) => (
|
|
305
|
+
<FileCard
|
|
384
306
|
filename={filename}
|
|
307
|
+
mimeType={mimeType}
|
|
385
308
|
size={size}
|
|
386
|
-
|
|
387
|
-
icon={icon}
|
|
309
|
+
description={sentence}
|
|
388
310
|
onDownload={onDownload}
|
|
389
311
|
/>
|
|
390
312
|
);
|
|
@@ -392,7 +314,15 @@ function PreviewBody({
|
|
|
392
314
|
switch (kind) {
|
|
393
315
|
case "pdf":
|
|
394
316
|
if (blobUrl) {
|
|
395
|
-
return
|
|
317
|
+
return (
|
|
318
|
+
<PdfViewer
|
|
319
|
+
src={blobUrl}
|
|
320
|
+
filename={filename}
|
|
321
|
+
size={size}
|
|
322
|
+
onDownload={onDownload}
|
|
323
|
+
className="min-h-[24rem] flex-1"
|
|
324
|
+
/>
|
|
325
|
+
);
|
|
396
326
|
}
|
|
397
327
|
return card(NEEDS_DOWNLOAD_LINK);
|
|
398
328
|
case "image":
|
|
@@ -405,10 +335,10 @@ function PreviewBody({
|
|
|
405
335
|
return card(NEEDS_DOWNLOAD_LINK);
|
|
406
336
|
case "audio":
|
|
407
337
|
if (blobUrl) return <AudioPreview src={blobUrl} filename={filename} />;
|
|
408
|
-
return card(NEEDS_DOWNLOAD_LINK
|
|
338
|
+
return card(NEEDS_DOWNLOAD_LINK);
|
|
409
339
|
case "csv":
|
|
410
340
|
if (hasText) return <CsvPreview content={content} />;
|
|
411
|
-
return card(NO_INLINE_CONTENT
|
|
341
|
+
return card(NO_INLINE_CONTENT);
|
|
412
342
|
case "code":
|
|
413
343
|
case "json":
|
|
414
344
|
case "yaml":
|
|
@@ -421,7 +351,7 @@ function PreviewBody({
|
|
|
421
351
|
if (hasText) return <TextPreview content={content} />;
|
|
422
352
|
return card(NO_INLINE_CONTENT);
|
|
423
353
|
case "spreadsheet":
|
|
424
|
-
return card("Download to open this workbook in a spreadsheet app."
|
|
354
|
+
return card("Download to open this workbook in a spreadsheet app.");
|
|
425
355
|
case "binary":
|
|
426
356
|
if (hasText) return <TextPreview content={content} />;
|
|
427
357
|
return card(blobUrl ? NO_INLINE_PREVIEW : NEEDS_DOWNLOAD_LINK);
|
|
@@ -482,6 +412,7 @@ export function FilePreview({
|
|
|
482
412
|
<PreviewBody
|
|
483
413
|
kind={kind}
|
|
484
414
|
filename={filename}
|
|
415
|
+
mimeType={mimeType}
|
|
485
416
|
content={content}
|
|
486
417
|
blobUrl={blobUrl}
|
|
487
418
|
size={size}
|
package/src/files/index.ts
CHANGED
|
@@ -18,6 +18,15 @@ export {
|
|
|
18
18
|
parseCsv,
|
|
19
19
|
type FilePreviewProps,
|
|
20
20
|
} from "./file-preview";
|
|
21
|
+
export { PdfViewer, type PdfViewerProps } from "./pdf-viewer";
|
|
22
|
+
export { configurePdfViewer, type PdfViewerConfig } from "./pdf-loader";
|
|
23
|
+
export {
|
|
24
|
+
FileCard,
|
|
25
|
+
type FileCardProps,
|
|
26
|
+
type FileDetails,
|
|
27
|
+
type FileUploader,
|
|
28
|
+
} from "./file-card";
|
|
29
|
+
export { FileList, type FileListProps, type FileListItem } from "./file-list";
|
|
21
30
|
export { FileTabs, type FileTabsProps, type FileTabData } from "./file-tabs";
|
|
22
31
|
export { FileArtifactPane, type FileArtifactPaneProps } from "./file-artifact-pane";
|
|
23
32
|
export {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const PDFJS = "pdfjs-dist/legacy/build/pdf.mjs";
|
|
4
|
+
const WORKER = "pdfjs-dist/legacy/build/pdf.worker.min.mjs";
|
|
5
|
+
|
|
6
|
+
/** What the next import of pdf.js yields: a namespace, or a thrown error. */
|
|
7
|
+
let pdfjsImport: () => Record<string, unknown>;
|
|
8
|
+
let workerImports = 0;
|
|
9
|
+
|
|
10
|
+
function installedPdfjs() {
|
|
11
|
+
return { getDocument: () => ({}), GlobalWorkerOptions: { workerSrc: "", workerPort: null } };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loads the module under test with pdf.js replaced. The loader reads it through
|
|
16
|
+
* a dynamic import, so the substitution happens before the graph is built.
|
|
17
|
+
*/
|
|
18
|
+
async function loadModule() {
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
vi.doMock(PDFJS, () => pdfjsImport());
|
|
21
|
+
vi.doMock(WORKER, () => {
|
|
22
|
+
workerImports += 1;
|
|
23
|
+
return {};
|
|
24
|
+
});
|
|
25
|
+
return await import("./pdf-loader");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let consoleWarn: ReturnType<typeof vi.spyOn>;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
pdfjsImport = installedPdfjs;
|
|
32
|
+
workerImports = 0;
|
|
33
|
+
consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
consoleWarn.mockRestore();
|
|
38
|
+
vi.doUnmock(PDFJS);
|
|
39
|
+
vi.doUnmock(WORKER);
|
|
40
|
+
vi.resetModules();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("loadPdfjs", () => {
|
|
44
|
+
it("runs the worker on the main thread when no worker URL is configured", async () => {
|
|
45
|
+
const { loadPdfjs } = await loadModule();
|
|
46
|
+
const pdfjs = await loadPdfjs();
|
|
47
|
+
expect(typeof pdfjs.getDocument).toBe("function");
|
|
48
|
+
expect(workerImports).toBe(1);
|
|
49
|
+
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe("");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("uses the configured worker URL instead of importing the worker", async () => {
|
|
53
|
+
const { configurePdfViewer, loadPdfjs } = await loadModule();
|
|
54
|
+
configurePdfViewer({ workerSrc: "/assets/pdf.worker.min.mjs", assetsUrl: "/pdfjs/" });
|
|
55
|
+
const pdfjs = await loadPdfjs();
|
|
56
|
+
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe("/assets/pdf.worker.min.mjs");
|
|
57
|
+
expect(workerImports).toBe(0);
|
|
58
|
+
expect(consoleWarn).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("warns once that scanned pages need assetsUrl", async () => {
|
|
62
|
+
const { loadPdfjs } = await loadModule();
|
|
63
|
+
await loadPdfjs();
|
|
64
|
+
await loadPdfjs();
|
|
65
|
+
expect(consoleWarn).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(consoleWarn.mock.calls[0][0]).toMatch(/no assetsUrl.*JBIG2, CCITT fax/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("treats a stub without the pdf.js API as missing, for the rest of the session", async () => {
|
|
70
|
+
// What a bundler told to ignore the uninstalled peer hands back.
|
|
71
|
+
pdfjsImport = () => ({ default: {}, getDocument: undefined });
|
|
72
|
+
const { loadPdfjs, isMissingPdfjsError } = await loadModule();
|
|
73
|
+
const first = await loadPdfjs().catch((error: unknown) => error);
|
|
74
|
+
expect(isMissingPdfjsError(first)).toBe(true);
|
|
75
|
+
expect((first as Error).message).toMatch(/Install pdfjs-dist/);
|
|
76
|
+
|
|
77
|
+
pdfjsImport = installedPdfjs;
|
|
78
|
+
expect(await loadPdfjs().catch((error: unknown) => error)).toBe(first);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("tries again after a failure that is not a missing package", async () => {
|
|
82
|
+
let attempts = 0;
|
|
83
|
+
pdfjsImport = () => {
|
|
84
|
+
attempts += 1;
|
|
85
|
+
if (attempts === 1) throw new Error("Failed to fetch dynamically imported module");
|
|
86
|
+
return installedPdfjs();
|
|
87
|
+
};
|
|
88
|
+
const { loadPdfjs, isMissingPdfjsError } = await loadModule();
|
|
89
|
+
const failure = await loadPdfjs().catch((error: unknown) => error);
|
|
90
|
+
expect(isMissingPdfjsError(failure)).toBe(false);
|
|
91
|
+
await expect(loadPdfjs()).resolves.toHaveProperty("getDocument");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("asMissingPdfjsError", () => {
|
|
96
|
+
it("names pdfjs-dist only for a resolution failure about it", async () => {
|
|
97
|
+
const { asMissingPdfjsError, isMissingPdfjsError } = await loadModule();
|
|
98
|
+
for (const message of [
|
|
99
|
+
`Failed to resolve import "${PDFJS}". Does the file exist?`,
|
|
100
|
+
`Could not resolve "pdfjs-dist/legacy/build/pdf.mjs"`,
|
|
101
|
+
"Cannot find module 'pdfjs-dist/legacy/build/pdf.mjs'",
|
|
102
|
+
]) {
|
|
103
|
+
expect(isMissingPdfjsError(asMissingPdfjsError(new Error(message))), message).toBe(true);
|
|
104
|
+
}
|
|
105
|
+
const chunk = new Error("Failed to fetch dynamically imported module");
|
|
106
|
+
expect(asMissingPdfjsError(chunk)).toBe(chunk);
|
|
107
|
+
const other = new Error('Could not resolve "./chunk-abc.js"');
|
|
108
|
+
expect(asMissingPdfjsError(other)).toBe(other);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("pdfAssetParams", () => {
|
|
113
|
+
it("asks for nothing until assetsUrl is configured", async () => {
|
|
114
|
+
const { pdfAssetParams } = await loadModule();
|
|
115
|
+
expect(pdfAssetParams()).toEqual({});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("points every asset directory under an absolute assetsUrl", async () => {
|
|
119
|
+
const { configurePdfViewer, pdfAssetParams } = await loadModule();
|
|
120
|
+
configurePdfViewer({ assetsUrl: "/static/pdfjs" });
|
|
121
|
+
const base = new URL("/static/pdfjs/", document.baseURI).href;
|
|
122
|
+
expect(pdfAssetParams()).toEqual({
|
|
123
|
+
wasmUrl: `${base}wasm/`,
|
|
124
|
+
cMapUrl: `${base}cmaps/`,
|
|
125
|
+
cMapPacked: true,
|
|
126
|
+
standardFontDataUrl: `${base}standard_fonts/`,
|
|
127
|
+
iccUrl: `${base}iccs/`,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads pdf.js for `PdfViewer`, which renders PDF pages to canvas in every
|
|
3
|
+
* browser, including Android Chrome and iOS Safari, where `<object>` shows a
|
|
4
|
+
* blank frame or only the first page.
|
|
5
|
+
*
|
|
6
|
+
* `pdfjs-dist` is an optional peer, reached only through the dynamic imports
|
|
7
|
+
* below, for the reasons `editor/editor-peers.ts` gives: a static import fails
|
|
8
|
+
* the build of every consumer that does not install it, and esbuild defers an
|
|
9
|
+
* unresolved `import()` to run time only when the call carries a `.catch()`.
|
|
10
|
+
* `scripts/validate-dist.mjs` enforces both.
|
|
11
|
+
*
|
|
12
|
+
* The legacy build is deliberate. The modern build calls APIs such as
|
|
13
|
+
* `Map.prototype.getOrInsertComputed` without a fallback, so it throws on
|
|
14
|
+
* phone browsers a year or two old; the legacy build carries polyfills for
|
|
15
|
+
* them at about 60 KB more minified code. The worker comes from the same
|
|
16
|
+
* build, because pdf.js refuses a worker whose version differs from its own.
|
|
17
|
+
*
|
|
18
|
+
* Worker. pdf.js parses documents in a worker named by `workerSrc`. No
|
|
19
|
+
* bundler-neutral expression yields that URL from inside a library, and the
|
|
20
|
+
* pdf.js default fetches from a CDN, so by default this module imports the
|
|
21
|
+
* worker module itself. pdf.js then runs its worker code on the main thread,
|
|
22
|
+
* which works in Vite, esbuild, webpack and Cloudflare Workers builds with no
|
|
23
|
+
* configuration and no third-party fetch. Parsing a large document then
|
|
24
|
+
* competes with scrolling, so production apps should serve the worker file and
|
|
25
|
+
* pass its URL to `configurePdfViewer` before the first viewer renders. With
|
|
26
|
+
* Vite:
|
|
27
|
+
*
|
|
28
|
+
* import workerSrc from "pdfjs-dist/legacy/build/pdf.worker.min.mjs?url";
|
|
29
|
+
* configurePdfViewer({ workerSrc });
|
|
30
|
+
*
|
|
31
|
+
* The setting is page-wide because pdf.js's own worker setting is: once the
|
|
32
|
+
* main-thread worker is loaded, every document on the page uses it.
|
|
33
|
+
*
|
|
34
|
+
* Assets. pdf.js 6 decodes JBIG2, CCITT fax and JPEG 2000 images with
|
|
35
|
+
* WebAssembly modules it fetches at run time; black-and-white scans use the
|
|
36
|
+
* first two. Without `assetsUrl` those images do not render, and pdf.js warns
|
|
37
|
+
* only in the console, so this module warns once when it loads without one.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type * as Pdfjs from "pdfjs-dist";
|
|
41
|
+
import { isMissingPeerError, rethrow } from "../lib/optional-peer";
|
|
42
|
+
|
|
43
|
+
export type PdfjsLib = typeof Pdfjs;
|
|
44
|
+
|
|
45
|
+
export interface PdfViewerConfig {
|
|
46
|
+
/**
|
|
47
|
+
* URL of `pdfjs-dist/legacy/build/pdf.worker.min.mjs` from the installed
|
|
48
|
+
* pdfjs-dist, served by the app. Moves PDF parsing off the main thread.
|
|
49
|
+
*/
|
|
50
|
+
workerSrc?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Base URL, ending in `/`, under which the app serves the `wasm/`, `cmaps/`,
|
|
53
|
+
* `standard_fonts/` and `iccs/` directories of the installed pdfjs-dist.
|
|
54
|
+
* Needed for scanned pages, CJK text and non-embedded fonts.
|
|
55
|
+
*/
|
|
56
|
+
assetsUrl?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let config: PdfViewerConfig = {};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sets the page-wide pdf.js worker and asset locations. Call it once, before
|
|
63
|
+
* the first `PdfViewer` renders; a worker set afterwards applies only when no
|
|
64
|
+
* document has loaded the main-thread worker yet.
|
|
65
|
+
*/
|
|
66
|
+
export function configurePdfViewer(next: PdfViewerConfig): void {
|
|
67
|
+
config = { ...next };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The optional peer behind `PdfViewer`. `scripts/validate-dist.mjs` reads this
|
|
72
|
+
* list and requires every import of it to be dynamic and caught.
|
|
73
|
+
*/
|
|
74
|
+
const DEFERRED_PEERS = ["pdfjs-dist"];
|
|
75
|
+
|
|
76
|
+
/** pdfjs-dist is absent, or resolved to a stub with none of its API. */
|
|
77
|
+
export class MissingPdfjsError extends Error {
|
|
78
|
+
constructor(options?: { cause?: unknown }) {
|
|
79
|
+
super(
|
|
80
|
+
"PdfViewer needs the optional peer pdfjs-dist. Install pdfjs-dist to render PDF pages.",
|
|
81
|
+
options,
|
|
82
|
+
);
|
|
83
|
+
this.name = "MissingPdfjsError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isMissingPdfjsError(error: unknown): boolean {
|
|
88
|
+
return error instanceof Error && error.name === "MissingPdfjsError";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Turns a resolution failure that names pdfjs-dist into `MissingPdfjsError`.
|
|
93
|
+
* Any other rejection, such as a chunk that did not download, passes through.
|
|
94
|
+
*/
|
|
95
|
+
export function asMissingPdfjsError(error: unknown): unknown {
|
|
96
|
+
if (!isMissingPeerError(error)) return error;
|
|
97
|
+
const message = (error as Error).message;
|
|
98
|
+
return DEFERRED_PEERS.some((name) => message.includes(name))
|
|
99
|
+
? new MissingPdfjsError({ cause: error })
|
|
100
|
+
: error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function importPdfjs(): Promise<PdfjsLib> {
|
|
104
|
+
let pdfjs: PdfjsLib;
|
|
105
|
+
try {
|
|
106
|
+
pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs").catch(rethrow);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw asMissingPdfjsError(error);
|
|
109
|
+
}
|
|
110
|
+
// A bundler told to ignore the missing peer can hand back an empty module.
|
|
111
|
+
if (typeof pdfjs.getDocument !== "function") throw new MissingPdfjsError();
|
|
112
|
+
return pdfjs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let pending: Promise<PdfjsLib> | null = null;
|
|
116
|
+
let warnedAboutAssets = false;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolves pdf.js with its worker configured. A missing peer stays missing for
|
|
120
|
+
* the session, so its rejection is kept; any other failure, such as a chunk
|
|
121
|
+
* that did not download, is dropped so the next viewer tries again.
|
|
122
|
+
*/
|
|
123
|
+
export function loadPdfjs(): Promise<PdfjsLib> {
|
|
124
|
+
pending ??= importPdfjs().catch((error: unknown) => {
|
|
125
|
+
if (!isMissingPdfjsError(error)) pending = null;
|
|
126
|
+
throw error;
|
|
127
|
+
});
|
|
128
|
+
return pending.then(async (pdfjs) => {
|
|
129
|
+
const { GlobalWorkerOptions } = pdfjs;
|
|
130
|
+
if (config.workerSrc) {
|
|
131
|
+
GlobalWorkerOptions.workerSrc = config.workerSrc;
|
|
132
|
+
} else if (!GlobalWorkerOptions.workerSrc && !GlobalWorkerOptions.workerPort) {
|
|
133
|
+
// Defines `globalThis.pdfjsWorker`, which pdf.js runs on this thread.
|
|
134
|
+
await import("pdfjs-dist/legacy/build/pdf.worker.min.mjs").catch(rethrow);
|
|
135
|
+
}
|
|
136
|
+
if (!config.assetsUrl && !warnedAboutAssets) {
|
|
137
|
+
warnedAboutAssets = true;
|
|
138
|
+
console.warn(
|
|
139
|
+
"PdfViewer: no assetsUrl is configured, so JBIG2, CCITT fax and JPEG 2000 images " +
|
|
140
|
+
"(most black-and-white scans) will not render. See configurePdfViewer.",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return pdfjs;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The `getDocument` options that locate pdf.js's run-time assets. */
|
|
148
|
+
export interface PdfAssetParams {
|
|
149
|
+
wasmUrl?: string;
|
|
150
|
+
cMapUrl?: string;
|
|
151
|
+
cMapPacked?: boolean;
|
|
152
|
+
standardFontDataUrl?: string;
|
|
153
|
+
iccUrl?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `getDocument` asset options for the configured `assetsUrl`, or none. */
|
|
157
|
+
export function pdfAssetParams(): PdfAssetParams {
|
|
158
|
+
if (!config.assetsUrl) return {};
|
|
159
|
+
// pdf.js joins file names onto these with plain concatenation, and a
|
|
160
|
+
// worker resolves a relative URL against its own script, so make it absolute.
|
|
161
|
+
const base = new URL(config.assetsUrl, document.baseURI).href.replace(/\/?$/, "/");
|
|
162
|
+
return {
|
|
163
|
+
wasmUrl: `${base}wasm/`,
|
|
164
|
+
cMapUrl: `${base}cmaps/`,
|
|
165
|
+
cMapPacked: true,
|
|
166
|
+
standardFontDataUrl: `${base}standard_fonts/`,
|
|
167
|
+
iccUrl: `${base}iccs/`,
|
|
168
|
+
};
|
|
169
|
+
}
|