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 @@ +
+ + + + +