yamlover 0.3.2 → 0.3.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yamlover",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Browse a yamlover tree in the web: npx yamlover <root> serves a React SPA over a directory.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,7 +3,7 @@ import { fetchInfo, fetchTasks, fetchTree, PasteResult, TaskInfo, TreeNode } fro
3
3
  import { Tree } from "./Tree";
4
4
  import { TaskStrip } from "./TaskStrip";
5
5
  import { NodeView, Format, FORMATS, DEFAULT_FORMAT } from "./NodeView";
6
- import { rendererName } from "./renderers/registry";
6
+ import { rendererName, tocView } from "./renderers/registry";
7
7
 
8
8
  const isStandardFormat = (f: Format) => (FORMATS as string[]).includes(f);
9
9
  import { crumbs, formatFromUrl, isAncestorPath, pathFromUrl, segsToStr, strToSegs, writeUrl } from "./paths";
@@ -66,6 +66,24 @@ function nextToLoad(tree: TreeNode, current: string): string | null {
66
66
  return null;
67
67
  }
68
68
 
69
+ /** The TOC rows in document (pre-order) order, mirroring exactly what `Tree`
70
+ * shows — `tocView` applies the same per-renderer unwrap/filter (chapters
71
+ * surface subchapters, dirs show children). Used by Ctrl-PgDn/PgUp to step the
72
+ * selection to the neighbouring entry. Covers only the LOADED tree: per-branch
73
+ * collapse state lives in each `Tree`'s local `open`, not here — but a branch
74
+ * starts open once its children are loaded, so loaded ≈ visible in practice;
75
+ * deep unloaded branches simply aren't reachable until expanded (lazy load). */
76
+ function flattenToc(tree: TreeNode | null): string[] {
77
+ if (!tree) return [];
78
+ const out: string[] = [];
79
+ const walk = (n: TreeNode) => {
80
+ out.push(n.path);
81
+ for (const c of tocView(n).children) walk(c);
82
+ };
83
+ walk(tree);
84
+ return out;
85
+ }
86
+
69
87
  export function App() {
70
88
  const [tree, setTree] = useState<TreeNode | null>(null);
71
89
  const [error, setError] = useState<string | null>(null);
@@ -73,6 +91,7 @@ export function App() {
73
91
  const [format, setFormat] = useState<Format>(formatFromUrl(DEFAULT_FORMAT) as Format);
74
92
  const [rootLabel, setRootLabel] = useState<string>(""); // CLI ROOT (breadcrumb head)
75
93
  const [leftWidth, setLeftWidth] = useState<number>(320);
94
+ const mainRef = useRef<HTMLElement>(null); // RHS pane — focused on TOC click so the keyboard drives the viewer
76
95
 
77
96
  // The breadcrumb head is the ROOT given on the command line (blank if omitted).
78
97
  useEffect(() => {
@@ -120,7 +139,7 @@ export function App() {
120
139
  const n = findNode(tree, current);
121
140
  if (!n) return; // not loaded along the path yet — wait for the next pass
122
141
  resolvedLanding.current = true;
123
- const rn = rendererName(n.type, n.format, n.concrete);
142
+ const rn = rendererName(n, n.concrete);
124
143
  if (rn) {
125
144
  setFormat(rn);
126
145
  writeUrl(current, rn, true);
@@ -249,7 +268,7 @@ export function App() {
249
268
  const target = tree ? findNode(tree, p) : null;
250
269
  let f: Format = format;
251
270
  if (target) {
252
- const rn = rendererName(target.type, target.format, target.concrete);
271
+ const rn = rendererName(target, target.concrete);
253
272
  f = rn ?? (isStandardFormat(format) ? format : DEFAULT_FORMAT);
254
273
  }
255
274
  writeUrl(p, f, false);
@@ -259,6 +278,39 @@ export function App() {
259
278
  [format, tree],
260
279
  );
261
280
 
281
+ // Selecting a TOC row navigates AND hands keyboard focus to the RHS pane, so
282
+ // Ctrl-PgDn/PgUp (and plain scroll keys) drive the viewer right after a click.
283
+ // Scoped to the tree — crumbs and in-content links keep plain `navigate`.
284
+ const selectFromToc = useCallback(
285
+ (p: string) => {
286
+ navigate(p);
287
+ mainRef.current?.focus();
288
+ },
289
+ [navigate],
290
+ );
291
+
292
+ // Ctrl/Alt + Down / Up step the selection to the next / previous TOC entry in
293
+ // document order (Alt as well as Ctrl because Ctrl+Up/Down is taken by macOS
294
+ // Mission Control). Attached once; reads live state through refs so the listener
295
+ // stays stable. `navigate` reveals + scrolls the new row (Tree's selected effect).
296
+ const navigateRef = useRef(navigate);
297
+ navigateRef.current = navigate;
298
+ useEffect(() => {
299
+ const onKey = (e: KeyboardEvent) => {
300
+ if (!(e.ctrlKey || e.altKey) || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return;
301
+ const t = e.target as HTMLElement | null;
302
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
303
+ const order = flattenToc(treeRef.current);
304
+ const i = order.indexOf(currentRef.current);
305
+ if (i < 0) return; // current not in the loaded TOC yet — nothing to step from
306
+ const next = Math.min(Math.max(i + (e.key === "ArrowDown" ? 1 : -1), 0), order.length - 1);
307
+ e.preventDefault();
308
+ if (next !== i) navigateRef.current(order[next]);
309
+ };
310
+ window.addEventListener("keydown", onKey);
311
+ return () => window.removeEventListener("keydown", onKey);
312
+ }, []);
313
+
262
314
  const changeFormat = useCallback(
263
315
  (f: Format) => {
264
316
  writeUrl(current, f, true);
@@ -288,7 +340,7 @@ export function App() {
288
340
  const sub = await fetchTree(dir, INITIAL_DEPTH);
289
341
  setTree((t) => (t ? replaceChildren(t, dir, sub.children) : t));
290
342
  const fileNode = sub.children.find((c) => c.path === result.path);
291
- const f: Format = (fileNode ? rendererName(fileNode.type, fileNode.format, fileNode.concrete) : null) ?? DEFAULT_FORMAT;
343
+ const f: Format = (fileNode ? rendererName(fileNode, fileNode.concrete) : null) ?? DEFAULT_FORMAT;
292
344
  writeUrl(result.path, f, false);
293
345
  setCurrent(result.path);
294
346
  setFormat(f);
@@ -324,7 +376,7 @@ export function App() {
324
376
  <nav className="crumbs">
325
377
  {crumbs(current, rootLabel).map((c, i) => (
326
378
  <span key={c.path}>
327
- {i > 0 && <span className="crumb-sep">/</span>}
379
+ {i > 0 && <span className="crumb-sep">:</span>}
328
380
  <a
329
381
  className="crumb"
330
382
  href={c.path}
@@ -353,7 +405,7 @@ export function App() {
353
405
  }
354
406
  if (error) return <div className="error">{error}</div>;
355
407
  if (!tree) return <div className="loading">loading…</div>;
356
- return <Tree node={tree} current={current} onSelect={navigate} onLoadChildren={loadChildren} />;
408
+ return <Tree node={tree} current={current} onSelect={selectFromToc} onLoadChildren={loadChildren} />;
357
409
  })()}
358
410
  </aside>
359
411
  <div
@@ -363,7 +415,7 @@ export function App() {
363
415
  document.body.style.userSelect = "none";
364
416
  }}
365
417
  />
366
- <main className="pane right">
418
+ <main className="pane right" ref={mainRef} tabIndex={-1}>
367
419
  <NodeView path={current} format={format} refreshSignal={refreshSignal} onFormat={changeFormat} onNavigate={navigate} onContentChanged={onContentChanged} onOpenUploaded={onOpenUploaded} />
368
420
  </main>
369
421
  </div>
package/src/client/api.ts CHANGED
@@ -5,6 +5,9 @@ export interface TreeNode {
5
5
  label: string;
6
6
  type: string;
7
7
  format: string | null;
8
+ valueType?: string | null; // renderer dispatch facets (TYPES.md §9)
9
+ hasKeyed?: boolean;
10
+ hasOrdinal?: boolean;
8
11
  concrete: string | null; // how it is stored; `dir` → a plain-folder icon
9
12
  hasChildren: boolean;
10
13
  children: TreeNode[];
@@ -13,7 +16,10 @@ export interface TreeNode {
13
16
  export interface NodeJson {
14
17
  path: string;
15
18
  type: string;
16
- format?: string | null; // schema `format`; with `type` it keys the renderer
19
+ format?: string | null; // schema `format`; with the facets it keys the renderer (TYPES.md §9)
20
+ valueType?: string | null; // the scalar self-VALUE's type (null|boolean|integer|number|string|binary), or null
21
+ hasKeyed?: boolean; // owns ≥1 keyed element
22
+ hasOrdinal?: boolean; // owns ≥1 ordinal (keyless) element
17
23
  concrete: string | null;
18
24
  documentPath?: string; // the document (nearest yamlover entity) this node is in —
19
25
  // the anchor a document-relative (`/…`) link resolves against
@@ -90,15 +96,18 @@ export interface TagRef {
90
96
  color: string | null;
91
97
  }
92
98
 
93
- /** An annotation of a material — ONE TAG APPLICATION: a marked segment (or the whole node, when
94
- * `selector` is absent) tagged by `tag`, with an optional per-application comment. `tag` is
95
- * null only for legacy annotations saved before tags carried the color. */
99
+ /** An annotation of a material — ONE TAG APPLICATION (ANNOTATIONS.md): the whole node, or a
100
+ * fragment within it (then `selector` carries that fragment's region and `fragmentSlug` its
101
+ * key, and `imageUrl` its crop), tagged by `tag` with optional `description` / `params`. */
96
102
  export interface Annotation {
97
- path: string; // the annotation's own node path
98
103
  tag?: TagRef | null;
99
104
  selector?: { type?: string; exact?: string; prefix?: string; suffix?: string; [k: string]: unknown };
105
+ fragmentSlug?: string; // set when the tag is on a fragment (the region) rather than the whole node
106
+ imageUrl?: string; // an image-like fragment's crop (a /api/blob URL)
100
107
  description?: string;
108
+ params?: Record<string, unknown>;
101
109
  created?: string;
110
+ path?: string; // a transient client marker only ("(preview)"/"(pending)"); annotations have no node path
102
111
  }
103
112
 
104
113
  /** The annotations whose `target` is the material at `path` (the engine's reverse link). */
@@ -112,16 +121,32 @@ export function fetchTagged(path: string): Promise<unknown[]> {
112
121
  return getJson<unknown[]>(`/api/tagged?path=${encodeURIComponent(path)}`);
113
122
  }
114
123
 
115
- /** Save a new annotation apply the tag at `tag` to the material at `target` (JSON paths),
116
- * optionally narrowed to `selector` and commented by `description`; returns the created path. */
117
- export function saveAnnotation(a: { target: string; tag: string; selector?: Record<string, unknown>; description?: string }): Promise<{ path: string }> {
118
- return fetch("/api/annotate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(a) }).then(
119
- async (res) => {
120
- const body = await res.json();
121
- if (!res.ok) throw new Error((body && body.error) || `HTTP ${res.status}`);
122
- return body as { path: string };
123
- },
124
- );
124
+ /** Evaluate a colon-grammar QUERY (QUERY.md / engine `query` op) at `at` (default: the root `:`),
125
+ * returning the matched node paths in canonical colon form. A malformed query rejects (the
126
+ * server answers 400). Reused by the tag-picker typeahead and, later, by find-usages. */
127
+ export function query(q: string, at = ":"): Promise<string[]> {
128
+ const params = new URLSearchParams({ q, path: at });
129
+ return getJson<{ results: string[] }>(`/api/query?${params}`).then((r) => r.results);
130
+ }
131
+
132
+ async function postJson<T>(url: string, body: unknown): Promise<T> {
133
+ const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
134
+ const json = await res.json();
135
+ if (!res.ok) throw new Error((json && json.error) || `HTTP ${res.status}`);
136
+ return json as T;
137
+ }
138
+
139
+ /** Create a FRAGMENT — a marked region in the node at `target` (ANNOTATIONS.md). Returns its slug
140
+ * and full node path, which is then the `target` for {@link annotate}. `imageBase64` is an
141
+ * optional PNG crop for image-like selections. */
142
+ export function createFragment(target: string, selector: Record<string, unknown>, imageBase64?: string): Promise<{ slug: string; fragmentPath: string }> {
143
+ return postJson("/api/fragment", { target, selector, ...(imageBase64 ? { imageBase64 } : {}) });
144
+ }
145
+
146
+ /** Apply the tag at `tag` to the node at `target` (a whole node OR a fragment path) — appends to
147
+ * the target's `yamlover-annotations`. `description`/`params` make it a parametrized annotation. */
148
+ export function annotate(a: { target: string; tag: string; description?: string; params?: Record<string, unknown> }): Promise<{ ok: true }> {
149
+ return postJson("/api/annotate", a);
125
150
  }
126
151
 
127
152
  /** Create a named tag at the project's default tags location (settings.yamlover; `/tags` by
@@ -178,9 +203,11 @@ export function pasteRich(target: string, rich: unknown): Promise<PasteResult> {
178
203
  return postPaste({ path: target, rich });
179
204
  }
180
205
 
181
- /** Delete the annotation at its node path (a standalone `<…>.yamlover` file, any directory). */
182
- export function deleteAnnotation(path: string): Promise<void> {
183
- return fetch(`/api/annotate?path=${encodeURIComponent(path)}`, { method: "DELETE" }).then(async (res) => {
206
+ /** Remove the application of `tag` from the node at `target` (a whole node OR a fragment path)
207
+ * splices the matching element out of its `yamlover-annotations`. */
208
+ export function deleteAnnotation(target: string, tag: string): Promise<void> {
209
+ const q = new URLSearchParams({ target, tag });
210
+ return fetch(`/api/annotate?${q}`, { method: "DELETE" }).then(async (res) => {
184
211
  if (!res.ok) throw new Error(((await res.json().catch(() => null))?.error) || `HTTP ${res.status}`);
185
212
  });
186
213
  }
@@ -15,19 +15,22 @@ import { ReactNode } from "react";
15
15
  const LINK_KEY = "$yamloverLink";
16
16
  const BINARY_KEY = "$yamloverBinary";
17
17
  const REF_KEY = "$yamloverRef";
18
- // An omni/mix node (a `!!omni` self-value + fields, or a `!!mix` of items + fields) arrives as
18
+ // An omni/mix node (a `!!var` self-value + fields, or a `!!mix` of items + fields) arrives as
19
19
  // `{ [MIXED_KEY]: {kind, value?, entries:[{key,value}]} }`, rendered in yamlover as a leading
20
20
  // scalar (omni) then each entry positional (`- v`, key=null) or keyed (`k: v`).
21
21
  const MIXED_KEY = "$yamloverMixed";
22
22
 
23
23
  export interface Link {
24
24
  kind: "object" | "array" | "scalar" | "binary" | "omni" | "mix";
25
- type?: string; // the target's JSON-Schema type; with `format`, the routing key
25
+ type?: string; // the target's JSON-Schema type; with the facets, the routing key
26
26
  path: string;
27
27
  title?: string; // the target's schema title, when set (used as a link label)
28
28
  count?: number;
29
29
  size?: number;
30
30
  format?: string | null;
31
+ valueType?: string | null; // renderer dispatch facets (TYPES.md §9) — carried so a chunk routes correctly
32
+ hasKeyed?: boolean;
33
+ hasOrdinal?: boolean;
31
34
  value?: unknown; // for a link to a scalar: its value, shown as the label
32
35
  color?: string | null; // for a link to a pure color tag: its explicit color (badges)
33
36
  concrete?: string | null; // how the target is stored; `dir`/`yamlover` → a folder icon
@@ -66,6 +69,16 @@ const asBinary = (v: unknown) => asSingle<BinaryPayload>(v, BINARY_KEY);
66
69
  const asRef = (v: unknown) => asSingle<Ref>(v, REF_KEY);
67
70
  const asMixed = (v: unknown) => asSingle<Mixed>(v, MIXED_KEY);
68
71
 
72
+ /** The scalar SELF-VALUE a string/scalar renderer should show. An OMNI node (a scalar that also
73
+ * carries fields — e.g. a markdown doc that gained `yamlover-annotations` keys) projects its page
74
+ * `value` as a `$yamloverMixed` marker, so peel it to the self-value; a plain scalar passes
75
+ * through. Pairs with the facet-tolerant dispatch (TYPES.md §9): routing keeps an annotated string
76
+ * on its renderer, and this hands that renderer the string — not the marker object. */
77
+ export function scalarValue(v: unknown): unknown {
78
+ const m = asMixed(v);
79
+ return m && m.kind === "omni" ? m.value : v;
80
+ }
81
+
69
82
  type Syntax = "yaml" | "json";
70
83
 
71
84
  /**
@@ -197,7 +210,7 @@ function emitYaml(value: unknown, indent: number, out: ReactNode[], kc: KC, nav:
197
210
  const mixed = asMixed(value);
198
211
  if (mixed) {
199
212
  const pad = " ".repeat(indent);
200
- // omni: the node's own scalar value on its own line first (`!!omni 5` → `5`)
213
+ // omni: the node's own scalar value on its own line first (`!!var 5` → `5`)
201
214
  if (mixed.kind === "omni") out.push(pad, scalarNode(mixed.value, "yaml", kc), "\n");
202
215
  for (const e of mixed.entries) {
203
216
  if (e.key === null) {
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState, type ReactNode } from "react";
2
- import { Annotation, TagRef, createTag, fetchAnnotations, fetchNode, saveAnnotation, deleteAnnotation } from "../api";
2
+ import { Annotation, TagRef, createTag, fetchAnnotations, fetchNode, query, createFragment, annotate, deleteAnnotation } from "../api";
3
3
  import { TAG_FORMAT, explicitColor, resolveTagColor, tagFields } from "./tag";
4
- import { canonPath, strToSegs } from "../paths";
4
+ import { canonPath, displayPath, strToSegs } from "../paths";
5
5
  import { touchesYamlover, useDiffBump } from "../live";
6
6
 
7
7
  /**
@@ -57,10 +57,10 @@ function annKey(a: Annotation): string {
57
57
  /** Whether an annotation can be edited/deleted here — any STANDALONE annotation file (its node
58
58
  * path is the `.yamlover` file itself), wherever it lives in the tree: annotations are graph
59
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). */
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
62
  export function editable(a: Annotation): boolean {
63
- return typeof a.path === "string" && a.path.endsWith(".yamlover");
63
+ return !!a.tag && a.path !== "(preview)" && a.path !== "(pending)";
64
64
  }
65
65
 
66
66
  // The color tags as indexed (fetched once per session; the constant covers offline/legacy roots).
@@ -88,6 +88,41 @@ export function useColorTags(): TagRef[] {
88
88
  return tags;
89
89
  }
90
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
+
91
126
  /** The remembered last-applied tag (persisted in localStorage) + a setter that persists it and
92
127
  * files a NAMED tag among the recents (color tags live in the swatch row already). */
93
128
  export function useAnnotationTag(): [TagRef, (t: TagRef) => void] {
@@ -140,10 +175,24 @@ function pruneRememberedTags(): Promise<TagRef[]> {
140
175
  });
141
176
  }
142
177
 
143
- /** Apply `tag` to the material at `target`, narrowed to `selector` when given (null = the whole
144
- * node); resolves when persisted. */
145
- export function createAnnotation(target: string, selector: Record<string, unknown> | null, tag: TagRef): Promise<unknown> {
146
- return saveAnnotation({ target, tag: tag.path, ...(selector ? { selector } : {}) });
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;
147
196
  }
148
197
 
149
198
  /** Read-only fetch of a material's annotations; `bump` (a changing number) forces a refetch.
@@ -168,8 +217,8 @@ export function useAnnotations(path: string, bump = 0): Annotation[] {
168
217
  * round-trip lands. */
169
218
  export interface MaterialAnnotations {
170
219
  annotations: Annotation[];
171
- create: (selector: Record<string, unknown> | null, tag: TagRef, opts?: { silent?: boolean }) => void;
172
- remove: (annPath?: string) => void;
220
+ create: (selector: Record<string, unknown> | null, tag: TagRef, opts?: { silent?: boolean; imageBase64?: string }) => void;
221
+ remove: (ann: Annotation) => void;
173
222
  retag: (ann: Annotation, tag: TagRef) => void;
174
223
  }
175
224
 
@@ -180,49 +229,54 @@ export function useMaterialAnnotations(path: string): MaterialAnnotations {
180
229
  const [bump, setBump] = useState(0);
181
230
  const fetched = useAnnotations(path, bump);
182
231
  const [optimistic, setOptimistic] = useState<Annotation[]>([]); // created, not yet in `fetched`
183
- const [deleted, setDeleted] = useState<Set<string>>(new Set()); // paths hidden, not yet dropped
232
+ const [deleted, setDeleted] = useState<Set<string>>(new Set()); // annKeys hidden, not yet dropped
184
233
 
185
- // Reconcile when the server list refreshes: drop optimistic creations it now holds, and keep a
186
- // path "deleted" only while the server still lists it (so a re-tag's old copy can't flash back).
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.
187
237
  useEffect(() => {
188
238
  const keys = new Set(fetched.map(annKey));
189
239
  setOptimistic((o) => o.filter((a) => !keys.has(annKey(a))));
190
- const present = new Set(fetched.map((a) => a.path).filter(Boolean) as string[]);
191
- setDeleted((d) => new Set([...d].filter((p) => present.has(p))));
240
+ setDeleted((d) => new Set([...d].filter((k) => keys.has(k))));
192
241
  }, [fetched]);
193
242
 
194
243
  const refresh = () => setBump((b) => b + 1);
195
- const create = (selector: Record<string, unknown> | null, tag: TagRef, opts?: { silent?: boolean }) => {
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 }) => {
196
249
  const entry = { path: "(pending)", selector: selector ?? undefined, tag } as Annotation;
197
250
  setOptimistic((o) => [...o, entry]);
198
- createAnnotation(path, selector, tag)
199
- .then(refresh)
200
- .catch((e) => {
201
- setOptimistic((o) => o.filter((x) => x !== entry)); // roll back the unsaved mark
202
- // An IMPLICIT save (clicking away with the pre-selected tag) is best-effort — e.g. the
203
- // default tag may not exist in this tree — so it rolls back QUIETLY. Only an explicit
204
- // pick (a swatch/badge/✓) reports the failure.
205
- if (!opts?.silent) window.alert("save failed: " + (e as Error).message);
206
- });
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));
207
254
  };
208
- const remove = (annPath?: string) => {
209
- if (!annPath || annPath === "(pending)") return;
210
- setDeleted((d) => new Set(d).add(annPath));
211
- deleteAnnotation(annPath)
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)
212
260
  .then(refresh)
213
- .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
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
214
262
  };
215
263
  const retag = (ann: Annotation, tag: TagRef) => {
216
- remove(ann.path); // hide + delete the old application
217
- create(ann.selector ?? null, tag); // show + save the new one
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
+ }
218
273
  };
219
274
 
220
275
  const seen = new Set<string>();
221
276
  const annotations: Annotation[] = [];
222
277
  for (const a of [...optimistic, ...fetched]) {
223
- if (a.path && deleted.has(a.path)) continue;
224
278
  const k = annKey(a);
225
- if (seen.has(k)) continue;
279
+ if (deleted.has(k) || seen.has(k)) continue;
226
280
  seen.add(k);
227
281
  annotations.push(a);
228
282
  }
@@ -235,6 +289,16 @@ function tagNameOf(path: string): string {
235
289
  return segs.length ? String(segs[segs.length - 1]) : path;
236
290
  }
237
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
+
238
302
  /** The floating tag picker — color-tag swatches, recent named-tag badges, a tag-path input, plus
239
303
  * action buttons. Mode decides which buttons show (the hook wires what each does): `create` gets
240
304
  * ✓ confirm + optional ⧉ copy + 🗑 discard; `edit` gets ✓ close + 🗑 delete (picking a tag re-tags).
@@ -247,8 +311,10 @@ export function AnnotationMenu({
247
311
  menuRef?: React.Ref<HTMLDivElement>;
248
312
  }) {
249
313
  const colorTags = useColorTags();
314
+ const tagIndex = useTagIndex(); // all named tags, for the typeahead
250
315
  const [recents, setRecents] = useState(recentTags); // shown at once; pruned against the server
251
316
  const [path, setPath] = useState("");
317
+ const [hi, setHi] = useState(-1); // highlighted suggestion (-1 = none)
252
318
  const [busy, setBusy] = useState(false); // a lookup/create round-trip is in flight
253
319
  const verb = mode === "edit" ? "re-tag" : "tag";
254
320
 
@@ -271,12 +337,31 @@ export function AnnotationMenu({
271
337
  ? recents
272
338
  : [tag, ...recents];
273
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
+
274
358
  // Apply an arbitrary tag by its node path: fetch, verify it IS a tag, pick it. A bare NAME
275
359
  // (no `/`) that matches no node is CREATED at the project's tags location and then picked —
276
360
  // typing a fresh name is how a new named tag is born. A missed multi-segment path stays an
277
- // error: a typo'd path must not silently mint a tag named like a path.
278
- const pickPath = () => {
279
- const p = path.trim();
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();
280
365
  if (!p || busy) return;
281
366
  setBusy(true);
282
367
  fetchNode(p.startsWith(":") ? p : p.startsWith("/") ? ":" + p.slice(1).split("/").join(":") : ":" + p, 1)
@@ -330,21 +415,56 @@ export function AnnotationMenu({
330
415
  ))}
331
416
  </div>
332
417
  )}
333
- <input
334
- className="annotate-taginput"
335
- type="text"
336
- placeholder={busy ? "creating tag…" : `${verb}: tag path or new name… ⏎`}
337
- value={path}
338
- disabled={busy}
339
- onChange={(e) => setPath(e.target.value)}
340
- onKeyDown={(e) => { if (e.key === "Enter") pickPath(); }}
341
- />
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>
342
462
  </div>
343
463
  );
344
464
  }
345
465
 
346
466
  type MenuState =
347
- | { mode: "create"; selector: Record<string, unknown>; copy?: () => void; x: number; y: number }
467
+ | { mode: "create"; selector: Record<string, unknown>; copy?: () => void; imageBase64?: string; x: number; y: number }
348
468
  | { mode: "edit"; ann: Annotation; x: number; y: number };
349
469
 
350
470
  /** Drives the floating picker for a material: `openCreate` after a fresh selection, `openEdit` on
@@ -352,7 +472,7 @@ type MenuState =
352
472
  * CREATE's selector + tag, so a renderer can keep the rectangle drawn while the picker is open).
353
473
  * Outside-click commits a create (with the pre-selected tag) but only closes an edit. */
354
474
  export function useAnnotationMenu(a: MaterialAnnotations): {
355
- openCreate: (selector: Record<string, unknown>, screen: { x: number; y: number }, copy?: () => void) => void;
475
+ openCreate: (selector: Record<string, unknown>, screen: { x: number; y: number }, copy?: () => void, imageBase64?: string) => void;
356
476
  openEdit: (ann: Annotation, screen: { x: number; y: number }) => void;
357
477
  palette: ReactNode;
358
478
  preview: { selector: Record<string, unknown>; tag: TagRef; color: string } | null;
@@ -363,12 +483,12 @@ export function useAnnotationMenu(a: MaterialAnnotations): {
363
483
  const menuRef = useRef<HTMLDivElement>(null);
364
484
  const close = () => setMenu(null);
365
485
 
366
- const openCreate = (selector: Record<string, unknown>, screen: { x: number; y: number }, copy?: () => void) =>
367
- setMenu({ mode: "create", selector, copy, x: screen.x, y: screen.y });
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 });
368
488
  const openEdit = (ann: Annotation, screen: { x: number; y: number }) =>
369
489
  setMenu({ mode: "edit", ann, x: screen.x, y: screen.y });
370
490
 
371
- const commitCreate = (t: TagRef, m: MenuState, silent = false) => { if (m.mode !== "create") return; setTag(t); a.create(m.selector, t, { silent }); close(); };
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(); };
372
492
  const commitRetag = (t: TagRef, m: MenuState) => { if (m.mode !== "edit") return; setTag(t); a.retag(m.ann, t); close(); };
373
493
 
374
494
  // Outside-click: a create commits with the pre-selected tag (default keeps the mark); an edit closes.
@@ -401,7 +521,7 @@ export function useAnnotationMenu(a: MaterialAnnotations): {
401
521
  menuRef={menuRef} x={menu.x} y={menu.y} tag={menu.ann.tag ?? DEFAULT_TAG} mode="edit"
402
522
  onPick={(t) => commitRetag(t, menu)}
403
523
  onConfirm={close} // ✓ closes the popup without deleting or re-tagging
404
- onTrash={() => { a.remove(menu.ann.path); close(); }}
524
+ onTrash={() => { a.remove(menu.ann); close(); }}
405
525
  />
406
526
  );
407
527
  }