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,966 +0,0 @@
/* Selftest scenario - runs inside the renderer via executeJavaScript.
Injected only when FOLIO_SELF_TEST=1. Exercises the full
renderer -> preload -> IPC -> chapters chain. */
const BOOK_DIR = __BOOKDIR_VALUE__;
const BOOK2_DIR = __BOOKDIR2_VALUE__;
const BOOK3_DIR = __BOOKDIR3_VALUE__;
(async () => {
const out = {};
try {
out.folioType = typeof window.folio;
out.recentsCount = (await window.folio.getRecents()).length;
out.openBook = await window.folio.openBookPath(BOOK_DIR);
out.metaTitle = (await window.folio.getBookMeta()).title;
const created = await window.folio.createChapter("Introduction");
out.created = created && !created.error ? { id: created.id, title: created.title } : created;
const cid = created.id;
out.contentEmpty = await window.folio.getChapterContent(cid);
out.listLen1 = (await window.folio.getChapterList()).length;
out.renamed = await window.folio.renameChapter(cid, "Intro");
const dup = await window.folio.duplicateChapter(cid);
out.dup = dup && !dup.error ? { id: dup.id, title: dup.title } : dup;
const did = dup.id;
out.listLen2 = (await window.folio.getChapterList()).length;
out.deleted = await window.folio.deleteChapter(cid);
const remaining = (await window.folio.getChapterList())[0];
out.listLen3 = (await window.folio.getChapterList()).length;
out.reorderOk = await window.folio.reorderChapters([remaining.id]);
out.reorderErr = await window.folio.reorderChapters(["bogus-id"]);
out.dupContent = await window.folio.getChapterContent(did);
// --- drag & drop reorder test ---
const a = (await window.folio.createChapter("Alpha")).id;
await new Promise((r) => setTimeout(r, 80));
const b = (await window.folio.createChapter("Bravo")).id;
await new Promise((r) => setTimeout(r, 80));
const c = (await window.folio.createChapter("Charlie")).id;
await new Promise((r) => setTimeout(r, 200));
const orderBefore = (await window.folio.getBookMeta()).chapterOrder;
out.orderBeforeDnD = orderBefore;
const rows = document.getElementById("chapterList").querySelectorAll(".chapter-row");
out.dndRowCount = rows.length;
const draggedId = rows[0].dataset.id;
const targetId = rows[2].dataset.id;
const dt = new DataTransfer();
rows[0].dispatchEvent(new DragEvent("dragstart", { dataTransfer: dt, bubbles: true }));
rows[2].dispatchEvent(new DragEvent("dragover", { dataTransfer: dt, bubbles: true }));
rows[2].dispatchEvent(new DragEvent("drop", { dataTransfer: dt, bubbles: true }));
rows[0].dispatchEvent(new DragEvent("dragend", { dataTransfer: dt, bubbles: true }));
await new Promise((r) => setTimeout(r, 250));
const orderAfter = (await window.folio.getBookMeta()).chapterOrder;
out.orderAfterDnD = orderAfter;
out.dndMoved =
orderAfter[0] === orderBefore[1] &&
orderAfter[2] === orderBefore[0] &&
orderAfter[3] === orderBefore[3];
// keyboard reorder: select first visible row, Alt+ArrowDown moves it down
const fresh1 = document.getElementById("chapterList").querySelectorAll(".chapter-row");
fresh1[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((r) => setTimeout(r, 120));
const fresh2 = document.getElementById("chapterList").querySelectorAll(".chapter-row");
fresh2[0].focus();
fresh2[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", altKey: true, bubbles: true }));
await new Promise((r) => setTimeout(r, 250));
const orderKb = (await window.folio.getBookMeta()).chapterOrder;
out.orderAfterKb = orderKb;
out.kbMoved = orderKb[0] === orderAfter[1] && orderKb[1] === orderAfter[0];
// UI-driven chapter creation via the in-app modal (window.prompt is unsupported)
const countBefore = (await window.folio.getBookMeta()).chapterOrder.length;
document.getElementById("addChapterBtn").click();
await new Promise((r) => setTimeout(r, 80));
const modalInput = document.getElementById("modalInput");
out.modalShown = !!modalInput && !document.getElementById("modalBackdrop").classList.contains("hidden");
modalInput.value = "Via Modal";
document.getElementById("modalOk").click();
await new Promise((r) => setTimeout(r, 300));
const metaAfterUi = await window.folio.getBookMeta();
out.uiCreated = metaAfterUi.chapterOrder.length === countBefore + 1 &&
Object.values(metaAfterUi.chapters).some((c) => c.title === "Via Modal");
// CodeMirror editor: typing, cursor/selection, autosave persistence
const cm = window.__folioEditor;
out.hasEditor = !!cm;
out.editorLoaded = cm ? cm.state.doc.toString() : null;
if (cm) {
cm.dispatch({
changes: {
from: 0,
to: cm.state.doc.length,
insert: "# Heading One\n\n**bold** and *italic* text\n\n- bullet one\n- bullet two\n\n> quoted line\n\n[link](https://example.com) and `inline code`\n\n---",
},
});
cm.dispatch({ selection: { anchor: cm.state.doc.length } });
}
await new Promise((r) => setTimeout(r, 1200));
out.editorDocAfterType = cm ? cm.state.doc.toString() : null;
const typedId = metaAfterUi.chapterOrder[metaAfterUi.chapterOrder.length - 1];
const typedContent = await window.folio.getChapterContent(typedId);
out.editorAutosaved =
!!cm &&
cm.state.doc.toString().includes("**bold** and *italic*") &&
typedContent.includes("# Heading One") &&
typedContent.includes("- bullet two") &&
typedContent.includes("> quoted line") &&
typedContent.includes("[link](https://example.com)") &&
typedContent.includes("`inline code`");
// --- formatting toolbar + keyboard shortcuts ---
if (cm) {
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" }, selection: { anchor: 0 } });
cm.focus();
cm.dispatch({ changes: { from: 0, to: 0, insert: "Hello World" }, selection: { anchor: 0, head: 5 } });
document.querySelector('[data-cmd="bold"]').click();
await new Promise((r) => setTimeout(r, 50));
out.toolbarBoldOk = cm.state.doc.toString() === "**Hello** World";
out.focusAfterBold = document.activeElement === cm.contentDOM;
cm.dispatch({ selection: { anchor: 10, head: 15 } }); // "World"
cm.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", { key: "b", ctrlKey: true, bubbles: true, cancelable: true })
);
await new Promise((r) => setTimeout(r, 50));
out.shortcutBoldOk = cm.state.doc.toString() === "**Hello** **World**";
const headingSel = document.getElementById("headingSelect");
headingSel.value = "2";
headingSel.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
out.headingOk = cm.state.doc.toString().startsWith("## ");
document.querySelector('[data-cmd="undo"]').click();
await new Promise((r) => setTimeout(r, 50));
out.undoOk = cm.state.doc.toString() === "**Hello** **World**";
document.querySelector('[data-cmd="redo"]').click();
await new Promise((r) => setTimeout(r, 50));
out.redoOk = cm.state.doc.toString().startsWith("## ");
cm.dispatch({ selection: { anchor: 15, head: 20 } }); // "World"
cm.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", {
key: "s",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
})
);
await new Promise((r) => setTimeout(r, 50));
out.strikeShortcutOk = cm.state.doc.toString().includes("~~World~~");
cm.dispatch({
changes: { from: 0, to: 0, insert: "line one\nline two\n" },
selection: { anchor: 0, head: 16 },
});
document.querySelector('[data-cmd="codeblock"]').click();
await new Promise((r) => setTimeout(r, 50));
out.fencedCodeOk = cm.state.doc.toString().startsWith("```\nline one\nline two\n```");
// underline shortcut (Ctrl+U)
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "Under me" }, selection: { anchor: 0, head: 5 } });
cm.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", { key: "u", ctrlKey: true, bubbles: true, cancelable: true })
);
await new Promise((r) => setTimeout(r, 50));
out.underlineOk = cm.state.doc.toString().startsWith("<u>Under</u>");
// task list via toolbar
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "task one\ntask two" }, selection: { anchor: 0, head: 8 } });
document.querySelector('[data-cmd="checklist"]').click();
await new Promise((r) => setTimeout(r, 50));
out.checklistOk = cm.state.doc.toString().startsWith("- [ ] task one");
// highlight via toolbar
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "cell text" }, selection: { anchor: 0, head: 4 } });
document.querySelector('[data-cmd="highlight"]').click();
await new Promise((r) => setTimeout(r, 50));
out.highlightOk = cm.state.doc.toString().startsWith("==cell==");
// table via toolbar dropdown config
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
document.getElementById("tableRows").value = "3";
document.getElementById("tableCols").value = "2";
document.querySelector('[data-action="table"]').click();
await new Promise((r) => setTimeout(r, 50));
out.tableOk = cm.state.doc.toString().startsWith("| Col 1 | Col 2 |\n| --- | --- |");
// format painter
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "**src** dst" }, selection: { anchor: 3, head: 3 } });
document.querySelector('[data-cmd="painter"]').click();
cm.dispatch({ selection: { anchor: 8, head: 11 } });
document.querySelector('[data-cmd="painter"]').click();
await new Promise((r) => setTimeout(r, 50));
out.painterOk = cm.state.doc.toString() === "**src** **dst**";
// text color via the toolbar dropdown.
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "color me" }, selection: { anchor: 0, head: 5 } });
cm.focus();
const colorToggle = document.querySelector('[data-dropdown="colorMenu"]');
colorToggle?.click();
await new Promise((r) => setTimeout(r, 50));
const colorMenuEl = document.getElementById("colorMenu");
const colorRect = colorMenuEl?.getBoundingClientRect();
out.colorMenuOpens =
!!colorRect && !colorMenuEl?.classList.contains("hidden") && colorRect.bottom <= window.innerHeight + 1;
// the dropdown is pinned to the viewport, so it must stay on screen even
// though the toolbar itself is a horizontal scroll container
out.colorMenuOnScreen = !!colorRect && colorRect.top >= 0 && colorRect.bottom <= window.innerHeight;
document.querySelector('[data-cmd="color:#e11d48"]').click();
await new Promise((r) => setTimeout(r, 50));
out.colorApplied = cm.state.doc.toString() === '<span style="color:#e11d48">color</span> me';
// custom color picker works
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "pick me" }, selection: { anchor: 0, head: 4 } });
cm.focus();
colorToggle?.click();
await new Promise((r) => setTimeout(r, 50));
const customInput = document.getElementById("customTextColor");
customInput.value = "#0066cc";
customInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
out.customColorApplied = cm.state.doc.toString() === '<span style="color:#0066cc">pick</span> me';
// leave some formatted content on disk for the autosave check
cm.dispatch({
changes: { from: 0, to: cm.state.doc.length, insert: "**Hello** World" },
selection: { anchor: 0 },
});
await new Promise((r) => setTimeout(r, 1200)); // wait for autosave debounce
out.formattingAutosaved =
(await window.folio.getChapterContent(typedId)).includes("**Hello**");
}
// --- full book view (fresh second book with known chapter contents) ---
await window.folio.openBookPath(BOOK2_DIR);
await new Promise((r) => setTimeout(r, 400));
const fb = window.__folioFullBookEditor;
const alphaId = (await window.folio.createChapter("Alpha")).id;
const betaId = (await window.folio.createChapter("Beta")).id;
const gammaId = (await window.folio.createChapter("Gamma")).id;
await new Promise((r) => setTimeout(r, 300));
await window.folio.saveChapter(alphaId, "# Alpha\n\nAlpha body text");
await window.folio.saveChapter(betaId, "Beta body without heading");
await window.folio.saveChapter(gammaId, "# Gamma's Own Title\n\nGamma text");
await new Promise((r) => setTimeout(r, 300));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const expectedAssembled =
"# Alpha\n\nAlpha body text\n\n# Beta\n\nBeta body without heading\n\n# Gamma\n\n# Gamma's Own Title\n\nGamma text";
out.fullBookShown =
!document.getElementById("fullBookView").classList.contains("hidden") &&
document.getElementById("chapterView").classList.contains("hidden") &&
document.getElementById("fullBookBtn").classList.contains("active") &&
!document.getElementById("fullBookNav").classList.contains("hidden");
const assembled = fb.state.doc.toString();
out.assembled = assembled;
out.assembledOk = assembled === expectedAssembled;
const words = expectedAssembled.trim().split(/\s+/).length;
const statsEl = document.getElementById("fullBookStats");
out.fullBookStatsText = statsEl.textContent;
out.fullBookStatsOk =
statsEl.textContent === `words: ${words} · chars: ${expectedAssembled.length}`;
// --- chapter page breaks in Full Book ---
// Each chapter after the first gets a "chapter-break" marker on its first
// rendered line (a CSS border on screen, break-before: page when printed).
// The first chapter must never be marked, so no blank page precedes it.
const chapterBreakLines = () =>
Array.from(document.querySelectorAll("#fullBookHost .cm-line.chapter-break"));
const allContentLines = () =>
Array.from(document.querySelectorAll("#fullBookHost .cm-content .cm-line"));
const fbBreaks = chapterBreakLines();
const firstLine = allContentLines()[0];
out.chapterBreakCount = fbBreaks.length;
out.chapterBreakCountOk = fbBreaks.length === 2; // 3 chapters -> 2 breaks
out.firstChapterUnmarked = !!firstLine && !firstLine.classList.contains("chapter-break");
out.breaksAtChapterStarts =
fbBreaks.length === 2 &&
fbBreaks.every((el) => {
const t = el.textContent;
return t.startsWith("# Beta") || t.startsWith("# Gamma");
});
// print rules must be present in the stylesheet (fetch the raw CSS because
// Chromium's CSSOM normalizes the page-break-before alias into break-before)
const rawCss = await (await fetch("./index.css")).text();
out.printBreakRule = /break-before\s*:\s*page/.test(rawCss);
out.printBreakFallback = /page-break-before\s*:\s*always/.test(rawCss);
out.printBreakScoped = /\.print-chapter\s*{[^}]*break-before\s*:\s*page/.test(rawCss);
document.getElementById("copyAllBtn").click();
await new Promise((r) => setTimeout(r, 200));
const clip = await window.folio.getClipboardText();
out.clipboardLen = clip.length;
out.copyAllOk = clip === expectedAssembled;
out.copyStatusShown = document.getElementById("fullBookStatus").textContent === "Full book copied";
// Ctrl+A (routed through the renderer listener) selects the whole read-only doc
fb.focus();
fb.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", { key: "a", ctrlKey: true, bubbles: true, cancelable: true })
);
const sel = fb.state.selection.main;
out.fullBookSelectAllOk =
fb.state.doc.length > 0 && sel.from === 0 && sel.to === fb.state.doc.length;
// back to normal chapter editing still works
document.querySelector("#chapterList .chapter-row").click();
await new Promise((r) => setTimeout(r, 250));
out.backToChapterOk =
document.getElementById("fullBookView").classList.contains("hidden") &&
!document.getElementById("chapterView").classList.contains("hidden") &&
!document.getElementById("fullBookBtn").classList.contains("active");
// --- requirement matrix: reorder, persistence, edit ops reflect in Full Book ---
const rowFor = (id) =>
Array.from(document.querySelectorAll("#chapterList .chapter-row")).find((r) => r.dataset.id === id);
const moveUp = async (id) => {
rowFor(id).dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowUp", altKey: true, bubbles: true })
);
await new Promise((r) => setTimeout(r, 250));
};
// current order: [Alpha, Beta, Gamma]; move Gamma to front -> [Gamma, Alpha, Beta]
rowFor(gammaId).click();
await new Promise((r) => setTimeout(r, 200));
await moveUp(gammaId);
await moveUp(gammaId);
const orderReordered = (await window.folio.getBookMeta()).chapterOrder;
out.orderReordered = orderReordered;
out.reorderOk =
orderReordered[0] === gammaId && orderReordered[1] === alphaId && orderReordered[2] === betaId;
// Full Book follows the new chapter order and keeps every chapter's content
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const assembledGAB = fb.state.doc.toString();
out.fbAfterReorder = assembledGAB;
out.fbAfterReorderOk =
assembledGAB.startsWith("# Gamma\n\n# Gamma's Own Title") &&
assembledGAB.indexOf("# Alpha") > assembledGAB.indexOf("# Gamma") &&
assembledGAB.indexOf("# Beta") > assembledGAB.indexOf("# Alpha");
out.fbHasAllContent =
assembledGAB.includes("Gamma text") &&
assembledGAB.includes("Alpha body text") &&
assembledGAB.includes("Beta body without heading");
// chapter breaks follow the reordered manuscript: still exactly 2 breaks,
// now marking Alpha and Beta (Gamma is first, so it stays unmarked)
const fbBreaksReordered = chapterBreakLines();
out.fbBreaksAfterReorder = fbBreaksReordered.map((el) => el.textContent);
out.fbBreakAfterReorderOk =
fbBreaksReordered.length === 2 &&
fbBreaksReordered.every((el) => {
const t = el.textContent;
return t.startsWith("# Alpha") || t.startsWith("# Beta");
}) &&
!allContentLines()[0].classList.contains("chapter-break");
// switching between an individual chapter and Full Book never shows stale data
document.querySelector("#chapterList .chapter-row").click();
await new Promise((r) => setTimeout(r, 200));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
out.fbNoStale = fb.state.doc.toString() === assembledGAB;
// the Full Book pane must be a bounded flex column whose editor can be
// scrolled, so every chapter is actually reachable on screen (a block
// layout here clips the manuscript to the first screenful)
const fbViewEl = document.getElementById("fullBookView");
const fbHost = document.getElementById("fullBookHost");
const fbEditor = fbHost.querySelector(".cm-editor");
const vs = getComputedStyle(fbViewEl);
out.fbViewDisplay = vs.display;
out.fbViewDir = vs.flexDirection;
out.fbEditorHeight = Math.round(fbEditor.getBoundingClientRect().height);
out.fbViewportHeight = window.innerHeight;
out.fbLayoutOk =
vs.display === "flex" &&
vs.flexDirection === "column" &&
fbEditor.getBoundingClientRect().height <= window.innerHeight;
// closing and reopening the book preserves the chapter order
await window.folio.openBookPath(BOOK2_DIR);
await new Promise((r) => setTimeout(r, 400));
const orderReopened = (await window.folio.getBookMeta()).chapterOrder;
out.orderAfterReopen = orderReopened;
out.reopenPersists = JSON.stringify(orderReopened) === JSON.stringify(orderReordered);
// deleting a chapter updates the sidebar and Full Book
rowFor(alphaId).querySelector('[data-act="del"]').click();
await new Promise((r) => setTimeout(r, 150));
document.getElementById("modalOk").click();
await new Promise((r) => setTimeout(r, 300));
out.orderAfterDelete = (await window.folio.getBookMeta()).chapterOrder;
out.deleteOk = !(await window.folio.getBookMeta()).chapterOrder.includes(alphaId);
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const assembledAfterDelete = fb.state.doc.toString();
out.fbAfterDelete = assembledAfterDelete;
out.fbAfterDeleteOk =
!assembledAfterDelete.includes("Alpha body text") && assembledAfterDelete.includes("Gamma text");
// renaming a chapter updates its Full Book heading
rowFor(betaId).querySelector('[data-act="rename"]').click();
await new Promise((r) => setTimeout(r, 150));
document.getElementById("modalInput").value = "Beta Renamed";
document.getElementById("modalOk").click();
await new Promise((r) => setTimeout(r, 300));
const assembledAfterRename = fb.state.doc.toString();
out.fbAfterRename = assembledAfterRename;
out.fbAfterRenameOk =
assembledAfterRename.includes("# Beta Renamed") &&
!assembledAfterRename.includes("# Beta\n\nBeta body without heading");
// a newly created empty chapter appears in Full Book with just its heading
const emptyId = (await window.folio.createChapter("Empty")).id;
await new Promise((r) => setTimeout(r, 300));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const assembledWithEmpty = fb.state.doc.toString();
out.fbWithEmpty = assembledWithEmpty;
out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed");
// --- Export of the Full Book (PDF / DOCX / ODT) ---
// The e2e harness sets FOLIO_TEST_PDF/DOCX/ODT, so the main process skips
// the native save dialog and writes to those paths, returning the byte
// count and whether the buffer starts with the right magic number. The
// Full Book view is on screen here, so the PDF is the assembled manuscript
// (the print stylesheet hides all app chrome).
const exportMenuToggle = document.getElementById("exportMenuToggle");
const exportMenu = document.getElementById("exportMenu");
out.exportMenuPresent = !!exportMenuToggle && !!exportMenu;
out.pdfExportPdfVisible = !document.getElementById("fullBookView").classList.contains("hidden");
exportMenuToggle.click();
await new Promise((r) => setTimeout(r, 50));
out.exportMenuOpened = !exportMenu.classList.contains("hidden");
const pdfItem = exportMenu.querySelector('[data-export="pdf"]');
out.pdfItemPresent = !!pdfItem;
pdfItem.click();
await new Promise((r) => setTimeout(r, 2500));
out.pdfStatusShown = document
.getElementById("fullBookStatus")
.textContent.startsWith("PDF saved to");
const pdfRes = await window.folio.exportPdf();
out.pdfExport =
pdfRes && "filePath" in pdfRes
? { filePath: pdfRes.filePath, bytes: pdfRes.bytes, pdfOk: pdfRes.pdfOk }
: pdfRes;
out.pdfExportOk =
!!pdfRes &&
!("error" in pdfRes) &&
"filePath" in pdfRes &&
pdfRes.filePath.endsWith(".pdf") &&
typeof pdfRes.bytes === "number" &&
pdfRes.bytes > 500 &&
pdfRes.pdfOk === true;
// The printed book comes from the plain-HTML #printBook container (the
// print stylesheet hides the whole app and shows #printBook instead), so
// the PDF can never be limited to the CodeMirror viewport. Verify the
// container is hidden on screen and carries every chapter.
const printBookEl = document.getElementById("printBook");
out.printBookHiddenOnScreen = getComputedStyle(printBookEl).display === "none";
const printChapters = () =>
Array.from(document.querySelectorAll("#printBook .print-chapter"));
const pcsAtSmallBook = printChapters();
out.printBookChapterCount = pcsAtSmallBook.length;
out.printBookHasAllChapters =
pcsAtSmallBook.length === 3 &&
pcsAtSmallBook.some((s) => s.textContent.includes("Gamma text")) &&
pcsAtSmallBook.some((s) => s.textContent.includes("Beta body without heading")) &&
pcsAtSmallBook.some((s) => s.querySelector("h1")?.textContent === "Empty");
// Long book: every line must reach the print DOM — the guard against the
// "PDF does not show the full book" bug where a long manuscript was cut
// off after the first screenful.
const longLines = Array.from(
{ length: 1500 },
(_, i) => `Line ${i} of a very long chapter that must all reach the printed book.`
);
const veryLongId = (await window.folio.createChapter("Very Long")).id;
await window.folio.saveChapter(veryLongId, longLines.join("\n"));
await new Promise((r) => setTimeout(r, 500));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 400));
const printBookText = printBookEl.textContent;
out.printBookHasFirstLongLine = printBookText.includes("Line 0 of a very long chapter");
out.printBookHasLastLongLine = printBookText.includes("Line 1499 of a very long chapter");
out.printBookLongChapterCount = printChapters().length;
out.printBookLongFull =
out.printBookHasFirstLongLine &&
out.printBookHasLastLongLine &&
printChapters().length === 4;
const pdfLongRes = await window.folio.exportPdf();
out.pdfLongExport =
pdfLongRes && "filePath" in pdfLongRes
? { filePath: pdfLongRes.filePath, bytes: pdfLongRes.bytes, pdfOk: pdfLongRes.pdfOk }
: pdfLongRes;
out.pdfLongExportOk =
!!pdfLongRes &&
!("error" in pdfLongRes) &&
"filePath" in pdfLongRes &&
pdfLongRes.bytes > pdfRes.bytes * 2 &&
pdfLongRes.pdfOk === true;
// DOCX export: main process assembles the manuscript from the chapter
// files (all four chapters, including the very long one), writes a ZIP
// (PK magic) to FOLIO_TEST_DOCX, and reports its size. The deep content
// checks live in tests/run-office-export-test.mjs; here we verify the
// end-to-end IPC path, extension, magic number, and the status notice.
const docxRes = await window.folio.exportDocx();
out.docxExport =
docxRes && "filePath" in docxRes
? { filePath: docxRes.filePath, bytes: docxRes.bytes, docxOk: docxRes.docxOk }
: docxRes;
out.docxExportOk =
!!docxRes &&
!("error" in docxRes) &&
"filePath" in docxRes &&
docxRes.filePath.endsWith(".docx") &&
typeof docxRes.bytes === "number" &&
docxRes.bytes > 1000 &&
docxRes.docxOk === true;
const odtRes = await window.folio.exportOdt();
out.odtExport =
odtRes && "filePath" in odtRes
? { filePath: odtRes.filePath, bytes: odtRes.bytes, odtOk: odtRes.odtOk }
: odtRes;
out.odtExportOk =
!!odtRes &&
!("error" in odtRes) &&
"filePath" in odtRes &&
odtRes.filePath.endsWith(".odt") &&
typeof odtRes.bytes === "number" &&
odtRes.bytes > 1000 &&
odtRes.odtOk === true;
// Formatting must survive into the printed book: markdown-it is configured
// to pass through the toolbar's HTML (<u>, <span style=...>, <div>),
// ==highlight== becomes <mark>, callouts become styled divs, and
// wikilinks resolve to links — none of them may show up as raw source.
const fmtId = (await window.folio.createChapter("Formatting")).id;
await window.folio.saveChapter(
fmtId,
[
"**bold** *italic* ~~strike~~ ==highlight== <u>under</u>",
'<span style="color:#e11d48">red</span> <span style="background-color:#fde047">marked</span>',
"",
"> [!note] Callout title",
"> callout body",
"",
"[[Beta Renamed]]",
].join("\n")
);
await new Promise((r) => setTimeout(r, 500));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 400));
const pbInner = document.getElementById("printBook").innerHTML;
const pbText = document.getElementById("printBook").textContent;
out.printFmtBold = /<strong>bold<\/strong>/.test(pbInner);
out.printFmtMark = /<mark>highlight<\/mark>/.test(pbInner);
out.printFmtUnderline = /<u>under<\/u>/.test(pbInner);
out.printFmtColor = /<span style="color:#e11d48">red<\/span>/.test(pbInner);
out.printFmtNoRawEq = !pbText.includes("==highlight==");
out.printFmtCallout = /class="callout callout-note"/.test(pbInner);
out.printFmtNoRawCallout = !pbText.includes("[!note]");
out.printFmtWikilink = /class="wikilink internal"/.test(pbInner);
out.printFmtNoRawWikilink = !pbText.includes("[[Beta Renamed]]");
out.printFmtOk =
out.printFmtBold &&
out.printFmtMark &&
out.printFmtUnderline &&
out.printFmtColor &&
out.printFmtNoRawEq &&
out.printFmtCallout &&
out.printFmtNoRawCallout &&
out.printFmtWikilink &&
out.printFmtNoRawWikilink;
// --- chapter navigation inside Full Book (third book: One/Two/Three/Four) ---
// Four chapters, each taller than the viewport, so every jump target lands
// its own heading near the top of the Full Book viewport.
await window.folio.openBookPath(BOOK3_DIR);
await new Promise((r) => setTimeout(r, 400));
const navBook = window.__folioFullBookEditor;
const navViewEl = document.getElementById("fullBookView");
const navSel = document.getElementById("fbChapterSelect");
const navPrev = document.getElementById("fbPrevBtn");
const navNext = document.getElementById("fbNextBtn");
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const navOptions = () => Array.from(navSel.options).map((o) => o.textContent);
const navDocStarts = () => {
const doc = navBook.state.doc.toString();
return ["One", "Two", "Three", "Four"].map((t) => doc.indexOf("# " + t));
};
// How far (px) the given chapter's heading sits below the top of the Full
// Book viewport. The jump is considered correct when it is near the top.
const chapterTopY = (title) => {
const doc = navBook.state.doc.toString();
const start = doc.indexOf("# " + title);
if (start < 0) return -1;
const rect = navBook.scrollDOM.getBoundingClientRect();
const c = navBook.coordsAtPos(start);
if (!c) return -1;
return c.top - rect.top;
};
const navTopChapter = () => {
const scroller = navBook.scrollDOM;
const rect = scroller.getBoundingClientRect();
const pos = navBook.posAtCoords({ x: rect.left + 4, y: rect.top + 30 });
if (pos === null || pos === undefined) return -1;
const starts = navDocStarts();
let idx = 0;
for (let i = 0; i < starts.length; i++) {
if (starts[i] >= 0 && starts[i] <= pos) idx = i;
else break;
}
return idx;
};
const navTopLine = () => {
const scroller = navBook.scrollDOM;
const rect = scroller.getBoundingClientRect();
const pos = navBook.posAtCoords({ x: rect.left + 4, y: rect.top + 30 });
if (pos === null || pos === undefined) return "";
return navBook.state.doc.lineAt(pos).text;
};
const navSnap = () => ({
value: navSel.value,
prevDisabled: navPrev.disabled,
nextDisabled: navNext.disabled,
topChapter: navTopChapter(),
topLine: navTopLine(),
fullBookVisible:
!navViewEl.classList.contains("hidden") &&
document.getElementById("chapterView").classList.contains("hidden"),
});
const navWait = (ms) => new Promise((r) => setTimeout(r, ms));
// 1. Full Book opens at Chapter One; Previous disabled.
const navStart = navSnap();
out.navOptions = navOptions();
out.navOptionsOk =
JSON.stringify(out.navOptions) === JSON.stringify(["One", "Two", "Three", "Four"]);
out.navOpenAtOne = navStart;
out.navOpenAtOneOk =
navStart.value === "ch1" &&
navStart.prevDisabled &&
!navStart.nextDisabled &&
navStart.fullBookVisible &&
navStart.topChapter === 0;
// 2. Next -> Chapter Two.
navNext.click();
await navWait(300);
const navTwo = navSnap();
out.navNextToTwo = navTwo;
out.navNextToTwoOk =
navTwo.value === "ch2" &&
!navTwo.prevDisabled &&
!navTwo.nextDisabled &&
navTwo.fullBookVisible &&
navTwo.topChapter === 1 &&
chapterTopY("Two") >= 0 &&
chapterTopY("Two") < 200;
// 3. Next -> Chapter Three.
navNext.click();
await navWait(300);
const navThree = navSnap();
out.navNextToThree = navThree;
out.navNextToThreeOk =
navThree.value === "ch3" &&
navThree.fullBookVisible &&
navThree.topChapter === 2 &&
chapterTopY("Three") >= 0 &&
chapterTopY("Three") < 200;
// 4. Previous -> back to Chapter Two.
navPrev.click();
await navWait(300);
const navBackTwo = navSnap();
out.navPrevToTwo = navBackTwo;
out.navPrevToTwoOk =
navBackTwo.value === "ch2" &&
navBackTwo.topChapter === 1 &&
chapterTopY("Two") >= 0 &&
chapterTopY("Two") < 200;
// 5. Dropdown -> Chapter Four (Next disabled at the last chapter).
navSel.value = "ch4";
navSel.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(300);
const navFour = navSnap();
out.navDropToFour = navFour;
out.navDropToFourOk =
navFour.value === "ch4" &&
navFour.nextDisabled &&
navFour.fullBookVisible &&
chapterTopY("Four") >= 0 &&
chapterTopY("Four") < 200;
// 6. Manual scrolling updates the selector: scroll to the very bottom
// (last chapter) and to the very top (first chapter).
navBook.scrollDOM.scrollTop = navBook.scrollDOM.scrollHeight;
navBook.scrollDOM.dispatchEvent(new Event("scroll"));
await navWait(250);
const navScrolledBottom = navSnap();
out.navScrollBottomTracks =
navScrolledBottom.value === "ch4" && navScrolledBottom.nextDisabled;
navBook.scrollDOM.scrollTop = 0;
navBook.scrollDOM.dispatchEvent(new Event("scroll"));
await navWait(250);
const navScrolledTop = navSnap();
out.navScrollTopTracks = navScrolledTop.value === "ch1" && navScrolledTop.prevDisabled;
// 7. Reordering chapters in the sidebar must be reflected immediately, even
// while Full Book stays open (no restart). Drag the last row onto the first.
const navRows = document.getElementById("chapterList").querySelectorAll(".chapter-row");
const navDt = new DataTransfer();
navRows[3].dispatchEvent(new DragEvent("dragstart", { dataTransfer: navDt, bubbles: true }));
navRows[0].dispatchEvent(new DragEvent("dragover", { dataTransfer: navDt, bubbles: true }));
navRows[0].dispatchEvent(new DragEvent("drop", { dataTransfer: navDt, bubbles: true }));
navRows[3].dispatchEvent(new DragEvent("dragend", { dataTransfer: navDt, bubbles: true }));
await navWait(400);
const navOrderReordered = (await window.folio.getBookMeta()).chapterOrder;
out.navOrderReordered = navOrderReordered;
out.navReorderChanged =
JSON.stringify(navOrderReordered) === JSON.stringify(["ch4", "ch1", "ch2", "ch3"]);
const navOptionsAfter = navOptions();
out.navOptionsAfterReorder = navOptionsAfter;
out.navDropdownFollowsReorder =
JSON.stringify(navOptionsAfter) === JSON.stringify(["Four", "One", "Two", "Three"]);
// Full Book is re-rendered at the top: the new first chapter (Four) is
// current, Previous is disabled, and Next now moves to One.
const navAfterReorder = navSnap();
out.navAfterReorder = navAfterReorder;
out.navFollowsNewOrder =
navAfterReorder.value === "ch4" &&
navAfterReorder.prevDisabled &&
navAfterReorder.fullBookVisible &&
chapterTopY("Four") >= 0 &&
chapterTopY("Four") < 200;
navNext.click();
await navWait(300);
const navNextAfterReorder = navSnap();
out.navNextAfterReorder = navNextAfterReorder;
out.navNextAfterReorderOk =
navNextAfterReorder.value === "ch1" &&
navNextAfterReorder.fullBookVisible &&
chapterTopY("One") >= 0 &&
chapterTopY("One") < 200;
// 8. Page breaks still render after navigation. Break markers are
// decorations on the first line of every chapter after the first, and
// CodeMirror only puts rendered lines in the DOM, so navigate to a middle
// chapter and confirm its marked first line is present.
navSel.value = "ch2";
navSel.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(300);
const navBreaks = chapterBreakLines();
out.navBreakCount = navBreaks.length;
out.navBreaksOk = navBreaks.length >= 1 && navBreaks.some((el) => el.textContent.startsWith("# Two"));
// --- theme system: selector, immediate switching, persistence ---
const themeSelect = document.getElementById("themeSelect");
const themeVar = (name) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const docBeforeTheme = navBook.state.doc.toString();
out.themeSelectPresent = !!themeSelect;
out.themeSelectOptions = themeSelect
? Array.from(themeSelect.options).map((o) => o.value)
: [];
out.themeHasLight = out.themeSelectOptions.includes("light");
out.themeHasNeonflux = out.themeSelectOptions.includes("neonflux");
out.themeInitialStored = await window.folio.getTheme();
out.themeInitialAttr = document.documentElement.getAttribute("data-theme");
// Old saves may hold "default", which must resolve to the Light theme
// (migration). Valid saved themes must be restored exactly on startup.
out.themeInitialConsistent =
out.themeInitialStored === "default" || out.themeInitialStored === "light"
? out.themeInitialAttr === "light"
: out.themeInitialAttr === out.themeInitialStored;
// Switch to Neonflux: applies immediately and persists via IPC.
themeSelect.value = "neonflux";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeNeonfluxAttr = document.documentElement.getAttribute("data-theme");
out.themeNeonfluxStored = await window.folio.getTheme();
const neonBg = themeVar("--color-bg");
const neonAccent = themeVar("--color-accent");
out.themeNeonfluxBg = neonBg;
out.themeNeonfluxAccent = neonAccent;
out.themeNeonfluxApplied =
out.themeNeonfluxAttr === "neonflux" &&
out.themeNeonfluxStored === "neonflux" &&
neonBg.toLowerCase() === "#0a0d14" &&
neonAccent.toLowerCase() === "#22d3ee";
// Neonflux is dark: computed background of the app should be near-black.
const neonBodyBg = getComputedStyle(document.body).backgroundColor;
out.themeNeonfluxDark = /rgb\((10|11|12|9|8),/.test(neonBodyBg);
// Switching themes must not touch the manuscript content.
out.themeContentUnchanged = navBook.state.doc.toString() === docBeforeTheme;
// Switch to Light: applies immediately and persists.
themeSelect.value = "light";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeLightAttr = document.documentElement.getAttribute("data-theme");
out.themeLightStored = await window.folio.getTheme();
const lightBg = themeVar("--color-bg");
const lightAccent = themeVar("--color-accent");
out.themeLightBg = lightBg;
out.themeLightAccent = lightAccent;
out.themeLightApplied =
out.themeLightAttr === "light" &&
out.themeLightStored === "light" &&
lightBg.toLowerCase() === "#fffdf9" &&
lightAccent.toLowerCase() === "#2f6b5f";
// Light is light: computed background must be a warm off-white, never the
// near-black used by Neonflux.
const lightBodyBg = getComputedStyle(document.body).backgroundColor;
out.themeLightNotDark = !/rgb\((10|11|12|9|8),/.test(lightBodyBg);
out.themeContentUnchanged2 = navBook.state.doc.toString() === docBeforeTheme;
// Round-trip: back to Neonflux restores it exactly.
themeSelect.value = "neonflux";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeRoundTripAttr = document.documentElement.getAttribute("data-theme");
out.themeRoundTripStored = await window.folio.getTheme();
out.themeRoundTripOk =
out.themeRoundTripAttr === "neonflux" &&
out.themeRoundTripStored === "neonflux" &&
themeVar("--color-bg").toLowerCase() === "#0a0d14" &&
themeVar("--color-accent").toLowerCase() === "#22d3ee";
// Leave the app on Light so subsequent runs start deterministically.
themeSelect.value = "light";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeFinalAttr = document.documentElement.getAttribute("data-theme");
out.themeFinalStored = await window.folio.getTheme();
out.themeFinalLightOk = out.themeFinalAttr === "light" && out.themeFinalStored === "light";
out.themeContentUnchanged3 = navBook.state.doc.toString() === docBeforeTheme;
const okFlags = [
"dndMoved",
"kbMoved",
"modalShown",
"uiCreated",
"hasEditor",
"editorAutosaved",
"toolbarBoldOk",
"focusAfterBold",
"shortcutBoldOk",
"headingOk",
"undoOk",
"redoOk",
"strikeShortcutOk",
"fencedCodeOk",
"underlineOk",
"checklistOk",
"highlightOk",
"tableOk",
"painterOk",
"colorMenuOpens",
"colorMenuOnScreen",
"colorApplied",
"customColorApplied",
"formattingAutosaved",
"fullBookShown",
"assembledOk",
"fullBookStatsOk",
"chapterBreakCountOk",
"firstChapterUnmarked",
"breaksAtChapterStarts",
"printBreakRule",
"printBreakFallback",
"printBreakScoped",
"copyAllOk",
"copyStatusShown",
"fullBookSelectAllOk",
"backToChapterOk",
"reorderOk",
"fbAfterReorderOk",
"fbHasAllContent",
"fbBreakAfterReorderOk",
"fbNoStale",
"fbLayoutOk",
"reopenPersists",
"deleteOk",
"fbAfterDeleteOk",
"fbAfterRenameOk",
"fbEmptyOk",
"exportMenuPresent",
"exportMenuOpened",
"pdfItemPresent",
"pdfExportPdfVisible",
"pdfStatusShown",
"pdfExportOk",
"printBookHiddenOnScreen",
"printBookHasAllChapters",
"printBookLongFull",
"pdfLongExportOk",
"docxExportOk",
"odtExportOk",
"printFmtOk",
"navOptionsOk",
"navOpenAtOneOk",
"navNextToTwoOk",
"navNextToThreeOk",
"navPrevToTwoOk",
"navDropToFourOk",
"navScrollBottomTracks",
"navScrollTopTracks",
"navReorderChanged",
"navDropdownFollowsReorder",
"navFollowsNewOrder",
"navNextAfterReorderOk",
"navBreaksOk",
"themeSelectPresent",
"themeHasLight",
"themeHasNeonflux",
"themeInitialConsistent",
"themeNeonfluxApplied",
"themeNeonfluxDark",
"themeContentUnchanged",
"themeLightApplied",
"themeLightNotDark",
"themeContentUnchanged2",
"themeRoundTripOk",
"themeFinalLightOk",
"themeContentUnchanged3",
];
out.all = okFlags.every((f) => out[f] === true);
} catch (e) {
out.error = String((e && e.message) || e);
}
return out;
})();