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/dist/server.js +370 -109
- package/package.json +1 -1
- package/src/client/App.tsx +59 -7
- package/src/client/api.ts +45 -18
- package/src/client/render.tsx +16 -3
- package/src/client/renderers/annotate.tsx +173 -53
- package/src/client/renderers/asciidoc.tsx +2 -1
- package/src/client/renderers/chapter.tsx +5 -1
- package/src/client/renderers/csv.tsx +2 -1
- package/src/client/renderers/djvu.tsx +8 -1
- package/src/client/renderers/explorer.tsx +90 -5
- package/src/client/renderers/imagemap.tsx +19 -6
- package/src/client/renderers/latex.tsx +2 -1
- package/src/client/renderers/marklower.tsx +2 -1
- package/src/client/renderers/pdf.tsx +9 -1
- package/src/client/renderers/plantuml.tsx +2 -1
- package/src/client/renderers/registry.tsx +91 -69
- package/src/client/renderers/text.tsx +2 -1
- package/src/client/styles.css +40 -3
- package/src/server/embed.ts +187 -0
- package/src/server/engine-api.ts +243 -122
- package/src/server/node-kind.ts +15 -2
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(() => {
|
|
@@ -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
|
|
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
|
|
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
|
|
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"
|
|
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/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
|
|
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
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
/**
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
/**
|
|
182
|
-
|
|
183
|
-
|
|
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
|
}
|
package/src/client/render.tsx
CHANGED
|
@@ -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 `!!
|
|
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
|
|
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 (`!!
|
|
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,
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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
|
|
144
|
-
*
|
|
145
|
-
export function createAnnotation(
|
|
146
|
-
|
|
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: (
|
|
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()); //
|
|
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
|
|
186
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 = (
|
|
209
|
-
if (!
|
|
210
|
-
|
|
211
|
-
|
|
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(
|
|
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
|
|
217
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
<
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
524
|
+
onTrash={() => { a.remove(menu.ann); close(); }}
|
|
405
525
|
/>
|
|
406
526
|
);
|
|
407
527
|
}
|