Replace markdown editor with native Tiptap editor and add Home navigation

- Migrate renderer from md-editor/md-format to a Tiptap-based native editor
  (native-doc pipeline: native-doc, markdown-to-native, native-to-markdown,
  native-assembly, migration, backup)
- Use pointer-event-based chapter reordering with grab/grabbing cursors to
  avoid the Wayland prohibited-drop cursor while preserving reordering
- Add a persistent Home button that closes the open book and returns to the
  Folio library/welcome screen
- Update app icons, package deps, theme styles, and tests
This commit is contained in:
avi 2026-08-20 11:12:17 -05:00
commit 786a65ae2a
48 changed files with 5762 additions and 3999 deletions

195
tests/run-fullbook-test.mjs Normal file
View file

@ -0,0 +1,195 @@
// Runner for the native Full Book test. Builds a jsdom DOM, bundles the
// renderer assembly + Tiptap modules, and verifies that a book assembled from
// native chapters renders with correct chapter order, page-break anchors,
// reorder behavior, title injection, and Copy-All native fidelity.
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
const { window } = dom;
const g = globalThis;
g.window = window;
g.document = window.document;
g.HTMLElement = window.HTMLElement;
g.Element = window.Element;
g.Node = window.Node;
g.MutationObserver = window.MutationObserver;
g.getComputedStyle = window.getComputedStyle.bind(window);
g.DOMParser = window.DOMParser;
g.requestAnimationFrame = (cb) => setTimeout(cb, 0);
g.cancelAnimationFrame = (id) => clearTimeout(id);
const out = path.join(os.tmpdir(), `folio-fullbook-${process.pid}.mjs`);
const entry = path.resolve("tests/.folio-fullbook-test-entry.ts");
await esbuild.build({
entryPoints: [entry],
bundle: true,
platform: "node",
format: "esm",
outfile: out,
write: true,
logLevel: "silent",
});
const mod = await import(url.pathToFileURL(out).href);
const {
buildFullBookDoc,
nativeNodeText,
createTiptapEditor,
setEditorDoc,
getEditorDoc,
ChapterBreak,
CURRENT_DOC_SCHEMA_VERSION,
} = mod;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else {
failures++;
console.error("FAIL -", msg);
}
}
function findNodes(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
for (const c of node.content ?? []) findNodes(c, type, acc);
return acc;
}
// A native chapter doc with a single paragraph of the given text.
function paraDoc(text) {
return {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
};
}
function headingDoc(text) {
return {
type: "doc",
content: [{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text }] }],
};
}
function makeFullBookEditor() {
const host = window.document.createElement("div");
window.document.body.appendChild(host);
return createTiptapEditor(host, { onChange: () => undefined }, { editable: false, extensions: [ChapterBreak] });
}
// --- canonical chapter order / navigation anchors ---
const chapters = [
{ id: "c1", title: "One", markdown: "Body one", doc: paraDoc("Body one") },
{ id: "c2", title: "Two", markdown: "Body two", doc: paraDoc("Body two") },
{ id: "c3", title: "Three", markdown: "Body three", doc: paraDoc("Body three") },
];
const assembled = buildFullBookDoc(chapters);
ok(assembled.chapterEntries.length === 3, "three chapter entries produced");
ok(
assembled.chapterEntries.map((c) => c.id).join(",") === "c1,c2,c3",
"chapter order follows canonical chapterOrder"
);
ok(
findNodes(assembled.nativeDoc, "chapterBreak").length === 3,
"one chapterBreak anchor per chapter"
);
ok(
findNodes(assembled.nativeDoc, "chapterBreak")[0].attrs.first === true,
"first chapter break is marked first"
);
ok(
findNodes(assembled.nativeDoc, "chapterBreak").slice(1).every((n) => n.attrs.first === false),
"subsequent chapter breaks are not first (page breaks)"
);
// Render into the read-only editor and check DOM anchors in order.
const editor = makeFullBookEditor();
setEditorDoc(editor, assembled.nativeDoc);
const anchors = Array.from(editor.view.dom.querySelectorAll("[data-chapter-id]"));
ok(anchors.length === 3, "three DOM anchors rendered");
ok(
anchors.map((a) => a.getAttribute("data-chapter-id")).join(",") === "c1,c2,c3",
"DOM anchors preserve canonical order"
);
ok(anchors[0].classList.contains("first"), "first anchor carries first class");
ok(
anchors.slice(1).every((a) => !a.classList.contains("first")),
"subsequent anchors are plain chapter-break separators"
);
// --- reorder impact on Full Book output ---
const reordered = buildFullBookDoc([chapters[1], chapters[0], chapters[2]]);
const reAnchors = Array.from(
(() => {
const e = makeFullBookEditor();
setEditorDoc(e, reordered.nativeDoc);
return e.view.dom;
})().querySelectorAll("[data-chapter-id]")
);
ok(
reAnchors.map((a) => a.getAttribute("data-chapter-id")).join(",") === "c2,c1,c3",
"reordering chapterOrder reorders Full Book output"
);
// --- title injection fidelity ---
const withHeading = buildFullBookDoc([
{ id: "h1", title: "Title", markdown: "# Title\nrest", doc: headingDoc("Title") },
]);
ok(
findNodes(withHeading.nativeDoc, "heading").length === 1,
"chapter already starting with matching heading is not duplicated"
);
const withoutHeading = buildFullBookDoc([
{ id: "h2", title: "Title", markdown: "body", doc: paraDoc("body") },
]);
const heads = findNodes(withoutHeading.nativeDoc, "heading");
ok(heads.length === 1 && heads[0].attrs.level === 1, "missing title heading is injected as H1");
ok(
nativeNodeText(withoutHeading.nativeDoc).includes("Title"),
"injected title text appears in the assembled book"
);
// --- Copy All native fidelity (round trip) ---
const copyJson = JSON.stringify(getEditorDoc(editor));
const parsed = JSON.parse(copyJson);
const firstDoc = getEditorDoc(editor).doc;
ok(parsed.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "copied native doc has schema version");
ok(
findNodes(parsed.doc, "chapterBreak").length === 3,
"copied native doc preserves chapterBreak anchors"
);
const editor2 = makeFullBookEditor();
setEditorDoc(editor2, parsed.doc);
ok(
JSON.stringify(getEditorDoc(editor2).doc) === JSON.stringify(firstDoc),
"copied native doc reloads byte-identical to editor state"
);
ok(
nativeNodeText(parsed.doc).includes("Body one") &&
nativeNodeText(parsed.doc).includes("Body three"),
"copied native content preserves chapter text"
);
// --- word / char counts ---
const text = nativeNodeText(assembled.nativeDoc);
ok(text.includes("Body one") && text.includes("Body three"), "word/char source includes all chapters");
ok(text.split(/\s+/).filter(Boolean).length >= 3, "word count reflects chapter text");
// --- empty book ---
const empty = buildFullBookDoc([]);
ok(
empty.nativeDoc.type === "doc" && Array.isArray(empty.nativeDoc.content) && empty.nativeDoc.content.length === 0,
"empty book assembles to empty doc"
);
fs.rmSync(out, { force: true });
console.log("\nFull Book tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
process.exit(failures === 0 ? 0 : 1);