yamlover 0.3.4 → 0.3.6
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/bin/yamlover.js +199 -112
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/client/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
- package/dist/client/assets/decoded-CFkcuQ1f.js +1 -0
- package/dist/client/assets/djvu-B3wuFWwI.js +1 -0
- package/dist/client/assets/docx-putHYXzO.js +230 -0
- package/dist/client/assets/heic-oIxhIbIW.js +66 -0
- package/dist/client/assets/imagemap-CUKUIcGe.js +1 -0
- package/dist/client/assets/index-CQ3TGVSx.js +617 -0
- package/dist/client/assets/index-wTjOzmSY.css +1 -0
- package/dist/client/assets/map-CZS083CS.js +1 -0
- package/dist/client/assets/paged-CjxSuuik.js +1 -0
- package/dist/client/assets/panzoom-C7OuIIda.js +4 -0
- package/dist/client/assets/panzoom-CIGW-MKW.css +1 -0
- package/dist/client/assets/pdf-Dz3gUx0B.js +12 -0
- package/dist/client/assets/pdf-m5e3jsV4.css +1 -0
- package/dist/client/assets/pdf.worker.min-qwK7q_zL.mjs +28 -0
- package/dist/client/assets/psd-C8yNwHFw.js +11 -0
- package/dist/client/assets/spreadsheet-DBv7aqoN.js +38 -0
- package/dist/client/assets/tiff-73RKtIux.js +1 -0
- package/{index.html → dist/client/index.html} +2 -1
- package/package.json +18 -21
- package/src/client/App.tsx +0 -424
- package/src/client/NodeView.tsx +0 -422
- package/src/client/TaskStrip.tsx +0 -34
- package/src/client/Tree.tsx +0 -97
- package/src/client/api.ts +0 -213
- package/src/client/icons.ts +0 -91
- package/src/client/links.tsx +0 -108
- package/src/client/live.ts +0 -42
- package/src/client/main.tsx +0 -10
- package/src/client/paste-html.ts +0 -228
- package/src/client/paste-links.ts +0 -42
- package/src/client/paths.ts +0 -139
- package/src/client/render.tsx +0 -339
- package/src/client/renderers/annotate.tsx +0 -641
- package/src/client/renderers/asciidoc.tsx +0 -36
- package/src/client/renderers/chapter.tsx +0 -142
- package/src/client/renderers/csv.tsx +0 -234
- package/src/client/renderers/decoded.tsx +0 -72
- package/src/client/renderers/djvu.tsx +0 -271
- package/src/client/renderers/djvuWorker.ts +0 -99
- package/src/client/renderers/doc.tsx +0 -40
- package/src/client/renderers/docx.tsx +0 -49
- package/src/client/renderers/epub.tsx +0 -147
- package/src/client/renderers/explorer.tsx +0 -294
- package/src/client/renderers/fb2.tsx +0 -149
- package/src/client/renderers/headings.ts +0 -69
- package/src/client/renderers/heic.tsx +0 -23
- package/src/client/renderers/imagemap.tsx +0 -170
- package/src/client/renderers/kml.ts +0 -46
- package/src/client/renderers/latex.tsx +0 -37
- package/src/client/renderers/map.tsx +0 -205
- package/src/client/renderers/marklower.tsx +0 -120
- package/src/client/renderers/markup.tsx +0 -64
- package/src/client/renderers/media.tsx +0 -19
- package/src/client/renderers/paged.ts +0 -137
- package/src/client/renderers/panzoom.ts +0 -101
- package/src/client/renderers/pdf.tsx +0 -316
- package/src/client/renderers/plaintext.tsx +0 -120
- package/src/client/renderers/plantuml.tsx +0 -83
- package/src/client/renderers/psd.tsx +0 -25
- package/src/client/renderers/registry.tsx +0 -411
- package/src/client/renderers/rtf.tsx +0 -210
- package/src/client/renderers/spreadsheet.tsx +0 -105
- package/src/client/renderers/tag.tsx +0 -113
- package/src/client/renderers/text.tsx +0 -42
- package/src/client/renderers/tiff.tsx +0 -33
- package/src/client/styles.css +0 -1212
- package/src/client/vendor/README.md +0 -30
- package/src/client/vite-env.d.ts +0 -31
- package/src/server/api.ts +0 -147
- package/src/server/embed.ts +0 -187
- package/src/server/engine-api.ts +0 -1563
- package/src/server/gitignore.ts +0 -81
- package/src/server/node-kind.ts +0 -61
- package/src/server/tasks.ts +0 -83
- package/src/server/yamlover.ts +0 -1133
- /package/{src/client/vendor/djvu.js → dist/client/assets/djvu-Ci0lFKxE.js} +0 -0
|
@@ -1,137 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import L from "leaflet";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The unified pan/zoom/select gesture model for a Leaflet map (shared by the image viewer and the
|
|
5
|
-
* KML map, see the UI guide). It overrides Leaflet's defaults so SELECTING is the primary gesture:
|
|
6
|
-
*
|
|
7
|
-
* - plain DRAG → rubber-band a selection rectangle (→ `onSelect`, to annotate the region)
|
|
8
|
-
* - ctrl/alt DRAG → pan (grab-and-drag the canvas)
|
|
9
|
-
* - plain WHEEL → pan vertically (scroll the canvas, like scrolling text)
|
|
10
|
-
* - ctrl/alt WHEEL→ zoom around the cursor
|
|
11
|
-
*
|
|
12
|
-
* When `onSelect` is omitted (e.g. an inline chapter chunk, which has no annotation target), plain
|
|
13
|
-
* drag PANS instead (Leaflet's default) and the plain wheel is left alone so the page keeps
|
|
14
|
-
* scrolling — only ctrl/alt-wheel zoom is added. Returns a disposer to unwire everything.
|
|
15
|
-
*/
|
|
16
|
-
export interface GestureOptions {
|
|
17
|
-
/** A plain-drag rectangle finished. `bounds` is in the map's coordinate space (CRS.Simple pixels
|
|
18
|
-
* for an image, lat/lng for a map); `screen` is the viewport point to anchor the menu at. */
|
|
19
|
-
onSelect?: (bounds: L.LatLngBounds, screen: { x: number; y: number }) => void;
|
|
20
|
-
/** The live rubber-band color (the current highlight color). */
|
|
21
|
-
color?: () => string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const hasMod = (e: MouseEvent | WheelEvent): boolean => e.ctrlKey || e.altKey || e.metaKey;
|
|
25
|
-
|
|
26
|
-
export function wireGestures(map: L.Map, opts: GestureOptions): () => void {
|
|
27
|
-
const container = map.getContainer();
|
|
28
|
-
const selectable = !!opts.onSelect;
|
|
29
|
-
|
|
30
|
-
// Selecting needs drag free for the rubber-band; without it, keep Leaflet's drag-to-pan.
|
|
31
|
-
if (selectable) map.dragging.disable();
|
|
32
|
-
map.scrollWheelZoom.disable(); // we drive the wheel ourselves (pan vs. zoom by modifier)
|
|
33
|
-
map.doubleClickZoom.disable();
|
|
34
|
-
map.boxZoom.disable();
|
|
35
|
-
|
|
36
|
-
let band: L.Rectangle | null = null;
|
|
37
|
-
let start: L.LatLng | null = null;
|
|
38
|
-
let startPt: L.Point | null = null;
|
|
39
|
-
let panning = false;
|
|
40
|
-
let panPrev: L.Point | null = null;
|
|
41
|
-
|
|
42
|
-
const onDown = (e: L.LeafletMouseEvent) => {
|
|
43
|
-
if (e.originalEvent.button !== 0) return; // left button only — right/middle never select or pan
|
|
44
|
-
if (hasMod(e.originalEvent)) {
|
|
45
|
-
if (!selectable) return; // dragging still enabled → Leaflet pans natively
|
|
46
|
-
panning = true; // modifier-drag → manual pan
|
|
47
|
-
panPrev = e.containerPoint;
|
|
48
|
-
L.DomUtil.disableTextSelection();
|
|
49
|
-
} else if (selectable) {
|
|
50
|
-
start = e.latlng; // plain drag → rubber-band a selection
|
|
51
|
-
startPt = e.containerPoint;
|
|
52
|
-
const c = opts.color?.() ?? "#f9e2af";
|
|
53
|
-
// a bold dashed "marching ants" rubber-band so the selection is clearly visible over a busy map
|
|
54
|
-
band = L.rectangle(L.latLngBounds(e.latlng, e.latlng), { className: "yo-band", color: c, weight: 3, dashArray: "6 4", fillColor: c, fillOpacity: 0.25, interactive: false }).addTo(map);
|
|
55
|
-
L.DomUtil.disableTextSelection();
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
const onMove = (e: L.LeafletMouseEvent) => {
|
|
59
|
-
if (panning && panPrev) {
|
|
60
|
-
map.panBy(panPrev.subtract(e.containerPoint), { animate: false });
|
|
61
|
-
panPrev = e.containerPoint;
|
|
62
|
-
} else if (band && start) {
|
|
63
|
-
band.setBounds(L.latLngBounds(start, e.latlng));
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
const onUp = (e: L.LeafletMouseEvent) => {
|
|
67
|
-
L.DomUtil.enableTextSelection();
|
|
68
|
-
if (panning) { panning = false; panPrev = null; return; }
|
|
69
|
-
if (band && start && startPt) {
|
|
70
|
-
const bounds = L.latLngBounds(start, e.latlng);
|
|
71
|
-
const moved = startPt.distanceTo(e.containerPoint) >= 4; // ignore a click (zero-size drag)
|
|
72
|
-
band.remove();
|
|
73
|
-
band = null; start = null; startPt = null;
|
|
74
|
-
const oe = e.originalEvent;
|
|
75
|
-
if (moved) opts.onSelect!(bounds, { x: oe.clientX, y: oe.clientY });
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
map.on("mousedown", onDown);
|
|
79
|
-
map.on("mousemove", onMove);
|
|
80
|
-
map.on("mouseup", onUp);
|
|
81
|
-
|
|
82
|
-
const onWheel = (ev: WheelEvent) => {
|
|
83
|
-
if (hasMod(ev)) {
|
|
84
|
-
ev.preventDefault();
|
|
85
|
-
map.setZoomAround(map.mouseEventToLatLng(ev), map.getZoom() + (ev.deltaY < 0 ? 1 : -1));
|
|
86
|
-
} else if (selectable) {
|
|
87
|
-
ev.preventDefault(); // a full image/map view pans vertically on a plain wheel
|
|
88
|
-
map.panBy([0, ev.deltaY], { animate: false });
|
|
89
|
-
}
|
|
90
|
-
// else (chunk, plain wheel): let the event bubble so the page scrolls
|
|
91
|
-
};
|
|
92
|
-
container.addEventListener("wheel", onWheel, { passive: false });
|
|
93
|
-
|
|
94
|
-
return () => {
|
|
95
|
-
map.off("mousedown", onDown);
|
|
96
|
-
map.off("mousemove", onMove);
|
|
97
|
-
map.off("mouseup", onUp);
|
|
98
|
-
container.removeEventListener("wheel", onWheel);
|
|
99
|
-
band?.remove();
|
|
100
|
-
};
|
|
101
|
-
}
|
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
-
import { Document, Page, pdfjs } from "react-pdf";
|
|
3
|
-
import "react-pdf/dist/Page/TextLayer.css";
|
|
4
|
-
import "react-pdf/dist/Page/AnnotationLayer.css";
|
|
5
|
-
import { Annotation, NodeJson, blobUrl } from "../api";
|
|
6
|
-
import { DEFAULT_COLOR, colorOf, editable, useAnnotationMenu, useMaterialAnnotations } from "./annotate";
|
|
7
|
-
import { usePagedScroll } from "./paged";
|
|
8
|
-
|
|
9
|
-
/** A rectangular annotation region on a PDF page, in points (origin top-left). `ann` is the source
|
|
10
|
-
* annotation when real/saved (→ clickable to edit); absent for the live preview. */
|
|
11
|
-
interface PdfRegion { page: number; x: number; y: number; w: number; h: number; title?: string; color?: string; ann?: Annotation }
|
|
12
|
-
const num = (v: unknown): number => Number(v) || 0;
|
|
13
|
-
|
|
14
|
-
// pdf.js renders in a Web Worker; point it at the bundled worker (the version
|
|
15
|
-
// react-pdf depends on) resolved through Vite. Done once at module load.
|
|
16
|
-
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
|
17
|
-
"pdfjs-dist/build/pdf.worker.min.mjs",
|
|
18
|
-
import.meta.url,
|
|
19
|
-
).toString();
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Renders a `application/pdf` file with pdf.js (via react-pdf): every page laid out top-to-bottom,
|
|
23
|
-
* fit to the pane's width. A plain wheel scrolls the document and text stays selectable; ctrl/alt-
|
|
24
|
-
* wheel zooms (scales the page width), matching the image/map viewers (see the UI guide).
|
|
25
|
-
* SELECTING text on a page raises the color palette and saves a `pdf` region annotation (the
|
|
26
|
-
* selection's bounding box, in page points) — the same flow as image/map regions. The document is
|
|
27
|
-
* loaded straight from `/api/blob` so pdf.js streams the bytes itself.
|
|
28
|
-
*/
|
|
29
|
-
export function PdfView({ node }: { node: NodeJson }) {
|
|
30
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
31
|
-
const [width, setWidth] = useState(0);
|
|
32
|
-
const [pages, setPages] = useState(0);
|
|
33
|
-
const [zoom, setZoom] = useState(1); // ctrl/alt-wheel scale factor
|
|
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);
|
|
45
|
-
|
|
46
|
-
// WINDOWED RENDERING: every page keeps a wrapper (so the scroll height is right), but only
|
|
47
|
-
// pages near the viewport mount a real <Page> — mounting ALL of them queues every canvas
|
|
48
|
-
// through the pdf.js worker at open (and again on each zoom), which saturates the main
|
|
49
|
-
// thread and janks scrolling on long documents. Far pages are fixed-height placeholders.
|
|
50
|
-
const wraps = useRef(new Map<number, HTMLElement>());
|
|
51
|
-
const [near, setNear] = useState<Set<number>>(() => new Set());
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (!pages) return;
|
|
54
|
-
const obs = new IntersectionObserver(
|
|
55
|
-
(entries) => {
|
|
56
|
-
setNear((prev) => {
|
|
57
|
-
const next = new Set(prev);
|
|
58
|
-
for (const e of entries) {
|
|
59
|
-
const pn = Number((e.target as HTMLElement).dataset.page);
|
|
60
|
-
if (e.isIntersecting) next.add(pn);
|
|
61
|
-
else next.delete(pn);
|
|
62
|
-
}
|
|
63
|
-
return next.size === prev.size && [...next].every((p) => prev.has(p)) ? prev : next;
|
|
64
|
-
});
|
|
65
|
-
},
|
|
66
|
-
// root = the .filepdf scroller (the pane scrolls INSIDE it — a viewport root would
|
|
67
|
-
// never see pages clipped below its fold); pre-render ~2 screens above/below
|
|
68
|
-
{ root: ref.current, rootMargin: "2000px 0px" },
|
|
69
|
-
);
|
|
70
|
-
for (const el of wraps.current.values()) obs.observe(el);
|
|
71
|
-
return () => obs.disconnect();
|
|
72
|
-
// wrappers exist only once BOTH the page count and the pane width are known
|
|
73
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
-
}, [pages, width > 0]);
|
|
75
|
-
|
|
76
|
-
const material = useMaterialAnnotations(node.path);
|
|
77
|
-
const { openCreate, openEdit, palette, preview } = useAnnotationMenu(material);
|
|
78
|
-
// include the live PREVIEW so the rectangle stays drawn while the menu is open
|
|
79
|
-
const shown = preview
|
|
80
|
-
? [...material.annotations, { path: "(preview)", selector: preview.selector, tag: preview.tag } as Annotation]
|
|
81
|
-
: material.annotations;
|
|
82
|
-
const regions: PdfRegion[] = shown
|
|
83
|
-
.filter((a) => a.selector?.type === "pdf")
|
|
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 }));
|
|
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
|
-
|
|
107
|
-
// Track the pane width so pages re-flow on resize.
|
|
108
|
-
useLayoutEffect(() => {
|
|
109
|
-
const el = ref.current;
|
|
110
|
-
if (!el) return;
|
|
111
|
-
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
|
|
112
|
-
ro.observe(el);
|
|
113
|
-
return () => ro.disconnect();
|
|
114
|
-
}, []);
|
|
115
|
-
|
|
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.
|
|
129
|
-
useEffect(() => {
|
|
130
|
-
const el = ref.current;
|
|
131
|
-
if (!el) return;
|
|
132
|
-
let bursting = false;
|
|
133
|
-
let end = 0;
|
|
134
|
-
const onWheel = (e: WheelEvent) => {
|
|
135
|
-
if (!(e.ctrlKey || e.altKey || e.metaKey)) return;
|
|
136
|
-
e.preventDefault();
|
|
137
|
-
if (!bursting) { pagedRef.current.captureAnchor(); bursting = true; }
|
|
138
|
-
clearTimeout(end);
|
|
139
|
-
end = window.setTimeout(() => (bursting = false), 250);
|
|
140
|
-
setZoom((z) => Math.min(5, Math.max(0.4, z * (e.deltaY < 0 ? 1.1 : 1 / 1.1))));
|
|
141
|
-
};
|
|
142
|
-
el.addEventListener("wheel", onWheel, { passive: false });
|
|
143
|
-
return () => { el.removeEventListener("wheel", onWheel); clearTimeout(end); };
|
|
144
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
145
|
-
}, []);
|
|
146
|
-
|
|
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
|
-
};
|
|
213
|
-
|
|
214
|
-
// A finished text selection on a page → a `pdf` region (its bounding box, converted to points).
|
|
215
|
-
const onMouseUp = () => {
|
|
216
|
-
const sel = window.getSelection();
|
|
217
|
-
if (!sel || sel.isCollapsed || !sel.anchorNode) return;
|
|
218
|
-
const host = sel.anchorNode.nodeType === 1 ? (sel.anchorNode as Element) : sel.anchorNode.parentElement;
|
|
219
|
-
const pageEl = host?.closest(".pdf-page") as HTMLElement | null;
|
|
220
|
-
if (!pageEl || !ref.current?.contains(pageEl)) return;
|
|
221
|
-
const pn = Number(pageEl.dataset.page);
|
|
222
|
-
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // raster px per point
|
|
223
|
-
if (!sc) return;
|
|
224
|
-
const pr = pageEl.getBoundingClientRect(); // display (CSS-zoomed) px
|
|
225
|
-
const sr = sel.getRangeAt(0).getBoundingClientRect();
|
|
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
|
|
229
|
-
openCreate(
|
|
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) },
|
|
231
|
-
{ x: sr.left, y: sr.bottom + 6 },
|
|
232
|
-
);
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
return (
|
|
236
|
-
<>
|
|
237
|
-
<div className="filepdf yo-zoomable" ref={ref} tabIndex={0} onMouseUp={onMouseUp}>
|
|
238
|
-
<Document
|
|
239
|
-
file={blobUrl(node.path)}
|
|
240
|
-
onLoadSuccess={({ numPages }) => setPages(numPages)}
|
|
241
|
-
loading={<div className="loading">loading PDF…</div>}
|
|
242
|
-
error={<div className="error">could not load PDF</div>}
|
|
243
|
-
>
|
|
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) => {
|
|
248
|
-
const pn = i + 1;
|
|
249
|
-
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // RASTER px per point (regions render inside the CSS-zoomed content)
|
|
250
|
-
// a far page's placeholder: its measured aspect when known, A4 portrait until then
|
|
251
|
-
const estHeight = pageWidth * (orig[pn] ? orig[pn].h / orig[pn].w : Math.SQRT2);
|
|
252
|
-
return (
|
|
253
|
-
<div
|
|
254
|
-
key={i}
|
|
255
|
-
className="pdf-page"
|
|
256
|
-
data-page={pn}
|
|
257
|
-
ref={(el) => {
|
|
258
|
-
if (el) wraps.current.set(pn, el);
|
|
259
|
-
else wraps.current.delete(pn);
|
|
260
|
-
}}
|
|
261
|
-
>
|
|
262
|
-
{near.has(pn) ? (
|
|
263
|
-
<>
|
|
264
|
-
<Page
|
|
265
|
-
pageNumber={pn}
|
|
266
|
-
width={pageWidth}
|
|
267
|
-
onLoadSuccess={(p) => setOrig((o) => (o[pn] ? o : { ...o, [pn]: { w: p.originalWidth || pageWidth, h: p.originalHeight || pageWidth * Math.SQRT2 } }))}
|
|
268
|
-
onRenderTextLayerSuccess={() => judgeTextLayer(pn)}
|
|
269
|
-
loading={<div className="loading" style={{ height: estHeight }}>page {pn}…</div>}
|
|
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
|
-
)}
|
|
288
|
-
{sc > 0 &&
|
|
289
|
-
regions.filter((r) => r.page === pn).map((r, j) => {
|
|
290
|
-
const c = r.color || DEFAULT_COLOR;
|
|
291
|
-
return (
|
|
292
|
-
<div
|
|
293
|
-
key={j}
|
|
294
|
-
className={"pdf-region" + (r.ann ? " editable" : "")}
|
|
295
|
-
title={r.ann ? r.title || "click to recolor or delete" : r.title}
|
|
296
|
-
onClick={r.ann ? (e) => { e.stopPropagation(); openEdit(r.ann!, { x: e.clientX, y: e.clientY }); } : undefined}
|
|
297
|
-
style={{ left: r.x * sc, top: r.y * sc, width: r.w * sc, height: r.h * sc, borderColor: c, background: c + "2e" }}
|
|
298
|
-
/>
|
|
299
|
-
);
|
|
300
|
-
})}
|
|
301
|
-
</>
|
|
302
|
-
) : (
|
|
303
|
-
<div className="pdf-placeholder" style={{ width: pageWidth, height: estHeight }} />
|
|
304
|
-
)}
|
|
305
|
-
</div>
|
|
306
|
-
);
|
|
307
|
-
})}
|
|
308
|
-
</div>
|
|
309
|
-
</div>
|
|
310
|
-
)}
|
|
311
|
-
</Document>
|
|
312
|
-
</div>
|
|
313
|
-
{palette}
|
|
314
|
-
</>
|
|
315
|
-
);
|
|
316
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from "react";
|
|
2
|
-
import { NodeJson, blobUrl } from "../api";
|
|
3
|
-
import { Chunk } from "./registry";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The renderer for a `binary`/`text/plain` (`.txt`/`.text`/`.log`) node: the file's
|
|
7
|
-
* bytes shown verbatim in a `<pre>` — NO markup interpretation. (A bare `.txt`
|
|
8
|
-
* otherwise falls to the marklower renderer, which both processes `*…*`/`_…_`-style
|
|
9
|
-
* formatting sequences and re-flows the text, both misleading for plain text.)
|
|
10
|
-
*
|
|
11
|
-
* Because the server serves `text/plain` as raw bytes (it is deliberately kept out
|
|
12
|
-
* of the server's TEXT_FORMATS), the **encoding is chosen on the client** — legacy
|
|
13
|
-
* Cyrillic files are commonly CP866 / Windows-1251 / KOI8-R, not UTF-8. The choice
|
|
14
|
-
* rides in the URL as `?enc=`, alongside `?format=`, so a particular reading is a
|
|
15
|
-
* shareable link; the {@link EncodingControl} `config` control in the node bar
|
|
16
|
-
* writes it (like the CSV controls / markdown width input). Bytes are fetched once
|
|
17
|
-
* per path and re-decoded in place when the encoding changes — no refetch.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
const params = () => new URLSearchParams(window.location.search);
|
|
21
|
-
|
|
22
|
-
/** Selectable encodings — label shown in the bar → the `TextDecoder` label. All four
|
|
23
|
-
* are part of the WHATWG Encoding standard, so `TextDecoder` decodes them natively. */
|
|
24
|
-
export const ENCODINGS: { label: string; value: string }[] = [
|
|
25
|
-
{ label: "UTF-8", value: "utf-8" },
|
|
26
|
-
{ label: "Windows-1251", value: "windows-1251" },
|
|
27
|
-
{ label: "CP866", value: "ibm866" },
|
|
28
|
-
{ label: "KOI8-R", value: "koi8-r" },
|
|
29
|
-
];
|
|
30
|
-
const DEFAULT_ENCODING = "utf-8";
|
|
31
|
-
const isEncoding = (v: string) => ENCODINGS.some((e) => e.value === v);
|
|
32
|
-
|
|
33
|
-
/** The encoding from the URL's `?enc=`, or UTF-8 (an unknown value is ignored). */
|
|
34
|
-
export function textEncoding(): string {
|
|
35
|
-
const e = params().get("enc") ?? "";
|
|
36
|
-
return isEncoding(e) ? e : DEFAULT_ENCODING;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Decode bytes under `encoding`, falling back to UTF-8 if the label is unsupported. */
|
|
40
|
-
function decode(bytes: Uint8Array, encoding: string): string {
|
|
41
|
-
try {
|
|
42
|
-
return new TextDecoder(encoding).decode(bytes);
|
|
43
|
-
} catch {
|
|
44
|
-
return new TextDecoder("utf-8").decode(bytes);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Fetch a file's raw bytes once per `path` (decoding happens separately, so changing
|
|
49
|
-
* the encoding does not refetch). */
|
|
50
|
-
function useBytes(path: string): { bytes: Uint8Array | null; error: string | null } {
|
|
51
|
-
const [bytes, setBytes] = useState<Uint8Array | null>(null);
|
|
52
|
-
const [error, setError] = useState<string | null>(null);
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
let cancelled = false;
|
|
55
|
-
setBytes(null);
|
|
56
|
-
setError(null);
|
|
57
|
-
fetch(blobUrl(path))
|
|
58
|
-
.then((r) => r.arrayBuffer())
|
|
59
|
-
.then((buf) => !cancelled && setBytes(new Uint8Array(buf)))
|
|
60
|
-
.catch((e) => !cancelled && setError(String((e as Error).message || e)));
|
|
61
|
-
return () => {
|
|
62
|
-
cancelled = true;
|
|
63
|
-
};
|
|
64
|
-
}, [path]);
|
|
65
|
-
return { bytes, error };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function PlaintextView({ node }: { node: NodeJson }) {
|
|
69
|
-
const { bytes, error } = useBytes(node.path);
|
|
70
|
-
const encoding = textEncoding();
|
|
71
|
-
const text = useMemo(() => (bytes ? decode(bytes, encoding) : null), [bytes, encoding]);
|
|
72
|
-
if (error) return <div className="error">text: {error}</div>;
|
|
73
|
-
if (text == null) return <div className="loading">reading…</div>;
|
|
74
|
-
return (
|
|
75
|
-
<div className="text">
|
|
76
|
-
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
77
|
-
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
78
|
-
<pre className="plaintext">{text}</pre>
|
|
79
|
-
</div>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** A plain-text chunk embedded inline in a chapter: just the verbatim text, decoded
|
|
84
|
-
* as UTF-8 (no per-chunk URL controls, like the CSV chunk). */
|
|
85
|
-
export function PlaintextChunk({ chunk }: { chunk: Chunk }) {
|
|
86
|
-
const { bytes, error } = useBytes(chunk.path);
|
|
87
|
-
const text = useMemo(() => (bytes ? decode(bytes, DEFAULT_ENCODING) : null), [bytes]);
|
|
88
|
-
if (error) return <div className="error">text: {error}</div>;
|
|
89
|
-
if (text == null) return <div className="loading">reading…</div>;
|
|
90
|
-
return <pre className="plaintext">{text}</pre>;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* The encoding selector shown in the node bar beside the plaintext tab — the
|
|
95
|
-
* `config` hook. Writes `?enc=` (preserving the path + other params) and calls
|
|
96
|
-
* `rerender` so {@link PlaintextView} re-decodes the already-fetched bytes.
|
|
97
|
-
*/
|
|
98
|
-
export function EncodingControl({ rerender }: { rerender: () => void }) {
|
|
99
|
-
const enc = textEncoding();
|
|
100
|
-
const setEnc = (value: string) => {
|
|
101
|
-
const q = params();
|
|
102
|
-
if (value && value !== DEFAULT_ENCODING) q.set("enc", value);
|
|
103
|
-
else q.delete("enc");
|
|
104
|
-
const qs = q.toString();
|
|
105
|
-
window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : ""));
|
|
106
|
-
rerender();
|
|
107
|
-
};
|
|
108
|
-
return (
|
|
109
|
-
<label className="enc-control">
|
|
110
|
-
encoding{" "}
|
|
111
|
-
<select value={enc} onChange={(e) => setEnc(e.target.value)}>
|
|
112
|
-
{ENCODINGS.map((o) => (
|
|
113
|
-
<option key={o.value} value={o.value}>
|
|
114
|
-
{o.label}
|
|
115
|
-
</option>
|
|
116
|
-
))}
|
|
117
|
-
</select>
|
|
118
|
-
</label>
|
|
119
|
-
);
|
|
120
|
-
}
|