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,209 @@
1
+ import { useEffect, useState } from "react";
2
+ import { NodeJson, fetchTagged } from "../api";
3
+ import { asLink, Link } from "../render";
4
+ import { typeIcon } from "../icons";
5
+ import { TAG_FORMAT, tagLabel, tagBody, resolveTagColor } from "./tag";
6
+ import { displayPath, displayKey } from "../paths";
7
+ import { touchesYamlover, useDiffBump } from "../live";
8
+
9
+ const ANNOTATION_FORMAT = "x-yamlover-annotation";
10
+ const MIXED_KEY = "$yamloverMixed";
11
+
12
+ // ---- the view mode: a URL parameter (`?view=`), so a view is a shareable link ---- //
13
+
14
+ const VIEWS = ["large", "small"] as const;
15
+ type ViewMode = (typeof VIEWS)[number];
16
+ const DEFAULT_VIEW: ViewMode = "large";
17
+ const params = () => new URLSearchParams(window.location.search);
18
+
19
+ /** The grid view from the URL's `?view=`, or the default (an unknown value ignored). */
20
+ export function explorerViewMode(): ViewMode {
21
+ const v = params().get("view");
22
+ return (VIEWS as readonly string[]).includes(v ?? "") ? (v as ViewMode) : DEFAULT_VIEW;
23
+ }
24
+
25
+ function writeViewMode(v: ViewMode): void {
26
+ const q = params();
27
+ if (v === DEFAULT_VIEW) q.delete("view");
28
+ else q.set("view", v);
29
+ const qs = q.toString();
30
+ window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : ""));
31
+ }
32
+
33
+ /** The view selector beside the explorer tab (the renderer's `config` hook) — writes
34
+ * `?view=` and rerenders, like the plaintext encoding control. */
35
+ export function ExplorerViewControl({ rerender }: { rerender: () => void }) {
36
+ return (
37
+ <label className="enc-control">
38
+ view{" "}
39
+ <select
40
+ value={explorerViewMode()}
41
+ onChange={(e) => {
42
+ writeViewMode(e.target.value as ViewMode);
43
+ rerender();
44
+ }}
45
+ >
46
+ <option value="large">large icons</option>
47
+ <option value="small">small icons</option>
48
+ </select>
49
+ </label>
50
+ );
51
+ }
52
+
53
+ /**
54
+ * The EXPLORER renderer — a directory (or a tag) as a desktop file manager:
55
+ * every member an icon + label, with the node's reverse UPLINKS (`relations`:
56
+ * the `..` parent and each upstream `*`/`~` source) leading the grid as
57
+ * visually distinct items. Two views, chosen by the `?view=` URL parameter
58
+ * (a renderer param, like the markup width or the CSV options): **large
59
+ * icons** (the default — tiles, the icon above the label) and **small icons**
60
+ * (rows, the icon beside the label).
61
+ *
62
+ * It claims two shapes:
63
+ * - a node stored as a filesystem directory (`concrete` `dir`/`yamlover`,
64
+ * the registry's concrete fallback) — ALL members show, not just files:
65
+ * scalar members read `key: value`, containers and binaries link onward;
66
+ * - a tag (`x-yamlover-tag`) — the members are the MATERIALS filed under it
67
+ * (GET /api/tagged: annotations resolved to their `target`, deduped),
68
+ * alongside its owned fields (subtags as colored badges); the mediating
69
+ * annotation nodes themselves stay out of the grid.
70
+ */
71
+
72
+ /** One grid item: a navigable link marker (or a defensive non-link member). */
73
+ export interface ExplorerItem {
74
+ key: string; // the label source: a member key, `[i]`, or a relation key (`..`, `/eve`, …)
75
+ link: Link | null;
76
+ raw?: unknown; // the value when it is not a link marker (rendered inert)
77
+ up?: boolean; // an uplink (relations) item — shown first, styled distinct
78
+ }
79
+
80
+ /** The uplink items: the node's `relations` in server order (`..` always first). */
81
+ export function uplinkItems(relations?: Record<string, unknown>): ExplorerItem[] {
82
+ return Object.entries(relations ?? {})
83
+ .map(([key, v]) => ({ key, link: asLink(v), up: true }))
84
+ .filter((it) => it.link != null);
85
+ }
86
+
87
+ /** The member items, from any depth-1 projection shape: a plain object's entries, an array's
88
+ * items, or a `$yamloverMixed` marker's entries (the omni self-value is the BODY — shown in
89
+ * the header, not the grid). At depth 1 every member arrives as a `$yamloverLink` marker;
90
+ * a non-link value is kept defensively as an inert label. */
91
+ export function memberItems(node: NodeJson): ExplorerItem[] {
92
+ const v = node.value;
93
+ if (Array.isArray(v)) return v.map((item, i) => ({ key: `[${i}]`, link: asLink(item), raw: item }));
94
+ if (!v || typeof v !== "object") return [];
95
+ const mixed = (v as Record<string, unknown>)[MIXED_KEY] as
96
+ | { entries?: { key: string | null; value: unknown }[] }
97
+ | undefined;
98
+ if (Object.keys(v).length === 1 && mixed?.entries) {
99
+ return mixed.entries.map((e, i) => ({ key: e.key ?? `[${i}]`, link: asLink(e.value), raw: e.value }));
100
+ }
101
+ return Object.entries(v as Record<string, unknown>).map(([key, val]) => ({ key, link: asLink(val), raw: val }));
102
+ }
103
+
104
+ /** A scalar member's value as a short label tail (`key: <this>`) — its first line,
105
+ * capped (a long text would bloat the DOM; the CSS ellipsis only hides overflow). */
106
+ function scalarText(v: unknown): string {
107
+ if (v === null || v === undefined) return "~";
108
+ const line = String(v).split("\n", 1)[0];
109
+ return line.length > 80 ? line.slice(0, 79) + "…" : line;
110
+ }
111
+
112
+ /** A scalar whose format marks it file-like CONTENT (a media type such as `text/markdown`,
113
+ * or an `x-yamlover-…` shape) — its value is the whole document, not a datum: the grid
114
+ * shows just the name (the icon already says what it is). A schema VALUE format (`date`,
115
+ * `email`, …) keeps the `key: value` form. */
116
+ function isDocFormat(f?: string | null): boolean {
117
+ return !!f && (f.includes("/") || f.startsWith("x-yamlover-"));
118
+ }
119
+
120
+ function Item({ it, onNavigate }: { it: ExplorerItem; onNavigate: (path: string) => void }) {
121
+ const link = it.link;
122
+ if (!link) {
123
+ // not a marker (unexpected at depth 1) — an inert label, no navigation
124
+ return (
125
+ <span className="dirview-item">
126
+ <span className="dirview-icon t-bin">•</span>
127
+ <span className="dirview-label">{it.key}: {scalarText(it.raw)}</span>
128
+ </span>
129
+ );
130
+ }
131
+ const g = typeIcon(link.type ?? link.kind, link.format ?? null, link.concrete);
132
+ // an uplink labels by its (decoded) relation key; a member by title, else its key
133
+ const name = it.up ? displayKey(it.key) : link.title ?? it.key;
134
+ const label =
135
+ link.format === TAG_FORMAT ? (
136
+ // a tag member (e.g. a subtag) keeps its badge color everywhere
137
+ <span className="tagtag" style={{ background: resolveTagColor({ name, color: link.color }) }}>{name}</span>
138
+ ) : link.kind === "scalar" && !isDocFormat(link.format) ? (
139
+ <>
140
+ {name}: <span className="val">{scalarText(link.value)}</span>
141
+ </>
142
+ ) : (
143
+ name
144
+ );
145
+ return (
146
+ <a
147
+ className={"dirview-item" + (it.up ? " dirview-up" : "")}
148
+ href={link.path}
149
+ title={displayPath(link.path)}
150
+ onClick={(e) => {
151
+ e.preventDefault();
152
+ onNavigate(link.path);
153
+ }}
154
+ >
155
+ <span className={"dirview-icon " + g.cls} title={g.title}>{g.glyph}</span>
156
+ <span className="dirview-label">{label}</span>
157
+ </a>
158
+ );
159
+ }
160
+
161
+ export function ExplorerView({ node, onNavigate }: { node: NodeJson; onNavigate: (path: string) => void }) {
162
+ const isTag = node.format === TAG_FORMAT;
163
+ // a tag's materials (annotations resolved to their targets) — fetched per tag page, refetched
164
+ // when a diff (live.ts) touches a `.yamlover` file (an annotation created/deleted anywhere)
165
+ const [tagged, setTagged] = useState<Link[]>([]);
166
+ const diffBump = useDiffBump(touchesYamlover);
167
+ useEffect(() => {
168
+ setTagged([]);
169
+ if (!isTag) return;
170
+ let cancelled = false;
171
+ fetchTagged(node.path)
172
+ .then((arr) => {
173
+ if (!cancelled) setTagged(arr.map(asLink).filter((l): l is Link => l != null));
174
+ })
175
+ .catch(() => {}); // the owned members still show
176
+ return () => {
177
+ cancelled = true;
178
+ };
179
+ }, [node.path, isTag, diffBump]);
180
+
181
+ const ups = uplinkItems(node.relations);
182
+ let members = memberItems(node);
183
+ if (isTag) {
184
+ // the raw back-edge members are the mediating ANNOTATION nodes — the grid shows the
185
+ // materials from /api/tagged instead (directly-tagged nodes are in both → dedup by path)
186
+ members = members.filter((m) => m.link?.format !== ANNOTATION_FORMAT);
187
+ const have = new Set(members.map((m) => m.link?.path).filter(Boolean));
188
+ for (const l of tagged) if (!have.has(l.path)) members.push({ key: tagLabel(l.path, l.title), link: l });
189
+ }
190
+ const items = [...ups, ...members];
191
+
192
+ // a tag page's description is its BODY (the header bar already names the node)
193
+ const desc = (isTag ? tagBody(node.value) : null) ?? node.description;
194
+ return (
195
+ <div className="explorerview">
196
+ {desc && (
197
+ <div className="dirhead">
198
+ <p className="tagdesc">{desc}</p>
199
+ </div>
200
+ )}
201
+ <div className={"dirview" + (explorerViewMode() === "large" ? " dirview-lg" : "")}>
202
+ {items.map((it, i) => (
203
+ <Item key={`${it.up ? "^" : ""}${it.link?.path ?? it.key}#${i}`} it={it} onNavigate={onNavigate} />
204
+ ))}
205
+ {items.length === 0 && <span className="dirview-empty">empty</span>}
206
+ </div>
207
+ </div>
208
+ );
209
+ }
@@ -0,0 +1,149 @@
1
+ import { useEffect, useState } from "react";
2
+ import { NodeJson, blobUrl } from "../api";
3
+
4
+ const XLINK = "http://www.w3.org/1999/xlink";
5
+ // Characters illegal in XML 1.0 (control codes other than tab/newline/cr). FB2
6
+ // generators frequently leave these in; strict parsing rejects them, so drop.
7
+ // Built from an escaped string to keep literal control bytes out of the source.
8
+ const BAD_XML = new RegExp("[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]", "g");
9
+
10
+ /** Decode the file's bytes using the encoding named in its XML declaration —
11
+ * FB2 is frequently windows-1251 (Cyrillic), not UTF-8 — then strip the stray
12
+ * control characters that would otherwise fail strict XML parsing. */
13
+ function decodeXml(buf: ArrayBuffer): string {
14
+ const bytes = new Uint8Array(buf);
15
+ const head = new TextDecoder("latin1").decode(bytes.subarray(0, 256));
16
+ const enc = (head.match(/encoding=["']([^"']+)["']/i)?.[1] || "utf-8").toLowerCase();
17
+ let text: string;
18
+ try {
19
+ text = new TextDecoder(enc).decode(bytes);
20
+ } catch {
21
+ text = new TextDecoder("utf-8").decode(bytes);
22
+ }
23
+ return text.replace(BAD_XML, "");
24
+ }
25
+
26
+ function esc(s: string): string {
27
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28
+ }
29
+
30
+ /** Recursively turn an FB2 body element into HTML. `images` maps a `<binary>`
31
+ * id to a data URL. Switches on `localName`, so the FB2 namespace is ignored. */
32
+ function toHtml(node: Node, images: Map<string, string>): string {
33
+ if (node.nodeType === 3) return esc(node.textContent || ""); // text
34
+ if (node.nodeType !== 1) return "";
35
+ const el = node as Element;
36
+ const kids = () => Array.from(el.childNodes).map((n) => toHtml(n, images)).join("");
37
+ switch (el.localName) {
38
+ case "section": return `<section class="fb2-section">${kids()}</section>`;
39
+ case "title": return `<div class="fb2-title">${kids()}</div>`;
40
+ case "subtitle": return `<p class="fb2-subtitle">${kids()}</p>`;
41
+ case "p": return `<p>${kids()}</p>`;
42
+ case "empty-line": return "<br/>";
43
+ case "emphasis": return `<em>${kids()}</em>`;
44
+ case "strong": return `<strong>${kids()}</strong>`;
45
+ case "strikethrough": return `<s>${kids()}</s>`;
46
+ case "sub": return `<sub>${kids()}</sub>`;
47
+ case "sup": return `<sup>${kids()}</sup>`;
48
+ case "code": return `<code>${kids()}</code>`;
49
+ case "epigraph":
50
+ case "cite": return `<blockquote class="fb2-cite">${kids()}</blockquote>`;
51
+ case "text-author": return `<p class="fb2-text-author">${kids()}</p>`;
52
+ case "poem": return `<div class="fb2-poem">${kids()}</div>`;
53
+ case "stanza": return `<div class="fb2-stanza">${kids()}</div>`;
54
+ case "v": return `<div class="fb2-v">${kids()}</div>`;
55
+ case "a": return `<span class="fb2-a">${kids()}</span>`; // FB2 links are intra-doc
56
+ case "image": {
57
+ const href =
58
+ el.getAttributeNS(XLINK, "href") || el.getAttribute("l:href") || el.getAttribute("href") || "";
59
+ const src = images.get(href.replace(/^#/, ""));
60
+ return src ? `<img class="fb2-img" src="${src}" alt=""/>` : "";
61
+ }
62
+ case "title-info":
63
+ case "description":
64
+ case "binary":
65
+ return ""; // metadata / payload, not body prose
66
+ default: return kids();
67
+ }
68
+ }
69
+
70
+ interface Book {
71
+ title?: string;
72
+ author?: string;
73
+ cover?: string;
74
+ html: string;
75
+ }
76
+
77
+ /**
78
+ * Renderer for an `application/x-fictionbook+xml` (`.fb2`) ebook. The file is
79
+ * served as bytes; here we decode it (honoring its declared encoding), parse the
80
+ * FictionBook XML, and present the book — cover, title, author, then the body
81
+ * with its sections/paragraphs/poems and embedded images inlined.
82
+ */
83
+ export function Fb2View({ node }: { node: NodeJson }) {
84
+ const [book, setBook] = useState<Book | null>(null);
85
+ const [error, setError] = useState<string | null>(null);
86
+
87
+ useEffect(() => {
88
+ let cancelled = false;
89
+ setBook(null);
90
+ setError(null);
91
+ fetch(blobUrl(node.path))
92
+ .then((r) => r.arrayBuffer())
93
+ .then((buf) => {
94
+ if (cancelled) return;
95
+ const doc = new DOMParser().parseFromString(decodeXml(buf), "application/xml");
96
+ const perr = doc.getElementsByTagName("parsererror")[0];
97
+ if (perr) throw new Error(perr.textContent?.trim().split("\n")[0] || "invalid FB2 XML");
98
+
99
+ const images = new Map<string, string>();
100
+ for (const b of Array.from(doc.getElementsByTagNameNS("*", "binary"))) {
101
+ const id = b.getAttribute("id");
102
+ if (id) {
103
+ const ct = b.getAttribute("content-type") || "image/jpeg";
104
+ images.set(id, `data:${ct};base64,${(b.textContent || "").replace(/\s+/g, "")}`);
105
+ }
106
+ }
107
+
108
+ const ti = doc.getElementsByTagNameNS("*", "title-info")[0] as Element | undefined;
109
+ const at = (e: Element | undefined, name: string) =>
110
+ e?.getElementsByTagNameNS("*", name)[0]?.textContent?.trim() || "";
111
+ const title = at(ti, "book-title") || undefined;
112
+ const author =
113
+ ti &&
114
+ Array.from(ti.getElementsByTagNameNS("*", "author"))
115
+ .map((a) =>
116
+ [at(a, "first-name"), at(a, "middle-name"), at(a, "last-name")]
117
+ .filter(Boolean)
118
+ .join(" ") || at(a, "nickname"),
119
+ )
120
+ .filter(Boolean)
121
+ .join(", ");
122
+ const coverImg = ti?.getElementsByTagNameNS("*", "coverpage")[0]?.getElementsByTagNameNS("*", "image")[0];
123
+ const coverId = (
124
+ coverImg?.getAttributeNS(XLINK, "href") || coverImg?.getAttribute("l:href") || ""
125
+ ).replace(/^#/, "");
126
+
127
+ const bodies = Array.from(doc.getElementsByTagNameNS("*", "body"));
128
+ const main = bodies.find((b) => b.getAttribute("name") !== "notes") || bodies[0];
129
+ const html = main ? Array.from(main.childNodes).map((n) => toHtml(n, images)).join("") : "";
130
+
131
+ setBook({ title, author: author || undefined, cover: coverId ? images.get(coverId) : undefined, html });
132
+ })
133
+ .catch((e) => !cancelled && setError(String((e as Error).message || e)));
134
+ return () => {
135
+ cancelled = true;
136
+ };
137
+ }, [node.path]);
138
+
139
+ if (error) return <div className="error">fb2: {error}</div>;
140
+ if (!book) return <div className="loading">loading FB2…</div>;
141
+ return (
142
+ <div className="text fb2">
143
+ {book.cover && <img className="fb2-cover" src={book.cover} alt="" />}
144
+ {book.title && <h1 className="chapter-title">{book.title}</h1>}
145
+ {book.author && <p className="chapter-subtitle">{book.author}</p>}
146
+ <div className="markup fb2-body" dangerouslySetInnerHTML={{ __html: book.html }} />
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,69 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * Shared heading machinery for the rendered-markup formats (Markdown and AsciiDoc).
5
+ *
6
+ * A `.md`/`.adoc` page is a single HTML blob dumped via `dangerouslySetInnerHTML`,
7
+ * so on its own a heading is not addressable. {@link anchorizeHeadings} gives every
8
+ * heading a stable `id` and a small `§` link to it, mirroring the way GitHub renders
9
+ * the same documents — so a deep link like `<page>#<slug>` lands on, and scrolls to,
10
+ * one section. This is the prose-document counterpart of the chapter renderer's `§N`
11
+ * chunk anchors (see `chapter.tsx`): there the locator is the chunk's path; here it
12
+ * is the heading's slug.
13
+ */
14
+
15
+ /** GitHub-style slug of a heading's text: lowercase, punctuation dropped, runs of
16
+ * whitespace collapsed to single hyphens. Unicode letters/numbers are kept. */
17
+ function slugify(text: string): string {
18
+ return text
19
+ .trim()
20
+ .toLowerCase()
21
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
22
+ .replace(/\s+/g, "-");
23
+ }
24
+
25
+ /** `base`, suffixed `-2`, `-3`, … until it is not already in `used` (which it is
26
+ * then added to). Empty `base` (a heading with no sluggable text) yields "". */
27
+ function uniqueId(base: string, used: Set<string>): string {
28
+ if (!base) return "";
29
+ let id = base;
30
+ for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
31
+ used.add(id);
32
+ return id;
33
+ }
34
+
35
+ /** Give every heading in a block of rendered markup an `id` and a leading `§`
36
+ * anchor link to it (placed first so it sits in the left gutter, like a chapter
37
+ * chunk's `§N` index). An id already present (Asciidoctor stamps section ids) is
38
+ * kept — so its anchor matches the document's own cross-references — otherwise a
39
+ * de-duplicated slug of the heading text is assigned. Returns the rewritten HTML.
40
+ * Runs in the browser/jsdom; with no `DOMParser` (or no headings) it is a no-op. */
41
+ export function anchorizeHeadings(html: string): string {
42
+ if (typeof DOMParser === "undefined" || !html.includes("<h")) return html;
43
+ const doc = new DOMParser().parseFromString(html, "text/html");
44
+ const used = new Set<string>();
45
+ for (const h of doc.querySelectorAll("h1, h2, h3, h4, h5, h6")) {
46
+ // slug from the text before inserting the anchor, so the `§` is not part of it
47
+ const id = uniqueId(h.id || slugify(h.textContent ?? ""), used);
48
+ if (!id) continue;
49
+ h.id = id;
50
+ const a = doc.createElement("a");
51
+ a.className = "header-anchor";
52
+ a.href = `#${id}`;
53
+ a.setAttribute("aria-label", "Link to this section");
54
+ a.textContent = "§";
55
+ h.insertBefore(a, h.firstChild);
56
+ }
57
+ return doc.body.innerHTML;
58
+ }
59
+
60
+ /** Scroll to the element named by the URL hash once `dep` (the rendered node)
61
+ * settles. A deep link `<page>#<slug>` lands on the page, but the value is fetched
62
+ * async — after the browser's own one-shot scroll — so re-scroll when it arrives.
63
+ * The same pattern the chapter renderer uses for `#/chunks[n]`. */
64
+ export function useHashScroll(dep: unknown): void {
65
+ useEffect(() => {
66
+ const id = decodeURIComponent(window.location.hash.slice(1));
67
+ if (id) document.getElementById(id)?.scrollIntoView?.(); // optional-call: absent in jsdom
68
+ }, [dep]);
69
+ }
@@ -0,0 +1,23 @@
1
+ import heic2any from "heic2any";
2
+ import { NodeJson } from "../api";
3
+ import { DecodedImageView } from "./decoded";
4
+
5
+ /**
6
+ * Renders an HEIC/HEIF image (`image/heic`, `.heic`/`.heif`) — the format iPhones
7
+ * shoot. It's HEVC-encoded and patent-encumbered, with no browser support, so we
8
+ * decode it with `heic2any` (libheif compiled to wasm), which converts the bytes
9
+ * to a PNG blob. An HEIC may hold an image *sequence* (burst/Live Photo); when it
10
+ * does, `heic2any` returns several blobs and we show each.
11
+ */
12
+ export function HeicView({ node }: { node: NodeJson }) {
13
+ return (
14
+ <DecodedImageView
15
+ node={node}
16
+ label="heic"
17
+ decode={async (buf) => {
18
+ const out = await heic2any({ blob: new Blob([buf]), toType: "image/png" });
19
+ return Array.isArray(out) ? out : [out];
20
+ }}
21
+ />
22
+ );
23
+ }
@@ -0,0 +1,157 @@
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 { Annotation } from "../api";
7
+ import { DEFAULT_COLOR, colorOf, editable, useAnnotationMenu, useMaterialAnnotations } from "./annotate";
8
+ import { wireGestures } from "./panzoom";
9
+
10
+ /** A rectangular annotation region in the image's own pixel space (origin top-left). `ann` is the
11
+ * source annotation when it is a real saved one (→ clickable to edit); absent for the live preview. */
12
+ export interface ImageRegion { x: number; y: number; w: number; h: number; title?: string; color?: string; ann?: Annotation }
13
+
14
+ const num = (v: unknown): number => Number(v) || 0;
15
+
16
+ /** The `rect`-type annotations, as pixel regions to overlay on the image. */
17
+ function imageRegions(anns: Annotation[]): ImageRegion[] {
18
+ return anns
19
+ .filter((a) => a.selector?.type === "rect")
20
+ .map((a) => ({ x: num(a.selector!.x), y: num(a.selector!.y), w: num(a.selector!.w), h: num(a.selector!.h), title: a.description, color: colorOf(a), ann: editable(a) ? a : undefined }));
21
+ }
22
+
23
+ /**
24
+ * Pan/zoom image viewer — the same widget the KML map uses, over a flat picture. The image is an
25
+ * `imageOverlay` on a `CRS.Simple` map sized to its natural pixels; the view fits it initially.
26
+ * Gestures follow the unified model (see {@link wireGestures} / the UI guide): plain drag selects a
27
+ * region (when `onSelectRegion` is set), ctrl/alt-drag pans, plain wheel pans vertically, ctrl/alt-
28
+ * wheel zooms. The map is built once (per `src`); regions redraw in place without resetting the view.
29
+ */
30
+ export function PanZoomImage({
31
+ src, className, regions, onSelectRegion, onRegionClick, selectColor,
32
+ }: {
33
+ src: string;
34
+ className: string;
35
+ regions?: ImageRegion[];
36
+ onSelectRegion?: (selector: Record<string, unknown>, screen: { x: number; y: number }) => void;
37
+ onRegionClick?: (ann: Annotation, screen: { x: number; y: number }) => void;
38
+ selectColor?: () => string;
39
+ }) {
40
+ const ref = useRef<HTMLDivElement>(null);
41
+ const mapRef = useRef<L.Map | null>(null);
42
+ const layerRef = useRef<L.LayerGroup | null>(null);
43
+ const sizeRef = useRef({ w: 1, h: 1 });
44
+ const onSelectRef = useRef(onSelectRegion);
45
+ const onRegionClickRef = useRef(onRegionClick);
46
+ const colorRef = useRef(selectColor);
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [ready, setReady] = useState(0); // bumps once the map (and its overlay layer) exist
49
+ const regionsKey = JSON.stringify(regions ?? []);
50
+ const selectable = !!onSelectRegion; // fixed per instance: full view annotates, chunk doesn't
51
+
52
+ useEffect(() => { onSelectRef.current = onSelectRegion; onRegionClickRef.current = onRegionClick; colorRef.current = selectColor; });
53
+
54
+ // Build the map once per src; gestures + the overlay layer live with it.
55
+ useEffect(() => {
56
+ let cancelled = false;
57
+ let dispose: (() => void) | null = null;
58
+ setError(null);
59
+ const img = new Image();
60
+ img.onload = () => {
61
+ if (cancelled || !ref.current) return;
62
+ const w = img.naturalWidth || 1;
63
+ const h = img.naturalHeight || 1;
64
+ sizeRef.current = { w, h };
65
+ // CRS.Simple: coordinates are raw pixels (y, x); negative minZoom allows zooming far out.
66
+ const map = L.map(ref.current, { crs: L.CRS.Simple, minZoom: -8, attributionControl: false, zoomSnap: 0 });
67
+ const bounds: L.LatLngBoundsExpression = [[0, 0], [h, w]];
68
+ L.imageOverlay(src, bounds).addTo(map);
69
+ map.fitBounds(bounds); // initial view frames the whole image
70
+ layerRef.current = L.layerGroup().addTo(map);
71
+ mapRef.current = map;
72
+ dispose = wireGestures(map, {
73
+ color: () => colorRef.current?.() ?? DEFAULT_COLOR,
74
+ onSelect: selectable
75
+ ? (b, screen) => {
76
+ // image pixels have y from the top; CRS.Simple lat is from the bottom → flip.
77
+ const { h: ih } = sizeRef.current;
78
+ const west = b.getWest(), east = b.getEast(), south = b.getSouth(), north = b.getNorth();
79
+ onSelectRef.current?.(
80
+ { type: "rect", x: Math.round(west), y: Math.round(ih - north), w: Math.round(east - west), h: Math.round(north - south) },
81
+ screen,
82
+ );
83
+ }
84
+ : undefined,
85
+ });
86
+ setReady((r) => r + 1);
87
+ };
88
+ img.onerror = () => { if (!cancelled) setError("could not load image"); };
89
+ img.src = src;
90
+ return () => {
91
+ cancelled = true;
92
+ dispose?.();
93
+ mapRef.current?.remove();
94
+ mapRef.current = null;
95
+ layerRef.current = null;
96
+ };
97
+ // eslint-disable-next-line react-hooks/exhaustive-deps
98
+ }, [src]);
99
+
100
+ // Draw the region rectangles into the overlay layer — in place, so creating one keeps the view.
101
+ useEffect(() => {
102
+ const lg = layerRef.current;
103
+ if (!lg) return;
104
+ lg.clearLayers();
105
+ const { h } = sizeRef.current;
106
+ for (const r of regions ?? []) {
107
+ const c = r.color || DEFAULT_COLOR;
108
+ // image y is from the top; CRS.Simple lat from the bottom, so flip y
109
+ const rect = L.rectangle([[h - r.y, r.x], [h - (r.y + r.h), r.x + r.w]], {
110
+ className: "yo-region", color: c, weight: 3, fillColor: c, fillOpacity: 0.25,
111
+ });
112
+ if (r.title) rect.bindTooltip(r.title);
113
+ if (r.ann) {
114
+ const ann = r.ann;
115
+ rect.on("click", (ev) => { L.DomEvent.stop(ev); onRegionClickRef.current?.(ann, { x: ev.originalEvent.clientX, y: ev.originalEvent.clientY }); });
116
+ }
117
+ rect.addTo(lg);
118
+ }
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
120
+ }, [regionsKey, ready]);
121
+
122
+ return (
123
+ <>
124
+ {error && <div className="error">image: {error}</div>}
125
+ <div ref={ref} className={className + (selectable ? " yo-selectable" : "")} />
126
+ </>
127
+ );
128
+ }
129
+
130
+ export function ImageView({ node }: { node: NodeJson }) {
131
+ const material = useMaterialAnnotations(node.path);
132
+ const { openCreate, openEdit, palette, preview, color } = useAnnotationMenu(material);
133
+ // include the live PREVIEW selector so the rectangle stays drawn while the menu is open
134
+ const shown = preview
135
+ ? [...material.annotations, { path: "(preview)", selector: preview.selector, tag: preview.tag } as Annotation]
136
+ : material.annotations;
137
+ return (
138
+ <div className="text">
139
+ {node.title && <h1 className="chapter-title">{node.title}</h1>}
140
+ {node.description && <p className="chapter-subtitle">{node.description}</p>}
141
+ <PanZoomImage
142
+ src={blobUrl(node.path)}
143
+ regions={imageRegions(shown)}
144
+ onSelectRegion={openCreate}
145
+ onRegionClick={openEdit}
146
+ selectColor={() => color}
147
+ className="filemap fileimagemap"
148
+ />
149
+ {palette}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ /** An image embedded inline in a chapter — pan/zoom only (no annotation target → plain drag pans). */
155
+ export function ImageChunk({ chunk }: { chunk: Chunk }) {
156
+ return <PanZoomImage src={blobUrl(chunk.path)} className="filemap chunk-map fileimagemap" />;
157
+ }
@@ -0,0 +1,46 @@
1
+ import { kml as kmlDomToGeoJSON } from "@tmcw/togeojson";
2
+ import { unzipSync, strFromU8 } from "fflate";
3
+
4
+ /**
5
+ * The pure (Leaflet-free) half of the map renderer: turn the bytes of a `.kml` or
6
+ * `.kmz` file into GeoJSON. Kept apart from `map.tsx` so it can be unit-tested
7
+ * without importing Leaflet (a browser-only library with CSS side effects).
8
+ *
9
+ * - **KML** is XML — decoded as text, parsed to a DOM, then converted with
10
+ * `@tmcw/togeojson`.
11
+ * - **KMZ** is a ZIP — unzipped (with the already-bundled `fflate`); its first
12
+ * `.kml` entry is the document, then the same KML path.
13
+ */
14
+
15
+ /** A GeoJSON FeatureCollection (the shape togeojson returns). Loosely typed — the
16
+ * map renderer only hands it to Leaflet's `L.geoJSON`. */
17
+ export interface GeoJSON {
18
+ type: "FeatureCollection";
19
+ features: unknown[];
20
+ }
21
+
22
+ /** Whether `bytes` begin with the ZIP local-file signature `PK\x03\x04` — i.e. a
23
+ * KMZ rather than a bare KML. Robust regardless of the file's extension. */
24
+ export function isZip(bytes: Uint8Array): boolean {
25
+ return bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
26
+ }
27
+
28
+ /** Parse a KML XML string into GeoJSON via a DOM (available in the browser and in
29
+ * jsdom tests). */
30
+ export function kmlStringToGeoJSON(xml: string): GeoJSON {
31
+ const dom = new DOMParser().parseFromString(xml, "application/xml");
32
+ if (dom.getElementsByTagName("parsererror").length) throw new Error("malformed KML XML");
33
+ return kmlDomToGeoJSON(dom) as unknown as GeoJSON;
34
+ }
35
+
36
+ /** Parse `.kml`/`.kmz` bytes into GeoJSON. KMZ is detected by its ZIP signature
37
+ * (not just the extension); the first `.kml` entry inside is the document. */
38
+ export function bytesToGeoJSON(bytes: Uint8Array): GeoJSON {
39
+ if (isZip(bytes)) {
40
+ const files = unzipSync(bytes);
41
+ const name = Object.keys(files).find((n) => n.toLowerCase().endsWith(".kml"));
42
+ if (!name) throw new Error("no .kml document inside the KMZ archive");
43
+ return kmlStringToGeoJSON(strFromU8(files[name]));
44
+ }
45
+ return kmlStringToGeoJSON(new TextDecoder().decode(bytes));
46
+ }