yamlover 0.3.2 → 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 +59 -7
- 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/djvu.tsx +8 -1
- package/src/client/renderers/explorer.tsx +90 -5
- 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/pdf.tsx +9 -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 +40 -3
- package/src/server/embed.ts +187 -0
- package/src/server/engine-api.ts +243 -122
- package/src/server/node-kind.ts +15 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Asciidoctor from "@asciidoctor/core";
|
|
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";
|
|
@@ -25,7 +26,7 @@ export function AsciidocView({ node }: { node: NodeJson }) {
|
|
|
25
26
|
<div className="text">
|
|
26
27
|
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
27
28
|
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
28
|
-
<Markup html={adoc(node.value)} />
|
|
29
|
+
<Markup html={adoc(scalarValue(node.value))} />
|
|
29
30
|
</div>
|
|
30
31
|
);
|
|
31
32
|
}
|
|
@@ -100,9 +100,13 @@ function ChunkBlock({
|
|
|
100
100
|
path: link?.path ?? "",
|
|
101
101
|
type: link?.type ?? "string",
|
|
102
102
|
format: link?.format ?? null,
|
|
103
|
+
// the renderer-dispatch facets (TYPES.md §9): a link carries them; a bare inline chunk is a string
|
|
104
|
+
valueType: link?.valueType ?? "string",
|
|
105
|
+
hasKeyed: link?.hasKeyed ?? false,
|
|
106
|
+
hasOrdinal: link?.hasOrdinal ?? false,
|
|
103
107
|
documentPath, // carried so a marklower chunk's `/…` link resolves to its document
|
|
104
108
|
};
|
|
105
|
-
const renderer = rendererFor(chunk
|
|
109
|
+
const renderer = rendererFor(chunk);
|
|
106
110
|
const body = renderer?.renderChunk
|
|
107
111
|
? renderer.renderChunk(chunk, onNavigate)
|
|
108
112
|
: <p className="chapter-prose">{String(chunk.value ?? "")}</p>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NodeJson } from "../api";
|
|
2
|
+
import { scalarValue } from "../render";
|
|
2
3
|
import { Chunk } from "./registry";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -163,7 +164,7 @@ export function CsvView({ node }: { node: NodeJson }) {
|
|
|
163
164
|
// the single source of truth — this view holds no parsing state of its own.
|
|
164
165
|
const p = params();
|
|
165
166
|
const header = headerOn(p);
|
|
166
|
-
const text = String(node.value ?? "");
|
|
167
|
+
const text = String(scalarValue(node.value) ?? "");
|
|
167
168
|
const sep = decodeSep(p.get("sep")) ?? autoSep(text, node.format ?? null);
|
|
168
169
|
const rows = parseDelimited(text, sep);
|
|
169
170
|
|
|
@@ -67,6 +67,13 @@ export function DjvuView({ node }: { node: NodeJson }) {
|
|
|
67
67
|
pagedRef.current = paged;
|
|
68
68
|
useLayoutEffect(() => { paged.restoreAnchor(); }, [zoom]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
69
69
|
|
|
70
|
+
// Focus the `.filedjvu` scroller on mount so arrows / space / PageUp-Down scroll the document
|
|
71
|
+
// natively (it's a nested scroller the focused RHS pane can't reach). Skip when chunk-embedded.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const el = ref.current;
|
|
74
|
+
if (el && !el.closest(".chunk-body")) el.focus({ preventScroll: true });
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
70
77
|
// Track the pane width so a page fits but is capped (≤1000px) like the PDF viewer.
|
|
71
78
|
useLayoutEffect(() => {
|
|
72
79
|
const el = ref.current;
|
|
@@ -205,7 +212,7 @@ export function DjvuView({ node }: { node: NodeJson }) {
|
|
|
205
212
|
if (error) return <div className="error">djvu: {error}</div>;
|
|
206
213
|
return (
|
|
207
214
|
<>
|
|
208
|
-
<div className="filedjvu yo-zoomable" ref={ref} onMouseUp={onMouseUp}>
|
|
215
|
+
<div className="filedjvu yo-zoomable" ref={ref} tabIndex={0} onMouseUp={onMouseUp}>
|
|
209
216
|
{count === 0 && <div className="loading">opening djvu…</div>}
|
|
210
217
|
{width > 0 &&
|
|
211
218
|
Array.from({ length: count }, (_, i) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { NodeJson, fetchTagged } from "../api";
|
|
3
3
|
import { asLink, Link } from "../render";
|
|
4
4
|
import { typeIcon } from "../icons";
|
|
@@ -117,12 +117,46 @@ function isDocFormat(f?: string | null): boolean {
|
|
|
117
117
|
return !!f && (f.includes("/") || f.startsWith("x-yamlover-"));
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
/** The grid item to move to from `cur` for an arrow key. Left/Right step in reading
|
|
121
|
+
* order; Up/Down pick the nearest item in the adjacent row, preferring the same
|
|
122
|
+
* column — measured from live geometry, so it's correct for the wrapping flex grid
|
|
123
|
+
* (variable columns, a short last row) without knowing the column count. */
|
|
124
|
+
function arrowTarget(els: (HTMLElement | null)[], cur: number, key: string, count: number): number {
|
|
125
|
+
if (key === "ArrowRight") return Math.min(cur + 1, count - 1);
|
|
126
|
+
if (key === "ArrowLeft") return Math.max(cur - 1, 0);
|
|
127
|
+
if (key === "Home") return 0;
|
|
128
|
+
if (key === "End") return count - 1;
|
|
129
|
+
const a = els[cur];
|
|
130
|
+
if (!a) return cur;
|
|
131
|
+
const r = a.getBoundingClientRect();
|
|
132
|
+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
133
|
+
const down = key === "ArrowDown";
|
|
134
|
+
let best = cur, bestScore = Infinity;
|
|
135
|
+
for (let i = 0; i < count; i++) {
|
|
136
|
+
const el = els[i];
|
|
137
|
+
if (!el || i === cur) continue;
|
|
138
|
+
const ri = el.getBoundingClientRect();
|
|
139
|
+
const ix = ri.left + ri.width / 2, iy = ri.top + ri.height / 2;
|
|
140
|
+
if (down ? iy <= cy + 1 : iy >= cy - 1) continue; // must lie in the arrow's direction
|
|
141
|
+
const score = Math.abs(ix - cx) * 2 + Math.abs(iy - cy); // prefer same column, nearest row
|
|
142
|
+
if (score < bestScore) { bestScore = score; best = i; }
|
|
143
|
+
}
|
|
144
|
+
return best;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function Item({ it, active, setRef, onFocus, onNavigate }: {
|
|
148
|
+
it: ExplorerItem;
|
|
149
|
+
active: boolean;
|
|
150
|
+
setRef: (el: HTMLElement | null) => void;
|
|
151
|
+
onFocus: () => void;
|
|
152
|
+
onNavigate: (path: string) => void;
|
|
153
|
+
}) {
|
|
121
154
|
const link = it.link;
|
|
155
|
+
const tabIndex = active ? 0 : -1; // roving tabindex: only the selected item is in the tab order
|
|
122
156
|
if (!link) {
|
|
123
157
|
// not a marker (unexpected at depth 1) — an inert label, no navigation
|
|
124
158
|
return (
|
|
125
|
-
<span className="dirview-item">
|
|
159
|
+
<span className="dirview-item" ref={setRef} tabIndex={tabIndex} onFocus={onFocus}>
|
|
126
160
|
<span className="dirview-icon t-bin">•</span>
|
|
127
161
|
<span className="dirview-label">{it.key}: {scalarText(it.raw)}</span>
|
|
128
162
|
</span>
|
|
@@ -147,6 +181,9 @@ function Item({ it, onNavigate }: { it: ExplorerItem; onNavigate: (path: string)
|
|
|
147
181
|
className={"dirview-item" + (it.up ? " dirview-up" : "")}
|
|
148
182
|
href={link.path}
|
|
149
183
|
title={displayPath(link.path)}
|
|
184
|
+
ref={setRef}
|
|
185
|
+
tabIndex={tabIndex}
|
|
186
|
+
onFocus={onFocus}
|
|
150
187
|
onClick={(e) => {
|
|
151
188
|
e.preventDefault();
|
|
152
189
|
onNavigate(link.path);
|
|
@@ -189,6 +226,43 @@ export function ExplorerView({ node, onNavigate }: { node: NodeJson; onNavigate:
|
|
|
189
226
|
}
|
|
190
227
|
const items = [...ups, ...members];
|
|
191
228
|
|
|
229
|
+
// Roving keyboard focus over the grid: plain arrows walk the icons, Enter opens the
|
|
230
|
+
// selected one. The item elements are tracked by index for the geometry-based row moves.
|
|
231
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
232
|
+
const itemEls = useRef<(HTMLElement | null)[]>([]);
|
|
233
|
+
itemEls.current.length = items.length; // drop stale refs when the member list shrinks
|
|
234
|
+
const [active, setActive] = useState(0);
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (active > items.length - 1) setActive(Math.max(0, items.length - 1));
|
|
237
|
+
}, [items.length, active]);
|
|
238
|
+
|
|
239
|
+
// On navigating to a new directory, reset the selection and re-arm autofocus (this
|
|
240
|
+
// component is reused across nodes, not remounted, so the flag must follow `node.path`).
|
|
241
|
+
const wantFocus = useRef(true);
|
|
242
|
+
useEffect(() => { setActive(0); wantFocus.current = true; }, [node.path]);
|
|
243
|
+
// Focus the first item once the grid has members (a tag's load async), so arrows work right
|
|
244
|
+
// after navigating here (the RHS pane handed us focus) — once per node, and never when
|
|
245
|
+
// embedded as a chapter chunk (several grids would fight over focus).
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (!wantFocus.current || !items.length) return;
|
|
248
|
+
wantFocus.current = false;
|
|
249
|
+
if (gridRef.current?.closest(".chunk-body")) return; // embedded — don't steal focus
|
|
250
|
+
itemEls.current[0]?.focus({ preventScroll: true });
|
|
251
|
+
}, [items.length, node.path]);
|
|
252
|
+
|
|
253
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
254
|
+
if (e.ctrlKey || e.altKey || e.metaKey || !items.length) return; // Ctrl/Alt+arrows = TOC nav (App)
|
|
255
|
+
if (e.key === "Enter") {
|
|
256
|
+
const link = items[active]?.link;
|
|
257
|
+
if (link) { e.preventDefault(); onNavigate(link.path); }
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (!/^(Arrow(Up|Down|Left|Right)|Home|End)$/.test(e.key)) return;
|
|
261
|
+
e.preventDefault(); // don't also scroll the pane
|
|
262
|
+
const next = arrowTarget(itemEls.current, active, e.key, items.length);
|
|
263
|
+
if (next !== active) { setActive(next); itemEls.current[next]?.focus(); }
|
|
264
|
+
};
|
|
265
|
+
|
|
192
266
|
// a tag page's description is its BODY (the header bar already names the node)
|
|
193
267
|
const desc = (isTag ? tagBody(node.value) : null) ?? node.description;
|
|
194
268
|
return (
|
|
@@ -198,9 +272,20 @@ export function ExplorerView({ node, onNavigate }: { node: NodeJson; onNavigate:
|
|
|
198
272
|
<p className="tagdesc">{desc}</p>
|
|
199
273
|
</div>
|
|
200
274
|
)}
|
|
201
|
-
<div
|
|
275
|
+
<div
|
|
276
|
+
ref={gridRef}
|
|
277
|
+
className={"dirview" + (explorerViewMode() === "large" ? " dirview-lg" : "")}
|
|
278
|
+
onKeyDown={onKeyDown}
|
|
279
|
+
>
|
|
202
280
|
{items.map((it, i) => (
|
|
203
|
-
<Item
|
|
281
|
+
<Item
|
|
282
|
+
key={`${it.up ? "^" : ""}${it.link?.path ?? it.key}#${i}`}
|
|
283
|
+
it={it}
|
|
284
|
+
active={i === active}
|
|
285
|
+
setRef={(el) => { itemEls.current[i] = el; }}
|
|
286
|
+
onFocus={() => setActive(i)}
|
|
287
|
+
onNavigate={onNavigate}
|
|
288
|
+
/>
|
|
204
289
|
))}
|
|
205
290
|
{items.length === 0 && <span className="dirview-empty">empty</span>}
|
|
206
291
|
</div>
|
|
@@ -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
|
}
|
|
@@ -96,6 +96,14 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
96
96
|
// After a zoom COMMIT reflows the pages, restore the captured reading position.
|
|
97
97
|
useLayoutEffect(() => { paged.restoreAnchor(); }, [zoom]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
98
98
|
|
|
99
|
+
// The `.filepdf` scroller is its OWN scroll container nested in the (focused) RHS pane, so the
|
|
100
|
+
// pane's focus can't drive it — focus it on mount so arrows / space / PageUp-Down / Home-End
|
|
101
|
+
// scroll the document natively. Skip when embedded as a chapter chunk (several would fight + jump).
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const el = ref.current;
|
|
104
|
+
if (el && !el.closest(".chunk-body")) el.focus({ preventScroll: true });
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
99
107
|
// Track the pane width so pages re-flow on resize.
|
|
100
108
|
useLayoutEffect(() => {
|
|
101
109
|
const el = ref.current;
|
|
@@ -226,7 +234,7 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
226
234
|
|
|
227
235
|
return (
|
|
228
236
|
<>
|
|
229
|
-
<div className="filepdf yo-zoomable" ref={ref} onMouseUp={onMouseUp}>
|
|
237
|
+
<div className="filepdf yo-zoomable" ref={ref} tabIndex={0} onMouseUp={onMouseUp}>
|
|
230
238
|
<Document
|
|
231
239
|
file={blobUrl(node.path)}
|
|
232
240
|
onLoadSuccess={({ numPages }) => setPages(numPages)}
|
|
@@ -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
|
}
|