yamlover 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/bin/yamlover.js +202 -0
- package/dist/server.js +4681 -0
- package/index.html +12 -0
- package/package.json +72 -0
- package/src/client/App.tsx +372 -0
- package/src/client/NodeView.tsx +422 -0
- package/src/client/TaskStrip.tsx +34 -0
- package/src/client/Tree.tsx +97 -0
- package/src/client/api.ts +186 -0
- package/src/client/icons.ts +91 -0
- package/src/client/links.tsx +108 -0
- package/src/client/live.ts +42 -0
- package/src/client/main.tsx +10 -0
- package/src/client/paste-html.ts +228 -0
- package/src/client/paste-links.ts +42 -0
- package/src/client/paths.ts +109 -0
- package/src/client/render.tsx +326 -0
- package/src/client/renderers/annotate.tsx +507 -0
- package/src/client/renderers/asciidoc.tsx +35 -0
- package/src/client/renderers/chapter.tsx +138 -0
- package/src/client/renderers/csv.tsx +233 -0
- package/src/client/renderers/decoded.tsx +72 -0
- package/src/client/renderers/djvu.tsx +97 -0
- package/src/client/renderers/doc.tsx +40 -0
- package/src/client/renderers/docx.tsx +49 -0
- package/src/client/renderers/epub.tsx +147 -0
- package/src/client/renderers/explorer.tsx +209 -0
- package/src/client/renderers/fb2.tsx +149 -0
- package/src/client/renderers/headings.ts +69 -0
- package/src/client/renderers/heic.tsx +23 -0
- package/src/client/renderers/imagemap.tsx +157 -0
- package/src/client/renderers/kml.ts +46 -0
- package/src/client/renderers/latex.tsx +36 -0
- package/src/client/renderers/map.tsx +205 -0
- package/src/client/renderers/marklower.tsx +119 -0
- package/src/client/renderers/markup.tsx +64 -0
- package/src/client/renderers/media.tsx +19 -0
- package/src/client/renderers/panzoom.ts +101 -0
- package/src/client/renderers/pdf.tsx +176 -0
- package/src/client/renderers/plaintext.tsx +120 -0
- package/src/client/renderers/plantuml.tsx +82 -0
- package/src/client/renderers/psd.tsx +25 -0
- package/src/client/renderers/registry.tsx +389 -0
- package/src/client/renderers/rtf.tsx +210 -0
- package/src/client/renderers/spreadsheet.tsx +105 -0
- package/src/client/renderers/tag.tsx +113 -0
- package/src/client/renderers/text.tsx +41 -0
- package/src/client/renderers/tiff.tsx +33 -0
- package/src/client/styles.css +1115 -0
- package/src/client/vendor/README.md +30 -0
- package/src/client/vendor/djvu.js +15535 -0
- package/src/client/vite-env.d.ts +31 -0
- package/src/server/api.ts +147 -0
- package/src/server/engine-api.ts +1442 -0
- package/src/server/gitignore.ts +81 -0
- package/src/server/node-kind.ts +48 -0
- package/src/server/tasks.ts +83 -0
- package/src/server/yamlover.ts +1133 -0
|
@@ -0,0 +1,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
|
+
}
|