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,209 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { NodeJson, fetchTagged } from "../api";
|
|
3
|
+
import { asLink, Link } from "../render";
|
|
4
|
+
import { typeIcon } from "../icons";
|
|
5
|
+
import { TAG_FORMAT, tagLabel, tagBody, resolveTagColor } from "./tag";
|
|
6
|
+
import { displayPath, displayKey } from "../paths";
|
|
7
|
+
import { touchesYamlover, useDiffBump } from "../live";
|
|
8
|
+
|
|
9
|
+
const ANNOTATION_FORMAT = "x-yamlover-annotation";
|
|
10
|
+
const MIXED_KEY = "$yamloverMixed";
|
|
11
|
+
|
|
12
|
+
// ---- the view mode: a URL parameter (`?view=`), so a view is a shareable link ---- //
|
|
13
|
+
|
|
14
|
+
const VIEWS = ["large", "small"] as const;
|
|
15
|
+
type ViewMode = (typeof VIEWS)[number];
|
|
16
|
+
const DEFAULT_VIEW: ViewMode = "large";
|
|
17
|
+
const params = () => new URLSearchParams(window.location.search);
|
|
18
|
+
|
|
19
|
+
/** The grid view from the URL's `?view=`, or the default (an unknown value ignored). */
|
|
20
|
+
export function explorerViewMode(): ViewMode {
|
|
21
|
+
const v = params().get("view");
|
|
22
|
+
return (VIEWS as readonly string[]).includes(v ?? "") ? (v as ViewMode) : DEFAULT_VIEW;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeViewMode(v: ViewMode): void {
|
|
26
|
+
const q = params();
|
|
27
|
+
if (v === DEFAULT_VIEW) q.delete("view");
|
|
28
|
+
else q.set("view", v);
|
|
29
|
+
const qs = q.toString();
|
|
30
|
+
window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : ""));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The view selector beside the explorer tab (the renderer's `config` hook) — writes
|
|
34
|
+
* `?view=` and rerenders, like the plaintext encoding control. */
|
|
35
|
+
export function ExplorerViewControl({ rerender }: { rerender: () => void }) {
|
|
36
|
+
return (
|
|
37
|
+
<label className="enc-control">
|
|
38
|
+
view{" "}
|
|
39
|
+
<select
|
|
40
|
+
value={explorerViewMode()}
|
|
41
|
+
onChange={(e) => {
|
|
42
|
+
writeViewMode(e.target.value as ViewMode);
|
|
43
|
+
rerender();
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<option value="large">large icons</option>
|
|
47
|
+
<option value="small">small icons</option>
|
|
48
|
+
</select>
|
|
49
|
+
</label>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The EXPLORER renderer — a directory (or a tag) as a desktop file manager:
|
|
55
|
+
* every member an icon + label, with the node's reverse UPLINKS (`relations`:
|
|
56
|
+
* the `..` parent and each upstream `*`/`~` source) leading the grid as
|
|
57
|
+
* visually distinct items. Two views, chosen by the `?view=` URL parameter
|
|
58
|
+
* (a renderer param, like the markup width or the CSV options): **large
|
|
59
|
+
* icons** (the default — tiles, the icon above the label) and **small icons**
|
|
60
|
+
* (rows, the icon beside the label).
|
|
61
|
+
*
|
|
62
|
+
* It claims two shapes:
|
|
63
|
+
* - a node stored as a filesystem directory (`concrete` `dir`/`yamlover`,
|
|
64
|
+
* the registry's concrete fallback) — ALL members show, not just files:
|
|
65
|
+
* scalar members read `key: value`, containers and binaries link onward;
|
|
66
|
+
* - a tag (`x-yamlover-tag`) — the members are the MATERIALS filed under it
|
|
67
|
+
* (GET /api/tagged: annotations resolved to their `target`, deduped),
|
|
68
|
+
* alongside its owned fields (subtags as colored badges); the mediating
|
|
69
|
+
* annotation nodes themselves stay out of the grid.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/** One grid item: a navigable link marker (or a defensive non-link member). */
|
|
73
|
+
export interface ExplorerItem {
|
|
74
|
+
key: string; // the label source: a member key, `[i]`, or a relation key (`..`, `/eve`, …)
|
|
75
|
+
link: Link | null;
|
|
76
|
+
raw?: unknown; // the value when it is not a link marker (rendered inert)
|
|
77
|
+
up?: boolean; // an uplink (relations) item — shown first, styled distinct
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The uplink items: the node's `relations` in server order (`..` always first). */
|
|
81
|
+
export function uplinkItems(relations?: Record<string, unknown>): ExplorerItem[] {
|
|
82
|
+
return Object.entries(relations ?? {})
|
|
83
|
+
.map(([key, v]) => ({ key, link: asLink(v), up: true }))
|
|
84
|
+
.filter((it) => it.link != null);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The member items, from any depth-1 projection shape: a plain object's entries, an array's
|
|
88
|
+
* items, or a `$yamloverMixed` marker's entries (the omni self-value is the BODY — shown in
|
|
89
|
+
* the header, not the grid). At depth 1 every member arrives as a `$yamloverLink` marker;
|
|
90
|
+
* a non-link value is kept defensively as an inert label. */
|
|
91
|
+
export function memberItems(node: NodeJson): ExplorerItem[] {
|
|
92
|
+
const v = node.value;
|
|
93
|
+
if (Array.isArray(v)) return v.map((item, i) => ({ key: `[${i}]`, link: asLink(item), raw: item }));
|
|
94
|
+
if (!v || typeof v !== "object") return [];
|
|
95
|
+
const mixed = (v as Record<string, unknown>)[MIXED_KEY] as
|
|
96
|
+
| { entries?: { key: string | null; value: unknown }[] }
|
|
97
|
+
| undefined;
|
|
98
|
+
if (Object.keys(v).length === 1 && mixed?.entries) {
|
|
99
|
+
return mixed.entries.map((e, i) => ({ key: e.key ?? `[${i}]`, link: asLink(e.value), raw: e.value }));
|
|
100
|
+
}
|
|
101
|
+
return Object.entries(v as Record<string, unknown>).map(([key, val]) => ({ key, link: asLink(val), raw: val }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A scalar member's value as a short label tail (`key: <this>`) — its first line,
|
|
105
|
+
* capped (a long text would bloat the DOM; the CSS ellipsis only hides overflow). */
|
|
106
|
+
function scalarText(v: unknown): string {
|
|
107
|
+
if (v === null || v === undefined) return "~";
|
|
108
|
+
const line = String(v).split("\n", 1)[0];
|
|
109
|
+
return line.length > 80 ? line.slice(0, 79) + "…" : line;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A scalar whose format marks it file-like CONTENT (a media type such as `text/markdown`,
|
|
113
|
+
* or an `x-yamlover-…` shape) — its value is the whole document, not a datum: the grid
|
|
114
|
+
* shows just the name (the icon already says what it is). A schema VALUE format (`date`,
|
|
115
|
+
* `email`, …) keeps the `key: value` form. */
|
|
116
|
+
function isDocFormat(f?: string | null): boolean {
|
|
117
|
+
return !!f && (f.includes("/") || f.startsWith("x-yamlover-"));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function Item({ it, onNavigate }: { it: ExplorerItem; onNavigate: (path: string) => void }) {
|
|
121
|
+
const link = it.link;
|
|
122
|
+
if (!link) {
|
|
123
|
+
// not a marker (unexpected at depth 1) — an inert label, no navigation
|
|
124
|
+
return (
|
|
125
|
+
<span className="dirview-item">
|
|
126
|
+
<span className="dirview-icon t-bin">•</span>
|
|
127
|
+
<span className="dirview-label">{it.key}: {scalarText(it.raw)}</span>
|
|
128
|
+
</span>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const g = typeIcon(link.type ?? link.kind, link.format ?? null, link.concrete);
|
|
132
|
+
// an uplink labels by its (decoded) relation key; a member by title, else its key
|
|
133
|
+
const name = it.up ? displayKey(it.key) : link.title ?? it.key;
|
|
134
|
+
const label =
|
|
135
|
+
link.format === TAG_FORMAT ? (
|
|
136
|
+
// a tag member (e.g. a subtag) keeps its badge color everywhere
|
|
137
|
+
<span className="tagtag" style={{ background: resolveTagColor({ name, color: link.color }) }}>{name}</span>
|
|
138
|
+
) : link.kind === "scalar" && !isDocFormat(link.format) ? (
|
|
139
|
+
<>
|
|
140
|
+
{name}: <span className="val">{scalarText(link.value)}</span>
|
|
141
|
+
</>
|
|
142
|
+
) : (
|
|
143
|
+
name
|
|
144
|
+
);
|
|
145
|
+
return (
|
|
146
|
+
<a
|
|
147
|
+
className={"dirview-item" + (it.up ? " dirview-up" : "")}
|
|
148
|
+
href={link.path}
|
|
149
|
+
title={displayPath(link.path)}
|
|
150
|
+
onClick={(e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
onNavigate(link.path);
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<span className={"dirview-icon " + g.cls} title={g.title}>{g.glyph}</span>
|
|
156
|
+
<span className="dirview-label">{label}</span>
|
|
157
|
+
</a>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function ExplorerView({ node, onNavigate }: { node: NodeJson; onNavigate: (path: string) => void }) {
|
|
162
|
+
const isTag = node.format === TAG_FORMAT;
|
|
163
|
+
// a tag's materials (annotations resolved to their targets) — fetched per tag page, refetched
|
|
164
|
+
// when a diff (live.ts) touches a `.yamlover` file (an annotation created/deleted anywhere)
|
|
165
|
+
const [tagged, setTagged] = useState<Link[]>([]);
|
|
166
|
+
const diffBump = useDiffBump(touchesYamlover);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
setTagged([]);
|
|
169
|
+
if (!isTag) return;
|
|
170
|
+
let cancelled = false;
|
|
171
|
+
fetchTagged(node.path)
|
|
172
|
+
.then((arr) => {
|
|
173
|
+
if (!cancelled) setTagged(arr.map(asLink).filter((l): l is Link => l != null));
|
|
174
|
+
})
|
|
175
|
+
.catch(() => {}); // the owned members still show
|
|
176
|
+
return () => {
|
|
177
|
+
cancelled = true;
|
|
178
|
+
};
|
|
179
|
+
}, [node.path, isTag, diffBump]);
|
|
180
|
+
|
|
181
|
+
const ups = uplinkItems(node.relations);
|
|
182
|
+
let members = memberItems(node);
|
|
183
|
+
if (isTag) {
|
|
184
|
+
// the raw back-edge members are the mediating ANNOTATION nodes — the grid shows the
|
|
185
|
+
// materials from /api/tagged instead (directly-tagged nodes are in both → dedup by path)
|
|
186
|
+
members = members.filter((m) => m.link?.format !== ANNOTATION_FORMAT);
|
|
187
|
+
const have = new Set(members.map((m) => m.link?.path).filter(Boolean));
|
|
188
|
+
for (const l of tagged) if (!have.has(l.path)) members.push({ key: tagLabel(l.path, l.title), link: l });
|
|
189
|
+
}
|
|
190
|
+
const items = [...ups, ...members];
|
|
191
|
+
|
|
192
|
+
// a tag page's description is its BODY (the header bar already names the node)
|
|
193
|
+
const desc = (isTag ? tagBody(node.value) : null) ?? node.description;
|
|
194
|
+
return (
|
|
195
|
+
<div className="explorerview">
|
|
196
|
+
{desc && (
|
|
197
|
+
<div className="dirhead">
|
|
198
|
+
<p className="tagdesc">{desc}</p>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
<div className={"dirview" + (explorerViewMode() === "large" ? " dirview-lg" : "")}>
|
|
202
|
+
{items.map((it, i) => (
|
|
203
|
+
<Item key={`${it.up ? "^" : ""}${it.link?.path ?? it.key}#${i}`} it={it} onNavigate={onNavigate} />
|
|
204
|
+
))}
|
|
205
|
+
{items.length === 0 && <span className="dirview-empty">empty</span>}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { NodeJson, blobUrl } from "../api";
|
|
3
|
+
|
|
4
|
+
const XLINK = "http://www.w3.org/1999/xlink";
|
|
5
|
+
// Characters illegal in XML 1.0 (control codes other than tab/newline/cr). FB2
|
|
6
|
+
// generators frequently leave these in; strict parsing rejects them, so drop.
|
|
7
|
+
// Built from an escaped string to keep literal control bytes out of the source.
|
|
8
|
+
const BAD_XML = new RegExp("[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]", "g");
|
|
9
|
+
|
|
10
|
+
/** Decode the file's bytes using the encoding named in its XML declaration —
|
|
11
|
+
* FB2 is frequently windows-1251 (Cyrillic), not UTF-8 — then strip the stray
|
|
12
|
+
* control characters that would otherwise fail strict XML parsing. */
|
|
13
|
+
function decodeXml(buf: ArrayBuffer): string {
|
|
14
|
+
const bytes = new Uint8Array(buf);
|
|
15
|
+
const head = new TextDecoder("latin1").decode(bytes.subarray(0, 256));
|
|
16
|
+
const enc = (head.match(/encoding=["']([^"']+)["']/i)?.[1] || "utf-8").toLowerCase();
|
|
17
|
+
let text: string;
|
|
18
|
+
try {
|
|
19
|
+
text = new TextDecoder(enc).decode(bytes);
|
|
20
|
+
} catch {
|
|
21
|
+
text = new TextDecoder("utf-8").decode(bytes);
|
|
22
|
+
}
|
|
23
|
+
return text.replace(BAD_XML, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function esc(s: string): string {
|
|
27
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Recursively turn an FB2 body element into HTML. `images` maps a `<binary>`
|
|
31
|
+
* id to a data URL. Switches on `localName`, so the FB2 namespace is ignored. */
|
|
32
|
+
function toHtml(node: Node, images: Map<string, string>): string {
|
|
33
|
+
if (node.nodeType === 3) return esc(node.textContent || ""); // text
|
|
34
|
+
if (node.nodeType !== 1) return "";
|
|
35
|
+
const el = node as Element;
|
|
36
|
+
const kids = () => Array.from(el.childNodes).map((n) => toHtml(n, images)).join("");
|
|
37
|
+
switch (el.localName) {
|
|
38
|
+
case "section": return `<section class="fb2-section">${kids()}</section>`;
|
|
39
|
+
case "title": return `<div class="fb2-title">${kids()}</div>`;
|
|
40
|
+
case "subtitle": return `<p class="fb2-subtitle">${kids()}</p>`;
|
|
41
|
+
case "p": return `<p>${kids()}</p>`;
|
|
42
|
+
case "empty-line": return "<br/>";
|
|
43
|
+
case "emphasis": return `<em>${kids()}</em>`;
|
|
44
|
+
case "strong": return `<strong>${kids()}</strong>`;
|
|
45
|
+
case "strikethrough": return `<s>${kids()}</s>`;
|
|
46
|
+
case "sub": return `<sub>${kids()}</sub>`;
|
|
47
|
+
case "sup": return `<sup>${kids()}</sup>`;
|
|
48
|
+
case "code": return `<code>${kids()}</code>`;
|
|
49
|
+
case "epigraph":
|
|
50
|
+
case "cite": return `<blockquote class="fb2-cite">${kids()}</blockquote>`;
|
|
51
|
+
case "text-author": return `<p class="fb2-text-author">${kids()}</p>`;
|
|
52
|
+
case "poem": return `<div class="fb2-poem">${kids()}</div>`;
|
|
53
|
+
case "stanza": return `<div class="fb2-stanza">${kids()}</div>`;
|
|
54
|
+
case "v": return `<div class="fb2-v">${kids()}</div>`;
|
|
55
|
+
case "a": return `<span class="fb2-a">${kids()}</span>`; // FB2 links are intra-doc
|
|
56
|
+
case "image": {
|
|
57
|
+
const href =
|
|
58
|
+
el.getAttributeNS(XLINK, "href") || el.getAttribute("l:href") || el.getAttribute("href") || "";
|
|
59
|
+
const src = images.get(href.replace(/^#/, ""));
|
|
60
|
+
return src ? `<img class="fb2-img" src="${src}" alt=""/>` : "";
|
|
61
|
+
}
|
|
62
|
+
case "title-info":
|
|
63
|
+
case "description":
|
|
64
|
+
case "binary":
|
|
65
|
+
return ""; // metadata / payload, not body prose
|
|
66
|
+
default: return kids();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Book {
|
|
71
|
+
title?: string;
|
|
72
|
+
author?: string;
|
|
73
|
+
cover?: string;
|
|
74
|
+
html: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Renderer for an `application/x-fictionbook+xml` (`.fb2`) ebook. The file is
|
|
79
|
+
* served as bytes; here we decode it (honoring its declared encoding), parse the
|
|
80
|
+
* FictionBook XML, and present the book — cover, title, author, then the body
|
|
81
|
+
* with its sections/paragraphs/poems and embedded images inlined.
|
|
82
|
+
*/
|
|
83
|
+
export function Fb2View({ node }: { node: NodeJson }) {
|
|
84
|
+
const [book, setBook] = useState<Book | null>(null);
|
|
85
|
+
const [error, setError] = useState<string | null>(null);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
setBook(null);
|
|
90
|
+
setError(null);
|
|
91
|
+
fetch(blobUrl(node.path))
|
|
92
|
+
.then((r) => r.arrayBuffer())
|
|
93
|
+
.then((buf) => {
|
|
94
|
+
if (cancelled) return;
|
|
95
|
+
const doc = new DOMParser().parseFromString(decodeXml(buf), "application/xml");
|
|
96
|
+
const perr = doc.getElementsByTagName("parsererror")[0];
|
|
97
|
+
if (perr) throw new Error(perr.textContent?.trim().split("\n")[0] || "invalid FB2 XML");
|
|
98
|
+
|
|
99
|
+
const images = new Map<string, string>();
|
|
100
|
+
for (const b of Array.from(doc.getElementsByTagNameNS("*", "binary"))) {
|
|
101
|
+
const id = b.getAttribute("id");
|
|
102
|
+
if (id) {
|
|
103
|
+
const ct = b.getAttribute("content-type") || "image/jpeg";
|
|
104
|
+
images.set(id, `data:${ct};base64,${(b.textContent || "").replace(/\s+/g, "")}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ti = doc.getElementsByTagNameNS("*", "title-info")[0] as Element | undefined;
|
|
109
|
+
const at = (e: Element | undefined, name: string) =>
|
|
110
|
+
e?.getElementsByTagNameNS("*", name)[0]?.textContent?.trim() || "";
|
|
111
|
+
const title = at(ti, "book-title") || undefined;
|
|
112
|
+
const author =
|
|
113
|
+
ti &&
|
|
114
|
+
Array.from(ti.getElementsByTagNameNS("*", "author"))
|
|
115
|
+
.map((a) =>
|
|
116
|
+
[at(a, "first-name"), at(a, "middle-name"), at(a, "last-name")]
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.join(" ") || at(a, "nickname"),
|
|
119
|
+
)
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join(", ");
|
|
122
|
+
const coverImg = ti?.getElementsByTagNameNS("*", "coverpage")[0]?.getElementsByTagNameNS("*", "image")[0];
|
|
123
|
+
const coverId = (
|
|
124
|
+
coverImg?.getAttributeNS(XLINK, "href") || coverImg?.getAttribute("l:href") || ""
|
|
125
|
+
).replace(/^#/, "");
|
|
126
|
+
|
|
127
|
+
const bodies = Array.from(doc.getElementsByTagNameNS("*", "body"));
|
|
128
|
+
const main = bodies.find((b) => b.getAttribute("name") !== "notes") || bodies[0];
|
|
129
|
+
const html = main ? Array.from(main.childNodes).map((n) => toHtml(n, images)).join("") : "";
|
|
130
|
+
|
|
131
|
+
setBook({ title, author: author || undefined, cover: coverId ? images.get(coverId) : undefined, html });
|
|
132
|
+
})
|
|
133
|
+
.catch((e) => !cancelled && setError(String((e as Error).message || e)));
|
|
134
|
+
return () => {
|
|
135
|
+
cancelled = true;
|
|
136
|
+
};
|
|
137
|
+
}, [node.path]);
|
|
138
|
+
|
|
139
|
+
if (error) return <div className="error">fb2: {error}</div>;
|
|
140
|
+
if (!book) return <div className="loading">loading FB2…</div>;
|
|
141
|
+
return (
|
|
142
|
+
<div className="text fb2">
|
|
143
|
+
{book.cover && <img className="fb2-cover" src={book.cover} alt="" />}
|
|
144
|
+
{book.title && <h1 className="chapter-title">{book.title}</h1>}
|
|
145
|
+
{book.author && <p className="chapter-subtitle">{book.author}</p>}
|
|
146
|
+
<div className="markup fb2-body" dangerouslySetInnerHTML={{ __html: book.html }} />
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared heading machinery for the rendered-markup formats (Markdown and AsciiDoc).
|
|
5
|
+
*
|
|
6
|
+
* A `.md`/`.adoc` page is a single HTML blob dumped via `dangerouslySetInnerHTML`,
|
|
7
|
+
* so on its own a heading is not addressable. {@link anchorizeHeadings} gives every
|
|
8
|
+
* heading a stable `id` and a small `§` link to it, mirroring the way GitHub renders
|
|
9
|
+
* the same documents — so a deep link like `<page>#<slug>` lands on, and scrolls to,
|
|
10
|
+
* one section. This is the prose-document counterpart of the chapter renderer's `§N`
|
|
11
|
+
* chunk anchors (see `chapter.tsx`): there the locator is the chunk's path; here it
|
|
12
|
+
* is the heading's slug.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** GitHub-style slug of a heading's text: lowercase, punctuation dropped, runs of
|
|
16
|
+
* whitespace collapsed to single hyphens. Unicode letters/numbers are kept. */
|
|
17
|
+
function slugify(text: string): string {
|
|
18
|
+
return text
|
|
19
|
+
.trim()
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
22
|
+
.replace(/\s+/g, "-");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `base`, suffixed `-2`, `-3`, … until it is not already in `used` (which it is
|
|
26
|
+
* then added to). Empty `base` (a heading with no sluggable text) yields "". */
|
|
27
|
+
function uniqueId(base: string, used: Set<string>): string {
|
|
28
|
+
if (!base) return "";
|
|
29
|
+
let id = base;
|
|
30
|
+
for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
|
|
31
|
+
used.add(id);
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Give every heading in a block of rendered markup an `id` and a leading `§`
|
|
36
|
+
* anchor link to it (placed first so it sits in the left gutter, like a chapter
|
|
37
|
+
* chunk's `§N` index). An id already present (Asciidoctor stamps section ids) is
|
|
38
|
+
* kept — so its anchor matches the document's own cross-references — otherwise a
|
|
39
|
+
* de-duplicated slug of the heading text is assigned. Returns the rewritten HTML.
|
|
40
|
+
* Runs in the browser/jsdom; with no `DOMParser` (or no headings) it is a no-op. */
|
|
41
|
+
export function anchorizeHeadings(html: string): string {
|
|
42
|
+
if (typeof DOMParser === "undefined" || !html.includes("<h")) return html;
|
|
43
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
44
|
+
const used = new Set<string>();
|
|
45
|
+
for (const h of doc.querySelectorAll("h1, h2, h3, h4, h5, h6")) {
|
|
46
|
+
// slug from the text before inserting the anchor, so the `§` is not part of it
|
|
47
|
+
const id = uniqueId(h.id || slugify(h.textContent ?? ""), used);
|
|
48
|
+
if (!id) continue;
|
|
49
|
+
h.id = id;
|
|
50
|
+
const a = doc.createElement("a");
|
|
51
|
+
a.className = "header-anchor";
|
|
52
|
+
a.href = `#${id}`;
|
|
53
|
+
a.setAttribute("aria-label", "Link to this section");
|
|
54
|
+
a.textContent = "§";
|
|
55
|
+
h.insertBefore(a, h.firstChild);
|
|
56
|
+
}
|
|
57
|
+
return doc.body.innerHTML;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Scroll to the element named by the URL hash once `dep` (the rendered node)
|
|
61
|
+
* settles. A deep link `<page>#<slug>` lands on the page, but the value is fetched
|
|
62
|
+
* async — after the browser's own one-shot scroll — so re-scroll when it arrives.
|
|
63
|
+
* The same pattern the chapter renderer uses for `#/chunks[n]`. */
|
|
64
|
+
export function useHashScroll(dep: unknown): void {
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const id = decodeURIComponent(window.location.hash.slice(1));
|
|
67
|
+
if (id) document.getElementById(id)?.scrollIntoView?.(); // optional-call: absent in jsdom
|
|
68
|
+
}, [dep]);
|
|
69
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import heic2any from "heic2any";
|
|
2
|
+
import { NodeJson } from "../api";
|
|
3
|
+
import { DecodedImageView } from "./decoded";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders an HEIC/HEIF image (`image/heic`, `.heic`/`.heif`) — the format iPhones
|
|
7
|
+
* shoot. It's HEVC-encoded and patent-encumbered, with no browser support, so we
|
|
8
|
+
* decode it with `heic2any` (libheif compiled to wasm), which converts the bytes
|
|
9
|
+
* to a PNG blob. An HEIC may hold an image *sequence* (burst/Live Photo); when it
|
|
10
|
+
* does, `heic2any` returns several blobs and we show each.
|
|
11
|
+
*/
|
|
12
|
+
export function HeicView({ node }: { node: NodeJson }) {
|
|
13
|
+
return (
|
|
14
|
+
<DecodedImageView
|
|
15
|
+
node={node}
|
|
16
|
+
label="heic"
|
|
17
|
+
decode={async (buf) => {
|
|
18
|
+
const out = await heic2any({ blob: new Blob([buf]), toType: "image/png" });
|
|
19
|
+
return Array.isArray(out) ? out : [out];
|
|
20
|
+
}}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
/** The `rect`-type annotations, as pixel regions to overlay on the image. */
|
|
17
|
+
function imageRegions(anns: Annotation[]): ImageRegion[] {
|
|
18
|
+
return anns
|
|
19
|
+
.filter((a) => a.selector?.type === "rect")
|
|
20
|
+
.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 }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pan/zoom image viewer — the same widget the KML map uses, over a flat picture. The image is an
|
|
25
|
+
* `imageOverlay` on a `CRS.Simple` map sized to its natural pixels; the view fits it initially.
|
|
26
|
+
* Gestures follow the unified model (see {@link wireGestures} / the UI guide): plain drag selects a
|
|
27
|
+
* region (when `onSelectRegion` is set), ctrl/alt-drag pans, plain wheel pans vertically, ctrl/alt-
|
|
28
|
+
* wheel zooms. The map is built once (per `src`); regions redraw in place without resetting the view.
|
|
29
|
+
*/
|
|
30
|
+
export function PanZoomImage({
|
|
31
|
+
src, className, regions, onSelectRegion, onRegionClick, selectColor,
|
|
32
|
+
}: {
|
|
33
|
+
src: string;
|
|
34
|
+
className: string;
|
|
35
|
+
regions?: ImageRegion[];
|
|
36
|
+
onSelectRegion?: (selector: Record<string, unknown>, screen: { x: number; y: number }) => void;
|
|
37
|
+
onRegionClick?: (ann: Annotation, screen: { x: number; y: number }) => void;
|
|
38
|
+
selectColor?: () => string;
|
|
39
|
+
}) {
|
|
40
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
41
|
+
const mapRef = useRef<L.Map | null>(null);
|
|
42
|
+
const layerRef = useRef<L.LayerGroup | null>(null);
|
|
43
|
+
const sizeRef = useRef({ w: 1, h: 1 });
|
|
44
|
+
const onSelectRef = useRef(onSelectRegion);
|
|
45
|
+
const onRegionClickRef = useRef(onRegionClick);
|
|
46
|
+
const colorRef = useRef(selectColor);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
const [ready, setReady] = useState(0); // bumps once the map (and its overlay layer) exist
|
|
49
|
+
const regionsKey = JSON.stringify(regions ?? []);
|
|
50
|
+
const selectable = !!onSelectRegion; // fixed per instance: full view annotates, chunk doesn't
|
|
51
|
+
|
|
52
|
+
useEffect(() => { onSelectRef.current = onSelectRegion; onRegionClickRef.current = onRegionClick; colorRef.current = selectColor; });
|
|
53
|
+
|
|
54
|
+
// Build the map once per src; gestures + the overlay layer live with it.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
let cancelled = false;
|
|
57
|
+
let dispose: (() => void) | null = null;
|
|
58
|
+
setError(null);
|
|
59
|
+
const img = new Image();
|
|
60
|
+
img.onload = () => {
|
|
61
|
+
if (cancelled || !ref.current) return;
|
|
62
|
+
const w = img.naturalWidth || 1;
|
|
63
|
+
const h = img.naturalHeight || 1;
|
|
64
|
+
sizeRef.current = { w, h };
|
|
65
|
+
// CRS.Simple: coordinates are raw pixels (y, x); negative minZoom allows zooming far out.
|
|
66
|
+
const map = L.map(ref.current, { crs: L.CRS.Simple, minZoom: -8, attributionControl: false, zoomSnap: 0 });
|
|
67
|
+
const bounds: L.LatLngBoundsExpression = [[0, 0], [h, w]];
|
|
68
|
+
L.imageOverlay(src, bounds).addTo(map);
|
|
69
|
+
map.fitBounds(bounds); // initial view frames the whole image
|
|
70
|
+
layerRef.current = L.layerGroup().addTo(map);
|
|
71
|
+
mapRef.current = map;
|
|
72
|
+
dispose = wireGestures(map, {
|
|
73
|
+
color: () => colorRef.current?.() ?? DEFAULT_COLOR,
|
|
74
|
+
onSelect: selectable
|
|
75
|
+
? (b, screen) => {
|
|
76
|
+
// image pixels have y from the top; CRS.Simple lat is from the bottom → flip.
|
|
77
|
+
const { h: ih } = sizeRef.current;
|
|
78
|
+
const west = b.getWest(), east = b.getEast(), south = b.getSouth(), north = b.getNorth();
|
|
79
|
+
onSelectRef.current?.(
|
|
80
|
+
{ type: "rect", x: Math.round(west), y: Math.round(ih - north), w: Math.round(east - west), h: Math.round(north - south) },
|
|
81
|
+
screen,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
: undefined,
|
|
85
|
+
});
|
|
86
|
+
setReady((r) => r + 1);
|
|
87
|
+
};
|
|
88
|
+
img.onerror = () => { if (!cancelled) setError("could not load image"); };
|
|
89
|
+
img.src = src;
|
|
90
|
+
return () => {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
dispose?.();
|
|
93
|
+
mapRef.current?.remove();
|
|
94
|
+
mapRef.current = null;
|
|
95
|
+
layerRef.current = null;
|
|
96
|
+
};
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, [src]);
|
|
99
|
+
|
|
100
|
+
// Draw the region rectangles into the overlay layer — in place, so creating one keeps the view.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const lg = layerRef.current;
|
|
103
|
+
if (!lg) return;
|
|
104
|
+
lg.clearLayers();
|
|
105
|
+
const { h } = sizeRef.current;
|
|
106
|
+
for (const r of regions ?? []) {
|
|
107
|
+
const c = r.color || DEFAULT_COLOR;
|
|
108
|
+
// image y is from the top; CRS.Simple lat from the bottom, so flip y
|
|
109
|
+
const rect = L.rectangle([[h - r.y, r.x], [h - (r.y + r.h), r.x + r.w]], {
|
|
110
|
+
className: "yo-region", color: c, weight: 3, fillColor: c, fillOpacity: 0.25,
|
|
111
|
+
});
|
|
112
|
+
if (r.title) rect.bindTooltip(r.title);
|
|
113
|
+
if (r.ann) {
|
|
114
|
+
const ann = r.ann;
|
|
115
|
+
rect.on("click", (ev) => { L.DomEvent.stop(ev); onRegionClickRef.current?.(ann, { x: ev.originalEvent.clientX, y: ev.originalEvent.clientY }); });
|
|
116
|
+
}
|
|
117
|
+
rect.addTo(lg);
|
|
118
|
+
}
|
|
119
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
+
}, [regionsKey, ready]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
{error && <div className="error">image: {error}</div>}
|
|
125
|
+
<div ref={ref} className={className + (selectable ? " yo-selectable" : "")} />
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function ImageView({ node }: { node: NodeJson }) {
|
|
131
|
+
const material = useMaterialAnnotations(node.path);
|
|
132
|
+
const { openCreate, openEdit, palette, preview, color } = useAnnotationMenu(material);
|
|
133
|
+
// include the live PREVIEW selector so the rectangle stays drawn while the menu is open
|
|
134
|
+
const shown = preview
|
|
135
|
+
? [...material.annotations, { path: "(preview)", selector: preview.selector, tag: preview.tag } as Annotation]
|
|
136
|
+
: material.annotations;
|
|
137
|
+
return (
|
|
138
|
+
<div className="text">
|
|
139
|
+
{node.title && <h1 className="chapter-title">{node.title}</h1>}
|
|
140
|
+
{node.description && <p className="chapter-subtitle">{node.description}</p>}
|
|
141
|
+
<PanZoomImage
|
|
142
|
+
src={blobUrl(node.path)}
|
|
143
|
+
regions={imageRegions(shown)}
|
|
144
|
+
onSelectRegion={openCreate}
|
|
145
|
+
onRegionClick={openEdit}
|
|
146
|
+
selectColor={() => color}
|
|
147
|
+
className="filemap fileimagemap"
|
|
148
|
+
/>
|
|
149
|
+
{palette}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** An image embedded inline in a chapter — pan/zoom only (no annotation target → plain drag pans). */
|
|
155
|
+
export function ImageChunk({ chunk }: { chunk: Chunk }) {
|
|
156
|
+
return <PanZoomImage src={blobUrl(chunk.path)} className="filemap chunk-map fileimagemap" />;
|
|
157
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|