yamlover 0.3.0
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/LICENSE +21 -0
- package/README.md +214 -0
- package/bin/yamlover.js +202 -0
- package/dist/server.js +4681 -0
- package/index.html +12 -0
- package/package.json +72 -0
- package/src/client/App.tsx +372 -0
- package/src/client/NodeView.tsx +422 -0
- package/src/client/TaskStrip.tsx +34 -0
- package/src/client/Tree.tsx +97 -0
- package/src/client/api.ts +186 -0
- package/src/client/icons.ts +91 -0
- package/src/client/links.tsx +108 -0
- package/src/client/live.ts +42 -0
- package/src/client/main.tsx +10 -0
- package/src/client/paste-html.ts +228 -0
- package/src/client/paste-links.ts +42 -0
- package/src/client/paths.ts +109 -0
- package/src/client/render.tsx +326 -0
- package/src/client/renderers/annotate.tsx +507 -0
- package/src/client/renderers/asciidoc.tsx +35 -0
- package/src/client/renderers/chapter.tsx +138 -0
- package/src/client/renderers/csv.tsx +233 -0
- package/src/client/renderers/decoded.tsx +72 -0
- package/src/client/renderers/djvu.tsx +97 -0
- package/src/client/renderers/doc.tsx +40 -0
- package/src/client/renderers/docx.tsx +49 -0
- package/src/client/renderers/epub.tsx +147 -0
- package/src/client/renderers/explorer.tsx +209 -0
- package/src/client/renderers/fb2.tsx +149 -0
- package/src/client/renderers/headings.ts +69 -0
- package/src/client/renderers/heic.tsx +23 -0
- package/src/client/renderers/imagemap.tsx +157 -0
- package/src/client/renderers/kml.ts +46 -0
- package/src/client/renderers/latex.tsx +36 -0
- package/src/client/renderers/map.tsx +205 -0
- package/src/client/renderers/marklower.tsx +119 -0
- package/src/client/renderers/markup.tsx +64 -0
- package/src/client/renderers/media.tsx +19 -0
- package/src/client/renderers/panzoom.ts +101 -0
- package/src/client/renderers/pdf.tsx +176 -0
- package/src/client/renderers/plaintext.tsx +120 -0
- package/src/client/renderers/plantuml.tsx +82 -0
- package/src/client/renderers/psd.tsx +25 -0
- package/src/client/renderers/registry.tsx +389 -0
- package/src/client/renderers/rtf.tsx +210 -0
- package/src/client/renderers/spreadsheet.tsx +105 -0
- package/src/client/renderers/tag.tsx +113 -0
- package/src/client/renderers/text.tsx +41 -0
- package/src/client/renderers/tiff.tsx +33 -0
- package/src/client/styles.css +1115 -0
- package/src/client/vendor/README.md +30 -0
- package/src/client/vendor/djvu.js +15535 -0
- package/src/client/vite-env.d.ts +31 -0
- package/src/server/api.ts +147 -0
- package/src/server/engine-api.ts +1442 -0
- package/src/server/gitignore.ts +81 -0
- package/src/server/node-kind.ts +48 -0
- package/src/server/tasks.ts +83 -0
- package/src/server/yamlover.ts +1133 -0
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
|
|
8
|
+
/** A rectangular annotation region on a PDF page, in points (origin top-left). `ann` is the source
|
|
9
|
+
* annotation when real/saved (→ clickable to edit); absent for the live preview. */
|
|
10
|
+
interface PdfRegion { page: number; x: number; y: number; w: number; h: number; title?: string; color?: string; ann?: Annotation }
|
|
11
|
+
const num = (v: unknown): number => Number(v) || 0;
|
|
12
|
+
|
|
13
|
+
// pdf.js renders in a Web Worker; point it at the bundled worker (the version
|
|
14
|
+
// react-pdf depends on) resolved through Vite. Done once at module load.
|
|
15
|
+
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
|
16
|
+
"pdfjs-dist/build/pdf.worker.min.mjs",
|
|
17
|
+
import.meta.url,
|
|
18
|
+
).toString();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Renders a `application/pdf` file with pdf.js (via react-pdf): every page laid out top-to-bottom,
|
|
22
|
+
* fit to the pane's width. A plain wheel scrolls the document and text stays selectable; ctrl/alt-
|
|
23
|
+
* wheel zooms (scales the page width), matching the image/map viewers (see the UI guide).
|
|
24
|
+
* SELECTING text on a page raises the color palette and saves a `pdf` region annotation (the
|
|
25
|
+
* selection's bounding box, in page points) — the same flow as image/map regions. The document is
|
|
26
|
+
* loaded straight from `/api/blob` so pdf.js streams the bytes itself.
|
|
27
|
+
*/
|
|
28
|
+
export function PdfView({ node }: { node: NodeJson }) {
|
|
29
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
30
|
+
const [width, setWidth] = useState(0);
|
|
31
|
+
const [pages, setPages] = useState(0);
|
|
32
|
+
const [zoom, setZoom] = useState(1); // ctrl/alt-wheel scale factor
|
|
33
|
+
const [orig, setOrig] = useState<Record<number, { w: number; h: number }>>({}); // each page's natural size in points
|
|
34
|
+
|
|
35
|
+
// WINDOWED RENDERING: every page keeps a wrapper (so the scroll height is right), but only
|
|
36
|
+
// pages near the viewport mount a real <Page> — mounting ALL of them queues every canvas
|
|
37
|
+
// through the pdf.js worker at open (and again on each zoom), which saturates the main
|
|
38
|
+
// thread and janks scrolling on long documents. Far pages are fixed-height placeholders.
|
|
39
|
+
const wraps = useRef(new Map<number, HTMLElement>());
|
|
40
|
+
const [near, setNear] = useState<Set<number>>(() => new Set());
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!pages) return;
|
|
43
|
+
const obs = new IntersectionObserver(
|
|
44
|
+
(entries) => {
|
|
45
|
+
setNear((prev) => {
|
|
46
|
+
const next = new Set(prev);
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
const pn = Number((e.target as HTMLElement).dataset.page);
|
|
49
|
+
if (e.isIntersecting) next.add(pn);
|
|
50
|
+
else next.delete(pn);
|
|
51
|
+
}
|
|
52
|
+
return next.size === prev.size && [...next].every((p) => prev.has(p)) ? prev : next;
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
// root = the .filepdf scroller (the pane scrolls INSIDE it — a viewport root would
|
|
56
|
+
// never see pages clipped below its fold); pre-render ~2 screens above/below
|
|
57
|
+
{ root: ref.current, rootMargin: "2000px 0px" },
|
|
58
|
+
);
|
|
59
|
+
for (const el of wraps.current.values()) obs.observe(el);
|
|
60
|
+
return () => obs.disconnect();
|
|
61
|
+
// wrappers exist only once BOTH the page count and the pane width are known
|
|
62
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
+
}, [pages, width > 0]);
|
|
64
|
+
|
|
65
|
+
const material = useMaterialAnnotations(node.path);
|
|
66
|
+
const { openCreate, openEdit, palette, preview } = useAnnotationMenu(material);
|
|
67
|
+
// include the live PREVIEW so the rectangle stays drawn while the menu is open
|
|
68
|
+
const shown = preview
|
|
69
|
+
? [...material.annotations, { path: "(preview)", selector: preview.selector, tag: preview.tag } as Annotation]
|
|
70
|
+
: material.annotations;
|
|
71
|
+
const regions: PdfRegion[] = shown
|
|
72
|
+
.filter((a) => a.selector?.type === "pdf")
|
|
73
|
+
.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
|
+
|
|
75
|
+
// Track the pane width so pages re-flow on resize.
|
|
76
|
+
useLayoutEffect(() => {
|
|
77
|
+
const el = ref.current;
|
|
78
|
+
if (!el) return;
|
|
79
|
+
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
|
|
80
|
+
ro.observe(el);
|
|
81
|
+
return () => ro.disconnect();
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
// ctrl/alt-wheel zooms; a plain wheel is left alone so the pane keeps scrolling.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const el = ref.current;
|
|
87
|
+
if (!el) return;
|
|
88
|
+
const onWheel = (e: WheelEvent) => {
|
|
89
|
+
if (!(e.ctrlKey || e.altKey || e.metaKey)) return;
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
setZoom((z) => Math.min(5, Math.max(0.4, z * (e.deltaY < 0 ? 1.1 : 1 / 1.1))));
|
|
92
|
+
};
|
|
93
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
94
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const pageWidth = Math.min(width, 1000) * zoom;
|
|
98
|
+
|
|
99
|
+
// A finished text selection on a page → a `pdf` region (its bounding box, converted to points).
|
|
100
|
+
const onMouseUp = () => {
|
|
101
|
+
const sel = window.getSelection();
|
|
102
|
+
if (!sel || sel.isCollapsed || !sel.anchorNode) return;
|
|
103
|
+
const host = sel.anchorNode.nodeType === 1 ? (sel.anchorNode as Element) : sel.anchorNode.parentElement;
|
|
104
|
+
const pageEl = host?.closest(".pdf-page") as HTMLElement | null;
|
|
105
|
+
if (!pageEl || !ref.current?.contains(pageEl)) return;
|
|
106
|
+
const pn = Number(pageEl.dataset.page);
|
|
107
|
+
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // rendered px per point
|
|
108
|
+
if (!sc) return;
|
|
109
|
+
const pr = pageEl.getBoundingClientRect();
|
|
110
|
+
const sr = sel.getRangeAt(0).getBoundingClientRect();
|
|
111
|
+
if (sr.width < 2 || sr.height < 2) return;
|
|
112
|
+
openCreate(
|
|
113
|
+
{ type: "pdf", page: pn, x: Math.round((sr.left - pr.left) / sc), y: Math.round((sr.top - pr.top) / sc), w: Math.round(sr.width / sc), h: Math.round(sr.height / sc) },
|
|
114
|
+
{ x: sr.left, y: sr.bottom + 6 },
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<>
|
|
120
|
+
<div className="filepdf yo-zoomable" ref={ref} onMouseUp={onMouseUp}>
|
|
121
|
+
<Document
|
|
122
|
+
file={blobUrl(node.path)}
|
|
123
|
+
onLoadSuccess={({ numPages }) => setPages(numPages)}
|
|
124
|
+
loading={<div className="loading">loading PDF…</div>}
|
|
125
|
+
error={<div className="error">could not load PDF</div>}
|
|
126
|
+
>
|
|
127
|
+
{width > 0 &&
|
|
128
|
+
Array.from({ length: pages }, (_, i) => {
|
|
129
|
+
const pn = i + 1;
|
|
130
|
+
const sc = orig[pn] ? pageWidth / orig[pn].w : 0; // rendered px per point — tracks zoom
|
|
131
|
+
// a far page's placeholder: its measured aspect when known, A4 portrait until then
|
|
132
|
+
const estHeight = pageWidth * (orig[pn] ? orig[pn].h / orig[pn].w : Math.SQRT2);
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
key={i}
|
|
136
|
+
className="pdf-page"
|
|
137
|
+
data-page={pn}
|
|
138
|
+
ref={(el) => {
|
|
139
|
+
if (el) wraps.current.set(pn, el);
|
|
140
|
+
else wraps.current.delete(pn);
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{near.has(pn) ? (
|
|
144
|
+
<>
|
|
145
|
+
<Page
|
|
146
|
+
pageNumber={pn}
|
|
147
|
+
width={pageWidth}
|
|
148
|
+
onLoadSuccess={(p) => setOrig((o) => (o[pn] ? o : { ...o, [pn]: { w: p.originalWidth || pageWidth, h: p.originalHeight || pageWidth * Math.SQRT2 } }))}
|
|
149
|
+
loading={<div className="loading" style={{ height: estHeight }}>page {pn}…</div>}
|
|
150
|
+
/>
|
|
151
|
+
{sc > 0 &&
|
|
152
|
+
regions.filter((r) => r.page === pn).map((r, j) => {
|
|
153
|
+
const c = r.color || DEFAULT_COLOR;
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
key={j}
|
|
157
|
+
className={"pdf-region" + (r.ann ? " editable" : "")}
|
|
158
|
+
title={r.ann ? r.title || "click to recolor or delete" : r.title}
|
|
159
|
+
onClick={r.ann ? (e) => { e.stopPropagation(); openEdit(r.ann!, { x: e.clientX, y: e.clientY }); } : undefined}
|
|
160
|
+
style={{ left: r.x * sc, top: r.y * sc, width: r.w * sc, height: r.h * sc, borderColor: c, background: c + "2e" }}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</>
|
|
165
|
+
) : (
|
|
166
|
+
<div className="pdf-placeholder" style={{ width: pageWidth, height: estHeight }} />
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
})}
|
|
171
|
+
</Document>
|
|
172
|
+
</div>
|
|
173
|
+
{palette}
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { deflateSync } from "fflate";
|
|
2
|
+
import { NodeJson } from "../api";
|
|
3
|
+
import { Chunk } from "./registry";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The renderer for a `string`/`text/x-plantuml` node: PlantUML source shown as the
|
|
7
|
+
* diagram it describes. Like the markdown/asciidoc renderers it works from the
|
|
8
|
+
* node's string value, so it serves both a whole `.puml` file (`render`) and a
|
|
9
|
+
* single inline chunk embedded in a chapter (`renderChunk`).
|
|
10
|
+
*
|
|
11
|
+
* PlantUML is a *language that compiles to a picture* — it can only be rendered by
|
|
12
|
+
* a PlantUML server. So, exactly as every PlantUML integration does, the source is
|
|
13
|
+
* deflate+encoded into a URL and handed to one as an `<img>`. The default is the
|
|
14
|
+
* public server; point `VITE_PLANTUML_SERVER` at a self-hosted instance (e.g.
|
|
15
|
+
* `docker run -d -p 8080:8080 plantuml/plantuml-server`) to keep diagrams off it.
|
|
16
|
+
*/
|
|
17
|
+
const PLANTUML_SERVER =
|
|
18
|
+
((import.meta as any).env?.VITE_PLANTUML_SERVER as string | undefined)?.replace(/\/+$/, "") ??
|
|
19
|
+
"https://www.plantuml.com/plantuml";
|
|
20
|
+
|
|
21
|
+
// PlantUML's text transport: UTF-8 → raw DEFLATE → its own base64 variant (the
|
|
22
|
+
// alphabet `0-9 A-Z a-z - _`, three bytes packed into four 6-bit chars). The
|
|
23
|
+
// server inflates it back, so any valid DEFLATE stream is accepted — we let
|
|
24
|
+
// fflate compress and only reproduce PlantUML's character mapping.
|
|
25
|
+
function encode6bit(b: number): string {
|
|
26
|
+
if (b < 10) return String.fromCharCode(48 + b);
|
|
27
|
+
b -= 10;
|
|
28
|
+
if (b < 26) return String.fromCharCode(65 + b);
|
|
29
|
+
b -= 26;
|
|
30
|
+
if (b < 26) return String.fromCharCode(97 + b);
|
|
31
|
+
b -= 26;
|
|
32
|
+
if (b === 0) return "-";
|
|
33
|
+
if (b === 1) return "_";
|
|
34
|
+
return "?";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function append3bytes(b1: number, b2: number, b3: number): string {
|
|
38
|
+
const c1 = b1 >> 2;
|
|
39
|
+
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4);
|
|
40
|
+
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6);
|
|
41
|
+
const c4 = b3 & 0x3f;
|
|
42
|
+
return encode6bit(c1 & 0x3f) + encode6bit(c2 & 0x3f) + encode6bit(c3 & 0x3f) + encode6bit(c4 & 0x3f);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function encode64(data: Uint8Array): string {
|
|
46
|
+
let r = "";
|
|
47
|
+
for (let i = 0; i < data.length; i += 3) {
|
|
48
|
+
if (i + 2 === data.length) r += append3bytes(data[i], data[i + 1], 0);
|
|
49
|
+
else if (i + 1 === data.length) r += append3bytes(data[i], 0, 0);
|
|
50
|
+
else r += append3bytes(data[i], data[i + 1], data[i + 2]);
|
|
51
|
+
}
|
|
52
|
+
return r;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The PlantUML server URL that renders `source` as an SVG diagram. */
|
|
56
|
+
export function plantumlUrl(source: string): string {
|
|
57
|
+
const deflated = deflateSync(new TextEncoder().encode(source), { level: 9 });
|
|
58
|
+
return `${PLANTUML_SERVER}/svg/${encode64(deflated)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Diagram({ source }: { source: string }) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="filemedia">
|
|
64
|
+
<img className="fileimage plantuml" src={plantumlUrl(source)} alt="PlantUML diagram" />
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function PlantumlView({ node }: { node: NodeJson }) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="text">
|
|
72
|
+
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
73
|
+
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
74
|
+
<Diagram source={String(node.value ?? "")} />
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A diagram chunk embedded inline (the chapter supplies the number + anchor). */
|
|
80
|
+
export function PlantumlChunk({ chunk }: { chunk: Chunk }) {
|
|
81
|
+
return <Diagram source={String(chunk.value ?? "")} />;
|
|
82
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readPsd } from "ag-psd";
|
|
2
|
+
import { NodeJson } from "../api";
|
|
3
|
+
import { DecodedImageView, canvasToPng } from "./decoded";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders an Adobe Photoshop document (`image/vnd.adobe.photoshop`, `.psd`/`.psb`).
|
|
7
|
+
* The browser has no native PSD support, so we decode it with `ag-psd`: read the
|
|
8
|
+
* file's *flattened composite* — the merged RGB preview Photoshop embeds on save —
|
|
9
|
+
* which `ag-psd` paints onto a `<canvas>` for us, and export that as a PNG. We skip
|
|
10
|
+
* the per-layer/thumbnail image data: we only show the one composite, and decoding
|
|
11
|
+
* every layer of a big PSD would allocate far more than we display.
|
|
12
|
+
*/
|
|
13
|
+
export function PsdView({ node }: { node: NodeJson }) {
|
|
14
|
+
return (
|
|
15
|
+
<DecodedImageView
|
|
16
|
+
node={node}
|
|
17
|
+
label="psd"
|
|
18
|
+
decode={async (buf) => {
|
|
19
|
+
const psd = readPsd(buf, { skipLayerImageData: true, skipThumbnail: true });
|
|
20
|
+
if (!psd.canvas) throw new Error("no composite image in this PSD");
|
|
21
|
+
return [await canvasToPng(psd.canvas)];
|
|
22
|
+
}}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|