# Feature Registration Pattern - Implementation ## File Structure ``` web/src/reader/ ├── reader-shell.ts ├── core/ │ ├── reader-context.ts │ ├── reader-events.ts │ ├── reader-state.ts │ ├── reader-navigation.ts │ └── reader-services.ts ├── features/ │ ├── gestures.ts │ ├── keyboard-shortcuts.ts │ ├── panel-dock-system.ts │ ├── navigator-panel.ts │ ├── offline-manager.ts │ └── reading-speed-tracker.ts └── ... ``` --- ## Core Infrastructure ### `web/src/reader/core/reader-context.ts` ```typescript import type { ReaderMetadata } from "../../types/reader"; type ReaderType = "ebook" | "pdf" | "comic" | "manga"; interface UniversalReader { type: "ebook"; cif: any; currentSpineIndex: number; } interface PDFReader { type: "pdf"; doc: any; currentPage: number; } interface ComicReader { type: "comic"; images: Blob[]; currentPage: number; } interface MangaReader { type: "manga"; images: Blob[]; currentPage: number; readingDirection: "rtl" | "vertical"; } type CurrentReader = UniversalReader | PDFReader | ComicReader | MangaReader; export interface ReaderContext { getState: () => { currentReader: CurrentReader | null; readerMetadata: ReaderMetadata | null; }; setState: (updates: Partial) => void; navigation: { nextPage: () => void; previousPage: () => void; goToPage: (page: number) => void; goToChapter: (chapterIndex: number) => void; }; render: () => void; elements: { readerContent: HTMLElement; chrome: HTMLElement | null; progressDisplay: HTMLElement | null; }; events: { on: (event: string, handler: Function) => void; emit: (event: string, data?: any) => void; }; } export interface ReaderState { currentReader: CurrentReader | null; readerMetadata: ReaderMetadata | null; } export function createReaderContext( getState: () => ReaderState, setState: (updates: Partial) => void, navigation: ReaderContext['navigation'], render: () => void, ): ReaderContext { return { getState, setState, navigation, render, elements: { readerContent: document.getElementById("reader-content")!, chrome: document.getElementById("reader-chrome"), progressDisplay: document.getElementById("progress-display"), }, events: { on: (event: string, handler: Function) => readerEvents.on(event, handler), emit: (event: string, data?: any) => readerEvents.emit(event, data), }, }; } import { readerEvents } from "./reader-events"; ``` ### `web/src/reader/core/reader-events.ts` ```typescript type ReaderEventType = | "readerReady" | "pageChanged" | "chapterChanged" | "zoomChanged" | "themeChanged" | "progressUpdated" | "beforePageChange" | "afterPageChange"; class EventBus { private listeners = new Map(); on(event: ReaderEventType, handler: Function): void { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event)!.push(handler); } off(event: ReaderEventType, handler: Function): void { const handlers = this.listeners.get(event); if (handlers) { const index = handlers.indexOf(handler); if (index > -1) { handlers.splice(index, 1); } } } emit(event: ReaderEventType, data?: any): void { this.listeners.get(event)?.forEach(handler => handler(data)); } clear(): void { this.listeners.clear(); } } export const readerEvents = new EventBus(); export type { ReaderEventType }; ``` ### `web/src/reader/core/reader-state.ts` ```typescript import type { ReaderMetadata, ReaderState } from "./reader-context"; type CurrentReader = import("./reader-context").CurrentReader; let currentState: ReaderState = { currentReader: null, readerMetadata: null, }; export function getState(): ReaderState { return { ...currentState }; } export function setState(updates: Partial): void { const oldState = { ...currentState }; currentState = { ...currentState, ...updates }; // Emit events for significant changes if (updates.currentReader && oldState.currentReader !== updates.currentReader) { readerEvents.emit("readerReady", currentState.currentReader); } } export function getCurrentReader(): CurrentReader | null { return currentState.currentReader; } export function getReaderMetadata(): ReaderMetadata | null { return currentState.readerMetadata; } export function getCurrentPage(): number { const reader = currentState.currentReader; if (!reader) return 0; if (reader.type === "ebook") { return reader.currentSpineIndex; } return reader.currentPage; } import { readerEvents } from "./reader-events"; ``` ### `web/src/reader/core/reader-navigation.ts` ```typescript import type { ReaderMetadata } from "../../types/reader"; import type { CurrentReader } from "./reader-context"; import { getState, setState } from "./reader-state"; import { readerEvents } from "./reader-events"; import { updateReadingProgress } from "./reader-services"; export function createNavigationAPI() { return { nextPage: () => { const state = getState(); if (!state.currentReader) return; readerEvents.emit("beforePageChange", state.currentReader); if (state.currentReader.type === "ebook") { if (state.currentReader.currentSpineIndex < state.currentReader.cif.spine.length - 1) { state.currentReader.currentSpineIndex++; renderSpineItem(); } } else if (state.currentReader.type === "pdf") { const totalPages = state.readerMetadata?.total_pages || 0; if (state.currentReader.currentPage < totalPages) { state.currentReader.currentPage++; renderPDFPage(); } } else if (state.currentReader.type === "comic" || state.currentReader.type === "manga") { if (state.currentReader.currentPage < state.currentReader.images.length - 1) { state.currentReader.currentPage++; 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") { if (state.currentReader.currentSpineIndex > 0) { state.currentReader.currentSpineIndex--; renderSpineItem(); } } else if (state.currentReader.type === "pdf") { if (state.currentReader.currentPage > 1) { state.currentReader.currentPage--; renderPDFPage(); } } else if (state.currentReader.type === "comic" || state.currentReader.type === "manga") { if (state.currentReader.currentPage > 0) { state.currentReader.currentPage--; renderComicPage(); } } setState({ currentReader: state.currentReader }); sendProgressUpdate(); readerEvents.emit("pageChanged", getCurrentPage()); readerEvents.emit("afterPageChange", state.currentReader); }, goToPage: (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(); } } else if (state.currentReader.type === "pdf") { if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) { state.currentReader.currentPage = page; renderPDFPage(); } } else if (state.currentReader.type === "comic" || state.currentReader.type === "manga") { if (page >= 0 && page < state.currentReader.images.length) { state.currentReader.currentPage = page; renderComicPage(); } } setState({ currentReader: state.currentReader }); sendProgressUpdate(); readerEvents.emit("pageChanged", page); readerEvents.emit("afterPageChange", state.currentReader); }, goToChapter: (chapterIndex: number) => { const state = getState(); if (!state.readerMetadata?.chapter_metadata?.chapters) 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); readerEvents.emit("chapterChanged", chapter); }, }; } 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; container.innerHTML = spineItem.content; applyReaderTheme(); applyTypography(); } async function renderPDFPage() { const state = getState(); if (state.currentReader?.type !== "pdf") return; const container = document.getElementById("reader-content"); if (!container) return; const { getPDFPage } = await import("../pdf/pdfjs-wrapper"); try { const page = await getPDFPage(state.currentReader.currentPage); const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.createElement("canvas"); canvas.width = viewport.width; canvas.height = viewport.height; canvas.className = "pdf-page mx-auto"; const ctx = canvas.getContext("2d"); if (ctx) { await page.render({ canvasContext: ctx, viewport: viewport, canvas: canvas, }).promise; container.innerHTML = ""; container.appendChild(canvas); } } catch (error) { console.error("Failed to render PDF page:", error); } } function renderComicPage() { const state = getState(); if (state.currentReader?.type !== "comic" && state.currentReader?.type !== "manga") return; const container = document.getElementById("reader-content"); if (!container || !state.currentReader.images[state.currentReader.currentPage]) return; const img = document.createElement("img"); img.src = URL.createObjectURL(state.currentReader.images[state.currentReader.currentPage]); img.className = "w-full h-contain object-contain"; img.alt = `Page ${state.currentReader.currentPage + 1}`; container.innerHTML = ""; container.appendChild(img); } function applyReaderTheme() { const state = getState(); const settings = getReaderSettings(); const container = document.getElementById("reader-content"); if (!container) return; container.className = `ebook-content theme-${settings.reading_theme}`; } function applyTypography() { const state = getState(); 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() { // TODO: Load from settings manager return {} as any; } function sendProgressUpdate() { const state = getState(); if (!state.currentReader || !state.readerMetadata) return; let currentPage = 0; let totalPages = 0; if (state.currentReader.type === "ebook") { currentPage = state.currentReader.currentSpineIndex + 1; totalPages = state.currentReader.cif.spine.length; } else if (state.currentReader.type === "pdf") { totalPages = state.readerMetadata.total_pages || 0; currentPage = state.currentReader.currentPage; } else if (state.currentReader.type === "comic" || state.currentReader.type === "manga") { totalPages = state.currentReader.images.length; currentPage = state.currentReader.currentPage; } updateReadingProgress(state.readerMetadata.media_item_id, { current_page: currentPage, total_pages: totalPages, }); readerEvents.emit("progressUpdated", { currentPage, totalPages }); } import { getCurrentPage } from "./reader-state"; ``` ### `web/src/reader/core/reader-services.ts` ```typescript import { apiPut } from "../../api"; interface ReadingProgress { current_page: number; total_pages: number; } export async function updateReadingProgress( mediaItemId: string, progress: ReadingProgress, ): Promise { const response = await apiPut(`/media-items/${mediaItemId}/progress`, progress); await response.json(); } export function getChapterNavigation(chapters: any[]) { return { getNextChapter: (currentPage: number) => { for (let i = 0; i < chapters.length - 1; i++) { const chapter = chapters[i]; const nextChapter = chapters[i + 1]; if (currentPage >= chapter.start_page && currentPage < nextChapter.start_page) { return nextChapter.start_page; } } return null; }, getPreviousChapter: (currentPage: number) => { for (let i = 1; i < chapters.length; i++) { const chapter = chapters[i]; if (currentPage >= chapter.start_page && currentPage < chapter.start_page + chapter.page_count) { return chapters[i - 1].start_page; } } if (currentPage < chapters[0].start_page) { return null; } return chapters[0].start_page; }, }; } ``` --- ## Feature Modules ### `web/src/reader/features/gestures.ts` ```typescript import type { Panel } from "../comic/panel-detector"; import { detectPanels } from "../comic/panel-detection.service"; import type { ReaderContext } from "../core/reader-context"; let currentPanelIndex = 0; let currentPagePanels: Panel[] = []; export async function init(context: ReaderContext): Promise { setupGestures(); await loadPanelsIfComic(); } function setupGestures() { const container = context.elements.readerContent; if (!container) return; const state = { touchStartX: 0, touchStartY: 0, touchStartTime: 0, lastTapTime: 0, initialPinchDistance: 0, scale: 1, }; container.addEventListener("touchstart", (e) => { if (e.touches.length === 1) { state.touchStartX = e.touches[0].clientX; state.touchStartY = e.touches[0].clientY; state.touchStartTime = Date.now(); } else if (e.touches.length === 2) { state.initialPinchDistance = getPinchDistance(e.touches); } }, { passive: true }); container.addEventListener("touchend", (e) => { const deltaX = e.changedTouches[0].clientX - state.touchStartX; const deltaY = e.changedTouches[0].clientY - state.touchStartY; const deltaTime = Date.now() - state.touchStartTime; if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) { const now = Date.now(); if (now - state.lastTapTime < 300) { handleDoubleTap(); state.lastTapTime = 0; } else { state.lastTapTime = now; setTimeout(() => { if (state.lastTapTime !== 0) { handleTap(); } }, 300); } return; } const minSwipeDistance = 50; const maxSwipeTime = 500; if (deltaTime > maxSwipeTime) return; if (Math.abs(deltaX) > Math.abs(deltaY)) { if (deltaX > minSwipeDistance) { handleSwipeRight(); } else if (deltaX < -minSwipeDistance) { handleSwipeLeft(); } } else { if (deltaY > minSwipeDistance) { handleSwipeDown(); } else if (deltaY < -minSwipeDistance) { handleSwipeUp(); } } }, { passive: true }); container.addEventListener("touchmove", (e) => { if (e.touches.length === 2) { const currentDistance = getPinchDistance(e.touches); if (state.initialPinchDistance > 0) { const scale = currentDistance / state.initialPinchDistance; handlePinch(scale); } } }, { passive: true }); } function handleSwipeLeft() { const metadata = context.getState().readerMetadata; if (metadata?.library_type === "manga") { navigateWithPanels("next"); } else { context.navigation.previousPage(); } } function handleSwipeRight() { const metadata = context.getState().readerMetadata; if (metadata?.library_type === "manga") { navigateWithPanels("previous"); } else { context.navigation.nextPage(); } } function handleSwipeUp() { const chrome = context.elements.chrome; if (chrome) chrome.classList.remove("visible"); } function handleSwipeDown() { const chrome = context.elements.chrome; if (chrome) chrome.classList.add("visible"); } function handleTap() { const chrome = context.elements.chrome; if (chrome) chrome.classList.toggle("visible"); } function handleDoubleTap() { const container = context.elements.readerContent; if (container) { const currentTransform = container.style.transform || ""; const currentScale = currentTransform.match(/scale\(([\d.]+)\)/); const scale = currentScale ? parseFloat(currentScale[1]) : 1; const newScale = scale === 1 ? 1.5 : 1; container.style.transform = `scale(${newScale})`; container.style.transformOrigin = "center center"; context.events.emit("zoomChanged", newScale); } } function handlePinch(scale: number) { const container = context.elements.readerContent; if (container && scale >= 0.5 && scale <= 3) { container.style.transform = `scale(${scale})`; container.style.transformOrigin = "center center"; context.events.emit("zoomChanged", scale); } } function getPinchDistance(touches: TouchList): number { const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.sqrt(dx * dx + dy * dy); } async function loadPanelsIfComic() { const state = context.getState(); if (!state.currentReader || (state.currentReader.type !== "comic" && state.currentReader.type !== "manga")) { return; } const reader = state.currentReader as any; if (!reader.images || reader.currentPage === undefined || reader.currentPage < 0 || reader.currentPage >= reader.images.length) { currentPagePanels = []; currentPanelIndex = 0; return; } try { const imageBlob = reader.images[reader.currentPage]; const imageData = await blobToImageData(imageBlob); const result = await detectPanels(imageData, false); currentPagePanels = result.panels; currentPanelIndex = 0; } catch (error) { console.warn("Failed to load panels:", error); currentPagePanels = []; currentPanelIndex = 0; } } async function blobToImageData(blob: Blob): Promise { const img = new Image(); const url = URL.createObjectURL(blob); return new Promise((resolve, reject) => { img.onload = () => { const canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext("2d"); if (!ctx) { reject(new Error("Failed to get canvas context")); return; } ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); resolve(imageData); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Failed to load image")); }; img.src = url; }); } function navigateWithPanels(direction: "next" | "previous") { if (currentPagePanels.length === 0) { if (direction === "next") { context.navigation.nextPage(); } else { context.navigation.previousPage(); } return; } if (direction === "next" && currentPanelIndex < currentPagePanels.length - 1) { currentPanelIndex++; scrollToPanel(currentPanelIndex); return; } if (direction === "previous" && currentPanelIndex > 0) { currentPanelIndex--; scrollToPanel(currentPanelIndex); return; } // No more panels, go to next/previous page if (direction === "next") { context.navigation.nextPage(); } else { context.navigation.previousPage(); } } function scrollToPanel(panelIndex: number) { const panel = currentPagePanels[panelIndex]; if (!panel) return; const container = context.elements.readerContent; if (!container) return; const panelElement = container.querySelector(`[data-panel-id="${panel.id}"]`) || container.querySelector(`#${panel.id}`); if (panelElement) { panelElement.scrollIntoView({ behavior: "smooth", block: "center" }); document.querySelectorAll(".panel-current").forEach(el => el.classList.remove("panel-current")); panelElement.classList.add("panel-current"); } } ``` ### `web/src/reader/features/keyboard-shortcuts.ts` ```typescript import type { ReaderContext } from "../core/reader-context"; export async function init(context: ReaderContext): Promise { setupKeyboardShortcuts(); } function setupKeyboardShortcuts() { const container = context.elements.readerContent; if (!container) return; const state = context.getState(); let maxPage = 0; if (state.currentReader?.type === "ebook") { maxPage = state.currentReader.cif.spine.length; } else if (state.currentReader?.type === "pdf") { maxPage = state.readerMetadata?.total_pages || 0; } else if (state.currentReader?.type === "comic" || state.currentReader?.type === "manga") { const reader = state.currentReader as any; maxPage = reader.images.length; } container.addEventListener("keydown", (e) => { if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement ) { return; } switch (e.key) { case "ArrowRight": case "PageDown": case "l": e.preventDefault(); context.navigation.nextPage(); break; case "ArrowLeft": case "PageUp": case "h": e.preventDefault(); context.navigation.previousPage(); break; case "ArrowUp": case "k": e.preventDefault(); context.navigation.previousPage(); break; case "ArrowDown": case "j": e.preventDefault(); context.navigation.nextPage(); break; case " ": e.preventDefault(); context.navigation.nextPage(); break; case "Home": e.preventDefault(); context.navigation.goToPage(1); break; case "End": e.preventDefault(); context.navigation.goToPage(maxPage); break; case "b": if (!e.ctrlKey && !e.metaKey) { e.preventDefault(); toggleBookmark(); } break; case "+": case "=": e.preventDefault(); zoomIn(); break; case "-": case "_": e.preventDefault(); zoomOut(); break; case "0": e.preventDefault(); zoomReset(); break; case "?": e.preventDefault(); showShortcutHelp(); break; case "f": if (!e.ctrlKey && !e.metaKey) { e.preventDefault(); toggleFullscreen(); } break; case "Escape": e.preventDefault(); exitFullscreen(); break; default: if (e.key >= "1" && e.key <= "9") { const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage); e.preventDefault(); context.navigation.goToPage(targetPage); } } }); } function toggleBookmark() { // TODO: Implement bookmark toggle console.log("Toggle bookmark"); } function zoomIn() { const container = context.elements.readerContent; if (container) { const currentTransform = container.style.transform || ""; const currentScale = currentTransform.match(/scale\(([\d.]+)\)/); const scale = currentScale ? parseFloat(currentScale[1]) : 1; const newScale = Math.min(scale + 0.25, 3); container.style.transform = `scale(${newScale})`; container.style.transformOrigin = "center center"; context.events.emit("zoomChanged", newScale); } } function zoomOut() { const container = context.elements.readerContent; if (container) { const currentTransform = container.style.transform || ""; const currentScale = currentTransform.match(/scale\(([\d.]+)\)/); const scale = currentScale ? parseFloat(currentScale[1]) : 1; const newScale = Math.max(scale - 0.25, 0.5); container.style.transform = `scale(${newScale})`; container.style.transformOrigin = "center center"; context.events.emit("zoomChanged", newScale); } } function zoomReset() { const container = context.elements.readerContent; if (container) { container.style.transform = "scale(1)"; container.style.transformOrigin = "center center"; context.events.emit("zoomChanged", 1); } } function toggleFullscreen() { if (document.fullscreenElement) { document.exitFullscreen(); } else { document.documentElement.requestFullscreen(); } } function exitFullscreen() { if (document.fullscreenElement) { document.exitFullscreen(); } } function showShortcutHelp() { const help = document.createElement("div"); help.className = "keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50"; help.innerHTML = `

