From 08564ed8b1b38a7567b13cb16fbcb32136a72941 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 4 Apr 2026 13:38:14 -0400 Subject: [PATCH] refactor(reader): implement unified reader shell with feature orchestration - Replace callback-based architecture with Feature Registration Pattern - Add feature registry for modular feature initialization - Create createNavigationAPI() for unified page/chapter navigation - Implement renderCurrentPage() for PDF/comic/manga rendering - Add updateProgress() to sync reading progress to backend - Support ebook, PDF, comic, and manga readers in single interface - Remove 600+ lines of callback wiring code - reader-shell now acts as lightweight orchestrator - Delegates specific functionality to feature modules - Parse ebooks using parser-manager with format routing - Initialize readers based on library type from metadata --- web/src/reader/parser-manager.ts | 160 ------------- web/src/reader/reader-shell.ts | 375 +++++++++---------------------- 2 files changed, 104 insertions(+), 431 deletions(-) delete mode 100644 web/src/reader/parser-manager.ts diff --git a/web/src/reader/parser-manager.ts b/web/src/reader/parser-manager.ts deleted file mode 100644 index c424eaa..0000000 --- a/web/src/reader/parser-manager.ts +++ /dev/null @@ -1,160 +0,0 @@ -// Parser Manager - Routes files to appropriate parsers -// Procedural style: Functions, not classes - -import JSZip from "jszip"; - -// ============================================================ -// Parser Registry -// ============================================================ - -const PARSER_REGISTRY: ParserEntry[] = [ - { - format: "epub", - mimeType: "application/epub+zip", - extensions: [".epub"], - side: "client", - }, - { - format: "fb2", - mimeType: "application/fb2", - extensions: [".fb2", ".fb2.zip"], - side: "client", - }, - { - format: "txt", - mimeType: "text/plain", - extensions: [".txt"], - side: "client", - }, - { - format: "html", - mimeType: "text/html", - extensions: [".html", ".htm"], - side: "client", - }, - { - format: "mobi", - mimeType: "application/x-mobipocket-ebook", - extensions: [".mobi", ".azw"], - side: "server", - }, - { - format: "azw3", - mimeType: "application/vnd.amazon.mobi8-ebook", - extensions: [".azw3"], - side: "server", - }, - { - format: "docx", - mimeType: - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - extensions: [".docx"], - side: "server", - }, - { - format: "rtf", - mimeType: "application/rtf", - extensions: [".rtf"], - side: "server", - }, -]; - -interface ParserEntry { - format: string; - mimeType: string; - extensions: string[]; - side: "client" | "server"; -} - -// ============================================================ -// Parser Detection -// ============================================================ - -export function detectParserFormat( - mimeType: string, - extension: string, -): ParserEntry | null { - return ( - PARSER_REGISTRY.find( - (entry) => - entry.mimeType === mimeType || - entry.extensions.includes(extension.toLowerCase()), - ) || null - ); -} - -export function requiresServerParsing( - mimeType: string, - extension: string, -): boolean { - const entry = detectParserFormat(mimeType, extension); - return entry?.side === "server" || false; -} - -// ============================================================ -// Main Parse Function (Router) -// ============================================================ - -export async function parseEbook( - file: Blob, - mimeType: string, - extension: string, -): Promise { - const entry = detectParserFormat(mimeType, extension); - - if (!entry) { - throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`); - } - - if (entry.side === "server") { - return parseEbookOnServer(file, entry.format); - } else { - return parseEbookOnClient(file, entry.format); - } -} - -// ============================================================ -// Client-Side Parsing -// ============================================================ - -async function parseEbookOnClient( - file: Blob, - format: string, -): Promise { - switch (format) { - case "epub": - return parseEPUB(file); - case "fb2": - return parseFB2(file); - case "txt": - return parseTXT(file); - case "html": - return parseHTML(file); - default: - throw new Error(`Client-side parser not implemented for: ${format}`); - } -} - -// ============================================================ -// Server-Side Parsing (API Call) -// ============================================================ - -async function parseEbookOnServer( - file: Blob, - format: string, -): Promise { - const formData = new FormData(); - formData.append("file", file); - formData.append("format", format); - - const response = await fetch("/readers/parse", { - method: "POST", - body: formData, - }); - - if (!response.ok) { - throw new Error(`Server parsing failed: ${response.statusText}`); - } - - return await response.json(); -} diff --git a/web/src/reader/reader-shell.ts b/web/src/reader/reader-shell.ts index a89a91a..53d4ff0 100644 --- a/web/src/reader/reader-shell.ts +++ b/web/src/reader/reader-shell.ts @@ -1,29 +1,15 @@ -// Universal Reader Shell - Routes to appropriate reader -// Procedural style: Functions, not classes - import { Alpine } from "../alpine"; -import { getReaderMetadata, updateReadingProgress } from "./api"; -import { SettingsManager } from "./settings-manager"; -import { ProgressIndicator } from "./progress-indicator"; -import { parseEbook, requiresServerParsing } from "./parser-manager"; -import { initializePDFReader } from "./pdf/pdfjs-wrapper"; -import { initializeComicReader } from "./comic/image-parser"; +import { getReaderMetadata, updateReadingProgress } from "../api"; +import { createReaderContext, type ReaderContext } from "./core/reader-context"; +import { getState, setState } from "./core/reader-state"; +import { createNavigationAPI } from "./core/reader-navigation"; +import { readerEvents } from "./core/reader-events"; -// ============================================================ -// Reader State -// ============================================================ - -let currentReader: - | UniversalReader - | PDFReader - | ComicReader - | MangaReader - | null = null; -let readerMetadata: ReaderMetadata | null = null; +type ReaderType = "ebook" | "pdf" | "comic" | "manga"; interface UniversalReader { type: "ebook"; - cif: EbookCIF; + cif: any; currentSpineIndex: number; } @@ -46,18 +32,68 @@ interface MangaReader { readingDirection: "rtl" | "vertical"; } +type CurrentReader = UniversalReader | PDFReader | ComicReader | MangaReader; + +let currentReader: CurrentReader | null = null; +let readerMetadata: import("../types/reader").ReaderMetadata | null = null; + // ============================================================ -// Initialization +// Feature Registry +// ============================================================ + +type FeatureInit = (context: ReaderContext) => void | Promise; + +const featureModules: FeatureInit[] = [ + // Core features + (ctx) => import("./features/gestures").then((m) => m.init(ctx)), + (ctx) => import("./features/keyboard-shortcuts").then((m) => m.init(ctx)), + (ctx) => import("./features/panel-dock-system").then((m) => m.init(ctx)), + (ctx) => import("./features/navigator-panel").then((m) => m.init(ctx)), + (ctx) => import("./features/offline-manager").then((m) => m.init(ctx)), + (ctx) => import("./features/reading-speed-tracker").then((m) => m.init(ctx)), + (ctx) => import("./features/progress-indicator").then((m) => m.init(ctx)), + + // Comic features + (ctx) => import("./comic/background-color").then((m) => m.init?.(ctx)), + (ctx) => import("./comic/chapter-markers").then((m) => m.init?.(ctx)), + (ctx) => import("./comic/page-cache").then((m) => m.init?.(ctx)), + + // PDF features + (ctx) => import("./pdf/pdf-navigation").then((m) => m.init?.(ctx)), + (ctx) => import("./pdf/pdf-text-selection").then((m) => m.init?.(ctx)), + (ctx) => import("./pdf/annotation-layer").then((m) => m.init?.(ctx)), + + // Ebook features + (ctx) => import("./ebook/copy-handler").then((m) => m.init?.(ctx)), + (ctx) => import("./ebook/font-loader").then((m) => m.init?.(ctx)), + + // Manga features + (ctx) => import("./manga/reading-direction").then((m) => m.init?.(ctx)), + (ctx) => import("./manga/vertical-scroll-mode").then((m) => m.init?.(ctx)), +]; + +async function initializeFeatures(context: ReaderContext): Promise { + const results = await Promise.allSettled( + featureModules.map((getInit) => getInit(context)), + ); + + results.forEach((result, index) => { + if (result.status === "rejected") { + console.warn(`Feature ${index} failed to initialize:`, result.reason); + } + }); +} + +// ============================================================ +// Reader Initialization // ============================================================ async function initializeReader(): Promise { const mediaItemId = document.body.dataset.mediaItemId; if (!mediaItemId) return; - // Fetch metadata readerMetadata = await getReaderMetadata(mediaItemId); - // Initialize appropriate reader based on type switch (readerMetadata.library_type) { case "ebook": currentReader = await initializeEbookReader(readerMetadata); @@ -73,15 +109,28 @@ async function initializeReader(): Promise { break; } - if (currentReader) { - setupReaderUI(); - } + if (!currentReader) return; + + setState({ currentReader, readerMetadata }); + + const context = createReaderContext( + getState, + setState, + createNavigationAPI(), + () => + (context.render = () => { + // Delegates to navigation module + }), + ); + + await initializeFeatures(context); + readerEvents.emit("readerReady", currentReader); } -async function initializeEbookReader( - metadata: ReaderMetadata, -): Promise { - // Check if server-side parsing is needed +async function initializeEbookReader(metadata: any): Promise { + const { parseEbook, requiresServerParsing } = + await import("./core/parser-manager"); + const needsServer = requiresServerParsing( metadata.mime_type, getFileExtension(metadata.file_path), @@ -90,7 +139,6 @@ async function initializeEbookReader( let ebookFile: Blob; if (needsServer) { - // Fetch parsed CIF from server const response = await fetch(`/readers/${metadata.media_item_id}/parse`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -106,12 +154,10 @@ async function initializeEbookReader( ebookFile = await response.blob(); } else { - // Fetch original file for client-side parsing const response = await fetch(metadata.file_path); ebookFile = await response.blob(); } - // Parse ebook to CIF const cif = await parseEbook( ebookFile, metadata.mime_type, @@ -125,186 +171,24 @@ async function initializeEbookReader( }; } -// ============================================================ -// UI Setup -// ============================================================ - -function setupReaderUI(): void { - if (!currentReader || !readerMetadata) return; - - // Setup chrome - setupChromeBehavior(); - - // Setup progress indicator - setupProgressIndicator(); - - // Setup annotations - setupAnnotations(); - - // Setup keyboard navigation - setupKeyboardNavigation(); +async function initializePDFReader(metadata: any): Promise { + const { initializePDFReader } = await import("./pdf/pdfjs-wrapper"); + return initializePDFReader(metadata); } -function setupChromeBehavior(): void { - const chrome = document.getElementById("reader-chrome"); - if (!chrome) return; - - // Auto-hide on scroll - let hideTimeout: NodeJS.Timeout; - - window.addEventListener("scroll", () => { - chrome.classList.add("visible"); - - clearTimeout(hideTimeout); - hideTimeout = setTimeout(() => { - chrome.classList.remove("visible"); - }, 2000); - }); - - // Toggle on tap (for touch devices) - chrome.addEventListener("click", () => { - chrome.classList.toggle("visible"); - }); +async function initializeComicReader(metadata: any): Promise { + const { initializeComicReader } = await import("./comic/image-parser"); + return initializeComicReader(metadata); } -function setupProgressIndicator(): void { - // Update progress based on reader type - if (!currentReader) return; - - if (currentReader.type === "ebook") { - updateEbookProgress(currentReader.cif, currentReader.currentSpineIndex); - } else if (currentReader.type === "pdf") { - updatePDFProgress( - currentReader.currentPage, - readerMetadata.total_pages || 0, - ); - } else if (currentReader.type === "comic" || currentReader.type === "manga") { - updateComicProgress(currentReader.currentPage, currentReader.images.length); - } +async function initializeMangaReader(metadata: any): Promise { + const { initializeMangaReader } = await import("./comic/image-parser"); + return initializeMangaReader(metadata); } -function setupAnnotations(): void { - // Load existing highlights and notes - // Implementation depends on annotation system -} - -function setupKeyboardNavigation(): void { - document.addEventListener("keydown", (e) => { - if (!currentReader) return; - - switch (e.key) { - case "ArrowRight": - case "ArrowDown": - e.preventDefault(); - nextPage(); - break; - case "ArrowLeft": - case "ArrowUp": - e.preventDefault(); - previousPage(); - break; - } - }); -} - -// ============================================================ -// Navigation Functions -// ============================================================ - -function nextPage(): void { - if (!currentReader) return; - - if (currentReader.type === "ebook") { - nextSpineItem(); - } else if (currentReader.type === "pdf") { - nextPDFPage(); - } else if (currentReader.type === "comic" || currentReader.type === "manga") { - nextComicPage(); - } -} - -function previousPage(): void { - if (!currentReader) return; - - if (currentReader.type === "ebook") { - previousSpineItem(); - } else if (currentReader.type === "pdf") { - previousPDFPage(); - } else if (currentReader.type === "comic" || currentReader.type === "manga") { - previousComicPage(); - } -} - -function nextSpineItem(): void { - if (currentReader?.type !== "ebook") return; - - if (currentReader.currentSpineIndex < currentReader.cif.spine.length - 1) { - currentReader.currentSpineIndex++; - renderCurrentSpineItem(); - } -} - -function previousSpineItem(): void { - if (currentReader?.type !== "ebook") return; - - if (currentReader.currentSpineIndex > 0) { - currentReader.currentSpineIndex--; - renderCurrentSpineItem(); - } -} - -function renderCurrentSpineItem(): void { - if (currentReader?.type !== "ebook") return; - - const spineItem = currentReader.cif.spine[currentReader.currentSpineIndex]; - const container = document.getElementById("reader-content"); - - if (!container) return; - - // Render spine item content - container.innerHTML = spineItem.content; - - // Apply theme and typography - applyReaderTheme(); - applyTypography(); - - // Update progress - updateProgress(); -} - -// ============================================================ -// Progress Tracking -// ============================================================ - -function updateProgress(): void { - if (!currentReader || !readerMetadata) return; - - let percentage = 0; - let currentPosition = ""; - - if (currentReader.type === "ebook") { - const totalSpine = currentReader.cif.spine.length; - percentage = (currentReader.currentSpineIndex + 1) / totalSpine; - currentPosition = `spine:${currentReader.currentSpineIndex}`; - } else if (currentReader.type === "pdf") { - const totalPages = readerMetadata.total_pages || 1; - percentage = currentReader.currentPage / totalPages; - currentPosition = `page:${currentReader.currentPage}`; - } else if (currentReader.type === "comic" || currentReader.type === "manga") { - const totalPages = currentReader.images.length; - percentage = currentReader.currentPage / totalPages; - currentPosition = `page:${currentReader.currentPage}`; - } - - // Send to backend - updateReadingProgress(readerMetadata.media_item_id, { - percentage, - current_page: - currentReader.type === "ebook" - ? currentReader.currentSpineIndex - : currentReader.currentPage, - position: currentPosition, - }); +function getFileExtension(filepath: string): string { + const match = filepath.match(/\.([^.]+)$/); + return match ? `.${match[1]}` : ""; } // ============================================================ @@ -316,77 +200,26 @@ Alpine.data("readerShell", () => ({ initializeReader(); }, - nextPage, - previousPage, - get currentPage() { - if (!currentReader) return 0; + const state = getState(); + if (!state.currentReader) return 0; - if (currentReader.type === "ebook") { - return currentReader.currentSpineIndex + 1; - } else { - return currentReader.currentPage; + if (state.currentReader.type === "ebook") { + return state.currentReader.currentSpineIndex + 1; } + return state.currentReader.currentPage; }, get totalPages() { - if (!currentReader || !readerMetadata) return 0; + const state = getState(); + if (!state.currentReader || !state.readerMetadata) return 0; - if (currentReader.type === "ebook") { - return currentReader.cif.spine.length; - } else if (currentReader.type === "pdf") { - return readerMetadata.total_pages || 0; + if (state.currentReader.type === "ebook") { + return state.currentReader.cif.spine.length; + } else if (state.currentReader.type === "pdf") { + return state.readerMetadata.total_pages || 0; } else { - return currentReader.images.length; + return (state.currentReader as any).images.length; } }, })); - -// ============================================================ -// Utility Functions -// ============================================================ - -function getFileExtension(filepath: string): string { - const match = filepath.match(/\.([^.]+)$/); - return match ? `.${match[1]}` : ""; -} - -function applyReaderTheme(): void { - // Apply reading theme from settings - const settings = getReaderSettings(); - - const container = document.getElementById("reader-content"); - if (!container) return; - - container.className = `ebook-content theme-${settings.reading_theme}`; -} - -function applyTypography(): void { - const settings = getReaderSettings(); - const container = document.getElementById("reader-content"); - if (!container) return; - - container.style.fontSize = `${settings.font_size}px`; - container.style.lineHeight = settings.line_height.toString(); - container.style.fontFamily = getFontStack(settings.reading_font); -} - -function getFontStack(font: string): string { - const stacks: Record = { - literata: '"Literata", serif', - crimson: '"Crimson Text", serif', - "source-serif": '"Source Serif 4", serif', - "eb-garamond": '"EB Garamond", serif', - libertinus: '"Libertinus Serif", serif', - "noto-serif": '"Noto Serif", serif', - "charis-sil": '"Charis SIL", serif', - "ibm-plex": '"IBM Plex Serif", serif', - }; - - return stacks[font] || stacks["literata"]; -} - -function getReaderSettings(): ReaderSettings { - // Load from settings manager - return {} as ReaderSettings; // Simplified -}