@tangle-network/ui 11.25.0 → 11.27.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 +12 -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/markdown/markdown.d.ts +3 -2
- package/dist/markdown/markdown.js +70 -4
- package/dist/primitives.d.ts +2 -2
- package/package.json +7 -2
- 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
- package/src/markdown/markdown.test.tsx +32 -3
- package/src/markdown/markdown.tsx +85 -4
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { MissingPdfjsError, loadPdfjs, type PdfjsLib } from "./pdf-loader";
|
|
4
|
+
import { PdfViewer } from "./pdf-viewer";
|
|
5
|
+
|
|
6
|
+
vi.mock("./pdf-loader", async (importOriginal) => ({
|
|
7
|
+
...(await importOriginal<typeof import("./pdf-loader")>()),
|
|
8
|
+
loadPdfjs: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const BLOB_URL = "blob:https://app.tangle.tools/9a8b7c";
|
|
12
|
+
/** US Letter in points. */
|
|
13
|
+
const LETTER = { width: 612, height: 792 };
|
|
14
|
+
const CSS_PER_POINT = 96 / 72;
|
|
15
|
+
|
|
16
|
+
/** A pdf.js stand-in: pages of US Letter that render at once. */
|
|
17
|
+
function fakePdfjs({ numPages = 3, fail }: { numPages?: number; fail?: Error } = {}) {
|
|
18
|
+
const render = vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() }));
|
|
19
|
+
const page = {
|
|
20
|
+
getViewport: ({ scale }: { scale: number }) => ({
|
|
21
|
+
width: LETTER.width * scale,
|
|
22
|
+
height: LETTER.height * scale,
|
|
23
|
+
}),
|
|
24
|
+
render,
|
|
25
|
+
cleanup: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
const doc = { numPages, getPage: vi.fn(async () => page) };
|
|
28
|
+
const destroy = vi.fn(async () => {});
|
|
29
|
+
const getDocument = vi.fn(() => ({
|
|
30
|
+
promise: fail ? Promise.reject(fail) : Promise.resolve(doc),
|
|
31
|
+
destroy,
|
|
32
|
+
onProgress: undefined,
|
|
33
|
+
}));
|
|
34
|
+
const pdfjs = { getDocument } as unknown as PdfjsLib;
|
|
35
|
+
return { pdfjs, getDocument, destroy, render };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function namedError(name: string, extra: Record<string, unknown> = {}): Error {
|
|
39
|
+
return Object.assign(new Error(name), { name }, extra);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const pageInput = () => screen.getByRole("textbox", { name: /^Page/ });
|
|
43
|
+
|
|
44
|
+
let consoleError: ReturnType<typeof vi.spyOn>;
|
|
45
|
+
let consoleWarn: ReturnType<typeof vi.spyOn>;
|
|
46
|
+
const realFetch = globalThis.fetch;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
50
|
+
consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
51
|
+
// Node cannot read a browser's object URLs; serve the test's as a PDF header.
|
|
52
|
+
vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) =>
|
|
53
|
+
String(input).startsWith("blob:")
|
|
54
|
+
? Promise.resolve(new Response("%PDF-"))
|
|
55
|
+
: realFetch(input, init),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
consoleError.mockRestore();
|
|
61
|
+
consoleWarn.mockRestore();
|
|
62
|
+
vi.unstubAllGlobals();
|
|
63
|
+
vi.mocked(loadPdfjs).mockReset();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("PdfViewer", () => {
|
|
67
|
+
it("renders every page of the document to a canvas", async () => {
|
|
68
|
+
const fake = fakePdfjs({ numPages: 3 });
|
|
69
|
+
vi.mocked(loadPdfjs).mockResolvedValue(fake.pdfjs);
|
|
70
|
+
|
|
71
|
+
const { container } = render(<PdfViewer src={BLOB_URL} filename="lease.pdf" />);
|
|
72
|
+
|
|
73
|
+
expect(screen.getByRole("status")).toHaveTextContent("Loading lease.pdf");
|
|
74
|
+
expect(await screen.findByRole("img", { name: "Page 3 of 3" })).toBeInTheDocument();
|
|
75
|
+
expect(screen.getByRole("region", { name: "lease.pdf" })).toBeInTheDocument();
|
|
76
|
+
expect(pageInput()).toHaveValue("1");
|
|
77
|
+
expect(screen.getByText("/ 3")).toBeInTheDocument();
|
|
78
|
+
await waitFor(() => expect(container.querySelectorAll("canvas")).toHaveLength(3));
|
|
79
|
+
expect(fake.render).toHaveBeenCalledWith(
|
|
80
|
+
expect.objectContaining({ canvas: expect.any(HTMLCanvasElement) }),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("streams an http(s) URL and reads other URLs as bytes", async () => {
|
|
85
|
+
const fake = fakePdfjs();
|
|
86
|
+
vi.mocked(loadPdfjs).mockResolvedValue(fake.pdfjs);
|
|
87
|
+
|
|
88
|
+
const remote = render(<PdfViewer src="https://files.example/brief.pdf" />);
|
|
89
|
+
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
90
|
+
expect(fake.getDocument).toHaveBeenLastCalledWith({ url: "https://files.example/brief.pdf" });
|
|
91
|
+
remote.unmount();
|
|
92
|
+
|
|
93
|
+
render(<PdfViewer src="data:application/pdf;base64,JVBERi0=" />);
|
|
94
|
+
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
95
|
+
const [params] = fake.getDocument.mock.lastCall as unknown as [{ data: Uint8Array }];
|
|
96
|
+
expect(new TextDecoder().decode(params.data)).toBe("%PDF-");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("moves between pages with the buttons and the page field", async () => {
|
|
100
|
+
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs({ numPages: 3 }).pdfjs);
|
|
101
|
+
render(<PdfViewer src={BLOB_URL} filename="lease.pdf" />);
|
|
102
|
+
await screen.findByRole("img", { name: "Page 3 of 3" });
|
|
103
|
+
|
|
104
|
+
const previous = screen.getByRole("button", { name: "Previous page" });
|
|
105
|
+
const next = screen.getByRole("button", { name: "Next page" });
|
|
106
|
+
expect(previous).toBeDisabled();
|
|
107
|
+
|
|
108
|
+
fireEvent.click(next);
|
|
109
|
+
expect(pageInput()).toHaveValue("2");
|
|
110
|
+
expect(previous).toBeEnabled();
|
|
111
|
+
|
|
112
|
+
fireEvent.change(pageInput(), { target: { value: "9" } });
|
|
113
|
+
fireEvent.keyDown(pageInput(), { key: "Enter" });
|
|
114
|
+
expect(pageInput()).toHaveValue("3");
|
|
115
|
+
expect(next).toBeDisabled();
|
|
116
|
+
|
|
117
|
+
fireEvent.change(pageInput(), { target: { value: "1x" } });
|
|
118
|
+
expect(pageInput()).toHaveValue("1");
|
|
119
|
+
fireEvent.keyDown(pageInput(), { key: "Escape" });
|
|
120
|
+
expect(pageInput()).toHaveValue("3");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("zooms in steps and returns to fit-to-width", async () => {
|
|
124
|
+
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs({ numPages: 1 }).pdfjs);
|
|
125
|
+
render(<PdfViewer src={BLOB_URL} filename="lease.pdf" />);
|
|
126
|
+
const page = await screen.findByRole("img", { name: "Page 1 of 1" });
|
|
127
|
+
|
|
128
|
+
// jsdom lays nothing out, so the fitted width falls back to 100%.
|
|
129
|
+
const fit = screen.getByRole("button", { name: "100%, fit to width" });
|
|
130
|
+
expect(fit).toHaveAttribute("aria-pressed", "true");
|
|
131
|
+
|
|
132
|
+
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
|
133
|
+
const zoomed = screen.getByRole("button", { name: "125%, fit to width" });
|
|
134
|
+
expect(zoomed).toHaveAttribute("aria-pressed", "false");
|
|
135
|
+
await waitFor(() =>
|
|
136
|
+
expect(page.style.width).toBe(`${Math.round(LETTER.width * CSS_PER_POINT * 1.25)}px`),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
|
|
140
|
+
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
|
|
141
|
+
expect(screen.getByRole("button", { name: "75%, fit to width" })).toBeInTheDocument();
|
|
142
|
+
|
|
143
|
+
fireEvent.click(screen.getByRole("button", { name: "75%, fit to width" }));
|
|
144
|
+
expect(screen.getByRole("button", { name: "100%, fit to width" })).toHaveAttribute(
|
|
145
|
+
"aria-pressed",
|
|
146
|
+
"true",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("fits the first page to the width of the pane", async () => {
|
|
151
|
+
const width = vi
|
|
152
|
+
.spyOn(HTMLElement.prototype, "clientWidth", "get")
|
|
153
|
+
.mockReturnValue(600);
|
|
154
|
+
try {
|
|
155
|
+
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs({ numPages: 1 }).pdfjs);
|
|
156
|
+
render(<PdfViewer src={BLOB_URL} />);
|
|
157
|
+
const page = await screen.findByRole("img", { name: "Page 1 of 1" });
|
|
158
|
+
// 600px less the column's 12px padding on each side.
|
|
159
|
+
await waitFor(() => expect(page.style.width).toBe("576px"));
|
|
160
|
+
const percent = Math.round((576 / (LETTER.width * CSS_PER_POINT)) * 100);
|
|
161
|
+
expect(screen.getByRole("button", { name: `${percent}%, fit to width` })).toHaveAttribute(
|
|
162
|
+
"aria-pressed",
|
|
163
|
+
"true",
|
|
164
|
+
);
|
|
165
|
+
} finally {
|
|
166
|
+
width.mockRestore();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("offers Download in the toolbar only when the host handles it", async () => {
|
|
171
|
+
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs().pdfjs);
|
|
172
|
+
const onDownload = vi.fn();
|
|
173
|
+
const { rerender } = render(<PdfViewer src={BLOB_URL} filename="lease.pdf" />);
|
|
174
|
+
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
175
|
+
expect(screen.queryByRole("button", { name: "Download lease.pdf" })).toBeNull();
|
|
176
|
+
|
|
177
|
+
rerender(<PdfViewer src={BLOB_URL} filename="lease.pdf" onDownload={onDownload} />);
|
|
178
|
+
fireEvent.click(screen.getByRole("button", { name: "Download lease.pdf" }));
|
|
179
|
+
expect(onDownload).toHaveBeenCalledTimes(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("says so and falls back to the browser's viewer when pdfjs-dist is missing", async () => {
|
|
183
|
+
vi.mocked(loadPdfjs).mockRejectedValue(new MissingPdfjsError());
|
|
184
|
+
const onDownload = vi.fn();
|
|
185
|
+
const { container } = render(
|
|
186
|
+
<PdfViewer src={BLOB_URL} filename="lease.pdf" size={2048} onDownload={onDownload} />,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(
|
|
190
|
+
await screen.findByText(/does not include the page viewer/),
|
|
191
|
+
).toBeInTheDocument();
|
|
192
|
+
const object = container.querySelector("object");
|
|
193
|
+
expect(object).toHaveAttribute("data", BLOB_URL);
|
|
194
|
+
expect(object).toHaveAttribute("type", "application/pdf");
|
|
195
|
+
expect(screen.getByText("This browser does not show PDFs inline.")).toBeInTheDocument();
|
|
196
|
+
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
|
197
|
+
fireEvent.click(screen.getByRole("button", { name: "Download" }));
|
|
198
|
+
expect(onDownload).toHaveBeenCalledTimes(1);
|
|
199
|
+
// A missing package will not appear on a retry.
|
|
200
|
+
expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
|
|
201
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
202
|
+
expect.stringContaining("falling back"),
|
|
203
|
+
expect.any(MissingPdfjsError),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it.each([
|
|
208
|
+
["PasswordException", {}, /password-protected/, false],
|
|
209
|
+
["InvalidPDFException", {}, /could not be read as a PDF/, false],
|
|
210
|
+
["ResponseException", { status: 404 }, /could not be downloaded \(HTTP 404\)/, true],
|
|
211
|
+
["UnknownErrorException", {}, /could not open this PDF/, true],
|
|
212
|
+
])("explains a %s and offers a retry only when one can help", async (name, extra, notice, retry) => {
|
|
213
|
+
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs({ fail: namedError(name, extra) }).pdfjs);
|
|
214
|
+
render(<PdfViewer src={BLOB_URL} />);
|
|
215
|
+
expect(await screen.findByText(notice)).toBeInTheDocument();
|
|
216
|
+
expect(Boolean(screen.queryByRole("button", { name: "Try again" }))).toBe(retry);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("loads the document again on Try again", async () => {
|
|
220
|
+
const failing = fakePdfjs({ fail: namedError("UnknownErrorException") });
|
|
221
|
+
const working = fakePdfjs({ numPages: 2 });
|
|
222
|
+
vi.mocked(loadPdfjs)
|
|
223
|
+
.mockResolvedValueOnce(failing.pdfjs)
|
|
224
|
+
.mockResolvedValueOnce(working.pdfjs);
|
|
225
|
+
render(<PdfViewer src={BLOB_URL} />);
|
|
226
|
+
|
|
227
|
+
fireEvent.click(await screen.findByRole("button", { name: "Try again" }));
|
|
228
|
+
expect(await screen.findByRole("img", { name: "Page 2 of 2" })).toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("destroys the loading task when it unmounts or the source changes", async () => {
|
|
232
|
+
const fake = fakePdfjs();
|
|
233
|
+
vi.mocked(loadPdfjs).mockResolvedValue(fake.pdfjs);
|
|
234
|
+
const { rerender, unmount } = render(<PdfViewer src={BLOB_URL} />);
|
|
235
|
+
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
236
|
+
|
|
237
|
+
rerender(<PdfViewer src="blob:https://app.tangle.tools/other" />);
|
|
238
|
+
expect(fake.destroy).toHaveBeenCalledTimes(1);
|
|
239
|
+
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
240
|
+
|
|
241
|
+
unmount();
|
|
242
|
+
await act(async () => {});
|
|
243
|
+
expect(fake.destroy).toHaveBeenCalledTimes(2);
|
|
244
|
+
});
|
|
245
|
+
});
|