feat(ebook-reader): implement page-based pagination with CFI support

This commit implements true page-based pagination for the ebook reader,
similar to Kindle's approach where content is split into discrete pages
based on viewport size, font settings, and content layout.

Phase 1 - Bug Fixes:
- Fix goToPage to use getScrollPositionForPage instead of treating
  page numbers as spine indices
- Move page calculation to initialize before first render to avoid
  race condition with scroll handler
- Add fallback page calculation when pageCalculationResult is null

Phase 2 - Page Splitting:
- Add new page-splitter.ts module with CFI-based splitting for EPUB
  and height-based fallback for other formats
- Integrate splitContent into page-calculator to generate discrete
  page content for each spine item
- Store pages[] array in ChapterPageInfo for rendering
- Rewrite navigation (nextPage, previousPage, renderSpineItem) to
  use page-based approach with currentPage tracking
- Remove old scroll-based pagination from view-modes.ts
- Update paginated mode CSS for true page clipping

Phase 3 - Progress Display:
- Update progress-indicator to use currentPage directly instead of
  calculating from scroll position
- Fix getCurrentPage in reader-state to return currentPage for ebooks

Phase 4 - Interface Fixes:
- Add currentPage field to UniversalReader interface
- Update sendProgressUpdate to use currentPage directly
- Initialize currentPage to 1 on reader creation

Key features:
- Dynamic page count based on font size, line height, margins
- CFI-based page splitting preserves reading context
- Falls back to height-based splitting for non-EPUB formats
- Progress display updates immediately on page change
- Settings changes trigger page recalculation and re-render
This commit is contained in:
2026-04-06 21:18:29 -04:00
parent 6805d0b66d
commit 1e7d13dc85
7 changed files with 539 additions and 267 deletions
+41 -57
View File
@@ -56,10 +56,10 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
switch (mode) {
case "paginated":
applyPaginatedMode(container, element);
applyPaginatedMode(element);
break;
case "scrolled":
applyScrolledMode(container, element);
applyScrolledMode(element);
break;
case "single-column":
applySingleColumn(element);
@@ -70,28 +70,56 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
}
}
function applyPaginatedMode(
container: HTMLElement,
element: HTMLElement,
): void {
function applyPaginatedMode(element: HTMLElement): void {
element.classList.add("paginated");
element.style.height = "100vh";
// True pagination: content fits exactly in viewport, no scrolling
element.style.height = "calc(100vh - 120px)"; // Account for chrome (top 60px + bottom 60px)
element.style.overflow = "hidden";
element.style.columnCount = "1";
element.style.columnGap = "0";
element.style.position = "relative";
enablePagination(container, element);
// Inject CSS for page clipping
injectPaginatedStyles();
}
function applyScrolledMode(container: HTMLElement, element: HTMLElement): void {
function injectPaginatedStyles(): void {
// Remove existing injected styles if any
const existing = document.getElementById("paginated-styles");
existing?.remove();
const style = document.createElement("style");
style.id = "paginated-styles";
style.textContent = `
.paginated .ebook-page {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.paginated .ebook-page > * {
max-height: 100%;
overflow: hidden;
}
.paginated .ebook-content {
height: 100%;
overflow: hidden !important;
position: relative;
}
.paginated .ebook-content * {
overflow-wrap: break-word;
}
`;
document.head.appendChild(style);
}
function applyScrolledMode(element: HTMLElement): void {
element.classList.add("scrolled");
element.style.height = "auto";
element.style.overflowY = "auto";
element.style.columnCount = "1";
disablePagination(container);
}
function applySingleColumn(element: HTMLElement): void {
@@ -113,51 +141,6 @@ function applyDoubleColumn(element: HTMLElement): void {
element.style.margin = "0 auto";
}
function enablePagination(container: HTMLElement, element: HTMLElement): void {
const totalHeight = element.scrollHeight;
const pageHeight = element.clientHeight;
const pageCount = Math.ceil(totalHeight / pageHeight);
addPaginationControls(container, pageCount);
}
function disablePagination(container: HTMLElement): void {
const controls = container.querySelector(".pagination-controls");
controls?.remove();
}
function addPaginationControls(
container: HTMLElement,
pageCount: number,
): void {
let currentPage = 1;
const controls = document.createElement("div");
controls.className =
"pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t";
controls.innerHTML = `
<button class="prev-page" ${currentPage === 1 ? "disabled" : ""}>← Previous</button>
<span class="page-info">Page ${currentPage} of ${pageCount}</span>
<button class="next-page" ${currentPage === pageCount ? "disabled" : ""}>Next →</button>
`;
controls.querySelector(".prev-page")?.addEventListener("click", () => {
if (currentPage > 1) {
currentPage--;
goToPage(container, currentPage);
}
});
controls.querySelector(".next-page")?.addEventListener("click", () => {
if (currentPage < pageCount) {
currentPage++;
goToPage(container, currentPage);
}
});
container.appendChild(controls);
}
function goToPage(container: HTMLElement, pageNumber: number): void {
const content = container.querySelector(".ebook-content") as HTMLElement;
if (!content) return;
@@ -196,4 +179,5 @@ export function getCurrentViewMode(container: HTMLElement): ViewMode {
if (content.classList.contains("double-column")) return "double-column";
return "paginated";
}
}