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 +34 -8
- package/package.json +1 -1
- package/src/client/App.tsx +56 -4
- package/src/client/paths.ts +30 -0
- package/src/client/renderers/annotate.tsx +35 -21
- package/src/client/renderers/djvu.tsx +237 -63
- package/src/client/renderers/djvuWorker.ts +99 -0
- package/src/client/renderers/explorer.tsx +90 -5
- package/src/client/renderers/paged.ts +137 -0
- package/src/client/renderers/pdf.tsx +150 -10
- package/src/client/styles.css +79 -4
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
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
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
package/src/client/App.tsx
CHANGED
|
@@ -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"
|
|
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={
|
|
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/paths.ts
CHANGED
|
@@ -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: "
|
|
34
|
-
{ path: "
|
|
35
|
-
{ path: "
|
|
36
|
-
{ path: "
|
|
37
|
-
{ path: "
|
|
38
|
-
{ path: "
|
|
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("
|
|
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
|
-
|
|
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) => {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
);
|