From 1e7d13dc85115090dbf37b3dabf3c295141bc1d4 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 6 Apr 2026 21:18:29 -0400 Subject: [PATCH] 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 --- web/src/reader/core/reader-navigation.ts | 327 +++++++++--------- web/src/reader/core/reader-state.ts | 3 +- web/src/reader/ebook/page-calculator.ts | 41 +-- web/src/reader/ebook/page-splitter.ts | 276 +++++++++++++++ web/src/reader/ebook/view-modes.ts | 98 +++--- web/src/reader/features/progress-indicator.ts | 46 ++- web/src/reader/reader-shell.ts | 15 +- 7 files changed, 539 insertions(+), 267 deletions(-) create mode 100644 web/src/reader/ebook/page-splitter.ts diff --git a/web/src/reader/core/reader-navigation.ts b/web/src/reader/core/reader-navigation.ts index 9bd44a7..4f7f35c 100644 --- a/web/src/reader/core/reader-navigation.ts +++ b/web/src/reader/core/reader-navigation.ts @@ -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 = `

Error: Could not load chapter content

`; + 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 = `

Error: Could not load chapter content

`; - 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(/]+>/g); - const svgImages = - modifiedContent.match(/]+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 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; diff --git a/web/src/reader/core/reader-state.ts b/web/src/reader/core/reader-state.ts index 8f1fa39..10c10a2 100644 --- a/web/src/reader/core/reader-state.ts +++ b/web/src/reader/core/reader-state.ts @@ -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; } diff --git a/web/src/reader/ebook/page-calculator.ts b/web/src/reader/ebook/page-calculator.ts index 4a7ee85..892e961 100644 --- a/web/src/reader/ebook/page-calculator.ts +++ b/web/src/reader/ebook/page-calculator.ts @@ -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(); diff --git a/web/src/reader/ebook/page-splitter.ts b/web/src/reader/ebook/page-splitter.ts new file mode 100644 index 0000000..c4a8e1d --- /dev/null +++ b/web/src/reader/ebook/page-splitter.ts @@ -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 `
${html}
`; +} +// 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); +} diff --git a/web/src/reader/ebook/view-modes.ts b/web/src/reader/ebook/view-modes.ts index 1846e6e..d05b125 100644 --- a/web/src/reader/ebook/view-modes.ts +++ b/web/src/reader/ebook/view-modes.ts @@ -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 = ` - - Page ${currentPage} of ${pageCount} - - `; - - 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"; -} \ No newline at end of file +} + diff --git a/web/src/reader/features/progress-indicator.ts b/web/src/reader/features/progress-indicator.ts index 7681ed5..f964a65 100644 --- a/web/src/reader/features/progress-indicator.ts +++ b/web/src/reader/features/progress-indicator.ts @@ -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; diff --git a/web/src/reader/reader-shell.ts b/web/src/reader/reader-shell.ts index 0e005ef..ba0298a 100644 --- a/web/src/reader/reader-shell.ts +++ b/web/src/reader/reader-shell.ts @@ -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 { 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 { type: "ebook", cif, currentSpineIndex: 0, + currentPage: 1, }; }