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.
@@ -1,97 +1,271 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import { NodeJson, blobUrl } from "../api";
3
- // The DjVu.js library (GPL-v2), vendored as a prebuilt IIFE bundle. Loaded as a
4
- // classic <script> so its `var DjVu = (…)()` lands on the global scope; see
5
- // vendor/README.md for provenance and license.
6
- import djvuScriptUrl from "../vendor/djvu.js?url";
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { Annotation, NodeJson, blobUrl } from "../api";
3
+ import { DEFAULT_COLOR, colorOf, editable, useAnnotationMenu, useMaterialAnnotations } from "./annotate";
4
+ import { usePagedScroll } from "./paged";
5
+ import { DecodedPage, decodeDjvuPage, openDjvu } from "./djvuWorker";
7
6
 
8
- declare global {
9
- interface Window {
10
- DjVu?: any;
11
- }
12
- }
7
+ const num = (v: unknown): number => Number(v) || 0;
8
+ /** A rectangular annotation region on a DjVu page, in the page's NATIVE pixels (like the OCR zones),
9
+ * so it's zoom-independent. `ann` is the saved annotation (→ clickable to edit). */
10
+ interface DjvuRegion { page: number; x: number; y: number; w: number; h: number; title?: string; color?: string; ann?: Annotation }
13
11
 
