yamlover 0.3.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/LICENSE +21 -0
- package/README.md +214 -0
- package/bin/yamlover.js +202 -0
- package/dist/server.js +4681 -0
- package/index.html +12 -0
- package/package.json +72 -0
- package/src/client/App.tsx +372 -0
- package/src/client/NodeView.tsx +422 -0
- package/src/client/TaskStrip.tsx +34 -0
- package/src/client/Tree.tsx +97 -0
- package/src/client/api.ts +186 -0
- package/src/client/icons.ts +91 -0
- package/src/client/links.tsx +108 -0
- package/src/client/live.ts +42 -0
- package/src/client/main.tsx +10 -0
- package/src/client/paste-html.ts +228 -0
- package/src/client/paste-links.ts +42 -0
- package/src/client/paths.ts +109 -0
- package/src/client/render.tsx +326 -0
- package/src/client/renderers/annotate.tsx +507 -0
- package/src/client/renderers/asciidoc.tsx +35 -0
- package/src/client/renderers/chapter.tsx +138 -0
- package/src/client/renderers/csv.tsx +233 -0
- package/src/client/renderers/decoded.tsx +72 -0
- package/src/client/renderers/djvu.tsx +97 -0
- package/src/client/renderers/doc.tsx +40 -0
- package/src/client/renderers/docx.tsx +49 -0
- package/src/client/renderers/epub.tsx +147 -0
- package/src/client/renderers/explorer.tsx +209 -0
- package/src/client/renderers/fb2.tsx +149 -0
- package/src/client/renderers/headings.ts +69 -0
- package/src/client/renderers/heic.tsx +23 -0
- package/src/client/renderers/imagemap.tsx +157 -0
- package/src/client/renderers/kml.ts +46 -0
- package/src/client/renderers/latex.tsx +36 -0
- package/src/client/renderers/map.tsx +205 -0
- package/src/client/renderers/marklower.tsx +119 -0
- package/src/client/renderers/markup.tsx +64 -0
- package/src/client/renderers/media.tsx +19 -0
- package/src/client/renderers/panzoom.ts +101 -0
- package/src/client/renderers/pdf.tsx +176 -0
- package/src/client/renderers/plaintext.tsx +120 -0
- package/src/client/renderers/plantuml.tsx +82 -0
- package/src/client/renderers/psd.tsx +25 -0
- package/src/client/renderers/registry.tsx +389 -0
- package/src/client/renderers/rtf.tsx +210 -0
- package/src/client/renderers/spreadsheet.tsx +105 -0
- package/src/client/renderers/tag.tsx +113 -0
- package/src/client/renderers/text.tsx +41 -0
- package/src/client/renderers/tiff.tsx +33 -0
- package/src/client/styles.css +1115 -0
- package/src/client/vendor/README.md +30 -0
- package/src/client/vendor/djvu.js +15535 -0
- package/src/client/vite-env.d.ts +31 -0
- package/src/server/api.ts +147 -0
- package/src/server/engine-api.ts +1442 -0
- package/src/server/gitignore.ts +81 -0
- package/src/server/node-kind.ts +48 -0
- package/src/server/tasks.ts +83 -0
- package/src/server/yamlover.ts +1133 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { lazy, Suspense } from "react";
|
|
2
|
+
import { NodeJson, TreeNode } from "../api";
|
|
3
|
+
import { ChapterView } from "./chapter";
|
|
4
|
+
import { TextView, TextChunk } from "./text";
|
|
5
|
+
import { MarklowerView, MarklowerChunk } from "./marklower";
|
|
6
|
+
import { LatexView, LatexChunk } from "./latex";
|
|
7
|
+
import { AsciidocView, AsciidocChunk } from "./asciidoc";
|
|
8
|
+
import { CsvView, CsvChunk, CsvControls } from "./csv";
|
|
9
|
+
import { PlaintextView, PlaintextChunk, EncodingControl } from "./plaintext";
|
|
10
|
+
import { RtfView, RtfChunk } from "./rtf";
|
|
11
|
+
import { DocView, DocChunk } from "./doc";
|
|
12
|
+
import { PlantumlView, PlantumlChunk } from "./plantuml";
|
|
13
|
+
import { ExplorerView, ExplorerViewControl } from "./explorer";
|
|
14
|
+
import { Fb2View } from "./fb2";
|
|
15
|
+
import { EpubView } from "./epub";
|
|
16
|
+
import { HtmlView } from "./media";
|
|
17
|
+
import { MarkupWidthControl } from "./markup";
|
|
18
|
+
|
|
19
|
+
// pdf.js and DjVu.js are heavy and browser-only (they reach for canvas globals at
|
|
20
|
+
// import time). Load them lazily so the registry — imported by the TOC and by
|
|
21
|
+
// tests — never pulls them in until a PDF/DjVu node is actually rendered.
|
|
22
|
+
const PdfView = lazy(() => import("./pdf").then((m) => ({ default: m.PdfView })));
|
|
23
|
+
const DjvuView = lazy(() => import("./djvu").then((m) => ({ default: m.DjvuView })));
|
|
24
|
+
const PsdView = lazy(() => import("./psd").then((m) => ({ default: m.PsdView })));
|
|
25
|
+
const TiffView = lazy(() => import("./tiff").then((m) => ({ default: m.TiffView })));
|
|
26
|
+
const HeicView = lazy(() => import("./heic").then((m) => ({ default: m.HeicView })));
|
|
27
|
+
// mammoth (.docx) and SheetJS (.xls/.xlsx) are heavy; load each on first use.
|
|
28
|
+
const DocxView = lazy(() => import("./docx").then((m) => ({ default: m.DocxView })));
|
|
29
|
+
const DocxChunk = lazy(() => import("./docx").then((m) => ({ default: m.DocxChunk })));
|
|
30
|
+
const SpreadsheetView = lazy(() => import("./spreadsheet").then((m) => ({ default: m.SpreadsheetView })));
|
|
31
|
+
const SpreadsheetChunk = lazy(() => import("./spreadsheet").then((m) => ({ default: m.SpreadsheetChunk })));
|
|
32
|
+
// Leaflet (KML/KMZ maps; and the pan/zoom image viewer) is heavy and browser-only; lazy-load.
|
|
33
|
+
const MapView = lazy(() => import("./map").then((m) => ({ default: m.MapView })));
|
|
34
|
+
const MapChunk = lazy(() => import("./map").then((m) => ({ default: m.MapChunk })));
|
|
35
|
+
const ImageView = lazy(() => import("./imagemap").then((m) => ({ default: m.ImageView })));
|
|
36
|
+
const ImageChunk = lazy(() => import("./imagemap").then((m) => ({ default: m.ImageChunk })));
|
|
37
|
+
const lazily = (el: JSX.Element) => <Suspense fallback={<div className="loading">…</div>}>{el}</Suspense>;
|
|
38
|
+
|
|
39
|
+
/** Synthesize a minimal `NodeJson` from a chunk so a file-backed renderer (which
|
|
40
|
+
* only needs the node's `path`/`value`) can be reused *inline* as a chapter
|
|
41
|
+
* chunk — the same view, addressed by the chunk's own node path. This is how a
|
|
42
|
+
* PDF / DjVu / PSD / TIFF / HEIC / FB2 / EPUB / HTML chunk renders in a chapter
|
|
43
|
+
* body, not just a full page. */
|
|
44
|
+
const chunkNode = (chunk: Chunk): NodeJson => ({
|
|
45
|
+
path: chunk.path,
|
|
46
|
+
type: chunk.type,
|
|
47
|
+
format: chunk.format,
|
|
48
|
+
concrete: null,
|
|
49
|
+
title: null,
|
|
50
|
+
description: null,
|
|
51
|
+
value: chunk.value,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A renderer turns a node into a React element for the RHS pane. It is selected
|
|
56
|
+
* by the node's **(type, format)** tuple — the same key the TOC icons and the
|
|
57
|
+
* link markers carry — so a renderer can claim, say, every `string`/`text/markdown`,
|
|
58
|
+
* every `object`/`x-yamlover-chapter`, or a bare `(type, None)`. Our own custom
|
|
59
|
+
* formats are prefixed `x-yamlover-`.
|
|
60
|
+
*
|
|
61
|
+
* The registry is the single extension point: add an entry here to teach the UI a
|
|
62
|
+
* new renderable shape. A renderer's `name` is also its representation key — the
|
|
63
|
+
* label of its tab and the `?format=` value (e.g. `chapter`).
|
|
64
|
+
*
|
|
65
|
+
* A renderer participates in the UI three ways, all keyed by the same tuple:
|
|
66
|
+
* - `render` — the full RHS page.
|
|
67
|
+
* - `renderChunk` — its *inline* form, when embedded in another renderer's page
|
|
68
|
+
* (a chapter renders each chunk by routing to the chunk's own renderer here).
|
|
69
|
+
* - `tocView` — how the node appears in the TOC: which children are navigable,
|
|
70
|
+
* whether it expands, and whether they are loaded. The chapter unwraps its
|
|
71
|
+
* `children` array (subchapters become its direct TOC entries) and keeps its
|
|
72
|
+
* `chunks` off the tree (prose is read on the page, not browsed).
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** A single chunk handed to a renderer's `renderChunk` — its value plus the
|
|
76
|
+
* (type, format) it was routed on and its JSON path (the anchor target). */
|
|
77
|
+
export interface Chunk {
|
|
78
|
+
value: unknown;
|
|
79
|
+
path: string;
|
|
80
|
+
type: string;
|
|
81
|
+
format: string | null;
|
|
82
|
+
/** The JSON-space path of the document this chunk belongs to (the enclosing
|
|
83
|
+
* chapter's `documentPath`) — the anchor a document-relative (`/…`) marklower
|
|
84
|
+
* link in the chunk resolves against. */
|
|
85
|
+
documentPath?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How a node appears in the TOC. `children` are the rows shown beneath it;
|
|
89
|
+
* `expandable` shows a chevron; `loaded` false means the children must be
|
|
90
|
+
* fetched (by `node.path`) on first expand. `loadDepth` is how many levels that
|
|
91
|
+
* expand fetch must pull (default 1) — more when a renderer's TOC rows live
|
|
92
|
+
* deeper than the node's direct children (a chapter surfaces its subchapters
|
|
93
|
+
* from *under* its `children` wrapper, and fetches one further level so each
|
|
94
|
+
* revealed subchapter's own chevron is accurate — so it needs 3). */
|
|
95
|
+
export interface TocView {
|
|
96
|
+
children: TreeNode[];
|
|
97
|
+
expandable: boolean;
|
|
98
|
+
loaded: boolean;
|
|
99
|
+
loadDepth?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface Renderer {
|
|
103
|
+
name: string;
|
|
104
|
+
/** The (type, format) tuples this renderer claims. A `null` format matches a
|
|
105
|
+
* node that carries no `format`; a string format matches that format exactly. */
|
|
106
|
+
accepts: ReadonlyArray<readonly [type: string, format: string | null]>;
|
|
107
|
+
/** Value depth `NodeView` must fetch for this renderer (default 1). A chapter
|
|
108
|
+
* needs 2: its `chunks`/`children` arrays one level, their elements the next. */
|
|
109
|
+
depth?: number;
|
|
110
|
+
/** This node's TOC presentation (default: its own children, lazily loaded). */
|
|
111
|
+
tocView?: (node: TreeNode) => TocView;
|
|
112
|
+
render: (node: NodeJson, onNavigate: (path: string) => void) => JSX.Element;
|
|
113
|
+
/** This renderer's inline form, for embedding a single value in another page. */
|
|
114
|
+
renderChunk?: (chunk: Chunk, onNavigate: (path: string) => void) => JSX.Element;
|
|
115
|
+
/** An optional control shown in the tab bar beside this renderer's button (only while its
|
|
116
|
+
* view is active) — e.g. the markdown/asciidoc reading-width input. `rerender` refreshes
|
|
117
|
+
* the node view after the control changes a URL parameter. */
|
|
118
|
+
config?: (rerender: () => void) => JSX.Element;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The EXPLORER — a file-manager "small icons" grid of a node's members, uplinks first. It is
|
|
123
|
+
* claimed two ways: by (type, format) for tags (a tag projects as `object` — fields only,
|
|
124
|
+
* `variant` — description BODY + fields, `string` — a leaf tag that is just its description,
|
|
125
|
+
* or `null` — a bare tag with neither, the shape the picker's create-on-miss writes;
|
|
126
|
+
* the grid shows the tagged MATERIALS), and as the CONCRETE fallback for any node stored as a
|
|
127
|
+
* filesystem directory (`dir`/`yamlover`) that no (type, format) renderer claims — see
|
|
128
|
+
* {@link getRenderer}. Hoisted so the fallback can reference the same instance.
|
|
129
|
+
*/
|
|
130
|
+
const EXPLORER: Renderer = {
|
|
131
|
+
name: "explorer",
|
|
132
|
+
accepts: [
|
|
133
|
+
["object", "x-yamlover-tag"],
|
|
134
|
+
["variant", "x-yamlover-tag"],
|
|
135
|
+
["string", "x-yamlover-tag"],
|
|
136
|
+
["null", "x-yamlover-tag"],
|
|
137
|
+
],
|
|
138
|
+
render: (node, onNavigate) => <ExplorerView node={node} onNavigate={onNavigate} />,
|
|
139
|
+
config: (rerender) => <ExplorerViewControl rerender={rerender} />, // large/small icons (`?view=`)
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** A directory-stored node (`dir` = a plain folder, `yamlover` = a folder with `.yamlover/`). */
|
|
143
|
+
const isDirConcrete = (concrete: string | null | undefined): boolean => concrete === "dir" || concrete === "yamlover";
|
|
144
|
+
|
|
145
|
+
const REGISTRY: Renderer[] = [
|
|
146
|
+
{
|
|
147
|
+
name: "chapter",
|
|
148
|
+
accepts: [["object", "x-yamlover-chapter"]],
|
|
149
|
+
depth: 2, // reach the chunk/subchapter elements (arrays one level, items the next)
|
|
150
|
+
tocView: chapterTocView,
|
|
151
|
+
render: (node, onNavigate) => <ChapterView node={node} onNavigate={onNavigate} />,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
// Our default for a bare, format-less string: marklower, a markup language a
|
|
155
|
+
// notch below Markdown. A chapter's prose chunks route here — both the bare
|
|
156
|
+
// (string, null) form and the explicit `text/marklower` the chunk schema applies.
|
|
157
|
+
name: "marklower",
|
|
158
|
+
accepts: [
|
|
159
|
+
["string", null],
|
|
160
|
+
["string", "text/marklower"],
|
|
161
|
+
],
|
|
162
|
+
render: (node, onNavigate) => <MarklowerView node={node} onNavigate={onNavigate} />,
|
|
163
|
+
renderChunk: (chunk, onNavigate) => <MarklowerChunk chunk={chunk} onNavigate={onNavigate} />,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
// Markdown (the component file is text.tsx for historical reasons; the renderer —
|
|
167
|
+
// its tab label and `?format=` key — is named for what it renders).
|
|
168
|
+
name: "markdown",
|
|
169
|
+
accepts: [["string", "text/markdown"]],
|
|
170
|
+
render: (node) => <TextView node={node} />,
|
|
171
|
+
renderChunk: (chunk) => <TextChunk chunk={chunk} />,
|
|
172
|
+
config: (rerender) => <MarkupWidthControl rerender={rerender} />,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "asciidoc",
|
|
176
|
+
accepts: [["string", "text/asciidoc"]],
|
|
177
|
+
render: (node) => <AsciidocView node={node} />,
|
|
178
|
+
renderChunk: (chunk) => <AsciidocChunk chunk={chunk} />,
|
|
179
|
+
config: (rerender) => <MarkupWidthControl rerender={rerender} />,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
// Delimited text (CSV/TSV, a string) shown as a table; its parsing options
|
|
183
|
+
// (separator, header) ride in the URL query — see csv.tsx.
|
|
184
|
+
name: "csv",
|
|
185
|
+
accepts: [
|
|
186
|
+
["string", "text/csv"],
|
|
187
|
+
["string", "text/tab-separated-values"],
|
|
188
|
+
],
|
|
189
|
+
render: (node) => <CsvView node={node} />,
|
|
190
|
+
renderChunk: (chunk) => <CsvChunk chunk={chunk} />,
|
|
191
|
+
config: (rerender) => <CsvControls rerender={rerender} />,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
// Plain text shown verbatim (no markup), with a node-bar encoding selector —
|
|
195
|
+
// CP866 / Windows-1251 / KOI8-R / UTF-8 (see plaintext.tsx). Served as raw
|
|
196
|
+
// bytes so the encoding is the client's to choose.
|
|
197
|
+
name: "plaintext",
|
|
198
|
+
accepts: [["binary", "text/plain"]],
|
|
199
|
+
render: (node) => <PlaintextView node={node} />,
|
|
200
|
+
renderChunk: (chunk) => <PlaintextChunk chunk={chunk} />,
|
|
201
|
+
config: (rerender) => <EncodingControl rerender={rerender} />,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
// RTF — a dependency-free converter to HTML (see rtf.tsx).
|
|
205
|
+
name: "rtf",
|
|
206
|
+
accepts: [["binary", "application/rtf"]],
|
|
207
|
+
render: (node) => <RtfView node={node} />,
|
|
208
|
+
renderChunk: (chunk) => <RtfChunk chunk={chunk} />,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
// .docx (Office Open XML) via mammoth, lazily loaded.
|
|
212
|
+
name: "docx",
|
|
213
|
+
accepts: [["binary", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"]],
|
|
214
|
+
render: (node) => lazily(<DocxView node={node} />),
|
|
215
|
+
renderChunk: (chunk) => lazily(<DocxChunk chunk={chunk} />),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
// Excel workbooks — .xlsx and legacy .xls — via SheetJS, lazily loaded.
|
|
219
|
+
name: "spreadsheet",
|
|
220
|
+
accepts: [
|
|
221
|
+
["binary", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
|
|
222
|
+
["binary", "application/vnd.ms-excel"],
|
|
223
|
+
],
|
|
224
|
+
render: (node) => lazily(<SpreadsheetView node={node} />),
|
|
225
|
+
renderChunk: (chunk) => lazily(<SpreadsheetChunk chunk={chunk} />),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
// Legacy .doc (Word 97–2003 binary) — no in-browser parser; download fallback.
|
|
229
|
+
name: "doc",
|
|
230
|
+
accepts: [["binary", "application/msword"]],
|
|
231
|
+
render: (node) => <DocView node={node} />,
|
|
232
|
+
renderChunk: (chunk) => <DocChunk chunk={chunk} />,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
// KML / KMZ geographic overlays drawn on a Leaflet map (lazily loaded).
|
|
236
|
+
name: "map",
|
|
237
|
+
accepts: [
|
|
238
|
+
["binary", "application/vnd.google-earth.kml+xml"],
|
|
239
|
+
["binary", "application/vnd.google-earth.kmz"],
|
|
240
|
+
],
|
|
241
|
+
render: (node) => lazily(<MapView node={node} />),
|
|
242
|
+
renderChunk: (chunk) => lazily(<MapChunk chunk={chunk} />),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
// LaTeX math (a string) typeset with KaTeX, both whole and inline. marklower
|
|
246
|
+
// reuses the same engine for its `$$…$$` spans.
|
|
247
|
+
name: "latex",
|
|
248
|
+
accepts: [["string", "text/x-latex"]],
|
|
249
|
+
render: (node) => <LatexView node={node} />,
|
|
250
|
+
renderChunk: (chunk) => <LatexChunk chunk={chunk} />,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
// PlantUML source (a string) shown as the diagram it compiles to, both as a
|
|
254
|
+
// whole node and inline as a chapter chunk.
|
|
255
|
+
name: "plantuml",
|
|
256
|
+
accepts: [["string", "text/x-plantuml"]],
|
|
257
|
+
render: (node) => <PlantumlView node={node} />,
|
|
258
|
+
renderChunk: (chunk) => <PlantumlChunk chunk={chunk} />,
|
|
259
|
+
},
|
|
260
|
+
EXPLORER,
|
|
261
|
+
{
|
|
262
|
+
// File-backed binaries the server tags with an inferred image format.
|
|
263
|
+
name: "image",
|
|
264
|
+
accepts: [
|
|
265
|
+
["binary", "image/png"],
|
|
266
|
+
["binary", "image/jpeg"],
|
|
267
|
+
["binary", "image/gif"],
|
|
268
|
+
["binary", "image/webp"],
|
|
269
|
+
["binary", "image/avif"],
|
|
270
|
+
["binary", "image/bmp"],
|
|
271
|
+
["binary", "image/x-icon"],
|
|
272
|
+
["binary", "image/svg+xml"],
|
|
273
|
+
],
|
|
274
|
+
render: (node) => lazily(<ImageView node={node} />),
|
|
275
|
+
renderChunk: (chunk) => lazily(<ImageChunk chunk={chunk} />),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "html",
|
|
279
|
+
accepts: [["binary", "text/html"]],
|
|
280
|
+
render: (node) => <HtmlView node={node} />,
|
|
281
|
+
renderChunk: (chunk) => <HtmlView node={chunkNode(chunk)} />,
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: "fb2",
|
|
285
|
+
accepts: [["binary", "application/x-fictionbook+xml"]],
|
|
286
|
+
render: (node) => <Fb2View node={node} />,
|
|
287
|
+
renderChunk: (chunk) => <Fb2View node={chunkNode(chunk)} />,
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: "epub",
|
|
291
|
+
accepts: [["binary", "application/epub+zip"]],
|
|
292
|
+
render: (node) => <EpubView node={node} />,
|
|
293
|
+
renderChunk: (chunk) => <EpubView node={chunkNode(chunk)} />,
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: "pdf",
|
|
297
|
+
accepts: [["binary", "application/pdf"]],
|
|
298
|
+
render: (node) => lazily(<PdfView node={node} />),
|
|
299
|
+
renderChunk: (chunk) => lazily(<PdfView node={chunkNode(chunk)} />),
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: "djvu",
|
|
303
|
+
accepts: [["binary", "image/vnd.djvu"]],
|
|
304
|
+
render: (node) => lazily(<DjvuView node={node} />),
|
|
305
|
+
renderChunk: (chunk) => lazily(<DjvuView node={chunkNode(chunk)} />),
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: "psd",
|
|
309
|
+
accepts: [["binary", "image/vnd.adobe.photoshop"]],
|
|
310
|
+
render: (node) => lazily(<PsdView node={node} />),
|
|
311
|
+
renderChunk: (chunk) => lazily(<PsdView node={chunkNode(chunk)} />),
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: "tiff",
|
|
315
|
+
accepts: [["binary", "image/tiff"]],
|
|
316
|
+
render: (node) => lazily(<TiffView node={node} />),
|
|
317
|
+
renderChunk: (chunk) => lazily(<TiffView node={chunkNode(chunk)} />),
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "heic",
|
|
321
|
+
accepts: [["binary", "image/heic"]],
|
|
322
|
+
render: (node) => lazily(<HeicView node={node} />),
|
|
323
|
+
renderChunk: (chunk) => lazily(<HeicView node={chunkNode(chunk)} />),
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
/** The last path segment (a property key or `[index]`) of a colon-form client path. */
|
|
328
|
+
function basename(path: string): string {
|
|
329
|
+
const i = path.lastIndexOf(":");
|
|
330
|
+
return i < 0 ? path : path.slice(i + 1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** A chapter's TOC view: its subchapters (the items of its `children` array)
|
|
334
|
+
* surfaced directly, with its `chunks` kept off the tree. The `children` wrapper
|
|
335
|
+
* sits one level below the chapter, so its items are loaded one level deeper —
|
|
336
|
+
* expandability follows the wrapper's own `hasChildren`. */
|
|
337
|
+
function chapterTocView(node: TreeNode): TocView {
|
|
338
|
+
// Subchapters live under the `children` wrapper, one level below the chapter, so
|
|
339
|
+
// revealing them costs a level (chapter → children → subchapters). We fetch one
|
|
340
|
+
// more (→ each subchapter's own `children` wrapper) so a revealed subchapter's
|
|
341
|
+
// chevron is decided from its real subchapter list, not the generic `hasChildren`
|
|
342
|
+
// hint (which is always true for a chapter — it has `chunks`/`children` arrays).
|
|
343
|
+
// Hence 3, so a childless chapter (only chunks) shows no chevron from the start.
|
|
344
|
+
const wrap = node.children.find((c) => basename(c.path) === "children");
|
|
345
|
+
if (!wrap) {
|
|
346
|
+
// the chapter itself is not loaded yet — defer to the server's hint
|
|
347
|
+
return { children: [], expandable: node.hasChildren, loaded: node.children.length > 0, loadDepth: 3 };
|
|
348
|
+
}
|
|
349
|
+
// expandable iff the `children` wrapper actually holds subchapters (chunks-only
|
|
350
|
+
// chapters have an empty wrapper → no chevron)
|
|
351
|
+
return {
|
|
352
|
+
children: wrap.children,
|
|
353
|
+
expandable: wrap.hasChildren,
|
|
354
|
+
loaded: wrap.children.length > 0 || !wrap.hasChildren,
|
|
355
|
+
loadDepth: 3,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The renderer whose `accepts` covers `(type, format)`, or null when none does. */
|
|
360
|
+
export function rendererFor(type: string, format: string | null): Renderer | null {
|
|
361
|
+
return REGISTRY.find((r) => r.accepts.some(([t, f]) => t === type && f === format)) ?? null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The renderer for a node: its (type, format) claim, else — for a node stored as a
|
|
365
|
+
* filesystem directory that no format renderer claims (a dir-backed chapter stays a
|
|
366
|
+
* chapter) — the explorer, else null → the default tabbed view. */
|
|
367
|
+
export function getRenderer(node: NodeJson): Renderer | null {
|
|
368
|
+
const r = rendererFor(node.type, node.format ?? null);
|
|
369
|
+
if (r) return r;
|
|
370
|
+
return isDirConcrete(node.concrete) ? EXPLORER : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The name (= representation key / `?format=` value) of the renderer for
|
|
374
|
+
* `(type, format)` — with the same directory-concrete explorer fallback as
|
|
375
|
+
* {@link getRenderer} — or null when none claims it. */
|
|
376
|
+
export function rendererName(type: string, format: string | null, concrete?: string | null): string | null {
|
|
377
|
+
const r = rendererFor(type, format);
|
|
378
|
+
if (r) return r.name;
|
|
379
|
+
return isDirConcrete(concrete) ? EXPLORER.name : null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** How `node` appears in the TOC: its renderer's `tocView`, or — when no renderer
|
|
383
|
+
* claims it — its own children, lazily loaded (the passive default). */
|
|
384
|
+
export function tocView(node: TreeNode): TocView {
|
|
385
|
+
const r = rendererFor(node.type, node.format);
|
|
386
|
+
if (r?.tocView) return r.tocView(node);
|
|
387
|
+
const loaded = node.children.length > 0;
|
|
388
|
+
return { children: node.children, expandable: loaded ? node.children.length > 0 : node.hasChildren, loaded };
|
|
389
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { NodeJson, blobUrl } from "../api";
|
|
3
|
+
import { Chunk } from "./registry";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renderer for an `application/rtf` (`.rtf`) document. RTF is a plain-text control
|
|
7
|
+
* language; rather than pull in a heavy dependency we walk it with a compact,
|
|
8
|
+
* dependency-free converter ({@link rtfToHtml}) that covers the common content:
|
|
9
|
+
* paragraphs, bold/italic/underline runs, tabs/line breaks, hex (`\'xx`, CP-1252)
|
|
10
|
+
* and `\uN` Unicode escapes — skipping the non-content destination groups
|
|
11
|
+
* (`fonttbl`, `colortbl`, `stylesheet`, `info`, pictures, …). The result is shown
|
|
12
|
+
* in the shared `.markup` body, like the Markdown/AsciiDoc renderers.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// The handful of Windows-1252 code points that differ from Latin-1 (0x80–0x9F),
|
|
16
|
+
// used to decode `\'xx` bytes the way most RTF producers mean them.
|
|
17
|
+
const CP1252: Record<number, string> = {
|
|
18
|
+
0x80: "€", 0x82: "‚", 0x83: "ƒ", 0x84: "„", 0x85: "…", 0x86: "†", 0x87: "‡",
|
|
19
|
+
0x88: "ˆ", 0x89: "‰", 0x8a: "Š", 0x8b: "‹", 0x8c: "Œ", 0x8e: "Ž", 0x91: "‘",
|
|
20
|
+
0x92: "’", 0x93: "“", 0x94: "”", 0x95: "•", 0x96: "–", 0x97: "—", 0x98: "˜",
|
|
21
|
+
0x99: "™", 0x9a: "š", 0x9b: "›", 0x9c: "œ", 0x9e: "ž", 0x9f: "Ÿ",
|
|
22
|
+
};
|
|
23
|
+
const byteToChar = (b: number) => (b < 0x80 ? String.fromCharCode(b) : CP1252[b] ?? String.fromCharCode(b));
|
|
24
|
+
|
|
25
|
+
// Group destinations whose contents are not document text — skipped wholesale.
|
|
26
|
+
const SKIP_DESTS = new Set([
|
|
27
|
+
"fonttbl", "colortbl", "stylesheet", "info", "pict", "header", "footer",
|
|
28
|
+
"footnote", "xmlnstbl", "themedata", "colorschememapping", "datastore",
|
|
29
|
+
"latentstyles", "listtable", "listoverridetable", "rsidtbl", "generator",
|
|
30
|
+
"operator", "creatim", "revtim", "printim", "buptim", "author", "title",
|
|
31
|
+
"subject", "keywords", "comment", "company", "manager", "category", "doccomm",
|
|
32
|
+
"hlinkbase", "filetbl", "mmathPr", "fldinst", "object", "nonshppict",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
interface Style {
|
|
36
|
+
b: boolean;
|
|
37
|
+
i: boolean;
|
|
38
|
+
u: boolean;
|
|
39
|
+
uc: number; // Unicode fallback skip count (\ucN)
|
|
40
|
+
}
|
|
41
|
+
interface Run {
|
|
42
|
+
text: string;
|
|
43
|
+
b: boolean;
|
|
44
|
+
i: boolean;
|
|
45
|
+
u: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function escapeHtml(s: string): string {
|
|
49
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Convert RTF source to an HTML string (a sequence of `<p>` paragraphs with
|
|
53
|
+
* `<strong>`/`<em>`/`<u>` runs). Best-effort: unknown control words are ignored. */
|
|
54
|
+
export function rtfToHtml(rtf: string): string {
|
|
55
|
+
const paragraphs: Run[][] = [];
|
|
56
|
+
let runs: Run[] = [];
|
|
57
|
+
let st: Style = { b: false, i: false, u: false, uc: 1 };
|
|
58
|
+
const stack: Style[] = [];
|
|
59
|
+
let depth = 0;
|
|
60
|
+
let skipDepth = Infinity; // skip content while depth >= skipDepth
|
|
61
|
+
const n = rtf.length;
|
|
62
|
+
let i = 0;
|
|
63
|
+
|
|
64
|
+
const active = () => depth < skipDepth;
|
|
65
|
+
const emit = (t: string) => {
|
|
66
|
+
if (!t || !active()) return;
|
|
67
|
+
const last = runs[runs.length - 1];
|
|
68
|
+
if (last && last.b === st.b && last.i === st.i && last.u === st.u) last.text += t;
|
|
69
|
+
else runs.push({ text: t, b: st.b, i: st.i, u: st.u });
|
|
70
|
+
};
|
|
71
|
+
const endPara = () => {
|
|
72
|
+
if (!active()) return;
|
|
73
|
+
paragraphs.push(runs);
|
|
74
|
+
runs = [];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const control = (word: string, param: number | null) => {
|
|
78
|
+
switch (word) {
|
|
79
|
+
case "b": st.b = param !== 0; break;
|
|
80
|
+
case "i": st.i = param !== 0; break;
|
|
81
|
+
case "ul": st.u = param !== 0; break;
|
|
82
|
+
case "ulnone": st.u = false; break;
|
|
83
|
+
case "uc": st.uc = param ?? 1; break;
|
|
84
|
+
case "par": case "row": endPara(); break;
|
|
85
|
+
case "line": case "sect": emit("\n"); break;
|
|
86
|
+
case "tab": case "cell": emit("\t"); break;
|
|
87
|
+
case "pard": case "plain": st.b = st.i = st.u = false; break;
|
|
88
|
+
default:
|
|
89
|
+
if (SKIP_DESTS.has(word)) skipDepth = Math.min(skipDepth, depth);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
while (i < n) {
|
|
95
|
+
const c = rtf[i];
|
|
96
|
+
if (c === "{") {
|
|
97
|
+
stack.push({ ...st });
|
|
98
|
+
depth++;
|
|
99
|
+
i++;
|
|
100
|
+
} else if (c === "}") {
|
|
101
|
+
depth--;
|
|
102
|
+
if (depth < skipDepth) skipDepth = Infinity;
|
|
103
|
+
st = stack.pop() ?? st;
|
|
104
|
+
i++;
|
|
105
|
+
} else if (c === "\\") {
|
|
106
|
+
const next = rtf[i + 1];
|
|
107
|
+
if (next === "\\" || next === "{" || next === "}") {
|
|
108
|
+
emit(next);
|
|
109
|
+
i += 2;
|
|
110
|
+
} else if (next === "*") {
|
|
111
|
+
skipDepth = Math.min(skipDepth, depth); // ignorable destination
|
|
112
|
+
i += 2;
|
|
113
|
+
} else if (next === "'") {
|
|
114
|
+
const b = parseInt(rtf.substr(i + 2, 2), 16);
|
|
115
|
+
if (!isNaN(b)) emit(byteToChar(b));
|
|
116
|
+
i += 4;
|
|
117
|
+
} else if (next === "~") {
|
|
118
|
+
emit(" "); i += 2; // non-breaking space
|
|
119
|
+
} else if (next === "-") {
|
|
120
|
+
i += 2; // optional hyphen — drop
|
|
121
|
+
} else if (/[a-zA-Z]/.test(next ?? "")) {
|
|
122
|
+
let j = i + 1;
|
|
123
|
+
while (j < n && /[a-zA-Z]/.test(rtf[j])) j++;
|
|
124
|
+
const word = rtf.slice(i + 1, j);
|
|
125
|
+
let num = "";
|
|
126
|
+
if (rtf[j] === "-") { num += "-"; j++; }
|
|
127
|
+
while (j < n && /[0-9]/.test(rtf[j])) num += rtf[j++];
|
|
128
|
+
const param = num === "" ? null : parseInt(num, 10);
|
|
129
|
+
if (rtf[j] === " ") j++; // a single trailing space delimits, and is consumed
|
|
130
|
+
i = j;
|
|
131
|
+
if (word === "u" && param !== null) {
|
|
132
|
+
if (active()) emit(String.fromCodePoint(param < 0 ? param + 0x10000 : param));
|
|
133
|
+
// skip the \ucN fallback characters that follow the \u
|
|
134
|
+
for (let skip = st.uc; skip > 0 && i < n; skip--) {
|
|
135
|
+
if (rtf[i] === "\\" && rtf[i + 1] === "'") i += 4;
|
|
136
|
+
else if (rtf[i] === "\\") i += 2;
|
|
137
|
+
else if (rtf[i] === "{" || rtf[i] === "}") break;
|
|
138
|
+
else i++;
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
control(word, param);
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
i++; // a lone backslash before something unexpected
|
|
145
|
+
}
|
|
146
|
+
} else if (c === "\r" || c === "\n") {
|
|
147
|
+
i++; // raw newlines in the source are not content
|
|
148
|
+
} else {
|
|
149
|
+
emit(c);
|
|
150
|
+
i++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
endPara();
|
|
154
|
+
|
|
155
|
+
const renderRun = (r: Run) => {
|
|
156
|
+
let t = escapeHtml(r.text).replace(/\t/g, " ").replace(/\n/g, "<br>");
|
|
157
|
+
if (r.u) t = `<u>${t}</u>`;
|
|
158
|
+
if (r.i) t = `<em>${t}</em>`;
|
|
159
|
+
if (r.b) t = `<strong>${t}</strong>`;
|
|
160
|
+
return t;
|
|
161
|
+
};
|
|
162
|
+
return paragraphs
|
|
163
|
+
.filter((p) => p.some((r) => r.text.trim() || r.text.includes("\n")))
|
|
164
|
+
.map((p) => `<p>${p.map(renderRun).join("")}</p>`)
|
|
165
|
+
.join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Fetch the `.rtf` bytes, decode as Windows-1252 (RTF source is 7-bit with
|
|
169
|
+
* `\'xx` for the rest), and convert to HTML. Shared by the page and chunk forms. */
|
|
170
|
+
function useRtfHtml(path: string): { html: string | null; error: string | null } {
|
|
171
|
+
const [html, setHtml] = useState<string | null>(null);
|
|
172
|
+
const [error, setError] = useState<string | null>(null);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
let cancelled = false;
|
|
175
|
+
setHtml(null);
|
|
176
|
+
setError(null);
|
|
177
|
+
fetch(blobUrl(path))
|
|
178
|
+
.then((r) => r.arrayBuffer())
|
|
179
|
+
.then((buf) => {
|
|
180
|
+
if (cancelled) return;
|
|
181
|
+
const src = new TextDecoder("windows-1252").decode(new Uint8Array(buf));
|
|
182
|
+
setHtml(rtfToHtml(src));
|
|
183
|
+
})
|
|
184
|
+
.catch((e) => !cancelled && setError(String((e as Error).message || e)));
|
|
185
|
+
return () => {
|
|
186
|
+
cancelled = true;
|
|
187
|
+
};
|
|
188
|
+
}, [path]);
|
|
189
|
+
return { html, error };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function RtfView({ node }: { node: NodeJson }) {
|
|
193
|
+
const { html, error } = useRtfHtml(node.path);
|
|
194
|
+
if (error) return <div className="error">rtf: {error}</div>;
|
|
195
|
+
if (html == null) return <div className="loading">reading RTF…</div>;
|
|
196
|
+
return (
|
|
197
|
+
<div className="text">
|
|
198
|
+
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
199
|
+
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
200
|
+
<div className="markup" dangerouslySetInnerHTML={{ __html: html }} />
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function RtfChunk({ chunk }: { chunk: Chunk }) {
|
|
206
|
+
const { html, error } = useRtfHtml(chunk.path);
|
|
207
|
+
if (error) return <div className="error">rtf: {error}</div>;
|
|
208
|
+
if (html == null) return <div className="loading">reading RTF…</div>;
|
|
209
|
+
return <div className="markup" dangerouslySetInnerHTML={{ __html: html }} />;
|
|
210
|
+
}
|