yamlover 0.3.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yamlover",
3
- "version": "0.3.2",
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>
@@ -67,6 +67,13 @@ export function DjvuView({ node }: { node: NodeJson }) {
67
67
  pagedRef.current = paged;
68
68
  useLayoutEffect(() => { paged.restoreAnchor(); }, [zoom]); // eslint-disable-line react-hooks/exhaustive-deps
69
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
+
70
77
  // Track the pane width so a page fits but is capped (≤1000px) like the PDF viewer.
71
78
  useLayoutEffect(() => {
72
79
  const el = ref.current;
@@ -205,7 +212,7 @@ export function DjvuView({ node }: { node: NodeJson }) {
205
212
  if (error) return <div className="error">djvu: {error}</div>;
206
213
  return (
207
214
  <>
208
- <div className="filedjvu yo-zoomable" ref={ref} onMouseUp={onMouseUp}>
215
+ <div className="filedjvu yo-zoomable" ref={ref} tabIndex={0} onMouseUp={onMouseUp}>
209
216
  {count === 0 && <div className="loading">opening djvu…</div>}
210
217
  {width > 0 &&
211
218
  Array.from({ length: count }, (_, i) => {
@@ -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>
@@ -96,6 +96,14 @@ export function PdfView({ node }: { node: NodeJson }) {
96
96
  // After a zoom COMMIT reflows the pages, restore the captured reading position.
97
97
  useLayoutEffect(() => { paged.restoreAnchor(); }, [zoom]); // eslint-disable-line react-hooks/exhaustive-deps
98
98
 
99
+ // The `.filepdf` scroller is its OWN scroll container nested in the (focused) RHS pane, so the
100
+ // pane's focus can't drive it — focus it on mount so arrows / space / PageUp-Down / Home-End
101
+ // scroll the document natively. Skip when embedded as a chapter chunk (several would fight + jump).
102
+ useEffect(() => {
103
+ const el = ref.current;
104
+ if (el && !el.closest(".chunk-body")) el.focus({ preventScroll: true });
105
+ }, []);
106
+
99
107
  // Track the pane width so pages re-flow on resize.
100
108
  useLayoutEffect(() => {
101
109
  const el = ref.current;
@@ -226,7 +234,7 @@ export function PdfView({ node }: { node: NodeJson }) {
226
234
 
227
235
  return (
228
236
  <>
229
- <div className="filepdf yo-zoomable" ref={ref} onMouseUp={onMouseUp}>
237
+ <div className="filepdf yo-zoomable" ref={ref} tabIndex={0} onMouseUp={onMouseUp}>
230
238
  <Document
231
239
  file={blobUrl(node.path)}
232
240
  onLoadSuccess={({ numPages }) => setPages(numPages)}
@@ -148,6 +148,10 @@ body {
148
148
  flex: 1 1 auto;
149
149
  padding: 14px 18px;
150
150
  }
151
+ /* programmatically focused on TOC click (so the keyboard drives the viewer) — no focus ring */
152
+ .right:focus {
153
+ outline: none;
154
+ }
151
155
  .splitter {
152
156
  flex: 0 0 5px;
153
157
  cursor: col-resize;
@@ -519,6 +523,11 @@ a.chunk-index:hover {
519
523
  height: calc(100vh - 160px);
520
524
  min-height: 360px;
521
525
  }
526
+ /* focused on mount so the keyboard scrolls the document — no focus ring on the scroller */
527
+ .filepdf:focus,
528
+ .filedjvu:focus {
529
+ outline: none;
530
+ }
522
531
  .fileimage {
523
532
  /* keep the image's own aspect ratio — never stretch (007) */
524
533
  max-width: 100%;
@@ -1080,6 +1089,12 @@ mark.yo-annotation {
1080
1089
  .dirview-item:hover {
1081
1090
  background: var(--panel);
1082
1091
  }
1092
+ /* keyboard selection (roving focus — arrows walk, Enter opens) */
1093
+ .dirview-item:focus {
1094
+ outline: none;
1095
+ background: var(--panel);
1096
+ box-shadow: 0 0 0 2px var(--accent) inset;
1097
+ }
1083
1098
  .dirview-icon {
1084
1099
  flex: none;
1085
1100
  width: 22px;