yamlover 0.3.1 → 0.3.3

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/dist/server.js CHANGED
@@ -2345,6 +2345,25 @@ async function walkTreeAsync(absDir, opts = {}) {
2345
2345
  }
2346
2346
  return r.value;
2347
2347
  }
2348
+ var BUILTIN_TAG_SCHEMA = "type: object\nformat: x-yamlover-tag\nproperties:\n color:\n type: string\nadditionalProperties: *:: yamlover: $defs: tag\n";
2349
+ var BUILTIN_TAGS_BODY = '!!<*yamlover:$defs:tag>\ncolors: The palette\n yellow:\n color: "#f9e2af"\n green:\n color: "#a6e3a1"\n sky:\n color: "#89dceb"\n mauve:\n color: "#cba6f7"\n pink:\n color: "#f5c2e7"\n peach:\n color: "#fab387"\n';
2350
+ var builtinTemplate = null;
2351
+ function builtinYamloverGraft() {
2352
+ builtinTemplate ??= {
2353
+ tag: parseYamlover(BUILTIN_TAG_SCHEMA, "$defs/tag").root,
2354
+ tags: parseYamlover(BUILTIN_TAGS_BODY, "tags/.yamlover/body.yamlover").root
2355
+ };
2356
+ const tagCopy = structuredClone(builtinTemplate.tag);
2357
+ const node = {
2358
+ kind: "mapping",
2359
+ array: false,
2360
+ entries: [
2361
+ { key: "$defs", edge: "contain", value: { kind: "mapping", array: false, entries: [{ key: "tag", edge: "contain", value: tagCopy }] } },
2362
+ { key: "tags", edge: "contain", value: structuredClone(builtinTemplate.tags) }
2363
+ ]
2364
+ };
2365
+ return { node, defs: /* @__PURE__ */ new Map([["tag", tagCopy]]) };
2366
+ }
2348
2367
  function* walkTreeGen(absDir, opts = {}) {
2349
2368
  const ctx = { root: path.resolve(absDir), opts, files: /* @__PURE__ */ new Map(), count: 0 };
2350
2369
  const root = yield* dirNode(ctx.root, ctx);
@@ -2352,13 +2371,20 @@ function* walkTreeGen(absDir, opts = {}) {
2352
2371
  const defsRoot = findDefsRoot(absDir);
2353
2372
  const defsDir = path.join(defsRoot, "$defs");
2354
2373
  const arrayRoot = root.array || (root.entries?.length ? root.entries.every((e2) => e2.key === null) : false);
2355
- if (!arrayRoot && fs.existsSync(defsDir) && root.entries && !root.entries.some((e2) => e2.key === "yamlover")) {
2356
- const shared = [{ key: "$defs", edge: "contain", value: yield* dirNode(defsDir, ctx) }];
2357
- const tagsDir = path.join(defsRoot, "tags");
2358
- if (fs.existsSync(tagsDir)) shared.push({ key: "tags", edge: "contain", value: yield* dirNode(tagsDir, ctx) });
2359
- root.entries.push({ key: "yamlover", edge: "contain", value: { kind: "mapping", entries: shared, array: false } });
2374
+ let builtinDefs;
2375
+ if (!arrayRoot && root.entries && !root.entries.some((e2) => e2.key === "yamlover")) {
2376
+ if (fs.existsSync(defsDir)) {
2377
+ const shared = [{ key: "$defs", edge: "contain", value: yield* dirNode(defsDir, ctx) }];
2378
+ const tagsDir = path.join(defsRoot, "tags");
2379
+ if (fs.existsSync(tagsDir)) shared.push({ key: "tags", edge: "contain", value: yield* dirNode(tagsDir, ctx) });
2380
+ root.entries.push({ key: "yamlover", edge: "contain", value: { kind: "mapping", entries: shared, array: false } });
2381
+ } else {
2382
+ const built = builtinYamloverGraft();
2383
+ root.entries.push({ key: "yamlover", edge: "contain", value: built.node });
2384
+ builtinDefs = built.defs;
2385
+ }
2360
2386
  }
2361
- applySchemas(root, defsRoot);
2387
+ applySchemas(root, defsRoot, builtinDefs);
2362
2388
  return {
2363
2389
  doc: { root, source: { concrete: "directory", uri: absDir } },
2364
2390
  files: [...ctx.files.values()]
@@ -2572,7 +2598,7 @@ function findDefsRoot(dir) {
2572
2598
  d = up;
2573
2599
  }
2574
2600
  }
2575
- function applySchemas(root, defsRoot) {
2601
+ function applySchemas(root, defsRoot, builtinDefs) {
2576
2602
  const cache = /* @__PURE__ */ new Map();
2577
2603
  const loadDef = (name) => {
2578
2604
  if (!cache.has(name)) {
@@ -2580,7 +2606,7 @@ function applySchemas(root, defsRoot) {
2580
2606
  try {
2581
2607
  cache.set(name, parseYamlover(fs.readFileSync(defFile, "utf8"), defFile).root);
2582
2608
  } catch {
2583
- cache.set(name, null);
2609
+ cache.set(name, builtinDefs?.get(name) ?? null);
2584
2610
  }
2585
2611
  }
2586
2612
  return cache.get(name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yamlover",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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(() => {
@@ -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);
@@ -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>
@@ -25,6 +25,14 @@ export function strToSegs(str: string): Seg[] {
25
25
  return out;
26
26
  }
27
27
 
28
+ /** Canonical key for cross-SCOPE comparison: collapse the colon scope ladder so a
29
+ * project ref (`::yamlover:…`), a document ref (`:yamlover:…`), and a server-echoed
30
+ * `:`-form path all compare equal. The ladder colons are not tokens (PATH_TOKEN), so
31
+ * re-emitting drops them; keys are normalized through one decode→encode pass. */
32
+ export function canonPath(p: string): string {
33
+ return segsToStr(strToSegs(p));
34
+ }
35
+
28
36
  function safeDecode(s: string): string {
29
37
  try {
30
38
  return decodeURIComponent(s);
@@ -79,6 +87,25 @@ export function formatFromUrl(fallback: string): string {
79
87
  return new URLSearchParams(window.location.search).get("format") || fallback;
80
88
  }
81
89
 
90
+ /** The current page from the URL's `?page=` (1-based). 1 when absent, ≤1, or not an integer —
91
+ * used by paged viewers (PDF/DjVu) to restore where the reader left off. */
92
+ export function pageFromUrl(): number {
93
+ const n = Number(new URLSearchParams(window.location.search).get("page"));
94
+ return Number.isInteger(n) && n > 1 ? n : 1;
95
+ }
96
+
97
+ /** Record the current page in `?page=` via replaceState — preserving every other param, and
98
+ * DROPPING the param at page 1 (the implicit default). This does NOT remount the renderer (only
99
+ * path/format/refreshSignal do), so a paged viewer can update it freely while scrolling. */
100
+ export function writePageToUrl(n: number): void {
101
+ const params = new URLSearchParams(window.location.search);
102
+ if (n > 1) params.set("page", String(n));
103
+ else params.delete("page");
104
+ const qs = params.toString();
105
+ const url = window.location.pathname + (qs ? "?" + qs : "");
106
+ if (url !== window.location.pathname + window.location.search) window.history.replaceState({}, "", url);
107
+ }
108
+
82
109
  /** Write the JSON path (canonical colon form, converted to the slash-transport URL)
83
110
  * plus `?format=` into the URL. Path navigation pushes a history entry; switching
84
111
  * format replaces. Any other query params already present are kept (e.g. a renderer's
@@ -86,6 +113,9 @@ export function formatFromUrl(fallback: string): string {
86
113
  export function writeUrl(path: string, format: string, replace = false): void {
87
114
  const params = new URLSearchParams(window.location.search);
88
115
  params.set("format", format);
116
+ // `?page=` is node-specific: a format switch (replace) keeps it, but navigating to another node
117
+ // (push) must not carry the old node's page over — drop it there.
118
+ if (!replace) params.delete("page");
89
119
  const url = `${urlOfPath(path || ":")}?${params.toString()}`;
90
120
  if (url === window.location.pathname + window.location.search) return;
91
121
  if (replace) window.history.replaceState({}, "", url);
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState, type ReactNode } from "react";
2
2
  import { Annotation, TagRef, createTag, fetchAnnotations, fetchNode, saveAnnotation, deleteAnnotation } from "../api";
3
3
  import { TAG_FORMAT, explicitColor, resolveTagColor, tagFields } from "./tag";
4
- import { strToSegs } from "../paths";
4
+ import { canonPath, strToSegs } from "../paths";
5
5
  import { touchesYamlover, useDiffBump } from "../live";
6
6
 
7
7
  /**
@@ -30,12 +30,12 @@ import { touchesYamlover, useDiffBump } from "../live";
30
30
  // fetches the real `/yamlover/tags/colors` nodes once per session (useColorTags) so a project
31
31
  // that re-themes them wins; the paths and hexes here mirror yamlover/tags/.yamlover/body.yamlover.
32
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" },
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
39
  ];
40
40
  export const DEFAULT_TAG = COLOR_TAGS[0];
41
41
  export const DEFAULT_COLOR = DEFAULT_TAG.color!;
@@ -68,12 +68,15 @@ let colorTagsPromise: Promise<TagRef[]> | null = null;
68
68
  export function useColorTags(): TagRef[] {
69
69
  const [tags, setTags] = useState<TagRef[]>(COLOR_TAGS);
70
70
  useEffect(() => {
71
- colorTagsPromise ??= fetchNode(":yamlover:tags:colors", 2)
71
+ colorTagsPromise ??= fetchNode("::yamlover:tags:colors", 2)
72
72
  .then((n) => {
73
73
  const out: TagRef[] = [];
74
74
  for (const [name, child] of tagFields(n.value)) {
75
75
  const color = explicitColor(child);
76
- if (color) out.push({ path: `${n.path}/${encodeURIComponent(name)}`, name, color });
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 });
77
80
  }
78
81
  return out.length ? out : COLOR_TAGS;
79
82
  })
@@ -113,7 +116,7 @@ export function recentTags(): TagRef[] {
113
116
  }
114
117
 
115
118
  function rememberRecent(t: TagRef): void {
116
- if (t.path.startsWith(":yamlover:tags:colors:")) return; // the swatch row already shows these
119
+ if (canonPath(t.path).startsWith(":yamlover:tags:colors:")) return; // the swatch row already shows these
117
120
  const next = [t, ...recentTags().filter((r) => r.path !== t.path)].slice(0, 6);
118
121
  localStorage.setItem(RECENT_KEY, JSON.stringify(next));
119
122
  }
@@ -165,7 +168,7 @@ export function useAnnotations(path: string, bump = 0): Annotation[] {
165
168
  * round-trip lands. */
166
169
  export interface MaterialAnnotations {
167
170
  annotations: Annotation[];
168
- create: (selector: Record<string, unknown> | null, tag: TagRef) => void;
171
+ create: (selector: Record<string, unknown> | null, tag: TagRef, opts?: { silent?: boolean }) => void;
169
172
  remove: (annPath?: string) => void;
170
173
  retag: (ann: Annotation, tag: TagRef) => void;
171
174
  }
@@ -189,12 +192,18 @@ export function useMaterialAnnotations(path: string): MaterialAnnotations {
189
192
  }, [fetched]);
190
193
 
191
194
  const refresh = () => setBump((b) => b + 1);
192
- const create = (selector: Record<string, unknown> | null, tag: TagRef) => {
195
+ const create = (selector: Record<string, unknown> | null, tag: TagRef, opts?: { silent?: boolean }) => {
193
196
  const entry = { path: "(pending)", selector: selector ?? undefined, tag } as Annotation;
194
197
  setOptimistic((o) => [...o, entry]);
195
198
  createAnnotation(path, selector, tag)
196
199
  .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
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
+ });
198
207
  };
199
208
  const remove = (annPath?: string) => {
200
209
  if (!annPath || annPath === "(pending)") return;
@@ -228,7 +237,7 @@ function tagNameOf(path: string): string {
228
237
 
229
238
  /** The floating tag picker — color-tag swatches, recent named-tag badges, a tag-path input, plus
230
239
  * 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).
240
+ * ✓ confirm + optional ⧉ copy + 🗑 discard; `edit` gets close + 🗑 delete (picking a tag re-tags).
232
241
  * `position: fixed`, so x/y are viewport coords. */
233
242
  export function AnnotationMenu({
234
243
  x, y, tag, mode, onPick, onConfirm, onCopy, onTrash, menuRef,
@@ -251,10 +260,14 @@ export function AnnotationMenu({
251
260
  return () => { on = false; };
252
261
  }, []);
253
262
 
263
+ // Compare tag paths on a CANONICAL key — a palette ref is project-scope (`::yamlover:…`)
264
+ // while a selected/edited tag may arrive `:`-form (the API echoes paths in `:`-form, and
265
+ // older localStorage holds `:`-form); raw `===` would miss the match and duplicate the tag.
266
+ const same = (a: string, b: string) => canonPath(a) === canonPath(b);
254
267
  // The badge row must always include THE tag this menu is about (`sel`-framed, like the
255
268
  // 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)
269
+ // even when it has aged out of the recents. A PALETTE tag is shown as a swatch, not a badge.
270
+ const badges = colorTags.some((c) => same(c.path, tag.path)) || recents.some((r) => same(r.path, tag.path))
258
271
  ? recents
259
272
  : [tag, ...recents];
260
273
 
@@ -288,14 +301,14 @@ export function AnnotationMenu({
288
301
  <button
289
302
  key={t.path}
290
303
  type="button"
291
- className={"annotate-swatch" + (t.path === tag.path ? " sel" : "")}
304
+ className={"annotate-swatch" + (same(t.path, tag.path) ? " sel" : "")}
292
305
  style={{ background: resolveTagColor(t) }}
293
306
  title={`${verb} ${t.name}`}
294
307
  onClick={() => onPick(t)}
295
308
  />
296
309
  ))}
297
310
  </div>
298
- {onConfirm && <button type="button" className="annotate-tool ok" title={`${verb} ${tag.name} (keep the mark)`} onClick={onConfirm}>✓</button>}
311
+ {onConfirm && <button type="button" className="annotate-tool ok" title={mode === "edit" ? "close (keep this annotation)" : `${verb} ${tag.name} (keep the mark)`} onClick={onConfirm}>✓</button>}
299
312
  {onCopy && <button type="button" className="annotate-tool" title="copy text to clipboard (don't annotate)" onClick={onCopy}>⧉</button>}
300
313
  <button type="button" className="annotate-tool danger" title={mode === "edit" ? "delete this annotation" : "discard (don't annotate)"} onClick={onTrash}>🗑</button>
301
314
  {badges.length > 0 && (
@@ -303,7 +316,7 @@ export function AnnotationMenu({
303
316
  {badges.map((t) => (
304
317
  // the frame is a WRAPPER: filter applies before clip-path on the same element, so a
305
318
  // 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" : "")}>
319
+ <span key={t.path} className={"tagframe" + (same(t.path, tag.path) ? " sel" : "")}>
307
320
  <button
308
321
  type="button"
309
322
  className="tagtag"
@@ -355,7 +368,7 @@ export function useAnnotationMenu(a: MaterialAnnotations): {
355
368
  const openEdit = (ann: Annotation, screen: { x: number; y: number }) =>
356
369
  setMenu({ mode: "edit", ann, x: screen.x, y: screen.y });
357
370
 
358
- const commitCreate = (t: TagRef, m: MenuState) => { if (m.mode !== "create") return; setTag(t); a.create(m.selector, t); close(); };
371
+ const commitCreate = (t: TagRef, m: MenuState, silent = false) => { if (m.mode !== "create") return; setTag(t); a.create(m.selector, t, { silent }); close(); };
359
372
  const commitRetag = (t: TagRef, m: MenuState) => { if (m.mode !== "edit") return; setTag(t); a.retag(m.ann, t); close(); };
360
373
 
361
374
  // Outside-click: a create commits with the pre-selected tag (default keeps the mark); an edit closes.
@@ -363,7 +376,7 @@ export function useAnnotationMenu(a: MaterialAnnotations): {
363
376
  if (!menu) return;
364
377
  const onDown = (e: MouseEvent) => {
365
378
  if (menuRef.current?.contains(e.target as Node)) return;
366
- if (menu.mode === "create") commitCreate(tag, menu);
379
+ if (menu.mode === "create") commitCreate(tag, menu, true); // implicit → best-effort, no error popup
367
380
  else close();
368
381
  };
369
382
  document.addEventListener("mousedown", onDown);
@@ -387,6 +400,7 @@ export function useAnnotationMenu(a: MaterialAnnotations): {
387
400
  <AnnotationMenu
388
401
  menuRef={menuRef} x={menu.x} y={menu.y} tag={menu.ann.tag ?? DEFAULT_TAG} mode="edit"
389
402
  onPick={(t) => commitRetag(t, menu)}
403
+ onConfirm={close} // ✓ closes the popup without deleting or re-tagging
390
404
  onTrash={() => { a.remove(menu.ann.path); close(); }}
391
405
  />
392
406
  );