From 3162d9b7ee4f75a7ca99c308e63768c2a6f94083 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 4 Apr 2026 13:37:44 -0400 Subject: [PATCH] docs: add Feature Registration Pattern refactor plan - Add comprehensive implementation guide for feature-based architecture - Define core infrastructure components (context, events, state, navigation) - Establish pattern for feature modules with init(context) functions - Include migration checklist and code examples --- FEATURE_REFACTOR.md | 1385 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1385 insertions(+) create mode 100644 FEATURE_REFACTOR.md diff --git a/FEATURE_REFACTOR.md b/FEATURE_REFACTOR.md new file mode 100644 index 0000000..548e92f --- /dev/null +++ b/FEATURE_REFACTOR.md @@ -0,0 +1,1385 @@ +# 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