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:
parent
0df26d4ce6
commit
786a65ae2a
48 changed files with 5762 additions and 3999 deletions
195
tests/run-fullbook-test.mjs
Normal file
195
tests/run-fullbook-test.mjs
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue