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.
This commit is contained in:
avi 2026-08-18 12:40:57 -05:00
commit 7011d0655b
6 changed files with 362 additions and 5 deletions

View file

@ -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) {