yamlover 0.3.4 → 0.3.6

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 (137) hide show
  1. package/bin/yamlover.js +199 -112
  2. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  3. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  4. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  5. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  6. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  7. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  11. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  12. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  13. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  17. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  18. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  19. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  20. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  21. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  22. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  23. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  24. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  25. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  26. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  27. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  28. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  29. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  30. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  31. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  32. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  33. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  34. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  35. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  36. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  37. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  44. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  45. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  46. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  47. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  48. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  49. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  50. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  51. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  52. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  53. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  54. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  55. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  56. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  57. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  58. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  59. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  60. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  61. package/dist/client/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
  62. package/dist/client/assets/decoded-CFkcuQ1f.js +1 -0
  63. package/dist/client/assets/djvu-B3wuFWwI.js +1 -0
  64. package/dist/client/assets/docx-putHYXzO.js +230 -0
  65. package/dist/client/assets/heic-oIxhIbIW.js +66 -0
  66. package/dist/client/assets/imagemap-CUKUIcGe.js +1 -0
  67. package/dist/client/assets/index-CQ3TGVSx.js +617 -0
  68. package/dist/client/assets/index-wTjOzmSY.css +1 -0
  69. package/dist/client/assets/map-CZS083CS.js +1 -0
  70. package/dist/client/assets/paged-CjxSuuik.js +1 -0
  71. package/dist/client/assets/panzoom-C7OuIIda.js +4 -0
  72. package/dist/client/assets/panzoom-CIGW-MKW.css +1 -0
  73. package/dist/client/assets/pdf-Dz3gUx0B.js +12 -0
  74. package/dist/client/assets/pdf-m5e3jsV4.css +1 -0
  75. package/dist/client/assets/pdf.worker.min-qwK7q_zL.mjs +28 -0
  76. package/dist/client/assets/psd-C8yNwHFw.js +11 -0
  77. package/dist/client/assets/spreadsheet-DBv7aqoN.js +38 -0
  78. package/dist/client/assets/tiff-73RKtIux.js +1 -0
  79. package/{index.html → dist/client/index.html} +2 -1
  80. package/package.json +18 -21
  81. package/src/client/App.tsx +0 -424
  82. package/src/client/NodeView.tsx +0 -422
  83. package/src/client/TaskStrip.tsx +0 -34
  84. package/src/client/Tree.tsx +0 -97
  85. package/src/client/api.ts +0 -213
  86. package/src/client/icons.ts +0 -91
  87. package/src/client/links.tsx +0 -108
  88. package/src/client/live.ts +0 -42
  89. package/src/client/main.tsx +0 -10
  90. package/src/client/paste-html.ts +0 -228
  91. package/src/client/paste-links.ts +0 -42
  92. package/src/client/paths.ts +0 -139
  93. package/src/client/render.tsx +0 -339
  94. package/src/client/renderers/annotate.tsx +0 -641
  95. package/src/client/renderers/asciidoc.tsx +0 -36
  96. package/src/client/renderers/chapter.tsx +0 -142
  97. package/src/client/renderers/csv.tsx +0 -234
  98. package/src/client/renderers/decoded.tsx +0 -72
  99. package/src/client/renderers/djvu.tsx +0 -271
  100. package/src/client/renderers/djvuWorker.ts +0 -99
  101. package/src/client/renderers/doc.tsx +0 -40
  102. package/src/client/renderers/docx.tsx +0 -49
  103. package/src/client/renderers/epub.tsx +0 -147
  104. package/src/client/renderers/explorer.tsx +0 -294
  105. package/src/client/renderers/fb2.tsx +0 -149
  106. package/src/client/renderers/headings.ts +0 -69
  107. package/src/client/renderers/heic.tsx +0 -23
  108. package/src/client/renderers/imagemap.tsx +0 -170
  109. package/src/client/renderers/kml.ts +0 -46
  110. package/src/client/renderers/latex.tsx +0 -37
  111. package/src/client/renderers/map.tsx +0 -205
  112. package/src/client/renderers/marklower.tsx +0 -120
  113. package/src/client/renderers/markup.tsx +0 -64
  114. package/src/client/renderers/media.tsx +0 -19
  115. package/src/client/renderers/paged.ts +0 -137
  116. package/src/client/renderers/panzoom.ts +0 -101
  117. package/src/client/renderers/pdf.tsx +0 -316
  118. package/src/client/renderers/plaintext.tsx +0 -120
  119. package/src/client/renderers/plantuml.tsx +0 -83
  120. package/src/client/renderers/psd.tsx +0 -25
  121. package/src/client/renderers/registry.tsx +0 -411
  122. package/src/client/renderers/rtf.tsx +0 -210
  123. package/src/client/renderers/spreadsheet.tsx +0 -105
  124. package/src/client/renderers/tag.tsx +0 -113
  125. package/src/client/renderers/text.tsx +0 -42
  126. package/src/client/renderers/tiff.tsx +0 -33
  127. package/src/client/styles.css +0 -1212
  128. package/src/client/vendor/README.md +0 -30
  129. package/src/client/vite-env.d.ts +0 -31
  130. package/src/server/api.ts +0 -147
  131. package/src/server/embed.ts +0 -187
  132. package/src/server/engine-api.ts +0 -1563
  133. package/src/server/gitignore.ts +0 -81
  134. package/src/server/node-kind.ts +0 -61
  135. package/src/server/tasks.ts +0 -83
  136. package/src/server/yamlover.ts +0 -1133
  137. /package/{src/client/vendor/djvu.js → dist/client/assets/djvu-Ci0lFKxE.js} +0 -0
