@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,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
|
+
});
|