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,170 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import L from "leaflet";
|
|
3
|
-
import "leaflet/dist/leaflet.css";
|
|
4
|
-
import { NodeJson, blobUrl } from "../api";
|
|
5
|
-
import { Chunk } from "./registry";
|
|
6
|
-
import { Annotation } from "../api";
|
|
7
|
-
import { DEFAULT_COLOR, colorOf, editable, useAnnotationMenu, useMaterialAnnotations } from "./annotate";
|
|
8
|
-
import { wireGestures } from "./panzoom";
|
|
9
|
-
|
|
10
|
-
/** A rectangular annotation region in the image's own pixel space (origin top-left). `ann` is the
|
|
11
|
-
* source annotation when it is a real saved one (→ clickable to edit); absent for the live preview. */
|
|
12
|
-
export interface ImageRegion { x: number; y: number; w: number; h: number; title?: string; color?: string; ann?: Annotation }
|
|
13
|
-
|
|
14
|
-
const num = (v: unknown): number => Number(v) || 0;
|
|
15
|
-
|
|
16
|
-
/** A PNG data-URL crop of the natural-pixel region (x,y,w,h) of `img`, for an image-like
|
|
17
|
-
* fragment's embedded preview; undefined if the region is empty or the canvas reads back tainted
|
|
18
|
-
* (cross-origin — image blobs are same-origin, so this is just a guard). */
|
|
19
|
-
function cropPng(img: HTMLImageElement | null, x: number, y: number, w: number, h: number): string | undefined {
|
|
20
|
-
if (!img || w <= 0 || h <= 0) return undefined;
|
|
21
|
-
const cv = document.createElement("canvas");
|
|
22
|
-
cv.width = w; cv.height = h;
|
|
23
|
-
const ctx = cv.getContext("2d");
|
|
24
|
-
if (!ctx) return undefined;
|
|
25
|
-
ctx.drawImage(img, x, y, w, h, 0, 0, w, h);
|
|
26
|
-
try { return cv.toDataURL("image/png"); } catch { return undefined; }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** The `rect`-type annotations, as pixel regions to overlay on the image. */
|
|
30
|
-
function imageRegions(anns: Annotation[]): ImageRegion[] {
|
|
31
|
-
return anns
|
|
32
|
-
.filter((a) => a.selector?.type === "rect")
|
|
33
|
-
.map((a) => ({ 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 }));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Pan/zoom image viewer — the same widget the KML map uses, over a flat picture. The image is an
|
|
38
|
-
* `imageOverlay` on a `CRS.Simple` map sized to its natural pixels; the view fits it initially.
|
|
39
|
-
* Gestures follow the unified model (see {@link wireGestures} / the UI guide): plain drag selects a
|
|
40
|
-
* region (when `onSelectRegion` is set), ctrl/alt-drag pans, plain wheel pans vertically, ctrl/alt-
|
|
41
|
-
* wheel zooms. The map is built once (per `src`); regions redraw in place without resetting the view.
|
|
42
|
-
*/
|
|
43
|
-
export function PanZoomImage({
|
|
44
|
-
src, className, regions, onSelectRegion, onRegionClick, selectColor,
|
|
45
|
-
}: {
|
|
46
|
-
src: string;
|
|
47
|
-
className: string;
|
|
48
|
-
regions?: ImageRegion[];
|
|
49
|
-
onSelectRegion?: (selector: Record<string, unknown>, screen: { x: number; y: number }, imageBase64?: string) => void;
|
|
50
|
-
onRegionClick?: (ann: Annotation, screen: { x: number; y: number }) => void;
|
|
51
|
-
selectColor?: () => string;
|
|
52
|
-
}) {
|
|
53
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
54
|
-
const mapRef = useRef<L.Map | null>(null);
|
|
55
|
-
const layerRef = useRef<L.LayerGroup | null>(null);
|
|
56
|
-
const imgElRef = useRef<HTMLImageElement | null>(null);
|
|
57
|
-
const sizeRef = useRef({ w: 1, h: 1 });
|
|
58
|
-
const onSelectRef = useRef(onSelectRegion);
|
|
59
|
-
const onRegionClickRef = useRef(onRegionClick);
|
|
60
|
-
const colorRef = useRef(selectColor);
|
|
61
|
-
const [error, setError] = useState<string | null>(null);
|
|
62
|
-
const [ready, setReady] = useState(0); // bumps once the map (and its overlay layer) exist
|
|
63
|
-
const regionsKey = JSON.stringify(regions ?? []);
|
|
64
|
-
const selectable = !!onSelectRegion; // fixed per instance: full view annotates, chunk doesn't
|
|
65
|
-
|
|
66
|
-
useEffect(() => { onSelectRef.current = onSelectRegion; onRegionClickRef.current = onRegionClick; colorRef.current = selectColor; });
|
|
67
|
-
|
|
68
|
-
// Build the map once per src; gestures + the overlay layer live with it.
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
let cancelled = false;
|
|
71
|
-
let dispose: (() => void) | null = null;
|
|
72
|
-
setError(null);
|
|
73
|
-
const img = new Image();
|
|
74
|
-
img.onload = () => {
|
|
75
|
-
if (cancelled || !ref.current) return;
|
|
76
|
-
const w = img.naturalWidth || 1;
|
|
77
|
-
const h = img.naturalHeight || 1;
|
|
78
|
-
sizeRef.current = { w, h };
|
|
79
|
-
imgElRef.current = img; // kept for cropping a selected region (same-origin → un-tainted canvas)
|
|
80
|
-
// CRS.Simple: coordinates are raw pixels (y, x); negative minZoom allows zooming far out.
|
|
81
|
-
const map = L.map(ref.current, { crs: L.CRS.Simple, minZoom: -8, attributionControl: false, zoomSnap: 0 });
|
|
82
|
-
const bounds: L.LatLngBoundsExpression = [[0, 0], [h, w]];
|
|
83
|
-
L.imageOverlay(src, bounds).addTo(map);
|
|
84
|
-
map.fitBounds(bounds); // initial view frames the whole image
|
|
85
|
-
layerRef.current = L.layerGroup().addTo(map);
|
|
86
|
-
mapRef.current = map;
|
|
87
|
-
dispose = wireGestures(map, {
|
|
88
|
-
color: () => colorRef.current?.() ?? DEFAULT_COLOR,
|
|
89
|
-
onSelect: selectable
|
|
90
|
-
? (b, screen) => {
|
|
91
|
-
// image pixels have y from the top; CRS.Simple lat is from the bottom → flip.
|
|
92
|
-
const { h: ih } = sizeRef.current;
|
|
93
|
-
const west = b.getWest(), east = b.getEast(), south = b.getSouth(), north = b.getNorth();
|
|
94
|
-
const x = Math.round(west), y = Math.round(ih - north), w = Math.round(east - west), hh = Math.round(north - south);
|
|
95
|
-
onSelectRef.current?.({ type: "rect", x, y, w, h: hh }, screen, cropPng(imgElRef.current, x, y, w, hh));
|
|
96
|
-
}
|
|
97
|
-
: undefined,
|
|
98
|
-
});
|
|
99
|
-
setReady((r) => r + 1);
|
|
100
|
-
};
|
|
101
|
-
img.onerror = () => { if (!cancelled) setError("could not load image"); };
|
|
102
|
-
img.src = src;
|
|
103
|
-
return () => {
|
|
104
|
-
cancelled = true;
|
|
105
|
-
dispose?.();
|
|
106
|
-
mapRef.current?.remove();
|
|
107
|
-
mapRef.current = null;
|
|
108
|
-
layerRef.current = null;
|
|
109
|
-
};
|
|
110
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
-
}, [src]);
|
|
112
|
-
|
|
113
|
-
// Draw the region rectangles into the overlay layer — in place, so creating one keeps the view.
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
const lg = layerRef.current;
|
|
116
|
-
if (!lg) return;
|
|
117
|
-
lg.clearLayers();
|
|
118
|
-
const { h } = sizeRef.current;
|
|
119
|
-
for (const r of regions ?? []) {
|
|
120
|
-
const c = r.color || DEFAULT_COLOR;
|
|
121
|
-
// image y is from the top; CRS.Simple lat from the bottom, so flip y
|
|
122
|
-
const rect = L.rectangle([[h - r.y, r.x], [h - (r.y + r.h), r.x + r.w]], {
|
|
123
|
-
className: "yo-region", color: c, weight: 3, fillColor: c, fillOpacity: 0.25,
|
|
124
|
-
});
|
|
125
|
-
if (r.title) rect.bindTooltip(r.title);
|
|
126
|
-
if (r.ann) {
|
|
127
|
-
const ann = r.ann;
|
|
128
|
-
rect.on("click", (ev) => { L.DomEvent.stop(ev); onRegionClickRef.current?.(ann, { x: ev.originalEvent.clientX, y: ev.originalEvent.clientY }); });
|
|
129
|
-
}
|
|
130
|
-
rect.addTo(lg);
|
|
131
|
-
}
|
|
132
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
133
|
-
}, [regionsKey, ready]);
|
|
134
|
-
|
|
135
|
-
return (
|
|
136
|
-
<>
|
|
137
|
-
{error && <div className="error">image: {error}</div>}
|
|
138
|
-
<div ref={ref} className={className + (selectable ? " yo-selectable" : "")} />
|
|
139
|
-
</>
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function ImageView({ node }: { node: NodeJson }) {
|
|
144
|
-
const material = useMaterialAnnotations(node.path);
|
|
145
|
-
const { openCreate, openEdit, palette, preview, color } = useAnnotationMenu(material);
|
|
146
|
-
// include the live PREVIEW selector so the rectangle stays drawn while the menu is open
|
|
147
|
-
const shown = preview
|
|
148
|
-
? [...material.annotations, { path: "(preview)", selector: preview.selector, tag: preview.tag } as Annotation]
|
|
149
|
-
: material.annotations;
|
|
150
|
-
return (
|
|
151
|
-
<div className="text">
|
|
152
|
-
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
153
|
-
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
154
|
-
<PanZoomImage
|
|
155
|
-
src={blobUrl(node.path)}
|
|
156
|
-
regions={imageRegions(shown)}
|
|
157
|
-
onSelectRegion={(sel, screen, crop) => openCreate(sel, screen, undefined, crop)}
|
|
158
|
-
onRegionClick={openEdit}
|
|
159
|
-
selectColor={() => color}
|
|
160
|
-
className="filemap fileimagemap"
|
|
161
|
-
/>
|
|
162
|
-
{palette}
|
|
163
|
-
</div>
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** An image embedded inline in a chapter — pan/zoom only (no annotation target → plain drag pans). */
|
|
168
|
-
export function ImageChunk({ chunk }: { chunk: Chunk }) {
|
|
169
|
-
return <PanZoomImage src={blobUrl(chunk.path)} className="filemap chunk-map fileimagemap" />;
|
|
170
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { kml as kmlDomToGeoJSON } from "@tmcw/togeojson";
|
|
2
|
-
import { unzipSync, strFromU8 } from "fflate";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The pure (Leaflet-free) half of the map renderer: turn the bytes of a `.kml` or
|
|
6
|
-
* `.kmz` file into GeoJSON. Kept apart from `map.tsx` so it can be unit-tested
|
|
7
|
-
* without importing Leaflet (a browser-only library with CSS side effects).
|
|
8
|
-
*
|
|
9
|
-
* - **KML** is XML — decoded as text, parsed to a DOM, then converted with
|
|
10
|
-
* `@tmcw/togeojson`.
|
|
11
|
-
* - **KMZ** is a ZIP — unzipped (with the already-bundled `fflate`); its first
|
|
12
|
-
* `.kml` entry is the document, then the same KML path.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/** A GeoJSON FeatureCollection (the shape togeojson returns). Loosely typed — the
|
|
16
|
-
* map renderer only hands it to Leaflet's `L.geoJSON`. */
|
|
17
|
-
export interface GeoJSON {
|
|
18
|
-
type: "FeatureCollection";
|
|
19
|
-
features: unknown[];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Whether `bytes` begin with the ZIP local-file signature `PK\x03\x04` — i.e. a
|
|
23
|
-
* KMZ rather than a bare KML. Robust regardless of the file's extension. */
|
|
24
|
-
export function isZip(bytes: Uint8Array): boolean {
|
|
25
|
-
return bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Parse a KML XML string into GeoJSON via a DOM (available in the browser and in
|
|
29
|
-
* jsdom tests). */
|
|
30
|
-
export function kmlStringToGeoJSON(xml: string): GeoJSON {
|
|
31
|
-
const dom = new DOMParser().parseFromString(xml, "application/xml");
|
|
32
|
-
if (dom.getElementsByTagName("parsererror").length) throw new Error("malformed KML XML");
|
|
33
|
-
return kmlDomToGeoJSON(dom) as unknown as GeoJSON;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Parse `.kml`/`.kmz` bytes into GeoJSON. KMZ is detected by its ZIP signature
|
|
37
|
-
* (not just the extension); the first `.kml` entry inside is the document. */
|
|
38
|
-
export function bytesToGeoJSON(bytes: Uint8Array): GeoJSON {
|
|
39
|
-
if (isZip(bytes)) {
|
|
40
|
-
const files = unzipSync(bytes);
|
|
41
|
-
const name = Object.keys(files).find((n) => n.toLowerCase().endsWith(".kml"));
|
|
42
|
-
if (!name) throw new Error("no .kml document inside the KMZ archive");
|
|
43
|
-
return kmlStringToGeoJSON(strFromU8(files[name]));
|
|
44
|
-
}
|
|
45
|
-
return kmlStringToGeoJSON(new TextDecoder().decode(bytes));
|
|
46
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import katex from "katex";
|
|
2
|
-
import "katex/dist/katex.min.css";
|
|
3
|
-
import { NodeJson } from "../api";
|
|
4
|
-
import { scalarValue } from "../render";
|
|
5
|
-
import { Chunk } from "./registry";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The renderer for a `string`/`text/x-latex` node: LaTeX math typeset with KaTeX.
|
|
9
|
-
* Like the markdown/asciidoc renderers it works straight from the node's string
|
|
10
|
-
* value, so it serves both a whole formula node (`render`) and a single inline
|
|
11
|
-
* chunk (`renderChunk`).
|
|
12
|
-
*
|
|
13
|
-
* `renderMath` is the one place KaTeX is invoked, exported so **marklower** can
|
|
14
|
-
* reuse it for its inline `$$…$$` spans — math is rendered the same way whether it
|
|
15
|
-
* is a standalone `text/x-latex` string or embedded in marklower prose.
|
|
16
|
-
*/
|
|
17
|
-
export function renderMath(tex: unknown, displayMode: boolean): string {
|
|
18
|
-
// `throwOnError: false` makes KaTeX emit the offending source in red rather than
|
|
19
|
-
// throwing, so a typo in one formula never blanks the whole page.
|
|
20
|
-
return katex.renderToString(String(tex ?? ""), { displayMode, throwOnError: false });
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function LatexView({ node }: { node: NodeJson }) {
|
|
24
|
-
return (
|
|
25
|
-
<div className="text">
|
|
26
|
-
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
27
|
-
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
28
|
-
<div className="markup" dangerouslySetInnerHTML={{ __html: renderMath(scalarValue(node.value), true) }} />
|
|
29
|
-
</div>
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** A LaTeX chunk embedded inline in a chapter: the formula typeset as a display
|
|
34
|
-
* block (the chapter supplies the surrounding number + anchor). */
|
|
35
|
-
export function LatexChunk({ chunk }: { chunk: Chunk }) {
|
|
36
|
-
return <div className="markup" dangerouslySetInnerHTML={{ __html: renderMath(chunk.value, true) }} />;
|
|
37
|
-
}
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import L from "leaflet";
|
|
3
|
-
import "leaflet/dist/leaflet.css";
|
|
4
|
-
import { NodeJson, blobUrl } from "../api";
|
|
5
|
-
import { Chunk } from "./registry";
|
|
6
|
-
import { bytesToGeoJSON, GeoJSON } from "./kml";
|
|
7
|
-
import { Annotation } from "../api";
|
|
8
|
-
import { DEFAULT_COLOR, colorOf, editable, useAnnotationMenu, useMaterialAnnotations } from "./annotate";
|
|
9
|
-
import { wireGestures } from "./panzoom";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Renderer for geographic overlays — `.kml` and `.kmz` (zipped KML). The file is
|
|
13
|
-
* served as bytes; we convert it to GeoJSON (see `kml.ts`) and draw it on a
|
|
14
|
-
* Leaflet slippy map over OpenStreetMap tiles, fitting the view to the data.
|
|
15
|
-
* Points become circle markers, lines/polygons keep their KML colours, and a
|
|
16
|
-
* feature's name/description show in a popup.
|
|
17
|
-
*
|
|
18
|
-
* Gestures follow the unified model (see {@link wireGestures} / the UI guide): plain drag selects a
|
|
19
|
-
* geographic region to annotate, ctrl/alt-drag pans, plain wheel pans vertically, ctrl/alt-wheel
|
|
20
|
-
* zooms.
|
|
21
|
-
*
|
|
22
|
-
* **Network note:** the vector overlay and KML/KMZ parsing are fully local, but the
|
|
23
|
-
* *map tiles* are fetched from a tile server — by default OpenStreetMap. Point
|
|
24
|
-
* `VITE_MAP_TILE_URL` (and optionally `VITE_MAP_TILE_ATTRIBUTION`) at a self-hosted
|
|
25
|
-
* tile server to keep map traffic off the public one. Leaflet is heavy and
|
|
26
|
-
* browser-only, so the registry loads this module lazily.
|
|
27
|
-
*/
|
|
28
|
-
const TILE_URL =
|
|
29
|
-
((import.meta as any).env?.VITE_MAP_TILE_URL as string | undefined) ??
|
|
30
|
-
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
|
31
|
-
const TILE_ATTRIBUTION =
|
|
32
|
-
((import.meta as any).env?.VITE_MAP_TILE_ATTRIBUTION as string | undefined) ??
|
|
33
|
-
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
34
|
-
|
|
35
|
-
/** A rectangular annotation region on the map, in geographic edges (degrees). `ann` is the source
|
|
36
|
-
* annotation when it is a real saved one (→ clickable to edit); absent for the live preview. */
|
|
37
|
-
interface MapRegion { n: number; s: number; e: number; w: number; title?: string; color?: string; ann?: Annotation }
|
|
38
|
-
const num = (v: unknown): number => Number(v) || 0;
|
|
39
|
-
|
|
40
|
-
/** The `map`-type annotations, as geographic rectangles to overlay. */
|
|
41
|
-
function mapRegions(anns: Annotation[]): MapRegion[] {
|
|
42
|
-
return anns
|
|
43
|
-
.filter((a) => a.selector?.type === "map")
|
|
44
|
-
.map((a) => ({ n: num(a.selector!.n), s: num(a.selector!.s), e: num(a.selector!.e), w: num(a.selector!.w), title: a.description, color: colorOf(a), ann: editable(a) ? a : undefined }));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function escapeHtml(s: string): string {
|
|
48
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** A feature's description as popup HTML. togeojson returns a plain-text KML
|
|
52
|
-
* description as a string (escaped here) and an HTML/CDATA one as
|
|
53
|
-
* `{ "@type": "html", value }` — authored markup, kept as-is (this is a local
|
|
54
|
-
* viewer of the user's own files). */
|
|
55
|
-
function descriptionHtml(description: unknown): string {
|
|
56
|
-
if (!description) return "";
|
|
57
|
-
if (typeof description === "string") return escapeHtml(description);
|
|
58
|
-
if (typeof description === "object" && (description as any)["@type"] === "html") {
|
|
59
|
-
return String((description as any).value ?? "");
|
|
60
|
-
}
|
|
61
|
-
return "";
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Draw `geo` onto a fresh Leaflet map in `el`, fit to the data, and return the map
|
|
65
|
-
* (so the caller can dispose it). */
|
|
66
|
-
function drawMap(el: HTMLElement, geo: GeoJSON): L.Map {
|
|
67
|
-
const map = L.map(el);
|
|
68
|
-
L.tileLayer(TILE_URL, { maxZoom: 19, attribution: TILE_ATTRIBUTION }).addTo(map);
|
|
69
|
-
const layer = L.geoJSON(geo as any, {
|
|
70
|
-
// honour KML styling that togeojson surfaces as simplestyle properties
|
|
71
|
-
style: (f) => {
|
|
72
|
-
const p = (f?.properties ?? {}) as Record<string, unknown>;
|
|
73
|
-
return {
|
|
74
|
-
color: (p["stroke"] as string) || "#3388ff",
|
|
75
|
-
weight: (p["stroke-width"] as number) || 2,
|
|
76
|
-
opacity: (p["stroke-opacity"] as number) ?? 1,
|
|
77
|
-
fillColor: (p["fill"] as string) || "#3388ff",
|
|
78
|
-
fillOpacity: (p["fill-opacity"] as number) ?? 0.2,
|
|
79
|
-
};
|
|
80
|
-
},
|
|
81
|
-
pointToLayer: (_f, latlng) => L.circleMarker(latlng, { radius: 5, color: "#e23", weight: 2, fillOpacity: 0.8 }),
|
|
82
|
-
onEachFeature: (f, lyr) => {
|
|
83
|
-
const p = (f.properties ?? {}) as { name?: string; description?: unknown };
|
|
84
|
-
const name = p.name ? `<strong>${escapeHtml(p.name)}</strong>` : "";
|
|
85
|
-
const body = descriptionHtml(p.description);
|
|
86
|
-
const desc = body ? `<div class="map-popup-desc">${body}</div>` : "";
|
|
87
|
-
if (name || desc) lyr.bindPopup(`${name}${desc}`);
|
|
88
|
-
},
|
|
89
|
-
}).addTo(map);
|
|
90
|
-
|
|
91
|
-
const bounds = layer.getBounds();
|
|
92
|
-
if (bounds.isValid()) map.fitBounds(bounds, { padding: [24, 24], maxZoom: 16 });
|
|
93
|
-
else map.setView([0, 0], 2); // nothing geocoded — show the whole world
|
|
94
|
-
return map;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Fetch the file, convert to GeoJSON, and render a Leaflet map into `className`.
|
|
98
|
-
* Shared by the full page and the inline chunk (which only differ in height/CSS + annotation). */
|
|
99
|
-
function MapBody({
|
|
100
|
-
path, className, regions, onSelectRegion, onRegionClick, selectColor,
|
|
101
|
-
}: {
|
|
102
|
-
path: string;
|
|
103
|
-
className: string;
|
|
104
|
-
regions?: MapRegion[];
|
|
105
|
-
onSelectRegion?: (selector: Record<string, unknown>, screen: { x: number; y: number }) => void;
|
|
106
|
-
onRegionClick?: (ann: Annotation, screen: { x: number; y: number }) => void;
|
|
107
|
-
selectColor?: () => string;
|
|
108
|
-
}) {
|
|
109
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
110
|
-
const mapRef = useRef<L.Map | null>(null);
|
|
111
|
-
const layerRef = useRef<L.LayerGroup | null>(null);
|
|
112
|
-
const onSelectRef = useRef(onSelectRegion);
|
|
113
|
-
const onRegionClickRef = useRef(onRegionClick);
|
|
114
|
-
const colorRef = useRef(selectColor);
|
|
115
|
-
const [error, setError] = useState<string | null>(null);
|
|
116
|
-
const [loading, setLoading] = useState(true);
|
|
117
|
-
const [ready, setReady] = useState(0);
|
|
118
|
-
const regionsKey = JSON.stringify(regions ?? []);
|
|
119
|
-
const selectable = !!onSelectRegion;
|
|
120
|
-
|
|
121
|
-
useEffect(() => { onSelectRef.current = onSelectRegion; onRegionClickRef.current = onRegionClick; colorRef.current = selectColor; });
|
|
122
|
-
|
|
123
|
-
useEffect(() => {
|
|
124
|
-
let cancelled = false;
|
|
125
|
-
let dispose: (() => void) | null = null;
|
|
126
|
-
setError(null);
|
|
127
|
-
setLoading(true);
|
|
128
|
-
fetch(blobUrl(path))
|
|
129
|
-
.then((r) => r.arrayBuffer())
|
|
130
|
-
.then((buf) => {
|
|
131
|
-
if (cancelled || !ref.current) return;
|
|
132
|
-
const map = drawMap(ref.current, bytesToGeoJSON(new Uint8Array(buf)));
|
|
133
|
-
layerRef.current = L.layerGroup().addTo(map);
|
|
134
|
-
mapRef.current = map;
|
|
135
|
-
dispose = wireGestures(map, {
|
|
136
|
-
color: () => colorRef.current?.() ?? DEFAULT_COLOR,
|
|
137
|
-
onSelect: selectable
|
|
138
|
-
? (b, screen) => onSelectRef.current?.({ type: "map", n: b.getNorth(), s: b.getSouth(), e: b.getEast(), w: b.getWest() }, screen)
|
|
139
|
-
: undefined,
|
|
140
|
-
});
|
|
141
|
-
setReady((x) => x + 1);
|
|
142
|
-
setLoading(false);
|
|
143
|
-
})
|
|
144
|
-
.catch((e) => {
|
|
145
|
-
if (!cancelled) {
|
|
146
|
-
setError(String((e as Error).message || e));
|
|
147
|
-
setLoading(false);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
return () => {
|
|
151
|
-
cancelled = true;
|
|
152
|
-
dispose?.();
|
|
153
|
-
mapRef.current?.remove();
|
|
154
|
-
mapRef.current = null;
|
|
155
|
-
layerRef.current = null;
|
|
156
|
-
};
|
|
157
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
-
}, [path]);
|
|
159
|
-
|
|
160
|
-
// Draw region rectangles into the overlay layer — in place, so creating one keeps the view.
|
|
161
|
-
useEffect(() => {
|
|
162
|
-
const lg = layerRef.current;
|
|
163
|
-
if (!lg) return;
|
|
164
|
-
lg.clearLayers();
|
|
165
|
-
for (const r of regions ?? []) {
|
|
166
|
-
const c = r.color || DEFAULT_COLOR;
|
|
167
|
-
const rect = L.rectangle([[r.s, r.w], [r.n, r.e]], { className: "yo-region", color: c, weight: 3, fillColor: c, fillOpacity: 0.25 });
|
|
168
|
-
if (r.title) rect.bindTooltip(r.title);
|
|
169
|
-
if (r.ann) {
|
|
170
|
-
const ann = r.ann;
|
|
171
|
-
rect.on("click", (ev) => { L.DomEvent.stop(ev); onRegionClickRef.current?.(ann, { x: ev.originalEvent.clientX, y: ev.originalEvent.clientY }); });
|
|
172
|
-
}
|
|
173
|
-
rect.addTo(lg);
|
|
174
|
-
}
|
|
175
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
-
}, [regionsKey, ready]);
|
|
177
|
-
|
|
178
|
-
return (
|
|
179
|
-
<>
|
|
180
|
-
{error && <div className="error">map: {error}</div>}
|
|
181
|
-
<div ref={ref} className={className + (selectable ? " yo-selectable" : "")} />
|
|
182
|
-
{loading && !error && <div className="loading">loading map…</div>}
|
|
183
|
-
</>
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export function MapView({ node }: { node: NodeJson }) {
|
|
188
|
-
const material = useMaterialAnnotations(node.path);
|
|
189
|
-
const { openCreate, openEdit, palette, preview, color } = useAnnotationMenu(material);
|
|
190
|
-
const shown = preview
|
|
191
|
-
? [...material.annotations, { path: "(preview)", selector: preview.selector, tag: preview.tag } as Annotation]
|
|
192
|
-
: material.annotations;
|
|
193
|
-
return (
|
|
194
|
-
<div className="text">
|
|
195
|
-
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
196
|
-
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
197
|
-
<MapBody path={node.path} regions={mapRegions(shown)} onSelectRegion={openCreate} onRegionClick={openEdit} selectColor={() => color} className="filemap" />
|
|
198
|
-
{palette}
|
|
199
|
-
</div>
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function MapChunk({ chunk }: { chunk: Chunk }) {
|
|
204
|
-
return <MapBody path={chunk.path} className="filemap chunk-map" />;
|
|
205
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { ReactNode } from "react";
|
|
2
|
-
import { NodeJson } from "../api";
|
|
3
|
-
import { scalarValue } from "../render";
|
|
4
|
-
import { Chunk } from "./registry";
|
|
5
|
-
import { renderMath } from "./latex";
|
|
6
|
-
import { NavLink } from "../links";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The renderer for a bare `string` with no explicit format — `(string, null)` —
|
|
10
|
-
* our own lightweight markup language, **marklower**: deliberately a notch below
|
|
11
|
-
* Markdown ("downshifted" from it). It is the *default* format for prose strings
|
|
12
|
-
* inside a chapter: a chunk that declares no format routes here rather than to the
|
|
13
|
-
* plain-paragraph fallback.
|
|
14
|
-
*
|
|
15
|
-
* The language is meant to cover inline concerns only — font styling, hyperlinks,
|
|
16
|
-
* images, math, and (perhaps) embedded code — but deliberately **no** chapter
|
|
17
|
-
* structure: no headings/subheadings, since chapters are modeled by the chapter
|
|
18
|
-
* renderer's `children`, not by markup.
|
|
19
|
-
*
|
|
20
|
-
* The syntax so far is all inline:
|
|
21
|
-
*
|
|
22
|
-
* - **atomic tokens**, whose contents are *not* re-interpreted as markup:
|
|
23
|
-
* `$$…$$` math (typeset with KaTeX via the shared {@link renderMath}, the same
|
|
24
|
-
* path the `text/x-latex` renderer uses) and `` `code` `` spans;
|
|
25
|
-
* - **links**: `[text](target)`, where `target` is a path in the app's JSON
|
|
26
|
-
* instance space (the same space the whole app navigates). Resolved and made
|
|
27
|
-
* clickable through the shared {@link NavLink} — the one link concept that refs
|
|
28
|
-
* and rels are expected to adopt later;
|
|
29
|
-
* - **text styling** on the plain runs between those: `**bold**`/`__bold__`,
|
|
30
|
-
* `*italic*`/`_italic_`, and `~~strikethrough~~`.
|
|
31
|
-
*
|
|
32
|
-
* Anything else is passed through verbatim. `parse` is the single seam every entry
|
|
33
|
-
* point goes through, so there is one place to teach the grammar.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
/** Escape a plain-text run so it can be dropped into HTML (`parse` emits HTML now
|
|
37
|
-
* that some tokens — math, code, emphasis — render to markup). */
|
|
38
|
-
function escapeHtml(s: string): string {
|
|
39
|
-
return s
|
|
40
|
-
.replace(/&/g, "&")
|
|
41
|
-
.replace(/</g, "<")
|
|
42
|
-
.replace(/>/g, ">");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Style a plain-text run (one of the stretches between the atomic tokens): escape
|
|
46
|
-
* it, then apply emphasis. Bold (`**`/`__`) runs before italic (`*`/`_`) so a
|
|
47
|
-
* double marker isn't mistaken for two single ones; non-greedy so neighbours don't
|
|
48
|
-
* merge. The markers (`* _ ~`) survive `escapeHtml`, so styling the escaped text
|
|
49
|
-
* is safe. */
|
|
50
|
-
function styleText(text: string): string {
|
|
51
|
-
return escapeHtml(text)
|
|
52
|
-
.replace(/~~(.+?)~~/g, "<del>$1</del>")
|
|
53
|
-
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
54
|
-
.replace(/__(.+?)__/g, "<strong>$1</strong>")
|
|
55
|
-
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
56
|
-
.replace(/_(.+?)_/g, "<em>$1</em>");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The non-text tokens, in one alternation matched in source order: `$$…$$` math
|
|
60
|
-
* (group 1; `[\s\S]` so a formula may span lines), a `` `code` `` span (group 2),
|
|
61
|
-
* or a `[label](target)` link (groups 3 = label, 4 = target). Non-greedy so
|
|
62
|
-
* adjacent tokens don't merge. The label may contain a balanced `[…]` (so a path
|
|
63
|
-
* used as its own label — `[/children[0]](/children[0])` — works), but a stray `]`
|
|
64
|
-
* is not a label, so a non-link `[a]` in prose is left alone. */
|
|
65
|
-
const TOKEN = /\$\$([\s\S]+?)\$\$|`([^`]+?)`|\[((?:[^\[\]]|\[[^\]]*\])*?)\]\(([^)]+?)\)/g;
|
|
66
|
-
|
|
67
|
-
/** Parse marklower into React nodes. Most syntax renders to an HTML string (math,
|
|
68
|
-
* code, emphasis), accumulated and flushed into `<span>`s; a link must be a real
|
|
69
|
-
* element so it navigates in-app (an HTML `<a href>` would reload), so the result
|
|
70
|
-
* is a node list, not one HTML string. `documentPath` anchors a link's `/…`
|
|
71
|
-
* (document-relative) target. */
|
|
72
|
-
function parse(value: unknown, onNavigate: (path: string) => void, documentPath?: string): ReactNode[] {
|
|
73
|
-
const src = String(value ?? "");
|
|
74
|
-
const nodes: ReactNode[] = [];
|
|
75
|
-
let html = ""; // buffer of HTML-rendered runs between links
|
|
76
|
-
let key = 0;
|
|
77
|
-
const flush = () => {
|
|
78
|
-
if (!html) return;
|
|
79
|
-
nodes.push(<span key={key++} dangerouslySetInnerHTML={{ __html: html }} />);
|
|
80
|
-
html = "";
|
|
81
|
-
};
|
|
82
|
-
let last = 0;
|
|
83
|
-
for (const m of src.matchAll(TOKEN)) {
|
|
84
|
-
html += styleText(src.slice(last, m.index)); // plain run before this token
|
|
85
|
-
if (m[1] !== undefined) {
|
|
86
|
-
html += renderMath(m[1], false); // $$ inline math $$
|
|
87
|
-
} else if (m[2] !== undefined) {
|
|
88
|
-
html += `<code>${escapeHtml(m[2])}</code>`; // `code` — contents literal
|
|
89
|
-
} else {
|
|
90
|
-
// [label](target) — a real anchor so it navigates in JSON instance space; the
|
|
91
|
-
// label keeps its own inline styling.
|
|
92
|
-
flush();
|
|
93
|
-
nodes.push(
|
|
94
|
-
<NavLink key={key++} target={m[4]} documentPath={documentPath} onNavigate={onNavigate}>
|
|
95
|
-
<span dangerouslySetInnerHTML={{ __html: styleText(m[3]) }} />
|
|
96
|
-
</NavLink>,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
last = m.index + m[0].length;
|
|
100
|
-
}
|
|
101
|
-
html += styleText(src.slice(last));
|
|
102
|
-
flush();
|
|
103
|
-
return nodes;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function MarklowerView({ node, onNavigate }: { node: NodeJson; onNavigate: (path: string) => void }) {
|
|
107
|
-
return (
|
|
108
|
-
<div className="marklower">
|
|
109
|
-
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
110
|
-
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
111
|
-
<p className="chapter-prose">{parse(scalarValue(node.value), onNavigate, node.documentPath)}</p>
|
|
112
|
-
</div>
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** A marklower chunk embedded inline in a chapter (the chapter supplies the
|
|
117
|
-
* surrounding number + anchor). */
|
|
118
|
-
export function MarklowerChunk({ chunk, onNavigate }: { chunk: Chunk; onNavigate: (path: string) => void }) {
|
|
119
|
-
return <p className="chapter-prose">{parse(chunk.value, onNavigate, chunk.documentPath)}</p>;
|
|
120
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The rendered-HTML body for the markdown/asciidoc page views, plus the control that sets its
|
|
5
|
-
* line-wrap measure. The **reading width is a URL parameter** — `?width=<ch>`, alongside
|
|
6
|
-
* `?format=` — so a particular width is a shareable link (the CSV renderer keeps its options in
|
|
7
|
-
* the query the same way). Default 72ch. The control lives in the tab bar next to the renderer
|
|
8
|
-
* button (see NodeView), not in the body. Chapter *chunks* render plain `.markup`, unaffected.
|
|
9
|
-
*/
|
|
10
|
-
const DEFAULT_WIDTH_CH = 72;
|
|
11
|
-
const MIN_CH = 20;
|
|
12
|
-
const MAX_CH = 400;
|
|
13
|
-
const params = () => new URLSearchParams(window.location.search);
|
|
14
|
-
|
|
15
|
-
/** The reading width in `ch` from the URL's `?width=`, or the default (out-of-range ignored). */
|
|
16
|
-
export function markupWidthCh(): number {
|
|
17
|
-
const w = Number(params().get("width"));
|
|
18
|
-
return Number.isFinite(w) && w >= MIN_CH && w <= MAX_CH ? w : DEFAULT_WIDTH_CH;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function writeWidth(ch: number): void {
|
|
22
|
-
const q = params();
|
|
23
|
-
if (ch === DEFAULT_WIDTH_CH) q.delete("width");
|
|
24
|
-
else q.set("width", String(ch));
|
|
25
|
-
const qs = q.toString();
|
|
26
|
-
window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : ""));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** The markdown/asciidoc body at the URL-configured reading width. */
|
|
30
|
-
export function Markup({ html }: { html: string }) {
|
|
31
|
-
return <div className="markup" style={{ maxWidth: `${markupWidthCh()}ch` }} dangerouslySetInnerHTML={{ __html: html }} />;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The width control beside the markdown/asciidoc renderer button (in the tab bar). It accepts
|
|
36
|
-
* ANY input — a valid measure (20–400 ch) is applied to the URL and `rerender()` re-wraps the
|
|
37
|
-
* body; an impossible/half-typed value is simply left unapplied and the field turns red (no
|
|
38
|
-
* editing is blocked). No visible label: the hover title reads "width, ch".
|
|
39
|
-
*/
|
|
40
|
-
export function MarkupWidthControl({ rerender }: { rerender: () => void }) {
|
|
41
|
-
const urlWidth = markupWidthCh();
|
|
42
|
-
const [text, setText] = useState(String(urlWidth));
|
|
43
|
-
useEffect(() => setText(String(urlWidth)), [urlWidth]); // resync when the URL changes (nav / apply)
|
|
44
|
-
const n = Number(text);
|
|
45
|
-
const valid = text.trim() !== "" && Number.isInteger(n) && n >= MIN_CH && n <= MAX_CH;
|
|
46
|
-
return (
|
|
47
|
-
<input
|
|
48
|
-
className={"markup-width" + (valid ? "" : " invalid")}
|
|
49
|
-
type="text"
|
|
50
|
-
inputMode="numeric"
|
|
51
|
-
title="width, ch"
|
|
52
|
-
value={text}
|
|
53
|
-
onChange={(e) => {
|
|
54
|
-
const v = e.target.value;
|
|
55
|
-
setText(v);
|
|
56
|
-
const num = Number(v);
|
|
57
|
-
if (v.trim() !== "" && Number.isInteger(num) && num >= MIN_CH && num <= MAX_CH) {
|
|
58
|
-
writeWidth(num);
|
|
59
|
-
rerender();
|
|
60
|
-
}
|
|
61
|
-
}}
|
|
62
|
-
/>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { NodeJson, blobUrl } from "../api";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Renderer for a file-backed HTML document (`<iframe>`), pointing at the server's `/api/blob`
|
|
5
|
-
* endpoint, which streams the file's raw bytes with its inferred Content-Type — so there is no
|
|
6
|
-
* base64 round-trip through the JSON API. (Images live in `imagemap.tsx`: a pan/zoom viewer.)
|
|
7
|
-
*/
|
|
8
|
-
export function HtmlView({ node }: { node: NodeJson }) {
|
|
9
|
-
// sandboxed: same-origin so the page's own CSS/images (served from /api/blob)
|
|
10
|
-
// load, but no scripts run — a saved web page renders without taking over.
|
|
11
|
-
return (
|
|
12
|
-
<iframe
|
|
13
|
-
className="filehtml"
|
|
14
|
-
src={blobUrl(node.path)}
|
|
15
|
-
sandbox="allow-same-origin"
|
|
16
|
-
title={node.title ?? node.path}
|
|
17
|
-
/>
|
|
18
|
-
);
|
|
19
|
-
}
|