@@ -1,641 +0,0 @@
1
- import { useEffect, useRef, useState, type ReactNode } from "react";
2
- import { Annotation, TagRef, createTag, fetchAnnotations, fetchNode, query, createFragment, annotate, deleteAnnotation } from "../api";
3
- import { TAG_FORMAT, explicitColor, resolveTagColor, tagFields } from "./tag";
4
- import { canonPath, displayPath, 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
- * Every embedded annotation (one with a resolved tag) is editable — re-tag/delete just edits its
61
- * host body. Transient markers (a `(preview)`/`(pending)` placeholder) are not. */
62
- export function editable(a: Annotation): boolean {
63
- return !!a.tag && a.path !== "(preview)" && a.path !== "(pending)";
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
- // PROJECT-scope ref pinned to `::yamlover:tags:colors:<name>` — NOT derived from
77
- // `n.path` (the API echoes that in `:`-form, which would mismatch COLOR_TAGS and
78
- // resurrect the ghost badge); and `:` not `/` (the pre-SEPARATOR separator bug).
79
- if (color) out.push({ path: `::yamlover:tags:colors:${encodeURIComponent(name)}`, name, color });
80
- }
81
- return out.length ? out : COLOR_TAGS;
82
- })
83
- .catch(() => COLOR_TAGS);
84
- let cancelled = false;
85
- colorTagsPromise.then((t) => { if (!cancelled) setTags(t); });
86
- return () => { cancelled = true; };
87
- }, []);
88
- return tags;
89
- }
90
-
91
- // Enumerate every NAMED tag in the project for the picker typeahead: a document-root recursive
92
- // descent, format-filtered (QUERY.md "all tag nodes"). Document-root scope finds tags wherever
93
- // `settings.tags.location` puts them — the client need not know that path. The grafted COLOR
94
- // palette lives off the document root (link scope `::yamlover:…`) so it is naturally absent;
95
- // the defensive filter below also drops any color tag a project re-themes in-tree (those are the
96
- // swatch row, not the suggestion list).
97
- const TAG_QUERY = ": ...: !!<format: x-yamlover-tag>";
98
-
99
- function indexToRefs(paths: string[]): TagRef[] {
100
- const seen = new Set<string>();
101
- const out: TagRef[] = [];
102
- for (const p of paths) {
103
- const cp = canonPath(p);
104
- if (cp.startsWith(":yamlover:tags:colors:") || seen.has(cp)) continue; // colors → swatch row
105
- seen.add(cp);
106
- out.push({ path: p, name: tagNameOf(p), color: null }); // named tag → hue derived from name
107
- }
108
- return out;
109
- }
110
-
111
- /** The project's named tags, enumerated once and re-enumerated when a `.yamlover` source changes
112
- * (so a freshly created tag appears). Feeds the picker's typeahead suggestions. */
113
- export function useTagIndex(): TagRef[] {
114
- const [tags, setTags] = useState<TagRef[]>([]);
115
- const bump = useDiffBump(touchesYamlover);
116
- useEffect(() => {
117
- let cancelled = false;
118
- query(TAG_QUERY)
119
- .then((paths) => { if (!cancelled) setTags(indexToRefs(paths)); })
120
- .catch(() => { if (!cancelled) setTags([]); });
121
- return () => { cancelled = true; };
122
- }, [bump]);
123
- return tags;
124
- }
125
-
126
- /** The remembered last-applied tag (persisted in localStorage) + a setter that persists it and
127
- * files a NAMED tag among the recents (color tags live in the swatch row already). */
128
- export function useAnnotationTag(): [TagRef, (t: TagRef) => void] {
129
- const [tag, set] = useState<TagRef>(() => {
130
- try {
131
- const t = JSON.parse(localStorage.getItem(TAG_KEY) || "") as TagRef;
132
- if (t?.path && t?.name) return t;
133
- } catch { /* no/invalid stored tag */ }
134
- return DEFAULT_TAG;
135
- });
136
- const setTag = (t: TagRef) => {
137
- localStorage.setItem(TAG_KEY, JSON.stringify(t));
138
- rememberRecent(t);
139
- set(t);
140
- };
141
- return [tag, setTag];
142
- }
143
-
144
- /** The recently applied NAMED tags (newest first, capped). */
145
- export function recentTags(): TagRef[] {
146
- try {
147
- const r = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]") as TagRef[];
148
- if (Array.isArray(r)) return r.filter((t) => t?.path && t?.name);
149
- } catch { /* no/invalid recents */ }
150
- return [];
151
- }
152
-
153
- function rememberRecent(t: TagRef): void {
154
- if (canonPath(t.path).startsWith(":yamlover:tags:colors:")) return; // the swatch row already shows these
155
- const next = [t, ...recentTags().filter((r) => r.path !== t.path)].slice(0, 6);
156
- localStorage.setItem(RECENT_KEY, JSON.stringify(next));
157
- }
158
-
159
- /** Drop remembered tags whose node is GONE (or stopped being a tag): localStorage outlives the
160
- * tags themselves, so a deleted tag would linger as a clickable badge forever. Each recent (and
161
- * the remembered last-applied tag) is checked against the server; survivors are written back.
162
- * Resolves to the live recents — the menu shows those. */
163
- function pruneRememberedTags(): Promise<TagRef[]> {
164
- const isLive = (t: TagRef): Promise<boolean> =>
165
- fetchNode(t.path, 0).then((n) => n.format === TAG_FORMAT).catch(() => false);
166
- try {
167
- const t = JSON.parse(localStorage.getItem(TAG_KEY) || "") as TagRef;
168
- if (t?.path) void isLive(t).then((live) => { if (!live) localStorage.removeItem(TAG_KEY); });
169
- } catch { /* no/invalid stored tag */ }
170
- const recents = recentTags();
171
- return Promise.all(recents.map((t) => isLive(t).then((live) => (live ? t : null)))).then((kept) => {
172
- const live = kept.filter(Boolean) as TagRef[];
173
- if (live.length !== recents.length) localStorage.setItem(RECENT_KEY, JSON.stringify(live));
174
- return live;
175
- });
176
- }
177
-
178
- /** Apply `tag` to the material at `target`. With a `selector`, first create a FRAGMENT (the
179
- * region, plus an optional PNG crop) and tag THAT; without one, tag the whole node. */
180
- export async function createAnnotation(
181
- target: string,
182
- selector: Record<string, unknown> | null,
183
- tag: TagRef,
184
- imageBase64?: string,
185
- ): Promise<unknown> {
186
- if (!selector) return annotate({ target, tag: tag.path });
187
- const { fragmentPath } = await createFragment(target, selector, imageBase64);
188
- return annotate({ target: fragmentPath, tag: tag.path });
189
- }
190
-
191
- /** The host node path that carries a tag application: the material itself, or — when the
192
- * annotation marks a region — that fragment's node path (`…:yamlover-fragments:<slug>`). */
193
- function annotationTarget(materialPath: string, ann: Annotation): string {
194
- if (!ann.fragmentSlug) return materialPath;
195
- return (materialPath === ":" ? "" : materialPath) + ":yamlover-fragments:" + ann.fragmentSlug;
196
- }
197
-
198
- /** Read-only fetch of a material's annotations; `bump` (a changing number) forces a refetch.
199
- * Also refetches whenever a diff (live.ts — the unified change flow) touches a `.yamlover`
200
- * file: an annotation written/deleted ANYWHERE (this page's own save, another tab, a shell rm
201
- * reconciled by the watcher) or an edited taxonomy must redraw the marks without a reload. */
202
- export function useAnnotations(path: string, bump = 0): Annotation[] {
203
- const [anns, setAnns] = useState<Annotation[]>([]);
204
- const extBump = useDiffBump(touchesYamlover);
205
- useEffect(() => {
206
- let cancelled = false;
207
- fetchAnnotations(path)
208
- .then((a) => !cancelled && setAnns(a))
209
- .catch(() => !cancelled && setAnns([]));
210
- return () => { cancelled = true; };
211
- }, [path, bump, extBump]);
212
- return anns;
213
- }
214
-
215
- /** The actions every renderer needs over a material's annotations, with OPTIMISTIC rendering: a
216
- * create/re-tag shows at once and a delete hides at once, before the (slow, reindexing) server
217
- * round-trip lands. */
218
- export interface MaterialAnnotations {
219
- annotations: Annotation[];
220
- create: (selector: Record<string, unknown> | null, tag: TagRef, opts?: { silent?: boolean; imageBase64?: string }) => void;
221
- remove: (ann: Annotation) => void;
222
- retag: (ann: Annotation, tag: TagRef) => void;
223
- }
224
-
225
- /** A material's annotations + optimistic create/delete/re-tag. The displayed list merges the
226
- * server's annotations with pending creations (shown until the refetch holds them) minus pending
227
- * deletions (hidden until the refetch drops them) — so every change is reflected instantly. */
228
- export function useMaterialAnnotations(path: string): MaterialAnnotations {
229
- const [bump, setBump] = useState(0);
230
- const fetched = useAnnotations(path, bump);
231
- const [optimistic, setOptimistic] = useState<Annotation[]>([]); // created, not yet in `fetched`
232
- const [deleted, setDeleted] = useState<Set<string>>(new Set()); // annKeys hidden, not yet dropped
233
-
234
- // Reconcile when the server list refreshes: drop optimistic creations it now holds, and keep an
235
- // annotation "deleted" only while the server still lists it (so a re-tag's old copy can't flash
236
- // back). Identity is annKey (selector + tag) — annotations carry no node path of their own.
237
- useEffect(() => {
238
- const keys = new Set(fetched.map(annKey));
239
- setOptimistic((o) => o.filter((a) => !keys.has(annKey(a))));
240
- setDeleted((d) => new Set([...d].filter((k) => keys.has(k))));
241
- }, [fetched]);
242
-
243
- const refresh = () => setBump((b) => b + 1);
244
- const rollback = (entry: Annotation, e: unknown, silent?: boolean) => {
245
- setOptimistic((o) => o.filter((x) => x !== entry));
246
- if (!silent) window.alert("save failed: " + (e as Error).message);
247
- };
248
- const create = (selector: Record<string, unknown> | null, tag: TagRef, opts?: { silent?: boolean; imageBase64?: string }) => {
249
- const entry = { path: "(pending)", selector: selector ?? undefined, tag } as Annotation;
250
- setOptimistic((o) => [...o, entry]);
251
- // An IMPLICIT save (clicking away with the pre-selected tag) is best-effort — e.g. the
252
- // default tag may not exist in this tree — so it rolls back QUIETLY (opts.silent).
253
- createAnnotation(path, selector, tag, opts?.imageBase64).then(refresh).catch((e) => rollback(entry, e, opts?.silent));
254
- };
255
- const remove = (ann: Annotation) => {
256
- if (!ann?.tag || ann.path === "(pending)") return;
257
- const key = annKey(ann);
258
- setDeleted((d) => new Set(d).add(key));
259
- deleteAnnotation(annotationTarget(path, ann), ann.tag.path)
260
- .then(refresh)
261
- .catch((e) => { setDeleted((d) => { const n = new Set(d); n.delete(key); return n; }); window.alert("delete failed: " + (e as Error).message); }); // un-hide on failure
262
- };
263
- const retag = (ann: Annotation, tag: TagRef) => {
264
- remove(ann); // hide + delete the old application
265
- if (ann.fragmentSlug) {
266
- // re-tag the SAME fragment (no new region) — annotate its existing node with the new tag
267
- const entry = { path: "(pending)", selector: ann.selector, fragmentSlug: ann.fragmentSlug, tag } as Annotation;
268
- setOptimistic((o) => [...o, entry]);
269
- annotate({ target: annotationTarget(path, ann), tag: tag.path }).then(refresh).catch((e) => rollback(entry, e));
270
- } else {
271
- create(null, tag); // whole-node re-tag
272
- }
273
- };
274
-
275
- const seen = new Set<string>();
276
- const annotations: Annotation[] = [];
277
- for (const a of [...optimistic, ...fetched]) {
278
- const k = annKey(a);
279
- if (deleted.has(k) || seen.has(k)) continue;
280
- seen.add(k);
281
- annotations.push(a);
282
- }
283
- return { annotations, create, remove, retag };
284
- }
285
-
286
- /** A tag's display name from its node path (its last segment). */
287
- function tagNameOf(path: string): string {
288
- const segs = strToSegs(path);
289
- return segs.length ? String(segs[segs.length - 1]) : path;
290
- }
291
-
292
- /** Typeahead rank for a tag against the lowercased query `q` (lower is better): an exact name,
293
- * then a name prefix, then a name substring, then matched only via the full path. */
294
- function rankTag(t: TagRef, q: string): number {
295
- const n = t.name.toLowerCase();
296
- if (n === q) return 0;
297
- if (n.startsWith(q)) return 1;
298
- if (n.includes(q)) return 2;
299
- return 3;
300
- }
301
-
302
- /** The floating tag picker — color-tag swatches, recent named-tag badges, a tag-path input, plus
303
- * action buttons. Mode decides which buttons show (the hook wires what each does): `create` gets
304
- * ✓ confirm + optional ⧉ copy + 🗑 discard; `edit` gets ✓ close + 🗑 delete (picking a tag re-tags).
305
- * `position: fixed`, so x/y are viewport coords. */
306
- export function AnnotationMenu({
307
- x, y, tag, mode, onPick, onConfirm, onCopy, onTrash, menuRef,
308
- }: {
309
- x: number; y: number; tag: TagRef; mode: "create" | "edit";
310
- onPick: (t: TagRef) => void; onConfirm?: () => void; onCopy?: () => void; onTrash: () => void;
311
- menuRef?: React.Ref<HTMLDivElement>;
312
- }) {
313
- const colorTags = useColorTags();
314
- const tagIndex = useTagIndex(); // all named tags, for the typeahead
315
- const [recents, setRecents] = useState(recentTags); // shown at once; pruned against the server
316
- const [path, setPath] = useState("");
317
- const [hi, setHi] = useState(-1); // highlighted suggestion (-1 = none)
318
- const [busy, setBusy] = useState(false); // a lookup/create round-trip is in flight
319
- const verb = mode === "edit" ? "re-tag" : "tag";
320
-
321
- // A deleted tag must not survive as a badge: on open, drop remembered tags the server no
322
- // longer holds (the stored list is shown immediately; the pruned one replaces it quietly).
323
- useEffect(() => {
324
- let on = true;
325
- pruneRememberedTags().then((live) => { if (on) setRecents(live); });
326
- return () => { on = false; };
327
- }, []);
328
-
329
- // Compare tag paths on a CANONICAL key — a palette ref is project-scope (`::yamlover:…`)
330
- // while a selected/edited tag may arrive `:`-form (the API echoes paths in `:`-form, and
331
- // older localStorage holds `:`-form); raw `===` would miss the match and duplicate the tag.
332
- const same = (a: string, b: string) => canonPath(a) === canonPath(b);
333
- // The badge row must always include THE tag this menu is about (`sel`-framed, like the
334
- // selected color swatch) — which tag is assigned/pre-selected must be visible at a glance,
335
- // even when it has aged out of the recents. A PALETTE tag is shown as a swatch, not a badge.
336
- const badges = colorTags.some((c) => same(c.path, tag.path)) || recents.some((r) => same(r.path, tag.path))
337
- ? recents
338
- : [tag, ...recents];
339
-
340
- // Typeahead suggestions: substring match on the typed text against the tag name OR its full
341
- // path (so `humor` and `genre:humor:deadpan` both hit), ranked, capped. Hide what is one click
342
- // away already — a swatch or a badge — so the list is only NEW choices.
343
- const q = path.trim().toLowerCase();
344
- const suggestions = q
345
- ? tagIndex
346
- .filter((t) =>
347
- !colorTags.some((c) => same(c.path, t.path)) &&
348
- !badges.some((b) => same(b.path, t.path)) &&
349
- (t.name.toLowerCase().includes(q) || canonPath(t.path).toLowerCase().includes(q)))
350
- .map((t) => ({ t, rank: rankTag(t, q) }))
351
- .sort((a, b) => a.rank - b.rank || a.t.name.localeCompare(b.t.name))
352
- .slice(0, 8)
353
- .map((x) => x.t)
354
- : [];
355
- // Re-seat the highlight whenever the typed text changes (suggestions derive from it).
356
- useEffect(() => { setHi(suggestions.length ? 0 : -1); }, [path]); // eslint-disable-line react-hooks/exhaustive-deps
357
-
358
- // Apply an arbitrary tag by its node path: fetch, verify it IS a tag, pick it. A bare NAME
359
- // (no `/`) that matches no node is CREATED at the project's tags location and then picked —
360
- // typing a fresh name is how a new named tag is born. A missed multi-segment path stays an
361
- // error: a typo'd path must not silently mint a tag named like a path. `raw` lets a chosen
362
- // suggestion route through the same fetch-verify (re-checking the tag still exists).
363
- const pickPath = (raw?: string) => {
364
- const p = (raw ?? path).trim();
365
- if (!p || busy) return;
366
- setBusy(true);
367
- fetchNode(p.startsWith(":") ? p : p.startsWith("/") ? ":" + p.slice(1).split("/").join(":") : ":" + p, 1)
368
- .then((n) => {
369
- if (n.format !== TAG_FORMAT) throw new Error("not a tag node");
370
- onPick({ path: n.path, name: n.title || tagNameOf(n.path), color: explicitColor(n.value) });
371
- })
372
- .catch((e) => {
373
- if (p.includes(":") || p.includes("/")) throw new Error(`cannot ${verb} with "${p}": ` + (e as Error).message);
374
- return createTag(p)
375
- .then(onPick)
376
- .catch((e2) => { throw new Error(`cannot create tag "${p}": ` + (e2 as Error).message); });
377
- })
378
- .catch((e) => window.alert((e as Error).message))
379
- .finally(() => setBusy(false));
380
- };
381
-
382
- return (
383
- <div ref={menuRef} className="annotate-menu" style={{ left: x, top: y }} role="menu">
384
- <div className="annotate-palette">
385
- {colorTags.map((t) => (
386
- <button
387
- key={t.path}
388
- type="button"
389
- className={"annotate-swatch" + (same(t.path, tag.path) ? " sel" : "")}
390
- style={{ background: resolveTagColor(t) }}
391
- title={`${verb} ${t.name}`}
392
- onClick={() => onPick(t)}
393
- />
394
- ))}
395
- </div>
396
- {onConfirm && <button type="button" className="annotate-tool ok" title={mode === "edit" ? "close (keep this annotation)" : `${verb} ${tag.name} (keep the mark)`} onClick={onConfirm}>✓</button>}
397
- {onCopy && <button type="button" className="annotate-tool" title="copy text to clipboard (don't annotate)" onClick={onCopy}>⧉</button>}
398
- <button type="button" className="annotate-tool danger" title={mode === "edit" ? "delete this annotation" : "discard (don't annotate)"} onClick={onTrash}>🗑</button>
399
- {badges.length > 0 && (
400
- <div className="annotate-recents">
401
- {badges.map((t) => (
402
- // the frame is a WRAPPER: filter applies before clip-path on the same element, so a
403
- // ring drawn on the clipped .tagtag itself would be clipped away with it (styles.css)
404
- <span key={t.path} className={"tagframe" + (same(t.path, tag.path) ? " sel" : "")}>
405
- <button
406
- type="button"
407
- className="tagtag"
408
- style={{ background: resolveTagColor(t) }}
409
- title={`${verb} ${t.name}`}
410
- onClick={() => onPick(t)}
411
- >
412
- {t.name}
413
- </button>
414
- </span>
415
- ))}
416
- </div>
417
- )}
418
- <div className="annotate-typeahead">
419
- <input
420
- className="annotate-taginput"
421
- type="text"
422
- placeholder={busy ? "creating tag…" : `${verb}: tag path or new name… ⏎`}
423
- value={path}
424
- disabled={busy}
425
- autoComplete="off"
426
- onChange={(e) => setPath(e.target.value)}
427
- onKeyDown={(e) => {
428
- // Plain Arrow/Enter never reach App.tsx's global nav (it bails on focused inputs),
429
- // but guard anyway and keep the caret from jumping while we drive the list.
430
- if (suggestions.length && e.key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); setHi((i) => (i + 1) % suggestions.length); return; }
431
- if (suggestions.length && e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); setHi((i) => (i - 1 + suggestions.length) % suggestions.length); return; }
432
- if (e.key === "Escape" && path) { e.preventDefault(); e.stopPropagation(); setPath(""); return; }
433
- if (e.key === "Enter") {
434
- e.preventDefault(); e.stopPropagation();
435
- if (hi >= 0 && suggestions[hi]) pickPath(suggestions[hi].path); // the highlighted tag wins
436
- else pickPath(); // else the typed path / create-on-miss
437
- }
438
- }}
439
- />
440
- {!busy && suggestions.length > 0 && (
441
- <div className="annotate-suggest" role="listbox">
442
- {suggestions.map((t, i) => (
443
- <span key={t.path} className={"tagframe" + (i === hi ? " sel" : "")}>
444
- <button
445
- type="button"
446
- role="option"
447
- aria-selected={i === hi}
448
- className="tagtag"
449
- style={{ background: resolveTagColor(t) }}
450
- title={displayPath(t.path)}
451
- // mousedown (not click) + preventDefault: fire before the input blurs, keep focus.
452
- onMouseDown={(e) => { e.preventDefault(); pickPath(t.path); }}
453
- onMouseEnter={() => setHi(i)}
454
- >
455
- {t.name}
456
- </button>
457
- </span>
458
- ))}
459
- </div>
460
- )}
461
- </div>
462
- </div>
463
- );
464
- }
465
-
466
- type MenuState =
467
- | { mode: "create"; selector: Record<string, unknown>; copy?: () => void; imageBase64?: string; x: number; y: number }
468
- | { mode: "edit"; ann: Annotation; x: number; y: number };
469
-
470
- /** Drives the floating picker for a material: `openCreate` after a fresh selection, `openEdit` on
471
- * a click on an existing mark. Returns the rendered `palette`, and a `preview` (the pending
472
- * CREATE's selector + tag, so a renderer can keep the rectangle drawn while the picker is open).
473
- * Outside-click commits a create (with the pre-selected tag) but only closes an edit. */
474
- export function useAnnotationMenu(a: MaterialAnnotations): {
475
- openCreate: (selector: Record<string, unknown>, screen: { x: number; y: number }, copy?: () => void, imageBase64?: string) => void;
476
- openEdit: (ann: Annotation, screen: { x: number; y: number }) => void;
477
- palette: ReactNode;
478
- preview: { selector: Record<string, unknown>; tag: TagRef; color: string } | null;
479
- color: string;
480
- } {
481
- const [tag, setTag] = useAnnotationTag();
482
- const [menu, setMenu] = useState<MenuState | null>(null);
483
- const menuRef = useRef<HTMLDivElement>(null);
484
- const close = () => setMenu(null);
485
-
486
- const openCreate = (selector: Record<string, unknown>, screen: { x: number; y: number }, copy?: () => void, imageBase64?: string) =>
487
- setMenu({ mode: "create", selector, copy, imageBase64, x: screen.x, y: screen.y });
488
- const openEdit = (ann: Annotation, screen: { x: number; y: number }) =>
489
- setMenu({ mode: "edit", ann, x: screen.x, y: screen.y });
490
-
491
- const commitCreate = (t: TagRef, m: MenuState, silent = false) => { if (m.mode !== "create") return; setTag(t); a.create(m.selector, t, { silent, imageBase64: m.imageBase64 }); close(); };
492
- const commitRetag = (t: TagRef, m: MenuState) => { if (m.mode !== "edit") return; setTag(t); a.retag(m.ann, t); close(); };
493
-
494
- // Outside-click: a create commits with the pre-selected tag (default keeps the mark); an edit closes.
495
- useEffect(() => {
496
- if (!menu) return;
497
- const onDown = (e: MouseEvent) => {
498
- if (menuRef.current?.contains(e.target as Node)) return;
499
- if (menu.mode === "create") commitCreate(tag, menu, true); // implicit → best-effort, no error popup
500
- else close();
501
- };
502
- document.addEventListener("mousedown", onDown);
503
- return () => document.removeEventListener("mousedown", onDown);
504
- // eslint-disable-next-line react-hooks/exhaustive-deps
505
- }, [menu, tag]);
506
-
507
- let palette: ReactNode = null;
508
- if (menu?.mode === "create") {
509
- palette = (
510
- <AnnotationMenu
511
- menuRef={menuRef} x={menu.x} y={menu.y} tag={tag} mode="create"
512
- onPick={(t) => commitCreate(t, menu)}
513
- onConfirm={() => commitCreate(tag, menu)}
514
- onCopy={menu.copy ? () => { menu.copy!(); close(); } : undefined}
515
- onTrash={close}
516
- />
517
- );
518
- } else if (menu?.mode === "edit") {
519
- palette = (
520
- <AnnotationMenu
521
- menuRef={menuRef} x={menu.x} y={menu.y} tag={menu.ann.tag ?? DEFAULT_TAG} mode="edit"
522
- onPick={(t) => commitRetag(t, menu)}
523
- onConfirm={close} // ✓ closes the popup without deleting or re-tagging
524
- onTrash={() => { a.remove(menu.ann); close(); }}
525
- />
526
- );
527
- }
528
- const preview = menu?.mode === "create" ? { selector: menu.selector, tag, color: resolveTagColor(tag) } : null;
529
- return { openCreate, openEdit, palette, preview, color: resolveTagColor(tag) };
530
- }
531
-
532
- export function AnnotatedMaterial({ path, children }: { path: string; children: ReactNode }) {
533
- const ref = useRef<HTMLDivElement>(null);
534
- const material = useMaterialAnnotations(path);
535
- const { openCreate, openEdit, palette } = useAnnotationMenu(material);
536
- const { annotations } = material;
537
-
538
- // Re-highlight after each render: the material (esp. a chapter's chunks) may settle a tick
539
- // after mount, so do it on a frame and clear any prior marks first.
540
- useEffect(() => {
541
- const el = ref.current;
542
- if (!el) return;
543
- const raf = requestAnimationFrame(() => highlight(el, annotations));
544
- return () => cancelAnimationFrame(raf);
545
- });
546
-
547
- // A finished text selection inside the material raises the CREATE menu by the selection.
548
- useEffect(() => {
549
- const onUp = (e: MouseEvent) => {
550
- const el = ref.current;
551
- if (!el) return;
552
- if ((e.target as HTMLElement)?.closest?.(".annotate-menu")) return; // a menu click, not a selection
553
- if ((e.target as HTMLElement)?.closest?.("mark.yo-annotation")) return; // a mark click → handled by onClick
554
- const sel = window.getSelection();
555
- if (!sel || sel.isCollapsed || !sel.anchorNode || !el.contains(sel.anchorNode)) return;
556
- const cap = capture(sel);
557
- if (!cap) return;
558
- const rect = sel.getRangeAt(0).getBoundingClientRect();
559
- const copy = () => navigator.clipboard?.writeText(cap.exact).catch(() => { /* clipboard blocked */ });
560
- openCreate({ type: "text", exact: cap.exact, prefix: cap.prefix, suffix: cap.suffix }, { x: rect.left, y: rect.bottom + 6 }, copy);
561
- };
562
- document.addEventListener("mouseup", onUp);
563
- return () => document.removeEventListener("mouseup", onUp);
564
- // eslint-disable-next-line react-hooks/exhaustive-deps
565
- }, []);
566
-
567
- // Clicking an existing highlight opens the EDIT menu for that annotation.
568
- const onClickMark = (e: React.MouseEvent) => {
569
- const mark = (e.target as HTMLElement).closest("mark.yo-annotation") as HTMLElement | null;
570
- if (!mark) return;
571
- const ann = annotations.find((x) => annKey(x) === mark.dataset.annSel);
572
- if (!ann || !editable(ann)) return;
573
- e.preventDefault();
574
- openEdit(ann, { x: e.clientX, y: e.clientY });
575
- };
576
-
577
- return (
578
- <div className="annotated">
579
- {annotations.length > 0 && (
580
- <div className="annotate-bar">
581
- <span className="annotate-count">{annotations.length} annotation{annotations.length > 1 ? "s" : ""}</span>
582
- </div>
583
- )}
584
- <div ref={ref} onClick={onClickMark}>{children}</div>
585
- {palette}
586
- </div>
587
- );
588
- }
589
-
590
- /** Resolve the current selection to a quote selector ({exact, prefix, suffix}), or null if empty. */
591
- function capture(sel: Selection): { exact: string; prefix: string; suffix: string } | null {
592
- const exact = sel.toString().trim();
593
- if (!exact) return null;
594
- const full = sel.anchorNode?.nodeValue ?? "";
595
- const at = full.indexOf(exact);
596
- const prefix = at >= 0 ? full.slice(Math.max(0, at - 24), at) : "";
597
- const suffix = at >= 0 ? full.slice(at + exact.length, at + exact.length + 24) : "";
598
- return { exact, prefix, suffix };
599
- }
600
-
601
- /** (Re)apply highlight marks for the text annotations in `container`. */
602
- function highlight(container: HTMLElement, anns: Annotation[]): void {
603
- container.querySelectorAll("mark.yo-annotation").forEach((m) => {
604
- const parent = m.parentNode;
605
- if (!parent) return;
606
- while (m.firstChild) parent.insertBefore(m.firstChild, m);
607
- parent.removeChild(m);
608
- parent.normalize();
609
- });
610
- for (const a of anns) {
611
- if (a.selector?.type !== "text" || !a.selector.exact) continue;
612
- wrapFirst(container, a.selector.exact, a);
613
- }
614
- }
615
-
616
- /** Wrap the first text occurrence of an annotation's `exact` (within one text node) in a colored,
617
- * clickable `<mark>` carrying its identity key (so a click maps back to the annotation). */
618
- function wrapFirst(container: HTMLElement, exact: string, a: Annotation): void {
619
- const c = colorOf(a);
620
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
621
- let n: Node | null;
622
- while ((n = walker.nextNode())) {
623
- const i = (n.nodeValue ?? "").indexOf(exact);
624
- if (i < 0) continue;
625
- const range = document.createRange();
626
- range.setStart(n, i);
627
- range.setEnd(n, i + exact.length);
628
- const mark = document.createElement("mark");
629
- mark.className = "yo-annotation";
630
- mark.style.backgroundColor = `color-mix(in srgb, ${c} 30%, transparent)`; // works for hex AND a named tag's hsl()
631
- mark.style.borderBottomColor = c;
632
- mark.dataset.annSel = annKey(a);
633
- mark.title = a.description || "click to re-tag or delete";
634
- try {
635
- range.surroundContents(mark); // works when the match is within one text node (v1)
636
- return;
637
- } catch {
638
- /* the snippet spans element boundaries — skip highlighting it for now */
639
- }
640
- }
641
- }
@@ -1,36 +0,0 @@
1
- import Asciidoctor from "@asciidoctor/core";
2
- import { NodeJson } from "../api";
3
- import { scalarValue } from "../render";
4
- import { Chunk } from "./registry";
5
- import { anchorizeHeadings, useHashScroll } from "./headings";
6
- import { Markup } from "./markup";
7
-
8
- // One processor instance for the app; `convert` is pure per call.
9
- const processor = Asciidoctor();
10
-
11
- /**
12
- * The renderer for a `string`/`text/asciidoc` node: AsciiDoc converted to HTML
13
- * with `@asciidoctor/core`. Like the text/markdown renderer it works from the
14
- * node's string value, so it serves both a whole `.adoc` file (`render`) and a
15
- * single inline chunk (`renderChunk`). Each heading is then given a `§` anchor link
16
- * (see {@link anchorizeHeadings}); Asciidoctor's own section ids are kept, so the
17
- * anchors line up with the document's internal cross-references.
18
- */
19
- function adoc(value: unknown): string {
20
- return anchorizeHeadings(processor.convert(String(value ?? ""), { standalone: false }) as string);
21
- }
22
-
23
- export function AsciidocView({ node }: { node: NodeJson }) {
24
- useHashScroll(node);
25
- return (
26
- <div className="text">
27
- {node.title && <h1 className="chapter-title">{node.title}</h1>}
28
- {node.description && <p className="chapter-subtitle">{node.description}</p>}
29
- <Markup html={adoc(scalarValue(node.value))} />
30
- </div>
31
- );
32
- }
33
-
34
- export function AsciidocChunk({ chunk }: { chunk: Chunk }) {
35
- return <div className="markup" dangerouslySetInnerHTML={{ __html: adoc(chunk.value) }} />;
36
- }