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,36 @@
|
|
|
1
|
+
import katex from "katex";
|
|
2
|
+
import "katex/dist/katex.min.css";
|
|
3
|
+
import { NodeJson } from "../api";
|
|
4
|
+
import { Chunk } from "./registry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The renderer for a `string`/`text/x-latex` node: LaTeX math typeset with KaTeX.
|
|
8
|
+
* Like the markdown/asciidoc renderers it works straight from the node's string
|
|
9
|
+
* value, so it serves both a whole formula node (`render`) and a single inline
|
|
10
|
+
* chunk (`renderChunk`).
|
|
11
|
+
*
|
|
12
|
+
* `renderMath` is the one place KaTeX is invoked, exported so **marklower** can
|
|
13
|
+
* reuse it for its inline `$$…$$` spans — math is rendered the same way whether it
|
|
14
|
+
* is a standalone `text/x-latex` string or embedded in marklower prose.
|
|
15
|
+
*/
|
|
16
|
+
export function renderMath(tex: unknown, displayMode: boolean): string {
|
|
17
|
+
// `throwOnError: false` makes KaTeX emit the offending source in red rather than
|
|
18
|
+
// throwing, so a typo in one formula never blanks the whole page.
|
|
19
|
+
return katex.renderToString(String(tex ?? ""), { displayMode, throwOnError: false });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function LatexView({ node }: { node: NodeJson }) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="text">
|
|
25
|
+
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
26
|
+
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
27
|
+
<div className="markup" dangerouslySetInnerHTML={{ __html: renderMath(node.value, true) }} />
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A LaTeX chunk embedded inline in a chapter: the formula typeset as a display
|
|
33
|
+
* block (the chapter supplies the surrounding number + anchor). */
|
|
34
|
+
export function LatexChunk({ chunk }: { chunk: Chunk }) {
|
|
35
|
+
return <div className="markup" dangerouslySetInnerHTML={{ __html: renderMath(chunk.value, true) }} />;
|
|
36
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import L from "leaflet";
|
|
3
|
+
import "leaflet/dist/leaflet.css";
|
|
4
|
+
import { NodeJson, blobUrl } from "../api";
|
|
5
|
+
import { Chunk } from "./registry";
|
|
6
|
+
import { bytesToGeoJSON, GeoJSON } from "./kml";
|
|
7
|
+
import { Annotation } from "../api";
|
|
8
|
+
import { DEFAULT_COLOR, colorOf, editable, useAnnotationMenu, useMaterialAnnotations } from "./annotate";
|
|
9
|
+
import { wireGestures } from "./panzoom";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Renderer for geographic overlays — `.kml` and `.kmz` (zipped KML). The file is
|
|
13
|
+
* served as bytes; we convert it to GeoJSON (see `kml.ts`) and draw it on a
|
|
14
|
+
* Leaflet slippy map over OpenStreetMap tiles, fitting the view to the data.
|
|
15
|
+
* Points become circle markers, lines/polygons keep their KML colours, and a
|
|
16
|
+
* feature's name/description show in a popup.
|
|
17
|
+
*
|
|
18
|
+
* Gestures follow the unified model (see {@link wireGestures} / the UI guide): plain drag selects a
|
|
19
|
+
* geographic region to annotate, ctrl/alt-drag pans, plain wheel pans vertically, ctrl/alt-wheel
|
|
20
|
+
* zooms.
|
|
21
|
+
*
|
|
22
|
+
* **Network note:** the vector overlay and KML/KMZ parsing are fully local, but the
|
|
23
|
+
* *map tiles* are fetched from a tile server — by default OpenStreetMap. Point
|
|
24
|
+
* `VITE_MAP_TILE_URL` (and optionally `VITE_MAP_TILE_ATTRIBUTION`) at a self-hosted
|
|
25
|
+
* tile server to keep map traffic off the public one. Leaflet is heavy and
|
|
26
|
+
* browser-only, so the registry loads this module lazily.
|
|
27
|
+
*/
|
|
28
|
+
const TILE_URL =
|
|
29
|
+
((import.meta as any).env?.VITE_MAP_TILE_URL as string | undefined) ??
|
|
30
|
+
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
|
31
|
+
const TILE_ATTRIBUTION =
|
|
32
|
+
((import.meta as any).env?.VITE_MAP_TILE_ATTRIBUTION as string | undefined) ??
|
|
33
|
+
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
34
|
+
|
|
35
|
+
/** A rectangular annotation region on the map, in geographic edges (degrees). `ann` is the source
|
|
36
|
+
* annotation when it is a real saved one (→ clickable to edit); absent for the live preview. */
|
|
37
|
+
interface MapRegion { n: number; s: number; e: number; w: number; title?: string; color?: string; ann?: Annotation }
|
|
38
|
+
const num = (v: unknown): number => Number(v) || 0;
|
|
39
|
+
|
|
40
|
+
/** The `map`-type annotations, as geographic rectangles to overlay. */
|
|
41
|
+
function mapRegions(anns: Annotation[]): MapRegion[] {
|
|
42
|
+
return anns
|
|
43
|
+
.filter((a) => a.selector?.type === "map")
|
|
44
|
+
.map((a) => ({ n: num(a.selector!.n), s: num(a.selector!.s), e: num(a.selector!.e), w: num(a.selector!.w), title: a.description, color: colorOf(a), ann: editable(a) ? a : undefined }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function escapeHtml(s: string): string {
|
|
48
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A feature's description as popup HTML. togeojson returns a plain-text KML
|
|
52
|
+
* description as a string (escaped here) and an HTML/CDATA one as
|
|
53
|
+
* `{ "@type": "html", value }` — authored markup, kept as-is (this is a local
|
|
54
|
+
* viewer of the user's own files). */
|
|
55
|
+
function descriptionHtml(description: unknown): string {
|
|
56
|
+
if (!description) return "";
|
|
57
|
+
if (typeof description === "string") return escapeHtml(description);
|
|
58
|
+
if (typeof description === "object" && (description as any)["@type"] === "html") {
|
|
59
|
+
return String((description as any).value ?? "");
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Draw `geo` onto a fresh Leaflet map in `el`, fit to the data, and return the map
|
|
65
|
+
* (so the caller can dispose it). */
|
|
66
|
+
function drawMap(el: HTMLElement, geo: GeoJSON): L.Map {
|
|
67
|
+
const map = L.map(el);
|
|
68
|
+
L.tileLayer(TILE_URL, { maxZoom: 19, attribution: TILE_ATTRIBUTION }).addTo(map);
|
|
69
|
+
const layer = L.geoJSON(geo as any, {
|
|
70
|
+
// honour KML styling that togeojson surfaces as simplestyle properties
|
|
71
|
+
style: (f) => {
|
|
72
|
+
const p = (f?.properties ?? {}) as Record<string, unknown>;
|
|
73
|
+
return {
|
|
74
|
+
color: (p["stroke"] as string) || "#3388ff",
|
|
75
|
+
weight: (p["stroke-width"] as number) || 2,
|
|
76
|
+
opacity: (p["stroke-opacity"] as number) ?? 1,
|
|
77
|
+
fillColor: (p["fill"] as string) || "#3388ff",
|
|
78
|
+
fillOpacity: (p["fill-opacity"] as number) ?? 0.2,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
pointToLayer: (_f, latlng) => L.circleMarker(latlng, { radius: 5, color: "#e23", weight: 2, fillOpacity: 0.8 }),
|
|
82
|
+
onEachFeature: (f, lyr) => {
|
|
83
|
+
const p = (f.properties ?? {}) as { name?: string; description?: unknown };
|
|
84
|
+
const name = p.name ? `<strong>${escapeHtml(p.name)}</strong>` : "";
|
|
85
|
+
const body = descriptionHtml(p.description);
|
|
86
|
+
const desc = body ? `<div class="map-popup-desc">${body}</div>` : "";
|
|
87
|
+
if (name || desc) lyr.bindPopup(`${name}${desc}`);
|
|
88
|
+
},
|
|
89
|
+
}).addTo(map);
|
|
90
|
+
|
|
91
|
+
const bounds = layer.getBounds();
|
|
92
|
+
if (bounds.isValid()) map.fitBounds(bounds, { padding: [24, 24], maxZoom: 16 });
|
|
93
|
+
else map.setView([0, 0], 2); // nothing geocoded — show the whole world
|
|
94
|
+
return map;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Fetch the file, convert to GeoJSON, and render a Leaflet map into `className`.
|
|
98
|
+
* Shared by the full page and the inline chunk (which only differ in height/CSS + annotation). */
|
|
99
|
+
function MapBody({
|
|
100
|
+
path, className, regions, onSelectRegion, onRegionClick, selectColor,
|
|
101
|
+
}: {
|
|
102
|
+
path: string;
|
|
103
|
+
className: string;
|
|
104
|
+
regions?: MapRegion[];
|
|
105
|
+
onSelectRegion?: (selector: Record<string, unknown>, screen: { x: number; y: number }) => void;
|
|
106
|
+
onRegionClick?: (ann: Annotation, screen: { x: number; y: number }) => void;
|
|
107
|
+
selectColor?: () => string;
|
|
108
|
+
}) {
|
|
109
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
110
|
+
const mapRef = useRef<L.Map | null>(null);
|
|
111
|
+
const layerRef = useRef<L.LayerGroup | null>(null);
|
|
112
|
+
const onSelectRef = useRef(onSelectRegion);
|
|
113
|
+
const onRegionClickRef = useRef(onRegionClick);
|
|
114
|
+
const colorRef = useRef(selectColor);
|
|
115
|
+
const [error, setError] = useState<string | null>(null);
|
|
116
|
+
const [loading, setLoading] = useState(true);
|
|
117
|
+
const [ready, setReady] = useState(0);
|
|
118
|
+
const regionsKey = JSON.stringify(regions ?? []);
|
|
119
|
+
const selectable = !!onSelectRegion;
|
|
120
|
+
|
|
121
|
+
useEffect(() => { onSelectRef.current = onSelectRegion; onRegionClickRef.current = onRegionClick; colorRef.current = selectColor; });
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
let cancelled = false;
|
|
125
|
+
let dispose: (() => void) | null = null;
|
|
126
|
+
setError(null);
|
|
127
|
+
setLoading(true);
|
|
128
|
+
fetch(blobUrl(path))
|
|
129
|
+
.then((r) => r.arrayBuffer())
|
|
130
|
+
.then((buf) => {
|
|
131
|
+
if (cancelled || !ref.current) return;
|
|
132
|
+
const map = drawMap(ref.current, bytesToGeoJSON(new Uint8Array(buf)));
|
|
133
|
+
layerRef.current = L.layerGroup().addTo(map);
|
|
134
|
+
mapRef.current = map;
|
|
135
|
+
dispose = wireGestures(map, {
|
|
136
|
+
color: () => colorRef.current?.() ?? DEFAULT_COLOR,
|
|
137
|
+
onSelect: selectable
|
|
138
|
+
? (b, screen) => onSelectRef.current?.({ type: "map", n: b.getNorth(), s: b.getSouth(), e: b.getEast(), w: b.getWest() }, screen)
|
|
139
|
+
: undefined,
|
|
140
|
+
});
|
|
141
|
+
setReady((x) => x + 1);
|
|
142
|
+
setLoading(false);
|
|
143
|
+
})
|
|
144
|
+
.catch((e) => {
|
|
145
|
+
if (!cancelled) {
|
|
146
|
+
setError(String((e as Error).message || e));
|
|
147
|
+
setLoading(false);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return () => {
|
|
151
|
+
cancelled = true;
|
|
152
|
+
dispose?.();
|
|
153
|
+
mapRef.current?.remove();
|
|
154
|
+
mapRef.current = null;
|
|
155
|
+
layerRef.current = null;
|
|
156
|
+
};
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, [path]);
|
|
159
|
+
|
|
160
|
+
// Draw region rectangles into the overlay layer — in place, so creating one keeps the view.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const lg = layerRef.current;
|
|
163
|
+
if (!lg) return;
|
|
164
|
+
lg.clearLayers();
|
|
165
|
+
for (const r of regions ?? []) {
|
|
166
|
+
const c = r.color || DEFAULT_COLOR;
|
|
167
|
+
const rect = L.rectangle([[r.s, r.w], [r.n, r.e]], { className: "yo-region", color: c, weight: 3, fillColor: c, fillOpacity: 0.25 });
|
|
168
|
+
if (r.title) rect.bindTooltip(r.title);
|
|
169
|
+
if (r.ann) {
|
|
170
|
+
const ann = r.ann;
|
|
171
|
+
rect.on("click", (ev) => { L.DomEvent.stop(ev); onRegionClickRef.current?.(ann, { x: ev.originalEvent.clientX, y: ev.originalEvent.clientY }); });
|
|
172
|
+
}
|
|
173
|
+
rect.addTo(lg);
|
|
174
|
+
}
|
|
175
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
+
}, [regionsKey, ready]);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<>
|
|
180
|
+
{error && <div className="error">map: {error}</div>}
|
|
181
|
+
<div ref={ref} className={className + (selectable ? " yo-selectable" : "")} />
|
|
182
|
+
{loading && !error && <div className="loading">loading map…</div>}
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function MapView({ node }: { node: NodeJson }) {
|
|
188
|
+
const material = useMaterialAnnotations(node.path);
|
|
189
|
+
const { openCreate, openEdit, palette, preview, color } = useAnnotationMenu(material);
|
|
190
|
+
const shown = preview
|
|
191
|
+
? [...material.annotations, { path: "(preview)", selector: preview.selector, tag: preview.tag } as Annotation]
|
|
192
|
+
: material.annotations;
|
|
193
|
+
return (
|
|
194
|
+
<div className="text">
|
|
195
|
+
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
196
|
+
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
197
|
+
<MapBody path={node.path} regions={mapRegions(shown)} onSelectRegion={openCreate} onRegionClick={openEdit} selectColor={() => color} className="filemap" />
|
|
198
|
+
{palette}
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function MapChunk({ chunk }: { chunk: Chunk }) {
|
|
204
|
+
return <MapBody path={chunk.path} className="filemap chunk-map" />;
|
|
205
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { NodeJson } from "../api";
|
|
3
|
+
import { Chunk } from "./registry";
|
|
4
|
+
import { renderMath } from "./latex";
|
|
5
|
+
import { NavLink } from "../links";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The renderer for a bare `string` with no explicit format — `(string, null)` —
|
|
9
|
+
* our own lightweight markup language, **marklower**: deliberately a notch below
|
|
10
|
+
* Markdown ("downshifted" from it). It is the *default* format for prose strings
|
|
11
|
+
* inside a chapter: a chunk that declares no format routes here rather than to the
|
|
12
|
+
* plain-paragraph fallback.
|
|
13
|
+
*
|
|
14
|
+
* The language is meant to cover inline concerns only — font styling, hyperlinks,
|
|
15
|
+
* images, math, and (perhaps) embedded code — but deliberately **no** chapter
|
|
16
|
+
* structure: no headings/subheadings, since chapters are modeled by the chapter
|
|
17
|
+
* renderer's `children`, not by markup.
|
|
18
|
+
*
|
|
19
|
+
* The syntax so far is all inline:
|
|
20
|
+
*
|
|
21
|
+
* - **atomic tokens**, whose contents are *not* re-interpreted as markup:
|
|
22
|
+
* `$$…$$` math (typeset with KaTeX via the shared {@link renderMath}, the same
|
|
23
|
+
* path the `text/x-latex` renderer uses) and `` `code` `` spans;
|
|
24
|
+
* - **links**: `[text](target)`, where `target` is a path in the app's JSON
|
|
25
|
+
* instance space (the same space the whole app navigates). Resolved and made
|
|
26
|
+
* clickable through the shared {@link NavLink} — the one link concept that refs
|
|
27
|
+
* and rels are expected to adopt later;
|
|
28
|
+
* - **text styling** on the plain runs between those: `**bold**`/`__bold__`,
|
|
29
|
+
* `*italic*`/`_italic_`, and `~~strikethrough~~`.
|
|
30
|
+
*
|
|
31
|
+
* Anything else is passed through verbatim. `parse` is the single seam every entry
|
|
32
|
+
* point goes through, so there is one place to teach the grammar.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Escape a plain-text run so it can be dropped into HTML (`parse` emits HTML now
|
|
36
|
+
* that some tokens — math, code, emphasis — render to markup). */
|
|
37
|
+
function escapeHtml(s: string): string {
|
|
38
|
+
return s
|
|
39
|
+
.replace(/&/g, "&")
|
|
40
|
+
.replace(/</g, "<")
|
|
41
|
+
.replace(/>/g, ">");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Style a plain-text run (one of the stretches between the atomic tokens): escape
|
|
45
|
+
* it, then apply emphasis. Bold (`**`/`__`) runs before italic (`*`/`_`) so a
|
|
46
|
+
* double marker isn't mistaken for two single ones; non-greedy so neighbours don't
|
|
47
|
+
* merge. The markers (`* _ ~`) survive `escapeHtml`, so styling the escaped text
|
|
48
|
+
* is safe. */
|
|
49
|
+
function styleText(text: string): string {
|
|
50
|
+
return escapeHtml(text)
|
|
51
|
+
.replace(/~~(.+?)~~/g, "<del>$1</del>")
|
|
52
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
53
|
+
.replace(/__(.+?)__/g, "<strong>$1</strong>")
|
|
54
|
+
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
55
|
+
.replace(/_(.+?)_/g, "<em>$1</em>");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The non-text tokens, in one alternation matched in source order: `$$…$$` math
|
|
59
|
+
* (group 1; `[\s\S]` so a formula may span lines), a `` `code` `` span (group 2),
|
|
60
|
+
* or a `[label](target)` link (groups 3 = label, 4 = target). Non-greedy so
|
|
61
|
+
* adjacent tokens don't merge. The label may contain a balanced `[…]` (so a path
|
|
62
|
+
* used as its own label — `[/children[0]](/children[0])` — works), but a stray `]`
|
|
63
|
+
* is not a label, so a non-link `[a]` in prose is left alone. */
|
|
64
|
+
const TOKEN = /\$\$([\s\S]+?)\$\$|`([^`]+?)`|\[((?:[^\[\]]|\[[^\]]*\])*?)\]\(([^)]+?)\)/g;
|
|
65
|
+
|
|
66
|
+
/** Parse marklower into React nodes. Most syntax renders to an HTML string (math,
|
|
67
|
+
* code, emphasis), accumulated and flushed into `<span>`s; a link must be a real
|
|
68
|
+
* element so it navigates in-app (an HTML `<a href>` would reload), so the result
|
|
69
|
+
* is a node list, not one HTML string. `documentPath` anchors a link's `/…`
|
|
70
|
+
* (document-relative) target. */
|
|
71
|
+
function parse(value: unknown, onNavigate: (path: string) => void, documentPath?: string): ReactNode[] {
|
|
72
|
+
const src = String(value ?? "");
|
|
73
|
+
const nodes: ReactNode[] = [];
|
|
74
|
+
let html = ""; // buffer of HTML-rendered runs between links
|
|
75
|
+
let key = 0;
|
|
76
|
+
const flush = () => {
|
|
77
|
+
if (!html) return;
|
|
78
|
+
nodes.push(<span key={key++} dangerouslySetInnerHTML={{ __html: html }} />);
|
|
79
|
+
html = "";
|
|
80
|
+
};
|
|
81
|
+
let last = 0;
|
|
82
|
+
for (const m of src.matchAll(TOKEN)) {
|
|
83
|
+
html += styleText(src.slice(last, m.index)); // plain run before this token
|
|
84
|
+
if (m[1] !== undefined) {
|
|
85
|
+
html += renderMath(m[1], false); // $$ inline math $$
|
|
86
|
+
} else if (m[2] !== undefined) {
|
|
87
|
+
html += `<code>${escapeHtml(m[2])}</code>`; // `code` — contents literal
|
|
88
|
+
} else {
|
|
89
|
+
// [label](target) — a real anchor so it navigates in JSON instance space; the
|
|
90
|
+
// label keeps its own inline styling.
|
|
91
|
+
flush();
|
|
92
|
+
nodes.push(
|
|
93
|
+
<NavLink key={key++} target={m[4]} documentPath={documentPath} onNavigate={onNavigate}>
|
|
94
|
+
<span dangerouslySetInnerHTML={{ __html: styleText(m[3]) }} />
|
|
95
|
+
</NavLink>,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
last = m.index + m[0].length;
|
|
99
|
+
}
|
|
100
|
+
html += styleText(src.slice(last));
|
|
101
|
+
flush();
|
|
102
|
+
return nodes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function MarklowerView({ node, onNavigate }: { node: NodeJson; onNavigate: (path: string) => void }) {
|
|
106
|
+
return (
|
|
107
|
+
<div className="marklower">
|
|
108
|
+
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
109
|
+
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
110
|
+
<p className="chapter-prose">{parse(node.value, onNavigate, node.documentPath)}</p>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A marklower chunk embedded inline in a chapter (the chapter supplies the
|
|
116
|
+
* surrounding number + anchor). */
|
|
117
|
+
export function MarklowerChunk({ chunk, onNavigate }: { chunk: Chunk; onNavigate: (path: string) => void }) {
|
|
118
|
+
return <p className="chapter-prose">{parse(chunk.value, onNavigate, chunk.documentPath)}</p>;
|
|
119
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The rendered-HTML body for the markdown/asciidoc page views, plus the control that sets its
|
|
5
|
+
* line-wrap measure. The **reading width is a URL parameter** — `?width=<ch>`, alongside
|
|
6
|
+
* `?format=` — so a particular width is a shareable link (the CSV renderer keeps its options in
|
|
7
|
+
* the query the same way). Default 72ch. The control lives in the tab bar next to the renderer
|
|
8
|
+
* button (see NodeView), not in the body. Chapter *chunks* render plain `.markup`, unaffected.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_WIDTH_CH = 72;
|
|
11
|
+
const MIN_CH = 20;
|
|
12
|
+
const MAX_CH = 400;
|
|
13
|
+
const params = () => new URLSearchParams(window.location.search);
|
|
14
|
+
|
|
15
|
+
/** The reading width in `ch` from the URL's `?width=`, or the default (out-of-range ignored). */
|
|
16
|
+
export function markupWidthCh(): number {
|
|
17
|
+
const w = Number(params().get("width"));
|
|
18
|
+
return Number.isFinite(w) && w >= MIN_CH && w <= MAX_CH ? w : DEFAULT_WIDTH_CH;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeWidth(ch: number): void {
|
|
22
|
+
const q = params();
|
|
23
|
+
if (ch === DEFAULT_WIDTH_CH) q.delete("width");
|
|
24
|
+
else q.set("width", String(ch));
|
|
25
|
+
const qs = q.toString();
|
|
26
|
+
window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : ""));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The markdown/asciidoc body at the URL-configured reading width. */
|
|
30
|
+
export function Markup({ html }: { html: string }) {
|
|
31
|
+
return <div className="markup" style={{ maxWidth: `${markupWidthCh()}ch` }} dangerouslySetInnerHTML={{ __html: html }} />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The width control beside the markdown/asciidoc renderer button (in the tab bar). It accepts
|
|
36
|
+
* ANY input — a valid measure (20–400 ch) is applied to the URL and `rerender()` re-wraps the
|
|
37
|
+
* body; an impossible/half-typed value is simply left unapplied and the field turns red (no
|
|
38
|
+
* editing is blocked). No visible label: the hover title reads "width, ch".
|
|
39
|
+
*/
|
|
40
|
+
export function MarkupWidthControl({ rerender }: { rerender: () => void }) {
|
|
41
|
+
const urlWidth = markupWidthCh();
|
|
42
|
+
const [text, setText] = useState(String(urlWidth));
|
|
43
|
+
useEffect(() => setText(String(urlWidth)), [urlWidth]); // resync when the URL changes (nav / apply)
|
|
44
|
+
const n = Number(text);
|
|
45
|
+
const valid = text.trim() !== "" && Number.isInteger(n) && n >= MIN_CH && n <= MAX_CH;
|
|
46
|
+
return (
|
|
47
|
+
<input
|
|
48
|
+
className={"markup-width" + (valid ? "" : " invalid")}
|
|
49
|
+
type="text"
|
|
50
|
+
inputMode="numeric"
|
|
51
|
+
title="width, ch"
|
|
52
|
+
value={text}
|
|
53
|
+
onChange={(e) => {
|
|
54
|
+
const v = e.target.value;
|
|
55
|
+
setText(v);
|
|
56
|
+
const num = Number(v);
|
|
57
|
+
if (v.trim() !== "" && Number.isInteger(num) && num >= MIN_CH && num <= MAX_CH) {
|
|
58
|
+
writeWidth(num);
|
|
59
|
+
rerender();
|
|
60
|
+
}
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NodeJson, blobUrl } from "../api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renderer for a file-backed HTML document (`<iframe>`), pointing at the server's `/api/blob`
|
|
5
|
+
* endpoint, which streams the file's raw bytes with its inferred Content-Type — so there is no
|
|
6
|
+
* base64 round-trip through the JSON API. (Images live in `imagemap.tsx`: a pan/zoom viewer.)
|
|
7
|
+
*/
|
|
8
|
+
export function HtmlView({ node }: { node: NodeJson }) {
|
|
9
|
+
// sandboxed: same-origin so the page's own CSS/images (served from /api/blob)
|
|
10
|
+
// load, but no scripts run — a saved web page renders without taking over.
|
|
11
|
+
return (
|
|
12
|
+
<iframe
|
|
13
|
+
className="filehtml"
|
|
14
|
+
src={blobUrl(node.path)}
|
|
15
|
+
sandbox="allow-same-origin"
|
|
16
|
+
title={node.title ?? node.path}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import L from "leaflet";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The unified pan/zoom/select gesture model for a Leaflet map (shared by the image viewer and the
|
|
5
|
+
* KML map, see the UI guide). It overrides Leaflet's defaults so SELECTING is the primary gesture:
|
|
6
|
+
*
|
|
7
|
+
* - plain DRAG → rubber-band a selection rectangle (→ `onSelect`, to annotate the region)
|
|
8
|
+
* - ctrl/alt DRAG → pan (grab-and-drag the canvas)
|
|
9
|
+
* - plain WHEEL → pan vertically (scroll the canvas, like scrolling text)
|
|
10
|
+
* - ctrl/alt WHEEL→ zoom around the cursor
|
|
11
|
+
*
|
|
12
|
+
* When `onSelect` is omitted (e.g. an inline chapter chunk, which has no annotation target), plain
|
|
13
|
+
* drag PANS instead (Leaflet's default) and the plain wheel is left alone so the page keeps
|
|
14
|
+
* scrolling — only ctrl/alt-wheel zoom is added. Returns a disposer to unwire everything.
|
|
15
|
+
*/
|
|
16
|
+
export interface GestureOptions {
|
|
17
|
+
/** A plain-drag rectangle finished. `bounds` is in the map's coordinate space (CRS.Simple pixels
|
|
18
|
+
* for an image, lat/lng for a map); `screen` is the viewport point to anchor the menu at. */
|
|
19
|
+
onSelect?: (bounds: L.LatLngBounds, screen: { x: number; y: number }) => void;
|
|
20
|
+
/** The live rubber-band color (the current highlight color). */
|
|
21
|
+
color?: () => string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const hasMod = (e: MouseEvent | WheelEvent): boolean => e.ctrlKey || e.altKey || e.metaKey;
|
|
25
|
+
|
|
26
|
+
export function wireGestures(map: L.Map, opts: GestureOptions): () => void {
|
|
27
|
+
const container = map.getContainer();
|
|
28
|
+
const selectable = !!opts.onSelect;
|
|
29
|
+
|
|
30
|
+
// Selecting needs drag free for the rubber-band; without it, keep Leaflet's drag-to-pan.
|
|
31
|
+
if (selectable) map.dragging.disable();
|
|
32
|
+
map.scrollWheelZoom.disable(); // we drive the wheel ourselves (pan vs. zoom by modifier)
|
|
33
|
+
map.doubleClickZoom.disable();
|
|
34
|
+
map.boxZoom.disable();
|
|
35
|
+
|
|
36
|
+
let band: L.Rectangle | null = null;
|
|
37
|
+
let start: L.LatLng | null = null;
|
|
38
|
+
let startPt: L.Point | null = null;
|
|
39
|
+
let panning = false;
|
|
40
|
+
let panPrev: L.Point | null = null;
|
|
41
|
+
|
|
42
|
+
const onDown = (e: L.LeafletMouseEvent) => {
|
|
43
|
+
if (e.originalEvent.button !== 0) return; // left button only — right/middle never select or pan
|
|
44
|
+
if (hasMod(e.originalEvent)) {
|
|
45
|
+
if (!selectable) return; // dragging still enabled → Leaflet pans natively
|
|
46
|
+
panning = true; // modifier-drag → manual pan
|
|
47
|
+
panPrev = e.containerPoint;
|
|
48
|
+
L.DomUtil.disableTextSelection();
|
|
49
|
+
} else if (selectable) {
|
|
50
|
+
start = e.latlng; // plain drag → rubber-band a selection
|
|
51
|
+
startPt = e.containerPoint;
|
|
52
|
+
const c = opts.color?.() ?? "#f9e2af";
|
|
53
|
+
// a bold dashed "marching ants" rubber-band so the selection is clearly visible over a busy map
|
|
54
|
+
band = L.rectangle(L.latLngBounds(e.latlng, e.latlng), { className: "yo-band", color: c, weight: 3, dashArray: "6 4", fillColor: c, fillOpacity: 0.25, interactive: false }).addTo(map);
|
|
55
|
+
L.DomUtil.disableTextSelection();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const onMove = (e: L.LeafletMouseEvent) => {
|
|
59
|
+
if (panning && panPrev) {
|
|
60
|
+
map.panBy(panPrev.subtract(e.containerPoint), { animate: false });
|
|
61
|
+
panPrev = e.containerPoint;
|
|
62
|
+
} else if (band && start) {
|
|
63
|
+
band.setBounds(L.latLngBounds(start, e.latlng));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const onUp = (e: L.LeafletMouseEvent) => {
|
|
67
|
+
L.DomUtil.enableTextSelection();
|
|
68
|
+
if (panning) { panning = false; panPrev = null; return; }
|
|
69
|
+
if (band && start && startPt) {
|
|
70
|
+
const bounds = L.latLngBounds(start, e.latlng);
|
|
71
|
+
const moved = startPt.distanceTo(e.containerPoint) >= 4; // ignore a click (zero-size drag)
|
|
72
|
+
band.remove();
|
|
73
|
+
band = null; start = null; startPt = null;
|
|
74
|
+
const oe = e.originalEvent;
|
|
75
|
+
if (moved) opts.onSelect!(bounds, { x: oe.clientX, y: oe.clientY });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
map.on("mousedown", onDown);
|
|
79
|
+
map.on("mousemove", onMove);
|
|
80
|
+
map.on("mouseup", onUp);
|
|
81
|
+
|
|
82
|
+
const onWheel = (ev: WheelEvent) => {
|
|
83
|
+
if (hasMod(ev)) {
|
|
84
|
+
ev.preventDefault();
|
|
85
|
+
map.setZoomAround(map.mouseEventToLatLng(ev), map.getZoom() + (ev.deltaY < 0 ? 1 : -1));
|
|
86
|
+
} else if (selectable) {
|
|
87
|
+
ev.preventDefault(); // a full image/map view pans vertically on a plain wheel
|
|
88
|
+
map.panBy([0, ev.deltaY], { animate: false });
|
|
89
|
+
}
|
|
90
|
+
// else (chunk, plain wheel): let the event bubble so the page scrolls
|
|
91
|
+
};
|
|
92
|
+
container.addEventListener("wheel", onWheel, { passive: false });
|
|
93
|
+
|
|
94
|
+
return () => {
|
|
95
|
+
map.off("mousedown", onDown);
|
|
96
|
+
map.off("mousemove", onMove);
|
|
97
|
+
map.off("mouseup", onUp);
|
|
98
|
+
container.removeEventListener("wheel", onWheel);
|
|
99
|
+
band?.remove();
|
|
100
|
+
};
|
|
101
|
+
}
|