From edc733947b2bb8607dd97f9ae35eb37f543a8d56 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 5 Apr 2026 21:14:42 -0400 Subject: [PATCH] feat: integrate page calculator with reader navigation - Add page calculation state and initialization function - Import page calculator functions for dynamic page tracking - Update nextPage/previousPage to use getScrollPositionForPage for chapter navigation - Add scroll tracking in renderSpineItem for real-time page updates - Emit progressUpdated with dynamic page count instead of spine index - Fix applyReaderTheme to use classList.add instead of className overwrite - Remove unused state variables from applyReaderTheme/applyTypography --- web/src/reader/core/reader-navigation.ts | 238 +++++++++++++++++++++-- 1 file changed, 219 insertions(+), 19 deletions(-) diff --git a/web/src/reader/core/reader-navigation.ts b/web/src/reader/core/reader-navigation.ts index d10acac..d0b0204 100644 --- a/web/src/reader/core/reader-navigation.ts +++ b/web/src/reader/core/reader-navigation.ts @@ -1,8 +1,17 @@ -import type { ReaderMetadata } from "../../types/reader"; -import type { CurrentReader } from "./reader-context"; -import { getState, setState } from "./reader-state"; +import { getDefaultSettings } from "../settings-manager"; +import { getState, setState, getCurrentPage } from "./reader-state"; import { readerEvents } from "./reader-events"; import { updateReadingProgress } from "./reader-services"; +import { + calculatePagesForEbook, + calculateProgressPercentage, + getCurrentPageFromScroll, + getScrollPositionForPage, + type PageCalculationResult, +} from "../ebook/page-calculator"; + +let pageCalculationResult: PageCalculationResult | null = null; +let isCalculatingPages = false; export function createNavigationAPI() { return { @@ -18,6 +27,25 @@ export function createNavigationAPI() { state.currentReader.cif.spine.length - 1 ) { state.currentReader.currentSpineIndex++; + if (pageCalculationResult) { + const container = document.getElementById("reader-content"); + if (container) { + const viewportHeight = window.innerHeight; + const target = getScrollPositionForPage( + pageCalculationResult, + state.currentReader.currentPage || + state.currentReader.currentSpineIndex + 1, + viewportHeight, + ); + if ( + target && + target.spineIndex !== state.currentReader.currentSpineIndex + ) { + state.currentReader.currentSpineIndex = target.spineIndex; + container.scrollTop = target.scrollTop; + } + } + } renderSpineItem(); } } else if (state.currentReader.type === "pdf") { @@ -54,6 +82,25 @@ export function createNavigationAPI() { if (state.currentReader.type === "ebook") { if (state.currentReader.currentSpineIndex > 0) { state.currentReader.currentSpineIndex--; + if (pageCalculationResult) { + const container = document.getElementById("reader-content"); + if (container) { + const viewportHeight = window.innerHeight; + const target = getScrollPositionForPage( + pageCalculationResult, + state.currentReader.currentPage || + state.currentReader.currentSpineIndex + 1, + viewportHeight, + ); + if ( + target && + target.spineIndex !== state.currentReader.currentSpineIndex + ) { + state.currentReader.currentSpineIndex = target.spineIndex; + container.scrollTop = target.scrollTop; + } + } + } renderSpineItem(); } } else if (state.currentReader.type === "pdf") { @@ -125,20 +172,182 @@ export function createNavigationAPI() { }; } -function renderSpineItem() { +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 }); + }); + 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; + } +} + +export async function renderSpineItem() { const state = getState(); if (state.currentReader?.type !== "ebook") return; - const spineItem = state.currentReader.cif.spine[state.currentReader.currentSpineIndex]; const container = document.getElementById("reader-content"); - if (!container) return; + if (pageCalculationResult) { + const chapter = pageCalculationResult.chapterMap.get( + state.currentReader.currentSpineIndex, + ); + if (chapter) { + // Reset scroll to start of new chapter + container.scrollTop = 0; + state.currentReader.currentScrollPosition = 0; + } + } + if (!container || !spineItem) return; + // Get the actual content from resources using the href + const resources = state.currentReader.cif.resources; + 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; - container.innerHTML = spineItem.content; + 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 images = modifiedContent.match(/]+>/g); + console.log("Found images in content:", images); + // Test resource lookup + console.log("Trying to find image:", { + direct: resources.get("image/1.png"), + withoutFolder: resources.get("1.png"), + allImageKeys: Array.from(resources.keys()).filter( + (k: string) => k.includes("image") || k.includes("png"), + ), + }); + + // Parse HTML and process images BEFORE setting innerHTML + const parser = new DOMParser(); + const doc = parser.parseFromString(modifiedContent, "text/html"); + 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 = resources.get(src); + if (!blob) blob = resources.get(src.split("/").pop()); // Try just filename + + if (blob) { + // Create blob URL - need to do this properly + const blobUrl = URL.createObjectURL(blob); + img.setAttribute("src", blobUrl); + } + } + container.innerHTML = doc.body.innerHTML; + // Apply reader styling applyReaderTheme(); applyTypography(); } +function rewriteImageUrls( + htmlContent: string, + resources: Map, +): string { + // Find all img tags and rewrite their src to blob URLs + const imgRegex = /]*src="([^"]+)"[^>]*>/gi; + return htmlContent.replace(imgRegex, (match, src) => { + // Try to find the image in resources + const imageBlob = + resources.get(src) || resources.get(src.replace(/^.*\//, "")); // Try filename only + + if (imageBlob) { + const blobUrl = URL.createObjectURL(imageBlob); + return match.replace(src, blobUrl); + } + return match; // Keep original if not found + }); +} + async function renderPDFPage() { const state = getState(); if (state.currentReader?.type !== "pdf") return; @@ -196,17 +405,15 @@ function renderComicPage() { } function applyReaderTheme() { - const state = getState(); - const settings = getReaderSettings(); + const settings = getDefaultSettings(); const container = document.getElementById("reader-content"); if (!container) return; - container.className = `ebook-content theme-${settings.reading_theme}`; + container.classList.add(`theme-${settings.reading_theme}`); } function applyTypography() { - const state = getState(); - const settings = getReaderSettings(); + const settings = getDefaultSettings(); const container = document.getElementById("reader-content"); if (!container) return; @@ -229,11 +436,6 @@ function getFontStack(font: string): string { return stacks[font] || stacks["literata"]; } -function getReaderSettings() { - // TODO: Load from settings manager - return {} as any; -} - function sendProgressUpdate() { const state = getState(); if (!state.currentReader || !state.readerMetadata) return; @@ -262,5 +464,3 @@ function sendProgressUpdate() { readerEvents.emit("progressUpdated", { currentPage, totalPages }); } - -import { getCurrentPage } from "./reader-state";