yamlover 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +34 -8
- package/package.json +1 -1
- package/src/client/App.tsx +56 -4
- package/src/client/paths.ts +30 -0
- package/src/client/renderers/annotate.tsx +35 -21
- package/src/client/renderers/djvu.tsx +237 -63
- package/src/client/renderers/djvuWorker.ts +99 -0
- package/src/client/renderers/explorer.tsx +90 -5
- package/src/client/renderers/paged.ts +137 -0
- package/src/client/renderers/pdf.tsx +150 -10
- package/src/client/styles.css +79 -4
|
@@ -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,27 @@ 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
|
+
|
|
99
|
+
// The `.filepdf` scroller is its OWN scroll container nested in the (focused) RHS pane, so the
|
|
100
|
+
// pane's focus can't drive it — focus it on mount so arrows / space / PageUp-Down / Home-End
|
|
101
|
+
// scroll the document natively. Skip when embedded as a chapter chunk (several would fight + jump).
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const el = ref.current;
|
|
104
|
+
if (el && !el.closest(".chunk-body")) el.focus({ preventScroll: true });
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
75
107
|
// Track the pane width so pages re-flow on resize.
|
|
76
108
|
useLayoutEffect(() => {
|
|
77
109
|
const el = ref.current;
|
|
@@ -81,20 +113,103 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
81
113
|
return () => ro.disconnect();
|
|
82
114
|
}, []);
|
|
83
115
|
|
|
84
|
-
//
|
|
116
|
+
// Measure the content's natural (unscaled) height so the sizer can reserve `height*disp`.
|
|
117
|
+
useLayoutEffect(() => {
|
|
118
|
+
const el = contentRef.current;
|
|
119
|
+
if (!el) return;
|
|
120
|
+
const ro = new ResizeObserver(() => setContentH(el.offsetHeight));
|
|
121
|
+
ro.observe(el);
|
|
122
|
+
setContentH(el.offsetHeight);
|
|
123
|
+
return () => ro.disconnect();
|
|
124
|
+
}, [pages, width > 0]);
|
|
125
|
+
|
|
126
|
+
// ctrl/alt-wheel zooms; a plain wheel is left alone so the pane keeps scrolling. Zoom is applied
|
|
127
|
+
// as a CSS scale on the content (below) — NOT by re-rastering the pages — so it never blinks; the
|
|
128
|
+
// reading position is anchored at the start of a wheel burst and restored after each step.
|
|
85
129
|
useEffect(() => {
|
|
86
130
|
const el = ref.current;
|
|
87
131
|
if (!el) return;
|
|
132
|
+
let bursting = false;
|
|
133
|
+
let end = 0;
|
|
88
134
|
const onWheel = (e: WheelEvent) => {
|
|
89
135
|
if (!(e.ctrlKey || e.altKey || e.metaKey)) return;
|
|
90
136
|
e.preventDefault();
|
|
137
|
+
if (!bursting) { pagedRef.current.captureAnchor(); bursting = true; }
|
|
138
|
+
clearTimeout(end);
|
|
139
|
+
end = window.setTimeout(() => (bursting = false), 250);
|
|
91
140
|
setZoom((z) => Math.min(5, Math.max(0.4, z * (e.deltaY < 0 ? 1.1 : 1 / 1.1))));
|
|
92
141
|
};
|
|
93
142
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
94
|
-
return () => el.removeEventListener("wheel", onWheel);
|
|
143
|
+
return () => { el.removeEventListener("wheel", onWheel); clearTimeout(end); };
|
|
144
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
95
145
|
}, []);
|
|
96
146
|
|
|
97
|
-
|
|
147
|
+
// RASTER the pages at a FIXED, zoom-independent width (supersampled by QUALITY so CSS zoom-in
|
|
148
|
+
// stays crisp to ~QUALITY×), and apply the user's zoom as a CSS scale on the content wrapper.
|
|
149
|
+
// Because the <Page width> never changes with zoom, pdf.js never re-rasterises (no canvas remount,
|
|
150
|
+
// no white flash) and the windowed `near` set doesn't churn — zoom is a pure, blink-free reflow.
|
|
151
|
+
const QUALITY = 2;
|
|
152
|
+
const pageWidth = Math.min(width, 1000) * QUALITY; // the raster width fed to <Page>
|
|
153
|
+
const disp = zoom / QUALITY; // CSS zoom on the content; display width = pageWidth*disp = base*zoom
|
|
154
|
+
const previewColor = preview?.color ?? DEFAULT_COLOR;
|
|
155
|
+
|
|
156
|
+
// Judge a page's text layer once it has rendered: unusable when there is no real text (< 3
|
|
157
|
+
// spans) or a typical glyph box is an implausible fraction of the page height (a normal line is
|
|
158
|
+
// ~1–2%; the pathological case is ~30%). Unusable pages switch to the marquee overlay below.
|
|
159
|
+
const judgeTextLayer = (pn: number) => {
|
|
160
|
+
const wrap = wraps.current.get(pn);
|
|
161
|
+
const tl = wrap?.querySelector(".textLayer");
|
|
162
|
+
const pageH = wrap?.getBoundingClientRect().height || 0;
|
|
163
|
+
let unusable = true;
|
|
164
|
+
if (tl && pageH) {
|
|
165
|
+
const hs = [...tl.querySelectorAll("span")]
|
|
166
|
+
.filter((s) => s.textContent?.trim())
|
|
167
|
+
.map((s) => s.getBoundingClientRect().height)
|
|
168
|
+
.sort((a, b) => a - b);
|
|
169
|
+
unusable = hs.length < 3 || hs[hs.length >> 1] / pageH > 0.05;
|
|
170
|
+
}
|
|
171
|
+
setMarquee((m) => {
|
|
172
|
+
if (unusable === m.has(pn)) return m;
|
|
173
|
+
const next = new Set(m);
|
|
174
|
+
if (unusable) next.add(pn);
|
|
175
|
+
else next.delete(pn);
|
|
176
|
+
return next;
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Marquee drag on an unusable-text-layer page. The wrapper rect is in DISPLAY (CSS-zoomed) px;
|
|
181
|
+
// divide by `disp` so coords are in RASTER (content-local) px — the same space the preview rect
|
|
182
|
+
// and saved regions render in (they live inside the zoomed content), and `/sc` then gives points.
|
|
183
|
+
const dragStart = (pn: number, e: React.MouseEvent) => {
|
|
184
|
+
const wrap = wraps.current.get(pn);
|
|
185
|
+
if (!wrap) return;
|
|
186
|
+
const pr = wrap.getBoundingClientRect();
|
|
187
|
+
const x = (e.clientX - pr.left) / disp, y = (e.clientY - pr.top) / disp;
|
|
188
|
+
setDrag({ page: pn, x0: x, y0: y, x1: x, y1: y });
|
|
189
|
+
};
|
|
190
|
+
const dragMove = (e: React.MouseEvent) =>
|
|
191
|
+
setDrag((d) => {
|
|
192
|
+
const wrap = d && wraps.current.get(d.page);
|
|
193
|
+
if (!wrap) return d;
|
|
194
|
+
const pr = wrap.getBoundingClientRect();
|
|
195
|
+
return { ...d!, x1: (e.clientX - pr.left) / disp, y1: (e.clientY - pr.top) / disp };
|
|
196
|
+
});
|
|
197
|
+
const dragEnd = (pn: number, e: React.MouseEvent) => {
|
|
198
|
+
const d = drag;
|
|
199
|
+
setDrag(null);
|
|
200
|
+
if (!d || d.page !== pn) return;
|
|
201
|
+
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // raster px per point
|
|
202
|
+
const wrap = wraps.current.get(pn);
|
|
203
|
+
if (!sc || !wrap) return;
|
|
204
|
+
const pr = wrap.getBoundingClientRect();
|
|
205
|
+
const x1 = (e.clientX - pr.left) / disp, y1 = (e.clientY - pr.top) / disp; // raster px
|
|
206
|
+
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);
|
|
207
|
+
if (w * disp < 3 || h * disp < 3) return; // a click, not a drag (threshold in display px)
|
|
208
|
+
openCreate(
|
|
209
|
+
{ type: "pdf", page: pn, x: Math.round(left / sc), y: Math.round(top / sc), w: Math.round(w / sc), h: Math.round(h / sc) },
|
|
210
|
+
{ x: pr.left + left * disp, y: pr.top + (top + h) * disp + 6 }, // menu position in viewport px
|
|
211
|
+
);
|
|
212
|
+
};
|
|
98
213
|
|
|
99
214
|
// A finished text selection on a page → a `pdf` region (its bounding box, converted to points).
|
|
100
215
|
const onMouseUp = () => {
|
|
@@ -104,30 +219,34 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
104
219
|
const pageEl = host?.closest(".pdf-page") as HTMLElement | null;
|
|
105
220
|
if (!pageEl || !ref.current?.contains(pageEl)) return;
|
|
106
221
|
const pn = Number(pageEl.dataset.page);
|
|
107
|
-
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; //
|
|
222
|
+
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // raster px per point
|
|
108
223
|
if (!sc) return;
|
|
109
|
-
const pr = pageEl.getBoundingClientRect();
|
|
224
|
+
const pr = pageEl.getBoundingClientRect(); // display (CSS-zoomed) px
|
|
110
225
|
const sr = sel.getRangeAt(0).getBoundingClientRect();
|
|
111
226
|
if (sr.width < 2 || sr.height < 2) return;
|
|
227
|
+
// sr/pr are display px → ÷disp to raster (content-local) px, then ÷sc to points.
|
|
228
|
+
const k = sc * disp; // display px per point
|
|
112
229
|
openCreate(
|
|
113
|
-
{ type: "pdf", page: pn, x: Math.round((sr.left - pr.left) /
|
|
230
|
+
{ 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
231
|
{ x: sr.left, y: sr.bottom + 6 },
|
|
115
232
|
);
|
|
116
233
|
};
|
|
117
234
|
|
|
118
235
|
return (
|
|
119
236
|
<>
|
|
120
|
-
<div className="filepdf yo-zoomable" ref={ref} onMouseUp={onMouseUp}>
|
|
237
|
+
<div className="filepdf yo-zoomable" ref={ref} tabIndex={0} onMouseUp={onMouseUp}>
|
|
121
238
|
<Document
|
|
122
239
|
file={blobUrl(node.path)}
|
|
123
240
|
onLoadSuccess={({ numPages }) => setPages(numPages)}
|
|
124
241
|
loading={<div className="loading">loading PDF…</div>}
|
|
125
242
|
error={<div className="error">could not load PDF</div>}
|
|
126
243
|
>
|
|
127
|
-
{width > 0 &&
|
|
128
|
-
|
|
244
|
+
{width > 0 && (
|
|
245
|
+
<div className="pdf-sizer" style={{ width: pageWidth * disp, height: contentH * disp }}>
|
|
246
|
+
<div className="pdf-content" ref={contentRef} style={{ width: pageWidth, transform: `scale(${disp})`, transformOrigin: "top left" }}>
|
|
247
|
+
{Array.from({ length: pages }, (_, i) => {
|
|
129
248
|
const pn = i + 1;
|
|
130
|
-
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; //
|
|
249
|
+
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // RASTER px per point (regions render inside the CSS-zoomed content)
|
|
131
250
|
// a far page's placeholder: its measured aspect when known, A4 portrait until then
|
|
132
251
|
const estHeight = pageWidth * (orig[pn] ? orig[pn].h / orig[pn].w : Math.SQRT2);
|
|
133
252
|
return (
|
|
@@ -146,8 +265,26 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
146
265
|
pageNumber={pn}
|
|
147
266
|
width={pageWidth}
|
|
148
267
|
onLoadSuccess={(p) => setOrig((o) => (o[pn] ? o : { ...o, [pn]: { w: p.originalWidth || pageWidth, h: p.originalHeight || pageWidth * Math.SQRT2 } }))}
|
|
268
|
+
onRenderTextLayerSuccess={() => judgeTextLayer(pn)}
|
|
149
269
|
loading={<div className="loading" style={{ height: estHeight }}>page {pn}…</div>}
|
|
150
270
|
/>
|
|
271
|
+
{/* unusable text layer → a crosshair marquee over the page (drag a box). It
|
|
272
|
+
sits ABOVE the text layer but BELOW the region divs (rendered next), so
|
|
273
|
+
existing editable regions stay clickable while empty areas start a drag. */}
|
|
274
|
+
{marquee.has(pn) && sc > 0 && (
|
|
275
|
+
<div className="pdf-marquee" onMouseDown={(e) => dragStart(pn, e)} onMouseMove={dragMove} onMouseUp={(e) => dragEnd(pn, e)}>
|
|
276
|
+
{drag?.page === pn && (
|
|
277
|
+
<div
|
|
278
|
+
className="pdf-region"
|
|
279
|
+
style={{
|
|
280
|
+
left: Math.min(drag.x0, drag.x1), top: Math.min(drag.y0, drag.y1),
|
|
281
|
+
width: Math.abs(drag.x1 - drag.x0), height: Math.abs(drag.y1 - drag.y0),
|
|
282
|
+
borderColor: previewColor, background: previewColor + "2e",
|
|
283
|
+
}}
|
|
284
|
+
/>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
151
288
|
{sc > 0 &&
|
|
152
289
|
regions.filter((r) => r.page === pn).map((r, j) => {
|
|
153
290
|
const c = r.color || DEFAULT_COLOR;
|
|
@@ -168,6 +305,9 @@ export function PdfView({ node }: { node: NodeJson }) {
|
|
|
168
305
|
</div>
|
|
169
306
|
);
|
|
170
307
|
})}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
171
311
|
</Document>
|
|
172
312
|
</div>
|
|
173
313
|
{palette}
|
package/src/client/styles.css
CHANGED
|
@@ -148,6 +148,10 @@ body {
|
|
|
148
148
|
flex: 1 1 auto;
|
|
149
149
|
padding: 14px 18px;
|
|
150
150
|
}
|
|
151
|
+
/* programmatically focused on TOC click (so the keyboard drives the viewer) — no focus ring */
|
|
152
|
+
.right:focus {
|
|
153
|
+
outline: none;
|
|
154
|
+
}
|
|
151
155
|
.splitter {
|
|
152
156
|
flex: 0 0 5px;
|
|
153
157
|
cursor: col-resize;
|
|
@@ -519,6 +523,11 @@ a.chunk-index:hover {
|
|
|
519
523
|
height: calc(100vh - 160px);
|
|
520
524
|
min-height: 360px;
|
|
521
525
|
}
|
|
526
|
+
/* focused on mount so the keyboard scrolls the document — no focus ring on the scroller */
|
|
527
|
+
.filepdf:focus,
|
|
528
|
+
.filedjvu:focus {
|
|
529
|
+
outline: none;
|
|
530
|
+
}
|
|
522
531
|
.fileimage {
|
|
523
532
|
/* keep the image's own aspect ratio — never stretch (007) */
|
|
524
533
|
max-width: 100%;
|
|
@@ -544,16 +553,47 @@ a.chunk-index:hover {
|
|
|
544
553
|
background: #fff;
|
|
545
554
|
}
|
|
546
555
|
.filepdf .react-pdf__Page,
|
|
547
|
-
.djvu-page {
|
|
556
|
+
.djvu-page-wrap {
|
|
548
557
|
margin: 0 0 12px; /* left-aligned (004) */
|
|
549
558
|
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
|
|
550
559
|
}
|
|
551
|
-
/* a
|
|
560
|
+
/* a DjVu page: a positioned wrapper (sized inline to the display width) holding the decoded page
|
|
561
|
+
<canvas>, its OCR text layer (selectable), the marquee overlay, and the annotation region divs */
|
|
562
|
+
.djvu-page-wrap {
|
|
563
|
+
position: relative;
|
|
564
|
+
}
|
|
552
565
|
.djvu-page {
|
|
553
566
|
display: block;
|
|
567
|
+
width: 100%;
|
|
554
568
|
max-width: none;
|
|
555
569
|
background: #fff;
|
|
556
570
|
}
|
|
571
|
+
/* a not-yet-decoded page (windowed rendering) — fills the wrapper's estimated height */
|
|
572
|
+
.djvu-placeholder {
|
|
573
|
+
width: 100%;
|
|
574
|
+
height: 100%;
|
|
575
|
+
display: flex;
|
|
576
|
+
align-items: center;
|
|
577
|
+
justify-content: center;
|
|
578
|
+
background: #fff;
|
|
579
|
+
color: #888;
|
|
580
|
+
font-size: 12px;
|
|
581
|
+
}
|
|
582
|
+
/* OCR text layer: transparent positioned spans over the page → native browser text selection */
|
|
583
|
+
.djvu-textlayer {
|
|
584
|
+
position: absolute;
|
|
585
|
+
inset: 0;
|
|
586
|
+
z-index: 2;
|
|
587
|
+
overflow: hidden;
|
|
588
|
+
line-height: 1;
|
|
589
|
+
}
|
|
590
|
+
.djvu-textlayer span {
|
|
591
|
+
position: absolute;
|
|
592
|
+
color: transparent;
|
|
593
|
+
white-space: pre;
|
|
594
|
+
transform-origin: 0 0;
|
|
595
|
+
cursor: text;
|
|
596
|
+
}
|
|
557
597
|
|
|
558
598
|
/* rendered Markdown / AsciiDoc body */
|
|
559
599
|
.markup {
|
|
@@ -884,14 +924,43 @@ a.chunk-index:hover {
|
|
|
884
924
|
background: #fff;
|
|
885
925
|
opacity: 0.06;
|
|
886
926
|
}
|
|
887
|
-
.pdf-
|
|
927
|
+
/* Zoom is a CSS transform on .pdf-content (scales the rendered pages with NO re-raster → no
|
|
928
|
+
blink). The transformed content is taken out of flow, so .pdf-sizer reserves its scaled
|
|
929
|
+
footprint to keep the scroll height correct. */
|
|
930
|
+
.pdf-sizer {
|
|
931
|
+
position: relative;
|
|
932
|
+
}
|
|
933
|
+
.pdf-content {
|
|
934
|
+
position: absolute;
|
|
935
|
+
top: 0;
|
|
936
|
+
left: 0;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/* drag-marquee overlay for pages with no usable text layer (scanned/pathological PDF, or a DjVu
|
|
940
|
+
with no OCR) — above the text layer (z 2), below the editable region divs so saved regions
|
|
941
|
+
stay clickable */
|
|
942
|
+
.pdf-marquee,
|
|
943
|
+
.djvu-marquee {
|
|
944
|
+
position: absolute;
|
|
945
|
+
inset: 0;
|
|
946
|
+
z-index: 3;
|
|
947
|
+
cursor: crosshair;
|
|
948
|
+
user-select: none;
|
|
949
|
+
}
|
|
950
|
+
.pdf-region,
|
|
951
|
+
.djvu-region {
|
|
888
952
|
position: absolute;
|
|
889
953
|
box-sizing: border-box;
|
|
954
|
+
/* above the text layer (z 2) and the marquee overlay (z 3) so a saved region is the topmost
|
|
955
|
+
element at its rectangle — otherwise a text-layer span or the marquee swallows the click and
|
|
956
|
+
the edit/delete menu never opens */
|
|
957
|
+
z-index: 4;
|
|
890
958
|
border: 2px solid #f9e2af;
|
|
891
959
|
background: rgba(249, 226, 175, 0.18);
|
|
892
960
|
pointer-events: none; /* the live preview must not block selecting text under it */
|
|
893
961
|
}
|
|
894
|
-
.pdf-region.editable
|
|
962
|
+
.pdf-region.editable,
|
|
963
|
+
.djvu-region.editable {
|
|
895
964
|
pointer-events: auto; /* a saved region is clickable → the edit menu */
|
|
896
965
|
cursor: pointer;
|
|
897
966
|
}
|
|
@@ -1020,6 +1089,12 @@ mark.yo-annotation {
|
|
|
1020
1089
|
.dirview-item:hover {
|
|
1021
1090
|
background: var(--panel);
|
|
1022
1091
|
}
|
|
1092
|
+
/* keyboard selection (roving focus — arrows walk, Enter opens) */
|
|
1093
|
+
.dirview-item:focus {
|
|
1094
|
+
outline: none;
|
|
1095
|
+
background: var(--panel);
|
|
1096
|
+
box-shadow: 0 0 0 2px var(--accent) inset;
|
|
1097
|
+
}
|
|
1023
1098
|
.dirview-icon {
|
|
1024
1099
|
flex: none;
|
|
1025
1100
|
width: 22px;
|