From 1a5eb40d82f2c3a88829ac474205f6a585fe89fb Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 7 Apr 2026 21:02:18 -0400 Subject: [PATCH] refactor(ebook-reader): switch to CSS columns pagination approach Replace HTML-splitting pagination with CSS columns for true paginated viewing. This simplifies the implementation and relies on the browser's native column-fill behavior for accurate page breaks. Changes: - view-modes.ts: Use CSS columns with column-fill: auto instead of pre-splitting HTML content into page chunks. Calculate page count from scrollHeight / viewportHeight. - reader-navigation.ts: Navigate by scrolling viewport height instead of extracting discrete page content. Track page position via scroll offset. Simplified renderSpineItem to load full spine content. - page-calculator.ts: Simplified to track spine info (charCount, estimatedPages) only. No more height-based content splitting. Page calculation happens in real-time from DOM scroll position. Benefits: - Accurate pagination without estimation errors - Works correctly across different font sizes and screen sizes - Simpler code with fewer edge cases - Natural page breaks at element boundaries via CSS --- web/src/reader/core/reader-navigation.ts | 414 +++++++++-------------- web/src/reader/ebook/page-calculator.ts | 257 +++----------- web/src/reader/ebook/view-modes.ts | 61 ++-- 3 files changed, 234 insertions(+), 498 deletions(-) diff --git a/web/src/reader/core/reader-navigation.ts b/web/src/reader/core/reader-navigation.ts index 4f7f35c..1efd4fa 100644 --- a/web/src/reader/core/reader-navigation.ts +++ b/web/src/reader/core/reader-navigation.ts @@ -2,16 +2,8 @@ import { getDefaultSettings } from "../settings-manager"; import { getState, setState } from "./reader-state"; import { readerEvents } from "./reader-events"; import { updateReadingProgress } from "./reader-services"; -import { - calculatePagesForEbook, - calculateProgressPercentage, - type PageCalculationResult, -} from "../ebook/page-calculator"; import { UniversalReader } from "../reader-shell"; -let pageCalculationResult: PageCalculationResult | null = null; -let isCalculatingPages = false; - export function createNavigationAPI() { return { nextPage: () => { @@ -19,50 +11,43 @@ export function createNavigationAPI() { if (!state.currentReader) return; readerEvents.emit("beforePageChange", state.currentReader); if (state.currentReader.type === "ebook") { - if (pageCalculationResult) { - const currentPage = state.currentReader.currentPage || 1; - - 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, - ); - - 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: spine-based navigation + const container = document.getElementById("reader-content"); + if (!container) return; + const viewportHeight = window.innerHeight - 120; + const currentScroll = container.scrollTop; + const newScroll = currentScroll + viewportHeight; + if (newScroll >= container.scrollHeight - viewportHeight) { if ( state.currentReader.currentSpineIndex < state.currentReader.cif.spine.length - 1 ) { state.currentReader.currentSpineIndex++; setState({ currentReader: state.currentReader }); - renderSpineItem(); + renderSpineItem().then(() => { + const newContainer = document.getElementById("reader-content"); + if (newContainer) newContainer.scrollTop = 0; + sendProgressUpdate(); + }); + } else { + container.scrollTo({ + top: container.scrollHeight, + behavior: "smooth", + }); sendProgressUpdate(); } + } else { + container.scrollTo({ + top: newScroll, + behavior: "smooth", + }); + sendProgressUpdate(); } } else if (state.currentReader.type === "pdf") { const totalPages = state.readerMetadata?.total_pages || 0; if (state.currentReader.currentPage < totalPages) { state.currentReader.currentPage++; renderPDFPage(); + sendProgressUpdate(); } } else if ( state.currentReader.type === "comic" || @@ -74,62 +59,46 @@ export function createNavigationAPI() { ) { state.currentReader.currentPage++; renderComicPage(); + sendProgressUpdate(); } } setState({ currentReader: state.currentReader }); readerEvents.emit("afterPageChange", state.currentReader); }, - previousPage: () => { const state = getState(); if (!state.currentReader) return; readerEvents.emit("beforePageChange", state.currentReader); if (state.currentReader.type === "ebook") { - if (pageCalculationResult) { - const currentPage = state.currentReader.currentPage || 1; - - 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 (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: spine-based navigation + const container = document.getElementById("reader-content"); + if (!container) return; + const currentScroll = container.scrollTop; + if (currentScroll <= 0) { if (state.currentReader.currentSpineIndex > 0) { state.currentReader.currentSpineIndex--; setState({ currentReader: state.currentReader }); - renderSpineItem(); - sendProgressUpdate(); + renderSpineItem().then(() => { + const newContainer = document.getElementById("reader-content"); + if (newContainer) { + newContainer.scrollTop = newContainer.scrollHeight; + } + sendProgressUpdate(); + }); } + } else { + const viewportHeight = window.innerHeight - 120; + const newScroll = currentScroll - viewportHeight; + container.scrollTo({ + top: Math.max(0, newScroll), + behavior: "smooth", + }); + sendProgressUpdate(); } } else if (state.currentReader.type === "pdf") { if (state.currentReader.currentPage > 1) { state.currentReader.currentPage--; renderPDFPage(); + sendProgressUpdate(); } } else if ( state.currentReader.type === "comic" || @@ -138,48 +107,41 @@ export function createNavigationAPI() { if (state.currentReader.currentPage > 0) { state.currentReader.currentPage--; renderComicPage(); + sendProgressUpdate(); } } setState({ currentReader: state.currentReader }); readerEvents.emit("afterPageChange", state.currentReader); }, - goToPage: async (page: number) => { const state = getState(); if (!state.currentReader) return; readerEvents.emit("beforePageChange", state.currentReader); if (state.currentReader.type === "ebook") { - if (pageCalculationResult) { - // Validate page number - if (page < 1 || page > pageCalculationResult.totalPages) { - return; + const spine = state.currentReader.cif.spine; + const spineCount = spine.length; + const pagesPerSpine = Math.ceil(1000 / spineCount); + const targetSpineIndex = Math.min( + Math.floor((page - 1) / pagesPerSpine), + spineCount - 1, + ); + state.currentReader.currentSpineIndex = targetSpineIndex; + setState({ currentReader: state.currentReader }); + await renderSpineItem(); + setTimeout(() => { + const container = document.getElementById("reader-content"); + if (container) { + const viewportHeight = window.innerHeight - 120; + const pageInSpine = page - targetSpineIndex * pagesPerSpine; + const scrollTop = Math.max(0, (pageInSpine - 1) * viewportHeight); + container.scrollTop = scrollTop; } - - // 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(); - } - } + }, 100); } else if (state.currentReader.type === "pdf") { if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) { state.currentReader.currentPage = page; renderPDFPage(); + sendProgressUpdate(); } } else if ( state.currentReader.type === "comic" || @@ -188,152 +150,98 @@ export function createNavigationAPI() { if (page >= 0 && page < (state.currentReader as any).images.length) { state.currentReader.currentPage = page; renderComicPage(); + sendProgressUpdate(); } } setState({ currentReader: state.currentReader }); readerEvents.emit("pageChanged", page); readerEvents.emit("afterPageChange", state.currentReader); }, - goToChapter: (chapterIndex: number) => { const state = getState(); if (!state.readerMetadata?.chapter_metadata?.chapters) return; - + if (state.currentReader?.type !== "ebook") return; const chapters = state.readerMetadata.chapter_metadata.chapters; if (chapterIndex < 0 || chapterIndex >= chapters.length) return; - const chapter = chapters[chapterIndex]; - const pageAPI = createNavigationAPI(); - pageAPI.goToPage(chapter.start_page); - + if ((chapter as any).spine_index !== undefined) { + state.currentReader.currentSpineIndex = (chapter as any).spine_index; + setState({ currentReader: state.currentReader }); + renderSpineItem(); + sendProgressUpdate(); + } else { + const pageAPI = createNavigationAPI(); + pageAPI.goToPage(chapter.start_page); + } readerEvents.emit("chapterChanged", chapter); }, }; } 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, - viewportWidth, - { - fontSize: settings.font_size, - lineHeight: settings.line_height, - 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, - ), - }); - }); + setupScrollTracking(); + console.log("Page tracking initialized (CSS columns mode)"); +} +function setupScrollTracking(): void { + let scrollTimeout: ReturnType | null = null; + document.addEventListener( + "scroll", + () => { + const container = document.getElementById("reader-content"); + if (!container) return; + const state = getState(); + if (!state.currentReader || state.currentReader.type !== "ebook") return; + if (scrollTimeout) clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { + updatePageFromScroll(container); + }, 50); + }, + { passive: true }, + ); +} +function updatePageFromScroll(container: HTMLElement): void { const state = getState(); if (!state.currentReader || state.currentReader.type !== "ebook") return; - if (isCalculatingPages) return; - - isCalculatingPages = true; - - try { - const settings = getDefaultSettings(); - const viewportWidth = window.innerWidth; - - pageCalculationResult = await calculatePagesForEbook( - state.currentReader.cif, - viewportWidth, - { - fontSize: settings.font_size, - lineHeight: settings.line_height, - marginWidth: settings.margin_width, - }, - ); - - state.currentReader.pageCalculationResult = pageCalculationResult; - setState({ currentReader: state.currentReader }); - - console.log( - "Page calculation complete:", - pageCalculationResult.totalPages, - "pages", - ); - } catch (error) { - console.error("Failed to calculate pages:", error); - } finally { - isCalculatingPages = false; - } + const viewportHeight = window.innerHeight - 120; + const scrollTop = container.scrollTop; + const contentHeight = container.scrollHeight; + const currentPage = Math.floor(scrollTop / viewportHeight) + 1; + const totalPages = Math.max(1, Math.ceil(contentHeight / viewportHeight)); + const percentage = contentHeight > 0 ? (scrollTop / contentHeight) * 100 : 0; + state.currentReader.currentPage = currentPage; + state.currentReader.currentScrollPosition = scrollTop; + setState({ currentReader: state.currentReader }); + readerEvents.emit("progressUpdated", { + currentPage, + totalPages, + percentage, + scrollTop, + contentHeight, + }); } -export async function renderSpineItem() { +export async function renderSpineItem(): Promise { 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]; if (!spineItem) return; - // Get page calculation result - const chapter = pageCalculationResult?.chapterMap.get( - state.currentReader.currentSpineIndex, - ); - - // Calculate which page within the chapter we're on - let currentPageContent = ""; - let currentPageNumber = state.currentReader.currentPage || 1; - - 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; - } + const resources = state.currentReader.cif.resources; + const contentBlob = resources?.get(spineItem.content); + if (!contentBlob) { + console.error("Spine item content not found:", spineItem.content); + container.innerHTML = `

Error: Could not load chapter content

`; + return; } - // Process and render the page content + let currentPageContent = await contentBlob.text(); const modifiedContent = rewriteImageUrls( currentPageContent, state.currentReader.cif.resources, ); - - // 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"); @@ -343,8 +251,6 @@ export async function renderSpineItem() { img.setAttribute("src", URL.createObjectURL(blob)); } } - - // Process SVG elements with xlink:href const svgImgElements = Array.from(doc.querySelectorAll("image")); for (const img of svgImgElements) { const src = img.getAttribute("xlink:href"); @@ -354,28 +260,12 @@ export async function renderSpineItem() { img.setAttribute("xlink:href", URL.createObjectURL(blob)); } } - - // Apply to container - replace innerHTML with current page content - container.innerHTML = doc.body.innerHTML; - - // Apply styling + container.innerHTML = `
${doc.body.innerHTML}
`; 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, - ), - }); - } + requestAnimationFrame(() => { + updatePageFromScroll(container); + }); } function rewriteImageUrls( @@ -511,32 +401,32 @@ function getFontStack(font: string): string { return stacks[font] || stacks["literata"]; } -function sendProgressUpdate() { +function sendProgressUpdate(): void { const state = getState(); if (!state.currentReader || !state.readerMetadata) return; - const mediaItemId = state.readerMetadata.id || document.body.dataset.mediaItemId || window.location.pathname.split("/").pop(); - if (!mediaItemId) { console.warn("No mediaItemId available for progress update"); return; } - - let currentPage = 0; - let totalPages = 0; - - if (state.currentReader.type === "ebook") { - // Use dynamic page calculation if available - if (pageCalculationResult) { - currentPage = state.currentReader.currentPage || 1; - totalPages = pageCalculationResult.totalPages; - } else { - currentPage = state.currentReader.currentSpineIndex + 1; - totalPages = state.currentReader.cif.spine.length; - } + const container = document.getElementById("reader-content"); + let currentPage = 1; + let totalPages = 1; + let percentage = 0; + let character = 0; + if (state.currentReader.type === "ebook" && container) { + const viewportHeight = window.innerHeight - 120; + const contentHeight = container.scrollHeight; + const scrollTop = container.scrollTop; + currentPage = Math.floor(scrollTop / viewportHeight) + 1; + totalPages = Math.max(1, Math.ceil(contentHeight / viewportHeight)); + percentage = contentHeight > 0 ? (scrollTop / contentHeight) * 100 : 0; + character = getCharacterOffset(); + state.currentReader.currentPage = currentPage; + setState({ currentReader: state.currentReader }); } else if (state.currentReader.type === "pdf") { totalPages = state.readerMetadata.total_pages || 0; currentPage = state.currentReader.currentPage; @@ -547,10 +437,7 @@ function sendProgressUpdate() { totalPages = state.currentReader.images.length; currentPage = state.currentReader.currentPage; } - const reader = state.currentReader as UniversalReader; - const percentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0; - updateReadingProgress( state.readerMetadata.id, { @@ -558,19 +445,26 @@ function sendProgressUpdate() { total_pages: totalPages, }, { - // Extended data for cross-device sync - character: getCharacterOffset(), + character, chapter: reader.currentSpineIndex, - percentage: percentage, + percentage, }, ); - - readerEvents.emit("progressUpdated", { currentPage, totalPages }); + readerEvents.emit("progressUpdated", { currentPage, totalPages, percentage }); } function getCharacterOffset(): number { const container = document.getElementById("reader-content"); if (!container) return 0; - const textContent = container.textContent || ""; - return textContent.length; + const viewportHeight = window.innerHeight - 120; + const scrollTop = container.scrollTop; + const viewportIndex = Math.floor(scrollTop / viewportHeight); + const allText = container.textContent || ""; + const totalChars = allText.length; + const viewportCount = Math.max( + 1, + Math.ceil(container.scrollHeight / viewportHeight), + ); + const charsPerViewport = totalChars / viewportCount; + return Math.floor(viewportIndex * charsPerViewport); } diff --git a/web/src/reader/ebook/page-calculator.ts b/web/src/reader/ebook/page-calculator.ts index 892e961..9fe92a6 100644 --- a/web/src/reader/ebook/page-calculator.ts +++ b/web/src/reader/ebook/page-calculator.ts @@ -1,121 +1,25 @@ -import { findImageInResources } from "../core/reader-navigation"; -import { splitContent, type PageContent } from "./page-splitter"; - -export interface ChapterPageInfo { +export interface SpineInfo { spineIndex: number; spineItemId: string; content: string; - startPage: number; - endPage: number; - scrollHeight: number; charCount: number; - pagesInChapter: number; - pages: PageContent[]; + estimatedPages: number; } - export interface PageCalculationResult { totalPages: number; - chapters: ChapterPageInfo[]; - chapterMap: Map; + spines: SpineInfo[]; + spineMap: Map; calculatedAt: number; - settings: PageCalculationSettings; } - export interface PageCalculationSettings { fontSize: number; lineHeight: number; marginWidth: number; viewportHeight: number; } - -interface HiddenContainerConfig { - width: number; - fontSize: number; - lineHeight: number; - paddingTop: number; - paddingBottom: number; - paddingLeft: number; - paddingRight: number; -} - -function getDefaultContainerConfig( - viewportWidth: number, - settings: PageCalculationSettings, -): HiddenContainerConfig { - const contentWidth = viewportWidth - settings.marginWidth * 2; - return { - width: contentWidth, - fontSize: settings.fontSize, - lineHeight: settings.lineHeight, - paddingTop: settings.marginWidth, - paddingBottom: settings.marginWidth, - paddingLeft: settings.marginWidth, - paddingRight: settings.marginWidth, - }; -} - -function createHiddenContainer(config: HiddenContainerConfig): HTMLElement { - const container = document.createElement("div"); - container.id = "page-calculation-hidden"; - container.style.position = "absolute"; - container.style.left = "-9999px"; - container.style.top = "0"; - container.style.width = `${config.width}px`; - container.style.fontSize = `${config.fontSize}px`; - container.style.lineHeight = config.lineHeight.toString(); - container.style.padding = `${config.paddingTop}px ${config.paddingRight}px ${config.paddingBottom}px ${config.paddingLeft}px`; - container.style.boxSizing = "border-box"; - container.style.overflow = "hidden"; - container.style.wordWrap = "break-word"; - container.style.whiteSpace = "pre-wrap"; - - return container; -} - -function stripScriptsAndStyles(html: string): string { - let result = html; - result = result.replace(/]*>[\s\S]*?<\/script>/gi, ""); - result = result.replace(/]*>[\s\S]*?<\/style>/gi, ""); - result = result.replace(/]*>/gi, ""); - return result; -} - -async function renderContentForMeasurement( - content: string, - resources: Map, - config: HiddenContainerConfig, -): Promise { - const container = createHiddenContainer(config); - - const parser = new DOMParser(); - const doc = parser.parseFromString( - stripScriptsAndStyles(content), - "text/html", - ); - - const images = Array.from(doc.querySelectorAll("img")); - for (const img of images) { - const src = img.getAttribute("src"); - if (!src) continue; - - let blob = findImageInResources(resources, src); - if (!blob) blob = resources.get(src.split("/").pop() || ""); - - if (blob) { - const blobUrl = URL.createObjectURL(blob); - img.setAttribute("src", blobUrl); - } - } - - container.appendChild(doc.body); - document.body.appendChild(container); - - return container; -} - export async function calculatePagesForEbook( cif: EbookCIF, - viewportWidth: number, + _viewportWidth: number, settings: { fontSize?: number; lineHeight?: number; @@ -124,156 +28,103 @@ export async function calculatePagesForEbook( ): Promise { const fontSize = settings.fontSize || 16; const lineHeight = settings.lineHeight || 1.6; - const marginWidth = settings.marginWidth || 20; - - const viewportHeight = window.innerHeight - 120; - - const pageSettings: PageCalculationSettings = { - fontSize, - lineHeight, - marginWidth, - viewportHeight, - }; - - const config = getDefaultContainerConfig(viewportWidth, pageSettings); - - const chapters: ChapterPageInfo[] = []; - let currentPage = 1; - + const charsPerPage = Math.round(2000 * (16 / fontSize) * (lineHeight / 1.6)); + const spines: SpineInfo[] = []; + let estimatedTotalPages = 0; for (let i = 0; i < cif.spine.length; i++) { const spineItem = cif.spine[i]; - if (spineItem.type !== "html") { - chapters.push({ + spines.push({ spineIndex: i, spineItemId: spineItem.id, content: "", - startPage: currentPage, - endPage: currentPage, - scrollHeight: 0, charCount: 0, - pagesInChapter: 0, - pages: [], + estimatedPages: 1, }); + estimatedTotalPages += 1; continue; } - const contentBlob = cif.resources.get(spineItem.content); if (!contentBlob) { - chapters.push({ + spines.push({ spineIndex: i, spineItemId: spineItem.id, content: "", - startPage: currentPage, - endPage: currentPage, - scrollHeight: 0, charCount: 0, - pagesInChapter: 0, - pages: [], + estimatedPages: 1, }); + estimatedTotalPages += 1; continue; } - const contentText = await contentBlob.text(); const charCount = contentText.replace(/<[^>]*>/g, "").length; - - let scrollHeight = 0; - try { - const container = await renderContentForMeasurement( - contentText, - cif.resources, - config, - ); - scrollHeight = container.scrollHeight; - container.remove(); - } catch (error) { - 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 = { + const estimatedPages = Math.max(1, Math.ceil(charCount / charsPerPage)); + estimatedTotalPages += estimatedPages; + spines.push({ spineIndex: i, spineItemId: spineItem.id, content: contentText, - startPage: currentPage, - endPage: currentPage + pageSplitResult.totalPages - 1, - scrollHeight, charCount, - pagesInChapter: pageSplitResult.totalPages, - pages: pageSplitResult.pages, - }; - - chapters.push(chapterInfo); - currentPage += pageSplitResult.totalPages; + estimatedPages, + }); } - - const chapterMap = new Map(); - for (const chapter of chapters) { - chapterMap.set(chapter.spineIndex, chapter); + const spineMap = new Map(); + for (const spine of spines) { + spineMap.set(spine.spineIndex, spine); } - - const totalPages = currentPage - 1; - return { - totalPages, - chapters, - chapterMap, + totalPages: estimatedTotalPages, + spines, + spineMap, calculatedAt: Date.now(), - settings: pageSettings, }; } - export function getCurrentPageFromScroll( pageInfo: PageCalculationResult, currentSpineIndex: number, scrollPosition: number, viewportHeight: number, ): number { - const chapter = pageInfo.chapterMap.get(currentSpineIndex); - if (!chapter || chapter.pagesInChapter === 0) { - return 1; - } - - const viewportHeightAdjusted = viewportHeight - 120; - const positionInChapter = Math.floor(scrollPosition / viewportHeightAdjusted); - - return Math.min( - chapter.endPage, - Math.max(chapter.startPage, chapter.startPage + positionInChapter), - ); + const spine = pageInfo.spineMap.get(currentSpineIndex); + if (!spine) return 1; + const charsBeforeSpine = pageInfo.spines + .slice(0, currentSpineIndex) + .reduce((sum, s) => sum + s.charCount, 0); + const charsInSpine = spine.charCount; + const charsAtPosition = (scrollPosition / viewportHeight) * charsInSpine; + const totalCharsAtPosition = charsBeforeSpine + charsAtPosition; + const charsPerPage = 2000; + return Math.ceil(totalCharsAtPosition / charsPerPage); } - export function getScrollPositionForPage( pageInfo: PageCalculationResult, targetPage: number, viewportHeight: number, ): { spineIndex: number; scrollTop: number } | null { - const viewportHeightAdjusted = viewportHeight - 120; - - for (const chapter of pageInfo.chapters) { - if (targetPage >= chapter.startPage && targetPage <= chapter.endPage) { - const positionInChapter = targetPage - chapter.startPage; - const scrollTop = positionInChapter * viewportHeightAdjusted; - + const charsPerPage = 2000; + const targetChar = (targetPage - 1) * charsPerPage; + let charsAccumulated = 0; + for (const spine of pageInfo.spines) { + charsAccumulated += spine.charCount; + if (targetChar < charsAccumulated) { + const charsBefore = charsAccumulated - spine.charCount; + const charsIntoSpine = targetChar - charsBefore; + const scrollTop = (charsIntoSpine / spine.charCount) * viewportHeight; return { - spineIndex: chapter.spineIndex, - scrollTop, + spineIndex: spine.spineIndex, + scrollTop: Math.max( + 0, + Math.min(scrollTop, viewportHeight * spine.estimatedPages), + ), }; } } - - return null; + const lastSpine = pageInfo.spines[pageInfo.spines.length - 1]; + return { + spineIndex: lastSpine?.spineIndex || 0, + scrollTop: 0, + }; } - export function calculateProgressPercentage( pageInfo: PageCalculationResult, currentPage: number, diff --git a/web/src/reader/ebook/view-modes.ts b/web/src/reader/ebook/view-modes.ts index d05b125..3cdb0f7 100644 --- a/web/src/reader/ebook/view-modes.ts +++ b/web/src/reader/ebook/view-modes.ts @@ -72,15 +72,15 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void { function applyPaginatedMode(element: HTMLElement): void { element.classList.add("paginated"); - - // 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"; + // Set up for CSS columns + element.style.height = "calc(100vh - 120px)"; + element.style.overflowY = "auto"; + element.style.overflowX = "hidden"; element.style.columnCount = "1"; + element.style.columnFill = "auto"; element.style.columnGap = "0"; element.style.position = "relative"; - - // Inject CSS for page clipping + // Inject CSS for CSS column pagination injectPaginatedStyles(); } @@ -92,23 +92,22 @@ function injectPaginatedStyles(): void { const style = document.createElement("style"); style.id = "paginated-styles"; style.textContent = ` - .paginated .ebook-page { - height: 100%; - overflow: hidden; - display: flex; - flex-direction: column; + .paginated { + height: calc(100vh - 120px) !important; + overflow-y: auto !important; + overflow-x: hidden !important; + column-fill: auto !important; + column-count: 1 !important; + column-gap: 0 !important; + position: relative !important; } - .paginated .ebook-page > * { - max-height: 100%; - overflow: hidden; + .paginated > * { + max-width: 100%; + column-fill: auto; } .paginated .ebook-content { - height: 100%; - overflow: hidden !important; - position: relative; - } - .paginated .ebook-content * { - overflow-wrap: break-word; + height: auto !important; + min-height: 100%; } `; document.head.appendChild(style); @@ -144,29 +143,22 @@ function applyDoubleColumn(element: HTMLElement): void { function goToPage(container: HTMLElement, pageNumber: number): void { const content = container.querySelector(".ebook-content") as HTMLElement; if (!content) return; - - const pageHeight = content.clientHeight; - const scrollTop = (pageNumber - 1) * pageHeight; - + const totalPages = getTotalPageCount(container); + const targetPage = Math.min(Math.max(1, pageNumber), totalPages); + const viewportHeight = window.innerHeight - 120; + const scrollTop = (targetPage - 1) * viewportHeight; content.scrollTo({ top: scrollTop, behavior: "smooth", }); - - const pageInfo = container.querySelector(".page-info"); - if (pageInfo) { - pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`; - } } function getTotalPageCount(container: HTMLElement): number { const content = container.querySelector(".ebook-content") as HTMLElement; if (!content) return 1; - - const totalHeight = content.scrollHeight; - const pageHeight = content.clientHeight; - - return Math.ceil(totalHeight / pageHeight); + const viewportHeight = window.innerHeight - 120; + const contentHeight = content.scrollHeight; + return Math.max(1, Math.ceil(contentHeight / viewportHeight)); } export function getCurrentViewMode(container: HTMLElement): ViewMode { @@ -180,4 +172,3 @@ export function getCurrentViewMode(container: HTMLElement): ViewMode { return "paginated"; } -