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
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>yamlover</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/client/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yamlover",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Browse a yamlover tree in the web: npx yamlover <root> serves a React SPA over a directory.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "dims",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"yamlover",
|
|
10
|
+
"yaml",
|
|
11
|
+
"json5",
|
|
12
|
+
"viewer",
|
|
13
|
+
"browser",
|
|
14
|
+
"spa",
|
|
15
|
+
"knowledge-graph",
|
|
16
|
+
"pointers"
|
|
17
|
+
],
|
|
18
|
+
"bin": {
|
|
19
|
+
"yamlover": "bin/yamlover.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin",
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"index.html"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"start": "node bin/yamlover.js",
|
|
29
|
+
"build": "node scripts/build.mjs",
|
|
30
|
+
"prepack": "node scripts/build.mjs",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@asciidoctor/core": "^3.0.4",
|
|
39
|
+
"@tmcw/togeojson": "^7.1.2",
|
|
40
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
41
|
+
"ag-psd": "^28.5.1",
|
|
42
|
+
"fflate": "^0.8.3",
|
|
43
|
+
"heic2any": "^0.0.4",
|
|
44
|
+
"ignore": "^6.0.2",
|
|
45
|
+
"js-yaml": "^4.1.0",
|
|
46
|
+
"katex": "^0.16.47",
|
|
47
|
+
"leaflet": "^1.9.4",
|
|
48
|
+
"mammoth": "^1.12.0",
|
|
49
|
+
"marked": "^18.0.4",
|
|
50
|
+
"react": "^18.3.1",
|
|
51
|
+
"react-dom": "^18.3.1",
|
|
52
|
+
"react-pdf": "^10.4.1",
|
|
53
|
+
"utif": "^3.1.0",
|
|
54
|
+
"vite": "^5.4.11",
|
|
55
|
+
"xlsx": "^0.18.5"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@testing-library/dom": "^10.4.0",
|
|
59
|
+
"@testing-library/react": "^16.1.0",
|
|
60
|
+
"@types/js-yaml": "^4.0.9",
|
|
61
|
+
"esbuild": "^0.21.5",
|
|
62
|
+
"xxhash-wasm": "^1.1.0",
|
|
63
|
+
"@types/katex": "^0.16.8",
|
|
64
|
+
"@types/leaflet": "^1.9.21",
|
|
65
|
+
"@types/node": "^22.10.1",
|
|
66
|
+
"@types/react": "^18.3.12",
|
|
67
|
+
"@types/react-dom": "^18.3.1",
|
|
68
|
+
"jsdom": "^25.0.1",
|
|
69
|
+
"typescript": "^5.6.3",
|
|
70
|
+
"vitest": "^2.1.8"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { fetchInfo, fetchTasks, fetchTree, PasteResult, TaskInfo, TreeNode } from "./api";
|
|
3
|
+
import { Tree } from "./Tree";
|
|
4
|
+
import { TaskStrip } from "./TaskStrip";
|
|
5
|
+
import { NodeView, Format, FORMATS, DEFAULT_FORMAT } from "./NodeView";
|
|
6
|
+
import { rendererName } from "./renderers/registry";
|
|
7
|
+
|
|
8
|
+
const isStandardFormat = (f: Format) => (FORMATS as string[]).includes(f);
|
|
9
|
+
import { crumbs, formatFromUrl, isAncestorPath, pathFromUrl, segsToStr, strToSegs, writeUrl } from "./paths";
|
|
10
|
+
import { broadcastDiff } from "./live";
|
|
11
|
+
|
|
12
|
+
// Levels of the TOC fetched at once — initially and on each lazy expand. One
|
|
13
|
+
// level keeps every fetch cheap on a huge/slow tree (a fetch only reads the
|
|
14
|
+
// directories it actually shows); deeper levels load instantly on expand.
|
|
15
|
+
const INITIAL_DEPTH = 1;
|
|
16
|
+
|
|
17
|
+
/** Return a copy of `tree` with the children of the node at `path` replaced. */
|
|
18
|
+
function replaceChildren(tree: TreeNode, path: string, children: TreeNode[]): TreeNode {
|
|
19
|
+
if (tree.path === path) return { ...tree, children };
|
|
20
|
+
if (!tree.children.length) return tree;
|
|
21
|
+
return { ...tree, children: tree.children.map((c) => replaceChildren(c, path, children)) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Merge a freshly fetched branch over the old one at the same path: the fresh rows win
|
|
25
|
+
* (labels, flags, order, additions/removals), but a row that already had its children
|
|
26
|
+
* loaded keeps them (recursively) when the fresh fetch didn't reach that deep — so a live
|
|
27
|
+
* refresh never collapses what the user has expanded. */
|
|
28
|
+
function mergeBranch(old: TreeNode | undefined, fresh: TreeNode): TreeNode {
|
|
29
|
+
if (!old) return fresh;
|
|
30
|
+
const byPath = new Map(old.children.map((c) => [c.path, c] as const));
|
|
31
|
+
const children = fresh.children.length
|
|
32
|
+
? fresh.children.map((c) => mergeBranch(byPath.get(c.path), c))
|
|
33
|
+
: fresh.hasChildren
|
|
34
|
+
? old.children // past the fetch depth — keep the loaded subtree
|
|
35
|
+
: [];
|
|
36
|
+
return { ...fresh, children };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Return a copy of `tree` with the fresh subtree merged in at `path` (see mergeBranch). */
|
|
40
|
+
function mergeAt(tree: TreeNode, path: string, fresh: TreeNode): TreeNode {
|
|
41
|
+
if (tree.path === path) return mergeBranch(tree, fresh);
|
|
42
|
+
if (!tree.children.length) return tree;
|
|
43
|
+
return { ...tree, children: tree.children.map((c) => mergeAt(c, path, fresh)) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Find the node at `path` in the (partially loaded) tree. */
|
|
47
|
+
function findNode(tree: TreeNode, path: string): TreeNode | null {
|
|
48
|
+
if (tree.path === path) return tree;
|
|
49
|
+
for (const c of tree.children) {
|
|
50
|
+
const f = findNode(c, path);
|
|
51
|
+
if (f) return f;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The shallowest ancestor of `current` that is loaded but whose children are
|
|
57
|
+
* not yet fetched — the next branch to load while revealing `current`. */
|
|
58
|
+
function nextToLoad(tree: TreeNode, current: string): string | null {
|
|
59
|
+
const segs = strToSegs(current);
|
|
60
|
+
for (let k = 0; k < segs.length; k++) {
|
|
61
|
+
const ancestor = segsToStr(segs.slice(0, k));
|
|
62
|
+
const node = findNode(tree, ancestor);
|
|
63
|
+
if (!node) return null; // the path diverges from the tree (e.g. invalid) — stop
|
|
64
|
+
if (node.hasChildren && node.children.length === 0) return ancestor;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function App() {
|
|
70
|
+
const [tree, setTree] = useState<TreeNode | null>(null);
|
|
71
|
+
const [error, setError] = useState<string | null>(null);
|
|
72
|
+
const [current, setCurrent] = useState<string>(pathFromUrl());
|
|
73
|
+
const [format, setFormat] = useState<Format>(formatFromUrl(DEFAULT_FORMAT) as Format);
|
|
74
|
+
const [rootLabel, setRootLabel] = useState<string>(""); // CLI ROOT (breadcrumb head)
|
|
75
|
+
const [leftWidth, setLeftWidth] = useState<number>(320);
|
|
76
|
+
|
|
77
|
+
// The breadcrumb head is the ROOT given on the command line (blank if omitted).
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
fetchInfo().then((i) => setRootLabel(i.root)).catch(() => {});
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
// Long-running server tasks (indexing, hashing, …): seeded from /api/tasks (a page loaded
|
|
83
|
+
// mid-task), updated by `{type:"task"}` SSE frames; finished ones linger ~3s then drop.
|
|
84
|
+
const [tasks, setTasks] = useState<TaskInfo[]>([]);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
fetchTasks().then(setTasks).catch(() => {});
|
|
87
|
+
}, []);
|
|
88
|
+
const upsertTask = useCallback((t: TaskInfo) => {
|
|
89
|
+
setTasks((prev) => [...prev.filter((x) => x.id !== t.id), t].sort((a, b) => a.startedAt - b.startedAt));
|
|
90
|
+
if (t.state !== "running") {
|
|
91
|
+
setTimeout(() => setTasks((prev) => prev.filter((x) => x.id !== t.id)), 3000);
|
|
92
|
+
}
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
// Whether the initial URL pinned a representation; if not, a landing node that
|
|
96
|
+
// has a renderer opens in its rendered view (decided once the TOC loads).
|
|
97
|
+
const explicitFormat = useRef(new URLSearchParams(window.location.search).has("format"));
|
|
98
|
+
|
|
99
|
+
// Reflect the initial path+format back into the URL (so ?format= is present).
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
writeUrl(current, format, true);
|
|
102
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
// Load the TOC's first few levels; deeper branches load on expand.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
fetchTree(":", INITIAL_DEPTH)
|
|
108
|
+
.then(setTree)
|
|
109
|
+
.catch((e) => setError(e.message));
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
// First time the landing node appears in the TOC (immediately when shallow,
|
|
114
|
+
// after lazy-loading the path when deep-linked), open it in its renderer's
|
|
115
|
+
// view — unless the URL pinned a representation. Runs once; later tab switches
|
|
116
|
+
// and in-app navigation make their own choice.
|
|
117
|
+
const resolvedLanding = useRef(false);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (explicitFormat.current || resolvedLanding.current || !tree) return;
|
|
120
|
+
const n = findNode(tree, current);
|
|
121
|
+
if (!n) return; // not loaded along the path yet — wait for the next pass
|
|
122
|
+
resolvedLanding.current = true;
|
|
123
|
+
const rn = rendererName(n.type, n.format, n.concrete);
|
|
124
|
+
if (rn) {
|
|
125
|
+
setFormat(rn);
|
|
126
|
+
writeUrl(current, rn, true);
|
|
127
|
+
}
|
|
128
|
+
}, [tree, current]);
|
|
129
|
+
|
|
130
|
+
// Fetch a collapsed branch's children and splice them into the tree in place.
|
|
131
|
+
// `levels` is how deep to pull (default one): a renderer whose TOC rows sit
|
|
132
|
+
// deeper than its direct children (a chapter, surfacing subchapters from under
|
|
133
|
+
// its `children` wrapper) asks for more, so one expand reveals them.
|
|
134
|
+
const loadChildren = useCallback(async (path: string, levels = INITIAL_DEPTH) => {
|
|
135
|
+
const sub = await fetchTree(path, levels);
|
|
136
|
+
setTree((t) => (t ? replaceChildren(t, path, sub.children) : t));
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
// Reveal the current node: lazily load the branches along its path (one level
|
|
140
|
+
// per pass; each load re-runs this until the whole path is present), so a
|
|
141
|
+
// deep-linked / pasted URL gets its TOC entry expanded and selected.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!tree) return;
|
|
144
|
+
const next = nextToLoad(tree, current);
|
|
145
|
+
if (next) loadChildren(next).catch(() => {});
|
|
146
|
+
}, [tree, current, loadChildren]);
|
|
147
|
+
|
|
148
|
+
// Live refresh: the server watches the filesystem and pushes each reindex diff over SSE
|
|
149
|
+
// (/api/events). For every changed path we re-fetch its deepest LOADED tree branch (merged
|
|
150
|
+
// in, so expanded subtrees stay open), and re-fetch the node pane when the current node is
|
|
151
|
+
// touched. Unloaded branches need nothing — they fetch fresh on expand anyway.
|
|
152
|
+
const treeRef = useRef(tree);
|
|
153
|
+
treeRef.current = tree;
|
|
154
|
+
const currentRef = useRef(current);
|
|
155
|
+
currentRef.current = current;
|
|
156
|
+
const [refreshSignal, setRefreshSignal] = useState(0);
|
|
157
|
+
|
|
158
|
+
const refreshBranches = useCallback(async (paths: string[]) => {
|
|
159
|
+
const t = treeRef.current;
|
|
160
|
+
if (!t) return;
|
|
161
|
+
const targets = new Set<string>();
|
|
162
|
+
for (const p of paths) {
|
|
163
|
+
const segs = strToSegs(p);
|
|
164
|
+
let best = ":";
|
|
165
|
+
for (let k = 0; k < segs.length; k++) {
|
|
166
|
+
const anc = segsToStr(segs.slice(0, k));
|
|
167
|
+
const node = findNode(t, anc);
|
|
168
|
+
if (!node) break;
|
|
169
|
+
if (anc === ":" || node.children.length > 0) best = anc;
|
|
170
|
+
}
|
|
171
|
+
targets.add(best);
|
|
172
|
+
}
|
|
173
|
+
const list = [...targets].filter((a) => ![...targets].some((b) => b !== a && isAncestorPath(b, a)));
|
|
174
|
+
await Promise.all(
|
|
175
|
+
list.map(async (p) => {
|
|
176
|
+
const sub = await fetchTree(p, INITIAL_DEPTH);
|
|
177
|
+
setTree((prev) => (prev ? mergeAt(prev, p, sub) : prev));
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
// Cold-start recovery: a page opened before the FIRST index landed has no tree (the initial
|
|
183
|
+
// fetch 404'd on an empty store). When the index task finishes — or any diff arrives —
|
|
184
|
+
// retry the initial fetches and clear the stale error.
|
|
185
|
+
const retryInitial = useCallback(() => {
|
|
186
|
+
if (treeRef.current) return;
|
|
187
|
+
fetchTree(":", INITIAL_DEPTH)
|
|
188
|
+
.then((t) => {
|
|
189
|
+
setTree(t);
|
|
190
|
+
setError(null);
|
|
191
|
+
})
|
|
192
|
+
.catch(() => {});
|
|
193
|
+
fetchInfo().then((i) => setRootLabel(i.root)).catch(() => {});
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (typeof EventSource === "undefined") return; // test envs (jsdom) have no SSE
|
|
198
|
+
const es = new EventSource("/api/events");
|
|
199
|
+
es.onmessage = (ev) => {
|
|
200
|
+
try {
|
|
201
|
+
const msg = JSON.parse(ev.data) as { type?: string };
|
|
202
|
+
if (msg.type === "task") {
|
|
203
|
+
const t = (msg as unknown as { task: TaskInfo }).task;
|
|
204
|
+
upsertTask(t);
|
|
205
|
+
if (t.state === "done") retryInitial(); // no-op once the tree is loaded
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const diff = msg as unknown as {
|
|
209
|
+
added: string[]; changed: string[]; removed: string[];
|
|
210
|
+
moved?: { from: string; to: string }[]; // inferred moves (frame-compatible: optional)
|
|
211
|
+
};
|
|
212
|
+
retryInitial(); // no-op once the tree is loaded
|
|
213
|
+
const paths = [...diff.added, ...diff.changed, ...diff.removed,
|
|
214
|
+
...(diff.moved ?? []).flatMap((m) => [m.from, m.to])];
|
|
215
|
+
if (!paths.length) return;
|
|
216
|
+
// Re-broadcast for the useDiffBump subscribers (live.ts — the unified change flow):
|
|
217
|
+
// hooks outside this component's prop reach refetch from the same diff currency.
|
|
218
|
+
broadcastDiff({ paths, removed: diff.removed });
|
|
219
|
+
refreshBranches(paths).catch(() => {});
|
|
220
|
+
const cur = currentRef.current;
|
|
221
|
+
if (paths.some((p) => p === cur || isAncestorPath(p, cur) || isAncestorPath(cur, p))) {
|
|
222
|
+
setRefreshSignal((s) => s + 1);
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// a malformed frame — ignore
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
return () => es.close();
|
|
229
|
+
}, [refreshBranches, upsertTask, retryInitial]);
|
|
230
|
+
|
|
231
|
+
// Keep state in sync with the browser's back/forward buttons.
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
const onPop = () => {
|
|
234
|
+
setCurrent(pathFromUrl());
|
|
235
|
+
setFormat(formatFromUrl(DEFAULT_FORMAT) as Format);
|
|
236
|
+
};
|
|
237
|
+
window.addEventListener("popstate", onPop);
|
|
238
|
+
return () => window.removeEventListener("popstate", onPop);
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
// Navigating changes the path. The format carries over, except: a target that
|
|
242
|
+
// has a renderer opens in that renderer's view (so e.g. a chapter reads as a
|
|
243
|
+
// page, with its tab active), and leaving a renderer node drops a stale renderer
|
|
244
|
+
// format back to the default. A target absent from the TOC keeps the current
|
|
245
|
+
// format — reading deeper into a rendered tree stays rendered. NodeView makes
|
|
246
|
+
// the final call from the fetched node's (type, format).
|
|
247
|
+
const navigate = useCallback(
|
|
248
|
+
(p: string) => {
|
|
249
|
+
const target = tree ? findNode(tree, p) : null;
|
|
250
|
+
let f: Format = format;
|
|
251
|
+
if (target) {
|
|
252
|
+
const rn = rendererName(target.type, target.format, target.concrete);
|
|
253
|
+
f = rn ?? (isStandardFormat(format) ? format : DEFAULT_FORMAT);
|
|
254
|
+
}
|
|
255
|
+
writeUrl(p, f, false);
|
|
256
|
+
setCurrent(p);
|
|
257
|
+
if (f !== format) setFormat(f);
|
|
258
|
+
},
|
|
259
|
+
[format, tree],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const changeFormat = useCallback(
|
|
263
|
+
(f: Format) => {
|
|
264
|
+
writeUrl(current, f, true);
|
|
265
|
+
setFormat(f);
|
|
266
|
+
},
|
|
267
|
+
[current],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// A paste/upload added a file (and maybe a chapter chunk) at `p`. Reload that branch of the TOC
|
|
271
|
+
// so a new directory child shows up; fall back to the root when the branch isn't loaded yet.
|
|
272
|
+
const onContentChanged = useCallback(
|
|
273
|
+
(p: string) => {
|
|
274
|
+
const target = tree ? findNode(tree, p) : null;
|
|
275
|
+
loadChildren(target ? p : ":").catch(() => {});
|
|
276
|
+
},
|
|
277
|
+
[tree, loadChildren],
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// A file pasted/dropped onto a directory MEMBER landed in the enclosing dir — open it. We fetch
|
|
281
|
+
// the dir's branch first (so the TOC shows the file AND we learn its type/format), then navigate
|
|
282
|
+
// to it in its renderer's view (an image opens as an image, not as data). Falls back to a plain
|
|
283
|
+
// navigate if the branch fetch fails.
|
|
284
|
+
const onOpenUploaded = useCallback(
|
|
285
|
+
async (result: PasteResult) => {
|
|
286
|
+
const dir = result.dir ?? ":";
|
|
287
|
+
try {
|
|
288
|
+
const sub = await fetchTree(dir, INITIAL_DEPTH);
|
|
289
|
+
setTree((t) => (t ? replaceChildren(t, dir, sub.children) : t));
|
|
290
|
+
const fileNode = sub.children.find((c) => c.path === result.path);
|
|
291
|
+
const f: Format = (fileNode ? rendererName(fileNode.type, fileNode.format, fileNode.concrete) : null) ?? DEFAULT_FORMAT;
|
|
292
|
+
writeUrl(result.path, f, false);
|
|
293
|
+
setCurrent(result.path);
|
|
294
|
+
setFormat(f);
|
|
295
|
+
} catch {
|
|
296
|
+
navigate(result.path);
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
[navigate],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// --- draggable splitter -------------------------------------------------- //
|
|
303
|
+
const dragging = useRef(false);
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
const onMove = (e: MouseEvent) => {
|
|
306
|
+
if (!dragging.current) return;
|
|
307
|
+
setLeftWidth(Math.min(Math.max(e.clientX, 160), window.innerWidth - 240));
|
|
308
|
+
};
|
|
309
|
+
const onUp = () => {
|
|
310
|
+
dragging.current = false;
|
|
311
|
+
document.body.style.userSelect = "";
|
|
312
|
+
};
|
|
313
|
+
window.addEventListener("mousemove", onMove);
|
|
314
|
+
window.addEventListener("mouseup", onUp);
|
|
315
|
+
return () => {
|
|
316
|
+
window.removeEventListener("mousemove", onMove);
|
|
317
|
+
window.removeEventListener("mouseup", onUp);
|
|
318
|
+
};
|
|
319
|
+
}, []);
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<div className="app">
|
|
323
|
+
<header className="topbar">
|
|
324
|
+
<nav className="crumbs">
|
|
325
|
+
{crumbs(current, rootLabel).map((c, i) => (
|
|
326
|
+
<span key={c.path}>
|
|
327
|
+
{i > 0 && <span className="crumb-sep">:</span>}
|
|
328
|
+
<a
|
|
329
|
+
className="crumb"
|
|
330
|
+
href={c.path}
|
|
331
|
+
onClick={(e) => {
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
navigate(c.path);
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
{c.label}
|
|
337
|
+
</a>
|
|
338
|
+
</span>
|
|
339
|
+
))}
|
|
340
|
+
</nav>
|
|
341
|
+
<TaskStrip tasks={tasks} />
|
|
342
|
+
</header>
|
|
343
|
+
|
|
344
|
+
<div className="body">
|
|
345
|
+
<aside className="pane left" style={{ width: leftWidth }}>
|
|
346
|
+
{(() => {
|
|
347
|
+
// no tree yet + a server task running ⇒ the index is still being built — show
|
|
348
|
+
// its progress instead of a stale fetch error / a bare "loading…"
|
|
349
|
+
const running = !tree ? tasks.find((t) => t.state === "running") : undefined;
|
|
350
|
+
if (running) {
|
|
351
|
+
const { done, total } = running.progress;
|
|
352
|
+
return <div className="loading">{running.label}… {total ? `${done}/${total}` : ""}</div>;
|
|
353
|
+
}
|
|
354
|
+
if (error) return <div className="error">{error}</div>;
|
|
355
|
+
if (!tree) return <div className="loading">loading…</div>;
|
|
356
|
+
return <Tree node={tree} current={current} onSelect={navigate} onLoadChildren={loadChildren} />;
|
|
357
|
+
})()}
|
|
358
|
+
</aside>
|
|
359
|
+
<div
|
|
360
|
+
className="splitter"
|
|
361
|
+
onMouseDown={() => {
|
|
362
|
+
dragging.current = true;
|
|
363
|
+
document.body.style.userSelect = "none";
|
|
364
|
+
}}
|
|
365
|
+
/>
|
|
366
|
+
<main className="pane right">
|
|
367
|
+
<NodeView path={current} format={format} refreshSignal={refreshSignal} onFormat={changeFormat} onNavigate={navigate} onContentChanged={onContentChanged} onOpenUploaded={onOpenUploaded} />
|
|
368
|
+
</main>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|