diff --git a/src/main/index.ts b/src/main/index.ts
index 18e39dc..190fe1e 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -70,7 +70,8 @@ function createWindow() {
const scenario = fs
.readFileSync(scenarioPath, "utf-8")
.replace(/__BOOKDIR_VALUE__/, JSON.stringify(bookDir))
- .replace(/__BOOKDIR2_VALUE__/, JSON.stringify(process.env.FOLIO_TEST_BOOK2 || ""));
+ .replace(/__BOOKDIR2_VALUE__/, JSON.stringify(process.env.FOLIO_TEST_BOOK2 || ""))
+ .replace(/__BOOKDIR3_VALUE__/, JSON.stringify(process.env.FOLIO_TEST_BOOK3 || ""));
mainWindow?.webContents
.executeJavaScript(scenario, false)
.then((summary) => {
diff --git a/src/renderer/book.ts b/src/renderer/book.ts
index dfa6c83..c50060b 100644
--- a/src/renderer/book.ts
+++ b/src/renderer/book.ts
@@ -123,6 +123,9 @@ export function initBook(api: FolioAPI) {
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
const fullBookStatus = $("fullBookStatus");
const exportPdfBtn = $("exportPdfBtn") as HTMLButtonElement;
+ const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement;
+ const fbNextBtn = $("fbNextBtn") as HTMLButtonElement;
+ const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement;
// Marks the line where every chapter after the first begins. In print this
// line carries the page-break; on screen it shows a subtle separator. The
@@ -147,6 +150,94 @@ export function initBook(api: FolioAPI) {
extensions: [chapterBreaksField],
});
+ // Navigation state for the Full Book view. chapterNav tracks every chapter
+ // that produced a block in the assembled manuscript, in canonical order,
+ // along with the offset where its block begins. This is the same boundary
+ // data that drives page breaks, so navigation can never drift from the
+ // assembled order. The current chapter is kept in sync with the scroll
+ // position (manual scrolling updates the selector too).
+ interface FullBookChapterNav {
+ id: string;
+ title: string;
+ start: number;
+ }
+ let fullBookChapters: FullBookChapterNav[] = [];
+ let currentChapterIndex = 0;
+ let navScrollRaf: number | null = null;
+
+ function populateChapterNav(entries: FullBookChapterNav[]) {
+ fullBookChapters = entries;
+ fbChapterSelect.innerHTML = "";
+ for (const e of entries) {
+ const opt = document.createElement("option");
+ opt.value = e.id;
+ opt.textContent = e.title;
+ fbChapterSelect.appendChild(opt);
+ }
+ setCurrentChapter(0);
+ }
+
+ function setCurrentChapter(index: number) {
+ if (!fullBookChapters.length) currentChapterIndex = 0;
+ else currentChapterIndex = Math.max(0, Math.min(index, fullBookChapters.length - 1));
+ updateChapterNavUI();
+ }
+
+ function updateChapterNavUI() {
+ const n = fullBookChapters.length;
+ fbPrevBtn.disabled = n === 0 || currentChapterIndex <= 0;
+ fbNextBtn.disabled = n === 0 || currentChapterIndex >= n - 1;
+ const entry = fullBookChapters[currentChapterIndex];
+ if (entry && fbChapterSelect.value !== entry.id) fbChapterSelect.value = entry.id;
+ }
+
+ // Scroll the assembled manuscript so the given chapter's block begins near
+ // the top of the Full Book viewport. This only touches the rendered editor,
+ // never the chapter files or the assembled Markdown.
+ function jumpToChapter(index: number) {
+ if (!fullBookChapters.length) return;
+ const clamped = Math.max(0, Math.min(index, fullBookChapters.length - 1));
+ fullBookEditor.dispatch({
+ effects: EditorView.scrollIntoView(fullBookChapters[clamped].start, { y: "start" }),
+ });
+ setCurrentChapter(clamped);
+ fullBookEditor.focus();
+ }
+
+ // Map the line currently at the top of the Full Book viewport back to the
+ // chapter whose block contains it, so manual scrolling updates the selector.
+ function detectChapterFromScroll(): number {
+ if (!fullBookChapters.length) return 0;
+ const scroller = fullBookEditor.scrollDOM;
+ const rect = scroller.getBoundingClientRect();
+ if (rect.height === 0) return currentChapterIndex;
+ const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
+ const topPos = fullBookEditor.posAtCoords({ x: rect.left + 4, y: rect.top + 4 });
+ if (topPos === null || topPos === undefined) return currentChapterIndex;
+ if (atBottom) {
+ const bottomPos = fullBookEditor.posAtCoords({ x: rect.left + 4, y: rect.bottom - 4 });
+ const last = fullBookChapters[fullBookChapters.length - 1];
+ if (bottomPos !== null && bottomPos !== undefined && bottomPos >= last.start) {
+ return fullBookChapters.length - 1;
+ }
+ }
+ let idx = 0;
+ for (let i = 0; i < fullBookChapters.length; i++) {
+ if (fullBookChapters[i].start <= topPos) idx = i;
+ else break;
+ }
+ return idx;
+ }
+
+ fullBookEditor.scrollDOM.addEventListener("scroll", () => {
+ if (!fullBookChapters.length) return;
+ if (navScrollRaf !== null) cancelAnimationFrame(navScrollRaf);
+ navScrollRaf = requestAnimationFrame(() => {
+ navScrollRaf = null;
+ setCurrentChapter(detectChapterFromScroll());
+ });
+ });
+
const editorView: EditorView = createMdEditor(editorHost, {
onChange: (text) => handleEditorChange(text),
onSelection: (view) => {
@@ -389,6 +480,10 @@ export function initBook(api: FolioAPI) {
setChapterButtons(false);
fullBookBtn.classList.remove("active");
setEditorText(fullBookEditor, "");
+ fullBookChapters = [];
+ currentChapterIndex = 0;
+ fbChapterSelect.innerHTML = "";
+ updateChapterNavUI();
renderChapterList();
}
@@ -537,15 +632,17 @@ export function initBook(api: FolioAPI) {
text: string;
chapterStarts: number[];
chapters: string[];
+ chapterEntries: { id: string; title: string; start: number }[];
targets: WikilinkTarget[];
}> {
const res = await api.getBookMeta();
if (!res || typeof res !== "object" || "error" in res) {
- return { text: "", chapterStarts: [], chapters: [], targets: [] };
+ return { text: "", chapterStarts: [], chapters: [], chapterEntries: [], targets: [] };
}
const book = res as BookMeta;
const parts: string[] = [];
const chapterStarts: number[] = [];
+ const chapterEntries: { id: string; title: string; start: number }[] = [];
const targets: WikilinkTarget[] = [];
let offset = 0;
for (const id of book.chapterOrder) {
@@ -566,17 +663,24 @@ export function initBook(api: FolioAPI) {
block.push(body);
}
if (block.length) {
+ chapterEntries.push({ id: ch.id, title: ch.title, start: offset });
chapterStarts.push(offset);
const joined = block.join("\n");
parts.push(joined);
offset += joined.length + 2;
}
}
- return { text: parts.join("\n\n"), chapterStarts, chapters: parts, targets };
+ return {
+ text: parts.join("\n\n"),
+ chapterStarts,
+ chapters: parts,
+ chapterEntries,
+ targets,
+ };
}
async function renderFullBook() {
- const { text, chapterStarts, chapters, targets } = await assembleFullBook();
+ const { text, chapterStarts, chapterEntries, chapters, targets } = await assembleFullBook();
setEditorText(fullBookEditor, text);
const ranges = chapterStarts.slice(1).map((from) => chapterBreakLine.range(from));
fullBookEditor.dispatch({
@@ -584,6 +688,12 @@ export function initBook(api: FolioAPI) {
});
fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length}`;
printBook.innerHTML = renderPrintChapters(chapters, targets);
+ populateChapterNav(chapterEntries);
+ // Re-render replaces the whole document, which resets the scroll position,
+ // so re-derive the current chapter once layout has settled.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => setCurrentChapter(detectChapterFromScroll()));
+ });
}
async function selectFullBook() {
@@ -887,6 +997,12 @@ export function initBook(api: FolioAPI) {
exportBtn.addEventListener("click", () => void exportMarkdown());
exportZipBtn.addEventListener("click", () => void exportZip());
fullBookBtn.addEventListener("click", () => void selectFullBook());
+ fbPrevBtn.addEventListener("click", () => jumpToChapter(currentChapterIndex - 1));
+ fbNextBtn.addEventListener("click", () => jumpToChapter(currentChapterIndex + 1));
+ fbChapterSelect.addEventListener("change", () => {
+ const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value);
+ if (idx >= 0) jumpToChapter(idx);
+ });
exportPdfBtn.addEventListener("click", () => void exportPdf());
copyAllBtn.addEventListener("click", async () => {
const text = editorText(fullBookEditor);
diff --git a/src/renderer/index.css b/src/renderer/index.css
index a4fba77..4bacadd 100644
--- a/src/renderer/index.css
+++ b/src/renderer/index.css
@@ -508,6 +508,33 @@ html, body {
overflow: hidden;
}
+/* ---- full book chapter navigation ---- */
+.full-book-navbar {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 8px 20px;
+ border-bottom: 1px solid var(--color-border);
+ background: var(--color-panel);
+}
+
+.full-book-navbar .button {
+ white-space: nowrap;
+}
+
+.fb-nav-label {
+ font-size: 0.8rem;
+ color: var(--color-muted);
+ white-space: nowrap;
+}
+
+.fb-chapter-select {
+ max-width: 280px;
+ min-width: 180px;
+}
+
/* ---- full book chapter breaks ----
The Full Book stays one continuous scroll on screen; every chapter after
the first gets a subtle separator where a printed page will begin. The
@@ -876,6 +903,10 @@ body.focus-mode .chapter-header {
display: none;
}
+body.focus-mode .full-book-navbar {
+ display: none;
+}
+
body.focus-mode .editor {
background: var(--color-bg);
}
diff --git a/src/renderer/index.html b/src/renderer/index.html
index b19a630..f1cc3e6 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -178,6 +178,12 @@
+
+
+
+
+
+
diff --git a/tests/run-e2e.sh b/tests/run-e2e.sh
index cdf83c3..44ff05c 100755
--- a/tests/run-e2e.sh
+++ b/tests/run-e2e.sh
@@ -6,9 +6,11 @@
set -e
BOOK=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"E2E Book",chapterOrder:[],chapters:{}}));process.stdout.write(d);')
BOOK2=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e2-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"E2E Book 2",chapterOrder:[],chapters:{}}));process.stdout.write(d);')
+BOOK3=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e3-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});const titles=["One","Two","Three","Four"];const chapters={};const order=[];titles.forEach((t,i)=>{const id="ch"+(i+1);const file=path.posix.join("chapters",id+".md");order.push(id);chapters[id]={id,title:t,file};const body=Array.from({length:40},(_,p)=>`Paragraph ${p} of chapter ${t}: the quick brown fox jumps over the lazy dog while the whole manuscript stays deterministic and long enough to scroll.`).join("\n\n");fs.writeFileSync(path.join(d,file),`# ${t}\n\n${body}`);});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"Nav Book",chapterOrder:order,chapters}));process.stdout.write(d);')
export FOLIO_SELF_TEST=1
export FOLIO_TEST_BOOK="$BOOK"
export FOLIO_TEST_BOOK2="$BOOK2"
+export FOLIO_TEST_BOOK3="$BOOK3"
export FOLIO_TEST_PDF="$BOOK/export-test.pdf"
-trap 'rm -rf "$BOOK" "$BOOK2"' EXIT
+trap 'rm -rf "$BOOK" "$BOOK2" "$BOOK3"' EXIT
xvfb-run -a electron .
diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js
index 96e29e0..819842c 100644
--- a/tests/selftest-scenario.js
+++ b/tests/selftest-scenario.js
@@ -3,6 +3,7 @@
renderer -> preload -> IPC -> chapters chain. */
const BOOK_DIR = __BOOKDIR_VALUE__;
const BOOK2_DIR = __BOOKDIR2_VALUE__;
+const BOOK3_DIR = __BOOKDIR3_VALUE__;
(async () => {
const out = {};
try {
@@ -526,6 +527,193 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
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"));
+
const okFlags = [
"dndMoved",
"kbMoved",
@@ -580,6 +768,19 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
"printBookLongFull",
"pdfLongExportOk",
"printFmtOk",
+ "navOptionsOk",
+ "navOpenAtOneOk",
+ "navNextToTwoOk",
+ "navNextToThreeOk",
+ "navPrevToTwoOk",
+ "navDropToFourOk",
+ "navScrollBottomTracks",
+ "navScrollTopTracks",
+ "navReorderChanged",
+ "navDropdownFollowsReorder",
+ "navFollowsNewOrder",
+ "navNextAfterReorderOk",
+ "navBreaksOk",
];
out.all = okFlags.every((f) => out[f] === true);
} catch (e) {