From ece77ed1be8aac70389473f16f85ac9c6bb55ac4 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 6 Apr 2026 16:02:25 -0400 Subject: [PATCH] fix(ebook-reader): improve image loading, keyboard nav, and progress tracking - Add enhanced image path lookup (findImageInResources) that tries multiple path variations: full path, relative path, filename only, without extension, and common extensions (.jpg, .jpeg, .gif, .webp, .svg, .png) - Fix keyboard navigation by focusing container on reader init - Implement Kindle-style page display using pageCalculationResult for both currentPage and totalPages instead of raw spine index - Store computed currentPage in state during scroll for UI display - Extend progress API to send character offset, chapter index, and percentage for accurate cross-device sync (backend already supports these fields) --- web/src/reader/core/reader-navigation.ts | 86 ++++++++++++++++++++---- web/src/reader/core/reader-services.ts | 28 +++++++- web/src/reader/ebook/page-calculator.ts | 4 +- web/src/reader/reader-shell.ts | 13 +++- 4 files changed, 114 insertions(+), 17 deletions(-) diff --git a/web/src/reader/core/reader-navigation.ts b/web/src/reader/core/reader-navigation.ts index 2ee08be..13ac6ed 100644 --- a/web/src/reader/core/reader-navigation.ts +++ b/web/src/reader/core/reader-navigation.ts @@ -8,6 +8,7 @@ import { getCurrentPageFromScroll, type PageCalculationResult, } from "../ebook/page-calculator"; +import { UniversalReader } from "../reader-shell"; let pageCalculationResult: PageCalculationResult | null = null; let isCalculatingPages = false; @@ -253,14 +254,27 @@ export async function renderSpineItem() { state.currentReader.cif.spine[state.currentReader.currentSpineIndex]; const container = document.getElementById("reader-content"); if (pageCalculationResult) { - const chapter = pageCalculationResult.chapterMap.get( + const viewportHeight = window.innerHeight; + const currentPage = getCurrentPageFromScroll( + pageCalculationResult, state.currentReader.currentSpineIndex, + container.scrollTop, + viewportHeight, ); - if (chapter) { - // Reset scroll to start of new chapter - container.scrollTop = 0; - state.currentReader.currentScrollPosition = 0; - } + + // Store computed page for UI display + state.currentReader.currentPage = currentPage; + setState({ currentReader: state.currentReader }); + + const percentage = calculateProgressPercentage( + pageCalculationResult, + currentPage, + ); + readerEvents.emit("progressUpdated", { + currentPage, + totalPages: pageCalculationResult.totalPages, + percentage, + }); } if (!container || !spineItem) return; // Get the actual content from resources using the href @@ -332,8 +346,7 @@ export async function renderSpineItem() { 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 + let blob = findImageInResources(resources, src); if (blob) { // Create blob URL - need to do this properly @@ -355,8 +368,7 @@ function rewriteImageUrls( 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 + const imageBlob = findImageInResources(resources, src); if (imageBlob) { const blobUrl = URL.createObjectURL(imageBlob); @@ -366,6 +378,33 @@ function rewriteImageUrls( }); } +export function findImageInResources( + resources: Map, + src: string, +): Blob | undefined { + // Try full path as stored + if (resources.has(src)) return resources.get(src); + // Try relative path (everything after first /) + const firstSlash = src.indexOf("/"); + if (firstSlash > 0) { + const relativePath = src.substring(firstSlash + 1); + if (resources.has(relativePath)) return resources.get(relativePath); + } + // Try filename only + const filename = src.split("/").pop(); + if (filename && resources.has(filename)) return resources.get(filename); + // Try without extension + const withoutExt = filename?.replace(/\.[^.]+$/, ""); + if (withoutExt && resources.has(withoutExt)) return resources.get(withoutExt); + // Try with common extensions + const extensions = [".jpg", ".jpeg", ".gif", ".webp", ".svg", ".png"]; + for (const ext of extensions) { + const withExt = withoutExt + ext; + if (resources.has(withExt)) return resources.get(withExt); + } + return undefined; +} + async function renderPDFPage() { const state = getState(); if (state.currentReader?.type !== "pdf") return; @@ -498,10 +537,29 @@ function sendProgressUpdate() { currentPage = state.currentReader.currentPage; } - updateReadingProgress(state.readerMetadata.media_item_id, { - current_page: currentPage, - total_pages: totalPages, - }); + const reader = state.currentReader as UniversalReader; + const percentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0; + + updateReadingProgress( + state.readerMetadata.media_item_id, + { + current_page: currentPage, + total_pages: totalPages, + }, + { + // Extended data for cross-device sync + character: getCharacterOffset(), + chapter: reader.currentSpineIndex, + percentage: percentage, + }, + ); readerEvents.emit("progressUpdated", { currentPage, totalPages }); } + +function getCharacterOffset(): number { + const container = document.getElementById("reader-content"); + if (!container) return 0; + const textContent = container.textContent || ""; + return textContent.length; +} diff --git a/web/src/reader/core/reader-services.ts b/web/src/reader/core/reader-services.ts index 31e146a..2342093 100644 --- a/web/src/reader/core/reader-services.ts +++ b/web/src/reader/core/reader-services.ts @@ -5,17 +5,43 @@ interface ReadingProgress { total_pages: number; } +interface ExtendedProgress { + character?: number; + chapter?: number; + percentage?: number; +} + export async function updateReadingProgress( mediaItemId: string, progress: ReadingProgress, + extended?: ExtendedProgress, ): Promise { if (!mediaItemId || mediaItemId === "undefined") { console.warn("Skipping progress update - no valid mediaItemId"); return; } + const payload: any = { + location: { + page: progress.current_page, + total_pages: progress.total_pages, + }, + }; + + if (extended) { + if (extended.character !== undefined) { + payload.location.character = extended.character; + } + if (extended.chapter !== undefined) { + payload.location.chapter = extended.chapter; + } + if (extended.percentage !== undefined) { + payload.location.percentage = extended.percentage; + } + } + const response = await apiPut( `/media-items/${mediaItemId}/progress`, - progress, + payload, ); if (!response.ok) { const errorText = await response.text(); diff --git a/web/src/reader/ebook/page-calculator.ts b/web/src/reader/ebook/page-calculator.ts index c9e83d5..4a7ee85 100644 --- a/web/src/reader/ebook/page-calculator.ts +++ b/web/src/reader/ebook/page-calculator.ts @@ -1,3 +1,5 @@ +import { findImageInResources } from "../core/reader-navigation"; + export interface ChapterPageInfo { spineIndex: number; spineItemId: string; @@ -94,7 +96,7 @@ async function renderContentForMeasurement( const src = img.getAttribute("src"); if (!src) continue; - let blob = resources.get(src); + let blob = findImageInResources(resources, src); if (!blob) blob = resources.get(src.split("/").pop() || ""); if (blob) { diff --git a/web/src/reader/reader-shell.ts b/web/src/reader/reader-shell.ts index 6b9bac3..0e005ef 100644 --- a/web/src/reader/reader-shell.ts +++ b/web/src/reader/reader-shell.ts @@ -7,7 +7,7 @@ import { createNavigationAPI } from "./core/reader-navigation"; import { readerEvents } from "./core/reader-events"; import { renderSpineItem } from "./core/reader-navigation"; -interface UniversalReader { +export interface UniversalReader { type: "ebook"; cif: any; currentSpineIndex: number; @@ -145,6 +145,9 @@ async function initializeReader(): Promise { readerEvents.emit("readerReady", currentReader); // 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 } = @@ -240,6 +243,10 @@ Alpine.data("readerShell", () => ({ if (!state.currentReader) return 0; if (state.currentReader.type === "ebook") { + // Use stored computed page if available + if (state.currentReader.currentPage) { + return state.currentReader.currentPage; + } return state.currentReader.currentSpineIndex + 1; } return state.currentReader.currentPage; @@ -250,6 +257,10 @@ Alpine.data("readerShell", () => ({ if (!state.currentReader || !state.readerMetadata) return 0; if (state.currentReader.type === "ebook") { + // Use dynamic page calculation if available + if (state.currentReader.pageCalculationResult) { + return state.currentReader.pageCalculationResult.totalPages; + } return state.currentReader.cif.spine.length; } else if (state.currentReader.type === "pdf") { return state.readerMetadata.total_pages || 0;