Keyboard Shortcuts

/ Space Next page
Previous page
Home First page
End Last page
+ / - Zoom
B Toggle bookmark
? Show help
Esc Exit fullscreen
`; document.body.appendChild(help); help.addEventListener("click", (e) => { if (e.target === help) help.remove(); }); } ``` --- ## Main Reader Shell (Refactored) ### `web/src/reader/reader-shell.ts` ```typescript import { Alpine } from "../alpine"; 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"; type ReaderType = "ebook" | "pdf" | "comic" | "manga"; interface UniversalReader { type: "ebook"; cif: any; currentSpineIndex: number; } interface PDFReader { type: "pdf"; doc: any; currentPage: number; } interface ComicReader { type: "comic"; images: Blob[]; currentPage: number; } interface MangaReader { type: "manga"; images: Blob[]; currentPage: number; readingDirection: "rtl" | "vertical"; } type CurrentReader = UniversalReader | PDFReader | ComicReader | MangaReader; let currentReader: CurrentReader | null = null; let readerMetadata: import("../types/reader").ReaderMetadata | null = null; // ============================================================ // 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)), // 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; readerMetadata = await getReaderMetadata(mediaItemId); switch (readerMetadata.library_type) { case "ebook": currentReader = await initializeEbookReader(readerMetadata); break; case "pdf": currentReader = await initializePDFReader(readerMetadata); break; case "comic": currentReader = await initializeComicReader(readerMetadata); break; case "manga": currentReader = await initializeMangaReader(readerMetadata); break; } 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: any): Promise { const { parseEbook, requiresServerParsing } = await import("./parser-manager"); const needsServer = requiresServerParsing( metadata.mime_type, getFileExtension(metadata.file_path), ); let ebookFile: Blob; if (needsServer) { const response = await fetch(`/readers/${metadata.media_item_id}/parse`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mime_type: metadata.mime_type, file_path: metadata.file_path, }), }); if (!response.ok) { throw new Error(`Server parsing failed: ${response.statusText}`); } ebookFile = await response.blob(); } else { const response = await fetch(metadata.file_path); ebookFile = await response.blob(); } const cif = await parseEbook( ebookFile, metadata.mime_type, getFileExtension(metadata.file_path), ); return { type: "ebook", cif, currentSpineIndex: 0, }; } async function initializePDFReader(metadata: any): Promise { const { initializePDFReader } = await import("./pdf/pdfjs-wrapper"); return initializePDFReader(metadata); } async function initializeComicReader(metadata: any): Promise { const { initializeComicReader } = await import("./comic/image-parser"); return initializeComicReader(metadata); } async function initializeMangaReader(metadata: any): Promise { const { initializeMangaReader } = await import("./comic/image-parser"); return initializeMangaReader(metadata); } function getFileExtension(filepath: string): string { const match = filepath.match(/\.([^.]+)$/); return match ? `.${match[1]}` : ""; } // ============================================================ // Alpine.js Integration // ============================================================ Alpine.data("readerShell", () => ({ init() { initializeReader(); }, get currentPage() { const state = getState(); if (!state.currentReader) return 0; if (state.currentReader.type === "ebook") { return state.currentReader.currentSpineIndex + 1; } return state.currentReader.currentPage; }, get totalPages() { const state = getState(); if (!state.currentReader || !state.readerMetadata) return 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 (state.currentReader as any).images.length; } }, })); ``` --- ## Remaining Features (Stubs) ### `web/src/reader/features/panel-dock-system.ts` ```typescript import type { ReaderContext } from "../core/reader-context"; import type { PanelState } from "../settings-manager"; export async function init(context: ReaderContext): Promise { initializePanelDockSystem(); } function initializePanelDockSystem() { const settings = loadSettings(); Object.entries(settings.panel_layout).forEach(([panelId, panelState]) => { setupPanel(panelId, panelState as PanelState); }); setupDragAndDrop(); } function loadSettings() { // TODO: Load from settings manager return { panel_layout: {} }; } function setupPanel(panelId: string, state: PanelState) { // TODO: Setup panel with state } function setupDragAndDrop() { // TODO: Setup drag and drop } ``` ### `web/src/reader/features/navigator-panel.ts` ```typescript import type { ReaderContext } from "../core/reader-context"; export async function init(context: ReaderContext): Promise { context.events.on("pageChanged", (page: number) => { updateNavigator(page); }); context.events.on("chapterChanged", (chapter: any) => { updateChapterIndicator(chapter); }); } function updateNavigator(page: number) { // TODO: Update navigator thumbnail } function updateChapterIndicator(chapter: any) { // TODO: Update chapter display } ``` ### `web/src/reader/features/offline-manager.ts` ```typescript import type { ReaderContext } from "../core/reader-context"; export async function init(context: ReaderContext): Promise { if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/static/service-worker.js") .then((registration) => { console.log("Service worker registered:", registration); }) .catch((error) => { console.error("Service worker registration failed:", error); }); } setupOnlineListeners(); } function setupOnlineListeners() { window.addEventListener("online", () => { context.events.emit("connectionChanged", { online: true }); }); window.addEventListener("offline", () => { context.events.emit("connectionChanged", { online: false }); }); } ``` ### `web/src/reader/features/reading-speed-tracker.ts` ```typescript import type { ReaderContext } from "../core/reader-context"; let startTime: number | null = null; let pagesRead = 0; export async function init(context: ReaderContext): Promise { context.events.on("pageChanged", trackPageRead); setInterval(calculateReadingSpeed, 60000); // Every minute } function trackPageRead() { if (!startTime) { startTime = Date.now(); } pagesRead++; } function calculateReadingSpeed() { if (!startTime || pagesRead === 0) return; const elapsed = Date.now() - startTime; const minutes = elapsed / 60000; const pagesPerMinute = pagesRead / minutes; console.log(`Reading speed: ${pagesPerMinute.toFixed(2)} pages/minute`); // TODO: Save to database } ``` --- ## Feature Interface Definitions All feature modules should export: ```typescript import type { ReaderContext } from "../core/reader-context"; export async function init(context: ReaderContext): Promise { // Feature initialization } ``` Features can optionally export cleanup: ```typescript export async function cleanup(context: ReaderContext): Promise { // Feature cleanup } ``` --- ## Migration Checklist ### Phase 1: Core Infrastructure - [ ] Create `core/reader-context.ts` - [ ] Create `core/reader-events.ts` - [ ] Create `core/reader-state.ts` - [ ] Create `core/reader-navigation.ts` - [ ] Create `core/reader-services.ts` ### Phase 2: Refactor Features - [ ] Move `gestures.ts` → `features/gestures.ts` - [ ] Move `keyboard-shortcuts.ts` → `features/keyboard-shortcuts.ts` - [ ] Refactor to use `init(context)` pattern - [ ] Remove callback parameters ### Phase 3: Update reader-shell.ts - [ ] Create feature registry - [ ] Simplify to orchestrator pattern - [ ] Keep Alpine integration minimal ### Phase 4: Remaining Features - [ ] Convert `panel-dock-system.ts` - [ ] Convert `navigator-panel.ts` - [ ] Convert `offline-manager.ts` - [ ] Convert `reading-speed-tracker.ts` - [ ] Convert all PDF features - [ ] Convert all ebook features - [ ] Convert all manga features ### Phase 5: Cleanup - [ ] Delete old callback-based code - [ ] Remove unused imports - [ ] Update type definitions - [ ] Test all features