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:
parent
d510a48742
commit
7011d0655b
6 changed files with 362 additions and 5 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,6 +178,12 @@
|
|||
<span class="save-state" id="fullBookStatus"></span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="full-book-navbar" id="fullBookNavbar">
|
||||
<button class="button" id="fbPrevBtn" title="Jump to the previous chapter">← Previous Chapter</button>
|
||||
<label class="fb-nav-label" for="fbChapterSelect">Chapter:</label>
|
||||
<select id="fbChapterSelect" class="toolbar-select fb-chapter-select" title="Jump to a chapter"></select>
|
||||
<button class="button" id="fbNextBtn" title="Jump to the next chapter">Next Chapter →</button>
|
||||
</div>
|
||||
<div class="editor-panes" id="fullBookHost"></div>
|
||||
</div>
|
||||
<div class="editor-placeholder hidden" id="editorPlaceholder">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue