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
+164 -163
View File
@@ -1,11 +1,10 @@
import { getDefaultSettings } from "../settings-manager";
import { getState, setState, getCurrentPage } from "./reader-state";
import { getState, setState } from "./reader-state";
import { readerEvents } from "./reader-events";
import { updateReadingProgress } from "./reader-services";
import {
calculatePagesForEbook,
calculateProgressPercentage,
getCurrentPageFromScroll,
type PageCalculationResult,
} from "../ebook/page-calculator";
import { UniversalReader } from "../reader-shell";
@@ -18,46 +17,45 @@ export function createNavigationAPI() {
nextPage: () => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
const container = document.getElementById("reader-content");
const viewportHeightAdjusted = window.innerHeight - 120;
if (pageCalculationResult) {
const currentChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
);
const currentPage = state.currentReader.currentPage || 1;
if (currentChapter && container) {
const currentPageInChapter = Math.floor(
container.scrollTop / viewportHeightAdjusted,
if (currentPage < pageCalculationResult.totalPages) {
const nextPage = currentPage + 1;
state.currentReader.currentPage = nextPage;
// Check if we need to move to next spine
const currentChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
);
const pagesInCurrentChapter =
currentChapter.endPage - currentChapter.startPage + 1;
if (currentPageInChapter < pagesInCurrentChapter - 1) {
// Still pages left in current chapter - just scroll down
container.scrollTop =
(currentPageInChapter + 1) * viewportHeightAdjusted;
} else if (
state.currentReader.currentSpineIndex <
state.currentReader.cif.spine.length - 1
) {
// At end of chapter, move to next spine
state.currentReader.currentSpineIndex++;
renderSpineItem();
if (currentChapter && nextPage > currentChapter.endPage) {
// Move to next spine
if (
state.currentReader.currentSpineIndex <
state.currentReader.cif.spine.length - 1
) {
state.currentReader.currentSpineIndex++;
}
}
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
readerEvents.emit("pageChanged", nextPage);
}
} else {
// Fallback: just move to next spine
// Fallback: spine-based navigation
if (
state.currentReader.currentSpineIndex <
state.currentReader.cif.spine.length - 1
) {
state.currentReader.currentSpineIndex++;
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
}
}
} else if (state.currentReader.type === "pdf") {
@@ -78,48 +76,54 @@ export function createNavigationAPI() {
renderComicPage();
}
}
setState({ currentReader: state.currentReader });
sendProgressUpdate();
readerEvents.emit("pageChanged", getCurrentPage());
readerEvents.emit("afterPageChange", state.currentReader);
},
previousPage: () => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
const container = document.getElementById("reader-content");
const viewportHeightAdjusted = window.innerHeight - 120;
if (pageCalculationResult) {
const currentChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
);
const currentPage = state.currentReader.currentPage || 1;
if (currentChapter && container) {
const currentPageInChapter = Math.floor(
container.scrollTop / viewportHeightAdjusted,
if (currentPage > 1) {
const prevPage = currentPage - 1;
// Check if we need to move to previous spine
const currentChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
);
if (currentPageInChapter > 0) {
// Not at start of chapter - just scroll up
container.scrollTop =
(currentPageInChapter - 1) * viewportHeightAdjusted;
} else if (state.currentReader.currentSpineIndex > 0) {
// At start of chapter, move to previous spine
state.currentReader.currentSpineIndex--;
renderSpineItem();
if (currentChapter && prevPage < currentChapter.startPage) {
// Move to previous spine
if (state.currentReader.currentSpineIndex > 0) {
state.currentReader.currentSpineIndex--;
const prevChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
);
state.currentReader.currentPage =
prevChapter?.endPage || prevPage;
} else {
state.currentReader.currentPage = 1;
}
} else {
state.currentReader.currentPage = prevPage;
}
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
readerEvents.emit("pageChanged", state.currentReader.currentPage);
}
} else {
// Fallback: just move to previous spine
// Fallback: spine-based navigation
if (state.currentReader.currentSpineIndex > 0) {
state.currentReader.currentSpineIndex--;
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
}
}
} else if (state.currentReader.type === "pdf") {
@@ -136,23 +140,41 @@ export function createNavigationAPI() {
renderComicPage();
}
}
setState({ currentReader: state.currentReader });
sendProgressUpdate();
readerEvents.emit("pageChanged", getCurrentPage());
readerEvents.emit("afterPageChange", state.currentReader);
},
goToPage: (page: number) => {
goToPage: async (page: number) => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
if (page >= 0 && page < state.currentReader.cif.spine.length) {
state.currentReader.currentSpineIndex = page;
renderSpineItem();
if (pageCalculationResult) {
// Validate page number
if (page < 1 || page > pageCalculationResult.totalPages) {
return;
}
// Find which spine contains this page
for (const chapter of pageCalculationResult.chapters) {
if (page >= chapter.startPage && page <= chapter.endPage) {
state.currentReader.currentSpineIndex = chapter.spineIndex;
state.currentReader.currentPage = page;
break;
}
}
setState({ currentReader: state.currentReader });
await renderSpineItem();
sendProgressUpdate();
} else {
// Fallback: treat page number as spine index
if (page >= 0 && page < state.currentReader.cif.spine.length) {
state.currentReader.currentSpineIndex = page;
state.currentReader.currentPage = page + 1;
setState({ currentReader: state.currentReader });
await renderSpineItem();
}
}
} else if (state.currentReader.type === "pdf") {
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
@@ -163,14 +185,12 @@ export function createNavigationAPI() {
state.currentReader.type === "comic" ||
state.currentReader.type === "manga"
) {
if (page >= 0 && page < state.currentReader.images.length) {
if (page >= 0 && page < (state.currentReader as any).images.length) {
state.currentReader.currentPage = page;
renderComicPage();
}
}
setState({ currentReader: state.currentReader });
sendProgressUpdate();
readerEvents.emit("pageChanged", page);
readerEvents.emit("afterPageChange", state.currentReader);
},
@@ -195,9 +215,7 @@ export async function initializePageCalculation() {
readerEvents.on("settings:changed", async (settings: any) => {
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return;
console.log("Recalculating pages due to settings change...");
const viewportWidth = window.innerWidth;
pageCalculationResult = await calculatePagesForEbook(
state.currentReader.cif,
@@ -208,9 +226,21 @@ export async function initializePageCalculation() {
marginWidth: settings.margin_width,
},
);
state.currentReader.pageCalculationResult = pageCalculationResult;
setState({ currentReader: state.currentReader });
// Re-render the current page with new settings
await renderSpineItem();
// Emit event so progress display updates
const currentPage = state.currentReader.currentPage || 1;
readerEvents.emit("progressUpdated", {
currentPage,
totalPages: pageCalculationResult.totalPages,
percentage: calculateProgressPercentage(
pageCalculationResult,
currentPage,
),
});
});
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return;
@@ -250,104 +280,67 @@ export async function initializePageCalculation() {
export async function renderSpineItem() {
const state = getState();
if (state.currentReader?.type !== "ebook") return;
if (!state.currentReader.currentPage) {
state.currentReader.currentPage = 1;
}
const container = document.getElementById("reader-content");
if (!container) return;
const spineItem =
state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
const container = document.getElementById("reader-content");
if (pageCalculationResult) {
const viewportHeight = window.innerHeight;
const currentPage = getCurrentPageFromScroll(
pageCalculationResult,
state.currentReader.currentSpineIndex,
container.scrollTop,
viewportHeight,
);
if (!spineItem) return;
// Get page calculation result
const chapter = pageCalculationResult?.chapterMap.get(
state.currentReader.currentSpineIndex,
);
// Store computed page for UI display
state.currentReader.currentPage = currentPage;
setState({ currentReader: state.currentReader });
// Calculate which page within the chapter we're on
let currentPageContent = "";
let currentPageNumber = state.currentReader.currentPage || 1;
const percentage = calculateProgressPercentage(
pageCalculationResult,
currentPage,
);
readerEvents.emit("progressUpdated", {
currentPage,
totalPages: pageCalculationResult.totalPages,
percentage,
});
if (chapter?.pages && chapter.pages.length > 0) {
// Calculate which page of this spine item to show
const globalPage = state.currentReader.currentPage || 1;
const pageWithinChapter = Math.max(0, globalPage - chapter.startPage);
const pageIndex = Math.min(pageWithinChapter, chapter.pages.length - 1);
currentPageContent = chapter.pages[pageIndex]?.html || chapter.content;
currentPageNumber = chapter.startPage + pageIndex;
} else {
// Fallback: load full content if pages not calculated yet
const resources = state.currentReader.cif.resources;
const contentBlob = resources?.get(spineItem.content);
if (contentBlob) {
currentPageContent = await contentBlob.text();
} else {
console.error(
"Spine item content not found in resources:",
spineItem.content,
);
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
return;
}
}
if (!container || !spineItem) return;
// Get the actual content from resources using the href
const resources = state.currentReader.cif.resources;
console.log("ALL resource keys:", Array.from(resources.keys()));
const contentBlob = resources?.get(spineItem.content);
if (!contentBlob) {
// Fallback: try to fetch directly if not in resources
console.error(
"Spine item content not found in resources:",
spineItem.content,
);
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
container.addEventListener(
"scroll",
() => {
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook")
return;
// Process and render the page content
const modifiedContent = rewriteImageUrls(
currentPageContent,
state.currentReader.cif.resources,
);
state.currentReader.currentScrollPosition = container.scrollTop;
if (pageCalculationResult) {
const viewportHeight = window.innerHeight;
const currentPage = getCurrentPageFromScroll(
pageCalculationResult,
state.currentReader.currentSpineIndex,
container.scrollTop,
viewportHeight,
);
const percentage = calculateProgressPercentage(
pageCalculationResult,
currentPage,
);
readerEvents.emit("progressUpdated", {
currentPage,
totalPages: pageCalculationResult.totalPages,
percentage,
});
}
},
{ passive: true },
);
return;
}
// Convert blob to text
const contentText = await contentBlob.text();
// Rewrite image src paths to use blob URLs from resources
const modifiedContent = rewriteImageUrls(contentText, resources);
const htmlImages = modifiedContent.match(/<img[^>]+>/g);
const svgImages =
modifiedContent.match(/<image[^>]+xlink:href="([^"]+)"[^>]*>/gi) || [];
const allImages = [...htmlImages, ...svgImages];
console.log("Found images in content:", allImages);
// Parse HTML and process images BEFORE setting innerHTML
// Parse HTML and process images
const parser = new DOMParser();
const doc = parser.parseFromString(modifiedContent, "text/html");
// Process <img> elements
const imgElements = Array.from(doc.querySelectorAll("img"));
for (const img of imgElements) {
const src = img.getAttribute("src");
if (!src) continue;
// Try different path formats to find the image
let blob = findImageInResources(resources, src);
const blob = findImageInResources(state.currentReader.cif.resources, src);
if (blob) {
// Create blob URL - need to do this properly
const blobUrl = URL.createObjectURL(blob);
img.setAttribute("src", blobUrl);
img.setAttribute("src", URL.createObjectURL(blob));
}
}
@@ -356,18 +349,33 @@ export async function renderSpineItem() {
for (const img of svgImgElements) {
const src = img.getAttribute("xlink:href");
if (!src) continue;
let blob = findImageInResources(resources, src);
const blob = findImageInResources(state.currentReader.cif.resources, src);
if (blob) {
const blobUrl = URL.createObjectURL(blob);
img.setAttribute("xlink:href", blobUrl);
img.setAttribute("xlink:href", URL.createObjectURL(blob));
}
}
// Apply to container - replace innerHTML with current page content
container.innerHTML = doc.body.innerHTML;
// Apply reader styling
// Apply styling
applyReaderTheme();
applyTypography();
// Emit progress update
if (pageCalculationResult) {
state.currentReader.currentPage = currentPageNumber;
setState({ currentReader: state.currentReader });
readerEvents.emit("progressUpdated", {
currentPage: currentPageNumber,
totalPages: pageCalculationResult.totalPages,
percentage: calculateProgressPercentage(
pageCalculationResult,
currentPageNumber,
),
});
}
}
function rewriteImageUrls(
@@ -523,14 +531,7 @@ function sendProgressUpdate() {
if (state.currentReader.type === "ebook") {
// Use dynamic page calculation if available
if (pageCalculationResult) {
const container = document.getElementById("reader-content");
const viewportHeight = window.innerHeight;
currentPage = getCurrentPageFromScroll(
pageCalculationResult,
state.currentReader.currentSpineIndex,
container?.scrollTop || 0,
viewportHeight,
);
currentPage = state.currentReader.currentPage || 1;
totalPages = pageCalculationResult.totalPages;
} else {
currentPage = state.currentReader.currentSpineIndex + 1;
+1 -2
View File
@@ -35,9 +35,8 @@ export function getReaderMetadata(): ReaderMetadata | null {
export function getCurrentPage(): number {
const reader = currentState.currentReader;
if (!reader) return 0;
if (reader.type === "ebook") {
return reader.currentSpineIndex;
return reader.currentPage ?? reader.currentSpineIndex + 1;
}
return reader.currentPage;
}
+19 -22
View File
@@ -1,4 +1,5 @@
import { findImageInResources } from "../core/reader-navigation";
import { splitContent, type PageContent } from "./page-splitter";
export interface ChapterPageInfo {
spineIndex: number;
@@ -9,6 +10,7 @@ export interface ChapterPageInfo {
scrollHeight: number;
charCount: number;
pagesInChapter: number;
pages: PageContent[];
}
export interface PageCalculationResult {
@@ -111,14 +113,6 @@ async function renderContentForMeasurement(
return container;
}
function calculatePageBreaks(
scrollHeight: number,
viewportHeight: number,
): number {
if (scrollHeight <= 0 || viewportHeight <= 0) return 1;
return Math.ceil(scrollHeight / viewportHeight);
}
export async function calculatePagesForEbook(
cif: EbookCIF,
viewportWidth: number,
@@ -159,6 +153,7 @@ export async function calculatePagesForEbook(
scrollHeight: 0,
charCount: 0,
pagesInChapter: 0,
pages: [],
});
continue;
}
@@ -174,6 +169,7 @@ export async function calculatePagesForEbook(
scrollHeight: 0,
charCount: 0,
pagesInChapter: 0,
pages: [],
});
continue;
}
@@ -182,41 +178,42 @@ export async function calculatePagesForEbook(
const charCount = contentText.replace(/<[^>]*>/g, "").length;
let scrollHeight = 0;
let pagesInChapter = 1;
try {
const container = await renderContentForMeasurement(
contentText,
cif.resources,
config,
);
scrollHeight = container.scrollHeight;
pagesInChapter = calculatePageBreaks(scrollHeight, viewportHeight);
container.remove();
} catch (error) {
console.warn(
"Failed to measure content for spine item:",
spineItem.id,
error,
);
pagesInChapter = Math.max(1, Math.ceil(charCount / 1500));
console.warn("Failed to measure content:", spineItem.id, error);
}
// Split content into discrete pages
const pageSplitResult = splitContent(
contentText,
i,
spineItem.id,
viewportHeight,
{ fontSize, lineHeight, marginWidth },
true, // useCFI
);
const chapterInfo: ChapterPageInfo = {
spineIndex: i,
spineItemId: spineItem.id,
content: contentText,
startPage: currentPage,
endPage: currentPage + pagesInChapter - 1,
endPage: currentPage + pageSplitResult.totalPages - 1,
scrollHeight,
charCount,
pagesInChapter,
pagesInChapter: pageSplitResult.totalPages,
pages: pageSplitResult.pages,
};
chapters.push(chapterInfo);
currentPage += pagesInChapter;
currentPage += pageSplitResult.totalPages;
}
const chapterMap = new Map<number, ChapterPageInfo>();
+276
View File
@@ -0,0 +1,276 @@
// Page splitter for ebooks - extracts discrete page content
// Uses CFI for EPUB when available, falls back to height-based splitting
import { generateCFI } from "./cfi-navigator";
export interface PageContent {
pageNumber: number;
spineIndex: number;
startCFI?: string;
endCFI?: string;
html: string;
charCount: number;
}
export interface PageSplitResult {
pages: PageContent[];
totalPages: number;
}
// Split content by viewport height (fallback for non-EPUB)
export function splitByHeight(
html: string,
spineIndex: number,
viewportHeight: number,
settings: { fontSize: number; lineHeight: number; marginWidth: number },
): PageSplitResult {
const pages: PageContent[] = [];
const doc = new DOMParser().parseFromString(html, "text/html");
// Clone body content
const content = doc.body;
const contentHeight = estimateContentHeight(content, settings);
const contentWidth = doc.body.scrollWidth || 600;
const pageCount = Math.max(1, Math.ceil(contentHeight / viewportHeight));
// For height-based splitting, we'll use a different approach:
// Wrap each block element and measure cumulative height
const blocks = Array.from(content.children);
let currentPageHTML = "";
let currentHeight = 0;
let pageNumber = 1;
for (const block of blocks) {
const blockHeight = estimateBlockHeight(block, settings, contentWidth);
if (
currentHeight + blockHeight > viewportHeight &&
currentPageHTML.length > 0
) {
// Save current page and start new one
pages.push({
pageNumber,
spineIndex,
html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount),
charCount: currentPageHTML.replace(/<[^>]*>/g, "").length,
});
pageNumber++;
currentPageHTML = "";
currentHeight = 0;
}
currentPageHTML += block.outerHTML;
currentHeight += blockHeight;
}
// Push final page
if (currentPageHTML.length > 0 || pages.length === 0) {
pages.push({
pageNumber,
spineIndex,
html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount),
charCount: currentPageHTML.replace(/<[^>]*>/g, "").length,
});
}
return { pages, totalPages: pages.length };
}
// Split content using CFI (for EPUB)
export function splitByCFI(
html: string,
spineIndex: number,
spineItemId: string,
viewportHeight: number,
settings: { fontSize: number; lineHeight: number; marginWidth: number },
): PageSplitResult {
console.log("splitByCFI called:", {
htmlLength: html.length,
viewportHeight,
spineIndex,
});
const pages: PageContent[] = [];
const doc = new DOMParser().parseFromString(html, "text/html");
// Find all text-containing elements (paragraphs, divs, etc.)
const elements = findTextElements(doc.body);
let currentPageElements: Element[] = [];
let currentHeight = 0;
const contentWidth = doc.body.scrollWidth || 600;
let pageNumber = 1;
let elementIndex = 0;
for (const element of elements) {
const elementHeight = estimateBlockHeight(element, settings, contentWidth);
if (
currentHeight + elementHeight > viewportHeight &&
currentPageElements.length > 0
) {
// Create page from accumulated elements
const pageHTML = elementsToHTML(currentPageElements);
const startIndex = elements.indexOf(currentPageElements[0]);
const endIndex = elements.indexOf(
currentPageElements[currentPageElements.length - 1],
);
pages.push({
pageNumber,
spineIndex,
startCFI: generateCFI(
spineIndex,
buildElementPath(elements[startIndex], doc.body),
0,
spineItemId,
),
endCFI: generateCFI(
spineIndex,
buildElementPath(elements[endIndex], doc.body),
0,
spineItemId,
),
html: wrapInPageContainer(pageHTML, pageNumber, 0), // pageCount TBD
charCount: pageHTML.replace(/<[^>]*>/g, "").length,
});
pageNumber++;
currentPageElements = [];
currentHeight = 0;
}
currentPageElements.push(element);
currentHeight += elementHeight;
elementIndex++;
}
// Push final page
if (currentPageElements.length > 0) {
const pageHTML = elementsToHTML(currentPageElements);
const startIndex = elements.indexOf(currentPageElements[0]);
const endIndex = elements.indexOf(
currentPageElements[currentPageElements.length - 1],
);
pages.push({
pageNumber,
spineIndex,
startCFI: generateCFI(
spineIndex,
buildElementPath(elements[startIndex], doc.body),
0,
spineItemId,
),
endCFI: generateCFI(
spineIndex,
buildElementPath(elements[endIndex], doc.body),
0,
spineItemId,
),
html: wrapInPageContainer(pageHTML, pageNumber, pageNumber),
charCount: pageHTML.replace(/<[^>]*>/g, "").length,
});
}
console.log("splitByCFI result:", {
totalPages: pages.length,
elementsFound: elements.length,
contentWidth,
});
return { pages, totalPages: pages.length };
}
// Helper: Estimate content height
function estimateContentHeight(element: Element, settings: any): number {
const text = element.textContent || "";
const charCount = text.length;
const charsPerLine = Math.floor(
(800 - settings.marginWidth * 2) / (settings.fontSize * 0.6),
);
const lineCount = Math.ceil(charCount / charsPerLine);
return lineCount * settings.fontSize * settings.lineHeight;
}
// Helper: Estimate block height
function estimateBlockHeight(
element: Element,
settings: any,
contentWidth: number,
): number {
const text = element.textContent || "";
const approxChars = text.length;
const lineHeight = settings.fontSize * settings.lineHeight;
// Use actual content width instead of hardcoded 800
const charsPerLine = Math.floor(contentWidth / (settings.fontSize * 0.6));
const lineCount = Math.max(1, Math.ceil(approxChars / charsPerLine));
return lineCount * lineHeight + 16;
}
// Helper: Find text-containing elements using querySelectorAll
function findTextElements(element: Element): Element[] {
const selector = [
"p",
"div",
"span",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"li",
"td",
"th",
"article",
"section",
"blockquote",
"pre",
"figcaption",
"header",
"footer",
"main",
"aside",
].join(",");
return Array.from(element.querySelectorAll(selector)).filter((el) => {
const text = el.textContent?.trim() || "";
return text.length > 0;
});
}
// Helper: Convert elements to HTML string
function elementsToHTML(elements: Element[]): string {
return elements.map((el) => el.outerHTML).join("");
}
// Helper: Build CFI element path
function buildElementPath(element: Element, root: Element): number[] {
const path: number[] = [];
let current: Element | null = element;
while (current && current !== root) {
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
const index = siblings.indexOf(current);
path.unshift(index);
current = parent;
} else {
break;
}
}
return path;
}
// Helper: Wrap content in page container
function wrapInPageContainer(
html: string,
current: number,
total: number,
): string {
return `<div class="ebook-page" data-page="${current}" data-total="${total}">${html}</div>`;
}
// Main entry point - choose method based on format
export function splitContent(
html: string,
spineIndex: number,
spineItemId: string | null,
viewportHeight: number,
settings: { fontSize: number; lineHeight: number; marginWidth: number },
useCFI: boolean = true,
): PageSplitResult {
if (useCFI && spineItemId) {
return splitByCFI(html, spineIndex, spineItemId, viewportHeight, settings);
}
return splitByHeight(html, spineIndex, viewportHeight, settings);
}
+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";
}
}
+30 -16
View File
@@ -1,5 +1,4 @@
import type { ReaderContext } from "../core/reader-context";
import { getCurrentPageFromScroll } from "../ebook/page-calculator";
interface ProgressDisplay {
mode: "pages" | "chapter" | "percentage" | "time-left";
text: string;
@@ -111,35 +110,50 @@ function updateProgressDisplay(context: ReaderContext): void {
if (!display || !state.currentReader) return;
let currentPage = 0;
let totalPages = 0;
let currentChapterPage = 0;
let chapterPages = 0;
if (state.currentReader.type === "ebook") {
const reader = state.currentReader as any;
const pageInfo = reader.pageCalculationResult;
if (pageInfo && pageInfo.totalPages > 0) {
const container = document.getElementById("reader-content");
const viewportHeight = window.innerHeight;
currentPage = getCurrentPageFromScroll(
pageInfo,
reader.currentSpineIndex,
container?.scrollTop || 0,
viewportHeight,
);
totalPages = pageInfo.totalPages;
} else {
// Fallback to estimated pages while calculating
// Use currentPage directly if available (page-based navigation)
if (reader.currentPage) {
currentPage = reader.currentPage;
} else if (reader.currentSpineIndex !== undefined) {
currentPage = reader.currentSpineIndex + 1;
}
// Get total pages from calculation result
const pageInfo = reader.pageCalculationResult;
if (pageInfo && pageInfo.totalPages > 0) {
totalPages = pageInfo.totalPages;
// Get chapter progress
const chapter = pageInfo.chapterMap.get(reader.currentSpineIndex);
if (chapter) {
currentChapterPage = currentPage - chapter.startPage + 1;
chapterPages = chapter.pagesInChapter;
}
} else {
// Fallback
totalPages =
state.readerMetadata?.total_pages ||
state.currentReader.cif.locations?.estimatedPages ||
state.currentReader.cif.spine.length;
}
} else if (state.currentReader.type === "pdf") {
currentPage = state.currentReader.currentPage;
totalPages = state.readerMetadata?.total_pages || 0;
} else {
// Comic/manga
currentPage = (state.currentReader as any).currentPage + 1;
totalPages = (state.currentReader as any).images?.length || 0;
}
getReadingSpeed().then((speed) => {
const result = calculateProgress(
currentPage,
totalPages,
0,
0,
currentChapterPage,
chapterPages,
speed || undefined,
);
display.textContent = result.text;
+8 -7
View File
@@ -11,6 +11,8 @@ export interface UniversalReader {
type: "ebook";
cif: any;
currentSpineIndex: number;
currentPage: number;
pageCalculationResult?: any;
}
interface PDFReader {
@@ -143,17 +145,15 @@ async function initializeReader(): Promise<void> {
await initializeFeatures(context);
readerEvents.emit("readerReady", currentReader);
// Render the initial chapter content
// Initialize page calculation FIRST, before any rendering
const { initializePageCalculation } =
await import("./core/reader-navigation");
await initializePageCalculation();
// Then render the initial chapter content
await renderSpineItem();
// Focus the content container so keyboard navigation works immediately
const container = document.getElementById("reader-content");
container?.focus();
// Initialize page calculation for dynamic page numbers
setTimeout(async () => {
const { initializePageCalculation } =
await import("./core/reader-navigation");
initializePageCalculation();
}, 100);
} catch (error) {
console.error("Render initialization failed:", error);
}
@@ -206,6 +206,7 @@ async function initializeEbookReader(metadata: any): Promise<UniversalReader> {
type: "ebook",
cif,
currentSpineIndex: 0,
currentPage: 1,
};
}