yamlover 0.3.0 → 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 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
- if (!arrayRoot && fs.existsSync(defsDir) && root.entries && !root.entries.some((e2) => e2.key === "yamlover")) {
2356
- const shared = [{ key: "$defs", edge: "contain", value: yield* dirNode(defsDir, ctx) }];
2357
- const tagsDir = path.join(defsRoot, "tags");
2358
- if (fs.existsSync(tagsDir)) shared.push({ key: "tags", edge: "contain", value: yield* dirNode(tagsDir, ctx) });
2359
- root.entries.push({ key: "yamlover", edge: "contain", value: { kind: "mapping", entries: shared, array: false } });
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
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "yamlover",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Browse a yamlover tree in the web: npx yamlover <root> serves a React SPA over a directory.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "dims",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dims12/yamlover.git",
11
+ "directory": "tools/server"
12
+ },
8
13
  "keywords": [
9
14
  "yamlover",
10
15
  "yaml",
@@ -324,7 +324,7 @@ export function App() {
324
324
  <nav className="crumbs">
325
325
  {crumbs(current, rootLabel).map((c, i) => (
326
326
  <span key={c.path}>
327
- {i > 0 && <span className="crumb-sep">:</span>}
327
+ {i > 0 && <span className="crumb-sep">/</span>}
328
328
  <a
329
329
  className="crumb"
330
330
  href={c.path}
@@ -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: ":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" },
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(":yamlover:tags:colors", 2)
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
- if (color) out.push({ path: `${n.path}/${encodeURIComponent(name)}`, name, color });
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) => { setOptimistic((o) => o.filter((x) => x !== entry)); window.alert("save failed: " + (e as Error).message); }); // roll back the unsaved mark
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 just 🗑 delete (picking a tag re-tags).
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 === tag.path) || recents.some((r) => r.path === tag.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 === tag.path ? " sel" : "")}
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 === tag.path ? " sel" : "")}>
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
- // The DjVu.js library (GPL-v2), vendored as a prebuilt IIFE bundle. Loaded as a
4
- // classic <script> so its `var DjVu = (…)()` lands on the global scope; see
5
- // vendor/README.md for provenance and license.
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
- declare global {
9
- interface Window {
10
- DjVu?: any;
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
- let loading: Promise<any> | null = null;
15
- /** Inject the vendored bundle once; resolve with the global `DjVu` namespace. */
16
- function loadDjVu(): Promise<any> {
17
- if (window.DjVu) return Promise.resolve(window.DjVu);
18
- if (!loading) {
19
- loading = new Promise((resolve, reject) => {
20
- const s = document.createElement("script");
21
- s.src = djvuScriptUrl;
22
- s.onload = () => (window.DjVu ? resolve(window.DjVu) : reject(new Error("DjVu failed to load")));
23
- s.onerror = () => reject(new Error("could not load djvu.js"));
24
- document.head.appendChild(s);
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. The browser has no native DjVu support,
32
- * so we decode it client-side with DjVu.js: fetch the bytes from `/api/blob`,
33
- * build a `DjVu.Document`, and render each page to a PNG object-URL shown as an
34
- * `<img>` (cheaper than holding every page as a full-resolution canvas). A plain
35
- * wheel scrolls the document; ctrl/alt-wheel zooms (scales the page width), matching
36
- * the image/map/pdf viewers (see the UI guide).
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 left alone so the pane keeps scrolling.
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
- const created: string[] = [];
61
- setPages([]);
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 doc = new DjVu.Document(buf);
68
- const total = doc.getPagesQuantity();
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
- <div className="filedjvu yo-zoomable" ref={ref}>
88
- {count > 0 && pages.length < count && (
89
- <div className="loading">decoding djvu… page {pages.length + 1} of {count}</div>
90
- )}
91
- {count === 0 && pages.length === 0 && <div className="loading">decoding djvu…</div>}
92
- {pages.map((url, i) => (
93
- <img key={i} className="djvu-page" style={{ width: `${zoom * 100}%` }} src={url} alt={`page ${i + 1}`} />
94
- ))}
95
- </div>
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
- // ctrl/alt-wheel zooms; a plain wheel is left alone so the pane keeps scrolling.
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
- const pageWidth = Math.min(width, 1000) * zoom;
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; // rendered px per point
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) / sc), y: Math.round((sr.top - pr.top) / sc), w: Math.round(sr.width / sc), h: Math.round(sr.height / sc) },
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
- Array.from({ length: pages }, (_, i) => {
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; // rendered px per point tracks zoom
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}
@@ -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 decoded DjVu page width is driven inline by the zoom factor (ctrl/alt-wheel) */
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-region {
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
  }