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.
- package/dist/server.js +34 -8
- package/package.json +1 -1
- package/src/client/App.tsx +56 -4
- package/src/client/paths.ts +30 -0
- package/src/client/renderers/annotate.tsx +35 -21
- package/src/client/renderers/djvu.tsx +237 -63
- package/src/client/renderers/djvuWorker.ts +99 -0
- package/src/client/renderers/explorer.tsx +90 -5
- package/src/client/renderers/paged.ts +137 -0
- package/src/client/renderers/pdf.tsx +150 -10
- package/src/client/styles.css +79 -4
|
@@ -1,97 +1,271 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import { NodeJson, blobUrl } from "../api";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* the
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
<div className="loading">
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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>
|