yamlover 0.3.3 → 0.3.4
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/dist/server.js +370 -109
- package/package.json +1 -1
- package/src/client/App.tsx +3 -3
- package/src/client/api.ts +45 -18
- package/src/client/render.tsx +16 -3
- package/src/client/renderers/annotate.tsx +173 -53
- package/src/client/renderers/asciidoc.tsx +2 -1
- package/src/client/renderers/chapter.tsx +5 -1
- package/src/client/renderers/csv.tsx +2 -1
- package/src/client/renderers/imagemap.tsx +19 -6
- package/src/client/renderers/latex.tsx +2 -1
- package/src/client/renderers/marklower.tsx +2 -1
- package/src/client/renderers/plantuml.tsx +2 -1
- package/src/client/renderers/registry.tsx +91 -69
- package/src/client/renderers/text.tsx +2 -1
- package/src/client/styles.css +25 -3
- package/src/server/embed.ts +187 -0
- package/src/server/engine-api.ts +243 -122
- package/src/server/node-kind.ts +15 -2
|
@@ -13,6 +13,19 @@ export interface ImageRegion { x: number; y: number; w: number; h: number; title
|
|
|
13
13
|
|
|
14
14
|
const num = (v: unknown): number => Number(v) || 0;
|
|
15
15
|
|
|
16
|
+
/** A PNG data-URL crop of the natural-pixel region (x,y,w,h) of `img`, for an image-like
|
|
17
|
+
* fragment's embedded preview; undefined if the region is empty or the canvas reads back tainted
|
|
18
|
+
* (cross-origin — image blobs are same-origin, so this is just a guard). */
|
|
19
|
+
function cropPng(img: HTMLImageElement | null, x: number, y: number, w: number, h: number): string | undefined {
|
|
20
|
+
if (!img || w <= 0 || h <= 0) return undefined;
|
|
21
|
+
const cv = document.createElement("canvas");
|
|
22
|
+
cv.width = w; cv.height = h;
|
|
23
|
+
const ctx = cv.getContext("2d");
|
|
24
|
+
if (!ctx) return undefined;
|
|
25
|
+
ctx.drawImage(img, x, y, w, h, 0, 0, w, h);
|
|
26
|
+
try { return cv.toDataURL("image/png"); } catch { return undefined; }
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
/** The `rect`-type annotations, as pixel regions to overlay on the image. */
|
|
17
30
|
function imageRegions(anns: Annotation[]): ImageRegion[] {
|
|
18
31
|
return anns
|
|
@@ -33,13 +46,14 @@ export function PanZoomImage({
|
|
|
33
46
|
src: string;
|
|
34
47
|
className: string;
|
|
35
48
|
regions?: ImageRegion[];
|
|
36
|
-
onSelectRegion?: (selector: Record<string, unknown>, screen: { x: number; y: number }) => void;
|
|
49
|
+
onSelectRegion?: (selector: Record<string, unknown>, screen: { x: number; y: number }, imageBase64?: string) => void;
|
|
37
50
|
onRegionClick?: (ann: Annotation, screen: { x: number; y: number }) => void;
|
|
38
51
|
selectColor?: () => string;
|
|
39
52
|
}) {
|
|
40
53
|
const ref = useRef<HTMLDivElement>(null);
|
|
41
54
|
const mapRef = useRef<L.Map | null>(null);
|
|
42
55
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
|
56
|
+
const imgElRef = useRef<HTMLImageElement | null>(null);
|
|
43
57
|
const sizeRef = useRef({ w: 1, h: 1 });
|
|
44
58
|
const onSelectRef = useRef(onSelectRegion);
|
|
45
59
|
const onRegionClickRef = useRef(onRegionClick);
|
|
@@ -62,6 +76,7 @@ export function PanZoomImage({
|
|
|
62
76
|
const w = img.naturalWidth || 1;
|
|
63
77
|
const h = img.naturalHeight || 1;
|
|
64
78
|
sizeRef.current = { w, h };
|
|
79
|
+
imgElRef.current = img; // kept for cropping a selected region (same-origin → un-tainted canvas)
|
|
65
80
|
// CRS.Simple: coordinates are raw pixels (y, x); negative minZoom allows zooming far out.
|
|
66
81
|
const map = L.map(ref.current, { crs: L.CRS.Simple, minZoom: -8, attributionControl: false, zoomSnap: 0 });
|
|
67
82
|
const bounds: L.LatLngBoundsExpression = [[0, 0], [h, w]];
|
|
@@ -76,10 +91,8 @@ export function PanZoomImage({
|
|
|
76
91
|
// image pixels have y from the top; CRS.Simple lat is from the bottom → flip.
|
|
77
92
|
const { h: ih } = sizeRef.current;
|
|
78
93
|
const west = b.getWest(), east = b.getEast(), south = b.getSouth(), north = b.getNorth();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
screen,
|
|
82
|
-
);
|
|
94
|
+
const x = Math.round(west), y = Math.round(ih - north), w = Math.round(east - west), hh = Math.round(north - south);
|
|
95
|
+
onSelectRef.current?.({ type: "rect", x, y, w, h: hh }, screen, cropPng(imgElRef.current, x, y, w, hh));
|
|
83
96
|
}
|
|
84
97
|
: undefined,
|
|
85
98
|
});
|
|
@@ -141,7 +154,7 @@ export function ImageView({ node }: { node: NodeJson }) {
|
|
|
141
154
|
<PanZoomImage
|
|
142
155
|
src={blobUrl(node.path)}
|
|
143
156
|
regions={imageRegions(shown)}
|
|
144
|
-
onSelectRegion={openCreate}
|
|
157
|
+
onSelectRegion={(sel, screen, crop) => openCreate(sel, screen, undefined, crop)}
|
|
145
158
|
onRegionClick={openEdit}
|
|
146
159
|
selectColor={() => color}
|
|
147
160
|
className="filemap fileimagemap"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import katex from "katex";
|
|
2
2
|
import "katex/dist/katex.min.css";
|
|
3
3
|
import { NodeJson } from "../api";
|
|
4
|
+
import { scalarValue } from "../render";
|
|
4
5
|
import { Chunk } from "./registry";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -24,7 +25,7 @@ export function LatexView({ node }: { node: NodeJson }) {
|
|
|
24
25
|
<div className="text">
|
|
25
26
|
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
26
27
|
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
27
|
-
<div className="markup" dangerouslySetInnerHTML={{ __html: renderMath(node.value, true) }} />
|
|
28
|
+
<div className="markup" dangerouslySetInnerHTML={{ __html: renderMath(scalarValue(node.value), true) }} />
|
|
28
29
|
</div>
|
|
29
30
|
);
|
|
30
31
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ReactNode } from "react";
|
|
2
2
|
import { NodeJson } from "../api";
|
|
3
|
+
import { scalarValue } from "../render";
|
|
3
4
|
import { Chunk } from "./registry";
|
|
4
5
|
import { renderMath } from "./latex";
|
|
5
6
|
import { NavLink } from "../links";
|
|
@@ -107,7 +108,7 @@ export function MarklowerView({ node, onNavigate }: { node: NodeJson; onNavigate
|
|
|
107
108
|
<div className="marklower">
|
|
108
109
|
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
109
110
|
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
110
|
-
<p className="chapter-prose">{parse(node.value, onNavigate, node.documentPath)}</p>
|
|
111
|
+
<p className="chapter-prose">{parse(scalarValue(node.value), onNavigate, node.documentPath)}</p>
|
|
111
112
|
</div>
|
|
112
113
|
);
|
|
113
114
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deflateSync } from "fflate";
|
|
2
2
|
import { NodeJson } from "../api";
|
|
3
|
+
import { scalarValue } from "../render";
|
|
3
4
|
import { Chunk } from "./registry";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -71,7 +72,7 @@ export function PlantumlView({ node }: { node: NodeJson }) {
|
|
|
71
72
|
<div className="text">
|
|
72
73
|
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
73
74
|
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
74
|
-
<Diagram source={String(node.value ?? "")} />
|
|
75
|
+
<Diagram source={String(scalarValue(node.value) ?? "")} />
|
|
75
76
|
</div>
|
|
76
77
|
);
|
|
77
78
|
}
|
|
@@ -79,6 +79,9 @@ export interface Chunk {
|
|
|
79
79
|
path: string;
|
|
80
80
|
type: string;
|
|
81
81
|
format: string | null;
|
|
82
|
+
valueType?: string | null; // renderer dispatch facets (TYPES.md §9) — so a tagged chunk still routes
|
|
83
|
+
hasKeyed?: boolean;
|
|
84
|
+
hasOrdinal?: boolean;
|
|
82
85
|
/** The JSON-space path of the document this chunk belongs to (the enclosing
|
|
83
86
|
* chapter's `documentPath`) — the anchor a document-relative (`/…`) marklower
|
|
84
87
|
* link in the chunk resolves against. */
|
|
@@ -99,11 +102,32 @@ export interface TocView {
|
|
|
99
102
|
loadDepth?: number;
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
/** The three TYPE FACETS a renderer dispatches on (TYPES.md §9): the scalar self-VALUE's type,
|
|
106
|
+
* the node's `format`, and whether it owns keyed/ordinal elements. */
|
|
107
|
+
export interface TypeFacets {
|
|
108
|
+
valueType: string | null;
|
|
109
|
+
format: string | null;
|
|
110
|
+
hasKeyed: boolean;
|
|
111
|
+
hasOrdinal: boolean;
|
|
112
|
+
}
|
|
113
|
+
/** A renderer's acceptance predicate — a hand-coded type formula. What it does NOT test, it
|
|
114
|
+
* TOLERATES: a `byFormat("text/markdown")` matcher ignores the keyed/ordinal facets, so a
|
|
115
|
+
* markdown chunk that gained `yamlover-annotations` keys (an omni node) still matches. */
|
|
116
|
+
export type Accepts = (f: TypeFacets) => boolean;
|
|
117
|
+
|
|
118
|
+
/** Any projected node/chunk/link shape carrying the facet fields. */
|
|
119
|
+
type FacetSource = { type?: string; format?: string | null; valueType?: string | null; hasKeyed?: boolean; hasOrdinal?: boolean };
|
|
120
|
+
const facetsFrom = (n: FacetSource): TypeFacets => ({ valueType: n.valueType ?? null, format: n.format ?? null, hasKeyed: !!n.hasKeyed, hasOrdinal: !!n.hasOrdinal });
|
|
121
|
+
/** The common matcher: claims a node whose `format` is one of `fmts` — tolerant of all structure. */
|
|
122
|
+
const byFormat = (...fmts: string[]): Accepts => (f) => f.format !== null && fmts.includes(f.format);
|
|
123
|
+
|
|
102
124
|
export interface Renderer {
|
|
103
125
|
name: string;
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
126
|
+
/** Whether this renderer claims a node, from its {@link TypeFacets}. */
|
|
127
|
+
accepts: Accepts;
|
|
128
|
+
/** Tie-break among matches — the highest wins. Format matchers are 2; the bare-string
|
|
129
|
+
* default (marklower) is 1. */
|
|
130
|
+
specificity: number;
|
|
107
131
|
/** Value depth `NodeView` must fetch for this renderer (default 1). A chapter
|
|
108
132
|
* needs 2: its `chunks`/`children` arrays one level, their elements the next. */
|
|
109
133
|
depth?: number;
|
|
@@ -129,12 +153,10 @@ export interface Renderer {
|
|
|
129
153
|
*/
|
|
130
154
|
const EXPLORER: Renderer = {
|
|
131
155
|
name: "explorer",
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
["null", "x-yamlover-tag"],
|
|
137
|
-
],
|
|
156
|
+
// a tag, whatever shape it projects (object / variant / leaf string / bare null) — the format
|
|
157
|
+
// alone identifies it; the grid shows the tagged MATERIALS. Also the dir-concrete fallback below.
|
|
158
|
+
accepts: byFormat("x-yamlover-tag"),
|
|
159
|
+
specificity: 2,
|
|
138
160
|
render: (node, onNavigate) => <ExplorerView node={node} onNavigate={onNavigate} />,
|
|
139
161
|
config: (rerender) => <ExplorerViewControl rerender={rerender} />, // large/small icons (`?view=`)
|
|
140
162
|
};
|
|
@@ -145,7 +167,8 @@ const isDirConcrete = (concrete: string | null | undefined): boolean => concrete
|
|
|
145
167
|
const REGISTRY: Renderer[] = [
|
|
146
168
|
{
|
|
147
169
|
name: "chapter",
|
|
148
|
-
accepts:
|
|
170
|
+
accepts: byFormat("x-yamlover-chapter"),
|
|
171
|
+
specificity: 2,
|
|
149
172
|
depth: 2, // reach the chunk/subchapter elements (arrays one level, items the next)
|
|
150
173
|
tocView: chapterTocView,
|
|
151
174
|
render: (node, onNavigate) => <ChapterView node={node} onNavigate={onNavigate} />,
|
|
@@ -155,10 +178,8 @@ const REGISTRY: Renderer[] = [
|
|
|
155
178
|
// notch below Markdown. A chapter's prose chunks route here — both the bare
|
|
156
179
|
// (string, null) form and the explicit `text/marklower` the chunk schema applies.
|
|
157
180
|
name: "marklower",
|
|
158
|
-
accepts:
|
|
159
|
-
|
|
160
|
-
["string", "text/marklower"],
|
|
161
|
-
],
|
|
181
|
+
accepts: (f) => f.format === "text/marklower" || (f.format === null && f.valueType === "string"),
|
|
182
|
+
specificity: 1, // the bare-string default — a tagged bare string (format-less) still routes here
|
|
162
183
|
render: (node, onNavigate) => <MarklowerView node={node} onNavigate={onNavigate} />,
|
|
163
184
|
renderChunk: (chunk, onNavigate) => <MarklowerChunk chunk={chunk} onNavigate={onNavigate} />,
|
|
164
185
|
},
|
|
@@ -166,14 +187,16 @@ const REGISTRY: Renderer[] = [
|
|
|
166
187
|
// Markdown (the component file is text.tsx for historical reasons; the renderer —
|
|
167
188
|
// its tab label and `?format=` key — is named for what it renders).
|
|
168
189
|
name: "markdown",
|
|
169
|
-
accepts:
|
|
190
|
+
accepts: byFormat("text/markdown"),
|
|
191
|
+
specificity: 2,
|
|
170
192
|
render: (node) => <TextView node={node} />,
|
|
171
193
|
renderChunk: (chunk) => <TextChunk chunk={chunk} />,
|
|
172
194
|
config: (rerender) => <MarkupWidthControl rerender={rerender} />,
|
|
173
195
|
},
|
|
174
196
|
{
|
|
175
197
|
name: "asciidoc",
|
|
176
|
-
accepts:
|
|
198
|
+
accepts: byFormat("text/asciidoc"),
|
|
199
|
+
specificity: 2,
|
|
177
200
|
render: (node) => <AsciidocView node={node} />,
|
|
178
201
|
renderChunk: (chunk) => <AsciidocChunk chunk={chunk} />,
|
|
179
202
|
config: (rerender) => <MarkupWidthControl rerender={rerender} />,
|
|
@@ -182,10 +205,8 @@ const REGISTRY: Renderer[] = [
|
|
|
182
205
|
// Delimited text (CSV/TSV, a string) shown as a table; its parsing options
|
|
183
206
|
// (separator, header) ride in the URL query — see csv.tsx.
|
|
184
207
|
name: "csv",
|
|
185
|
-
accepts:
|
|
186
|
-
|
|
187
|
-
["string", "text/tab-separated-values"],
|
|
188
|
-
],
|
|
208
|
+
accepts: byFormat("text/csv", "text/tab-separated-values"),
|
|
209
|
+
specificity: 2,
|
|
189
210
|
render: (node) => <CsvView node={node} />,
|
|
190
211
|
renderChunk: (chunk) => <CsvChunk chunk={chunk} />,
|
|
191
212
|
config: (rerender) => <CsvControls rerender={rerender} />,
|
|
@@ -195,7 +216,8 @@ const REGISTRY: Renderer[] = [
|
|
|
195
216
|
// CP866 / Windows-1251 / KOI8-R / UTF-8 (see plaintext.tsx). Served as raw
|
|
196
217
|
// bytes so the encoding is the client's to choose.
|
|
197
218
|
name: "plaintext",
|
|
198
|
-
accepts:
|
|
219
|
+
accepts: byFormat("text/plain"),
|
|
220
|
+
specificity: 2,
|
|
199
221
|
render: (node) => <PlaintextView node={node} />,
|
|
200
222
|
renderChunk: (chunk) => <PlaintextChunk chunk={chunk} />,
|
|
201
223
|
config: (rerender) => <EncodingControl rerender={rerender} />,
|
|
@@ -203,41 +225,40 @@ const REGISTRY: Renderer[] = [
|
|
|
203
225
|
{
|
|
204
226
|
// RTF — a dependency-free converter to HTML (see rtf.tsx).
|
|
205
227
|
name: "rtf",
|
|
206
|
-
accepts:
|
|
228
|
+
accepts: byFormat("application/rtf"),
|
|
229
|
+
specificity: 2,
|
|
207
230
|
render: (node) => <RtfView node={node} />,
|
|
208
231
|
renderChunk: (chunk) => <RtfChunk chunk={chunk} />,
|
|
209
232
|
},
|
|
210
233
|
{
|
|
211
234
|
// .docx (Office Open XML) via mammoth, lazily loaded.
|
|
212
235
|
name: "docx",
|
|
213
|
-
accepts:
|
|
236
|
+
accepts: byFormat("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
|
237
|
+
specificity: 2,
|
|
214
238
|
render: (node) => lazily(<DocxView node={node} />),
|
|
215
239
|
renderChunk: (chunk) => lazily(<DocxChunk chunk={chunk} />),
|
|
216
240
|
},
|
|
217
241
|
{
|
|
218
242
|
// Excel workbooks — .xlsx and legacy .xls — via SheetJS, lazily loaded.
|
|
219
243
|
name: "spreadsheet",
|
|
220
|
-
accepts:
|
|
221
|
-
|
|
222
|
-
["binary", "application/vnd.ms-excel"],
|
|
223
|
-
],
|
|
244
|
+
accepts: byFormat("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel"),
|
|
245
|
+
specificity: 2,
|
|
224
246
|
render: (node) => lazily(<SpreadsheetView node={node} />),
|
|
225
247
|
renderChunk: (chunk) => lazily(<SpreadsheetChunk chunk={chunk} />),
|
|
226
248
|
},
|
|
227
249
|
{
|
|
228
250
|
// Legacy .doc (Word 97–2003 binary) — no in-browser parser; download fallback.
|
|
229
251
|
name: "doc",
|
|
230
|
-
accepts:
|
|
252
|
+
accepts: byFormat("application/msword"),
|
|
253
|
+
specificity: 2,
|
|
231
254
|
render: (node) => <DocView node={node} />,
|
|
232
255
|
renderChunk: (chunk) => <DocChunk chunk={chunk} />,
|
|
233
256
|
},
|
|
234
257
|
{
|
|
235
258
|
// KML / KMZ geographic overlays drawn on a Leaflet map (lazily loaded).
|
|
236
259
|
name: "map",
|
|
237
|
-
accepts:
|
|
238
|
-
|
|
239
|
-
["binary", "application/vnd.google-earth.kmz"],
|
|
240
|
-
],
|
|
260
|
+
accepts: byFormat("application/vnd.google-earth.kml+xml", "application/vnd.google-earth.kmz"),
|
|
261
|
+
specificity: 2,
|
|
241
262
|
render: (node) => lazily(<MapView node={node} />),
|
|
242
263
|
renderChunk: (chunk) => lazily(<MapChunk chunk={chunk} />),
|
|
243
264
|
},
|
|
@@ -245,7 +266,8 @@ const REGISTRY: Renderer[] = [
|
|
|
245
266
|
// LaTeX math (a string) typeset with KaTeX, both whole and inline. marklower
|
|
246
267
|
// reuses the same engine for its `$$…$$` spans.
|
|
247
268
|
name: "latex",
|
|
248
|
-
accepts:
|
|
269
|
+
accepts: byFormat("text/x-latex"),
|
|
270
|
+
specificity: 2,
|
|
249
271
|
render: (node) => <LatexView node={node} />,
|
|
250
272
|
renderChunk: (chunk) => <LatexChunk chunk={chunk} />,
|
|
251
273
|
},
|
|
@@ -253,7 +275,8 @@ const REGISTRY: Renderer[] = [
|
|
|
253
275
|
// PlantUML source (a string) shown as the diagram it compiles to, both as a
|
|
254
276
|
// whole node and inline as a chapter chunk.
|
|
255
277
|
name: "plantuml",
|
|
256
|
-
accepts:
|
|
278
|
+
accepts: byFormat("text/x-plantuml"),
|
|
279
|
+
specificity: 2,
|
|
257
280
|
render: (node) => <PlantumlView node={node} />,
|
|
258
281
|
renderChunk: (chunk) => <PlantumlChunk chunk={chunk} />,
|
|
259
282
|
},
|
|
@@ -261,64 +284,64 @@ const REGISTRY: Renderer[] = [
|
|
|
261
284
|
{
|
|
262
285
|
// File-backed binaries the server tags with an inferred image format.
|
|
263
286
|
name: "image",
|
|
264
|
-
accepts:
|
|
265
|
-
|
|
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
|
-
],
|
|
287
|
+
accepts: byFormat("image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/bmp", "image/x-icon", "image/svg+xml"),
|
|
288
|
+
specificity: 2,
|
|
274
289
|
render: (node) => lazily(<ImageView node={node} />),
|
|
275
290
|
renderChunk: (chunk) => lazily(<ImageChunk chunk={chunk} />),
|
|
276
291
|
},
|
|
277
292
|
{
|
|
278
293
|
name: "html",
|
|
279
|
-
accepts:
|
|
294
|
+
accepts: byFormat("text/html"),
|
|
295
|
+
specificity: 2,
|
|
280
296
|
render: (node) => <HtmlView node={node} />,
|
|
281
297
|
renderChunk: (chunk) => <HtmlView node={chunkNode(chunk)} />,
|
|
282
298
|
},
|
|
283
299
|
{
|
|
284
300
|
name: "fb2",
|
|
285
|
-
accepts:
|
|
301
|
+
accepts: byFormat("application/x-fictionbook+xml"),
|
|
302
|
+
specificity: 2,
|
|
286
303
|
render: (node) => <Fb2View node={node} />,
|
|
287
304
|
renderChunk: (chunk) => <Fb2View node={chunkNode(chunk)} />,
|
|
288
305
|
},
|
|
289
306
|
{
|
|
290
307
|
name: "epub",
|
|
291
|
-
accepts:
|
|
308
|
+
accepts: byFormat("application/epub+zip"),
|
|
309
|
+
specificity: 2,
|
|
292
310
|
render: (node) => <EpubView node={node} />,
|
|
293
311
|
renderChunk: (chunk) => <EpubView node={chunkNode(chunk)} />,
|
|
294
312
|
},
|
|
295
313
|
{
|
|
296
314
|
name: "pdf",
|
|
297
|
-
accepts:
|
|
315
|
+
accepts: byFormat("application/pdf"),
|
|
316
|
+
specificity: 2,
|
|
298
317
|
render: (node) => lazily(<PdfView node={node} />),
|
|
299
318
|
renderChunk: (chunk) => lazily(<PdfView node={chunkNode(chunk)} />),
|
|
300
319
|
},
|
|
301
320
|
{
|
|
302
321
|
name: "djvu",
|
|
303
|
-
accepts:
|
|
322
|
+
accepts: byFormat("image/vnd.djvu"),
|
|
323
|
+
specificity: 2,
|
|
304
324
|
render: (node) => lazily(<DjvuView node={node} />),
|
|
305
325
|
renderChunk: (chunk) => lazily(<DjvuView node={chunkNode(chunk)} />),
|
|
306
326
|
},
|
|
307
327
|
{
|
|
308
328
|
name: "psd",
|
|
309
|
-
accepts:
|
|
329
|
+
accepts: byFormat("image/vnd.adobe.photoshop"),
|
|
330
|
+
specificity: 2,
|
|
310
331
|
render: (node) => lazily(<PsdView node={node} />),
|
|
311
332
|
renderChunk: (chunk) => lazily(<PsdView node={chunkNode(chunk)} />),
|
|
312
333
|
},
|
|
313
334
|
{
|
|
314
335
|
name: "tiff",
|
|
315
|
-
accepts:
|
|
336
|
+
accepts: byFormat("image/tiff"),
|
|
337
|
+
specificity: 2,
|
|
316
338
|
render: (node) => lazily(<TiffView node={node} />),
|
|
317
339
|
renderChunk: (chunk) => lazily(<TiffView node={chunkNode(chunk)} />),
|
|
318
340
|
},
|
|
319
341
|
{
|
|
320
342
|
name: "heic",
|
|
321
|
-
accepts:
|
|
343
|
+
accepts: byFormat("image/heic"),
|
|
344
|
+
specificity: 2,
|
|
322
345
|
render: (node) => lazily(<HeicView node={node} />),
|
|
323
346
|
renderChunk: (chunk) => lazily(<HeicView node={chunkNode(chunk)} />),
|
|
324
347
|
},
|
|
@@ -356,33 +379,32 @@ function chapterTocView(node: TreeNode): TocView {
|
|
|
356
379
|
};
|
|
357
380
|
}
|
|
358
381
|
|
|
359
|
-
/** The renderer
|
|
360
|
-
|
|
361
|
-
|
|
382
|
+
/** The renderer that claims `src`'s facets, or null when none does: of the matchers that accept
|
|
383
|
+
* it, the most SPECIFIC (highest `specificity`) wins (TYPES.md §9). */
|
|
384
|
+
export function rendererFor(src: FacetSource): Renderer | null {
|
|
385
|
+
const f = facetsFrom(src);
|
|
386
|
+
let best: Renderer | null = null;
|
|
387
|
+
for (const r of REGISTRY) if (r.accepts(f) && (best === null || r.specificity > best.specificity)) best = r;
|
|
388
|
+
return best;
|
|
362
389
|
}
|
|
363
390
|
|
|
364
|
-
/** The renderer for a node: its
|
|
365
|
-
*
|
|
366
|
-
*
|
|
391
|
+
/** The renderer for a node: its facet claim, else — for a node stored as a filesystem directory
|
|
392
|
+
* that no format renderer claims (a dir-backed chapter stays a chapter) — the explorer, else
|
|
393
|
+
* null → the default tabbed view. */
|
|
367
394
|
export function getRenderer(node: NodeJson): Renderer | null {
|
|
368
|
-
|
|
369
|
-
if (r) return r;
|
|
370
|
-
return isDirConcrete(node.concrete) ? EXPLORER : null;
|
|
395
|
+
return rendererFor(node) ?? (isDirConcrete(node.concrete) ? EXPLORER : null);
|
|
371
396
|
}
|
|
372
397
|
|
|
373
|
-
/** The name (= representation key / `?format=` value) of the renderer
|
|
374
|
-
*
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const r = rendererFor(type, format);
|
|
378
|
-
if (r) return r.name;
|
|
379
|
-
return isDirConcrete(concrete) ? EXPLORER.name : null;
|
|
398
|
+
/** The name (= representation key / `?format=` value) of the renderer that claims `src` — with the
|
|
399
|
+
* same directory-concrete explorer fallback as {@link getRenderer} — or null when none claims it. */
|
|
400
|
+
export function rendererName(src: FacetSource, concrete?: string | null): string | null {
|
|
401
|
+
return rendererFor(src)?.name ?? (isDirConcrete(concrete) ? EXPLORER.name : null);
|
|
380
402
|
}
|
|
381
403
|
|
|
382
404
|
/** How `node` appears in the TOC: its renderer's `tocView`, or — when no renderer
|
|
383
405
|
* claims it — its own children, lazily loaded (the passive default). */
|
|
384
406
|
export function tocView(node: TreeNode): TocView {
|
|
385
|
-
const r = rendererFor(node
|
|
407
|
+
const r = rendererFor(node);
|
|
386
408
|
if (r?.tocView) return r.tocView(node);
|
|
387
409
|
const loaded = node.children.length > 0;
|
|
388
410
|
return { children: node.children, expandable: loaded ? node.children.length > 0 : node.hasChildren, loaded };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { marked } from "marked";
|
|
2
2
|
import { NodeJson } from "../api";
|
|
3
|
+
import { scalarValue } from "../render";
|
|
3
4
|
import { Chunk } from "./registry";
|
|
4
5
|
import { anchorizeHeadings, useHashScroll } from "./headings";
|
|
5
6
|
import { Markup } from "./markup";
|
|
@@ -29,7 +30,7 @@ export function TextView({ node }: { node: NodeJson }) {
|
|
|
29
30
|
<div className="text">
|
|
30
31
|
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
31
32
|
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
32
|
-
<Markup html={md(node.value)} />
|
|
33
|
+
<Markup html={md(scalarValue(node.value))} />
|
|
33
34
|
</div>
|
|
34
35
|
);
|
|
35
36
|
}
|
package/src/client/styles.css
CHANGED
|
@@ -873,7 +873,8 @@ a.chunk-index:hover {
|
|
|
873
873
|
flex-wrap: wrap;
|
|
874
874
|
gap: 4px;
|
|
875
875
|
}
|
|
876
|
-
.annotate-recents .tagtag
|
|
876
|
+
.annotate-recents .tagtag,
|
|
877
|
+
.annotate-suggest .tagtag {
|
|
877
878
|
border: 0;
|
|
878
879
|
font: inherit;
|
|
879
880
|
font-size: 11px;
|
|
@@ -885,10 +886,12 @@ a.chunk-index:hover {
|
|
|
885
886
|
unclipped WRAPPER the child is clipped first; then a first round of hard offset drop-shadows
|
|
886
887
|
in the MENU BACKGROUND color grows the silhouette by 1px (the gap), and a second round in
|
|
887
888
|
the foreground color rings that — gap + ring, both following the actual tag shape. */
|
|
888
|
-
.annotate-recents .tagframe
|
|
889
|
+
.annotate-recents .tagframe,
|
|
890
|
+
.annotate-suggest .tagframe {
|
|
889
891
|
display: inline-flex;
|
|
890
892
|
}
|
|
891
|
-
.annotate-recents .tagframe.sel
|
|
893
|
+
.annotate-recents .tagframe.sel,
|
|
894
|
+
.annotate-suggest .tagframe.sel {
|
|
892
895
|
filter:
|
|
893
896
|
drop-shadow(1px 0 0 var(--bg-alt))
|
|
894
897
|
drop-shadow(-1px 0 0 var(--bg-alt))
|
|
@@ -915,6 +918,25 @@ a.chunk-index:hover {
|
|
|
915
918
|
outline: none;
|
|
916
919
|
border-color: var(--dim);
|
|
917
920
|
}
|
|
921
|
+
/* the typeahead: the path input plus a suggestion list below it, spanning the menu width */
|
|
922
|
+
.annotate-typeahead {
|
|
923
|
+
flex-basis: 100%;
|
|
924
|
+
min-width: 0;
|
|
925
|
+
position: relative;
|
|
926
|
+
}
|
|
927
|
+
.annotate-typeahead .annotate-taginput {
|
|
928
|
+
width: 100%;
|
|
929
|
+
box-sizing: border-box;
|
|
930
|
+
}
|
|
931
|
+
/* matched named tags as badges (the highlighted one rings via .tagframe.sel) */
|
|
932
|
+
.annotate-suggest {
|
|
933
|
+
display: flex;
|
|
934
|
+
flex-wrap: wrap;
|
|
935
|
+
gap: 4px;
|
|
936
|
+
margin-top: 5px;
|
|
937
|
+
max-height: 132px;
|
|
938
|
+
overflow-y: auto;
|
|
939
|
+
}
|
|
918
940
|
/* a region annotation drawn over a PDF page (the image overlay uses a Leaflet rectangle) */
|
|
919
941
|
.pdf-page {
|
|
920
942
|
position: relative;
|