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
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>
|
|
@@ -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
|
-
|
|
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
|
|
275
|
+
<div
|
|
276
|
+
ref={gridRef}
|
|
277
|
+
className={"dirview" + (explorerViewMode() === "large" ? " dirview-lg" : "")}
|
|
278
|
+
onKeyDown={onKeyDown}
|
|
279
|
+
>
|
|
202
280
|
{items.map((it, i) => (
|
|
203
|
-
<Item
|
|
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)}
|
package/src/client/styles.css
CHANGED
|
@@ -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;
|