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

View file

@ -1,7 +1,8 @@
// Standalone test for the DOCX/ODT Full Book export (office-export.ts).
// Bundles the main-process module with esbuild and drives buildDocx/buildOdt
// against a throwaway book, then inspects the produced ZIP contents to check
// that the manuscript, its chapter order, and its formatting survive.
// against a throwaway book whose chapters are stored as native Folio document
// JSON, then inspects the produced ZIP contents to check that the manuscript,
// its chapter order, and its formatting survive the native -> DOCX path.
// Run with: node tests/run-office-export-test.mjs
import esbuild from "esbuild";
import fs from "fs";
@ -30,94 +31,161 @@ const check = (name, ok) => {
if (!ok) failures += 1;
};
// --- fixture book -----------------------------------------------------------
// --- fixture book (native JSON chapters) ------------------------------------
const bookDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-office-test-"));
fs.mkdirSync(path.join(bookDir, "chapters"), { recursive: true });
const chapters = {};
const order = [];
const addChapter = (id, title, body) => {
const file = path.posix.join("chapters", `${id}.md`);
// Build a native chapter file ({ schemaVersion, doc }).
const addChapter = (id, title, content) => {
const file = path.posix.join("chapters", `${id}.json`);
order.push(id);
chapters[id] = { id, title, file };
fs.writeFileSync(path.join(bookDir, file), body);
fs.writeFileSync(
path.join(bookDir, file),
JSON.stringify({ schemaVersion: 1, doc: { type: "doc", content } }, null, 2)
);
};
addChapter(
"ch1",
"Alpha",
[
"# Alpha",
"",
"**bold** *italic* ~~strike~~ ==highlight== <u>under</u>",
"",
'<span style="color:#e11d48">red</span> <span style="background-color:#fde047">marked</span>',
"",
"- bullet one",
"- bullet two",
" - nested bullet",
"",
"1. numbered one",
"2. numbered two",
"",
"> [!note] Callout title",
"> callout body",
"",
"> plain quote",
"",
"```js",
"const answer = 42;",
"```",
"",
"---",
"",
"| Head A | Head B |",
"| --- | --- |",
"| cell 1 | cell 2 |",
"",
"[Folio](https://example.com)",
"",
"[[Beta|Go to Beta]]",
"",
'<div align="center">centered text</div>',
].join("\n")
// Inline helpers --------------------------------------------------------------
const text = (t, marks = []) => ({ type: "text", text: t, marks });
const para = (content, attrs = {}) => ({ type: "paragraph", attrs, content });
const heading = (level, t) => ({ type: "heading", attrs: { level }, content: [text(t)] });
const listItem = (content) => ({ type: "listItem", content });
addChapter("ch1", "Alpha", [
heading(1, "Alpha"),
para([
text("bold", [{ type: "bold" }]),
text(" "),
text("italic", [{ type: "italic" }]),
text(" "),
text("strike", [{ type: "strike" }]),
text(" "),
text("highlight", [{ type: "highlight" }]),
text(" "),
text("under", [{ type: "underline" }]),
]),
para([
text("red", [{ type: "textColor", attrs: { color: "#e11d48" } }]),
text(" "),
text("marked", [{ type: "highlight", attrs: { color: "#fde047" } }]),
]),
{
type: "bulletList",
content: [
listItem([para([text("bullet one")])]),
listItem([
para([text("bullet two")]),
{
type: "bulletList",
content: [listItem([para([text("nested bullet")])])],
},
]),
],
},
{
type: "orderedList",
content: [
listItem([para([text("numbered one")])]),
listItem([para([text("numbered two")])]),
],
},
{
type: "blockquote",
attrs: { callout: "note" },
content: [
para([text("Callout title")], { class: "callout-title" }),
para([text("callout body")]),
],
},
{
type: "blockquote",
content: [para([text("plain quote")])],
},
{ type: "codeBlock", attrs: { language: "js" }, content: [text("const answer = 42;")] },
{ type: "horizontalRule" },
{
type: "table",
content: [
{
type: "tableRow",
content: [
{ type: "tableHeader", content: [para([text("Head A")])] },
{ type: "tableHeader", content: [para([text("Head B")])] },
],
},
{
type: "tableRow",
content: [
{ type: "tableCell", content: [para([text("cell 1")])] },
{ type: "tableCell", content: [para([text("cell 2")])] },
],
},
],
},
para([text("Folio", [{ type: "link", attrs: { href: "https://example.com" } }])]),
para([text("Go to Beta", [{ type: "link", attrs: { href: "folio:chapter/Beta" } }])]),
para([text("centered text")], { textAlign: "center" }),
]);
addChapter("ch2", "Beta", [para([text("This chapter has no heading of its own.")])]);
addChapter("ch3", "Gamma", [
heading(1, "Gamma"),
heading(2, "Subsection"),
para([text("trailing paragraph")]),
]);
addChapter("ch4", "Empty", []);
addChapter("ch5", "Unicode", [para([text("Café — naïve naïve 中文 📖")])]);
// Real image file so the DOCX embedding path (ImageRun) is exercised: a
// relative src must resolve against the book directory and the bytes survive.
fs.mkdirSync(path.join(bookDir, "assets"), { recursive: true });
fs.writeFileSync(
path.join(bookDir, "assets", "pic.png"),
Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64")
);
addChapter("ch2", "Beta", "This chapter has no heading of its own.");
addChapter("ch3", "Gamma", `# Gamma\n\n## Subsection\n\ntrailing paragraph`);
addChapter("ch4", "Empty", "");
addChapter("ch5", "Unicode", "Café — naïve naïve 中文 📖");
addChapter("ch6", "Pics", [{ type: "image", attrs: { src: "assets/pic.png", alt: "a picture" } }]);
fs.writeFileSync(
path.join(bookDir, "folio.json"),
JSON.stringify({ app: "folio", version: 1, title: "Office Test Book", chapterOrder: order, chapters })
);
const origFiles = order.map((id) => chapters[id].file).map((f) => [f, fs.readFileSync(path.join(bookDir, f))]);
const origFiles = order
.map((id) => chapters[id].file)
.map((f) => [f, fs.readFileSync(path.join(bookDir, f))]);
const origManifest = fs.readFileSync(path.join(bookDir, "folio.json"));
const docxBuf = await mod.buildDocx(bookDir);
const docxZip = await JSZip.loadAsync(docxBuf);
const docxXml = await docxZip.file("word/document.xml").async("string");
const docxStyles = await docxZip.file("word/styles.xml").async("string");
const docxXml = await docxZip.file("word/document.xml").async("string");
const docxStyles = await docxZip.file("word/styles.xml").async("string");
check("docx is a zip (PK magic)", docxBuf.subarray(0, 2).toString("utf-8") === "PK");
check("docx has [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
check("docx has word/document.xml", !!docxZip.file("word/document.xml"));
check("docx is a zip (PK magic)", docxBuf.subarray(0, 2).toString("utf-8") === "PK");
check("docx has [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
check("docx has word/document.xml", !!docxZip.file("word/document.xml"));
const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
const docxText = stripTags(docxXml);
check("docx contains book title", docxText.includes("Office Test Book"));
check("docx contains byline", docxText.includes("A book compiled with Folio."));
const docxText = stripTags(docxXml);
check("docx contains book title", docxText.includes("Office Test Book"));
check("docx contains byline", docxText.includes("A book compiled with Folio."));
const idxAlpha = docxText.indexOf("Alpha");
const idxBeta = docxText.indexOf("Beta");
const idxGamma = docxText.indexOf("Gamma");
const idxUnicode = docxText.indexOf("Unicode");
const idxPics = docxText.indexOf("Pics");
check(
"docx keeps chapter order",
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode && idxUnicode < idxPics
);
const mediaFiles = Object.keys(docxZip.files).filter((f) => f.startsWith("word/media/"));
check("docx embeds the image (no data loss)", mediaFiles.length >= 1);
check("docx has bold", /<w:b\/>/.test(docxXml));
check("docx has italic", /<w:i\/>/.test(docxXml));
check("docx has strike", /<w:strike\/>/.test(docxXml));
@ -140,8 +208,8 @@ check("docx has 1.7 line spacing", /<w:spacing[^>]*w:line="408"[^>]*w:lineRule="
check("docx has blank-line paragraph spacing", /<w:spacing[^>]*w:after="408"/.test(docxXml));
check("docx has chapter break before later chapters", (docxXml.match(/w:before="320"/g) || []).length >= 4);
check("docx has callout shading", /w:fill="F5F2ED"/.test(docxXml));
check("docx renders ==highlight== (no raw markup)", !docxText.includes("==highlight=="));
check("docx renders **bold** (no raw markup)", !docxText.includes("**bold**"));
check("docx renders highlight (no raw markup)", !docxText.includes("==highlight=="));
check("docx renders bold (no raw markup)", !docxText.includes("**bold**"));
check("docx resolves wikilink label", docxText.includes("Go to Beta"));
check("docx has no raw wikilink brackets", !docxText.includes("[[Beta"));
check("docx headingless chapter gets title", docxText.includes("Beta") && docxText.includes("no heading"));
@ -190,4 +258,4 @@ if (failures > 0) {
console.error(`\n${failures} check(s) failed.`);
process.exit(1);
}
console.log("\nAll office-export checks passed.");
console.log("\nAll office-export checks passed.");