@tangle-network/ui 11.24.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -0
  3. package/dist/chat/agent-timeline.d.ts +13 -1
  4. package/dist/chat/agent-timeline.js +25 -4
  5. package/dist/chat/chat-message.d.ts +13 -1
  6. package/dist/chat/chat-message.js +9 -5
  7. package/dist/chat/message-author.d.ts +24 -0
  8. package/dist/chat/message-author.js +53 -0
  9. package/dist/chat/user-message.d.ts +12 -2
  10. package/dist/chat/user-message.js +52 -16
  11. package/dist/chat.d.ts +3 -1
  12. package/dist/chat.js +2 -1
  13. package/dist/editor/editor-peers.js +1 -21
  14. package/dist/files/file-card.d.ts +30 -0
  15. package/dist/files/file-card.js +162 -0
  16. package/dist/files/file-list.d.ts +25 -0
  17. package/dist/files/file-list.js +88 -0
  18. package/dist/files/file-preview.js +16 -62
  19. package/dist/files/pdf-loader.d.ts +22 -0
  20. package/dist/files/pdf-loader.js +82 -0
  21. package/dist/files/pdf-viewer.d.ts +32 -0
  22. package/dist/files/pdf-viewer.js +567 -0
  23. package/dist/files.d.ts +5 -1
  24. package/dist/files.js +5 -1
  25. package/dist/index.d.ts +9 -4
  26. package/dist/index.js +6 -1
  27. package/dist/lib/optional-peer.js +29 -0
  28. package/dist/primitives.d.ts +2 -2
  29. package/dist/types/message.d.ts +19 -0
  30. package/dist/types.d.ts +2 -2
  31. package/package.json +7 -2
  32. package/src/chat/agent-timeline.tsx +33 -3
  33. package/src/chat/chat-message.tsx +37 -10
  34. package/src/chat/index.ts +2 -0
  35. package/src/chat/message-author.stories.tsx +170 -0
  36. package/src/chat/message-author.test.tsx +189 -0
  37. package/src/chat/message-author.tsx +52 -0
  38. package/src/chat/user-message.tsx +63 -8
  39. package/src/editor/editor-peers.ts +3 -25
  40. package/src/files/file-artifact-pane.test.tsx +12 -2
  41. package/src/files/file-card.test.tsx +62 -0
  42. package/src/files/file-card.tsx +210 -0
  43. package/src/files/file-list.stories.tsx +121 -0
  44. package/src/files/file-list.test.tsx +81 -0
  45. package/src/files/file-list.tsx +167 -0
  46. package/src/files/file-preview.tsx +25 -94
  47. package/src/files/index.ts +9 -0
  48. package/src/files/pdf-loader.test.ts +130 -0
  49. package/src/files/pdf-loader.ts +169 -0
  50. package/src/files/pdf-viewer.stories.tsx +81 -0
  51. package/src/files/pdf-viewer.test.tsx +245 -0
  52. package/src/files/pdf-viewer.tsx +677 -0
  53. package/src/files/pdfjs-worker.d.ts +3 -0
  54. package/src/lib/optional-peer.ts +30 -0
  55. package/src/markdown/code-block.test.tsx +2 -1
  56. package/src/types/message.ts +20 -0
@@ -2,7 +2,7 @@
2
2
  * FilePreview — universal file renderer.
3
3
  *
4
4
  * Renders any file type:
5
- * - PDF: embedded viewer at full pane height
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 { formatBytes } from "../utils/format";
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
- const card = (sentence: string, icon?: LucideIcon) => (
383
- <DownloadCard
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
- sentence={sentence}
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 <PdfPreview blobUrl={blobUrl} filename={filename} size={size} onDownload={onDownload} />;
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, Music);
338
+ return card(NEEDS_DOWNLOAD_LINK);
409
339
  case "csv":
410
340
  if (hasText) return <CsvPreview content={content} />;
411
- return card(NO_INLINE_CONTENT, FileSpreadsheet);
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.", FileSpreadsheet);
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}
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { configurePdfViewer, type PdfViewerConfig } from "./pdf-loader";
3
+ import { PdfViewer } from "./pdf-viewer";
4
+
5
+ // Served by .storybook/main.ts from .storybook/fixtures.
6
+ // US Courts form AO 440, Summons in a Civil Action: two pages, a work of the
7
+ // United States government (https://www.uscourts.gov/sites/default/files/ao440.pdf).
8
+ const SUMMONS = "/fixtures/summons-ao440.pdf";
9
+ // A fictional three-page letter stored as CCITT Group 4 images at 200 dpi, the
10
+ // way an office scanner writes black-and-white pages.
11
+ const SCANNED_LETTER = "/fixtures/scanned-letter.pdf";
12
+
13
+ /** The worker and decoder assets, served the way an app serves them. */
14
+ const SERVED_ASSETS: PdfViewerConfig = {
15
+ workerSrc: "/pdfjs/legacy/build/pdf.worker.min.mjs",
16
+ assetsUrl: "/pdfjs/",
17
+ };
18
+
19
+ // Storybook deep-merges parameters, so an empty story-level object would keep
20
+ // the served assets; a named mode cannot be merged into.
21
+ type PdfAssets = "served" | "none";
22
+
23
+ const meta: Meta<typeof PdfViewer> = {
24
+ title: "Files/PdfViewer",
25
+ component: PdfViewer,
26
+ parameters: { layout: "fullscreen", pdfAssets: "served" satisfies PdfAssets },
27
+ decorators: [
28
+ (Story, context) => {
29
+ // pdf.js fixes its worker with the first document on the page, so a
30
+ // story's configuration holds when the story is opened on its own.
31
+ configurePdfViewer(context.parameters.pdfAssets === "none" ? {} : SERVED_ASSETS);
32
+ return (
33
+ <div className="h-[100dvh] w-full bg-background p-3 text-foreground sm:h-[680px] sm:max-w-[820px]">
34
+ <Story />
35
+ </div>
36
+ );
37
+ },
38
+ ],
39
+ args: {
40
+ src: SUMMONS,
41
+ filename: "Summons in a Civil Action.pdf",
42
+ size: 25_380,
43
+ onDownload: () => {},
44
+ className: "h-full",
45
+ },
46
+ };
47
+
48
+ export default meta;
49
+ type Story = StoryObj<typeof PdfViewer>;
50
+
51
+ export const CourtForm: Story = {
52
+ name: "Court form",
53
+ };
54
+
55
+ export const CourtFormLight: Story = {
56
+ name: "Court form (light)",
57
+ globals: { theme: "light" },
58
+ };
59
+
60
+ export const ScannedLetter: Story = {
61
+ name: "Scanned letter (CCITT pages)",
62
+ args: {
63
+ src: SCANNED_LETTER,
64
+ filename: "Notice of lease termination.pdf",
65
+ size: 75_955,
66
+ },
67
+ };
68
+
69
+ export const ZeroConfiguration: Story = {
70
+ name: "Zero configuration (main-thread worker)",
71
+ parameters: { pdfAssets: "none" satisfies PdfAssets },
72
+ };
73
+
74
+ export const NotAPdf: Story = {
75
+ name: "Not a PDF (falls back)",
76
+ args: {
77
+ src: "data:application/pdf;base64,VGhpcyBpcyBub3QgYSBQREYu",
78
+ filename: "Exhibit C.pdf",
79
+ size: 15,
80
+ },
81
+ };