14
- let loading: Promise<any> | null = null;
15
- /** Inject the vendored bundle once; resolve with the global `DjVu` namespace. */
16
- function loadDjVu(): Promise<any> {
17
- if (window.DjVu) return Promise.resolve(window.DjVu);
18
- if (!loading) {
19
- loading = new Promise((resolve, reject) => {
20
- const s = document.createElement("script");
21
- s.src = djvuScriptUrl;
22
- s.onload = () => (window.DjVu ? resolve(window.DjVu) : reject(new Error("DjVu failed to load")));
23
- s.onerror = () => reject(new Error("could not load djvu.js"));
24
- document.head.appendChild(s);
25
- });
26
- }
27
- return loading;
12
+ /** Paints worker-decoded DjVu pixels into a <canvas> at native size (CSS-scaled to the display
13
+ * width by the wrapper). putImageData is cheap; no PNG encode and no main-thread decompression. */
14
+ function DjvuCanvas({ image }: { image: ImageData }) {
15
+ const ref = useRef<HTMLCanvasElement>(null);
16
+ useLayoutEffect(() => {
17
+ const c = ref.current;
18
+ if (!c) return;
19
+ c.width = image.width;
20
+ c.height = image.height;
21
+ c.getContext("2d")?.putImageData(image, 0, 0);
22
+ }, [image]);
23
+ return <canvas className="djvu-page" ref={ref} />;
28
24
  }
29
25
 
30
26
  /**
31
- * Renders an `image/vnd.djvu` document. The browser has no native DjVu support,
32
- * so we decode it client-side with DjVu.js: fetch the bytes from `/api/blob`,
33
- * build a `DjVu.Document`, and render each page to a PNG object-URL shown as an
34
- * `<img>` (cheaper than holding every page as a full-resolution canvas). A plain
35
- * wheel scrolls the document; ctrl/alt-wheel zooms (scales the page width), matching
36
- * the image/map/pdf viewers (see the UI guide).
27
+ * Renders an `image/vnd.djvu` document. DjVu.js decodes pages in a Web Worker (djvuWorker.ts), and
28
+ * only the pages NEAR the viewport decode (windowed, like the PDF viewer) — so a long scan opens
29
+ * fast and the main thread stays free to annotate while pages decode. Each decoded page is a
30
+ * <canvas> (the worker returns ImageData). An OCR text layer (when present) makes text selectable →
31
+ * a region annotation; pages without OCR get a drag-marquee instead. ctrl/alt-wheel zooms (a CSS
32
+ * resize — no re-decode), with the reading position anchored across zoom; `?page=` tracks the page.
37
33
  */
38
34
  export function DjvuView({ node }: { node: NodeJson }) {
39
35
  const ref = useRef<HTMLDivElement>(null);
40
- const [pages, setPages] = useState<string[]>([]);
41
36
  const [count, setCount] = useState(0);
42
37
  const [zoom, setZoom] = useState(1);
38
+ const [width, setWidth] = useState(0); // pane width (so a page caps at ~1000px like PDF, not full pane)
43
39
  const [error, setError] = useState<string | null>(null);
40
+ const [drag, setDrag] = useState<{ page: number; x0: number; y0: number; x1: number; y1: number } | null>(null);
41
+ const [near, setNear] = useState<Set<number>>(() => new Set()); // pages near the viewport
42
+ const [decoded, setDecoded] = useState<Map<number, DecodedPage>>(() => new Map()); // near pages' pixels+zones
43
+ const sizes = useRef(new Map<number, { w: number; h: number }>()); // remembered native sizes → stable placeholders
44
+
45
+ // Annotations: a `djvu` rect region (page + native-pixel box) from a text selection on an OCR
46
+ // page, or a drag-marquee on a page with no OCR. Same picker/flow as image & PDF.
47
+ const material = useMaterialAnnotations(node.path);
48
+ const { openCreate, openEdit, palette, preview } = useAnnotationMenu(material);
49
+ const shown = preview
50
+ ? [...material.annotations, { path: "(preview)", selector: preview.selector, tag: preview.tag } as Annotation]
51
+ : material.annotations;
52
+ const regions: DjvuRegion[] = shown
53
+ .filter((a) => a.selector?.type === "djvu")
54
+ .map((a) => ({ page: num(a.selector!.page) || 1, x: num(a.selector!.x), y: num(a.selector!.y), w: num(a.selector!.w), h: num(a.selector!.h), title: a.description, color: colorOf(a), ann: editable(a) ? a : undefined }));
55
+ const previewColor = preview?.color ?? DEFAULT_COLOR;
56
+
57
+ // WINDOWED RENDERING: every page keeps a `.djvu-page-wrap` (so the scroll height is right), but
58
+ // only near pages decode + mount a canvas; far pages are estimated-height placeholders.
59
+ const wraps = useRef(new Map<number, HTMLElement>());
60
+ const getPageEls = () => {
61
+ const out: HTMLElement[] = [];
62
+ for (let i = 1; i <= count; i++) { const el = wraps.current.get(i); if (el) out.push(el); }
63
+ return out;
64
+ };
65
+ const paged = usePagedScroll(ref, getPageEls, count > 0 && width > 0);
66
+ const pagedRef = useRef(paged);
67
+ pagedRef.current = paged;
68
+ useLayoutEffect(() => { paged.restoreAnchor(); }, [zoom]); // eslint-disable-line react-hooks/exhaustive-deps
69
+
70
+ // Focus the `.filedjvu` scroller on mount so arrows / space / PageUp-Down scroll the document
71
+ // natively (it's a nested scroller the focused RHS pane can't reach). Skip when chunk-embedded.
72
+ useEffect(() => {
73
+ const el = ref.current;
74
+ if (el && !el.closest(".chunk-body")) el.focus({ preventScroll: true });
75
+ }, []);
76
+
77
+ // Track the pane width so a page fits but is capped (≤1000px) like the PDF viewer.
78
+ useLayoutEffect(() => {
79
+ const el = ref.current;
80
+ if (!el) return;
81
+ const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
82
+ ro.observe(el);
83
+ return () => ro.disconnect();
84
+ }, []);
85
+ const dispW = Math.min(width, 1000) * zoom; // each page's displayed width in px
44
86
 
45
- // ctrl/alt-wheel zooms; a plain wheel is left alone so the pane keeps scrolling.
87
+ // ctrl/alt-wheel zooms; a plain wheel scrolls. Zoom is a CSS resize (no re-decode), applied live;
88
+ // the reading position is anchored at the burst start and restored after each step.
46
89
  useEffect(() => {
47
90
  const el = ref.current;
48
91
  if (!el) return;
92
+ let bursting = false;
93
+ let end = 0;
49
94
  const onWheel = (e: WheelEvent) => {
50
95
  if (!(e.ctrlKey || e.altKey || e.metaKey)) return;
51
96
  e.preventDefault();
97
+ if (!bursting) { pagedRef.current.captureAnchor(); bursting = true; }
98
+ clearTimeout(end);
99
+ end = window.setTimeout(() => (bursting = false), 250);
52
100
  setZoom((z) => Math.min(5, Math.max(0.4, z * (e.deltaY < 0 ? 1.1 : 1 / 1.1))));
53
101
  };
54
102
  el.addEventListener("wheel", onWheel, { passive: false });
55
- return () => el.removeEventListener("wheel", onWheel);
103
+ return () => { el.removeEventListener("wheel", onWheel); clearTimeout(end); };
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
56
105
  }, []);
57
106
 
107
+ // Open the document in the worker (off the main thread) → page count. Decoding happens lazily,
108
+ // per near page, in the effect below.
58
109
  useEffect(() => {
59
110
  let cancelled = false;
60
- const created: string[] = [];
61
- setPages([]);
62
- setCount(0);
63
- setError(null);
111
+ setCount(0); setError(null); setNear(new Set()); setDecoded(new Map());
112
+ wraps.current.clear(); sizes.current.clear();
64
113
  (async () => {
65
- const DjVu = await loadDjVu();
66
114
  const buf = await fetch(blobUrl(node.path)).then((r) => r.arrayBuffer());
67
- const doc = new DjVu.Document(buf);
68
- const total = doc.getPagesQuantity();
69
- if (!cancelled) setCount(total);
70
- // Decode page-by-page and reveal each as it's ready, so a long scanned
71
- // document shows its first page quickly instead of blocking on the whole.
72
- for (let i = 1; i <= total && !cancelled; i++) {
73
- const page = await doc.getPage(i);
74
- const { url } = await page.createPngObjectUrl();
75
- created.push(url);
76
- if (!cancelled) setPages((prev) => [...prev, url]);
77
- }
115
+ const n = await openDjvu(buf, node.path);
116
+ if (!cancelled) setCount(n);
78
117
  })().catch((e) => !cancelled && setError(String((e as Error).message || e)));
79
- return () => {
80
- cancelled = true;
81
- created.forEach(URL.revokeObjectURL);
82
- };
118
+ return () => { cancelled = true; };
83
119
  }, [node.path]);
84
120
 
121
+ // Windowed observer (mirror the PDF viewer): mark pages near the viewport.
122
+ useEffect(() => {
123
+ if (!count) return;
124
+ const obs = new IntersectionObserver(
125
+ (entries) => {
126
+ setNear((prev) => {
127
+ const next = new Set(prev);
128
+ for (const e of entries) {
129
+ const pn = Number((e.target as HTMLElement).dataset.page);
130
+ if (e.isIntersecting) next.add(pn);
131
+ else next.delete(pn);
132
+ }
133
+ return next.size === prev.size && [...next].every((p) => prev.has(p)) ? prev : next;
134
+ });
135
+ },
136
+ { root: ref.current, rootMargin: "2000px 0px" },
137
+ );
138
+ for (const el of wraps.current.values()) obs.observe(el);
139
+ return () => obs.disconnect();
140
+ // eslint-disable-next-line react-hooks/exhaustive-deps
141
+ }, [count, width > 0]);
142
+
143
+ // Decode near pages (lazy, in the worker); drop decoded pixels that left the window to bound
144
+ // memory (the worker keeps an LRU cache, so re-entry is fast).
145
+ useEffect(() => {
146
+ let alive = true;
147
+ near.forEach((n) => {
148
+ if (!decoded.has(n)) {
149
+ decodeDjvuPage(n)
150
+ .then((dp) => {
151
+ sizes.current.set(n, { w: dp.w, h: dp.h });
152
+ if (alive) setDecoded((m) => new Map(m).set(n, dp));
153
+ })
154
+ .catch(() => {});
155
+ }
156
+ });
157
+ setDecoded((m) => {
158
+ let changed = false;
159
+ const x = new Map(m);
160
+ for (const k of x.keys()) if (!near.has(k)) { x.delete(k); changed = true; }
161
+ return changed ? x : m;
162
+ });
163
+ return () => { alive = false; };
164
+ // eslint-disable-next-line react-hooks/exhaustive-deps
165
+ }, [near]);
166
+
167
+ // A finished text selection on an OCR page → a `djvu` region (its bounding box, in native px).
168
+ const onMouseUp = () => {
169
+ const sel = window.getSelection();
170
+ if (!sel || sel.isCollapsed || !sel.anchorNode) return;
171
+ const hostEl = sel.anchorNode.nodeType === 1 ? (sel.anchorNode as Element) : sel.anchorNode.parentElement;
172
+ const wrap = hostEl?.closest(".djvu-page-wrap") as HTMLElement | null;
173
+ if (!wrap || !ref.current?.contains(wrap)) return;
174
+ const pn = Number(wrap.dataset.page);
175
+ const wr = wrap.getBoundingClientRect();
176
+ const s = wr.width / (decoded.get(pn)?.w || 1); // display px per native px
177
+ if (!s) return;
178
+ const sr = sel.getRangeAt(0).getBoundingClientRect();
179
+ if (sr.width < 2 || sr.height < 2) return;
180
+ openCreate(
181
+ { type: "djvu", page: pn, x: Math.round((sr.left - wr.left) / s), y: Math.round((sr.top - wr.top) / s), w: Math.round(sr.width / s), h: Math.round(sr.height / s) },
182
+ { x: sr.left, y: sr.bottom + 6 },
183
+ );
184
+ };
185
+
186
+ // Marquee drag on a page with NO OCR: draw a box, convert to native px.
187
+ const scaleOf = (pn: number, wrap: HTMLElement) => wrap.getBoundingClientRect().width / (decoded.get(pn)?.w || 1);
188
+ const dragStart = (pn: number, e: React.MouseEvent) => {
189
+ const wr = e.currentTarget.getBoundingClientRect();
190
+ const s = scaleOf(pn, e.currentTarget as HTMLElement);
191
+ setDrag({ page: pn, x0: (e.clientX - wr.left) / s, y0: (e.clientY - wr.top) / s, x1: (e.clientX - wr.left) / s, y1: (e.clientY - wr.top) / s });
192
+ };
193
+ const dragMove = (pn: number, e: React.MouseEvent) => {
194
+ const wr = e.currentTarget.getBoundingClientRect();
195
+ const s = scaleOf(pn, e.currentTarget as HTMLElement);
196
+ setDrag((d) => (d ? { ...d, x1: (e.clientX - wr.left) / s, y1: (e.clientY - wr.top) / s } : d));
197
+ };
198
+ const dragEnd = (pn: number, e: React.MouseEvent) => {
199
+ const d = drag;
200
+ setDrag(null);
201
+ if (!d || d.page !== pn) return;
202
+ const s = scaleOf(pn, e.currentTarget as HTMLElement);
203
+ const left = Math.min(d.x0, d.x1), top = Math.min(d.y0, d.y1), w = Math.abs(d.x1 - d.x0), h = Math.abs(d.y1 - d.y0);
204
+ if (w * s < 3 || h * s < 3) return; // a click, not a drag
205
+ const wr = e.currentTarget.getBoundingClientRect();
206
+ openCreate(
207
+ { type: "djvu", page: pn, x: Math.round(left), y: Math.round(top), w: Math.round(w), h: Math.round(h) },
208
+ { x: wr.left + left * s, y: wr.top + (top + h) * s + 6 },
209
+ );
210
+ };
211
+
85
212
  if (error) return <div className="error">djvu: {error}</div>;
86
213
  return (
87
- <div className="filedjvu yo-zoomable" ref={ref}>
88
- {count > 0 && pages.length < count && (
89
- <div className="loading">decoding djvu… page {pages.length + 1} of {count}</div>
90
- )}
91
- {count === 0 && pages.length === 0 && <div className="loading">decoding djvu…</div>}
92
- {pages.map((url, i) => (
93
- <img key={i} className="djvu-page" style={{ width: `${zoom * 100}%` }} src={url} alt={`page ${i + 1}`} />
94
- ))}
95
- </div>
214
+ <>
215
+ <div className="filedjvu yo-zoomable" ref={ref} tabIndex={0} onMouseUp={onMouseUp}>
216
+ {count === 0 && <div className="loading">opening djvu…</div>}
217
+ {width > 0 &&
218
+ Array.from({ length: count }, (_, i) => {
219
+ const pn = i + 1;
220
+ const dp = near.has(pn) ? decoded.get(pn) : undefined;
221
+ const size = sizes.current.get(pn);
222
+ const estHeight = dispW * (size ? size.h / size.w : Math.SQRT2);
223
+ const s = dp ? dispW / dp.w : 0;
224
+ return (
225
+ <div
226
+ key={i}
227
+ className="djvu-page-wrap"
228
+ data-page={pn}
229
+ ref={(el) => { if (el) wraps.current.set(pn, el); else wraps.current.delete(pn); }}
230
+ style={{ width: dispW, height: dp ? undefined : estHeight }}
231
+ >
232
+ {dp ? (
233
+ <>
234
+ <DjvuCanvas image={dp.image} />
235
+ {dp.zones.length > 0 ? (
236
+ <div className="djvu-textlayer">
237
+ {dp.zones.map((z, j) => (
238
+ <span key={j} style={{ left: z.x * s, top: z.y * s, width: z.width * s, height: z.height * s, fontSize: z.height * s }}>{z.text}</span>
239
+ ))}
240
+ </div>
241
+ ) : (
242
+ <div className="djvu-marquee" onMouseDown={(e) => dragStart(pn, e)} onMouseMove={(e) => dragMove(pn, e)} onMouseUp={(e) => dragEnd(pn, e)}>
243
+ {drag?.page === pn && (
244
+ <div className="djvu-region" style={{ left: Math.min(drag.x0, drag.x1) * s, top: Math.min(drag.y0, drag.y1) * s, width: Math.abs(drag.x1 - drag.x0) * s, height: Math.abs(drag.y1 - drag.y0) * s, borderColor: previewColor, background: previewColor + "2e" }} />
245
+ )}
246
+ </div>
247
+ )}
248
+ {regions.filter((r) => r.page === pn).map((r, j) => {
249
+ const c = r.color || DEFAULT_COLOR;
250
+ return (
251
+ <div
252
+ key={j}
253
+ className={"djvu-region" + (r.ann ? " editable" : "")}
254
+ title={r.ann ? r.title || "click to recolor or delete" : r.title}
255
+ onClick={r.ann ? (e) => { e.stopPropagation(); openEdit(r.ann!, { x: e.clientX, y: e.clientY }); } : undefined}
256
+ style={{ left: r.x * s, top: r.y * s, width: r.w * s, height: r.h * s, borderColor: c, background: c + "2e" }}
257
+ />
258
+ );
259
+ })}
260
+ </>
261
+ ) : (
262
+ <div className="djvu-placeholder">{near.has(pn) ? "decoding…" : ""}</div>
263
+ )}
264
+ </div>
265
+ );
266
+ })}
267
+ </div>
268
+ {palette}
269
+ </>
96
270
  );
97
271
  }
@@ -0,0 +1,99 @@
1
+ // Off-main-thread DjVu decoding. The vendored DjVu.js (vendor/djvu.js) doubles as its own Web
2
+ // Worker script (it detects worker context internally); `new DjVu.Worker()` spins that worker from
3
+ // an inline blob. We drive ONE worker + ONE open document at a time (one viewer is open at a time),
4
+ // decoding pages lazily on demand so the main thread never blocks on JB2/IW44 decompression — which
5
+ // is what froze annotation while a big scan decoded. The library bundle itself is injected once as a
6
+ // classic <script> so the `DjVu.Worker` class is available on the main thread.
7
+ import djvuScriptUrl from "../vendor/djvu.js?url";
8
+
9
+ declare global {
10
+ interface Window { DjVu?: any }
11
+ }
12
+
13
+ /** One OCR text zone — absolute pixels in the page's native space (top-left origin). */
14
+ export interface Zone { x: number; y: number; width: number; height: number; text: string }
15
+ /** A decoded page: pixels (paint to a canvas), OCR zones (may be empty), native pixel size. */
16
+ export interface DecodedPage { image: ImageData; zones: Zone[]; w: number; h: number }
17
+
18
+ let libLoading: Promise<any> | null = null;
19
+ /** Inject the vendored bundle once; resolve with the global `DjVu` namespace (for `DjVu.Worker`). */
20
+ function loadDjVu(): Promise<any> {
21
+ if (window.DjVu) return Promise.resolve(window.DjVu);
22
+ libLoading ??= new Promise((resolve, reject) => {
23
+ const s = document.createElement("script");
24
+ s.src = djvuScriptUrl;
25
+ s.onload = () => (window.DjVu ? resolve(window.DjVu) : reject(new Error("DjVu failed to load")));
26
+ s.onerror = () => reject(new Error("could not load djvu.js"));
27
+ document.head.appendChild(s);
28
+ });
29
+ return libLoading;
30
+ }
31
+
32
+ let worker: any = null; // the singleton DjVu.Worker (kept alive across viewers, like pdf.js's worker)
33
+ let curKey: string | null = null; // node.path of the currently open document
34
+ let opening: Promise<number> | null = null; // resolves to the page count of the open document
35
+ const cache = new Map<number, DecodedPage>(); // LRU (by re-insertion) of decoded pages — bounds memory
36
+ const inflight = new Map<number, Promise<DecodedPage>>();
37
+ const CACHE_CAP = 6; // few full pages kept; scans are huge (a native page can be ~tens of MB)
38
+ const MAX_RASTER_W = 1500; // cap stored pixels: a scan's native width is overkill for a ~1000px display
39
+ // (and at full native res the in-memory ImageData crashes the tab)
40
+
41
+ /** Open a DjVu document in the worker (re-creating only when the file changes) and resolve its page
42
+ * count. The buffer is transferred to the worker. */
43
+ export async function openDjvu(buf: ArrayBuffer, key: string): Promise<number> {
44
+ const DjVu = await loadDjVu();
45
+ worker ??= new DjVu.Worker(); // inline-blob worker; no separate script URL needed
46
+ if (key !== curKey) {
47
+ curKey = key;
48
+ cache.clear();
49
+ inflight.clear();
50
+ opening = (async () => {
51
+ await worker.createDocument(buf);
52
+ return Number(await worker.doc.getPagesQuantity().run()) || 0;
53
+ })();
54
+ }
55
+ return opening!;
56
+ }
57
+
58
+ /** Resize a (large) ImageData to `targetW` wide, preserving aspect → a smaller ImageData. The
59
+ * browser does the resampling in `createImageBitmap` (off the main thread); the full-size source is
60
+ * then released. Keeps memory bounded without changing coordinates (native size is tracked apart). */
61
+ async function downscale(full: ImageData, targetW: number): Promise<ImageData> {
62
+ const targetH = Math.max(1, Math.round((full.height * targetW) / full.width));
63
+ const bmp = await createImageBitmap(full, { resizeWidth: targetW, resizeHeight: targetH, resizeQuality: "medium" });
64
+ const cnv = document.createElement("canvas");
65
+ cnv.width = targetW;
66
+ cnv.height = targetH;
67
+ const cx = cnv.getContext("2d")!;
68
+ cx.drawImage(bmp, 0, 0);
69
+ bmp.close();
70
+ return cx.getImageData(0, 0, targetW, targetH);
71
+ }
72
+
73
+ /** Decode one page (1-based) off the main thread: image + OCR zones + native size, in a single
74
+ * batched worker round-trip. Memoized (LRU) so re-scroll/zoom doesn't re-decode. */
75
+ export function decodeDjvuPage(n: number): Promise<DecodedPage> {
76
+ const hit = cache.get(n);
77
+ if (hit) { cache.delete(n); cache.set(n, hit); return Promise.resolve(hit); } // LRU touch
78
+ const pending = inflight.get(n);
79
+ if (pending) return pending;
80
+ const p = (async () => {
81
+ const [full, zones, w, h] = await worker.run(
82
+ worker.doc.getPage(n).getImageData(),
83
+ worker.doc.getPage(n).getNormalizedTextZones(),
84
+ worker.doc.getPage(n).getWidth(),
85
+ worker.doc.getPage(n).getHeight(),
86
+ );
87
+ const nativeW = Number(w) || full.width, nativeH = Number(h) || full.height;
88
+ // Downscale to a display-adequate raster (the native scan is huge); coordinates stay in NATIVE
89
+ // px (zones + region selectors), so the smaller canvas is purely a sharpness/memory trade.
90
+ const image = nativeW > MAX_RASTER_W ? await downscale(full, MAX_RASTER_W) : full;
91
+ const dp: DecodedPage = { image, zones: Array.isArray(zones) ? zones : [], w: nativeW, h: nativeH };
92
+ inflight.delete(n);
93
+ cache.set(n, dp);
94
+ while (cache.size > CACHE_CAP) cache.delete(cache.keys().next().value as number); // evict oldest
95
+ return dp;
96
+ })();
97
+ inflight.set(n, p);
98
+ return p;
99
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { NodeJson, fetchTagged } from "../api";
3
3
  import { asLink, Link } from "../render";
4
4
  import { typeIcon } from "../icons";
@@ -117,12 +117,46 @@ function isDocFormat(f?: string | null): boolean {
117
117
  return !!f && (f.includes("/") || f.startsWith("x-yamlover-"));
118
118
  }
119
119
 
120
- function Item({ it, onNavigate }: { it: ExplorerItem; onNavigate: (path: string) => void }) {
120
+ /** The grid item to move to from `cur` for an arrow key. Left/Right step in reading
121
+ * order; Up/Down pick the nearest item in the adjacent row, preferring the same
122
+ * column — measured from live geometry, so it's correct for the wrapping flex grid
123
+ * (variable columns, a short last row) without knowing the column count. */
124
+ function arrowTarget(els: (HTMLElement | null)[], cur: number, key: string, count: number): number {
125
+ if (key === "ArrowRight") return Math.min(cur + 1, count - 1);
126
+ if (key === "ArrowLeft") return Math.max(cur - 1, 0);
127
+ if (key === "Home") return 0;
128
+ if (key === "End") return count - 1;
129
+ const a = els[cur];
130
+ if (!a) return cur;
131
+ const r = a.getBoundingClientRect();
132
+ const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
133
+ const down = key === "ArrowDown";
134
+ let best = cur, bestScore = Infinity;
135
+ for (let i = 0; i < count; i++) {
136
+ const el = els[i];
137
+ if (!el || i === cur) continue;
138
+ const ri = el.getBoundingClientRect();
139
+ const ix = ri.left + ri.width / 2, iy = ri.top + ri.height / 2;
140
+ if (down ? iy <= cy + 1 : iy >= cy - 1) continue; // must lie in the arrow's direction
141
+ const score = Math.abs(ix - cx) * 2 + Math.abs(iy - cy); // prefer same column, nearest row
142
+ if (score < bestScore) { bestScore = score; best = i; }
143
+ }
144
+ return best;
145
+ }
146
+
147
+ function Item({ it, active, setRef, onFocus, onNavigate }: {
148
+ it: ExplorerItem;
149
+ active: boolean;
150
+ setRef: (el: HTMLElement | null) => void;
151
+ onFocus: () => void;
152
+ onNavigate: (path: string) => void;
153
+ }) {
121
154
  const link = it.link;
155
+ const tabIndex = active ? 0 : -1; // roving tabindex: only the selected item is in the tab order
122
156
  if (!link) {
123
157
  // not a marker (unexpected at depth 1) — an inert label, no navigation
124
158
  return (
125
- <span className="dirview-item">
159
+ <span className="dirview-item" ref={setRef} tabIndex={tabIndex} onFocus={onFocus}>
126
160
  <span className="dirview-icon t-bin">•</span>
127
161
  <span className="dirview-label">{it.key}: {scalarText(it.raw)}</span>
128
162
  </span>
@@ -147,6 +181,9 @@ function Item({ it, onNavigate }: { it: ExplorerItem; onNavigate: (path: string)
147
181
  className={"dirview-item" + (it.up ? " dirview-up" : "")}
148
182
  href={link.path}
149
183
  title={displayPath(link.path)}
184
+ ref={setRef}
185
+ tabIndex={tabIndex}
186
+ onFocus={onFocus}
150
187
  onClick={(e) => {
151
188
  e.preventDefault();
152
189
  onNavigate(link.path);
@@ -189,6 +226,43 @@ export function ExplorerView({ node, onNavigate }: { node: NodeJson; onNavigate:
189
226
  }
190
227
  const items = [...ups, ...members];
191
228
 
229
+ // Roving keyboard focus over the grid: plain arrows walk the icons, Enter opens the
230
+ // selected one. The item elements are tracked by index for the geometry-based row moves.
231
+ const gridRef = useRef<HTMLDivElement>(null);
232
+ const itemEls = useRef<(HTMLElement | null)[]>([]);
233
+ itemEls.current.length = items.length; // drop stale refs when the member list shrinks
234
+ const [active, setActive] = useState(0);
235
+ useEffect(() => {
236
+ if (active > items.length - 1) setActive(Math.max(0, items.length - 1));
237
+ }, [items.length, active]);
238
+
239
+ // On navigating to a new directory, reset the selection and re-arm autofocus (this
240
+ // component is reused across nodes, not remounted, so the flag must follow `node.path`).
241
+ const wantFocus = useRef(true);
242
+ useEffect(() => { setActive(0); wantFocus.current = true; }, [node.path]);
243
+ // Focus the first item once the grid has members (a tag's load async), so arrows work right
244
+ // after navigating here (the RHS pane handed us focus) — once per node, and never when
245
+ // embedded as a chapter chunk (several grids would fight over focus).
246
+ useEffect(() => {
247
+ if (!wantFocus.current || !items.length) return;
248
+ wantFocus.current = false;
249
+ if (gridRef.current?.closest(".chunk-body")) return; // embedded — don't steal focus
250
+ itemEls.current[0]?.focus({ preventScroll: true });
251
+ }, [items.length, node.path]);
252
+
253
+ const onKeyDown = (e: React.KeyboardEvent) => {
254
+ if (e.ctrlKey || e.altKey || e.metaKey || !items.length) return; // Ctrl/Alt+arrows = TOC nav (App)
255
+ if (e.key === "Enter") {
256
+ const link = items[active]?.link;
257
+ if (link) { e.preventDefault(); onNavigate(link.path); }
258
+ return;
259
+ }
260
+ if (!/^(Arrow(Up|Down|Left|Right)|Home|End)$/.test(e.key)) return;
261
+ e.preventDefault(); // don't also scroll the pane
262
+ const next = arrowTarget(itemEls.current, active, e.key, items.length);
263
+ if (next !== active) { setActive(next); itemEls.current[next]?.focus(); }
264
+ };
265
+
192
266
  // a tag page's description is its BODY (the header bar already names the node)
193
267
  const desc = (isTag ? tagBody(node.value) : null) ?? node.description;
194
268
  return (
@@ -198,9 +272,20 @@ export function ExplorerView({ node, onNavigate }: { node: NodeJson; onNavigate:
198
272
  <p className="tagdesc">{desc}</p>
199
273
  </div>
200
274
  )}
201
- <div className={"dirview" + (explorerViewMode() === "large" ? " dirview-lg" : "")}>
275
+ <div
276
+ ref={gridRef}
277
+ className={"dirview" + (explorerViewMode() === "large" ? " dirview-lg" : "")}
278
+ onKeyDown={onKeyDown}
279
+ >
202
280
  {items.map((it, i) => (
203
- <Item key={`${it.up ? "^" : ""}${it.link?.path ?? it.key}#${i}`} it={it} onNavigate={onNavigate} />
281
+ <Item
282
+ key={`${it.up ? "^" : ""}${it.link?.path ?? it.key}#${i}`}
283
+ it={it}
284
+ active={i === active}
285
+ setRef={(el) => { itemEls.current[i] = el; }}
286
+ onFocus={() => setActive(i)}
287
+ onNavigate={onNavigate}
288
+ />
204
289
  ))}
205
290
  {items.length === 0 && <span className="dirview-empty">empty</span>}
206
291
  </div>