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.
@@ -1,5 +1,6 @@
1
1
  import Asciidoctor from "@asciidoctor/core";
2
2
  import { NodeJson } from "../api";
3
+ import { scalarValue } from "../render";
3
4
  import { Chunk } from "./registry";
4
5
  import { anchorizeHeadings, useHashScroll } from "./headings";
5
6
  import { Markup } from "./markup";
@@ -25,7 +26,7 @@ export function AsciidocView({ node }: { node: NodeJson }) {
25
26
  <div className="text">
26
27
  {node.title && <h1 className="chapter-title">{node.title}</h1>}
27
28
  {node.description && <p className="chapter-subtitle">{node.description}</p>}
28
- <Markup html={adoc(node.value)} />
29
+ <Markup html={adoc(scalarValue(node.value))} />
29
30
  </div>
30
31
  );
31
32
  }
@@ -100,9 +100,13 @@ function ChunkBlock({
100
100
  path: link?.path ?? "",
101
101
  type: link?.type ?? "string",
102
102
  format: link?.format ?? null,
103
+ // the renderer-dispatch facets (TYPES.md §9): a link carries them; a bare inline chunk is a string
104
+ valueType: link?.valueType ?? "string",
105
+ hasKeyed: link?.hasKeyed ?? false,
106
+ hasOrdinal: link?.hasOrdinal ?? false,
103
107
  documentPath, // carried so a marklower chunk's `/…` link resolves to its document
104
108
  };
105
- const renderer = rendererFor(chunk.type, chunk.format);
109
+ const renderer = rendererFor(chunk);
106
110
  const body = renderer?.renderChunk
107
111
  ? renderer.renderChunk(chunk, onNavigate)
108
112
  : <p className="chapter-prose">{String(chunk.value ?? "")}</p>;
@@ -1,4 +1,5 @@
1
1
  import { NodeJson } from "../api";
2
+ import { scalarValue } from "../render";
2
3
  import { Chunk } from "./registry";
3
4
 
4
5
  /**
@@ -163,7 +164,7 @@ export function CsvView({ node }: { node: NodeJson }) {
163
164
  // the single source of truth — this view holds no parsing state of its own.
164
165
  const p = params();
165
166
  const header = headerOn(p);
166
- const text = String(node.value ?? "");
167
+ const text = String(scalarValue(node.value) ?? "");
167
168
  const sep = decodeSep(p.get("sep")) ?? autoSep(text, node.format ?? null);
168
169
  const rows = parseDelimited(text, sep);
169
170
 
@@ -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>
@@ -13,6 +13,19 @@ export interface ImageRegion { x: number; y: number; w: number; h: number; title
13
13
 
14
14
  const num = (v: unknown): number => Number(v) || 0;
15
15
 
16
+ /** A PNG data-URL crop of the natural-pixel region (x,y,w,h) of `img`, for an image-like
17
+ * fragment's embedded preview; undefined if the region is empty or the canvas reads back tainted
18
+ * (cross-origin — image blobs are same-origin, so this is just a guard). */
19
+ function cropPng(img: HTMLImageElement | null, x: number, y: number, w: number, h: number): string | undefined {
20
+ if (!img || w <= 0 || h <= 0) return undefined;
21
+ const cv = document.createElement("canvas");
22
+ cv.width = w; cv.height = h;
23
+ const ctx = cv.getContext("2d");
24
+ if (!ctx) return undefined;
25
+ ctx.drawImage(img, x, y, w, h, 0, 0, w, h);
26
+ try { return cv.toDataURL("image/png"); } catch { return undefined; }
27
+ }
28
+
16
29
  /** The `rect`-type annotations, as pixel regions to overlay on the image. */
17
30
  function imageRegions(anns: Annotation[]): ImageRegion[] {
18
31
  return anns
@@ -33,13 +46,14 @@ export function PanZoomImage({
33
46
  src: string;
34
47
  className: string;
35
48
  regions?: ImageRegion[];
36
- onSelectRegion?: (selector: Record<string, unknown>, screen: { x: number; y: number }) => void;
49
+ onSelectRegion?: (selector: Record<string, unknown>, screen: { x: number; y: number }, imageBase64?: string) => void;
37
50
  onRegionClick?: (ann: Annotation, screen: { x: number; y: number }) => void;
38
51
  selectColor?: () => string;
39
52
  }) {
40
53
  const ref = useRef<HTMLDivElement>(null);
41
54
  const mapRef = useRef<L.Map | null>(null);
42
55
  const layerRef = useRef<L.LayerGroup | null>(null);
56
+ const imgElRef = useRef<HTMLImageElement | null>(null);
43
57
  const sizeRef = useRef({ w: 1, h: 1 });
44
58
  const onSelectRef = useRef(onSelectRegion);
45
59
  const onRegionClickRef = useRef(onRegionClick);
@@ -62,6 +76,7 @@ export function PanZoomImage({
62
76
  const w = img.naturalWidth || 1;
63
77
  const h = img.naturalHeight || 1;
64
78
  sizeRef.current = { w, h };
79
+ imgElRef.current = img; // kept for cropping a selected region (same-origin → un-tainted canvas)
65
80
  // CRS.Simple: coordinates are raw pixels (y, x); negative minZoom allows zooming far out.
66
81
  const map = L.map(ref.current, { crs: L.CRS.Simple, minZoom: -8, attributionControl: false, zoomSnap: 0 });
67
82
  const bounds: L.LatLngBoundsExpression = [[0, 0], [h, w]];
@@ -76,10 +91,8 @@ export function PanZoomImage({
76
91
  // image pixels have y from the top; CRS.Simple lat is from the bottom → flip.
77
92
  const { h: ih } = sizeRef.current;
78
93
  const west = b.getWest(), east = b.getEast(), south = b.getSouth(), north = b.getNorth();
79
- onSelectRef.current?.(
80
- { type: "rect", x: Math.round(west), y: Math.round(ih - north), w: Math.round(east - west), h: Math.round(north - south) },
81
- screen,
82
- );
94
+ const x = Math.round(west), y = Math.round(ih - north), w = Math.round(east - west), hh = Math.round(north - south);
95
+ onSelectRef.current?.({ type: "rect", x, y, w, h: hh }, screen, cropPng(imgElRef.current, x, y, w, hh));
83
96
  }
84
97
  : undefined,
85
98
  });
@@ -141,7 +154,7 @@ export function ImageView({ node }: { node: NodeJson }) {
141
154
  <PanZoomImage
142
155
  src={blobUrl(node.path)}
143
156
  regions={imageRegions(shown)}
144
- onSelectRegion={openCreate}
157
+ onSelectRegion={(sel, screen, crop) => openCreate(sel, screen, undefined, crop)}
145
158
  onRegionClick={openEdit}
146
159
  selectColor={() => color}
147
160
  className="filemap fileimagemap"
@@ -1,6 +1,7 @@
1
1
  import katex from "katex";
2
2
  import "katex/dist/katex.min.css";
3
3
  import { NodeJson } from "../api";
4
+ import { scalarValue } from "../render";
4
5
  import { Chunk } from "./registry";
5
6
 
6
7
  /**
@@ -24,7 +25,7 @@ export function LatexView({ node }: { node: NodeJson }) {
24
25
  <div className="text">
25
26
  {node.title && <h1 className="chapter-title">{node.title}</h1>}
26
27
  {node.description && <p className="chapter-subtitle">{node.description}</p>}
27
- <div className="markup" dangerouslySetInnerHTML={{ __html: renderMath(node.value, true) }} />
28
+ <div className="markup" dangerouslySetInnerHTML={{ __html: renderMath(scalarValue(node.value), true) }} />
28
29
  </div>
29
30
  );
30
31
  }
@@ -1,5 +1,6 @@
1
1
  import { ReactNode } from "react";
2
2
  import { NodeJson } from "../api";
3
+ import { scalarValue } from "../render";
3
4
  import { Chunk } from "./registry";
4
5
  import { renderMath } from "./latex";
5
6
  import { NavLink } from "../links";
@@ -107,7 +108,7 @@ export function MarklowerView({ node, onNavigate }: { node: NodeJson; onNavigate
107
108
  <div className="marklower">
108
109
  {node.title && <h1 className="chapter-title">{node.title}</h1>}
109
110
  {node.description && <p className="chapter-subtitle">{node.description}</p>}
110
- <p className="chapter-prose">{parse(node.value, onNavigate, node.documentPath)}</p>
111
+ <p className="chapter-prose">{parse(scalarValue(node.value), onNavigate, node.documentPath)}</p>
111
112
  </div>
112
113
  );
113
114
  }
@@ -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)}
@@ -1,5 +1,6 @@
1
1
  import { deflateSync } from "fflate";
2
2
  import { NodeJson } from "../api";
3
+ import { scalarValue } from "../render";
3
4
  import { Chunk } from "./registry";
4
5
 
5
6
  /**
@@ -71,7 +72,7 @@ export function PlantumlView({ node }: { node: NodeJson }) {
71
72
  <div className="text">
72
73
  {node.title && <h1 className="chapter-title">{node.title}</h1>}
73
74
  {node.description && <p className="chapter-subtitle">{node.description}</p>}
74
- <Diagram source={String(node.value ?? "")} />
75
+ <Diagram source={String(scalarValue(node.value) ?? "")} />
75
76
  </div>
76
77
  );
77
78
  }