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