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,507 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { Annotation, TagRef, createTag, fetchAnnotations, fetchNode, saveAnnotation, deleteAnnotation } from "../api";
3
+ import { TAG_FORMAT, explicitColor, resolveTagColor, tagFields } from "./tag";
4
+ import { strToSegs } from "../paths";
5
+ import { touchesYamlover, useDiffBump } from "../live";
6
+
7
+ /**
8
+ * The annotation layer, shared across materials (the UI guide). An annotation is ONE TAG
9
+ * APPLICATION: a region of the material tagged by a tag, optionally commented. You SELECT to
10
+ * annotate — drag-select text in prose or a PDF, drag a rectangle on an image or map — and a
11
+ * floating tag picker appears:
12
+ *
13
+ * - the PURE COLOR TAGS (built-in `yamlover/tags/colors/…`) as swatches; the last-used tag is
14
+ * pre-selected. Click a swatch to apply that tag.
15
+ * - the recently used NAMED tags as badges, plus a path input to apply ANY tag by its node
16
+ * path (a named tag's hue derives from its name; a color tag carries its color).
17
+ * - a ✓ CONFIRM button — apply the pre-selected tag (the explicit alternative to clicking
18
+ * outside, which also commits).
19
+ * - (text only) a ⧉ COPY button — copies the selected text, creates nothing.
20
+ * - a 🗑 DISCARD button — drops the pending mark.
21
+ *
22
+ * Clicking an EXISTING annotation reopens the picker in "edit" mode: picking a tag RE-TAGS it,
23
+ * 🗑 DELETES it, clicking away just closes. A new/edited mark renders IMMEDIATELY
24
+ * (optimistically) — it does not wait for the server round-trip (which reindexes). Annotations
25
+ * are graph-native — saved server-side as yamlover objects, reverse-linked to the material and
26
+ * members of their tag — so they persist on reload.
27
+ */
28
+
29
+ // The built-in pure color tags (the palette). This constant is the OFFLINE fallback — the picker
30
+ // fetches the real `/yamlover/tags/colors` nodes once per session (useColorTags) so a project
31
+ // that re-themes them wins; the paths and hexes here mirror yamlover/tags/.yamlover/body.yamlover.
32
+ export const COLOR_TAGS: TagRef[] = [
33
+ { path: ":yamlover:tags:colors:yellow", name: "yellow", color: "#f9e2af" },
34
+ { path: ":yamlover:tags:colors:green", name: "green", color: "#a6e3a1" },
35
+ { path: ":yamlover:tags:colors:sky", name: "sky", color: "#89dceb" },
36
+ { path: ":yamlover:tags:colors:mauve", name: "mauve", color: "#cba6f7" },
37
+ { path: ":yamlover:tags:colors:pink", name: "pink", color: "#f5c2e7" },
38
+ { path: ":yamlover:tags:colors:peach", name: "peach", color: "#fab387" },
39
+ ];
40
+ export const DEFAULT_TAG = COLOR_TAGS[0];
41
+ export const DEFAULT_COLOR = DEFAULT_TAG.color!;
42
+ const TAG_KEY = "yo-annotate-tag";
43
+ const RECENT_KEY = "yo-annotate-recent-tags";
44
+
45
+ /** An annotation's display color — its applied tag's (explicit color, else name-derived hue);
46
+ * the default for legacy marks saved before annotations carried a tag. */
47
+ export function colorOf(a: Annotation): string {
48
+ return a.tag ? resolveTagColor(a.tag) : DEFAULT_COLOR;
49
+ }
50
+
51
+ /** An annotation's identity for optimistic reconcile/dedup: the same region tagged by two tags
52
+ * is TWO annotations, so the key is (selector, tag path) — not the selector alone. */
53
+ function annKey(a: Annotation): string {
54
+ return JSON.stringify([a.selector ?? null, a.tag?.path ?? null]);
55
+ }
56
+
57
+ /** Whether an annotation can be edited/deleted here — any STANDALONE annotation file (its node
58
+ * path is the `.yamlover` file itself), wherever it lives in the tree: annotations are graph
59
+ * nodes, not residents of a fixed folder, so one moved to another directory stays editable.
60
+ * Excludes the optimistic `(pending)` placeholders and "frozen" annotations authored inline in
61
+ * shared documents (which the server can't delete without editing that document). */
62
+ export function editable(a: Annotation): boolean {
63
+ return typeof a.path === "string" && a.path.endsWith(".yamlover");
64
+ }
65
+
66
+ // The color tags as indexed (fetched once per session; the constant covers offline/legacy roots).
67
+ let colorTagsPromise: Promise<TagRef[]> | null = null;
68
+ export function useColorTags(): TagRef[] {
69
+ const [tags, setTags] = useState<TagRef[]>(COLOR_TAGS);
70
+ useEffect(() => {
71
+ colorTagsPromise ??= fetchNode(":yamlover:tags:colors", 2)
72
+ .then((n) => {
73
+ const out: TagRef[] = [];
74
+ for (const [name, child] of tagFields(n.value)) {
75
+ const color = explicitColor(child);
76
+ if (color) out.push({ path: `${n.path}/${encodeURIComponent(name)}`, name, color });
77
+ }
78
+ return out.length ? out : COLOR_TAGS;
79
+ })
80
+ .catch(() => COLOR_TAGS);
81
+ let cancelled = false;
82
+ colorTagsPromise.then((t) => { if (!cancelled) setTags(t); });
83
+ return () => { cancelled = true; };
84
+ }, []);
85
+ return tags;
86
+ }
87
+
88
+ /** The remembered last-applied tag (persisted in localStorage) + a setter that persists it and
89
+ * files a NAMED tag among the recents (color tags live in the swatch row already). */
90
+ export function useAnnotationTag(): [TagRef, (t: TagRef) => void] {
91
+ const [tag, set] = useState<TagRef>(() => {
92
+ try {
93
+ const t = JSON.parse(localStorage.getItem(TAG_KEY) || "") as TagRef;
94
+ if (t?.path && t?.name) return t;
95
+ } catch { /* no/invalid stored tag */ }
96
+ return DEFAULT_TAG;
97
+ });
98
+ const setTag = (t: TagRef) => {
99
+ localStorage.setItem(TAG_KEY, JSON.stringify(t));
100
+ rememberRecent(t);
101
+ set(t);
102
+ };
103
+ return [tag, setTag];
104
+ }
105
+
106
+ /** The recently applied NAMED tags (newest first, capped). */
107
+ export function recentTags(): TagRef[] {
108
+ try {
109
+ const r = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]") as TagRef[];
110
+ if (Array.isArray(r)) return r.filter((t) => t?.path && t?.name);
111
+ } catch { /* no/invalid recents */ }
112
+ return [];
113
+ }
114
+
115
+ function rememberRecent(t: TagRef): void {
116
+ if (t.path.startsWith(":yamlover:tags:colors:")) return; // the swatch row already shows these
117
+ const next = [t, ...recentTags().filter((r) => r.path !== t.path)].slice(0, 6);
118
+ localStorage.setItem(RECENT_KEY, JSON.stringify(next));
119
+ }
120
+
121
+ /** Drop remembered tags whose node is GONE (or stopped being a tag): localStorage outlives the
122
+ * tags themselves, so a deleted tag would linger as a clickable badge forever. Each recent (and
123
+ * the remembered last-applied tag) is checked against the server; survivors are written back.
124
+ * Resolves to the live recents — the menu shows those. */
125
+ function pruneRememberedTags(): Promise<TagRef[]> {
126
+ const isLive = (t: TagRef): Promise<boolean> =>
127
+ fetchNode(t.path, 0).then((n) => n.format === TAG_FORMAT).catch(() => false);
128
+ try {
129
+ const t = JSON.parse(localStorage.getItem(TAG_KEY) || "") as TagRef;
130
+ if (t?.path) void isLive(t).then((live) => { if (!live) localStorage.removeItem(TAG_KEY); });
131
+ } catch { /* no/invalid stored tag */ }
132
+ const recents = recentTags();
133
+ return Promise.all(recents.map((t) => isLive(t).then((live) => (live ? t : null)))).then((kept) => {
134
+ const live = kept.filter(Boolean) as TagRef[];
135
+ if (live.length !== recents.length) localStorage.setItem(RECENT_KEY, JSON.stringify(live));
136
+ return live;
137
+ });
138
+ }
139
+
140
+ /** Apply `tag` to the material at `target`, narrowed to `selector` when given (null = the whole
141
+ * node); resolves when persisted. */
142
+ export function createAnnotation(target: string, selector: Record<string, unknown> | null, tag: TagRef): Promise<unknown> {
143
+ return saveAnnotation({ target, tag: tag.path, ...(selector ? { selector } : {}) });
144
+ }
145
+
146
+ /** Read-only fetch of a material's annotations; `bump` (a changing number) forces a refetch.
147
+ * Also refetches whenever a diff (live.ts — the unified change flow) touches a `.yamlover`
148
+ * file: an annotation written/deleted ANYWHERE (this page's own save, another tab, a shell rm
149
+ * reconciled by the watcher) or an edited taxonomy must redraw the marks without a reload. */
150
+ export function useAnnotations(path: string, bump = 0): Annotation[] {
151
+ const [anns, setAnns] = useState<Annotation[]>([]);
152
+ const extBump = useDiffBump(touchesYamlover);
153
+ useEffect(() => {
154
+ let cancelled = false;
155
+ fetchAnnotations(path)
156
+ .then((a) => !cancelled && setAnns(a))
157
+ .catch(() => !cancelled && setAnns([]));
158
+ return () => { cancelled = true; };
159
+ }, [path, bump, extBump]);
160
+ return anns;
161
+ }
162
+
163
+ /** The actions every renderer needs over a material's annotations, with OPTIMISTIC rendering: a
164
+ * create/re-tag shows at once and a delete hides at once, before the (slow, reindexing) server
165
+ * round-trip lands. */
166
+ export interface MaterialAnnotations {
167
+ annotations: Annotation[];
168
+ create: (selector: Record<string, unknown> | null, tag: TagRef) => void;
169
+ remove: (annPath?: string) => void;
170
+ retag: (ann: Annotation, tag: TagRef) => void;
171
+ }
172
+
173
+ /** A material's annotations + optimistic create/delete/re-tag. The displayed list merges the
174
+ * server's annotations with pending creations (shown until the refetch holds them) minus pending
175
+ * deletions (hidden until the refetch drops them) — so every change is reflected instantly. */
176
+ export function useMaterialAnnotations(path: string): MaterialAnnotations {
177
+ const [bump, setBump] = useState(0);
178
+ const fetched = useAnnotations(path, bump);
179
+ const [optimistic, setOptimistic] = useState<Annotation[]>([]); // created, not yet in `fetched`
180
+ const [deleted, setDeleted] = useState<Set<string>>(new Set()); // paths hidden, not yet dropped
181
+
182
+ // Reconcile when the server list refreshes: drop optimistic creations it now holds, and keep a
183
+ // path "deleted" only while the server still lists it (so a re-tag's old copy can't flash back).
184
+ useEffect(() => {
185
+ const keys = new Set(fetched.map(annKey));
186
+ setOptimistic((o) => o.filter((a) => !keys.has(annKey(a))));
187
+ const present = new Set(fetched.map((a) => a.path).filter(Boolean) as string[]);
188
+ setDeleted((d) => new Set([...d].filter((p) => present.has(p))));
189
+ }, [fetched]);
190
+
191
+ const refresh = () => setBump((b) => b + 1);
192
+ const create = (selector: Record<string, unknown> | null, tag: TagRef) => {
193
+ const entry = { path: "(pending)", selector: selector ?? undefined, tag } as Annotation;
194
+ setOptimistic((o) => [...o, entry]);
195
+ createAnnotation(path, selector, tag)
196
+ .then(refresh)
197
+ .catch((e) => { setOptimistic((o) => o.filter((x) => x !== entry)); window.alert("save failed: " + (e as Error).message); }); // roll back the unsaved mark
198
+ };
199
+ const remove = (annPath?: string) => {
200
+ if (!annPath || annPath === "(pending)") return;
201
+ setDeleted((d) => new Set(d).add(annPath));
202
+ deleteAnnotation(annPath)
203
+ .then(refresh)
204
+ .catch((e) => { setDeleted((d) => { const n = new Set(d); n.delete(annPath); return n; }); window.alert("delete failed: " + (e as Error).message); }); // un-hide on failure
205
+ };
206
+ const retag = (ann: Annotation, tag: TagRef) => {
207
+ remove(ann.path); // hide + delete the old application
208
+ create(ann.selector ?? null, tag); // show + save the new one
209
+ };
210
+
211
+ const seen = new Set<string>();
212
+ const annotations: Annotation[] = [];
213
+ for (const a of [...optimistic, ...fetched]) {
214
+ if (a.path && deleted.has(a.path)) continue;
215
+ const k = annKey(a);
216
+ if (seen.has(k)) continue;
217
+ seen.add(k);
218
+ annotations.push(a);
219
+ }
220
+ return { annotations, create, remove, retag };
221
+ }
222
+
223
+ /** A tag's display name from its node path (its last segment). */
224
+ function tagNameOf(path: string): string {
225
+ const segs = strToSegs(path);
226
+ return segs.length ? String(segs[segs.length - 1]) : path;
227
+ }
228
+
229
+ /** The floating tag picker — color-tag swatches, recent named-tag badges, a tag-path input, plus
230
+ * action buttons. Mode decides which buttons show (the hook wires what each does): `create` gets
231
+ * ✓ confirm + optional ⧉ copy + 🗑 discard; `edit` gets just 🗑 delete (picking a tag re-tags).
232
+ * `position: fixed`, so x/y are viewport coords. */
233
+ export function AnnotationMenu({
234
+ x, y, tag, mode, onPick, onConfirm, onCopy, onTrash, menuRef,
235
+ }: {
236
+ x: number; y: number; tag: TagRef; mode: "create" | "edit";
237
+ onPick: (t: TagRef) => void; onConfirm?: () => void; onCopy?: () => void; onTrash: () => void;
238
+ menuRef?: React.Ref<HTMLDivElement>;
239
+ }) {
240
+ const colorTags = useColorTags();
241
+ const [recents, setRecents] = useState(recentTags); // shown at once; pruned against the server
242
+ const [path, setPath] = useState("");
243
+ const [busy, setBusy] = useState(false); // a lookup/create round-trip is in flight
244
+ const verb = mode === "edit" ? "re-tag" : "tag";
245
+
246
+ // A deleted tag must not survive as a badge: on open, drop remembered tags the server no
247
+ // longer holds (the stored list is shown immediately; the pruned one replaces it quietly).
248
+ useEffect(() => {
249
+ let on = true;
250
+ pruneRememberedTags().then((live) => { if (on) setRecents(live); });
251
+ return () => { on = false; };
252
+ }, []);
253
+
254
+ // The badge row must always include THE tag this menu is about (`sel`-framed, like the
255
+ // selected color swatch) — which tag is assigned/pre-selected must be visible at a glance,
256
+ // even when it has aged out of the recents.
257
+ const badges = colorTags.some((c) => c.path === tag.path) || recents.some((r) => r.path === tag.path)
258
+ ? recents
259
+ : [tag, ...recents];
260
+
261
+ // Apply an arbitrary tag by its node path: fetch, verify it IS a tag, pick it. A bare NAME
262
+ // (no `/`) that matches no node is CREATED at the project's tags location and then picked —
263
+ // typing a fresh name is how a new named tag is born. A missed multi-segment path stays an
264
+ // error: a typo'd path must not silently mint a tag named like a path.
265
+ const pickPath = () => {
266
+ const p = path.trim();
267
+ if (!p || busy) return;
268
+ setBusy(true);
269
+ fetchNode(p.startsWith(":") ? p : p.startsWith("/") ? ":" + p.slice(1).split("/").join(":") : ":" + p, 1)
270
+ .then((n) => {
271
+ if (n.format !== TAG_FORMAT) throw new Error("not a tag node");
272
+ onPick({ path: n.path, name: n.title || tagNameOf(n.path), color: explicitColor(n.value) });
273
+ })
274
+ .catch((e) => {
275
+ if (p.includes(":") || p.includes("/")) throw new Error(`cannot ${verb} with "${p}": ` + (e as Error).message);
276
+ return createTag(p)
277
+ .then(onPick)
278
+ .catch((e2) => { throw new Error(`cannot create tag "${p}": ` + (e2 as Error).message); });
279
+ })
280
+ .catch((e) => window.alert((e as Error).message))
281
+ .finally(() => setBusy(false));
282
+ };
283
+
284
+ return (
285
+ <div ref={menuRef} className="annotate-menu" style={{ left: x, top: y }} role="menu">
286
+ <div className="annotate-palette">
287
+ {colorTags.map((t) => (
288
+ <button
289
+ key={t.path}
290
+ type="button"
291
+ className={"annotate-swatch" + (t.path === tag.path ? " sel" : "")}
292
+ style={{ background: resolveTagColor(t) }}
293
+ title={`${verb} ${t.name}`}
294
+ onClick={() => onPick(t)}
295
+ />
296
+ ))}
297
+ </div>
298
+ {onConfirm && <button type="button" className="annotate-tool ok" title={`${verb} ${tag.name} (keep the mark)`} onClick={onConfirm}>✓</button>}
299
+ {onCopy && <button type="button" className="annotate-tool" title="copy text to clipboard (don't annotate)" onClick={onCopy}>⧉</button>}
300
+ <button type="button" className="annotate-tool danger" title={mode === "edit" ? "delete this annotation" : "discard (don't annotate)"} onClick={onTrash}>🗑</button>
301
+ {badges.length > 0 && (
302
+ <div className="annotate-recents">
303
+ {badges.map((t) => (
304
+ // the frame is a WRAPPER: filter applies before clip-path on the same element, so a
305
+ // ring drawn on the clipped .tagtag itself would be clipped away with it (styles.css)
306
+ <span key={t.path} className={"tagframe" + (t.path === tag.path ? " sel" : "")}>
307
+ <button
308
+ type="button"
309
+ className="tagtag"
310
+ style={{ background: resolveTagColor(t) }}
311
+ title={`${verb} ${t.name}`}
312
+ onClick={() => onPick(t)}
313
+ >
314
+ {t.name}
315
+ </button>
316
+ </span>
317
+ ))}
318
+ </div>
319
+ )}
320
+ <input
321
+ className="annotate-taginput"
322
+ type="text"
323
+ placeholder={busy ? "creating tag…" : `${verb}: tag path or new name… ⏎`}
324
+ value={path}
325
+ disabled={busy}
326
+ onChange={(e) => setPath(e.target.value)}
327
+ onKeyDown={(e) => { if (e.key === "Enter") pickPath(); }}
328
+ />
329
+ </div>
330
+ );
331
+ }
332
+
333
+ type MenuState =
334
+ | { mode: "create"; selector: Record<string, unknown>; copy?: () => void; x: number; y: number }
335
+ | { mode: "edit"; ann: Annotation; x: number; y: number };
336
+
337
+ /** Drives the floating picker for a material: `openCreate` after a fresh selection, `openEdit` on
338
+ * a click on an existing mark. Returns the rendered `palette`, and a `preview` (the pending
339
+ * CREATE's selector + tag, so a renderer can keep the rectangle drawn while the picker is open).
340
+ * Outside-click commits a create (with the pre-selected tag) but only closes an edit. */
341
+ export function useAnnotationMenu(a: MaterialAnnotations): {
342
+ openCreate: (selector: Record<string, unknown>, screen: { x: number; y: number }, copy?: () => void) => void;
343
+ openEdit: (ann: Annotation, screen: { x: number; y: number }) => void;
344
+ palette: ReactNode;
345
+ preview: { selector: Record<string, unknown>; tag: TagRef; color: string } | null;
346
+ color: string;
347
+ } {
348
+ const [tag, setTag] = useAnnotationTag();
349
+ const [menu, setMenu] = useState<MenuState | null>(null);
350
+ const menuRef = useRef<HTMLDivElement>(null);
351
+ const close = () => setMenu(null);
352
+
353
+ const openCreate = (selector: Record<string, unknown>, screen: { x: number; y: number }, copy?: () => void) =>
354
+ setMenu({ mode: "create", selector, copy, x: screen.x, y: screen.y });
355
+ const openEdit = (ann: Annotation, screen: { x: number; y: number }) =>
356
+ setMenu({ mode: "edit", ann, x: screen.x, y: screen.y });
357
+
358
+ const commitCreate = (t: TagRef, m: MenuState) => { if (m.mode !== "create") return; setTag(t); a.create(m.selector, t); close(); };
359
+ const commitRetag = (t: TagRef, m: MenuState) => { if (m.mode !== "edit") return; setTag(t); a.retag(m.ann, t); close(); };
360
+
361
+ // Outside-click: a create commits with the pre-selected tag (default keeps the mark); an edit closes.
362
+ useEffect(() => {
363
+ if (!menu) return;
364
+ const onDown = (e: MouseEvent) => {
365
+ if (menuRef.current?.contains(e.target as Node)) return;
366
+ if (menu.mode === "create") commitCreate(tag, menu);
367
+ else close();
368
+ };
369
+ document.addEventListener("mousedown", onDown);
370
+ return () => document.removeEventListener("mousedown", onDown);
371
+ // eslint-disable-next-line react-hooks/exhaustive-deps
372
+ }, [menu, tag]);
373
+
374
+ let palette: ReactNode = null;
375
+ if (menu?.mode === "create") {
376
+ palette = (
377
+ <AnnotationMenu
378
+ menuRef={menuRef} x={menu.x} y={menu.y} tag={tag} mode="create"
379
+ onPick={(t) => commitCreate(t, menu)}
380
+ onConfirm={() => commitCreate(tag, menu)}
381
+ onCopy={menu.copy ? () => { menu.copy!(); close(); } : undefined}
382
+ onTrash={close}
383
+ />
384
+ );
385
+ } else if (menu?.mode === "edit") {
386
+ palette = (
387
+ <AnnotationMenu
388
+ menuRef={menuRef} x={menu.x} y={menu.y} tag={menu.ann.tag ?? DEFAULT_TAG} mode="edit"
389
+ onPick={(t) => commitRetag(t, menu)}
390
+ onTrash={() => { a.remove(menu.ann.path); close(); }}
391
+ />
392
+ );
393
+ }
394
+ const preview = menu?.mode === "create" ? { selector: menu.selector, tag, color: resolveTagColor(tag) } : null;
395
+ return { openCreate, openEdit, palette, preview, color: resolveTagColor(tag) };
396
+ }
397
+
398
+ export function AnnotatedMaterial({ path, children }: { path: string; children: ReactNode }) {
399
+ const ref = useRef<HTMLDivElement>(null);
400
+ const material = useMaterialAnnotations(path);
401
+ const { openCreate, openEdit, palette } = useAnnotationMenu(material);
402
+ const { annotations } = material;
403
+
404
+ // Re-highlight after each render: the material (esp. a chapter's chunks) may settle a tick
405
+ // after mount, so do it on a frame and clear any prior marks first.
406
+ useEffect(() => {
407
+ const el = ref.current;
408
+ if (!el) return;
409
+ const raf = requestAnimationFrame(() => highlight(el, annotations));
410
+ return () => cancelAnimationFrame(raf);
411
+ });
412
+
413
+ // A finished text selection inside the material raises the CREATE menu by the selection.
414
+ useEffect(() => {
415
+ const onUp = (e: MouseEvent) => {
416
+ const el = ref.current;
417
+ if (!el) return;
418
+ if ((e.target as HTMLElement)?.closest?.(".annotate-menu")) return; // a menu click, not a selection
419
+ if ((e.target as HTMLElement)?.closest?.("mark.yo-annotation")) return; // a mark click → handled by onClick
420
+ const sel = window.getSelection();
421
+ if (!sel || sel.isCollapsed || !sel.anchorNode || !el.contains(sel.anchorNode)) return;
422
+ const cap = capture(sel);
423
+ if (!cap) return;
424
+ const rect = sel.getRangeAt(0).getBoundingClientRect();
425
+ const copy = () => navigator.clipboard?.writeText(cap.exact).catch(() => { /* clipboard blocked */ });
426
+ openCreate({ type: "text", exact: cap.exact, prefix: cap.prefix, suffix: cap.suffix }, { x: rect.left, y: rect.bottom + 6 }, copy);
427
+ };
428
+ document.addEventListener("mouseup", onUp);
429
+ return () => document.removeEventListener("mouseup", onUp);
430
+ // eslint-disable-next-line react-hooks/exhaustive-deps
431
+ }, []);
432
+
433
+ // Clicking an existing highlight opens the EDIT menu for that annotation.
434
+ const onClickMark = (e: React.MouseEvent) => {
435
+ const mark = (e.target as HTMLElement).closest("mark.yo-annotation") as HTMLElement | null;
436
+ if (!mark) return;
437
+ const ann = annotations.find((x) => annKey(x) === mark.dataset.annSel);
438
+ if (!ann || !editable(ann)) return;
439
+ e.preventDefault();
440
+ openEdit(ann, { x: e.clientX, y: e.clientY });
441
+ };
442
+
443
+ return (
444
+ <div className="annotated">
445
+ {annotations.length > 0 && (
446
+ <div className="annotate-bar">
447
+ <span className="annotate-count">{annotations.length} annotation{annotations.length > 1 ? "s" : ""}</span>
448
+ </div>
449
+ )}
450
+ <div ref={ref} onClick={onClickMark}>{children}</div>
451
+ {palette}
452
+ </div>
453
+ );
454
+ }
455
+
456
+ /** Resolve the current selection to a quote selector ({exact, prefix, suffix}), or null if empty. */
457
+ function capture(sel: Selection): { exact: string; prefix: string; suffix: string } | null {
458
+ const exact = sel.toString().trim();
459
+ if (!exact) return null;
460
+ const full = sel.anchorNode?.nodeValue ?? "";
461
+ const at = full.indexOf(exact);
462
+ const prefix = at >= 0 ? full.slice(Math.max(0, at - 24), at) : "";
463
+ const suffix = at >= 0 ? full.slice(at + exact.length, at + exact.length + 24) : "";
464
+ return { exact, prefix, suffix };
465
+ }
466
+
467
+ /** (Re)apply highlight marks for the text annotations in `container`. */
468
+ function highlight(container: HTMLElement, anns: Annotation[]): void {
469
+ container.querySelectorAll("mark.yo-annotation").forEach((m) => {
470
+ const parent = m.parentNode;
471
+ if (!parent) return;
472
+ while (m.firstChild) parent.insertBefore(m.firstChild, m);
473
+ parent.removeChild(m);
474
+ parent.normalize();
475
+ });
476
+ for (const a of anns) {
477
+ if (a.selector?.type !== "text" || !a.selector.exact) continue;
478
+ wrapFirst(container, a.selector.exact, a);
479
+ }
480
+ }
481
+
482
+ /** Wrap the first text occurrence of an annotation's `exact` (within one text node) in a colored,
483
+ * clickable `<mark>` carrying its identity key (so a click maps back to the annotation). */
484
+ function wrapFirst(container: HTMLElement, exact: string, a: Annotation): void {
485
+ const c = colorOf(a);
486
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
487
+ let n: Node | null;
488
+ while ((n = walker.nextNode())) {
489
+ const i = (n.nodeValue ?? "").indexOf(exact);
490
+ if (i < 0) continue;
491
+ const range = document.createRange();
492
+ range.setStart(n, i);
493
+ range.setEnd(n, i + exact.length);
494
+ const mark = document.createElement("mark");
495
+ mark.className = "yo-annotation";
496
+ mark.style.backgroundColor = `color-mix(in srgb, ${c} 30%, transparent)`; // works for hex AND a named tag's hsl()
497
+ mark.style.borderBottomColor = c;
498
+ mark.dataset.annSel = annKey(a);
499
+ mark.title = a.description || "click to re-tag or delete";
500
+ try {
501
+ range.surroundContents(mark); // works when the match is within one text node (v1)
502
+ return;
503
+ } catch {
504
+ /* the snippet spans element boundaries — skip highlighting it for now */
505
+ }
506
+ }
507
+ }
@@ -0,0 +1,35 @@
1
+ import Asciidoctor from "@asciidoctor/core";
2
+ import { NodeJson } from "../api";
3
+ import { Chunk } from "./registry";
4
+ import { anchorizeHeadings, useHashScroll } from "./headings";
5
+ import { Markup } from "./markup";
6
+
7
+ // One processor instance for the app; `convert` is pure per call.
8
+ const processor = Asciidoctor();
9
+
10
+ /**
11
+ * The renderer for a `string`/`text/asciidoc` node: AsciiDoc converted to HTML
12
+ * with `@asciidoctor/core`. Like the text/markdown renderer it works from the
13
+ * node's string value, so it serves both a whole `.adoc` file (`render`) and a
14
+ * single inline chunk (`renderChunk`). Each heading is then given a `§` anchor link
15
+ * (see {@link anchorizeHeadings}); Asciidoctor's own section ids are kept, so the
16
+ * anchors line up with the document's internal cross-references.
17
+ */
18
+ function adoc(value: unknown): string {
19
+ return anchorizeHeadings(processor.convert(String(value ?? ""), { standalone: false }) as string);
20
+ }
21
+
22
+ export function AsciidocView({ node }: { node: NodeJson }) {
23
+ useHashScroll(node);
24
+ return (
25
+ <div className="text">
26
+ {node.title && <h1 className="chapter-title">{node.title}</h1>}
27
+ {node.description && <p className="chapter-subtitle">{node.description}</p>}
28
+ <Markup html={adoc(node.value)} />
29
+ </div>
30
+ );
31
+ }
32
+
33
+ export function AsciidocChunk({ chunk }: { chunk: Chunk }) {
34
+ return <div className="markup" dangerouslySetInnerHTML={{ __html: adoc(chunk.value) }} />;
35
+ }
@@ -0,0 +1,138 @@
1
+ import { NodeJson } from "../api";
2
+ import { asLink, Link } from "../render";
3
+ import { segsToStr, strToSegs } from "../paths";
4
+ import { Chunk, rendererFor } from "./registry";
5
+ import { useHashScroll } from "./headings";
6
+
7
+ /**
8
+ * The renderer for an `object`/`x-yamlover-chapter`: a chapter shown as a readable
9
+ * page. This is yamlover's first instance of **partial flattening** (see the global
10
+ * README): a deeper subtree is presented shallowly, pulling some descendants up as
11
+ * constituent parts of *this* page rather than as nodes you navigate away to.
12
+ *
13
+ * A chapter is a heading (`title`/`description`) plus two arrays:
14
+ *
15
+ * - `chunks` — the body, **flattened** into this page as numbered blocks. Each
16
+ * chunk is delegated to the renderer for its own (type, format), so
17
+ * a chapter is not prose-only: a `text/markdown` chunk routes to the
18
+ * text renderer, a `text/x-plantuml` chunk to the diagram renderer,
19
+ * and any file-backed binary (image, html, pdf, fb2, epub, psd,
20
+ * tiff, …) to its own renderer via `renderChunk`. A flattened chunk
21
+ * exposes its location as a **fragment anchor** whose syntax is the
22
+ * chunk's path continuation: chunk `[1]`, still reachable in full at
23
+ * `<chapter>:chunks[1]`, is anchored here at `#:chunks[1]` (so
24
+ * `<chapter>#:chunks[1]` scrolls to it). The `§N` marker is that
25
+ * in-page anchor link. See 16-all-formats-chunks.
26
+ * - `children` — the subchapters, *not* flattened: rendered as heading links you
27
+ * navigate to (and surfaced in the TOC; see `chapterTocView`).
28
+ *
29
+ * The value arrives two levels deep (see the chapter renderer's `depth`): the
30
+ * arrays are present, and each element is a link marker carrying its (type,
31
+ * format), value/title, and path.
32
+ */
33
+ export function ChapterView({
34
+ node,
35
+ onNavigate,
36
+ }: {
37
+ node: NodeJson;
38
+ onNavigate: (path: string) => void;
39
+ }) {
40
+ const v = (node.value ?? {}) as { chunks?: unknown; children?: unknown };
41
+ const chunks = Array.isArray(v.chunks) ? v.chunks : [];
42
+ const children = Array.isArray(v.children) ? v.children : [];
43
+
44
+ // A deep link to a flattened chunk (`<chapter>#/chunks[1]`) lands on the chapter
45
+ // page, so scroll to the anchored chunk once it has rendered (the browser's own
46
+ // scroll fires before the async value arrives). Re-runs when the chapter changes.
47
+ useHashScroll(node);
48
+
49
+ return (
50
+ <div className="chapter">
51
+ {node.title && <h1 className="chapter-title">{node.title}</h1>}
52
+ {node.description && <p className="chapter-subtitle">{node.description}</p>}
53
+
54
+ {chunks.map((item, i) => (
55
+ <ChunkBlock key={i} index={i} item={item} basePath={node.path} documentPath={node.documentPath} onNavigate={onNavigate} />
56
+ ))}
57
+
58
+ {children.map((item, i) => {
59
+ const link = asLink(item);
60
+ return (
61
+ <h2 className="chapter-link" key={i}>
62
+ <a
63
+ className="descend"
64
+ href={link?.path ?? "#"}
65
+ onClick={(e) => {
66
+ e.preventDefault();
67
+ if (link) onNavigate(link.path);
68
+ }}
69
+ >
70
+ {chapterTitle(link)}
71
+ </a>
72
+ </h2>
73
+ );
74
+ })}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ /** One numbered chunk, flattened into the chapter page: its zero-based index `§N`
80
+ * as an in-page anchor link to the chunk's own location (the fragment mirrors the
81
+ * chunk's path continuation), and the chunk rendered by the renderer for its
82
+ * (type, format) — falling back to a plain paragraph when none claims it (or the
83
+ * chunk is a bare inline value with no path). */
84
+ function ChunkBlock({
85
+ index,
86
+ item,
87
+ basePath,
88
+ documentPath,
89
+ onNavigate,
90
+ }: {
91
+ index: number;
92
+ item: unknown;
93
+ basePath: string;
94
+ documentPath?: string;
95
+ onNavigate: (path: string) => void;
96
+ }) {
97
+ const link = asLink(item);
98
+ const chunk: Chunk = {
99
+ value: link ? link.value : item,
100
+ path: link?.path ?? "",
101
+ type: link?.type ?? "string",
102
+ format: link?.format ?? null,
103
+ documentPath, // carried so a marklower chunk's `/…` link resolves to its document
104
+ };
105
+ const renderer = rendererFor(chunk.type, chunk.format);
106
+ const body = renderer?.renderChunk
107
+ ? renderer.renderChunk(chunk, onNavigate)
108
+ : <p className="chapter-prose">{String(chunk.value ?? "")}</p>;
109
+ // The chunk's location *within this page*: its path continuation past the chapter
110
+ // (e.g. `/chunks[1]`), used as both the element id and the `§N` anchor link. The
111
+ // full path stays navigable; this is the flattened, in-page locator.
112
+ const anchor = chunk.path ? pathContinuation(basePath, chunk.path) : null;
113
+ return (
114
+ <div className="chunk" id={anchor ?? undefined}>
115
+ {anchor ? (
116
+ <a className="chunk-index" href={`#${anchor}`}>
117
+ §{index}
118
+ </a>
119
+ ) : (
120
+ <span className="chunk-index">§{index}</span>
121
+ )}
122
+ <div className="chunk-body">{body}</div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ /** The path continuation from `base` to `full` — the segments of `full` past
128
+ * `base`, in JSON-path syntax. E.g. ("/book", "/book/chunks[1]") → "/chunks[1]".
129
+ * A flattened child's fragment anchor is exactly this continuation, so the anchor
130
+ * spelling matches the still-navigable full path. */
131
+ function pathContinuation(base: string, full: string): string {
132
+ return segsToStr(strToSegs(full).slice(strToSegs(base).length));
133
+ }
134
+
135
+ /** A subchapter link's label: its schema title, else a generic fallback. */
136
+ function chapterTitle(link: Link | null): string {
137
+ return link?.title ?? "(untitled chapter)";
138
+ }