yamlover 0.3.1 → 0.3.2
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/paths.ts +30 -0
- package/src/client/renderers/annotate.tsx +35 -21
- package/src/client/renderers/djvu.tsx +230 -63
- package/src/client/renderers/djvuWorker.ts +99 -0
- package/src/client/renderers/paged.ts +137 -0
- package/src/client/renderers/pdf.tsx +141 -9
- package/src/client/styles.css +64 -4
package/dist/server.js
CHANGED
|
@@ -2345,6 +2345,25 @@ async function walkTreeAsync(absDir, opts = {}) {
|
|
|
2345
2345
|
}
|
|
2346
2346
|
return r.value;
|
|
2347
2347
|
}
|
|
2348
|
+
var BUILTIN_TAG_SCHEMA = "type: object\nformat: x-yamlover-tag\nproperties:\n color:\n type: string\nadditionalProperties: *:: yamlover: $defs: tag\n";
|
|
2349
|
+
var BUILTIN_TAGS_BODY = '!!<*yamlover:$defs:tag>\ncolors: The palette\n yellow:\n color: "#f9e2af"\n green:\n color: "#a6e3a1"\n sky:\n color: "#89dceb"\n mauve:\n color: "#cba6f7"\n pink:\n color: "#f5c2e7"\n peach:\n color: "#fab387"\n';
|
|
2350
|
+
var builtinTemplate = null;
|
|
2351
|
+
function builtinYamloverGraft() {
|
|
2352
|
+
builtinTemplate ??= {
|
|
2353
|
+
tag: parseYamlover(BUILTIN_TAG_SCHEMA, "$defs/tag").root,
|
|
2354
|
+
tags: parseYamlover(BUILTIN_TAGS_BODY, "tags/.yamlover/body.yamlover").root
|
|
2355
|
+
};
|
|
2356
|
+
const tagCopy = structuredClone(builtinTemplate.tag);
|
|
2357
|
+
const node = {
|
|
2358
|
+
kind: "mapping",
|
|
2359
|
+
array: false,
|
|
2360
|
+
entries: [
|
|
2361
|
+
{ key: "$defs", edge: "contain", value: { kind: "mapping", array: false, entries: [{ key: "tag", edge: "contain", value: tagCopy }] } },
|
|
2362
|
+
{ key: "tags", edge: "contain", value: structuredClone(builtinTemplate.tags) }
|
|
2363
|
+
]
|
|
2364
|
+
};
|
|
2365
|
+
return { node, defs: /* @__PURE__ */ new Map([["tag", tagCopy]]) };
|
|
2366
|
+
}
|
|
2348
2367
|
function* walkTreeGen(absDir, opts = {}) {
|
|
2349
2368
|
const ctx = { root: path.resolve(absDir), opts, files: /* @__PURE__ */ new Map(), count: 0 };
|
|
2350
2369
|
const root = yield* dirNode(ctx.root, ctx);
|
|
@@ -2352,13 +2371,20 @@ function* walkTreeGen(absDir, opts = {}) {
|
|
|
2352
2371
|
const defsRoot = findDefsRoot(absDir);
|
|
2353
2372
|
const defsDir = path.join(defsRoot, "$defs");
|
|
2354
2373
|
const arrayRoot = root.array || (root.entries?.length ? root.entries.every((e2) => e2.key === null) : false);
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2374
|
+
let builtinDefs;
|
|
2375
|
+
if (!arrayRoot && root.entries && !root.entries.some((e2) => e2.key === "yamlover")) {
|
|
2376
|
+
if (fs.existsSync(defsDir)) {
|
|
2377
|
+
const shared = [{ key: "$defs", edge: "contain", value: yield* dirNode(defsDir, ctx) }];
|
|
2378
|
+
const tagsDir = path.join(defsRoot, "tags");
|
|
2379
|
+
if (fs.existsSync(tagsDir)) shared.push({ key: "tags", edge: "contain", value: yield* dirNode(tagsDir, ctx) });
|
|
2380
|
+
root.entries.push({ key: "yamlover", edge: "contain", value: { kind: "mapping", entries: shared, array: false } });
|
|
2381
|
+
} else {
|
|
2382
|
+
const built = builtinYamloverGraft();
|
|
2383
|
+
root.entries.push({ key: "yamlover", edge: "contain", value: built.node });
|
|
2384
|
+
builtinDefs = built.defs;
|
|
2385
|
+
}
|
|
2360
2386
|
}
|
|
2361
|
-
applySchemas(root, defsRoot);
|
|
2387
|
+
applySchemas(root, defsRoot, builtinDefs);
|
|
2362
2388
|
return {
|
|
2363
2389
|
doc: { root, source: { concrete: "directory", uri: absDir } },
|
|
2364
2390
|
files: [...ctx.files.values()]
|
|
@@ -2572,7 +2598,7 @@ function findDefsRoot(dir) {
|
|
|
2572
2598
|
d = up;
|
|
2573
2599
|
}
|
|
2574
2600
|
}
|
|
2575
|
-
function applySchemas(root, defsRoot) {
|
|
2601
|
+
function applySchemas(root, defsRoot, builtinDefs) {
|
|
2576
2602
|
const cache = /* @__PURE__ */ new Map();
|
|
2577
2603
|
const loadDef = (name) => {
|
|
2578
2604
|
if (!cache.has(name)) {
|
|
@@ -2580,7 +2606,7 @@ function applySchemas(root, defsRoot) {
|
|
|
2580
2606
|
try {
|
|
2581
2607
|
cache.set(name, parseYamlover(fs.readFileSync(defFile, "utf8"), defFile).root);
|
|
2582
2608
|
} catch {
|
|
2583
|
-
cache.set(name, null);
|
|
2609
|
+
cache.set(name, builtinDefs?.get(name) ?? null);
|
|
2584
2610
|
}
|
|
2585
2611
|
}
|
|
2586
2612
|
return cache.get(name);
|
package/package.json
CHANGED
package/src/client/paths.ts
CHANGED
|
@@ -25,6 +25,14 @@ export function strToSegs(str: string): Seg[] {
|
|
|
25
25
|
return out;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Canonical key for cross-SCOPE comparison: collapse the colon scope ladder so a
|
|
29
|
+
* project ref (`::yamlover:…`), a document ref (`:yamlover:…`), and a server-echoed
|
|
30
|
+
* `:`-form path all compare equal. The ladder colons are not tokens (PATH_TOKEN), so
|
|
31
|
+
* re-emitting drops them; keys are normalized through one decode→encode pass. */
|
|
32
|
+
export function canonPath(p: string): string {
|
|
33
|
+
return segsToStr(strToSegs(p));
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
function safeDecode(s: string): string {
|
|
29
37
|
try {
|
|
30
38
|
return decodeURIComponent(s);
|
|
@@ -79,6 +87,25 @@ export function formatFromUrl(fallback: string): string {
|
|
|
79
87
|
return new URLSearchParams(window.location.search).get("format") || fallback;
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
/** The current page from the URL's `?page=` (1-based). 1 when absent, ≤1, or not an integer —
|
|
91
|
+
* used by paged viewers (PDF/DjVu) to restore where the reader left off. */
|
|
92
|
+
export function pageFromUrl(): number {
|
|
93
|
+
const n = Number(new URLSearchParams(window.location.search).get("page"));
|
|
94
|
+
return Number.isInteger(n) && n > 1 ? n : 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Record the current page in `?page=` via replaceState — preserving every other param, and
|
|
98
|
+
* DROPPING the param at page 1 (the implicit default). This does NOT remount the renderer (only
|
|
99
|
+
* path/format/refreshSignal do), so a paged viewer can update it freely while scrolling. */
|
|
100
|
+
export function writePageToUrl(n: number): void {
|
|
101
|
+
const params = new URLSearchParams(window.location.search);
|
|
102
|
+
if (n > 1) params.set("page", String(n));
|
|
103
|
+
else params.delete("page");
|
|
104
|
+
const qs = params.toString();
|
|
105
|
+
const url = window.location.pathname + (qs ? "?" + qs : "");
|
|
106
|
+
if (url !== window.location.pathname + window.location.search) window.history.replaceState({}, "", url);
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
/** Write the JSON path (canonical colon form, converted to the slash-transport URL)
|
|
83
110
|
* plus `?format=` into the URL. Path navigation pushes a history entry; switching
|
|
84
111
|
* format replaces. Any other query params already present are kept (e.g. a renderer's
|
|
@@ -86,6 +113,9 @@ export function formatFromUrl(fallback: string): string {
|
|
|
86
113
|
export function writeUrl(path: string, format: string, replace = false): void {
|
|
87
114
|
const params = new URLSearchParams(window.location.search);
|
|
88
115
|
params.set("format", format);
|
|
116
|
+
// `?page=` is node-specific: a format switch (replace) keeps it, but navigating to another node
|
|
117
|
+
// (push) must not carry the old node's page over — drop it there.
|
|
118
|
+
if (!replace) params.delete("page");
|
|
89
119
|
const url = `${urlOfPath(path || ":")}?${params.toString()}`;
|
|
90
120
|
if (url === window.location.pathname + window.location.search) return;
|
|
91
121
|
if (replace) window.history.replaceState({}, "", url);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
2
|
import { Annotation, TagRef, createTag, fetchAnnotations, fetchNode, saveAnnotation, deleteAnnotation } from "../api";
|
|
3
3
|
import { TAG_FORMAT, explicitColor, resolveTagColor, tagFields } from "./tag";
|
|
4
|
-
import { strToSegs } from "../paths";
|
|
4
|
+
import { canonPath, strToSegs } from "../paths";
|
|
5
5
|
import { touchesYamlover, useDiffBump } from "../live";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -30,12 +30,12 @@ import { touchesYamlover, useDiffBump } from "../live";
|
|
|
30
30
|
// fetches the real `/yamlover/tags/colors` nodes once per session (useColorTags) so a project
|
|
31
31
|
// that re-themes them wins; the paths and hexes here mirror yamlover/tags/.yamlover/body.yamlover.
|
|
32
32
|
export const COLOR_TAGS: TagRef[] = [
|
|
33
|
-
{ path: "
|
|
34
|
-
{ path: "
|
|
35
|
-
{ path: "
|
|
36
|
-
{ path: "
|
|
37
|
-
{ path: "
|
|
38
|
-
{ path: "
|
|
33
|
+
{ path: "::yamlover:tags:colors:yellow", name: "yellow", color: "#f9e2af" },
|
|
34
|
+
{ path: "::yamlover:tags:colors:green", name: "green", color: "#a6e3a1" },
|
|
35
|
+
{ path: "::yamlover:tags:colors:sky", name: "sky", color: "#89dceb" },
|
|
36
|
+
{ path: "::yamlover:tags:colors:mauve", name: "mauve", color: "#cba6f7" },
|
|
37
|
+
{ path: "::yamlover:tags:colors:pink", name: "pink", color: "#f5c2e7" },
|
|
38
|
+
{ path: "::yamlover:tags:colors:peach", name: "peach", color: "#fab387" },
|
|
39
39
|
];
|
|
40
40
|
export const DEFAULT_TAG = COLOR_TAGS[0];
|
|
41
41
|
export const DEFAULT_COLOR = DEFAULT_TAG.color!;
|
|
@@ -68,12 +68,15 @@ let colorTagsPromise: Promise<TagRef[]> | null = null;
|
|
|
68
68
|
export function useColorTags(): TagRef[] {
|
|
69
69
|
const [tags, setTags] = useState<TagRef[]>(COLOR_TAGS);
|
|
70
70
|
useEffect(() => {
|
|
71
|
-
colorTagsPromise ??= fetchNode("
|
|
71
|
+
colorTagsPromise ??= fetchNode("::yamlover:tags:colors", 2)
|
|
72
72
|
.then((n) => {
|
|
73
73
|
const out: TagRef[] = [];
|
|
74
74
|
for (const [name, child] of tagFields(n.value)) {
|
|
75
75
|
const color = explicitColor(child);
|
|
76
|
-
|
|
76
|
+
// PROJECT-scope ref pinned to `::yamlover:tags:colors:<name>` — NOT derived from
|
|
77
|
+
// `n.path` (the API echoes that in `:`-form, which would mismatch COLOR_TAGS and
|
|
78
|
+
// resurrect the ghost badge); and `:` not `/` (the pre-SEPARATOR separator bug).
|
|
79
|
+
if (color) out.push({ path: `::yamlover:tags:colors:${encodeURIComponent(name)}`, name, color });
|
|
77
80
|
}
|
|
78
81
|
return out.length ? out : COLOR_TAGS;
|
|
79
82
|
})
|
|
@@ -113,7 +116,7 @@ export function recentTags(): TagRef[] {
|
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
function rememberRecent(t: TagRef): void {
|
|
116
|
-
if (t.path.startsWith(":yamlover:tags:colors:")) return; // the swatch row already shows these
|
|
119
|
+
if (canonPath(t.path).startsWith(":yamlover:tags:colors:")) return; // the swatch row already shows these
|
|
117
120
|
const next = [t, ...recentTags().filter((r) => r.path !== t.path)].slice(0, 6);
|
|
118
121
|
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
|
119
122
|
}
|
|
@@ -165,7 +168,7 @@ export function useAnnotations(path: string, bump = 0): Annotation[] {
|
|
|
165
168
|
* round-trip lands. */
|
|
166
169
|
export interface MaterialAnnotations {
|
|
167
170
|
annotations: Annotation[];
|
|
168
|
-
create: (selector: Record<string, unknown> | null, tag: TagRef) => void;
|
|
171
|
+
create: (selector: Record<string, unknown> | null, tag: TagRef, opts?: { silent?: boolean }) => void;
|
|
169
172
|
remove: (annPath?: string) => void;
|
|
170
173
|
retag: (ann: Annotation, tag: TagRef) => void;
|
|
171
174
|
}
|
|
@@ -189,12 +192,18 @@ export function useMaterialAnnotations(path: string): MaterialAnnotations {
|
|
|
189
192
|
}, [fetched]);
|
|
190
193
|
|
|
191
194
|
const refresh = () => setBump((b) => b + 1);
|
|
192
|
-
const create = (selector: Record<string, unknown> | null, tag: TagRef) => {
|
|
195
|
+
const create = (selector: Record<string, unknown> | null, tag: TagRef, opts?: { silent?: boolean }) => {
|
|
193
196
|
const entry = { path: "(pending)", selector: selector ?? undefined, tag } as Annotation;
|
|
194
197
|
setOptimistic((o) => [...o, entry]);
|
|
195
198
|
createAnnotation(path, selector, tag)
|
|
196
199
|
.then(refresh)
|
|
197
|
-
.catch((e) => {
|
|
200
|
+
.catch((e) => {
|
|
201
|
+
setOptimistic((o) => o.filter((x) => x !== entry)); // roll back the unsaved mark
|
|
202
|
+
// An IMPLICIT save (clicking away with the pre-selected tag) is best-effort — e.g. the
|
|
203
|
+
// default tag may not exist in this tree — so it rolls back QUIETLY. Only an explicit
|
|
204
|
+
// pick (a swatch/badge/✓) reports the failure.
|
|
205
|
+
if (!opts?.silent) window.alert("save failed: " + (e as Error).message);
|
|
206
|
+
});
|
|
198
207
|
};
|
|
199
208
|
const remove = (annPath?: string) => {
|
|
200
209
|
if (!annPath || annPath === "(pending)") return;
|
|
@@ -228,7 +237,7 @@ function tagNameOf(path: string): string {
|
|
|
228
237
|
|
|
229
238
|
/** The floating tag picker — color-tag swatches, recent named-tag badges, a tag-path input, plus
|
|
230
239
|
* action buttons. Mode decides which buttons show (the hook wires what each does): `create` gets
|
|
231
|
-
* ✓ confirm + optional ⧉ copy + 🗑 discard; `edit` gets
|
|
240
|
+
* ✓ confirm + optional ⧉ copy + 🗑 discard; `edit` gets ✓ close + 🗑 delete (picking a tag re-tags).
|
|
232
241
|
* `position: fixed`, so x/y are viewport coords. */
|
|
233
242
|
export function AnnotationMenu({
|
|
234
243
|
x, y, tag, mode, onPick, onConfirm, onCopy, onTrash, menuRef,
|
|
@@ -251,10 +260,14 @@ export function AnnotationMenu({
|
|
|
251
260
|
return () => { on = false; };
|
|
252
261
|
}, []);
|
|
253
262
|
|
|
263
|
+
// Compare tag paths on a CANONICAL key — a palette ref is project-scope (`::yamlover:…`)
|
|
264
|
+
// while a selected/edited tag may arrive `:`-form (the API echoes paths in `:`-form, and
|
|
265
|
+
// older localStorage holds `:`-form); raw `===` would miss the match and duplicate the tag.
|
|
266
|
+
const same = (a: string, b: string) => canonPath(a) === canonPath(b);
|
|
254
267
|
// The badge row must always include THE tag this menu is about (`sel`-framed, like the
|
|
255
268
|
// selected color swatch) — which tag is assigned/pre-selected must be visible at a glance,
|
|
256
|
-
// even when it has aged out of the recents.
|
|
257
|
-
const badges = colorTags.some((c) => c.path
|
|
269
|
+
// even when it has aged out of the recents. A PALETTE tag is shown as a swatch, not a badge.
|
|
270
|
+
const badges = colorTags.some((c) => same(c.path, tag.path)) || recents.some((r) => same(r.path, tag.path))
|
|
258
271
|
? recents
|
|
259
272
|
: [tag, ...recents];
|
|
260
273
|
|
|
@@ -288,14 +301,14 @@ export function AnnotationMenu({
|
|
|
288
301
|
<button
|
|
289
302
|
key={t.path}
|
|
290
303
|
type="button"
|
|
291
|
-
className={"annotate-swatch" + (t.path
|
|
304
|
+
className={"annotate-swatch" + (same(t.path, tag.path) ? " sel" : "")}
|
|
292
305
|
style={{ background: resolveTagColor(t) }}
|
|
293
306
|
title={`${verb} ${t.name}`}
|
|
294
307
|
onClick={() => onPick(t)}
|
|
295
308
|
/>
|
|
296
309
|
))}
|
|
297
310
|
</div>
|
|
298
|
-
{onConfirm && <button type="button" className="annotate-tool ok" title={`${verb} ${tag.name} (keep the mark)`} onClick={onConfirm}>✓</button>}
|
|
311
|
+
{onConfirm && <button type="button" className="annotate-tool ok" title={mode === "edit" ? "close (keep this annotation)" : `${verb} ${tag.name} (keep the mark)`} onClick={onConfirm}>✓</button>}
|
|
299
312
|
{onCopy && <button type="button" className="annotate-tool" title="copy text to clipboard (don't annotate)" onClick={onCopy}>⧉</button>}
|
|
300
313
|
<button type="button" className="annotate-tool danger" title={mode === "edit" ? "delete this annotation" : "discard (don't annotate)"} onClick={onTrash}>🗑</button>
|
|
301
314
|
{badges.length > 0 && (
|
|
@@ -303,7 +316,7 @@ export function AnnotationMenu({
|
|
|
303
316
|
{badges.map((t) => (
|
|
304
317
|
// the frame is a WRAPPER: filter applies before clip-path on the same element, so a
|
|
305
318
|
// ring drawn on the clipped .tagtag itself would be clipped away with it (styles.css)
|
|
306
|
-
<span key={t.path} className={"tagframe" + (t.path
|
|
319
|
+
<span key={t.path} className={"tagframe" + (same(t.path, tag.path) ? " sel" : "")}>
|
|
307
320
|
<button
|
|
308
321
|
type="button"
|
|
309
322
|
className="tagtag"
|
|
@@ -355,7 +368,7 @@ export function useAnnotationMenu(a: MaterialAnnotations): {
|
|
|
355
368
|
const openEdit = (ann: Annotation, screen: { x: number; y: number }) =>
|
|
356
369
|
setMenu({ mode: "edit", ann, x: screen.x, y: screen.y });
|
|
357
370
|
|
|
358
|
-
const commitCreate = (t: TagRef, m: MenuState) => { if (m.mode !== "create") return; setTag(t); a.create(m.selector, t); close(); };
|
|
371
|
+
const commitCreate = (t: TagRef, m: MenuState, silent = false) => { if (m.mode !== "create") return; setTag(t); a.create(m.selector, t, { silent }); close(); };
|
|
359
372
|
const commitRetag = (t: TagRef, m: MenuState) => { if (m.mode !== "edit") return; setTag(t); a.retag(m.ann, t); close(); };
|
|
360
373
|
|
|
361
374
|
// Outside-click: a create commits with the pre-selected tag (default keeps the mark); an edit closes.
|
|
@@ -363,7 +376,7 @@ export function useAnnotationMenu(a: MaterialAnnotations): {
|
|
|
363
376
|
if (!menu) return;
|
|
364
377
|
const onDown = (e: MouseEvent) => {
|
|
365
378
|
if (menuRef.current?.contains(e.target as Node)) return;
|
|
366
|
-
if (menu.mode === "create") commitCreate(tag, menu);
|
|
379
|
+
if (menu.mode === "create") commitCreate(tag, menu, true); // implicit → best-effort, no error popup
|
|
367
380
|
else close();
|
|
368
381
|
};
|
|
369
382
|
document.addEventListener("mousedown", onDown);
|
|
@@ -387,6 +400,7 @@ export function useAnnotationMenu(a: MaterialAnnotations): {
|
|
|
387
400
|
<AnnotationMenu
|
|
388
401
|
menuRef={menuRef} x={menu.x} y={menu.y} tag={menu.ann.tag ?? DEFAULT_TAG} mode="edit"
|
|
389
402
|
onPick={(t) => commitRetag(t, menu)}
|
|
403
|
+
onConfirm={close} // ✓ closes the popup without deleting or re-tagging
|
|
390
404
|
onTrash={() => { a.remove(menu.ann.path); close(); }}
|
|
391
405
|
/>
|
|
392
406
|
);
|
|
@@ -1,97 +1,264 @@
|
|
|
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
|
+
// Track the pane width so a page fits but is capped (≤1000px) like the PDF viewer.
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
const el = ref.current;
|
|
73
|
+
if (!el) return;
|
|
74
|
+
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
|
|
75
|
+
ro.observe(el);
|
|
76
|
+
return () => ro.disconnect();
|
|
77
|
+
}, []);
|
|
78
|
+
const dispW = Math.min(width, 1000) * zoom; // each page's displayed width in px
|
|
44
79
|
|
|
45
|
-
// ctrl/alt-wheel zooms; a plain wheel is
|
|
80
|
+
// ctrl/alt-wheel zooms; a plain wheel scrolls. Zoom is a CSS resize (no re-decode), applied live;
|
|
81
|
+
// the reading position is anchored at the burst start and restored after each step.
|
|
46
82
|
useEffect(() => {
|
|
47
83
|
const el = ref.current;
|
|
48
84
|
if (!el) return;
|
|
85
|
+
let bursting = false;
|
|
86
|
+
let end = 0;
|
|
49
87
|
const onWheel = (e: WheelEvent) => {
|
|
50
88
|
if (!(e.ctrlKey || e.altKey || e.metaKey)) return;
|
|
51
89
|
e.preventDefault();
|
|
90
|
+
if (!bursting) { pagedRef.current.captureAnchor(); bursting = true; }
|
|
91
|
+
clearTimeout(end);
|
|
92
|
+
end = window.setTimeout(() => (bursting = false), 250);
|
|
52
93
|
setZoom((z) => Math.min(5, Math.max(0.4, z * (e.deltaY < 0 ? 1.1 : 1 / 1.1))));
|
|
53
94
|
};
|
|
54
95
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
55
|
-
return () => el.removeEventListener("wheel", onWheel);
|
|
96
|
+
return () => { el.removeEventListener("wheel", onWheel); clearTimeout(end); };
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
98
|
}, []);
|
|
57
99
|
|
|
100
|
+
// Open the document in the worker (off the main thread) → page count. Decoding happens lazily,
|
|
101
|
+
// per near page, in the effect below.
|
|
58
102
|
useEffect(() => {
|
|
59
103
|
let cancelled = false;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
setCount(0);
|
|
63
|
-
setError(null);
|
|
104
|
+
setCount(0); setError(null); setNear(new Set()); setDecoded(new Map());
|
|
105
|
+
wraps.current.clear(); sizes.current.clear();
|
|
64
106
|
(async () => {
|
|
65
|
-
const DjVu = await loadDjVu();
|
|
66
107
|
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
|
-
}
|
|
108
|
+
const n = await openDjvu(buf, node.path);
|
|
109
|
+
if (!cancelled) setCount(n);
|
|
78
110
|
})().catch((e) => !cancelled && setError(String((e as Error).message || e)));
|
|
79
|
-
return () => {
|
|
80
|
-
cancelled = true;
|
|
81
|
-
created.forEach(URL.revokeObjectURL);
|
|
82
|
-
};
|
|
111
|
+
return () => { cancelled = true; };
|
|
83
112
|
}, [node.path]);
|
|
84
113
|
|
|
114
|
+
// Windowed observer (mirror the PDF viewer): mark pages near the viewport.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!count) return;
|
|
117
|
+
const obs = new IntersectionObserver(
|
|
118
|
+
(entries) => {
|
|
119
|
+
setNear((prev) => {
|
|
120
|
+
const next = new Set(prev);
|
|
121
|
+
for (const e of entries) {
|
|
122
|
+
const pn = Number((e.target as HTMLElement).dataset.page);
|
|
123
|
+
if (e.isIntersecting) next.add(pn);
|
|
124
|
+
else next.delete(pn);
|
|
125
|
+
}
|
|
126
|
+
return next.size === prev.size && [...next].every((p) => prev.has(p)) ? prev : next;
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
{ root: ref.current, rootMargin: "2000px 0px" },
|
|
130
|
+
);
|
|
131
|
+
for (const el of wraps.current.values()) obs.observe(el);
|
|
132
|
+
return () => obs.disconnect();
|
|
133
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
+
}, [count, width > 0]);
|
|
135
|
+
|
|
136
|
+
// Decode near pages (lazy, in the worker); drop decoded pixels that left the window to bound
|
|
137
|
+
// memory (the worker keeps an LRU cache, so re-entry is fast).
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
let alive = true;
|
|
140
|
+
near.forEach((n) => {
|
|
141
|
+
if (!decoded.has(n)) {
|
|
142
|
+
decodeDjvuPage(n)
|
|
143
|
+
.then((dp) => {
|
|
144
|
+
sizes.current.set(n, { w: dp.w, h: dp.h });
|
|
145
|
+
if (alive) setDecoded((m) => new Map(m).set(n, dp));
|
|
146
|
+
})
|
|
147
|
+
.catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
setDecoded((m) => {
|
|
151
|
+
let changed = false;
|
|
152
|
+
const x = new Map(m);
|
|
153
|
+
for (const k of x.keys()) if (!near.has(k)) { x.delete(k); changed = true; }
|
|
154
|
+
return changed ? x : m;
|
|
155
|
+
});
|
|
156
|
+
return () => { alive = false; };
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, [near]);
|
|
159
|
+
|
|
160
|
+
// A finished text selection on an OCR page → a `djvu` region (its bounding box, in native px).
|
|
161
|
+
const onMouseUp = () => {
|
|
162
|
+
const sel = window.getSelection();
|
|
163
|
+
if (!sel || sel.isCollapsed || !sel.anchorNode) return;
|
|
164
|
+
const hostEl = sel.anchorNode.nodeType === 1 ? (sel.anchorNode as Element) : sel.anchorNode.parentElement;
|
|
165
|
+
const wrap = hostEl?.closest(".djvu-page-wrap") as HTMLElement | null;
|
|
166
|
+
if (!wrap || !ref.current?.contains(wrap)) return;
|
|
167
|
+
const pn = Number(wrap.dataset.page);
|
|
168
|
+
const wr = wrap.getBoundingClientRect();
|
|
169
|
+
const s = wr.width / (decoded.get(pn)?.w || 1); // display px per native px
|
|
170
|
+
if (!s) return;
|
|
171
|
+
const sr = sel.getRangeAt(0).getBoundingClientRect();
|
|
172
|
+
if (sr.width < 2 || sr.height < 2) return;
|
|
173
|
+
openCreate(
|
|
174
|
+
{ 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) },
|
|
175
|
+
{ x: sr.left, y: sr.bottom + 6 },
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Marquee drag on a page with NO OCR: draw a box, convert to native px.
|
|
180
|
+
const scaleOf = (pn: number, wrap: HTMLElement) => wrap.getBoundingClientRect().width / (decoded.get(pn)?.w || 1);
|
|
181
|
+
const dragStart = (pn: number, e: React.MouseEvent) => {
|
|
182
|
+
const wr = e.currentTarget.getBoundingClientRect();
|
|
183
|
+
const s = scaleOf(pn, e.currentTarget as HTMLElement);
|
|
184
|
+
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 });
|
|
185
|
+
};
|
|
186
|
+
const dragMove = (pn: number, e: React.MouseEvent) => {
|
|
187
|
+
const wr = e.currentTarget.getBoundingClientRect();
|
|
188
|
+
const s = scaleOf(pn, e.currentTarget as HTMLElement);
|
|
189
|
+
setDrag((d) => (d ? { ...d, x1: (e.clientX - wr.left) / s, y1: (e.clientY - wr.top) / s } : d));
|
|
190
|
+
};
|
|
191
|
+
const dragEnd = (pn: number, e: React.MouseEvent) => {
|
|
192
|
+
const d = drag;
|
|
193
|
+
setDrag(null);
|
|
194
|
+
if (!d || d.page !== pn) return;
|
|
195
|
+
const s = scaleOf(pn, e.currentTarget as HTMLElement);
|
|
196
|
+
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);
|
|
197
|
+
if (w * s < 3 || h * s < 3) return; // a click, not a drag
|
|
198
|
+
const wr = e.currentTarget.getBoundingClientRect();
|
|
199
|
+
openCreate(
|
|
200
|
+
{ type: "djvu", page: pn, x: Math.round(left), y: Math.round(top), w: Math.round(w), h: Math.round(h) },
|
|
201
|
+
{ x: wr.left + left * s, y: wr.top + (top + h) * s + 6 },
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
85
205
|
if (error) return <div className="error">djvu: {error}</div>;
|
|
86
206
|
return (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<div className="loading">
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
207
|
+
<>
|
|
208
|
+
<div className="filedjvu yo-zoomable" ref={ref} onMouseUp={onMouseUp}>
|
|
209
|
+
{count === 0 && <div className="loading">opening djvu…</div>}
|
|
210
|
+
{width > 0 &&
|
|
211
|
+
Array.from({ length: count }, (_, i) => {
|
|
212
|
+
const pn = i + 1;
|
|
213
|
+
const dp = near.has(pn) ? decoded.get(pn) : undefined;
|
|
214
|
+
const size = sizes.current.get(pn);
|
|
215
|
+
const estHeight = dispW * (size ? size.h / size.w : Math.SQRT2);
|
|
216
|
+
const s = dp ? dispW / dp.w : 0;
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
key={i}
|
|
220
|
+
className="djvu-page-wrap"
|
|
221
|
+
data-page={pn}
|
|
222
|
+
ref={(el) => { if (el) wraps.current.set(pn, el); else wraps.current.delete(pn); }}
|
|
223
|
+
style={{ width: dispW, height: dp ? undefined : estHeight }}
|
|
224
|
+
>
|
|
225
|
+
{dp ? (
|
|
226
|
+
<>
|
|
227
|
+
<DjvuCanvas image={dp.image} />
|
|
228
|
+
{dp.zones.length > 0 ? (
|
|
229
|
+
<div className="djvu-textlayer">
|
|
230
|
+
{dp.zones.map((z, j) => (
|
|
231
|
+
<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>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
) : (
|
|
235
|
+
<div className="djvu-marquee" onMouseDown={(e) => dragStart(pn, e)} onMouseMove={(e) => dragMove(pn, e)} onMouseUp={(e) => dragEnd(pn, e)}>
|
|
236
|
+
{drag?.page === pn && (
|
|
237
|
+
<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" }} />
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
{regions.filter((r) => r.page === pn).map((r, j) => {
|
|
242
|
+
const c = r.color || DEFAULT_COLOR;
|
|
243
|
+
return (
|
|
244
|
+
<div
|
|
245
|
+
key={j}
|
|
246
|
+
className={"djvu-region" + (r.ann ? " editable" : "")}
|
|
247
|
+
title={r.ann ? r.title || "click to recolor or delete" : r.title}
|
|
248
|
+
onClick={r.ann ? (e) => { e.stopPropagation(); openEdit(r.ann!, { x: e.clientX, y: e.clientY }); } : undefined}
|
|
249
|
+
style={{ left: r.x * s, top: r.y * s, width: r.w * s, height: r.h * s, borderColor: c, background: c + "2e" }}
|
|
250
|
+
/>
|
|
251
|
+
);
|
|
252
|
+
})}
|
|
253
|
+
</>
|
|
254
|
+
) : (
|
|
255
|
+
<div className="djvu-placeholder">{near.has(pn) ? "decoding…" : ""}</div>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
})}
|
|
260
|
+
</div>
|
|
261
|
+
{palette}
|
|
262
|
+
</>
|
|
96
263
|
);
|
|
97
264
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { pageFromUrl, writePageToUrl } from "../paths";
|
|
3
|
+
|
|
4
|
+
/** Page-tracking + zoom-anchoring for a vertically-paged viewer (PDF, DjVu). */
|
|
5
|
+
export interface PagedScroll {
|
|
6
|
+
/** Record {page, fraction-within-page} from the current scroll — call BEFORE a zoom commit. */
|
|
7
|
+
captureAnchor(): void;
|
|
8
|
+
/** After the zoom reflow, put that same page+fraction back under the viewport. */
|
|
9
|
+
restoreAnchor(): void;
|
|
10
|
+
/** Scroll a 1-based page to the top of the viewport (used for the initial `?page=` restore). */
|
|
11
|
+
scrollToPage(n: number): void;
|
|
12
|
+
/** The page the URL asked for on mount (1 if none). */
|
|
13
|
+
initialPage: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tracks the current page of a paged viewer and keeps it stable across zoom and reload.
|
|
18
|
+
*
|
|
19
|
+
* `scrollRef` is the scrolling container; `getPageEls()` returns the page elements top-to-bottom
|
|
20
|
+
* (1-based by array index, may be short/sparse before everything has rendered); `ready` is true
|
|
21
|
+
* once pages are laid out enough to measure. While ready it (a) writes the current page to `?page=`
|
|
22
|
+
* on scroll (rAF-throttled, replaceState — no remount), (b) restores `?page=` once on load
|
|
23
|
+
* (re-attempting until the target page's height settles, since it may start as a placeholder), and
|
|
24
|
+
* (c) exposes capture/restore so the caller can hold the reading position across a zoom reflow.
|
|
25
|
+
*
|
|
26
|
+
* All geometry uses getBoundingClientRect relative to the scroller, so it is correct regardless of
|
|
27
|
+
* which element is the page's offsetParent.
|
|
28
|
+
*/
|
|
29
|
+
export function usePagedScroll(
|
|
30
|
+
scrollRef: React.RefObject<HTMLElement | null>,
|
|
31
|
+
getPageEls: () => HTMLElement[],
|
|
32
|
+
ready: boolean,
|
|
33
|
+
): PagedScroll {
|
|
34
|
+
const initialPage = useRef(pageFromUrl()).current;
|
|
35
|
+
const anchor = useRef<{ page: number; fraction: number } | null>(null);
|
|
36
|
+
const suppress = useRef(false); // true around a programmatic scroll → don't write ?page=
|
|
37
|
+
const restored = useRef(false); // initial ?page= scroll settled
|
|
38
|
+
const lastH = useRef(0); // target-page height at the last restore attempt (settled when stable)
|
|
39
|
+
|
|
40
|
+
// A page's top in the scroller's content coordinates (offsetParent-agnostic).
|
|
41
|
+
const contentTop = (el: HTMLElement): number => {
|
|
42
|
+
const sc = scrollRef.current!;
|
|
43
|
+
return el.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop;
|
|
44
|
+
};
|
|
45
|
+
// The 1-based page at the viewport's "reading" line (a bit below the top).
|
|
46
|
+
const currentPage = (): number => {
|
|
47
|
+
const sc = scrollRef.current;
|
|
48
|
+
const els = getPageEls();
|
|
49
|
+
if (!sc || !els.length) return 1;
|
|
50
|
+
const probeY = sc.getBoundingClientRect().top + sc.clientHeight * 0.3;
|
|
51
|
+
for (let i = 0; i < els.length; i++) {
|
|
52
|
+
const r = els[i]?.getBoundingClientRect();
|
|
53
|
+
if (r && probeY < r.bottom) return i + 1;
|
|
54
|
+
}
|
|
55
|
+
return els.length;
|
|
56
|
+
};
|
|
57
|
+
const scrollTo = (top: number) => {
|
|
58
|
+
const sc = scrollRef.current;
|
|
59
|
+
if (!sc) return;
|
|
60
|
+
suppress.current = true;
|
|
61
|
+
sc.scrollTop = top;
|
|
62
|
+
setTimeout(() => (suppress.current = false), 120); // outlast the resulting scroll event
|
|
63
|
+
};
|
|
64
|
+
const scrollToPage = (n: number) => {
|
|
65
|
+
const els = getPageEls();
|
|
66
|
+
if (!els.length) return;
|
|
67
|
+
const t = els[Math.min(Math.max(n, 1), els.length) - 1];
|
|
68
|
+
if (t) scrollTo(contentTop(t));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Page tracking — rAF-throttled scroll → ?page=.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const sc = scrollRef.current;
|
|
74
|
+
if (!sc || !ready) return;
|
|
75
|
+
let raf = 0;
|
|
76
|
+
const onScroll = () => {
|
|
77
|
+
if (raf) return;
|
|
78
|
+
raf = requestAnimationFrame(() => {
|
|
79
|
+
raf = 0;
|
|
80
|
+
if (!suppress.current) writePageToUrl(currentPage());
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
sc.addEventListener("scroll", onScroll, { passive: true });
|
|
84
|
+
return () => { sc.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); };
|
|
85
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
|
+
}, [ready]);
|
|
87
|
+
|
|
88
|
+
// Initial ?page= restore — runs each render until the target page's height stabilizes (it may
|
|
89
|
+
// start as an estimated-height placeholder), then latches `restored`.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (restored.current || initialPage <= 1) { restored.current = true; return; }
|
|
92
|
+
if (!ready) return;
|
|
93
|
+
const els = getPageEls();
|
|
94
|
+
const t = els[Math.min(initialPage, els.length) - 1];
|
|
95
|
+
if (!t) return; // target not laid out yet — a later render retries
|
|
96
|
+
const h = t.getBoundingClientRect().height;
|
|
97
|
+
scrollToPage(initialPage);
|
|
98
|
+
if (h > 0 && h === lastH.current) restored.current = true; // height settled → done
|
|
99
|
+
lastH.current = h;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const captureAnchor = () => {
|
|
103
|
+
const sc = scrollRef.current;
|
|
104
|
+
const els = getPageEls();
|
|
105
|
+
if (!sc || !els.length) { anchor.current = null; return; }
|
|
106
|
+
const page = currentPage();
|
|
107
|
+
const t = els[page - 1];
|
|
108
|
+
const h = t?.getBoundingClientRect().height ?? 0;
|
|
109
|
+
anchor.current = t ? { page, fraction: h ? (sc.scrollTop - contentTop(t)) / h : 0 } : null;
|
|
110
|
+
};
|
|
111
|
+
// Restore the anchored page+fraction, RE-APPLYING over a few frames until scrollTop stabilizes:
|
|
112
|
+
// a zoom commit resizes far-page placeholders and renders newly-near pages asynchronously, which
|
|
113
|
+
// shifts everything above the anchor — a single set would land a page or two off (or, when
|
|
114
|
+
// shrinking hard, at the clamped bottom). Re-applying tracks the anchor page as layout settles.
|
|
115
|
+
const restoreAnchor = () => {
|
|
116
|
+
const a = anchor.current;
|
|
117
|
+
if (!a) return;
|
|
118
|
+
let tries = 0;
|
|
119
|
+
let last = -1;
|
|
120
|
+
suppress.current = true;
|
|
121
|
+
const apply = () => {
|
|
122
|
+
const sc = scrollRef.current;
|
|
123
|
+
const t = getPageEls()[a.page - 1];
|
|
124
|
+
if (!sc || !t) { suppress.current = false; return; }
|
|
125
|
+
sc.scrollTop = contentTop(t) + a.fraction * t.getBoundingClientRect().height;
|
|
126
|
+
if (Math.abs(sc.scrollTop - last) > 1 && tries++ < 8) {
|
|
127
|
+
last = sc.scrollTop;
|
|
128
|
+
requestAnimationFrame(apply);
|
|
129
|
+
} else {
|
|
130
|
+
setTimeout(() => (suppress.current = false), 120); // settled — release the page-writer
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
apply();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return { captureAnchor, restoreAnchor, scrollToPage, initialPage };
|
|
137
|
+
}
|
|
@@ -4,6 +4,7 @@ import "react-pdf/dist/Page/TextLayer.css";
|
|
|
4
4
|
import "react-pdf/dist/Page/AnnotationLayer.css";
|
|
5
5
|
import { Annotation, NodeJson, blobUrl } from "../api";
|
|
6
6
|
import { DEFAULT_COLOR, colorOf, editable, useAnnotationMenu, useMaterialAnnotations } from "./annotate";
|
|
7
|
+
import { usePagedScroll } from "./paged";
|
|
7
8
|
|
|
8
9
|
/** A rectangular annotation region on a PDF page, in points (origin top-left). `ann` is the source
|
|
9
10
|
* annotation when real/saved (→ clickable to edit); absent for the live preview. */
|
|
@@ -31,6 +32,16 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
31
32
|
const [pages, setPages] = useState(0);
|
|
32
33
|
const [zoom, setZoom] = useState(1); // ctrl/alt-wheel scale factor
|
|
33
34
|
const [orig, setOrig] = useState<Record<number, { w: number; h: number }>>({}); // each page's natural size in points
|
|
35
|
+
// Pages whose pdf.js TEXT LAYER is unusable for selection — absent (a scanned PDF has no text)
|
|
36
|
+
// or pathological (some fonts make pdf.js emit glyph boxes many times a line tall, so a text
|
|
37
|
+
// selection's geometry is garbage). Such pages fall back to a drag-marquee, like images.
|
|
38
|
+
const [marquee, setMarquee] = useState<Set<number>>(() => new Set());
|
|
39
|
+
const [drag, setDrag] = useState<{ page: number; x0: number; y0: number; x1: number; y1: number } | null>(null);
|
|
40
|
+
// Zoom scales the page content via a CSS transform (no re-raster → no blink). The transformed
|
|
41
|
+
// `.pdf-content` is out of flow, so a `.pdf-sizer` reserves the SCALED footprint to keep the
|
|
42
|
+
// scrollbar/height right; `contentH` is the content's natural (unscaled) height, measured below.
|
|
43
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
const [contentH, setContentH] = useState(0);
|
|
34
45
|
|
|
35
46
|
// WINDOWED RENDERING: every page keeps a wrapper (so the scroll height is right), but only
|
|
36
47
|
// pages near the viewport mount a real <Page> — mounting ALL of them queues every canvas
|
|
@@ -72,6 +83,19 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
72
83
|
.filter((a) => a.selector?.type === "pdf")
|
|
73
84
|
.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 }));
|
|
74
85
|
|
|
86
|
+
// Page tracking + zoom-anchoring (`?page=` in the URL; same page stays put across a zoom). Every
|
|
87
|
+
// page has a `.pdf-page` wrapper (windowing swaps only the CONTENT), so the list is dense 1..N.
|
|
88
|
+
const getPageEls = () => {
|
|
89
|
+
const out: HTMLElement[] = [];
|
|
90
|
+
for (let i = 1; i <= pages; i++) { const el = wraps.current.get(i); if (el) out.push(el); }
|
|
91
|
+
return out;
|
|
92
|
+
};
|
|
93
|
+
const paged = usePagedScroll(ref, getPageEls, width > 0 && pages > 0);
|
|
94
|
+
const pagedRef = useRef(paged);
|
|
95
|
+
pagedRef.current = paged;
|
|
96
|
+
// After a zoom COMMIT reflows the pages, restore the captured reading position.
|
|
97
|
+
useLayoutEffect(() => { paged.restoreAnchor(); }, [zoom]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
98
|
+
|
|
75
99
|
// Track the pane width so pages re-flow on resize.
|
|
76
100
|
useLayoutEffect(() => {
|
|
77
101
|
const el = ref.current;
|
|
@@ -81,20 +105,103 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
81
105
|
return () => ro.disconnect();
|
|
82
106
|
}, []);
|
|
83
107
|
|
|
84
|
-
//
|
|
108
|
+
// Measure the content's natural (unscaled) height so the sizer can reserve `height*disp`.
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
const el = contentRef.current;
|
|
111
|
+
if (!el) return;
|
|
112
|
+
const ro = new ResizeObserver(() => setContentH(el.offsetHeight));
|
|
113
|
+
ro.observe(el);
|
|
114
|
+
setContentH(el.offsetHeight);
|
|
115
|
+
return () => ro.disconnect();
|
|
116
|
+
}, [pages, width > 0]);
|
|
117
|
+
|
|
118
|
+
// ctrl/alt-wheel zooms; a plain wheel is left alone so the pane keeps scrolling. Zoom is applied
|
|
119
|
+
// as a CSS scale on the content (below) — NOT by re-rastering the pages — so it never blinks; the
|
|
120
|
+
// reading position is anchored at the start of a wheel burst and restored after each step.
|
|
85
121
|
useEffect(() => {
|
|
86
122
|
const el = ref.current;
|
|
87
123
|
if (!el) return;
|
|
124
|
+
let bursting = false;
|
|
125
|
+
let end = 0;
|
|
88
126
|
const onWheel = (e: WheelEvent) => {
|
|
89
127
|
if (!(e.ctrlKey || e.altKey || e.metaKey)) return;
|
|
90
128
|
e.preventDefault();
|
|
129
|
+
if (!bursting) { pagedRef.current.captureAnchor(); bursting = true; }
|
|
130
|
+
clearTimeout(end);
|
|
131
|
+
end = window.setTimeout(() => (bursting = false), 250);
|
|
91
132
|
setZoom((z) => Math.min(5, Math.max(0.4, z * (e.deltaY < 0 ? 1.1 : 1 / 1.1))));
|
|
92
133
|
};
|
|
93
134
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
94
|
-
return () => el.removeEventListener("wheel", onWheel);
|
|
135
|
+
return () => { el.removeEventListener("wheel", onWheel); clearTimeout(end); };
|
|
136
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
95
137
|
}, []);
|
|
96
138
|
|
|
97
|
-
|
|
139
|
+
// RASTER the pages at a FIXED, zoom-independent width (supersampled by QUALITY so CSS zoom-in
|
|
140
|
+
// stays crisp to ~QUALITY×), and apply the user's zoom as a CSS scale on the content wrapper.
|
|
141
|
+
// Because the <Page width> never changes with zoom, pdf.js never re-rasterises (no canvas remount,
|
|
142
|
+
// no white flash) and the windowed `near` set doesn't churn — zoom is a pure, blink-free reflow.
|
|
143
|
+
const QUALITY = 2;
|
|
144
|
+
const pageWidth = Math.min(width, 1000) * QUALITY; // the raster width fed to <Page>
|
|
145
|
+
const disp = zoom / QUALITY; // CSS zoom on the content; display width = pageWidth*disp = base*zoom
|
|
146
|
+
const previewColor = preview?.color ?? DEFAULT_COLOR;
|
|
147
|
+
|
|
148
|
+
// Judge a page's text layer once it has rendered: unusable when there is no real text (< 3
|
|
149
|
+
// spans) or a typical glyph box is an implausible fraction of the page height (a normal line is
|
|
150
|
+
// ~1–2%; the pathological case is ~30%). Unusable pages switch to the marquee overlay below.
|
|
151
|
+
const judgeTextLayer = (pn: number) => {
|
|
152
|
+
const wrap = wraps.current.get(pn);
|
|
153
|
+
const tl = wrap?.querySelector(".textLayer");
|
|
154
|
+
const pageH = wrap?.getBoundingClientRect().height || 0;
|
|
155
|
+
let unusable = true;
|
|
156
|
+
if (tl && pageH) {
|
|
157
|
+
const hs = [...tl.querySelectorAll("span")]
|
|
158
|
+
.filter((s) => s.textContent?.trim())
|
|
159
|
+
.map((s) => s.getBoundingClientRect().height)
|
|
160
|
+
.sort((a, b) => a - b);
|
|
161
|
+
unusable = hs.length < 3 || hs[hs.length >> 1] / pageH > 0.05;
|
|
162
|
+
}
|
|
163
|
+
setMarquee((m) => {
|
|
164
|
+
if (unusable === m.has(pn)) return m;
|
|
165
|
+
const next = new Set(m);
|
|
166
|
+
if (unusable) next.add(pn);
|
|
167
|
+
else next.delete(pn);
|
|
168
|
+
return next;
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Marquee drag on an unusable-text-layer page. The wrapper rect is in DISPLAY (CSS-zoomed) px;
|
|
173
|
+
// divide by `disp` so coords are in RASTER (content-local) px — the same space the preview rect
|
|
174
|
+
// and saved regions render in (they live inside the zoomed content), and `/sc` then gives points.
|
|
175
|
+
const dragStart = (pn: number, e: React.MouseEvent) => {
|
|
176
|
+
const wrap = wraps.current.get(pn);
|
|
177
|
+
if (!wrap) return;
|
|
178
|
+
const pr = wrap.getBoundingClientRect();
|
|
179
|
+
const x = (e.clientX - pr.left) / disp, y = (e.clientY - pr.top) / disp;
|
|
180
|
+
setDrag({ page: pn, x0: x, y0: y, x1: x, y1: y });
|
|
181
|
+
};
|
|
182
|
+
const dragMove = (e: React.MouseEvent) =>
|
|
183
|
+
setDrag((d) => {
|
|
184
|
+
const wrap = d && wraps.current.get(d.page);
|
|
185
|
+
if (!wrap) return d;
|
|
186
|
+
const pr = wrap.getBoundingClientRect();
|
|
187
|
+
return { ...d!, x1: (e.clientX - pr.left) / disp, y1: (e.clientY - pr.top) / disp };
|
|
188
|
+
});
|
|
189
|
+
const dragEnd = (pn: number, e: React.MouseEvent) => {
|
|
190
|
+
const d = drag;
|
|
191
|
+
setDrag(null);
|
|
192
|
+
if (!d || d.page !== pn) return;
|
|
193
|
+
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // raster px per point
|
|
194
|
+
const wrap = wraps.current.get(pn);
|
|
195
|
+
if (!sc || !wrap) return;
|
|
196
|
+
const pr = wrap.getBoundingClientRect();
|
|
197
|
+
const x1 = (e.clientX - pr.left) / disp, y1 = (e.clientY - pr.top) / disp; // raster px
|
|
198
|
+
const left = Math.min(d.x0, x1), top = Math.min(d.y0, y1), w = Math.abs(x1 - d.x0), h = Math.abs(y1 - d.y0);
|
|
199
|
+
if (w * disp < 3 || h * disp < 3) return; // a click, not a drag (threshold in display px)
|
|
200
|
+
openCreate(
|
|
201
|
+
{ type: "pdf", page: pn, x: Math.round(left / sc), y: Math.round(top / sc), w: Math.round(w / sc), h: Math.round(h / sc) },
|
|
202
|
+
{ x: pr.left + left * disp, y: pr.top + (top + h) * disp + 6 }, // menu position in viewport px
|
|
203
|
+
);
|
|
204
|
+
};
|
|
98
205
|
|
|
99
206
|
// A finished text selection on a page → a `pdf` region (its bounding box, converted to points).
|
|
100
207
|
const onMouseUp = () => {
|
|
@@ -104,13 +211,15 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
104
211
|
const pageEl = host?.closest(".pdf-page") as HTMLElement | null;
|
|
105
212
|
if (!pageEl || !ref.current?.contains(pageEl)) return;
|
|
106
213
|
const pn = Number(pageEl.dataset.page);
|
|
107
|
-
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; //
|
|
214
|
+
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // raster px per point
|
|
108
215
|
if (!sc) return;
|
|
109
|
-
const pr = pageEl.getBoundingClientRect();
|
|
216
|
+
const pr = pageEl.getBoundingClientRect(); // display (CSS-zoomed) px
|
|
110
217
|
const sr = sel.getRangeAt(0).getBoundingClientRect();
|
|
111
218
|
if (sr.width < 2 || sr.height < 2) return;
|
|
219
|
+
// sr/pr are display px → ÷disp to raster (content-local) px, then ÷sc to points.
|
|
220
|
+
const k = sc * disp; // display px per point
|
|
112
221
|
openCreate(
|
|
113
|
-
{ type: "pdf", page: pn, x: Math.round((sr.left - pr.left) /
|
|
222
|
+
{ type: "pdf", page: pn, x: Math.round((sr.left - pr.left) / k), y: Math.round((sr.top - pr.top) / k), w: Math.round(sr.width / k), h: Math.round(sr.height / k) },
|
|
114
223
|
{ x: sr.left, y: sr.bottom + 6 },
|
|
115
224
|
);
|
|
116
225
|
};
|
|
@@ -124,10 +233,12 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
124
233
|
loading={<div className="loading">loading PDF…</div>}
|
|
125
234
|
error={<div className="error">could not load PDF</div>}
|
|
126
235
|
>
|
|
127
|
-
{width > 0 &&
|
|
128
|
-
|
|
236
|
+
{width > 0 && (
|
|
237
|
+
<div className="pdf-sizer" style={{ width: pageWidth * disp, height: contentH * disp }}>
|
|
238
|
+
<div className="pdf-content" ref={contentRef} style={{ width: pageWidth, transform: `scale(${disp})`, transformOrigin: "top left" }}>
|
|
239
|
+
{Array.from({ length: pages }, (_, i) => {
|
|
129
240
|
const pn = i + 1;
|
|
130
|
-
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; //
|
|
241
|
+
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // RASTER px per point (regions render inside the CSS-zoomed content)
|
|
131
242
|
// a far page's placeholder: its measured aspect when known, A4 portrait until then
|
|
132
243
|
const estHeight = pageWidth * (orig[pn] ? orig[pn].h / orig[pn].w : Math.SQRT2);
|
|
133
244
|
return (
|
|
@@ -146,8 +257,26 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
146
257
|
pageNumber={pn}
|
|
147
258
|
width={pageWidth}
|
|
148
259
|
onLoadSuccess={(p) => setOrig((o) => (o[pn] ? o : { ...o, [pn]: { w: p.originalWidth || pageWidth, h: p.originalHeight || pageWidth * Math.SQRT2 } }))}
|
|
260
|
+
onRenderTextLayerSuccess={() => judgeTextLayer(pn)}
|
|
149
261
|
loading={<div className="loading" style={{ height: estHeight }}>page {pn}…</div>}
|
|
150
262
|
/>
|
|
263
|
+
{/* unusable text layer → a crosshair marquee over the page (drag a box). It
|
|
264
|
+
sits ABOVE the text layer but BELOW the region divs (rendered next), so
|
|
265
|
+
existing editable regions stay clickable while empty areas start a drag. */}
|
|
266
|
+
{marquee.has(pn) && sc > 0 && (
|
|
267
|
+
<div className="pdf-marquee" onMouseDown={(e) => dragStart(pn, e)} onMouseMove={dragMove} onMouseUp={(e) => dragEnd(pn, e)}>
|
|
268
|
+
{drag?.page === pn && (
|
|
269
|
+
<div
|
|
270
|
+
className="pdf-region"
|
|
271
|
+
style={{
|
|
272
|
+
left: Math.min(drag.x0, drag.x1), top: Math.min(drag.y0, drag.y1),
|
|
273
|
+
width: Math.abs(drag.x1 - drag.x0), height: Math.abs(drag.y1 - drag.y0),
|
|
274
|
+
borderColor: previewColor, background: previewColor + "2e",
|
|
275
|
+
}}
|
|
276
|
+
/>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
151
280
|
{sc > 0 &&
|
|
152
281
|
regions.filter((r) => r.page === pn).map((r, j) => {
|
|
153
282
|
const c = r.color || DEFAULT_COLOR;
|
|
@@ -168,6 +297,9 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
168
297
|
</div>
|
|
169
298
|
);
|
|
170
299
|
})}
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
171
303
|
</Document>
|
|
172
304
|
</div>
|
|
173
305
|
{palette}
|
package/src/client/styles.css
CHANGED
|
@@ -544,16 +544,47 @@ a.chunk-index:hover {
|
|
|
544
544
|
background: #fff;
|
|
545
545
|
}
|
|
546
546
|
.filepdf .react-pdf__Page,
|
|
547
|
-
.djvu-page {
|
|
547
|
+
.djvu-page-wrap {
|
|
548
548
|
margin: 0 0 12px; /* left-aligned (004) */
|
|
549
549
|
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
|
|
550
550
|
}
|
|
551
|
-
/* a
|
|
551
|
+
/* a DjVu page: a positioned wrapper (sized inline to the display width) holding the decoded page
|
|
552
|
+
<canvas>, its OCR text layer (selectable), the marquee overlay, and the annotation region divs */
|
|
553
|
+
.djvu-page-wrap {
|
|
554
|
+
position: relative;
|
|
555
|
+
}
|
|
552
556
|
.djvu-page {
|
|
553
557
|
display: block;
|
|
558
|
+
width: 100%;
|
|
554
559
|
max-width: none;
|
|
555
560
|
background: #fff;
|
|
556
561
|
}
|
|
562
|
+
/* a not-yet-decoded page (windowed rendering) — fills the wrapper's estimated height */
|
|
563
|
+
.djvu-placeholder {
|
|
564
|
+
width: 100%;
|
|
565
|
+
height: 100%;
|
|
566
|
+
display: flex;
|
|
567
|
+
align-items: center;
|
|
568
|
+
justify-content: center;
|
|
569
|
+
background: #fff;
|
|
570
|
+
color: #888;
|
|
571
|
+
font-size: 12px;
|
|
572
|
+
}
|
|
573
|
+
/* OCR text layer: transparent positioned spans over the page → native browser text selection */
|
|
574
|
+
.djvu-textlayer {
|
|
575
|
+
position: absolute;
|
|
576
|
+
inset: 0;
|
|
577
|
+
z-index: 2;
|
|
578
|
+
overflow: hidden;
|
|
579
|
+
line-height: 1;
|
|
580
|
+
}
|
|
581
|
+
.djvu-textlayer span {
|
|
582
|
+
position: absolute;
|
|
583
|
+
color: transparent;
|
|
584
|
+
white-space: pre;
|
|
585
|
+
transform-origin: 0 0;
|
|
586
|
+
cursor: text;
|
|
587
|
+
}
|
|
557
588
|
|
|
558
589
|
/* rendered Markdown / AsciiDoc body */
|
|
559
590
|
.markup {
|
|
@@ -884,14 +915,43 @@ a.chunk-index:hover {
|
|
|
884
915
|
background: #fff;
|
|
885
916
|
opacity: 0.06;
|
|
886
917
|
}
|
|
887
|
-
.pdf-
|
|
918
|
+
/* Zoom is a CSS transform on .pdf-content (scales the rendered pages with NO re-raster → no
|
|
919
|
+
blink). The transformed content is taken out of flow, so .pdf-sizer reserves its scaled
|
|
920
|
+
footprint to keep the scroll height correct. */
|
|
921
|
+
.pdf-sizer {
|
|
922
|
+
position: relative;
|
|
923
|
+
}
|
|
924
|
+
.pdf-content {
|
|
925
|
+
position: absolute;
|
|
926
|
+
top: 0;
|
|
927
|
+
left: 0;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/* drag-marquee overlay for pages with no usable text layer (scanned/pathological PDF, or a DjVu
|
|
931
|
+
with no OCR) — above the text layer (z 2), below the editable region divs so saved regions
|
|
932
|
+
stay clickable */
|
|
933
|
+
.pdf-marquee,
|
|
934
|
+
.djvu-marquee {
|
|
935
|
+
position: absolute;
|
|
936
|
+
inset: 0;
|
|
937
|
+
z-index: 3;
|
|
938
|
+
cursor: crosshair;
|
|
939
|
+
user-select: none;
|
|
940
|
+
}
|
|
941
|
+
.pdf-region,
|
|
942
|
+
.djvu-region {
|
|
888
943
|
position: absolute;
|
|
889
944
|
box-sizing: border-box;
|
|
945
|
+
/* above the text layer (z 2) and the marquee overlay (z 3) so a saved region is the topmost
|
|
946
|
+
element at its rectangle — otherwise a text-layer span or the marquee swallows the click and
|
|
947
|
+
the edit/delete menu never opens */
|
|
948
|
+
z-index: 4;
|
|
890
949
|
border: 2px solid #f9e2af;
|
|
891
950
|
background: rgba(249, 226, 175, 0.18);
|
|
892
951
|
pointer-events: none; /* the live preview must not block selecting text under it */
|
|
893
952
|
}
|
|
894
|
-
.pdf-region.editable
|
|
953
|
+
.pdf-region.editable,
|
|
954
|
+
.djvu-region.editable {
|
|
895
955
|
pointer-events: auto; /* a saved region is clickable → the edit menu */
|
|
896
956
|
cursor: pointer;
|
|
897
957
|
}
|