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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/bin/yamlover.js +202 -0
  4. package/dist/server.js +4681 -0
  5. package/index.html +12 -0
  6. package/package.json +72 -0
  7. package/src/client/App.tsx +372 -0
  8. package/src/client/NodeView.tsx +422 -0
  9. package/src/client/TaskStrip.tsx +34 -0
  10. package/src/client/Tree.tsx +97 -0
  11. package/src/client/api.ts +186 -0
  12. package/src/client/icons.ts +91 -0
  13. package/src/client/links.tsx +108 -0
  14. package/src/client/live.ts +42 -0
  15. package/src/client/main.tsx +10 -0
  16. package/src/client/paste-html.ts +228 -0
  17. package/src/client/paste-links.ts +42 -0
  18. package/src/client/paths.ts +109 -0
  19. package/src/client/render.tsx +326 -0
  20. package/src/client/renderers/annotate.tsx +507 -0
  21. package/src/client/renderers/asciidoc.tsx +35 -0
  22. package/src/client/renderers/chapter.tsx +138 -0
  23. package/src/client/renderers/csv.tsx +233 -0
  24. package/src/client/renderers/decoded.tsx +72 -0
  25. package/src/client/renderers/djvu.tsx +97 -0
  26. package/src/client/renderers/doc.tsx +40 -0
  27. package/src/client/renderers/docx.tsx +49 -0
  28. package/src/client/renderers/epub.tsx +147 -0
  29. package/src/client/renderers/explorer.tsx +209 -0
  30. package/src/client/renderers/fb2.tsx +149 -0
  31. package/src/client/renderers/headings.ts +69 -0
  32. package/src/client/renderers/heic.tsx +23 -0
  33. package/src/client/renderers/imagemap.tsx +157 -0
  34. package/src/client/renderers/kml.ts +46 -0
  35. package/src/client/renderers/latex.tsx +36 -0
  36. package/src/client/renderers/map.tsx +205 -0
  37. package/src/client/renderers/marklower.tsx +119 -0
  38. package/src/client/renderers/markup.tsx +64 -0
  39. package/src/client/renderers/media.tsx +19 -0
  40. package/src/client/renderers/panzoom.ts +101 -0
  41. package/src/client/renderers/pdf.tsx +176 -0
  42. package/src/client/renderers/plaintext.tsx +120 -0
  43. package/src/client/renderers/plantuml.tsx +82 -0
  44. package/src/client/renderers/psd.tsx +25 -0
  45. package/src/client/renderers/registry.tsx +389 -0
  46. package/src/client/renderers/rtf.tsx +210 -0
  47. package/src/client/renderers/spreadsheet.tsx +105 -0
  48. package/src/client/renderers/tag.tsx +113 -0
  49. package/src/client/renderers/text.tsx +41 -0
  50. package/src/client/renderers/tiff.tsx +33 -0
  51. package/src/client/styles.css +1115 -0
  52. package/src/client/vendor/README.md +30 -0
  53. package/src/client/vendor/djvu.js +15535 -0
  54. package/src/client/vite-env.d.ts +31 -0
  55. package/src/server/api.ts +147 -0
  56. package/src/server/engine-api.ts +1442 -0
  57. package/src/server/gitignore.ts +81 -0
  58. package/src/server/node-kind.ts +48 -0
  59. package/src/server/tasks.ts +83 -0
  60. 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
+ '&copy; <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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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, "&amp;")
40
+ .replace(/</g, "&lt;")
41
+ .replace(/>/g, "&gt;");
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
+ }