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
|
|
@ -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;
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue