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,294 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, 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
|
-
/** The grid item to move to from `cur` for an arrow key. Left/Right step in reading
|
|
121
|
-
* order; Up/Down pick the nearest item in the adjacent row, preferring the same
|
|
122
|
-
* column — measured from live geometry, so it's correct for the wrapping flex grid
|
|
123
|
-
* (variable columns, a short last row) without knowing the column count. */
|
|
124
|
-
function arrowTarget(els: (HTMLElement | null)[], cur: number, key: string, count: number): number {
|
|
125
|
-
if (key === "ArrowRight") return Math.min(cur + 1, count - 1);
|
|
126
|
-
if (key === "ArrowLeft") return Math.max(cur - 1, 0);
|
|
127
|
-
if (key === "Home") return 0;
|
|
128
|
-
if (key === "End") return count - 1;
|
|
129
|
-
const a = els[cur];
|
|
130
|
-
if (!a) return cur;
|
|
131
|
-
const r = a.getBoundingClientRect();
|
|
132
|
-
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
133
|
-
const down = key === "ArrowDown";
|
|
134
|
-
let best = cur, bestScore = Infinity;
|
|
135
|
-
for (let i = 0; i < count; i++) {
|
|
136
|
-
const el = els[i];
|
|
137
|
-
if (!el || i === cur) continue;
|
|
138
|
-
const ri = el.getBoundingClientRect();
|
|
139
|
-
const ix = ri.left + ri.width / 2, iy = ri.top + ri.height / 2;
|
|
140
|
-
if (down ? iy <= cy + 1 : iy >= cy - 1) continue; // must lie in the arrow's direction
|
|
141
|
-
const score = Math.abs(ix - cx) * 2 + Math.abs(iy - cy); // prefer same column, nearest row
|
|
142
|
-
if (score < bestScore) { bestScore = score; best = i; }
|
|
143
|
-
}
|
|
144
|
-
return best;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function Item({ it, active, setRef, onFocus, onNavigate }: {
|
|
148
|
-
it: ExplorerItem;
|
|
149
|
-
active: boolean;
|
|
150
|
-
setRef: (el: HTMLElement | null) => void;
|
|
151
|
-
onFocus: () => void;
|
|
152
|
-
onNavigate: (path: string) => void;
|
|
153
|
-
}) {
|
|
154
|
-
const link = it.link;
|
|
155
|
-
const tabIndex = active ? 0 : -1; // roving tabindex: only the selected item is in the tab order
|
|
156
|
-
if (!link) {
|
|
157
|
-
// not a marker (unexpected at depth 1) — an inert label, no navigation
|
|
158
|
-
return (
|
|
159
|
-
<span className="dirview-item" ref={setRef} tabIndex={tabIndex} onFocus={onFocus}>
|
|
160
|
-
<span className="dirview-icon t-bin">•</span>
|
|
161
|
-
<span className="dirview-label">{it.key}: {scalarText(it.raw)}</span>
|
|
162
|
-
</span>
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
const g = typeIcon(link.type ?? link.kind, link.format ?? null, link.concrete);
|
|
166
|
-
// an uplink labels by its (decoded) relation key; a member by title, else its key
|
|
167
|
-
const name = it.up ? displayKey(it.key) : link.title ?? it.key;
|
|
168
|
-
const label =
|
|
169
|
-
link.format === TAG_FORMAT ? (
|
|
170
|
-
// a tag member (e.g. a subtag) keeps its badge color everywhere
|
|
171
|
-
<span className="tagtag" style={{ background: resolveTagColor({ name, color: link.color }) }}>{name}</span>
|
|
172
|
-
) : link.kind === "scalar" && !isDocFormat(link.format) ? (
|
|
173
|
-
<>
|
|
174
|
-
{name}: <span className="val">{scalarText(link.value)}</span>
|
|
175
|
-
</>
|
|
176
|
-
) : (
|
|
177
|
-
name
|
|
178
|
-
);
|
|
179
|
-
return (
|
|
180
|
-
<a
|
|
181
|
-
className={"dirview-item" + (it.up ? " dirview-up" : "")}
|
|
182
|
-
href={link.path}
|
|
183
|
-
title={displayPath(link.path)}
|
|
184
|
-
ref={setRef}
|
|
185
|
-
tabIndex={tabIndex}
|
|
186
|
-
onFocus={onFocus}
|
|
187
|
-
onClick={(e) => {
|
|
188
|
-
e.preventDefault();
|
|
189
|
-
onNavigate(link.path);
|
|
190
|
-
}}
|
|
191
|
-
>
|
|
192
|
-
<span className={"dirview-icon " + g.cls} title={g.title}>{g.glyph}</span>
|
|
193
|
-
<span className="dirview-label">{label}</span>
|
|
194
|
-
</a>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function ExplorerView({ node, onNavigate }: { node: NodeJson; onNavigate: (path: string) => void }) {
|
|
199
|
-
const isTag = node.format === TAG_FORMAT;
|
|
200
|
-
// a tag's materials (annotations resolved to their targets) — fetched per tag page, refetched
|
|
201
|
-
// when a diff (live.ts) touches a `.yamlover` file (an annotation created/deleted anywhere)
|
|
202
|
-
const [tagged, setTagged] = useState<Link[]>([]);
|
|
203
|
-
const diffBump = useDiffBump(touchesYamlover);
|
|
204
|
-
useEffect(() => {
|
|
205
|
-
setTagged([]);
|
|
206
|
-
if (!isTag) return;
|
|
207
|
-
let cancelled = false;
|
|
208
|
-
fetchTagged(node.path)
|
|
209
|
-
.then((arr) => {
|
|
210
|
-
if (!cancelled) setTagged(arr.map(asLink).filter((l): l is Link => l != null));
|
|
211
|
-
})
|
|
212
|
-
.catch(() => {}); // the owned members still show
|
|
213
|
-
return () => {
|
|
214
|
-
cancelled = true;
|
|
215
|
-
};
|
|
216
|
-
}, [node.path, isTag, diffBump]);
|
|
217
|
-
|
|
218
|
-
const ups = uplinkItems(node.relations);
|
|
219
|
-
let members = memberItems(node);
|
|
220
|
-
if (isTag) {
|
|
221
|
-
// the raw back-edge members are the mediating ANNOTATION nodes — the grid shows the
|
|
222
|
-
// materials from /api/tagged instead (directly-tagged nodes are in both → dedup by path)
|
|
223
|
-
members = members.filter((m) => m.link?.format !== ANNOTATION_FORMAT);
|
|
224
|
-
const have = new Set(members.map((m) => m.link?.path).filter(Boolean));
|
|
225
|
-
for (const l of tagged) if (!have.has(l.path)) members.push({ key: tagLabel(l.path, l.title), link: l });
|
|
226
|
-
}
|
|
227
|
-
const items = [...ups, ...members];
|
|
228
|
-
|
|
229
|
-
// Roving keyboard focus over the grid: plain arrows walk the icons, Enter opens the
|
|
230
|
-
// selected one. The item elements are tracked by index for the geometry-based row moves.
|
|
231
|
-
const gridRef = useRef<HTMLDivElement>(null);
|
|
232
|
-
const itemEls = useRef<(HTMLElement | null)[]>([]);
|
|
233
|
-
itemEls.current.length = items.length; // drop stale refs when the member list shrinks
|
|
234
|
-
const [active, setActive] = useState(0);
|
|
235
|
-
useEffect(() => {
|
|
236
|
-
if (active > items.length - 1) setActive(Math.max(0, items.length - 1));
|
|
237
|
-
}, [items.length, active]);
|
|
238
|
-
|
|
239
|
-
// On navigating to a new directory, reset the selection and re-arm autofocus (this
|
|
240
|
-
// component is reused across nodes, not remounted, so the flag must follow `node.path`).
|
|
241
|
-
const wantFocus = useRef(true);
|
|
242
|
-
useEffect(() => { setActive(0); wantFocus.current = true; }, [node.path]);
|
|
243
|
-
// Focus the first item once the grid has members (a tag's load async), so arrows work right
|
|
244
|
-
// after navigating here (the RHS pane handed us focus) — once per node, and never when
|
|
245
|
-
// embedded as a chapter chunk (several grids would fight over focus).
|
|
246
|
-
useEffect(() => {
|
|
247
|
-
if (!wantFocus.current || !items.length) return;
|
|
248
|
-
wantFocus.current = false;
|
|
249
|
-
if (gridRef.current?.closest(".chunk-body")) return; // embedded — don't steal focus
|
|
250
|
-
itemEls.current[0]?.focus({ preventScroll: true });
|
|
251
|
-
}, [items.length, node.path]);
|
|
252
|
-
|
|
253
|
-
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
254
|
-
if (e.ctrlKey || e.altKey || e.metaKey || !items.length) return; // Ctrl/Alt+arrows = TOC nav (App)
|
|
255
|
-
if (e.key === "Enter") {
|
|
256
|
-
const link = items[active]?.link;
|
|
257
|
-
if (link) { e.preventDefault(); onNavigate(link.path); }
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
if (!/^(Arrow(Up|Down|Left|Right)|Home|End)$/.test(e.key)) return;
|
|
261
|
-
e.preventDefault(); // don't also scroll the pane
|
|
262
|
-
const next = arrowTarget(itemEls.current, active, e.key, items.length);
|
|
263
|
-
if (next !== active) { setActive(next); itemEls.current[next]?.focus(); }
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
// a tag page's description is its BODY (the header bar already names the node)
|
|
267
|
-
const desc = (isTag ? tagBody(node.value) : null) ?? node.description;
|
|
268
|
-
return (
|
|
269
|
-
<div className="explorerview">
|
|
270
|
-
{desc && (
|
|
271
|
-
<div className="dirhead">
|
|
272
|
-
<p className="tagdesc">{desc}</p>
|
|
273
|
-
</div>
|
|
274
|
-
)}
|
|
275
|
-
<div
|
|
276
|
-
ref={gridRef}
|
|
277
|
-
className={"dirview" + (explorerViewMode() === "large" ? " dirview-lg" : "")}
|
|
278
|
-
onKeyDown={onKeyDown}
|
|
279
|
-
>
|
|
280
|
-
{items.map((it, i) => (
|
|
281
|
-
<Item
|
|
282
|
-
key={`${it.up ? "^" : ""}${it.link?.path ?? it.key}#${i}`}
|
|
283
|
-
it={it}
|
|
284
|
-
active={i === active}
|
|
285
|
-
setRef={(el) => { itemEls.current[i] = el; }}
|
|
286
|
-
onFocus={() => setActive(i)}
|
|
287
|
-
onNavigate={onNavigate}
|
|
288
|
-
/>
|
|
289
|
-
))}
|
|
290
|
-
{items.length === 0 && <span className="dirview-empty">empty</span>}
|
|
291
|
-
</div>
|
|
292
|
-
</div>
|
|
293
|
-
);
|
|
294
|
-
}
|
|
@@ -1,149 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
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
|
-
}
|