From 7011d0655b3b190f0f1f82059607cd0b80e63bea Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 18 Aug 2026 12:40:57 -0500 Subject: [PATCH] Add chapter navigation to the Full Book view Add a chapter dropdown plus Previous/Next buttons to the Full Book toolbar. The dropdown lists every chapter in canonical order and the buttons jump between them, scrolling the assembled manuscript so the target chapter begins near the top of the viewport. Manual scrolling keeps the dropdown in sync with the chapter currently on screen, and reordering chapters while Full Book is open re-renders navigation to match the new order immediately. Navigation reuses the same chapter boundaries that drive page breaks, so it can never drift from the assembled chapter order. --- src/main/index.ts | 3 +- src/renderer/book.ts | 122 +++++++++++++++++++++- src/renderer/index.css | 31 ++++++ src/renderer/index.html | 6 ++ tests/run-e2e.sh | 4 +- tests/selftest-scenario.js | 201 +++++++++++++++++++++++++++++++++++++ 6 files changed, 362 insertions(+), 5 deletions(-) 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 @@ +
+ + + + +