diff --git a/EPUB_PAGE_CALCULATOR.md b/EPUB_PAGE_CALCULATOR.md deleted file mode 100644 index 4b774d5..0000000 --- a/EPUB_PAGE_CALCULATOR.md +++ /dev/null @@ -1,569 +0,0 @@ -# EPUB Dynamic Page Calculator - Phase 2 Implementation - -This document contains the complete implementation for dynamic viewport-based EPUB page calculation. - -## File Structure - -``` -web/src/reader/ebook/page-calculator.ts (NEW) -web/src/reader/core/reader-state.ts (MODIFY) -web/src/reader/core/reader-navigation.ts (MODIFY) -web/src/reader/features/progress-indicator.ts (MODIFY) -web/src/reader/reader-shell.ts (MODIFY) -``` - ---- - -## 1. New File: page-calculator.ts - -Create: `web/src/reader/ebook/page-calculator.ts` - -```typescript -import type { EbookCIF, SpineItem } from "../../types/reader"; - -export interface ChapterPageInfo { - spineIndex: number; - spineItemId: string; - content: string; - startPage: number; - endPage: number; - scrollHeight: number; - charCount: number; -} - -export interface PageCalculationResult { - totalPages: number; - chapters: ChapterPageInfo[]; - chapterMap: Map; - calculatedAt: number; - settings: PageCalculationSettings; -} - -export interface PageCalculationSettings { - fontSize: number; - lineHeight: number; - marginWidth: number; - viewportHeight: number; -} - -interface HiddenContainerConfig { - width: number; - fontSize: number; - lineHeight: number; - paddingTop: number; - paddingBottom: number; - paddingLeft: number; - paddingRight: number; -} - -function getDefaultContainerConfig(viewportWidth: number, settings: PageCalculationSettings): HiddenContainerConfig { - const contentWidth = viewportWidth - (settings.marginWidth * 2); - return { - width: contentWidth, - fontSize: settings.fontSize, - lineHeight: settings.lineHeight, - paddingTop: settings.marginWidth, - paddingBottom: settings.marginWidth, - paddingLeft: settings.marginWidth, - paddingRight: settings.marginWidth, - }; -} - -function createHiddenContainer(config: HiddenContainerConfig): HTMLElement { - const container = document.createElement("div"); - container.id = "page-calculation-hidden"; - container.style.position = "absolute"; - container.style.left = "-9999px"; - container.style.top = "0"; - container.style.width = `${config.width}px`; - container.style.fontSize = `${config.fontSize}px`; - container.style.lineHeight = config.lineHeight.toString(); - container.style.padding = `${config.paddingTop}px ${config.paddingRight}px ${config.paddingBottom}px ${config.paddingLeft}px`; - container.style.boxSizing = "border-box"; - container.style.overflow = "hidden"; - container.style.wordWrap = "break-word"; - container.style.whiteSpace = "pre-wrap"; - - return container; -} - -function stripScriptsAndStyles(html: string): string { - let result = html; - result = result.replace(/]*>[\s\S]*?<\/script>/gi, ""); - result = result.replace(/]*>[\s\S]*?<\/style>/gi, ""); - result = result.replace(/]*>/gi, ""); - return result; -} - -async function renderContentForMeasurement( - content: string, - resources: Map, - config: HiddenContainerConfig -): Promise { - const container = createHiddenContainer(config); - - const parser = new DOMParser(); - const doc = parser.parseFromString(stripScriptsAndStyles(content), "text/html"); - - const images = doc.querySelectorAll("img"); - for (const img of images) { - const src = img.getAttribute("src"); - if (!src) continue; - - let blob = resources.get(src); - if (!blob) blob = resources.get(src.split("/").pop() || ""); - - if (blob) { - const blobUrl = URL.createObjectURL(blob); - img.setAttribute("src", blobUrl); - } - } - - container.appendChild(doc.body); - document.body.appendChild(container); - - return container; -} - -function calculatePageBreaks(scrollHeight: number, viewportHeight: number): number { - if (scrollHeight <= 0 || viewportHeight <= 0) return 1; - return Math.ceil(scrollHeight / viewportHeight); -} - -export async function calculatePagesForEbook( - cif: EbookCIF, - viewportWidth: number, - settings: { - fontSize?: number; - lineHeight?: number; - marginWidth?: number; - } -): Promise { - const fontSize = settings.fontSize || 16; - const lineHeight = settings.lineHeight || 1.6; - const marginWidth = settings.marginWidth || 20; - - const viewportHeight = window.innerHeight - 120; - - const pageSettings: PageCalculationSettings = { - fontSize, - lineHeight, - marginWidth, - viewportHeight, - }; - - const config = getDefaultContainerConfig(viewportWidth, pageSettings); - - const chapters: ChapterPageInfo[] = []; - let currentPage = 1; - - for (let i = 0; i < cif.spine.length; i++) { - const spineItem = cif.spine[i]; - - if (spineItem.type !== "html") { - chapters.push({ - spineIndex: i, - spineItemId: spineItem.id, - content: "", - startPage: currentPage, - endPage: currentPage, - scrollHeight: 0, - charCount: 0, - }); - continue; - } - - const contentBlob = cif.resources.get(spineItem.content); - if (!contentBlob) { - chapters.push({ - spineIndex: i, - spineItemId: spineItem.id, - content: "", - startPage: currentPage, - endPage: currentPage, - scrollHeight: 0, - charCount: 0, - }); - continue; - } - - const contentText = await contentBlob.text(); - const charCount = contentText.replace(/<[^>]*>/g, "").length; - - let scrollHeight = 0; - let pagesInChapter = 1; - - try { - const container = await renderContentForMeasurement( - contentText, - cif.resources, - config - ); - - scrollHeight = container.scrollHeight; - pagesInChapter = calculatePageBreaks(scrollHeight, viewportHeight); - - container.remove(); - } catch (error) { - console.warn("Failed to measure content for spine item:", spineItem.id, error); - pagesInChapter = Math.max(1, Math.ceil(charCount / 1500)); - } - - const chapterInfo: ChapterPageInfo = { - spineIndex: i, - spineItemId: spineItem.id, - content: contentText, - startPage: currentPage, - endPage: currentPage + pagesInChapter - 1, - scrollHeight, - charCount, - }; - - chapters.push(chapterInfo); - currentPage += pagesInChapter; - } - - const chapterMap = new Map(); - for (const chapter of chapters) { - chapterMap.set(chapter.spineIndex, chapter); - } - - const totalPages = currentPage - 1; - - return { - totalPages, - chapters, - chapterMap, - calculatedAt: Date.now(), - settings: pageSettings, - }; -} - -export function getCurrentPageFromScroll( - pageInfo: PageCalculationResult, - currentSpineIndex: number, - scrollPosition: number, - viewportHeight: number -): number { - const chapter = pageInfo.chapterMap.get(currentSpineIndex); - if (!chapter || chapter.pagesInChapter === 0) { - return 1; - } - - const viewportHeightAdjusted = viewportHeight - 120; - const positionInChapter = Math.floor(scrollPosition / viewportHeightAdjusted); - - return Math.min(chapter.endPage, Math.max(chapter.startPage, chapter.startPage + positionInChapter)); -} - -export function getScrollPositionForPage( - pageInfo: PageCalculationResult, - targetPage: number, - viewportHeight: number -): { spineIndex: number; scrollTop: number } | null { - const viewportHeightAdjusted = viewportHeight - 120; - - for (const chapter of pageInfo.chapters) { - if (targetPage >= chapter.startPage && targetPage <= chapter.endPage) { - const positionInChapter = targetPage - chapter.startPage; - const scrollTop = positionInChapter * viewportHeightAdjusted; - - return { - spineIndex: chapter.spineIndex, - scrollTop, - }; - } - } - - return null; -} - -export function calculateProgressPercentage( - pageInfo: PageCalculationResult, - currentPage: number -): number { - if (pageInfo.totalPages <= 0) return 0; - return Math.round((currentPage / pageInfo.totalPages) * 100); -} -``` - ---- - -## 2. Modify: reader-state.ts - -Update the state to include page calculation results. - -Find the `CurrentReader` type or `EbookReader` interface and add: - -```typescript -import type { PageCalculationResult } from "../ebook/page-calculator"; -``` - -Add to the reader state interfaces: - -```typescript -interface UniversalReader { - type: "ebook"; - cif: any; - currentSpineIndex: number; - pageCalculationResult?: PageCalculationResult; // ADD THIS - currentScrollPosition?: number; // ADD THIS -} -``` - ---- - -## 3. Modify: reader-navigation.ts - -Add scroll tracking and integrate page calculator. - -### Add imports at top of file: - -```typescript -import { - calculatePagesForEbook, - getCurrentPageFromScroll, - getScrollPositionForPage, - calculateProgressPercentage, - type PageCalculationResult, -} from "../ebook/page-calculator"; -``` - -### Add state variable after getState() declaration: - -```typescript -let pageCalculationResult: PageCalculationResult | null = null; -let isCalculatingPages = false; -``` - -### Add function to initialize page calculation (call after reader is ready): - -```typescript -async function initializePageCalculation() { - const state = getState(); - if (!state.currentReader || state.currentReader.type !== "ebook") return; - if (isCalculatingPages) return; - - isCalculatingPages = true; - - try { - const settings = getDefaultSettings(); - const viewportWidth = window.innerWidth; - - pageCalculationResult = await calculatePagesForEbook( - state.currentReader.cif, - viewportWidth, - { - fontSize: settings.font_size, - lineHeight: settings.line_height, - marginWidth: settings.margin_width, - } - ); - - state.currentReader.pageCalculationResult = pageCalculationResult; - setState({ currentReader: state.currentReader }); - - console.log("Page calculation complete:", pageCalculationResult.totalPages, "pages"); - } catch (error) { - console.error("Failed to calculate pages:", error); - } finally { - isCalculatingPages = false; - } -} -``` - -### Update renderSpineItem function to reset scroll position when changing chapters: - -After `state.currentReader.currentSpineIndex++` or `--`, add: - -```typescript -if (pageCalculationResult) { - const chapter = pageCalculationResult.chapterMap.get(state.currentReader.currentSpineIndex); - if (chapter) { - // Reset scroll to start of new chapter - container.scrollTop = 0; - state.currentReader.currentScrollPosition = 0; - } -} -``` - -### Add scroll tracking setup in renderSpineItem: - -After `container.innerHTML = ...`, add: - -```typescript -container.addEventListener("scroll", () => { - const state = getState(); - if (!state.currentReader || state.currentReader.type !== "ebook") return; - - state.currentReader.currentScrollPosition = container.scrollTop; - - if (pageCalculationResult) { - const viewportHeight = window.innerHeight; - const currentPage = getCurrentPageFromScroll( - pageCalculationResult, - state.currentReader.currentSpineIndex, - container.scrollTop, - viewportHeight - ); - - const percentage = calculateProgressPercentage(pageCalculationResult, currentPage); - readerEvents.emit("progressUpdated", { - currentPage, - totalPages: pageCalculationResult.totalPages, - percentage, - }); - } -}, { passive: true }); -``` - -### Update navigation functions to use page calculation: - -In `nextPage()` and `previousPage()`, after updating state: - -```typescript -if (pageCalculationResult) { - const container = document.getElementById("reader-content"); - if (container) { - const viewportHeight = window.innerHeight; - const target = getScrollPositionForPage( - pageCalculationResult, - state.currentReader.currentPage || state.currentReader.currentSpineIndex + 1, - viewportHeight - ); - if (target && target.spineIndex !== state.currentReader.currentSpineIndex) { - state.currentReader.currentSpineIndex = target.spineIndex; - container.scrollTop = target.scrollTop; - } - } -} -``` - ---- - -## 4. Modify: progress-indicator.ts - -Update to use dynamic page calculation. - -Find the ebook handling in `updateProgressDisplay()` function and replace: - -```typescript -if (state.currentReader.type === "ebook") { - const reader = state.currentReader as any; - const pageInfo = reader.pageCalculationResult; - - if (pageInfo && pageInfo.totalPages > 0) { - const container = document.getElementById("reader-content"); - const viewportHeight = window.innerHeight; - const currentPage = getCurrentPageFromScroll( - pageInfo, - reader.currentSpineIndex, - container?.scrollTop || 0, - viewportHeight - ); - currentPage = currentPage; - totalPages = pageInfo.totalPages; - } else { - // Fallback to estimated pages while calculating - currentPage = reader.currentSpineIndex + 1; - totalPages = state.readerMetadata?.total_pages || state.currentReader.cif.locations?.estimatedPages || state.currentReader.cif.spine.length; - } -} -``` - ---- - -## 5. Modify: reader-shell.ts - -Initialize page calculation after reader is ready. - -Find where `renderSpineItem()` is called after `readerEvents.emit("readerReady", ...)`, add: - -```typescript -readerEvents.emit("readerReady", currentReader); - -// ADD THIS: Initialize page calculation -setTimeout(() => { - const { initializePageCalculation } = require("./core/reader-navigation"); - initializePageCalculation(); -}, 100); -``` - -Or if using dynamic import: - -```typescript -readerEvents.emit("readerReady", currentReader); - -// ADD THIS: Initialize page calculation after a short delay -setTimeout(async () => { - const { initializePageCalculation } = await import("./core/reader-navigation"); - initializePageCalculation(); -}, 100); -``` - ---- - -## 6. Handle Settings Changes - -When reading settings change (font size, margins), recalculate pages. - -In reader-navigation.ts, add listener in `renderSpineItem` or setup function: - -```typescript -context?.events.on("settings:changed", async (detail: { settings: any }) => { - const state = getState(); - if (!state.currentReader || state.currentReader.type !== "ebook") return; - - console.log("Recalculating pages due to settings change..."); - - const viewportWidth = window.innerWidth; - - pageCalculationResult = await calculatePagesForEbook( - state.currentReader.cif, - viewportWidth, - { - fontSize: detail.settings.font_size, - lineHeight: detail.settings.line_height, - marginWidth: detail.settings.margin_width, - } - ); - - state.currentReader.pageCalculationResult = pageCalculationResult; - setState({ currentReader: state.currentReader }); -}); -``` - ---- - -## Testing Checklist - -1. Load any EPUB - should see page count in progress indicator -2. Scroll through content - page number should update in real-time -3. Click next/previous chapter - should jump to correct page number -4. Change font size in settings - pages should recalculate -5. Change margins in settings - pages should recalculate -6. Resize browser window - pages should recalculate -7. Navigate between different EPUB files - should work correctly each time - -## Console Debug Commands - -```javascript -// Check page calculation state -window.pageInfo - -// Force recalculation -import("./core/reader-navigation").then(m => m.initializePageCalculation()) - -// Check current page -document.getElementById("reader-content").scrollTop -``` - ---- - -## Notes - -- The page calculation is asynchronous and may take a moment for large books -- A loading state can be shown while calculating -- The calculation uses a hidden container that is immediately removed after measurement -- Images are converted to blob URLs for measurement to get accurate heights -- The viewport height is reduced by 120px to account for top/bottom chrome bars \ No newline at end of file diff --git a/FEATURE_REFACTOR.md b/FEATURE_REFACTOR.md deleted file mode 100644 index 548e92f..0000000 --- a/FEATURE_REFACTOR.md +++ /dev/null @@ -1,1385 +0,0 @@ -# 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 diff --git a/READER_IMPLEMENTATION_PLAN.md b/READER_IMPLEMENTATION_PLAN.md deleted file mode 100644 index ef8fa39..0000000 --- a/READER_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,11265 +0,0 @@ - - -# 📖 Bookhoard Reader Implementation Plan - -## Overview - -Build a modern, responsive web reader for ebooks, comics, manga, and PDFs with full feature parity across all four media types. - -**Design Philosophy:** -- **Universal reader architecture**: One rendering engine with pluggable parser components -- **Common Intermediate Format (CIF)**: All reflowable ebooks convert to standardized HTML structure -- **Hybrid parsing strategy**: Server-side for complex formats (MOBI, AZW3, DOCX), client-side for simple formats (EPUB, FB2, TXT) -- **Procedural TypeScript**: No OOP, no classes, functional techniques where helpful (per PROJECT_GUIDELINES.md) -- **Surgical code reuse**: Leverage existing WebSocket sync, progress tracking, annotation systems -- **Progressive enhancement**: SSR-first with TypeScript enhancements -- **Privacy-first**: Per-user settings with localStorage fallback -- **Offline-capable**: PWA with offline dictionary -- **Full PDF support**: Mozilla pdf.js for text selection, highlights, search -- **Technical textbook optimization**: TOC navigation, bookmarks, dual-page view, navigator, copy support -- **Modular dockable panels**: TOC, Settings, Navigator, Bookmarks - each independent, dockable to either side, window-shade support - -## What's New in This Version - -### **Major Architecture Change: Universal Reader + Parsers** - -**Previous approach:** Separate readers for each format (EbookReader, ComicReader, etc.) - -**New approach:** Single universal reader with parser pipeline - -``` -All Reflowable Ebooks → Parse to CIF → Universal Reader -├── EPUB → EPUBParser → CIF → Universal Reader -├── FB2 → FB2Parser → CIF → Universal Reader -├── TXT → TXTParser → CIF → Universal Reader -├── HTML → HTMLParser → CIF → Universal Reader -├── MOBI → Server Parser → CIF → Universal Reader -├── AZW3 → Server Parser → CIF → Universal Reader -├── DOCX → Server Parser → CIF → Universal Reader -└── RTF → Server Parser → CIF → Universal Reader -``` - -**Benefits:** -- One codebase for UI/UX (fix once, works for all formats) -- Easy to add new formats (just implement parser interface) -- Consistent user experience across all ebooks -- ~500 KB total dependency size (vs. 182 MB Calibre) - -### **Procedural TypeScript (No OOP)** - -All code follows PROJECT_GUIDELINES.md: -- ❌ No classes -- ❌ No `this` capture -- ❌ No inheritance -- ✅ Functions and modules -- ✅ Functional techniques where helpful -- ✅ Procedural/imperative style - -**Example:** - -```typescript -// ❌ OLD (OOP - not allowed) -class EPUBParser { - private zip: JSZip | null = null; - async parse(blob: Blob): Promise { ... } -} - -// ✅ NEW (Procedural - correct) -export async function parseEPUB(blob: Blob): Promise { ... } -``` - ---- - -## Phase 0: Prerequisites & Setup ⚠️ **MUST COMPLETE FIRST** - -**IMPORTANT:** Do not start implementation until these prerequisites are complete. Failure to complete these steps will result in compilation errors and missing functionality. - -### 0.1 Database Schema Setup - -**Step 1:** The database tables are already defined in **Section 2.1** of this document. Copy the SQL from **Section 2.1 (lines 563-641)** and add it to: - -**File:** `database/schema/schema.sql` (modify existing) - -Add the SQL at the end of the file, before the index section (around line 1105). - -**Step 2:** Also add the ALTER TABLE statement from **Section 2.2** to add `chapter_metadata` column to `media_items` table. - -**Step 3:** After adding the tables, regenerate database queries: - -```bash -cd /home/nymusicman/Code/bookhoard/database -sqlc generate -``` - -**Step 4:** Update your local database: - -**Option 1: Recreate database (LOSES ALL DATA - Recommended for development):** -```bash -podman compose down -v # Delete all volumes -podman compose up -d # Start with fresh schema -``` - -**Option 2: Manual SQL migration (preserves data):** -```bash -podman exec bookhoard_db psql -U postgres -d bookhoard -# Then paste the SQL from Section 2.1 and 2.2 -``` - -### 0.2 Database Queries Setup - -**File:** `internal/database/queries/queries.sql` (modify existing) - -Add these queries to support the reader functionality: - -```sql --- name: GetPanelData :one -SELECT * FROM panel_data -WHERE media_item_id = $1 AND page_number = $2; - --- name: UpsertPanelData :one -INSERT INTO panel_data (media_item_id, page_number, detection_method, panels) -VALUES ($1, $2, $3, $4) -ON CONFLICT (media_item_id, page_number) -DO UPDATE SET - detection_method = EXCLUDED.detection_method, - panels = EXCLUDED.panels, - updated_at = NOW() -RETURNING *; - --- name: GetReadingSpeed :one -SELECT * FROM reading_speed -WHERE user_id = $1 AND media_item_id = $2; - --- name: CreateReadingSpeed :one -INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING *; - --- name: UpdateReadingSpeed :one -UPDATE reading_speed -SET - pages_per_minute = $3, - pages_read = pages_read + $4, - total_reading_minutes = total_reading_minutes + $5, - last_read_at = $6, - updated_at = NOW() -WHERE user_id = $1 AND media_item_id = $2 -RETURNING *; - --- name: GetDictionaryEntry :one -SELECT * FROM dictionary_cache -WHERE word = $1; - --- name: CreateDictionaryEntry :one -INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at) -VALUES ($1, $2, $3, $4, $5, NOW()) -RETURNING *; - --- name: UpdateDictionaryAccessed :one -UPDATE dictionary_cache -SET accessed_at = NOW() -WHERE word = $1 -RETURNING *; - --- name: GetReaderSettings :one -SELECT setting_value FROM reader_settings -WHERE user_id = $1 AND setting_key = 'reader_settings'; - --- name: UpsertReaderSettings :one -INSERT INTO reader_settings (user_id, setting_key, setting_value) -VALUES ($1, 'reader_settings', $2) -ON CONFLICT (user_id, setting_key) -DO UPDATE SET - setting_value = EXCLUDED.setting_value, - updated_at = NOW() -RETURNING *; - --- name: GetMediaBookmarks :many -SELECT * FROM media_bookmarks -WHERE media_item_id = $1 AND user_id = $2 -ORDER BY created_at DESC; - --- name: CreateMediaBookmark :one -INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING *; - --- name: DeleteMediaBookmark :exec -DELETE FROM media_bookmarks -WHERE id = $1; - --- name: UpdateMediaBookmark :one -UPDATE media_bookmarks -SET - title = $2, - notes = $3, - position = $4, - updated_at = NOW() -WHERE id = $1 AND user_id = $5 -RETURNING *; -``` - -**After adding the queries, regenerate:** -```bash -cd /home/nymusicman/Code/bookhoard/database -sqlc generate -``` - -### 0.3 Frontend Dependencies - -**File:** `package.json` (modify existing) - -Add these dependencies to the `dependencies` section: - -```json -{ - "dependencies": { - "jszip": "^3.10.1", - "pdfjs-dist": "^3.11.174" - } -} -``` - -**Then install:** -```bash -npm install -``` - -### 0.4 Directory Structure - -**Create the required directory structure:** - -```bash -# Frontend reader directories -mkdir -p web/src/reader/ebook -mkdir -p web/src/reader/comic -mkdir -p web/src/reader/manga -mkdir -p web/src/reader/pdf - -# Fonts directory -mkdir -p web/static/fonts - -# Bruno API test directory -mkdir -p bruno/reader -``` - -### 0.5 Pre-Implementation Checklist - -Before starting Phase 1, verify all items are complete: - -- [ ] **Database**: Added SQL from **Section 2.1** to `database/schema/schema.sql` -- [ ] **Database**: Added ALTER TABLE from **Section 2.2** to `database/schema/schema.sql` -- [ ] **Database**: Added queries to `internal/database/queries/queries.sql` (from this section) -- [ ] **Database**: Ran `sqlc generate` to regenerate Go code -- [ ] **Database**: Updated local database (recreated or migrated) -- [ ] **Frontend**: Added `jszip` and `pdfjs-dist` to `package.json` -- [ ] **Frontend**: Ran `npm install` to install dependencies -- [ ] **Structure**: Created all required directories (mkdir commands above) -- [ ] **Verification**: Can run `go build ./...` without errors -- [ ] **Verification**: Database tables exist (check with `\dt` in psql) - -### 0.6 Verification Commands - -**Verify database tables exist:** -```bash -podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt panel_data reading_speed dictionary_cache reader_settings media_bookmarks" -``` - -**Expected output should show 5 tables.** - -**Verify queries were generated:** -```bash -grep -c "GetPanelData\|UpsertPanelData\|GetReadingSpeed\|GetDictionaryEntry\|GetReaderSettings\|GetMediaBookmarks" /home/nymusicman/Code/bookhoard/internal/database/queries.sql.go -# Should return count > 0 (at least 10-12 matches) -``` - -**Verify frontend dependencies:** -```bash -npm list jszip pdfjs-dist -# Should show versions, not "(empty)" -``` - -**Expected output:** -``` -bookhoard@1.0.0 /home/nymusicman/Code/bookhoard -├── jszip@3.10.1 -└── pdfjs-dist@3.11.174 -``` - -**Verify Go compilation:** -```bash -go build ./... -# Should complete without errors -``` - -**Verify directories exist:** -```bash -ls -la web/src/reader/ | grep -E "ebook|comic|manga|pdf" -# Should show 4 directories -``` - -### 0.7 Troubleshooting - -**Problem:** `sqlc generate` fails with "undefined type" -**Solution:** Make sure you copied the SQL from Section 2.1 EXACTLY as written - -**Problem:** Database tables don't show up after recreation -**Solution:** Check that SQL was added BEFORE the index section in schema.sql - -**Problem:** `npm install` fails -**Solution:** Try `npm cache clean --force` then `npm install` again - -**Problem:** `go build` fails with "undefined: GetPanelData" -**Solution:** Make sure you ran `sqlc generate` after adding queries - -**Problem:** psql shows "column does not exist" for chapter_metadata -**Solution:** Make sure you ran the ALTER TABLE command from Section 2.2 - ---- - -**File:** `internal/database/queries/queries.sql` (modify existing) - -**Add these queries to the queries.sql file:** - -```sql --- name: GetPanelData :one -SELECT * FROM panel_data -WHERE media_item_id = $1 AND page_number = $2; - --- name: UpsertPanelData :one -INSERT INTO panel_data (media_item_id, page_number, detection_method, panels) -VALUES ($1, $2, $3, $4) -ON CONFLICT (media_item_id, page_number) -DO UPDATE SET - detection_method = EXCLUDED.detection_method, - panels = EXCLUDED.panels, - updated_at = NOW() -RETURNING *; - --- name: GetReadingSpeed :one -SELECT * FROM reading_speed -WHERE user_id = $1 AND media_item_id = $2; - --- name: CreateReadingSpeed :one -INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING *; - --- name: UpdateReadingSpeed :one -UPDATE reading_speed -SET - pages_per_minute = $3, - pages_read = pages_read + $4, - total_reading_minutes = total_reading_minutes + $5, - last_read_at = $6, - updated_at = NOW() -WHERE user_id = $1 AND media_item_id = $2 -RETURNING *; - --- name: GetDictionaryEntry :one -SELECT * FROM dictionary_cache -WHERE word = $1; - --- name: CreateDictionaryEntry :one -INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at) -VALUES ($1, $2, $3, $4, $5, NOW()) -RETURNING *; - --- name: UpdateDictionaryAccessed :one -UPDATE dictionary_cache -SET accessed_at = NOW() -WHERE word = $1 -RETURNING *; - --- name: GetReaderSettings :one -SELECT setting_value FROM reader_settings -WHERE user_id = $1 AND setting_key = 'reader_settings'; - --- name: UpsertReaderSettings :one -INSERT INTO reader_settings (user_id, setting_key, setting_value) -VALUES ($1, 'reader_settings', $2) -ON CONFLICT (user_id, setting_key) -DO UPDATE SET - setting_value = EXCLUDED.setting_value, - updated_at = NOW() -RETURNING *; - --- name: GetMediaBookmarks :many -SELECT * FROM media_bookmarks -WHERE media_item_id = $1 AND user_id = $2 -ORDER BY created_at DESC; - --- name: CreateMediaBookmark :one -INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING *; - --- name: DeleteMediaBookmark :exec -DELETE FROM media_bookmarks -WHERE id = $1; -``` - -**Regenerate queries after adding:** -```bash -cd /home/nymusicman/Code/bookhoard/database -sqlc generate -``` - -### 0.3 Frontend Dependencies - -**File:** `package.json` (modify existing) - -**Add these dependencies to the dependencies section:** - -```json -{ - "dependencies": { - "jszip": "^3.10.1", - "pdfjs-dist": "^3.11.174" - } -} -``` - -**Then install:** -```bash -npm install -``` - -### 0.4 Directory Structure - -**Create the required directory structure:** - -```bash -# Frontend reader directories -mkdir -p web/src/reader/ebook -mkdir -p web/src/reader/comic -mkdir -p web/src/reader/manga -mkdir -p web/src/reader/pdf - -# Fonts directory -mkdir -p web/static/fonts - -# Bruno API test directory -mkdir -p bruno/reader -``` - -### 0.5 Pre-Implementation Checklist - -Before starting Phase 1, verify all items are complete: - -- [ ] **Database**: Added 5 new tables to `database/schema/schema.sql` -- [ ] **Database**: Added SQL queries to `internal/database/queries/queries.sql` -- [ ] **Database**: Ran `sqlc generate` to regenerate Go code -- [ ] **Database**: Updated local database (recreated or migrated) -- [ ] **Frontend**: Added `jszip` and `pdfjs-dist` to `package.json` -- [ ] **Frontend**: Ran `npm install` to install dependencies -- [ ] **Structure**: Created all required directories -- [ ] **Verification**: Can run `go build ./...` without errors -- [ ] **Verification**: Database tables exist (check with `\dt` in psql) - -### 0.6 Verification Commands - -**Verify database tables exist:** -```bash -podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt panel_data reading_speed dictionary_cache reader_settings media_bookmarks" -``` - -**Verify queries were generated:** -```bash -grep -c "GetPanelData\|UpsertPanelData\|GetReadingSpeed" /home/nymusicman/Code/bookhoard/internal/database/queries.sql.go -# Should return count > 0 -``` - -**Verify frontend dependencies:** -```bash -npm list jszip pdfjs-dist -# Should show versions, not "(empty)" -``` - -**Verify Go compilation:** -```bash -go build ./... -# Should complete without errors -``` - ---- - -## 1. Architecture - -### 1.1 Universal Reader with Pluggable Parsers - -**Architectural Decision: Single Reader + Parser Pipeline** - -Instead of separate readers for each format, we use **one universal reader** with **pluggable parsers** that convert all formats to a **Common Intermediate Format (CIF)**. - -``` -┌──────────────────────────────────────────────────────────┐ -│ Universal Ebook Reader (Single) │ -│ - HTML Renderer (shared) │ -│ - Typography Engine (shared) │ -│ - Progress Tracker (shared) │ -│ - Annotation Manager (shared) │ -│ - Navigation Controls (shared) │ -└──────────────────────────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────┐ -│ Common Intermediate Format (CIF) │ -│ - Standardized HTML structure │ -│ - Universal metadata schema │ -│ - Unified navigation (TOC) │ -│ - Consistent resource loading │ -└──────────────────────────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────┐ -│ Parser Manager (Router) │ -│ Detects format → Routes to appropriate parser │ -└──────────────────────────────────────────────────────────┘ - ↓ - ┌─────────┬─────────┬──────────┬──────────┐ - │ EPUB │ FB2 │ TXT │ HTML │ ← Client-side - │ Parser │ Parser │ Parser │ Parser │ (TypeScript) - └─────────┴─────────┴──────────┴──────────┘ - - ┌─────────┬─────────┬──────────┬──────────┐ - │ MOBI │ AZW3 │ DOCX │ RTF │ ← Server-side - │ Parser │ Parser │ Parser │ Parser │ (Go backend) - └─────────┴─────────┴──────────┴──────────┘ - -PDF and Comics use dedicated readers (not CIF pipeline): -- PDFReader (pdf.js) - Fixed-layout documents -- ComicReader (canvas) - Image archives -- MangaReader (extends Comic) - RTL/vertical modes -``` - -**Why This Approach?** - -1. **Code Reuse**: One reader implementation for all reflowable ebooks -2. **Consistency**: All formats have identical UI/UX -3. **Maintainability**: Fix bug once, applies to all formats -4. **Extensibility**: Add new format by implementing parser interface -5. **Performance**: Client-side for simple formats, server-side for complex - -### 1.2 Component Structure - -``` -Reader Infrastructure (Shared) -├── reader-shell.ts - UI shell, chrome control, routing -├── panel-dock-system.ts - Modular dockable panel system -│ ├── panel-container.ts - Base panel container with dock logic -│ │ - Lock toggle: prevents accidental drag/move -│ │ - Snap-back: returns to last valid position if dropped in invalid area -│ │ - Dock zones: left edge, right edge, valid drop targets -│ │ - Drag handlers: mouse/touch drag to reposition -│ │ - Drop zone detection: validates drop position -│ │ - Persist state: saves to settings on move/lock/collapse -│ ├── window-shade.ts - Vertical collapse to title bar -│ │ - Animated collapse/expand -│ │ - State persistence -│ └── panel-state.ts - Per-user layout persistence (includes lock state) -├── progress-tracker.ts - Integration with reading_progress table -├── annotation-manager.ts - Integration with notes/highlights tables -├── websocket-sync.ts - Reuse existing sync system -├── settings-manager.ts - Per-user preferences (DB + localStorage) -├── bookmark-manager.ts - Integration with existing bookmarks -└── chapter-detector.ts - Chapter detection for all media types - -Dockable Panel Components (Reusable) -├── toc-panel.ts - Table of Contents (dockable left/right, window-shade) -├── settings-panel.ts - Reader settings (dockable left/right, window-shade) -├── navigator-panel.ts - Page navigator with viewport box (dockable left/right, window-shade) -└── bookmarks-panel.ts - User bookmarks (dockable left/right, window-shade, default bottom-right) - -Universal Ebook Reader (Reflowable Formats) -├── html-renderer.ts - Browser-native HTML rendering (shared) -├── typography-engine.ts - Font rendering, theme integration (shared) -├── cfi-navigator.ts - Universal position navigation -├── dictionary-popup.ts - Offline dictionary lookup - -Parser Pipeline -├── parser-manager.ts - Routes format → appropriate parser -├── cif-types.ts - Common Intermediate Format types -│ -├── Client-Side Parsers (TypeScript) -│ ├── epub-parser.ts - EPUB 2/3 parsing (ZIP + XML) -│ ├── fb2-parser.ts - FictionBook 2 parsing (XML) -│ ├── txt-parser.ts - Plain text wrapper -│ └── html-parser.ts - Standalone HTML files -│ -└── Server-Side Parsers (Go backend) - ├── mobi-parser.go - MOBI parsing - ├── azw3-parser.go - AZW3/KF8 parsing - ├── docx-parser.go - Word document parsing - └── rtf-parser.go - Rich Text Format parsing - -PDF Reader (Fixed Layout) -├── pdfjs-wrapper.ts - Mozilla pdf.js integration -├── text-layer-renderer.ts - Text layer overlay for selection -├── annotation-layer.ts - Highlight/note rendering -├── pdf-navigation.ts - Page navigation, zoom, fit modes -├── pdf-search.ts - Full-text search within PDF -├── page-cache.ts - 5-page ahead cache -├── text-selection.ts - Text selection and highlight creation -├── pdf-outline.ts - TOC navigation -├── pdf-bookmarks.ts - Custom bookmarks -├── pdf-clipboard.ts - Copy to clipboard -├── pdf-links.ts - Internal link handling -├── pdf-dual-page.ts - Dual page spread view -├── pdf-rotation.ts - Rotated page support -└── pdf-page-sizes.ts - Variable page size handling - -Comic Reader (Image Archives) - Uses shared navigator-panel.ts for page navigation -├── image-archive-parser.ts - CBZ/CBR parsing -├── canvas-renderer.ts - Canvas rendering with lazy loading -├── panel-detector.ts - Grid-based + ML + manual override -├── panel-navigator.ts - Panel zoom with smooth animations (when detection ON) -└── page-cache.ts - 5-page ahead cache - -Manga Reader (extends Comic) -├── rtl-navigator.ts - Right-to-left navigation -├── vertical-scroll-mode.ts - Webtoon-style vertical scroll -└── panel-detector.ts - Manga-aware panel detection -``` - -### 1.3 Theming Strategy (Hybrid Approach) - -**Design Decision:** - -Bookhoard Reader uses a **hybrid theming approach** to balance user personalization with reading best practices: - -``` -┌─────────────────────────────────────────────────────────┐ -│ UI Chrome (Bars, Panels, Settings) │ -│ ✅ All 11 Bookhoard themes available │ -│ - tokyo-night, dracula, nord, etc. │ -│ - Maintains consistency with rest of app │ -└─────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────┐ -│ Ebook Text Content │ -│ ✅ 5 reading-optimized themes only │ -│ - Light (standard) │ -│ - Sepia (warm, easier on eyes) │ -│ - Dark (reduced eye strain) │ -│ - Night (reduced blue light for better sleep) │ -│ - High Contrast (accessibility) │ -└─────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────┐ -│ PDF Documents │ -│ ✅ 5 reading-optimized themes only │ -│ - Light, Sepia, Dark, Night, High Contrast │ -│ - PDF.js supports custom CSS for text layer │ -│ - Maintains readability for long documents │ -└─────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────┐ -│ Comic/Manga Images │ -│ ✅ All 11 Bookhoard themes available │ -│ - Visual content works well with any theme │ -│ - No eye fatigue concerns with images │ -└─────────────────────────────────────────────────────────┘ -``` - -**Why This Approach?** - -1. **Reading Science**: Long-form reading (300+ pages) requires eye-comfort optimization -2. **User Expectations**: Kindle, Kobo, Apple Books offer 3-5 reading themes -3. **Accessibility**: Reading-optimized themes help users with visual impairments -4. **Best Practices**: Unusual colors (purple text) cause eye fatigue over long sessions -5. **Flexibility**: Still have full theming for UI and visual content - -**Popular Ebook Reader Comparison:** - -| Reader | Reading Themes | Color Options? | -|-------------|----------------|----------------| -| Kindle | 4 | No | -| Kobo | 4 | No (green for night) | -| Apple Books | 5 | No | -| **Bookhoard** | **5 (ebooks)** | **Yes (11 themes for UI/comics)** | - -**Implementation:** - -- `chrome_theme`: Applied to reader shell, navigation bars, settings panels -- `reading_theme`: Applied to ebook text content only (5 options) -- Comics/manga: Use `chrome_theme` (all 11 themes work well) - -### 1.4 Data Flow - -``` -User opens reader - ↓ -Backend: GET /readers/:mediaItemId (SSR skeleton page) - ↓ -Verify access, fetch initial metadata, progress, bookmarks - ↓ -SSR render: templates/reader.templ (skeleton shell with data attributes) - - Passes: metadata, progress, bookmarks for initial display - - Renders: chrome UI, panel containers (empty), data attributes - - Does NOT render: actual content pages (loaded by JS) - ↓ -Frontend TypeScript: reader-shell.ts initializes - - Reads: data-media-item-id, data-media-type, data-total-pages - - Detects: media type (ebook/pdf/comic/manga) - - Loads: appropriate reader (EbookReader/PDFReader/ComicReader/MangaReader) - ↓ -Reader fetches content dynamically: - - Ebooks: /readers/:id/pages/:n (HTML chapters) - - Comics: /readers/:id/pages/:n (images) - - PDFs: /readers/:id/pages/:n (text layer + images) - ↓ -User interacts (turn page, highlight, bookmark) - - All UI logic handled by TypeScript - - Progress updates sent via WebSocket - ↓ -Real-time sync via WebSocket (reuse existing system) -``` - -### 1.5 SSR vs Client-Side Responsibilities - -**IMPORTANT:** The reader uses a hybrid approach - SSR provides the shell, TypeScript handles all interactivity. - -**SSR (Go template) responsibilities:** -- ✅ Render HTML skeleton/structure -- ✅ Embed initial metadata (title, author, cover) -- ✅ Embed initial progress (current page, percentage) -- ✅ Embed initial bookmarks list -- ✅ Render chrome UI (top/bottom bars with basic info) -- ✅ Render panel containers (empty, populated by JS) -- ✅ Set data attributes for Alpine.js initialization -- ❌ Does NOT render actual content pages -- ❌ Does NOT handle reader interactions - -**Client-Side (TypeScript) responsibilities:** -- ✅ Read data attributes from DOM -- ✅ Detect media type and initialize appropriate reader -- ✅ Fetch and render content pages dynamically -- ✅ Handle all user interactions (page turns, zoom, pan) -- ✅ Manage panel state (show/hide, dock, collapse) -- ✅ Sync progress via WebSocket -- ✅ Handle highlights, annotations, bookmarks CRUD -- ✅ Implement reading features (dictionary, search, TOC navigation) - -**Example: What SSR provides:** -```html - -
-

Book Title

- Page 45/300 -
-
- -
-
- -
- -``` - -**Example: What TypeScript does:** -```typescript -// reader-shell.ts -function initReader() { - const mediaItemId = document.body.dataset.mediaItemId; - const mediaType = document.body.dataset.mediaType; - - // Fetch and render first page - loadPage(mediaItemId, 1, mediaType); - - // Initialize panel interactions - initPanelSystem(); - - // Setup WebSocket sync - initWebSocketSync(mediaItemId); -} -``` - ---- - -## 2. Database Schema Changes - -### 2.1 New Tables - -```sql --- Panel detection data -CREATE TABLE IF NOT EXISTS panel_data ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - page_number INTEGER NOT NULL, - detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual' - panels JSONB NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(media_item_id, page_number) -); - -CREATE INDEX IF NOT EXISTS idx_panel_data_media_item ON panel_data(media_item_id); - --- Reading speed tracking -CREATE TABLE IF NOT EXISTS reading_speed ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - words_per_minute DECIMAL(6,2), - pages_per_minute DECIMAL(6,2), - pages_read INTEGER DEFAULT 0, - total_reading_minutes DECIMAL(8,2) DEFAULT 0, - last_read_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, media_item_id) -); - -CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id); -CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id); - --- Dictionary cache (for offline use) -CREATE TABLE IF NOT EXISTS dictionary_cache ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - word VARCHAR(100) NOT NULL UNIQUE, - definition TEXT NOT NULL, - part_of_speech VARCHAR(20), - example TEXT, - etymology TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - accessed_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word); - --- Reader settings (per-user preferences) -CREATE TABLE IF NOT EXISTS reader_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - setting_key VARCHAR(50) NOT NULL, - setting_value JSONB NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, setting_key) -); - -CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id); - --- PDF bookmarks (custom user bookmarks) -CREATE TABLE IF NOT EXISTS media_bookmarks ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - page_number INTEGER, - chapter_number INTEGER, - cfi_position VARCHAR(255), -- For ebooks: EPUB CFI position - title VARCHAR(255) NOT NULL, - position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(media_item_id, user_id, COALESCE(page_number, 0), COALESCE(chapter_number, 0)) -); - -CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id); -CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id); -``` - -### 2.2 Alter Existing Tables - -```sql --- Add chapter metadata to media_items -ALTER TABLE media_items -ADD COLUMN chapter_metadata JSONB; - --- Example structure: --- { --- "chapters": [ --- {"id": "chap1", "title": "Chapter 1", "start_page": 1, "page_count": 20}, --- {"id": "chap2", "title": "Chapter 2", "start_page": 21, "page_count": 25} --- ] --- } - --- Note: reading_progress table already exists with epubcfi, page, percentage fields --- Note: notes and highlights tables already exist --- Note: bookmarks table already exists -``` - -### 2.3 Schema.sql Implementation - -**File:** `database/schema/schema.sql` - -Add the above tables to the schema file. Follow existing patterns: -- Use `gen_random_uuid()` for UUID defaults -- Use `TIMESTAMPTZ DEFAULT NOW()` for timestamps -- Add appropriate indexes for foreign keys -- Use `ON DELETE CASCADE` for referential integrity - ---- - -## 2.4 Required Code Implementation (Database & Go Backend) - -### Database Tables (Already Exist) - -The following tables already exist in `database/schema/schema.sql`: -- `panel_data` - Panel detection for comics/manga -- `reader_settings` - **Stores panel layout configuration** (including dock positions, lock state, collapsed state, width) -- `media_bookmarks` - User bookmarks -- `reading_speed` - Reading statistics -- `dictionary_cache` - Offline dictionary - -**Panel layout storage**: All panel dock system state (side, visible, collapsed, width_px, order, locked, last_valid_side) is stored as JSON in `reader_settings.setting_value` under the `panel_layout` key. - -### Database Schema Issues to Fix - -The following database schema issues need to be resolved before implementation: - -1. **media_items.chapter_metadata**: Add to schema as `JSONB` column (see Section 2.2) - -2. **reader_settings table query**: The generated query `UpsertReaderSettings` expects: - - Parameters: `UserID` (UUID), `SettingValue` ([]byte) - - The query uses `setting_key = 'reader_settings'` hardcoded - - Code using `SettingKey` field will fail - remove that field from params - -3. **reading_speed table fields**: The generated model uses different types: - - `pages_per_minute` is `pgtype.Float8`, not `pgtype.Numeric` - - `pages_read` is `pgtype.Int4`, not plain `int32` - - Fix type conversions in service code - -4. **panel_data table**: The generated model `database.PanelData` is a struct, not a type with `Valid/Bytes` fields - - Code using `cached.Valid` and `cached.Bytes` will fail - - Need to query directly and handle JSON unmarshaling differently - -### Database Queries to Add - -Add these queries to `internal/database/queries/queries.sql`: - -```sql --- name: UpdateMediaItemChapterMetadata :one -UPDATE media_items -SET chapter_metadata = $2, updated_at = NOW() -WHERE id = $1 -RETURNING *; - --- name: GetReadingSpeedByUser :one -SELECT * FROM reading_speed -WHERE user_id = $1 AND media_item_id = $2; - --- name: UpsertReadingSpeed :one -INSERT INTO reading_speed (user_id, media_item_id, words_per_minute, pages_per_minute, pages_read, total_reading_minutes, last_read_at) -VALUES ($1, $2, $3, $4, $5, $6, $7) -ON CONFLICT (user_id, media_item_id) -DO UPDATE SET - words_per_minute = COALESCE($3, reading_speed.words_per_minute), - pages_per_minute = COALESCE($4, reading_speed.pages_per_minute), - pages_read = reading_speed.pages_read + COALESCE($5, 0), - total_reading_minutes = reading_speed.total_reading_minutes + COALESCE($6, 0), - last_read_at = $7, - updated_at = NOW() -RETURNING *; -``` - -### Go Service Implementation Issues - -The following issues in `internal/services/reader.go` need fixing: - -1. **Line 71-76**: Chapter metadata is `[]byte`, not `pgtype.JSONB` - ```go - // Change from: - if item.ChapterMetadata.Valid { ... item.ChapterMetadata.Bytes ... } - // To: - if len(item.ChapterMetadata) > 0 { ... item.ChapterMetadata ... } - ``` - -2. **Line 126**: PageCount is `pgtype.Int4`, use `.Int32` not `.Int64` - ```go - // Change from: - pageCount := int(item.PageCount.Int64) - // To: - pageCount := int(item.PageCount.Int32) - ``` - -3. **Lines 172-174**: PanelData query returns `database.PanelData` struct, handle differently - -4. **Lines 283-285**: Fix pgtype conversions for reading_speed - ```go - // Use proper pgtype types: - PagesPerMinute: pgtype.Float8{Float64: pagesPerMinute, Valid: true}, - PagesRead: pgtype.Int4{Int32: int32(pagesRead), Valid: true}, - TotalReadingMinutes: pgtype.Float8{Float64: minutes, Valid: true}, - ``` - -5. **Lines 353, 386-387**: Fix reader settings access - ```go - // SettingValue is []byte, use directly - if len(settings.SettingValue) > 0 { ... } - // UpsertReaderSettingsParams doesn't have SettingKey - // Use: UserID, SettingValue only - ``` - -### Router Implementation - -The router at `internal/router/reader.go` has a placeholder SSR handler that needs: -1. Fetch metadata/progress/bookmarks (inline or via handler) -2. Import "bookhoard/templates" -3. Call `templates.Reader()` with proper data - -Note: This creates import cycle if handler imports templates - follow frontend.go pattern where router handles template rendering directly. - ---- - -## 3. API Endpoints - -### 3.1 Reader Routes - -**IMPORTANT:** Reader routes are split into two locations following Bookhoard's architecture: -- **SSR page** (HTML): Registered in `internal/router/frontend.go` -- **API endpoints** (JSON): Registered in `internal/router/reader.go` - -**File:** `internal/router/reader.go` (new file) - -```go -package router - -import ( - "bookhoard/internal/handlers" - "bookhoard/internal/services" -) - -func registerReaderRoutes(cfg *Config) { - e := cfg.Echo - - // Create reader service and handler - readerService := services.NewReaderService(cfg.Queries, cfg.Worker) - cfg.ReaderHandler = handlers.NewReaderHandler( - cfg.Queries, - services.NewLibraryService(cfg.Queries), // Create inline, not from cfg - readerService, - cfg.Worker, - ) - - jwtMiddleware := createJWTMiddleware(cfg) - - // API routes only (JSON responses) - // Note: SSR reader page is registered in frontend.go under /readers/:mediaItemId - reader := e.Group("/readers", jwtMiddleware) - - // Content serving (lazy-loaded pages) - reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage) - - // Chapter metadata - reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters) - - // Panel data (comics/manga) - reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels) - reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels) - - // PDF outline/TOC - reader.GET("/:mediaItemId/outline", cfg.ReaderHandler.GetPDFOutline) - - // PDF thumbnails (for mini-map) - reader.GET("/:mediaItemId/thumbnails/:pageNumber", cfg.ReaderHandler.GetPDFThumbnail) - - // Reading speed - reader.GET("/:mediaItemId/reading-speed", cfg.ReaderHandler.GetReadingSpeed) - reader.POST("/:mediaItemId/reading-speed", cfg.ReaderHandler.UpdateReadingSpeed) - - // Dictionary lookup - reader.GET("/dictionary/:word", cfg.ReaderHandler.LookupWord) - - // Reader settings - reader.GET("/settings", cfg.ReaderHandler.GetSettings) - reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings) - - // **PHASE 1 NOTE:** Do NOT register parse route in Phase 1 - // **PHASE 2.5 TASK:** Uncomment below when implementing server-side parsers - // // Parse endpoint for server-side formats (MOBI, AZW3, DOCX, RTF) - // reader.POST("/:mediaItemId/parse", cfg.ReaderHandler.ParseEbook) -} -``` - -**File:** `internal/router/frontend.go` (modify existing) - -Add the SSR reader page route to the `frontendProtected` group: - -```go -// In registerFrontendRoutes function, after other frontendProtected routes: - -// Reader page (SSR) -frontendProtected.GET("/readers/:mediaItemId", func(c *echo.Context) error { - // Get user - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return renderErrorPage(c, "Error loading user", "user_load_error") - } - - // Parse mediaItemID - mediaItemID := c.Param("mediaItemId") - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return renderErrorPage(c, "Invalid media item ID", "invalid_id") - } - - // Fetch media item - mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) - if err != nil { - if err == pgx.ErrNoRows { - return renderErrorPage(c, "Media item not found", "not_found") - } - return renderErrorPage(c, "Failed to fetch media item", "db_error") - } - - // Check library access (using GetUserVisibleLibraries pattern) - userUUID, _ := uuid.Parse(user.ID) - libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) - if err != nil { - return renderErrorPage(c, "Failed to check access", "access_error") - } - - visible := false - for _, lib := range libraries { - if lib.ID == mediaItem.LibraryID { - visible = true - break - } - } - if !visible { - return renderErrorPage(c, "Access denied to this library", "access_denied") - } - - // Get reading progress - var progress database.ReadingProgress - progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ - MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, - UserID: uuidToPGType(userUUID), - }) - if err != nil && err != pgx.ErrNoRows { - progress = database.ReadingProgress{} - } - - // Get bookmarks - bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{ - MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, - UserID: uuidToPGType(userUUID), - }) - - // Convert to template types (inline, following frontend.go pattern) - mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16]) - metadata := templates.ReaderMetadata{ - MediaItemID: mediaUUID.String(), - Title: mediaItem.Title, - Author: textToString(mediaItem.Author), - CoverImagePath: textToString(mediaItem.CoverImagePath), - LibraryType: mediaItem.FormatGroup, - MimeType: textToString(mediaItem.MimeType), - FilePath: mediaItem.FilePath, - TotalPages: int(mediaItem.PageCount.Int32), - ChapterCount: int(mediaItem.ChapterCount.Int32), - } - - // Progress conversion (inline) - progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16]) - progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16]) - progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16]) - templateProgress := templates.ReadingProgress{ - ID: progressUUID.String(), - MediaItemID: progressMediaUUID.String(), - UserID: progressUserUUID.String(), - CurrentPage: int(progress.CurrentPage.Int32), - TotalPages: int(progress.TotalPages.Int32), - Percentage: progress.Percentage.Float64, - EpubCfi: textToString(progress.Epubcfi), - LastReadAt: progress.LastReadAt.Time, - } - - // Bookmarks conversion (inline, with loop) - templateBookmarks := make([]templates.Bookmark, len(bookmarks)) - for i, b := range bookmarks { - bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16]) - bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16]) - bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16]) - - var pageNumber *int - if b.PageNumber.Valid { - pn := int(b.PageNumber.Int32) - pageNumber = &pn - } - - var chapterNumber *int - if b.ChapterNumber.Valid { - cn := int(b.ChapterNumber.Int32) - chapterNumber = &cn - } - - templateBookmarks[i] = templates.Bookmark{ - ID: bookmarkUUID.String(), - MediaItemID: bookmarkMediaUUID.String(), - UserID: bookmarkUserUUID.String(), - PageNumber: pageNumber, - ChapterNumber: chapterNumber, - CfiPosition: textToString(b.CfiPosition), - Title: b.Title, - Position: textToString(b.Position), - Notes: textToString(b.Notes), - CreatedAt: b.CreatedAt.Time, - } - } - - // Render template - var buf bytes.Buffer - err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf) - if err != nil { - return renderErrorPage(c, "Error rendering reader", "render_error") - } - - return c.HTML(http.StatusOK, buf.String()) -}) -``` - -**File:** `internal/router/router.go` (modify existing) - -Add ReaderHandler to Config struct (around line 38-67): - -```go -type Config struct { - Echo *echo.Echo - Queries *database.Queries - Cfg *config.Config - DBPool interface{} - AuthHandler *handlers.AuthHandler - LibraryHandler *handlers.LibraryHandler - DeviceHandler *handlers.DeviceHandler - MediaHandler *handlers.MediaHandler - MatchingHandler *handlers.MatchingHandler - KOReaderHandler *handlers.KOReaderHandler - WSHandler *handlers.WSHandler - ConflictHandler *handlers.ConflictHandler - AnalyticsHandler *handlers.AnalyticsHandler - QueueHandler *handlers.QueueHandler - CollectionHandler *handlers.CollectionHandler - Worker *services.Worker - FiltersHandler *handlers.FiltersHandler - DashboardHandler *handlers.DashboardHandler - DashboardService *services.DashboardService - OPDSHandler *handlers.OPDSHandler - SystemSettingsHandler *handlers.SystemSettingsHandler - ConnManager *sync.ConnectionManager - QueueProcessor *sync.SyncQueueProcessor - DeviceAuthMiddleware *middleware.DeviceAuthMiddleware - LoginTracker *ratelimit.LoginAttemptTracker - ScannerHandler *handlers.Handler - JobsHandler *handlers.JobsHandler - SidecarHandler *handlers.SidecarHandler - ReaderHandler *handlers.ReaderHandler // ADD THIS LINE -} -``` - -Add registerReaderRoutes call in RegisterRoutes function (after line 220): - -```go -func RegisterRoutes(cfg *Config) *handlers.Handler { - // ... existing route registrations ... - - registerFiltersRoutes(cfg) - registerOPDSRoutes(cfg) - registerReaderRoutes(cfg) // ADD THIS LINE - reader routes must be registered before frontend routes - registerWebSocketRoutes(cfg) - registerFrontendRoutes(cfg) - registerDocumentationRoutes(cfg) - - // ... rest of function ... -} -``` - -### 3.2 Handler Implementation - -**IMPORTANT:** Handlers return JSON only (API endpoints). SSR rendering is handled in `router/frontend.go` to avoid circular dependencies between handlers and templates. - -**File:** `internal/handlers/reader.go` (new file) - -Follow existing patterns from `media.go` and `auth.go`: -- Use `database.Queries` for all DB operations -- Return JSON responses with consistent structure -- Handle errors properly (404, 403, 500) -- **DO NOT import templates package** (causes circular dependency) - -**ShowReader method is removed** - SSR is handled by router/frontend.go, not handlers - -**API Handler Structure:** - -```go -package handlers - -import ( - "bookhoard/internal/database" - "bookhoard/internal/services" - "context" - "fmt" - "net/http" - "os" - "strconv" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/labstack/echo/v5" -) - -type ReaderHandler struct { - db *database.Queries - libraryService *services.LibraryService - readerService *services.ReaderService - worker *services.Worker -} - -func NewReaderHandler( - db *database.Queries, - libraryService *services.LibraryService, - readerService *services.ReaderService, - worker ...*services.Worker, -) *ReaderHandler { - rh := &ReaderHandler{ - db: db, - libraryService: libraryService, - readerService: readerService, - } - if len(worker) > 0 && worker[0] != nil { - rh.worker = worker[0] - } - return rh -} - -// NOTE: No ShowReader method here - SSR is handled by router/frontend.go -// All methods below return JSON for API endpoints only - -// GetPage returns a specific page for lazy loading -func (h *ReaderHandler) GetPage(c echo.Context) error { - mediaItemID := c.Param("mediaItemId") - pageNumber := c.Param("pageNumber") - - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) - } - - page, err := strconv.Atoi(pageNumber) - if err != nil || page < 1 { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) - } - - // Get media item - mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) - } - - // Check user access - user := c.Get("user").(database.Users) - hasAccess, _ := h.libraryService.UserHasLibraryAccess(c.Request().Context(), user.ID, mediaItem.LibraryID) - if !hasAccess { - return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied"}) - } - - // Resolve full file path - fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"}) - } - - // Get requested format - format := c.QueryParam("format") - if format == "" { - format = "html" - } - - // Extract page content based on format - content, err := h.extractPageContent(c.Request().Context(), &mediaItem, page, format, fullPath) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "content": content, - "page_number": page, - "total_pages": mediaItem.PageCount, - "media_item_id": mediaItemID, - }) -} - -// GetChapters returns chapter metadata -func (h *ReaderHandler) GetChapters(c echo.Context) error { - mediaItemID := c.Param("mediaItemId") - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) - } - - // Use reader service to detect chapters - chapters, err := h.readerService.DetectChapters(c.Request().Context(), parsedUUID) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect chapters"}) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "chapters": chapters, - }) -} - -// GetPanels returns panel detection data for comics -func (h *ReaderHandler) GetPanels(c echo.Context) error { - mediaItemID := c.Param("mediaItemId") - pageNumber := c.Param("pageNumber") - - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) - } - - page, err := strconv.Atoi(pageNumber) - if err != nil || page < 1 { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) - } - - // Get detection method from query - method := c.QueryParam("method") - if method == "" { - method = "grid" - } - - // Use reader service to detect panels - panels, err := h.readerService.DetectPanels(c.Request().Context(), parsedUUID, page, method) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect panels"}) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "page_number": page, - "detection_method": method, - "panels": panels, - }) -} - -// UpdatePanels allows manual panel override -func (h *ReaderHandler) UpdatePanels(c echo.Context) error { - mediaItemID := c.Param("mediaItemId") - pageNumber := c.Param("pageNumber") - - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) - } - - page, err := strconv.Atoi(pageNumber) - if err != nil || page < 1 { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) - } - - // Parse request body - var req struct { - Panels []services.Panel `json:"panels"` - DetectionMethod string `json:"detection_method"` - } - - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) - } - - // Store manual panel override in database - // This would require implementing UpsertPanelData in database/queries.sql - _ = parsedUUID - _ = page - _ = req - - return c.JSON(http.StatusOK, map[string]interface{}{ - "success": true, - "message": "Panels updated successfully", - }) -} - -// GetReadingSpeed retrieves reading speed statistics -func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error { - mediaItemID := c.Param("mediaItemId") - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) - } - - user := c.Get("user").(database.Users) - - // Get reading speed from database - speed, err := h.db.GetReadingSpeed(c.Request().Context(), database.GetReadingSpeedParams{ - UserID: user.ID, - MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, - }) - - if err != nil { - if err == pgx.ErrNoRows { - // Return zero values if no reading has occurred - return c.JSON(http.StatusOK, map[string]interface{}{ - "words_per_minute": 0, - "pages_per_minute": 0, - "pages_read": 0, - "total_reading_minutes": 0, - "last_read_at": nil, - }) - } - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch reading speed"}) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "words_per_minute": speed.PagesPerMinute.Float64 * 250, // Estimate WPM - "pages_per_minute": speed.PagesPerMinute.Float64, - "pages_read": speed.PagesRead, - "total_reading_minutes": speed.TotalReadingMinutes.Float64, - "last_read_at": speed.LastReadAt.Time, - }) -} - -// UpdateReadingSpeed updates reading speed statistics -func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error { - mediaItemID := c.Param("mediaItemId") - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) - } - - user := c.Get("user").(database.Users) - - // Parse request body - var req struct { - PagesRead int `json:"pages_read"` - TimeSpentMinutes float64 `json:"time_spent_minutes"` - } - - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) - } - - // Update reading speed using service - err = h.readerService.CalculateReadingSpeed( - c.Request().Context(), - user.ID, - parsedUUID, - req.PagesRead, - req.TimeSpentMinutes, - ) - - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update reading speed"}) - } - - // Calculate and return updated statistics - pagesPerMinute := float64(req.PagesRead) / req.TimeSpentMinutes - wordsPerMinute := pagesPerMinute * 250 // Estimate - - return c.JSON(http.StatusOK, map[string]interface{}{ - "success": true, - "words_per_minute": wordsPerMinute, - "pages_per_minute": pagesPerMinute, - }) -} - -// GetPDFOutline returns PDF outline/TOC -func (h *ReaderHandler) GetPDFOutline(c echo.Context) error { - mediaItemID := c.Param("mediaItemId") - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) - } - - // Get media item - mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) - } - - // Only PDFs have outlines - if mediaItem.FormatGroup != "pdf" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Not a PDF file"}) - } - - // Extract PDF outline using pdfcpu - fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"}) - } - - // Use pdfcpu to extract outline - outline := h.extractPDFOutline(fullPath) - - return c.JSON(http.StatusOK, map[string]interface{}{ - "outline": outline, - }) -} - -// GetPDFThumbnail returns a thumbnail for PDF mini-map -func (h *ReaderHandler) GetPDFThumbnail(c echo.Context) error { - mediaItemID := c.Param("mediaItemId") - pageNumber := c.Param("pageNumber") - - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) - } - - page, err := strconv.Atoi(pageNumber) - if err != nil || page < 1 { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) - } - - // Get media item - mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) - } - - // Get thumbnail size from query - width := c.QueryParam("width") - height := c.QueryParam("height") - - // Generate thumbnail using pdfcpu - fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"}) - } - - thumbnail, err := h.generatePDFThumbnail(fullPath, page, width, height) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate thumbnail"}) - } - - c.Response().Header().Set("Content-Type", "image/png") - return c.Blob(http.StatusOK, "image/png", thumbnail) -} - -// LookupWord performs dictionary lookup -func (h *ReaderHandler) LookupWord(c echo.Context) error { - word := c.Param("word") - if word == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Word parameter required"}) - } - - // Use reader service for dictionary lookup - entry, err := h.readerService.LookupWord(c.Request().Context(), word) - if err != nil { - return c.JSON(http.StatusNotFound, map[string]string{"error": "Word not found in dictionary"}) - } - - return c.JSON(http.StatusOK, entry) -} - -// GetSettings retrieves user's reader settings -func (h *ReaderHandler) GetSettings(c echo.Context) error { - user := c.Get("user").(database.Users) - - // Use reader service to get settings - settings, err := h.readerService.GetSettings(c.Request().Context(), user.ID) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"}) - } - - return c.JSON(http.StatusOK, settings) -} - -// UpdateSettings updates user's reader settings -func (h *ReaderHandler) UpdateSettings(c echo.Context) error { - user := c.Get("user").(database.Users) - - // Parse request body (partial update supported) - var settings map[string]interface{} - if err := c.Bind(&settings); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) - } - - // Validate settings - if readingTheme, ok := settings["reading_theme"].(string); ok { - validThemes := map[string]bool{ - "light": true, "sepia": true, "dark": true, "night": true, "high-contrast": true, - } - if !validThemes[readingTheme] { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid reading theme"}) - } - } - - // Use reader service to update settings - err := h.readerService.UpdateSettings(c.Request().Context(), user.ID, settings) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"}) - } - - // Return updated settings - updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), user.ID) - return c.JSON(http.StatusOK, updatedSettings) -} - -// Helper functions - -func (h *ReaderHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) { - // Get library folders - folders, err := h.db.GetLibraryFolders(ctx, libraryID) - if err != nil { - return "", err - } - - // Try each folder until we find the file - for _, folder := range folders { - fullPath := folder.FolderPath + string(os.PathSeparator) + relativePath - if _, err := os.Stat(fullPath); err == nil { - return fullPath, nil - } - } - - return "", fmt.Errorf("file not found in any library folder") -} - -func (h *ReaderHandler) extractPageContent(ctx context.Context, item *database.MediaItems, page int, format, fullPath string) (string, error) { - // Extract content based on format - // This is a simplified implementation - // In production, would use format-specific parsers - - switch item.FormatGroup { - case "reflowable": - // For EPUB and other ebooks, extract the requested page/chapter - return h.extractEbookPage(fullPath, page, format) - case "fixed-layout": - // For comics, return image data URL or path - return h.extractComicPage(fullPath, page, format) - case "pdf": - // For PDFs, extract text or image - return h.extractPDFPage(fullPath, page, format) - default: - return "", fmt.Errorf("unsupported format: %s", item.FormatGroup) - } -} - -func (h *ReaderHandler) extractEbookPage(fullPath string, page int, format string) (string, error) { - // Simplified EPUB extraction - // In production, would use epub-parser.ts logic - return fmt.Sprintf("

Page %d content

", page), nil -} - -func (h *ReaderHandler) extractComicPage(fullPath string, page int, format string) (string, error) { - // For comics, return image path or data URL - return fmt.Sprintf("/readers/comic-image?page=%d", page), nil -} - -func (h *ReaderHandler) extractPDFPage(fullPath string, page int, format string) (string, error) { - // For PDFs, extract text content or image - return fmt.Sprintf("

Page %d content

", page), nil -} - -func (h *ReaderHandler) extractPDFOutline(fullPath string) []map[string]interface{} { - // Extract PDF outline using pdfcpu - // This is a placeholder - return []map[string]interface{}{} -} - -func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, height string) ([]byte, error) { - // Generate thumbnail using pdfcpu - // This is a placeholder - return []byte{}, nil -} - -// Helper function to convert pgtype.Text to string -func textToString(t pgtype.Text) string { - if !t.Valid { - return "" - } - return t.String -} -``` - -### 3.3 Service Layer - -**File:** `internal/services/reader_service.go` (new file) - -All business logic goes here, not in handlers: - -```go -package services - -import ( - "bookhoard/internal/database" - "context" - "encoding/json" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" -) - -type ReaderService struct { - db *database.Queries - worker *Worker -} - -func NewReaderService(db *database.Queries, worker *Worker) *ReaderService { - return &ReaderService{ - db: db, - worker: worker, - } -} - -// Chapter represents a detected chapter -type Chapter struct { - ID string `json:"id"` - Title string `json:"title"` - StartPage int `json:"start_page"` - PageCount int `json:"page_count"` - Level int `json:"level"` - ParentID *string `json:"parent_id,omitempty"` -} - -// Panel represents a detected comic panel -type Panel struct { - ID string `json:"id"` - X int `json:"x"` - Y int `json:"y"` - Width int `json:"width"` - Height int `json:"height"` - ReadingOrder int `json:"reading_order"` -} - -// DictionaryEntry represents a word definition -type DictionaryEntry struct { - Word string `json:"word"` - Definition string `json:"definition"` - PartOfSpeech string `json:"part_of_speech,omitempty"` - Example string `json:"example,omitempty"` - Etymology string `json:"etymology,omitempty"` -} - -// ChapterDetectionResult contains chapter metadata -type ChapterDetectionResult struct { - Chapters []Chapter `json:"chapters"` - Metadata json.RawMessage `json:"metadata,omitempty"` - DetectedAt time.Time `json:"detected_at"` -} - -// DetectChapters analyzes a media item to detect chapter structure -func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error) { - // Get media item to determine type - item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true}) - if err != nil { - return nil, fmt.Errorf("failed to get media item: %w", err) - } - - // Check if chapter metadata already exists - if item.ChapterMetadata.Valid { - var existing ChapterDetectionResult - if err := json.Unmarshal(item.ChapterMetadata.Bytes, &existing); err == nil { - return existing.Chapters, nil - } - } - - // Detect chapters based on format - var chapters []Chapter - - switch item.FormatGroup { - case "reflowable": - // For ebooks, parse from TOC if available - chapters, err = s.detectEbookChapters(ctx, &item) - case "fixed-layout": - // For comics/manga, detect page breaks as chapters - chapters, err = s.detectComicChapters(ctx, &item) - case "pdf": - // For PDFs, use PDF outline - chapters, err = s.detectPDFChapters(ctx, &item) - default: - chapters = []Chapter{} - } - - if err != nil { - return nil, fmt.Errorf("chapter detection failed: %w", err) - } - - // Cache the results - result := ChapterDetectionResult{ - Chapters: chapters, - Metadata: nil, - DetectedAt: time.Now(), - } - - metadataBytes, err := json.Marshal(result) - if err == nil { - // Update media item with chapter metadata - // This would require a new query in database/queries.sql - _ = metadataBytes - } - - return chapters, nil -} - -func (s *ReaderService) detectEbookChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { - // For EPUB files, parse the TOC from the OPF file - // This requires EPUB parsing (see epub-parser.ts) - // For now, return empty structure - return []Chapter{}, nil -} - -func (s *ReaderService) detectComicChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { - // For comics, treat each page as a potential chapter - // or group pages by story arcs if metadata exists - pageCount := int(item.PageCount.Int64) - - if pageCount <= 0 { - return []Chapter{}, nil - } - - chapters := make([]Chapter, 0) - chapterSize := 20 // Group pages into chapters of 20 pages each - - for i := 0; i < pageCount; i += chapterSize { - endPage := i + chapterSize - if endPage > pageCount { - endPage = pageCount - } - - chapters = append(chapters, Chapter{ - ID: fmt.Sprintf("chapter-%d", len(chapters)+1), - Title: fmt.Sprintf("Page %d-%d", i+1, endPage), - StartPage: i + 1, - PageCount: endPage - i, - Level: 1, - }) - } - - return chapters, nil -} - -func (s *ReaderService) detectPDFChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { - // For PDFs, use pdfcpu to extract outline/bookmarks - // This requires PDF parsing library - return []Chapter{}, nil -} - -// DetectPanels analyzes a comic page to detect panel boundaries -func (s *ReaderService) DetectPanels( - ctx context.Context, - mediaItemID uuid.UUID, - pageNumber int, - method string, -) ([]Panel, error) { - // Check if panels already exist in cache - cached, err := s.db.GetPanelData(ctx, database.GetPanelDataParams{ - MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, - PageNumber: int32(pageNumber), - }) - - if err == nil && cached.Valid { - var panels []Panel - if err := json.Unmarshal(cached.Bytes, &panels); err == nil { - return panels, nil - } - } - - // Detect panels using specified method - var panels []Panel - - switch method { - case "grid": - panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber) - case "ml": - panels, err = s.detectPanelsML(ctx, mediaItemID, pageNumber) - case "manual": - panels, err = s.detectPanelsManual(ctx, mediaItemID, pageNumber) - default: - // Default to grid detection - panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber) - } - - if err != nil { - return nil, fmt.Errorf("panel detection failed: %w", err) - } - - // Cache the results - panelsJSON, _ := json.Marshal(panels) - // Insert into panel_data table - _ = panelsJSON - - return panels, nil -} - -func (s *ReaderService) detectPanelsGrid( - ctx context.Context, - mediaItemID uuid.UUID, - pageNumber int, -) ([]Panel, error) { - // Simple grid-based panel detection - // Divide page into 2x2 or 3x3 grid - // This is a simplified implementation - - panels := []Panel{ - { - ID: "panel-1", - X: 0, - Y: 0, - Width: 50, - Height: 100, - ReadingOrder: 1, - }, - { - ID: "panel-2", - X: 50, - Y: 0, - Width: 50, - Height: 100, - ReadingOrder: 2, - }, - } - - return panels, nil -} - -func (s *ReaderService) detectPanelsML( - ctx context.Context, - mediaItemID uuid.UUID, - pageNumber int, -) ([]Panel, error) { - // ML-based panel detection - // This would require a trained model - // For now, fall back to grid detection - return s.detectPanelsGrid(ctx, mediaItemID, pageNumber) -} - -func (s *ReaderService) detectPanelsManual( - ctx context.Context, - mediaItemID uuid.UUID, - pageNumber int, -) ([]Panel, error) { - // Manual panel detection returns existing manually-set panels - // These would be stored in the panel_data table - return []Panel{}, nil -} - -// CalculateReadingSpeed updates reading speed statistics -func (s *ReaderService) CalculateReadingSpeed( - ctx context.Context, - userID uuid.UUID, - mediaItemID uuid.UUID, - pagesRead int, - minutes float64, -) error { - if minutes <= 0 { - return fmt.Errorf("invalid time: must be positive") - } - - pagesPerMinute := float64(pagesRead) / minutes - - // Get or create reading speed record - _, err := s.db.GetReadingSpeed(ctx, database.GetReadingSpeedParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, - }) - - if err != nil { - // Create new record - _, err = s.db.CreateReadingSpeed(ctx, database.CreateReadingSpeedParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, - PagesPerMinute: pgtype.Float8{Float64: pagesPerMinute, Valid: true}, - PagesRead: int32(pagesRead), - TotalReadingMinutes: pgtype.Float8{Float64: minutes, Valid: true}, - LastReadAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, - }) - } else { - // Update existing record with moving average - // This would require an UpdateReadingSpeed query - _ = pagesPerMinute - } - - return err -} - -// LookupWord retrieves dictionary entry for a word -func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error) { - // Check cache first - cached, err := s.db.GetDictionaryEntry(ctx, word) - - if err == nil { - return &DictionaryEntry{ - Word: cached.Word, - Definition: cached.Definition, - PartOfSpeech: cached.PartOfSpeech.String, - Example: cached.Example.String, - Etymology: cached.Etymology.String, - }, nil - } - - // Not in cache, fetch from dictionary API - entry, err := s.fetchDictionaryEntry(ctx, word) - if err != nil { - return nil, err - } - - // Cache the entry - _, _ = s.db.CreateDictionaryEntry(ctx, database.CreateDictionaryEntryParams{ - Word: entry.Word, - Definition: entry.Definition, - PartOfSpeech: pgtype.Text{String: entry.PartOfSpeech, Valid: entry.PartOfSpeech != ""}, - Example: pgtype.Text{String: entry.Example, Valid: entry.Example != ""}, - Etymology: pgtype.Text{String: entry.Etymology, Valid: entry.Etymology != ""}, - }) - - return entry, nil -} - -func (s *ReaderService) fetchDictionaryEntry(ctx context.Context, word string) (*DictionaryEntry, error) { - // Fetch from external dictionary API - // For now, return a placeholder - return &DictionaryEntry{ - Word: word, - Definition: fmt.Sprintf("Definition for %s", word), - }, nil -} - -// GetSettings retrieves reader settings for a user -func (s *ReaderService) GetSettings( - ctx context.Context, - userID uuid.UUID, -) (map[string]interface{}, error) { - // Get settings from database - settings, err := s.db.GetReaderSettings(ctx, pgtype.UUID{Bytes: userID, Valid: true}) - - if err != nil { - // Return default settings - return s.getDefaultSettings(), nil - } - - var result map[string]interface{} - if err := json.Unmarshal(settings.Bytes, &result); err != nil { - return s.getDefaultSettings(), nil - } - - return result, nil -} - -// UpdateSettings updates reader settings for a user -func (s *ReaderService) UpdateSettings( - ctx context.Context, - userID uuid.UUID, - settings map[string]interface{}, -) error { - // Merge with existing settings - existing, err := s.GetSettings(ctx, userID) - if err != nil { - existing = s.getDefaultSettings() - } - - // Merge settings (partial update) - for key, value := range settings { - existing[key] = value - } - - // Serialize and save - settingsJSON, err := json.Marshal(existing) - if err != nil { - return fmt.Errorf("failed to serialize settings: %w", err) - } - - // Update in database - _, err = s.db.UpsertReaderSettings(ctx, database.UpsertReaderSettingsParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - SettingKey: "reader_settings", - SettingValue: string(settingsJSON), - }) - - return err -} - -func (s *ReaderService) getDefaultSettings() map[string]interface{} { - return map[string]interface{}{ - "chrome_behavior": "auto-hide", - "progress_mode": "pages", - "chrome_theme": "tokyo-night", - "reading_theme": "dark", - "reading_font": "literata", - "font_size": 16, - "line_height": 1.6, - "margin_width": 20, - "tap_zone_size": 30, - "auto_scroll": false, - "panel_zoom_enabled": true, - } -} -``` - ---- - -## 4. Frontend Implementation - -### 4.1 File Structure - -``` -web/src/reader/ -├── reader.ts - Main reader entry point -├── reader-shell.ts - UI shell, chrome control -├── progress-indicator.ts - KOReader-style switchable progress -├── settings-manager.ts - Settings (DB + localStorage) -├── slide-in-panel.ts - Shared slide-in panel (TOC + Settings) -├── annotation-manager.ts - Highlights, notes, bookmarks -├── websocket-sync.ts - Reuse existing websocket.ts -├── dictionary-popup.ts - Offline dictionary lookup -│ -├── ebook/ -│ ├── epub-parser.ts - EPUB parsing (ZIP + XML) -│ ├── html-renderer.ts - Browser-native rendering -│ ├── cfi-navigator.ts - EPUB CFI navigation -│ ├── typography-engine.ts - Font rendering, themes -│ └── chapter-detector.ts - Chapter detection -│ -├── comic/ -│ ├── image-parser.ts - CBZ/CBR/PDF parsing -│ ├── canvas-renderer.ts - Canvas rendering -│ ├── panel-detector.ts - Grid + ML + manual -│ ├── panel-navigator.ts - Panel zoom animations -│ └── page-cache.ts - 5-page ahead cache -│ -└── manga/ - ├── rtl-navigator.ts - Right-to-left navigation - └── vertical-scroll.ts - Webtoon-style scroll -``` - -### 4.2 TypeScript Types - -**File:** `web/src/types/reader.d.ts` (new file) - -```typescript -// ============================================================ -// Common Intermediate Format (CIF) Types -// Universal format for all reflowable ebooks after parsing -// ============================================================ - -interface EbookCIF { - // Universal metadata (all formats) - metadata: { - title: string; - author: string; - language: string; - publisher?: string; - isbn?: string; - coverImage?: Blob; - }; - - // Unified navigation structure - toc: TOCNode[]; - - // Content spine (reading order) - spine: SpineItem[]; - - // Resources (CSS, fonts, images) - resources: Map; - - // Progress tracking (minimal - backend handles detailed tracking) - locations: { - totalCharacters: number; - estimatedPages: number; - }; -} - -interface SpineItem { - id: string; - type: 'html' | 'image'; - content: string; - properties?: string[]; - - // Minimal position info for UI - index: number; -} - -interface TOCNode { - id: string; - title: string; - href: string; - children: TOCNode[]; -} - -// ============================================================ -// Parser Types (Procedural, not OOP) -// ============================================================ - -type ParserFormat = 'epub' | 'fb2' | 'txt' | 'html' | 'mobi' | 'azw3' | 'docx' | 'rtf'; - -interface ParserCapabilities { - canParse(mimeType: string, extension: string): boolean; - parse(file: Blob): Promise; - extractMetadata(file: Blob): Promise>; -} - -// ============================================================ -// Reader Metadata (from API) -// ============================================================ - -interface ReaderMetadata { - media_item_id: string; - title: string; - author: string; - cover_image_path: string; - library_type: 'ebook' | 'comic' | 'manga' | 'pdf'; - mime_type: string; - file_path: string; - chapter_metadata?: ChapterMetadata; - total_pages?: number; -} - -// ============================================================ -// Other Shared Types -// ============================================================ - -interface ChapterMetadata { - chapters: Chapter[]; -} - -interface Chapter { - id: string; - title: string; - start_page: number; - page_count: number; -} - -interface PanelData { - media_item_id: string; - page_number: number; - detection_method: 'grid' | 'ml' | 'manual'; - panels: Panel[]; - updated_at: string; -} - -interface Panel { - id: string; - x: number; - y: number; - width: number; - height: number; - reading_order: number; -} - -interface ReadingSpeed { - words_per_minute: number; - pages_per_minute: number; - pages_read: number; - total_reading_minutes: number; - last_read_at: string; -} - -interface DictionaryEntry { - word: string; - definition: string; - part_of_speech?: string; - example?: string; - etymology?: string; -} - -interface ReaderSettings { - chrome_behavior: 'auto-hide' | 'always-visible' | 'hide-on-scroll'; - progress_mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; - - chrome_theme: string; - reading_theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; - - reading_font: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; - font_size: number; - line_height: number; - margin_width: number; - - tap_zone_size: number; - auto_scroll: boolean; - panel_zoom_enabled: boolean; - - double_page_spread: boolean; - reading_direction: 'ltr' | 'rtl' | 'vertical'; - - pdf_fit_mode: 'fit-width' | 'fit-page' | 'fit-height' | 'none'; - pdf_zoom_level: number; - pdf_text_layer_enabled: boolean; - pdf_dual_page_mode: 'auto' | 'single' | 'dual'; - pdf_dual_page_threshold: number; - pdf_minimap_enabled: boolean; - pdf_outline_visible: boolean; - media_bookmarks_visible: boolean; - - hardware_acceleration: boolean; - - // Dockable panel layout configuration (per-user, media-type-aware) - panel_layout: PanelLayoutSettings; -} - -interface PanelLayoutSettings { - // Per panel state: which side, visible, collapsed (window-shade), width - toc: PanelState; - settings: PanelState; - navigator: PanelState; - bookmarks: PanelState; - - // Mobile override - mobile_nav_visible: boolean; -} - -interface PanelState { - side: 'left' | 'right' | 'hidden'; - visible: boolean; - collapsed: boolean; // Window-shade: true = collapsed to title bar - width_px: number; // Panel width in pixels - order: number; // Order within the side - locked: boolean; // Lock toggle: prevents accidental drag/move - last_valid_side: 'left' | 'right' | 'hidden'; // Snap-back target if dropped in invalid area -} - -interface ProgressDisplay { - mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; - current: number; - total: number; - label?: string; - time_left?: string; -} -``` - -### 4.3 Universal Reader Shell (Procedural) - -**File:** `web/src/reader/reader-shell.ts` - -```typescript -// 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'; - -// ============================================================ -// Reader State -// ============================================================ - -let currentReader: UniversalReader | PDFReader | ComicReader | MangaReader | null = null; -let readerMetadata: ReaderMetadata | null = null; - -interface UniversalReader { - type: 'ebook'; - cif: EbookCIF; - 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'; -} - -// ============================================================ -// 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); - break; - case 'pdf': - currentReader = await initializePDFReader(readerMetadata); - break; - case 'comic': - currentReader = await initializeComicReader(readerMetadata); - break; - case 'manga': - currentReader = await initializeMangaReader(readerMetadata); - break; - } - - if (currentReader) { - setupReaderUI(); - } -} - -async function initializeEbookReader(metadata: ReaderMetadata): Promise { - // Check if server-side parsing is needed - const needsServer = requiresServerParsing(metadata.mime_type, getFileExtension(metadata.file_path)); - - 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' }, - 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 { - // 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, getFileExtension(metadata.file_path)); - - return { - type: 'ebook', - cif, - currentSpineIndex: 0, - }; -} - -// ============================================================ -// UI Setup -// ============================================================ - -function setupReaderUI(): void { - if (!currentReader || !readerMetadata) return; - - // Setup chrome - setupChromeBehavior(); - - // Setup progress indicator - setupProgressIndicator(); - - // Setup annotations - setupAnnotations(); - - // Setup keyboard navigation - setupKeyboardNavigation(); -} - -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'); - }); -} - -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); - } -} - -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, - }); -} - -// ============================================================ -// Alpine.js Integration -// ============================================================ - -Alpine.data('readerShell', () => ({ - init() { - initializeReader(); - }, - - nextPage, - previousPage, - - get currentPage() { - if (!currentReader) return 0; - - if (currentReader.type === 'ebook') { - return currentReader.currentSpineIndex + 1; - } else { - return currentReader.currentPage; - } - }, - - get totalPages() { - if (!currentReader || !readerMetadata) return 0; - - if (currentReader.type === 'ebook') { - return currentReader.cif.spine.length; - } else if (currentReader.type === 'pdf') { - return readerMetadata.total_pages || 0; - } else { - return currentReader.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 -} -``` - ---- - -### 4.4 Server-Side Parsers (Go Backend) - -**PHASE 1 SCOPE:** Stub only - returns "not implemented" for complex formats - -**File:** `internal/handlers/reader.go` (add ParseEbook method) - -```go -package handlers - -import ( - "bookhoard/internal/database" - "bookhoard/internal/services" - "fmt" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/labstack/echo/v5" - "github.com/google/uuid" -) - -// ParseEbook handles server-side parsing for complex ebook formats -// **PHASE 1:** Returns 501 "not implemented" for MOBI/AZW3/DOCX/RTF -// **PHASE 2.5:** Implement actual parsing logic -// -// **NOTE:** This method is NOT registered as a route in Phase 1 -// Route registration happens in Phase 2.5 -func (h *ReaderHandler) ParseEbook(c echo.Context) error { - mediaItemID := c.Param("mediaItemId") - parsedUUID, err := uuid.Parse(mediaItemID) - if err != nil { - return c.JSON(400, map[string]string{"error": "Invalid media item ID"}) - } - - // Fetch media item - mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) - if err != nil { - if err == pgx.ErrNoRows { - return c.JSON(404, map[string]string{"error": "Media item not found"}) - } - return c.JSON(500, map[string]string{"error": "Failed to fetch media item"}) - } - - // Check if format requires server-side parsing - requiresServerParsing := false - formatName := "" - switch mediaItem.MimeType.String { - case "application/x-mobipocket-ebook": - requiresServerParsing = true - formatName = "MOBI" - case "application/vnd.amazon.mobi8-ebook": - requiresServerParsing = true - formatName = "AZW3/KF8" - case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - requiresServerParsing = true - formatName = "DOCX" - case "application/rtf": - requiresServerParsing = true - formatName = "RTF" - } - - if requiresServerParsing { - // Phase 1: Return 501 for server-side formats - return c.JSON(501, map[string]interface{}{ - "error": fmt.Sprintf("Server-side %s parsing is not yet implemented", formatName), - "message": fmt.Sprintf("%s format support is planned for Phase 2.5", formatName), - "suggestion": "Please convert your ebook to EPUB format for now", - "format": formatName, - "phase": "2.5", - }) - } - - // Client-side format - should not call this endpoint - return c.JSON(400, map[string]string{ - "error": "This format should be parsed client-side, not on the server", - }) -} -``` - -**PHASE 1:** That's it - just the stub above. No parser implementations needed. - -**PHASE 2.5:** See Section 14.5 for complete parser implementations (parseMOBI, parseAZW3, parseDOCX, parseRTF) - ---- - -### 4.5 Progress Indicator (KOReader-style) - -**File:** `web/src/reader/progress-indicator.ts` - -```typescript -// KOReader-style switchable progress indicator - -import { Alpine } from "../alpine"; -import { getReadingSpeed } from "./api"; - -interface ProgressDisplay { - mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; - text: string; -} - -function calculateProgress( - currentPage: number, - totalPages: number, - currentChapterPage: number, - chapterPages: number, - readingSpeed?: ReadingSpeed -): ProgressDisplay { - const mode = getCurrentProgressMode(); // From settings - - switch (mode) { - case 'pages': - return { - mode: 'pages', - text: `${currentPage}/${totalPages}` - }; - - case 'chapter': - return { - mode: 'chapter', - text: `${currentChapterPage}/${chapterPages}` - }; - - case 'percentage': - const percentage = Math.round((currentPage / totalPages) * 100); - return { - mode: 'percentage', - text: `${percentage}%` - }; - - case 'time-left': - if (!readingSpeed) { - return { mode: 'time-left', text: '--:--' }; - } - const pagesLeft = totalPages - currentPage; - const minutesLeft = pagesLeft / readingSpeed.pages_per_minute; - const hours = Math.floor(minutesLeft / 60); - const mins = Math.round(minutesLeft % 60); - return { - mode: 'time-left', - text: `${hours}h ${mins}m` - }; - } -} - -function cycleProgressMode(): void { - const modes: Array<'pages' | 'chapter' | 'percentage' | 'time-left'> = - ['pages', 'chapter', 'percentage', 'time-left']; - const currentMode = getCurrentProgressMode(); - const currentIndex = modes.indexOf(currentMode); - const nextMode = modes[(currentIndex + 1) % modes.length]; - setProgressMode(nextMode); -} -``` - -### 4.6 Settings Manager (DB + localStorage) - -**File:** `web/src/reader/settings-manager.ts` - -```typescript -// Per-user settings with localStorage fallback - -import { apiGet, apiPut } from "../api"; -import { getToken, setItem, getItem } from "../storage"; - -const SETTINGS_KEY = 'reader_settings'; -const LOCALSTORAGE_KEY = 'reader_settings_local'; - -interface SettingsManager { - load(): Promise; - save(settings: Partial): Promise; - sync(): Promise; // Sync localStorage → DB - get(key: keyof ReaderSettings): any; - set(key: keyof ReaderSettings, value: any): Promise; -} - -async function loadSettings(): Promise { - const token = getToken(); - if (!token) { - // Fallback to localStorage - const local = getItem(LOCALSTORAGE_KEY); - return local ? JSON.parse(local) : getDefaultSettings(); - } - - try { - const response = await apiGet('/readers/settings'); - const settings = await response.json(); - // Cache in localStorage - setItem(LOCALSTORAGE_KEY, JSON.stringify(settings)); - return settings; - } catch (error) { - // Fallback to localStorage on error - const local = getItem(LOCALSTORAGE_KEY); - return local ? JSON.parse(local) : getDefaultSettings(); - } -} - -async function saveSettings(settings: Partial): Promise { - const token = getToken(); - if (!token) { - // Save to localStorage only - const current = loadSettings(); - const updated = { ...current, ...settings }; - setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); - return; - } - - try { - await apiPut('/readers/settings', settings); - // Update localStorage cache - const current = loadSettings(); - const updated = { ...current, ...settings }; - setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); - } catch (error) { - // Fallback to localStorage - const current = loadSettings(); - const updated = { ...current, ...settings }; - setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); - } -} - -function getDefaultSettings(): ReaderSettings { - return { - chrome_behavior: 'auto-hide', - progress_mode: 'pages', - chrome_theme: 'tokyo-night', // UI chrome: All 11 themes available - reading_theme: 'dark', // Ebook text: 5 reading-optimized themes - reading_font: 'literata', // Default reading font (designed for ebooks) - tap_zone_size: 30, - auto_scroll: false, - panel_zoom_enabled: true, - font_size: 16, - line_height: 1.6, - margin_width: 20, - double_page_spread: false, - reading_direction: 'ltr', - hardware_acceleration: true, - - // Dockable panel defaults by media type - panel_layout: { - toc: { side: 'left', visible: true, collapsed: false, width_px: 320, order: 1, locked: false, last_valid_side: 'left' }, - settings: { side: 'left', visible: false, collapsed: true, width_px: 380, order: 2, locked: false, last_valid_side: 'left' }, - navigator: { side: 'right', visible: true, collapsed: false, width_px: 200, order: 1, locked: false, last_valid_side: 'right' }, - bookmarks: { side: 'right', visible: false, collapsed: true, width_px: 280, order: 2, locked: false, last_valid_side: 'right' }, - mobile_nav_visible: false - } - }; -} -``` - -### 4.7 Panel Dock System (Modular Dockable Panels) - -**File:** `web/src/reader/panel-dock-system.ts` - -```typescript -// Modular dockable panel system - handles drag, lock, snap-back, window-shade -// Procedural style: Functions, not classes - -import { saveSettings, loadSettings, getDefaultSettings } from "./settings-manager"; - -interface PanelDockState { - panels: Map; - dragState: DragState | null; - dockZones: DockZone[]; -} - -interface DragState { - panelId: string; - startX: number; - startY: number; - currentX: number; - currentY: number; - isLocked: boolean; -} - -interface DockZone { - side: 'left' | 'right'; - x: number; - width: number; - height: number; -} - -const state: PanelDockState = { - panels: new Map(), - dragState: null, - dockZones: [ - { side: 'left', x: 0, width: 400, height: window.innerHeight }, - { side: 'right', x: window.innerWidth - 400, width: 400, height: window.innerHeight } - ] -}; - -// Initialize all panels from settings -function initializePanelDockSystem(): void { - const settings = loadSettings(); - - for (const [panelId, panelState] of Object.entries(settings.panel_layout)) { - registerPanel(panelId, panelState); - } - - setupDragHandlers(); - setupWindowShadeHandlers(); - setupLockHandlers(); -} - -// Register a panel with the dock system -function registerPanel(panelId: string, panelState: PanelState): void { - state.panels.set(panelId, panelState); - applyPanelState(panelId, panelState); -} - -// Apply panel state to DOM -function applyPanelState(panelId: string, panelState: PanelState): void { - const panel = document.querySelector(`[data-panel="${panelId}"]`); - if (!panel) return; - - const container = panel.parentElement; - if (!container) return; - - // Apply side positioning - if (panelState.side === 'left') { - container.style.left = '0'; - container.style.right = 'auto'; - } else if (panelState.side === 'right') { - container.style.right = '0'; - container.style.left = 'auto'; - } else { - container.style.left = '-9999px'; - } - - // Apply width - panel.style.width = `${panelState.width_px}px`; - - // Apply collapsed (window-shade) state - if (panelState.collapsed) { - panel.classList.add('panel-collapsed'); - panel.querySelector('.panel-content')?.classList.add('hidden'); - } else { - panel.classList.remove('panel-collapsed'); - panel.querySelector('.panel-content')?.classList.remove('hidden'); - } - - // Apply lock state - const lockBtn = panel.querySelector('.panel-lock'); - if (lockBtn) { - lockBtn.textContent = panelState.locked ? '🔒' : '🔓'; - } -} - -// Setup mouse/touch drag handlers -function setupDragHandlers(): void { - document.querySelectorAll('.dockable-panel .panel-header').forEach(header => { - header.addEventListener('mousedown', handleDragStart); - header.addEventListener('touchstart', handleDragStart, { passive: false }); - }); - - document.addEventListener('mousemove', handleDragMove); - document.addEventListener('touchmove', handleDragMove, { passive: false }); - document.addEventListener('mouseup', handleDragEnd); - document.addEventListener('touchend', handleDragEnd); -} - -function handleDragStart(e: MouseEvent | TouchEvent): void { - const header = e.target.closest('.panel-header') as HTMLElement; - const panel = header?.closest('.dockable-panel') as HTMLElement; - if (!panel) return; - - const panelId = panel.dataset.panel; - const panelState = state.panels.get(panelId); - - // Check if locked - if (panelState?.locked) return; - - const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; - const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; - - state.dragState = { - panelId: panelId!, - startX: clientX, - startY: clientY, - currentX: clientX, - currentY: clientY, - isLocked: panelState?.locked || false - }; - - panel.classList.add('dragging'); -} - -function handleDragMove(e: MouseEvent | TouchEvent): void { - if (!state.dragState) return; - - const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; - const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; - - state.dragState.currentX = clientX; - state.dragState.currentY = clientY; - - const panel = document.querySelector(`[data-panel="${state.dragState.panelId}"]`); - const container = panel?.parentElement; - if (container) { - container.style.transform = `translateX(${clientX - state.dragState.startX}px)`; - } -} - -function handleDragEnd(e: MouseEvent | TouchEvent): void { - if (!state.dragState) return; - - const { panelId, currentX } = state.dragState; - const panel = document.querySelector(`[data-panel="${panelId}"]`); - const container = panel?.parentElement; - - // Reset transform - container.style.transform = ''; - panel?.classList.remove('dragging'); - - // Determine drop zone - const newSide = currentX < window.innerWidth / 2 ? 'left' : 'right'; - const panelState = state.panels.get(panelId); - - if (panelState) { - // Check if dropped in valid zone - const isValidDrop = newSide === 'left' || newSide === 'right'; - - if (isValidDrop) { - panelState.last_valid_side = panelState.side; // Save previous valid position - panelState.side = newSide; - } else { - // Snap back to last valid position - panelState.side = panelState.last_valid_side; - } - - applyPanelState(panelId, panelState); - savePanelState(panelId, panelState); - } - - state.dragState = null; -} - -// Setup window-shade (collapse/expand) handlers -function setupWindowShadeHandlers(): void { - document.querySelectorAll('.window-shade-toggle').forEach(btn => { - btn.addEventListener('click', (e) => { - const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement; - const panelId = panel.dataset.panel; - const panelState = state.panels.get(panelId); - - if (panelState) { - panelState.collapsed = !panelState.collapsed; - applyPanelState(panelId, panelState); - savePanelState(panelId, panelState); - } - }); - }); -} - -// Setup lock toggle handlers -function setupLockHandlers(): void { - document.querySelectorAll('.panel-lock').forEach(btn => { - btn.addEventListener('click', (e) => { - const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement; - const panelId = panel.dataset.panel; - const panelState = state.panels.get(panelId); - - if (panelState) { - panelState.locked = !panelState.locked; - applyPanelState(panelId, panelState); - savePanelState(panelId, panelState); - } - }); - }); -} - -// Persist panel state to settings -async function savePanelState(panelId: string, panelState: PanelState): Promise { - const settings = loadSettings(); - settings.panel_layout[panelId as keyof typeof settings.panel_layout] = panelState; - await saveSettings(settings); -} - -export { initializePanelDockSystem, registerPanel, applyPanelState }; -``` - ---- - -## 5. Ebook Reader Implementation - -### 5.1 Parser Manager (Procedural) - -**File:** `web/src/reader/parser-manager.ts` - -```typescript -// 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(); -} -``` - ---- - -### 5.2 EPUB Parser (Refactored - Procedural) - -**File:** `web/src/reader/parsers/epub-parser.ts` - -```typescript -// EPUB Parser - Converts EPUB 2/3 to Common Intermediate Format -// Procedural style: Functions, not classes - -import JSZip from 'jszip'; - -// ============================================================ -// Main Parse Function -// ============================================================ - -export async function parseEPUB(epubBlob: Blob): Promise { - const zip = await JSZip.loadAsync(epubBlob); - - // Parse container.xml to find OPF file - const containerXml = await getZipFileContent(zip, 'META-INF/container.xml'); - const opfPath = extractOPFPath(containerXml); - - if (!opfPath) { - throw new Error('Invalid EPUB: no OPF file found'); - } - - // Parse OPF file - const opfXml = await getZipFileContent(zip, opfPath); - const packageDoc = parseXML(opfXml); - - // Extract all components - const metadata = extractMetadata(packageDoc); - const spine = parseSpine(packageDoc); - const toc = await parseTOC(zip, packageDoc, opfPath); - const resources = await loadResources(zip); - const coverImage = await extractCover(zip, packageDoc); - - // Calculate locations (minimal - backend handles detailed tracking) - const totalCharacters = await calculateTotalCharacters(spine, resources); - - return { - metadata, - toc, - spine, - resources, - locations: { - totalCharacters, - estimatedPages: Math.ceil(totalCharacters / 1500), - }, - }; -} - -// ============================================================ -// Helper Functions -// ============================================================ - -async function getZipFileContent(zip: JSZip, path: string): Promise { - const file = zip.file(path); - if (!file) { - throw new Error(`File not found: ${path}`); - } - return await file.async('text'); -} - -function parseXML(xmlString: string): XMLDocument { - const parser = new DOMParser(); - return parser.parseFromString(xmlString, 'text/xml'); -} - -function extractOPFPath(containerXml: string): string | null { - const containerDoc = parseXML(containerXml); - return containerDoc.querySelector('rootfile')?.getAttribute('full-path') || null; -} - -function extractMetadata(packageDoc: XMLDocument): EbookCIF['metadata'] { - const metadata = packageDoc.querySelector('metadata'); - if (!metadata) { - throw new Error('No metadata found in OPF'); - } - - return { - title: metadata.querySelector('title')?.textContent || '', - author: metadata.querySelector('creator')?.textContent || '', - language: metadata.querySelector('language')?.textContent || 'en', - publisher: metadata.querySelector('publisher')?.textContent || undefined, - isbn: metadata.querySelector('identifier')?.textContent || undefined, - }; -} - -function parseSpine(packageDoc: XMLDocument): EbookCIF['spine'] { - const spine = packageDoc.querySelector('spine'); - const manifest = packageDoc.querySelector('manifest'); - - if (!spine || !manifest) { - throw new Error('No spine or manifest found in OPF'); - } - - const spineItems = spine.querySelectorAll('itemref'); - const result: EbookCIF['spine'] = []; - - spineItems.forEach((itemref) => { - const idref = itemref.getAttribute('idref'); - if (!idref) return; - - const manifestItem = manifest.querySelector(`[id="${idref}"]`); - if (!manifestItem) return; - - const href = manifestItem.getAttribute('href'); - if (!href) return; - - result.push({ - id: idref, - type: 'html', - content: href, - properties: itemref.getAttribute('properties') || undefined, - }); - }); - - return result; -} - -async function parseTOC(zip: JSZip, packageDoc: XMLDocument, opfPath: string): Promise { - // Try EPUB 3.0 navigation document first - const navItem = packageDoc.querySelector('manifest item[properties~="nav"]'); - if (navItem) { - const navHref = navItem.getAttribute('href'); - if (navHref) { - const navPath = resolvePath(opfPath, navHref); - return parseNavTOC(zip, navPath); - } - } - - // Fallback to EPUB 2.0 NCX - const ncxId = spine?.getAttribute('toc'); - if (ncxId) { - const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`); - if (ncxItem) { - const ncxHref = ncxItem.getAttribute('href'); - if (ncxHref) { - const ncxPath = resolvePath(opfPath, ncxHref); - return parseNCXTOC(zip, ncxPath); - } - } - } - - return []; -} - -async function parseNavTOC(zip: JSZip, navPath: string): Promise { - const navXml = await getZipFileContent(zip, navPath); - const navDoc = parseXML(navXml); - const nav = navDoc.querySelector('nav'); - - if (!nav) return []; - - const ol = nav.querySelector('ol'); - if (!ol) return []; - - const items = ol.querySelectorAll(':scope > li'); - const result: EbookCIF['toc'] = []; - - for (const li of items) { - const link = li.querySelector('a'); - if (link) { - result.push({ - id: link.getAttribute('href') || '', - title: link.textContent || '', - href: link.getAttribute('href') || '', - children: [], - }); - } - } - - return result; -} - -async function parseNCXTOC(zip: JSZip, ncxPath: string): Promise { - const ncxXml = await getZipFileContent(zip, ncxPath); - const ncxDoc = parseXML(ncxXml); - const navMap = ncxDoc.querySelector('navMap'); - - if (!navMap) return []; - - return parseNCXNode(navMap); -} - -function parseNCXNode(node: Element): EbookCIF['toc'] { - const navPoints = node.querySelectorAll(':scope > navPoint'); - const result: EbookCIF['toc'] = []; - - navPoints.forEach((navPoint) => { - const label = navPoint.querySelector('navLabel text')?.textContent || ''; - const content = navPoint.querySelector('content'); - const href = content?.getAttribute('src') || ''; - - result.push({ - id: href, - title: label, - href, - children: parseNCXNode(navPoint), - }); - }); - - return result; -} - -async function loadResources(zip: JSZip): Promise> { - const resources = new Map(); - const files = Object.keys(zip.files); - - for (const path of files) { - const file = zip.file(path); - if (file && !file.dir) { - const blob = await file.async('blob'); - resources.set(path, blob); - } - } - - return resources; -} - -async function extractCover(zip: JSZip, packageDoc: XMLDocument): Promise { - // Try cover-id metadata - const coverId = packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content'); - if (coverId) { - const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`); - if (coverItem) { - const coverHref = coverItem.getAttribute('href'); - if (coverHref) { - const coverFile = zip.file(coverHref); - if (coverFile) { - return await coverFile.async('blob'); - } - } - } - } - - // Fallback: look for cover image in manifest - const coverItem = packageDoc.querySelector('manifest item[properties~="cover-image"]'); - if (coverItem) { - const coverHref = coverItem.getAttribute('href'); - if (coverHref) { - const coverFile = zip.file(coverHref); - if (coverFile) { - return await coverFile.async('blob'); - } - } - } - - return undefined; -} - -function resolvePath(basePath: string, relativePath: string): string { - const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1); - return baseDir + relativePath; -} - -async function calculateTotalCharacters(spine: EbookCIF['spine'], resources: Map): Promise { - let total = 0; - - for (const item of spine) { - if (item.type === 'html') { - const content = resources.get(item.content); - if (content) { - const text = await content.text(); - total += text.length; - } - } - } - - return total; -} - -function resolvePath(basePath: string, relativePath: string): string { - const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1); - return baseDir + relativePath; -} - } - } - - return total; -} - -function generatePageBreaks(totalCharacters: number): number[] { - const breaks: number[] = []; - const charsPerPage = 1000; // Rough estimate - - for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) { - breaks.push(i); - } - - return breaks; -} - -// ============================================================ -// Metadata Quick Extract (for library view) -// ============================================================ - -export async function extractEPUBMetadata(epubBlob: Blob): Promise> { - const zip = await JSZip.loadAsync(epubBlob); - - const containerXml = await getZipFileContent(zip, 'META-INF/container.xml'); - const opfPath = extractOPFPath(containerXml); - - if (!opfPath) { - return {}; - } - - const opfXml = await getZipFileContent(zip, opfPath); - const packageDoc = parseXML(opfXml); - - return extractMetadata(packageDoc); -} -``` - ---- - -### 5.3 FictionBook 2 (FB2) Parser - -**File:** `web/src/reader/parsers/fb2-parser.ts` - -```typescript -// FB2 Parser - Converts FictionBook 2 to Common Intermediate Format -// FB2 is XML-based, similar to EPUB structure -// Procedural style: Functions, not classes - -import JSZip from 'jszip'; - -// ============================================================ -// Main Parse Function -// ============================================================ - -export async function parseFB2(fb2Blob: Blob): Promise { - // FB2 can be plain XML or zipped (.fb2.zip) - let xmlContent: string; - - if (fb2Blob.type === 'application/zip' || fb2Blob.type === 'application/x-zip-compressed') { - const zip = await JSZip.loadAsync(fb2Blob); - const files = Object.keys(zip.files); - - // Find the first .fb2 file in the zip - const fb2File = files.find(f => f.endsWith('.fb2')); - if (!fb2File) { - throw new Error('No .fb2 file found in archive'); - } - - xmlContent = await zip.file(fb2File)!.async('text'); - } else { - xmlContent = await fb2Blob.text(); - } - - const xmlDoc = parseXML(xmlContent); - - const metadata = extractFB2Metadata(xmlDoc); - const toc = parseFB2TOC(xmlDoc); - const spine = createFB2Spine(xmlDoc); - const resources = await extractFB2Resources(xmlDoc, fb2Blob); - - // Calculate locations (minimal - backend handles detailed tracking) - const totalCharacters = calculateFB2Characters(xmlDoc); - - return { - metadata, - toc, - spine, - resources, - locations: { - totalCharacters, - estimatedPages: Math.ceil(totalCharacters / 1500), - }, - }; -} - -// ============================================================ -// Helper Functions -// ============================================================ - -function parseXML(xmlString: string): XMLDocument { - const parser = new DOMParser(); - return parser.parseFromString(xmlString, 'text/xml'); -} - -function extractFB2Metadata(xmlDoc: XMLDocument): EbookCIF['metadata'] { - const titleInfo = xmlDoc.querySelector('title-info'); - const documentInfo = xmlDoc.querySelector('document-info'); - - if (!titleInfo) { - throw new Error('Invalid FB2: no title-info found'); - } - - return { - title: titleInfo.querySelector('book-title')?.textContent || '', - author: extractFB2Author(titleInfo), - language: titleInfo.querySelector('lang')?.textContent || 'en', - publisher: documentInfo?.querySelector('publisher')?.textContent || undefined, - isbn: undefined, // FB2 doesn't typically have ISBN - }; -} - -function extractFB2Author(titleInfo: Element): string { - const author = titleInfo.querySelector('author'); - if (!author) return ''; - - const firstName = author.querySelector('first-name')?.textContent || ''; - const lastName = author.querySelector('last-name')?.textContent || ''; - const middleName = author.querySelector('middle-name')?.textContent || ''; - - const parts = [firstName, middleName, lastName].filter(Boolean); - return parts.join(' ') || 'Unknown'; -} - -function parseFB2TOC(xmlDoc: XMLDocument): EbookCIF['toc'] { - const toc: EbookCIF['toc'] = []; - const body = xmlDoc.querySelector('body'); - - if (!body) return toc; - - const sections = body.querySelectorAll(':scope > section'); - let sectionIndex = 0; - - for (const section of sections) { - const title = section.querySelector('title'); - const titleText = title?.textContent.trim() || `Section ${sectionIndex + 1}`; - - toc.push({ - id: `section-${sectionIndex}`, - title: titleText, - href: `#section-${sectionIndex}`, - children: [], - }); - - sectionIndex++; - } - - return toc; -} - -function createFB2Spine(xmlDoc: XMLDocument): EbookCIF['spine'] { - const spine: EbookCIF['spine'] = []; - const body = xmlDoc.querySelector('body'); - - if (!body) return spine; - - // Convert each section to HTML - const sections = body.querySelectorAll(':scope > section'); - - sections.forEach((section, index) => { - const htmlContent = convertFB2SectionToHTML(section, index); - - spine.push({ - id: `section-${index}`, - type: 'html', - content: htmlContent, - index, - }); - }); - - return spine; -} - -function convertFB2SectionToHTML(section: Element, index: number): string { - const title = section.querySelector('title'); - let html = `
`; - - if (title) { - html += `

${title.textContent}

`; - } - - // Convert paragraphs - const paragraphs = section.querySelectorAll('p'); - paragraphs.forEach(p => { - html += `

${p.innerHTML}

`; - }); - - // Convert images - const images = section.querySelectorAll('image'); - images.forEach(img => { - const href = img.getAttribute('l:href'); - const alt = img.getAttribute('alt') || ''; - if (href) { - html += `${alt}`; - } - }); - - html += '
'; - - return html; -} - -async function extractFB2Resources(xmlDoc: XMLDocument, fb2Blob: Blob): Promise> { - const resources = new Map(); - - // FB2 can have embedded images (base64) or external references - const binary = xmlDoc.querySelector('binary'); - if (binary) { - const contentType = binary.getAttribute('content-type'); - const id = binary.getAttribute('id'); - - if (contentType && id && binary.textContent) { - // Decode base64 - const base64Data = binary.textContent.trim(); - const byteString = atob(base64Data); - const byteArray = new Uint8Array(byteString.length); - - for (let i = 0; i < byteString.length; i++) { - byteArray[i] = byteString.charCodeAt(i); - } - - const blob = new Blob([byteArray], { type: contentType }); - resources.set(`#${id}`, blob); - } - } - - return resources; -} - -function calculateFB2Characters(xmlDoc: XMLDocument): number { - const body = xmlDoc.querySelector('body'); - if (!body) return 0; - - return body.textContent?.length || 0; -} - -function generatePageBreaks(totalCharacters: number): number[] { - const breaks: number[] = []; - const charsPerPage = 1000; - - for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) { - breaks.push(i); - } - - return breaks; -} - -// ============================================================ -// Metadata Quick Extract -// ============================================================ - -export async function extractFB2Metadata(fb2Blob: Blob): Promise> { - let xmlContent: string; - - if (fb2Blob.type === 'application/zip') { - const zip = await JSZip.loadAsync(fb2Blob); - const files = Object.keys(zip.files); - const fb2File = files.find(f => f.endsWith('.fb2')); - - if (!fb2File) return {}; - - xmlContent = await zip.file(fb2File)!.async('text'); - } else { - xmlContent = await fb2Blob.text(); - } - - const xmlDoc = parseXML(xmlContent); - return extractFB2Metadata(xmlDoc); -} -``` - ---- - -### 5.4 Plain Text (TXT) Parser - -**File:** `web/src/reader/parsers/txt-parser.ts` - -```typescript -// TXT Parser - Wraps plain text in HTML structure -// Procedural style: Functions, not classes - -// ============================================================ -// Main Parse Function -// ============================================================ - -export async function parseTXT(txtBlob: Blob): Promise { - const textContent = await txtBlob.text(); - - const metadata = extractTXTMetadata(txtBlob); - const toc = createTXTTOC(textContent); - const spine = createTXTSpine(textContent); - const resources = new Map(); // No external resources for plain text - - const totalCharacters = textContent.length; - - return { - metadata, - toc, - spine, - resources, - locations: { - totalCharacters, - estimatedPages: Math.ceil(totalCharacters / 1500), - }, - }; -} - -// ============================================================ -// Helper Functions -// ============================================================ - -function extractTXTMetadata(txtBlob: Blob): EbookCIF['metadata'] { - const filename = txtBlob.name || 'Unknown'; - - return { - title: filename.replace(/\.(txt|text)$/i, ''), - author: 'Unknown', - language: 'en', - }; -} - -function createTXTTOC(textContent: string): EbookCIF['toc'] { - // Try to detect chapters (simple heuristic) - const toc: EbookCIF['toc'] = []; - const lines = textContent.split('\n'); - - let chapterIndex = 0; - - lines.forEach((line, index) => { - // Common chapter patterns - const chapterPattern = /^(chapter|part|section)\s+\d+/i; - if (chapterPattern.test(line.trim())) { - toc.push({ - id: `chapter-${chapterIndex}`, - title: line.trim(), - href: `#chapter-${chapterIndex}`, - children: [], - }); - - chapterIndex++; - } - }); - - // If no chapters found, create single entry - if (toc.length === 0) { - toc.push({ - id: 'full-text', - title: 'Full Text', - href: '#full-text', - children: [], - }); - } - - return toc; -} - -function createTXTSpine(textContent: string): EbookCIF['spine'] { - // Convert plain text to HTML paragraphs - const lines = textContent.split('\n'); - let htmlContent = '
'; - - lines.forEach(line => { - const trimmed = line.trim(); - if (trimmed) { - htmlContent += `

${escapeHTML(trimmed)}

`; - } else { - htmlContent += '
'; - } - }); - - htmlContent += '
'; - - return [{ - id: 'full-text', - type: 'html', - content: htmlContent, - index: 0, - }]; -} - -function escapeHTML(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// Removed - backend handles detailed position tracking - -// ============================================================ -// Metadata Quick Extract -// ============================================================ - -export async function extractTXTMetadata(txtBlob: Blob): Promise> { - return extractTXTMetadata(txtBlob); -} -``` - ---- - -### 5.5 HTML Parser - -**File:** `web/src/reader/parsers/html-parser.ts` - -```typescript -// HTML Parser - Wraps standalone HTML files -// Procedural style: Functions, not classes - -// ============================================================ -// Main Parse Function -// ============================================================ - -export async function parseHTML(htmlBlob: Blob): Promise { - const htmlContent = await htmlBlob.text(); - - const metadata = extractHTMLMetadata(htmlBlob, htmlContent); - const toc = createHTMLTOC(htmlContent); - const spine = createHTMLSpine(htmlContent); - const resources = await extractHTMLResources(htmlBlob, htmlContent); - - const totalCharacters = stripHTML(htmlContent).length; - const pageBreaks = generatePageBreaks(totalCharacters); - - return { - metadata, - toc, - spine, - resources, - locations: { - totalCharacters, - pageBreaks, - }, - }; -} - -// ============================================================ -// Helper Functions -// ============================================================ - -function extractHTMLMetadata(htmlBlob: Blob, htmlContent: string): EbookCIF['metadata'] { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlContent, 'text/html'); - - const title = doc.querySelector('title')?.textContent || - htmlBlob.name.replace(/\.(html?|htm)$/i, ''); - - const metaAuthor = doc.querySelector('meta[name="author"]')?.getAttribute('content'); - const metaLang = doc.querySelector('html')?.getAttribute('lang') || 'en'; - - return { - title, - author: metaAuthor || 'Unknown', - language: metaLang, - }; -} - -function createHTMLTOC(htmlContent: string): EbookCIF['toc'] { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlContent, 'text/html'); - - const toc: EbookCIF['toc'] = []; - - // Try to find headings - const headings = doc.querySelectorAll('h1, h2, h3'); - let headingIndex = 0; - - headings.forEach(heading => { - toc.push({ - id: `heading-${headingIndex}`, - title: heading.textContent || '', - href: `#${heading.id || `heading-${headingIndex}`}`, - children: [], - }); - - headingIndex++; - }); - - // If no headings, create single entry - if (toc.length === 0) { - toc.push({ - id: 'full-document', - title: 'Full Document', - href: '#full-document', - children: [], - }); - } - - return toc; -} - -function createHTMLSpine(htmlContent: string): EbookCIF['spine'] { - return [{ - id: 'full-document', - type: 'html', - content: htmlContent, - index: 0, - }]; -} - -async function extractHTMLResources(htmlBlob: Blob, htmlContent: string): Promise> { - const resources = new Map(); - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlContent, 'text/html'); - - // Extract images - const images = doc.querySelectorAll('img[src]'); - - for (const img of Array.from(images)) { - const src = img.getAttribute('src'); - if (!src) continue; - - // Try to resolve relative URLs - if (src.startsWith('data:')) { - // Data URI - extract blob - const match = src.match(/^data:([^;]+);base64,(.+)$/); - if (match) { - const mimeType = match[1]; - const base64 = match[2]; - const byteString = atob(base64); - const byteArray = new Uint8Array(byteString.length); - - for (let i = 0; i < byteString.length; i++) { - byteArray[i] = byteString.charCodeAt(i); - } - - const blob = new Blob([byteArray], { type: mimeType }); - resources.set(src, blob); - } - } - // External resources would need to be fetched - // For now, skip them (browser will load them naturally) - } - - return resources; -} - -function stripHTML(html: string): string { - const div = document.createElement('div'); - div.innerHTML = html; - return div.textContent || ''; -} - -// ============================================================ -// Metadata Quick Extract -// ============================================================ - -export async function extractHTMLMetadata(htmlBlob: Blob): Promise> { - const htmlContent = await htmlBlob.text(); - return extractHTMLMetadata(htmlBlob, htmlContent); -} -``` - ---- - -### 5.6 HTML Renderer (Procedural) - -**File:** `web/src/reader/ebook/html-renderer.ts` - -```typescript -// HTML rendering with theme support, font loading, and image handling -// Procedural style: Functions, not classes - -interface RendererConfig { - readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; - readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; - fontSize: number; - lineHeight: number; - marginWidth: number; - textAlign: 'left' | 'justify'; - columnCount: 1 | 2; -} - -// ============================================================ -// Main Render Function -// ============================================================ - -export async function renderHTMLDocument( - doc: HTMLDocument, - container: HTMLElement, - config: RendererConfig -): Promise { - // Apply theme - applyHTMLTheme(container, config.readingTheme); - - // Apply typography settings - applyHTMLTypography(container, config); - - // Inject custom styles for reader - injectHTMLReaderStyles(container); - - // Handle embedded fonts - await loadEmbeddedHTMLFonts(doc, container); - - // Handle images - processHTMLImages(doc, container); - - // Clear container and append content - container.innerHTML = ''; - container.appendChild(doc.body); - - // Apply column layout - applyHTMLColumnLayout(container, config.columnCount); -} - -// ============================================================ -// Theme Application -// ============================================================ - -function applyHTMLTheme(container: HTMLElement, theme: string): void { - const readingThemes: Record> = { - 'light': { - '--bg-primary': '#ffffff', - '--text-primary': '#1a1a1a', - '--text-secondary': '#666666', - '--accent': '#0066cc' - }, - 'sepia': { - '--bg-primary': '#f4ecd8', - '--text-primary': '#5f4b32', - '--text-secondary': '#8b7355', - '--accent': '#8b4513' - }, - 'dark': { - '--bg-primary': '#1a1b26', - '--text-primary': '#c0caf5', - '--text-secondary': '#565f89', - '--accent': '#7aa2f7' - }, - 'night': { - '--bg-primary': '#0d1117', - '--text-primary': '#c9d1d9', - '--text-secondary': '#8b949e', - '--accent': '#58a6ff' - }, - 'high-contrast': { - '--bg-primary': '#000000', - '--text-primary': '#ffffff', - '--text-secondary': '#cccccc', - '--accent': '#ffff00' - } - }; - - const themeConfig = readingThemes[theme] || readingThemes['dark']; - - for (const [key, value] of Object.entries(themeConfig)) { - container.style.setProperty(key, value); - } -} - -function applyHTMLTypography(container: HTMLElement, config: RendererConfig): void { - const style = document.createElement('style'); - const fontStack = getFontStack(config.readingFont); - - style.textContent = ` - .ebook-content { - font-family: ${fontStack}; - font-size: ${config.fontSize}px; - line-height: ${config.lineHeight}; - text-align: ${config.textAlign}; - padding: 0 ${config.marginWidth}px; - max-width: 100%; - overflow-wrap: break-word; - } - - .ebook-content p { - margin-bottom: 1em; - text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'}; - } - - .ebook-content img { - max-width: 100%; - height: auto; - display: block; - margin: 1em auto; - } - - .ebook-content a { - color: var(--accent); - text-decoration: underline; - } - - .ebook-content a:active { - color: var(--text-secondary); - } - `; - - container.appendChild(style); -} - -function injectHTMLReaderStyles(container: HTMLElement): void { - container.setAttribute('role', 'main'); - container.setAttribute('aria-label', 'Book content'); -} - -async function loadEmbeddedHTMLFonts(doc: HTMLDocument, container: HTMLElement): Promise { - const styleSheets = doc.querySelectorAll('style'); - - for (const sheet of styleSheets) { - const fontFaceRegex = /@font-face\s*{([^}]+)}/g; - const matches = sheet.textContent?.matchAll(fontFaceRegex) || []; - - for (const match of matches) { - const fontFace = match[1]; - const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace); - - if (urlMatch) { - const fontUrl = urlMatch[1]; - await loadHTMLFont(fontUrl, container); - } - } - } -} - -async function loadHTMLFont(fontUrl: string, container: HTMLElement): Promise { - const loadedFonts = container.dataset.loadedFonts ? - JSON.parse(container.dataset.loadedFonts) : []; - - if (loadedFonts.includes(fontUrl)) return; - - try { - const fontFace = new FontFace('custom-font', `url(${fontUrl})`); - await fontFace.load(); - document.fonts.add(fontFace); - - loadedFonts.push(fontUrl); - container.dataset.loadedFonts = JSON.stringify(loadedFonts); - } catch (error) { - console.error('Failed to load font:', fontUrl, error); - } -} - -function processHTMLImages(doc: HTMLDocument): void { - const images = doc.querySelectorAll('img'); - - images.forEach((img) => { - img.setAttribute('loading', 'lazy'); - - if (!img.alt) { - img.alt = 'Image from book'; - } - - img.style.cursor = 'pointer'; - img.addEventListener('click', () => { - showImageFullscreen(img.src); - }); - }); -} - -function showImageFullscreen(src: string): void { - const modal = document.createElement('div'); - modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50'; - modal.onclick = () => modal.remove(); - - const img = document.createElement('img'); - img.src = src; - img.className = 'max-w-full max-h-full object-contain'; - - modal.appendChild(img); - document.body.appendChild(modal); -} - -function applyHTMLColumnLayout(container: HTMLElement, columnCount: number): void { - if (columnCount === 2) { - container.style.columnCount = '2'; - container.style.columnGap = '20px'; - container.style.columnRule = '1px solid var(--text-secondary)'; - } else { - container.style.columnCount = 'auto'; - } -} - -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']; -} -``` - ---- - -### 5.7 CFI Navigation (Procedural) - -**File:** `web/src/reader/ebook/cfi-navigator.ts` - -```typescript -// EPUB CFI (Canonical Fragment Identifier) navigation -// Reuses logic from internal/sync/format.go -// Procedural style: Functions, not classes - -interface CFIComponent { - type: 'index' | 'indirection-step' | 'text-location'; - value: number; - id?: string; - textOffset?: number; -} - -// ============================================================ -// CFI Parsing Functions -// ============================================================ - -export function parseCFI(cfi: string): CFIComponent[] { - const components: CFIComponent[] = []; - - const cleanCFI = cfi.startsWith('!') ? cfi.substring(1) : cfi; - const parts = cleanCFI.split('/').filter(Boolean); - - for (const part of parts) { - const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/); - if (match) { - const component: CFIComponent = { - type: match[3] !== undefined ? 'text-location' : 'index', - value: parseInt(match[1], 10), - id: match[2], - textOffset: match[3] !== undefined ? parseInt(match[3], 10) : undefined - }; - - components.push(component); - } - } - - return components; -} - -export function generateCFI( - spineIndex: number, - elementPath: number[], - textOffset: number = 0, - spineItemId?: string -): string { - let cfi = `/6/${spineIndex}`; - - if (spineItemId) { - cfi += `[${spineItemId}]`; - } - - for (const index of elementPath) { - cfi += `/${index}`; - } - - if (textOffset > 0) { - cfi += `:${textOffset}`; - } - - return cfi; -} - -export function navigateToCFI(doc: Document, cfi: string): Element | Text | null { - const components = parseCFI(cfi); - - if (components.length === 0) return null; - - let current: Node | null = doc.body; - - for (let i = 1; i < components.length; i++) { - const component = components[i]; - - if (component.type === 'index') { - if (current instanceof Element) { - const children = getElementChildren(current); - current = children[component.value] || null; - } - } - } - - return current as Element | Text; -} - -export function getSelectionCFI(doc: Document): string | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return null; - - const range = selection.getRangeAt(0); - const startContainer = range.startContainer; - - // Build path to start container - const path: number[] = []; - let current: Node | null = startContainer; - - while (current && current !== doc.body) { - const parent = current.parentElement; - if (parent) { - const siblings = getElementChildren(parent); - const index = siblings.indexOf(current as Element); - path.unshift(index); - } - current = parent; - } - - const spineIndex = 0; - const textOffset = range.startOffset; - - return generateCFI(spineIndex, path, textOffset); -} - -export function getPercentageFromCFI(cfi: string): number { - const components = parseCFI(cfi); - const textLocation = components.find(c => c.type === 'text-location'); - - if (textLocation && textLocation.textOffset !== undefined) { - return Math.min((textLocation.textOffset / 10), 100); - } - - return 0; -} - -export function compareCFIs(cfi1: string, cfi2: string): number { - const components1 = parseCFI(cfi1); - const components2 = parseCFI(cfi2); - - const maxLen = Math.max(components1.length, components2.length); - - for (let i = 0; i < maxLen; i++) { - const comp1 = components1[i]; - const comp2 = components2[i]; - - if (!comp1) return -1; - if (!comp2) return 1; - - if (comp1.value !== comp2.value) { - return comp1.value - comp2.value; - } - } - - return 0; -} - -function getElementChildren(element: Element): Element[] { - return Array.from(element.children).filter(el => - el.nodeType === Node.ELEMENT_NODE - ) as Element[]; -} -``` - ---- - -### 5.9 Ebook Search (Procedural) - -**File:** `web/src/reader/ebook/search.ts` - -```typescript -// Search within ebook content -// Procedural style: Functions, not classes - -interface SearchResult { - cfi: string; - snippet: string; - chapterTitle: string; -} - -interface EbookSearchConfig { - epubPackage: EPUBPackage; -} - -// ============================================================ -// Main Search Function -// ============================================================ - -export async function searchEbook( - epubPackage: EPUBPackage, - query: string -): Promise { - const results: SearchResult[] = []; - const lowerQuery = query.toLowerCase(); - - // Search all spine items - for (const [index, spineItem] of epubPackage.spine.entries()) { - const doc = await getSpineItemDocument(epubPackage, spineItem); - - if (!doc) continue; - - const chapterTitle = getChapterTitle(spineItem); - - // Search in text nodes - const textNodes = findTextNodes(doc.body); - - for (const node of textNodes) { - const text = node.textContent || ''; - const lowerText = text.toLowerCase(); - - let foundAt = 0; - while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) { - const cfi = generateCFIForNode(node, foundAt); - const snippet = extractSnippet(text, foundAt, query.length); - - results.push({ - cfi, - snippet, - chapterTitle - }); - - foundAt += lowerQuery.length; - } - } - } - - return results; -} - -async function getSpineItemDocument( - epubPackage: EPUBPackage, - spineItem: EPUBSpineItem -): Promise { - try { - const content = await epubPackage.resources.get(spineItem.href)?.text(); - if (!content) return null; - - const parser = new DOMParser(); - return parser.parseFromString(content, 'text/html'); - } catch (error) { - console.error('Failed to load spine item:', spineItem.href, error); - return null; - } -} - -function getChapterTitle(spineItem: EPUBSpineItem): string { - // Extract title from spine item or use default - return spineItem.id || `Section ${spineItem.index}`; -} - -function findTextNodes(root: Node): Text[] { - const textNodes: Text[] = []; - const walker = document.createTreeWalker( - root, - NodeFilter.SHOW_TEXT, - { - acceptNode: (node) => { - const parent = node.parentElement; - if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) { - return NodeFilter.FILTER_REJECT; - } - - if (!node.textContent?.trim()) { - return NodeFilter.FILTER_REJECT; - } - - return NodeFilter.FILTER_ACCEPT; - } - } - ); - - let node: Node | null; - while ((node = walker.nextNode())) { - textNodes.push(node as Text); - } - - return textNodes; -} - -function generateCFIForNode(node: Text, offset: number): string { - const path: number[] = []; - let current: Node | null = node; - - while (current && current.parentNode) { - const parent = current.parentNode; - const siblings = Array.from(parent.childNodes) - .filter(n => n.nodeType === Node.ELEMENT_NODE); - const index = siblings.indexOf(current as Node); - - path.unshift(index); - current = parent; - } - - const spineIndex = 0; // Would come from parent context - - return generateCFI(spineIndex, path, offset); -} - -function extractSnippet(text: string, offset: number, length: number): string { - const contextBefore = 30; - const contextAfter = 50; - - const start = Math.max(0, offset - contextBefore); - const end = Math.min(text.length, offset + length + contextAfter); - - return text.slice(start, end); -} -``` - -### 5.10 Libre Reading Fonts (Bundled) - -**8 Open Source Fonts Optimized for Extended Reading** - -All fonts are bundled with Bookhoard using WOFF2 format (~1.2MB total). Standard weights only: Regular (400), Italic (400i), Bold (700), Bold Italic (700i). - -**Font Directory:** `web/static/fonts/` - -#### 5.10.1 Font Acquisition & Installation - -**Automated Setup Script** - -**File:** `scripts/fetch-reading-fonts.sh` (new file) - -```bash -#!/bin/bash -# Fetch and prepare libre reading fonts for Bookhoard -# Usage: ./scripts/fetch-reading-fonts.sh - -set -e - -FONTS_DIR="web/static/fonts" -mkdir -p "$FONTS_DIR" - -echo "📦 Downloading libre reading fonts for Bookhoard..." - -# 1. Literata (v2.001 - latest stable) -echo "Downloading Literata..." -wget -O /tmp/literata.zip "https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip" -unzip -q /tmp/literata.zip -d /tmp/literata -mkdir -p "$FONTS_DIR/literata" -# Convert to WOFF2 using fonttools -for file in /tmp/literata/Static/*.otf; do - basename=$(basename "$file" .otf) - if [[ $basename == *"Regular"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF' - elif [[ $basename == *"Italic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF' - elif [[ $basename == *"Bold"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF' - elif [[ $basename == *"BoldItalic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF' - fi -done -rm -rf /tmp/literata /tmp/literata.zip - -# 2. Crimson Text (v1.102) -echo "Downloading Crimson Text..." -wget -O /tmp/crimson.zip "https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip" -unzip -q /tmp/crimson.zip -d /tmp/crimson -mkdir -p "$FONTS_DIR/crimson" -for file in /tmp/crimson/OTF/CrimsonPro-*.otf; do - basename=$(basename "$file" .otf) - if [[ $basename == *"Roman"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400.woff2" --flavor=woff2 - elif [[ $basename == *"Roman-Italic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400i.woff2" --flavor=woff2 - elif [[ $basename == *"Bold"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700.woff2" --flavor=woff2 - elif [[ $basename == *"BoldItalic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700i.woff2" --flavor=woff2 - fi -done -rm -rf /tmp/crimson /tmp/crimson.zip - -# 3. Source Serif 4 (v4.004) -echo "Downloading Source Serif 4..." -wget -O /tmp/source-serif.zip "https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip" -unzip -q /tmp/source-serif.zip -d /tmp/source-serif -mkdir -p "$FONTS_DIR/source-serif" -for file in /tmp/source-serif/OTF/SourceSerif4-*.otf; do - basename=$(basename "$file" .otf) - if [[ $basename == *"Regular"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400.woff2" --flavor=woff2 - elif [[ $basename == *"It"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400i.woff2" --flavor=woff2 - elif [[ $basename == *"Bold"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700.woff2" --flavor=woff2 - elif [[ $basename == *"BoldIt"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700i.woff2" --flavor=woff2 - fi -done -rm -rf /tmp/source-serif /tmp/source-serif.zip - -# 4. EB Garamond (v0.016) -echo "Downloading EB Garamond..." -wget -O /tmp/ebgaramond.zip "https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip" -unzip -q /tmp/ebgaramond.zip -d /tmp/ebgaramond -mkdir -p "$FONTS_DIR/eb-garamond" -for file in /tmp/ebgaramond/otf/*.otf; do - basename=$(basename "$file" .otf) - if [[ $basename == *"Regular"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400.woff2" --flavor=woff2 - elif [[ $basename == *"Italic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400i.woff2" --flavor=woff2 - elif [[ $basename == *"Bold"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700.woff2" --flavor=woff2 - elif [[ $basename == *"BoldItalic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700i.woff2" --flavor=woff2 - fi -done -rm -rf /tmp/ebgaramond /tmp/ebgaramond.zip - -# 5. Libertinus Serif (v7.050) -echo "Downloading Libertinus Serif..." -wget -O /tmp/libertinus.zip "https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip" -unzip -q /tmp/libertinus.zip -d /tmp/libertinus -mkdir -p "$FONTS_DIR/libertinus" -for file in /tmp/libertinus/LibertinusSerif-*.otf; do - basename=$(basename "$file" .otf) - if [[ $basename == *"Regular"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400.woff2" --flavor=woff2 - elif [[ $basename == *"Italic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400i.woff2" --flavor=woff2 - elif [[ $basename == *"Bold"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700.woff2" --flavor=woff2 - elif [[ $basename == *"BoldItalic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700i.woff2" --flavor=woff2 - fi -done -rm -rf /tmp/libertinus /tmp/libertinus.zip - -# 6. Noto Serif (v2.013 - subset to common languages only to reduce size) -echo "Downloading Noto Serif..." -wget -O /tmp/noto-serif.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Regular.ttf" -pyftsubset /tmp/noto-serif.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400.woff2" --flavor=woff2 --unicodes='U+0000-007F' --text-file="common-latin.txt" -wget -O /tmp/noto-serif-i.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Italic.ttf" -pyftsubset /tmp/noto-serif-i.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400i.woff2" --flavor=woff2 --unicodes='U+0000-007F' -wget -O /tmp/noto-serif-b.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Bold.ttf" -pyftsubset /tmp/noto-serif-b.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700.woff2" --flavor=woff2 --unicodes='U+0000-007F' -wget -O /tmp/noto-serif-bi.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-BoldItalic.ttf" -pyftsubset /tmp/noto-serif-bi.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700i.woff2" --flavor=woff2 --unicodes='U+0000-007F' -rm -f /tmp/noto-serif*.zip - -# 7. Charis SIL (v6.200) -echo "Downloading Charis SIL..." -wget -O /tmp/charis.zip "https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip" -unzip -q /tmp/charis.zip -d /tmp/charis -mkdir -p "$FONTS_DIR/charis-sil" -for file in /tmp/charis/CharisSIL-6.200/*.ttf; do - basename=$(basename "$file" .ttf) - if [[ $basename == *"Regular"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400.woff2" --flavor=woff2 - elif [[ $basename == *"Italic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400i.woff2" --flavor=woff2 - elif [[ $basename == *"Bold"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700.woff2" --flavor=woff2 - elif [[ $basename == *"BoldItalic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700i.woff2" --flavor=woff2 - fi -done -rm -rf /tmp/charis /tmp/charis.zip - -# 8. IBM Plex Serif (v1.1.0) -echo "Downloading IBM Plex Serif..." -wget -O /tmp/ibm-plex.zip "https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip" -unzip -q /tmp/ibm-plex.zip -d /tmp/ibm-plex -mkdir -p "$FONTS_DIR/ibm-plex" -for file in /tmp/ibm-plex/OpenType/IBM-Plex-Serif/*.otf; do - basename=$(basename "$file" .otf) - if [[ $basename == *"Regular"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400.woff2" --flavor=woff2 - elif [[ $basename == *"TextItalic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400i.woff2" --flavor=woff2 - elif [[ $basename == *"SemiBold"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700.woff2" --flavor=woff2 - elif [[ $basename == *"SemiBoldItalic"* ]]; then - pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700i.woff2" --flavor=woff2 - fi -done -rm -rf /tmp/ibm-plex /tmp/ibm-plex.zip - -echo "✅ All fonts downloaded and converted to WOFF2" -echo "📊 Total size:" -du -sh "$FONTS_DIR" - -echo "🔍 Verifying fonts..." -ls -lh "$FONTS_DIR"/*/ - -echo "✨ Font setup complete!" -``` - -**Manual Setup (Alternative)** - -If you prefer manual setup or the script fails: - -| Font | Version | Download URL | License | -|------|---------|-------------|---------| -| **Literata** | v2.001 | https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip | SIL OFL 1.1 | -| **Crimson Text** | v1.102 | https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip | SIL OFL 1.1 | -| **Source Serif 4** | v4.004 | https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip | SIL OFL 1.1 | -| **EB Garamond** | v0.016 | https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip | SIL OFL 1.1 | -| **Libertinus Serif** | v7.050 | https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip | SIL OFL 1.1 | -| **Noto Serif** | v2.013 | https://github.com/googlefonts/noto-fonts (subset to Latin-1) | SIL OFL 1.1 | -| **Charis SIL** | v6.200 | https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip | SIL OFL 1.1 | -| **IBM Plex Serif** | v1.1.0 | https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip | SIL OFL 1.1 | - -#### 5.10.2 Font Conversion Requirements - -**Required Tools:** - -```bash -# Python fonttools for WOFF2 conversion -pip install fonttools brotli - -# Alternative: Google Fonts woff2 tool -git clone --recursive https://github.com/google/woff2.git -cd woff2 -make -sudo cp woff2_compress /usr/local/bin/ -sudo cp woff2_decompress /usr/local/bin/ -``` - -**Conversion Commands:** - -```bash -# Using fonttools (recommended) -pyftsubset input.otf --output-file=output.woff2 \ - --flavor=woff2 \ - --layout-features='*' \ - --unicodes='U+0000-10FFFF' - -# Using woff2_compress tool -woff2_compress input.otf output.woff2 -``` - -#### 5.10.3 Font Verification - -**Verify fonts are working:** - -```bash -# List all fonts -ls -lh web/static/fonts/*/ - -# Check file sizes (should be ~100-200KB each) -du -h web/static/fonts/*/*.* - -# Verify WOFF2 format -file web/static/fonts/*/*.woff2 - -# Should output: "WOFF2 font data" -``` - -**Add to git:** - -```bash -# Add fonts to repository -git add web/static/fonts/ - -# Commit -git commit -m "feat: add 8 bundled libre reading fonts - -- Literata (default) -- Crimson Text -- Source Serif 4 -- EB Garamond -- Libertinus Serif -- Noto Serif -- Charis SIL -- IBM Plex Serif - -All fonts use SIL Open Font License 1.1 -WOFF2 format, ~1.2MB total" -``` - -#### 5.10.4 Alternative: Use Google Fonts CDN (Not Recommended) - -If you don't want to bundle fonts (slower initial load, privacy concerns): - -```html - - - -``` - -**Why bundling is better:** -- ✅ Offline-ready (no network requests) -- ✅ Privacy (Google doesn't track usage) -- ✅ Faster (no DNS lookup, no TLS handshake) -- ✅ Control (exact versions, no breaking changes) - -#### 5.10.6 Font Subsetting for Language Support - -**Full Unicode vs. Latin-1 Subset:** - -- **Full Unicode**: ~200KB per style (supports all languages) -- **Latin-1 Subset**: ~50KB per style (supports Western European languages) - -**Recommendation:** Bundle full Unicode for most fonts, but subset Noto Serif to Latin-1 unless you need extensive language support. - -**Subset Noto Serif (Latin-1 only):** - -```bash -pyftsubset NotoSerif-Regular.ttf \ - --output-file=NotoSerif-400.woff2 \ - --flavor=woff2 \ - --unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215' -``` - -This reduces Noto Serif from ~180KB to ~50KB per style. - -#### 5.10.7 Font Loading Performance - -**Critical Rendering Path Optimization:** - -```typescript -// Preload default font (Literata) in HTML head - - -// Preload user's preferred font (from settings) - -``` - -**Lazy-load other fonts:** - -```typescript -// Load other fonts on demand -async function loadFont(fontId: string): Promise { - const font = READING_FONTS.find(f => f.id === fontId); - if (!font) return; - - document.fonts.load(`16px "${font.stack}"`); -} -``` - -``` -web/static/fonts/ -├── literata/ -│ ├── Literata-400.woff2 (200KB) -│ ├── Literata-400i.woff2 (200KB) -│ ├── Literata-700.woff2 (180KB) -│ └── Literata-700i.woff2 (180KB) -├── crimson/ -│ ├── CrimsonText-400.woff2 (100KB) -│ ├── CrimsonText-400i.woff2 (100KB) -│ ├── CrimsonText-700.woff2 (95KB) -│ └── CrimsonText-700i.woff2 (95KB) -├── source-serif/ -│ ├── SourceSerif4-400.woff2 (150KB) -│ ├── SourceSerif4-400i.woff2 (150KB) -│ ├── SourceSerif4-700.woff2 (145KB) -│ └── SourceSerif4-700i.woff2 (145KB) -├── eb-garamond/ -│ ├── EBGaramond-400.woff2 (120KB) -│ ├── EBGaramond-400i.woff2 (120KB) -│ ├── EBGaramond-700.woff2 (115KB) -│ └── EBGaramond-700i.woff2 (115KB) -├── libertinus/ -│ ├── LibertinusSerif-400.woff2 (150KB) -│ ├── LibertinusSerif-400i.woff2 (150KB) -│ ├── LibertinusSerif-700.woff2 (145KB) -│ └── LibertinusSerif-700i.woff2 (145KB) -├── noto-serif/ -│ ├── NotoSerif-400.woff2 (180KB) -│ ├── NotoSerif-400i.woff2 (180KB) -│ ├── NotoSerif-700.woff2 (175KB) -│ └── NotoSerif-700i.woff2 (175KB) -├── charis-sil/ -│ ├── CharisSIL-400.woff2 (130KB) -│ ├── CharisSIL-400i.woff2 (130KB) -│ ├── CharisSIL-700.woff2 (125KB) -│ └── CharisSIL-700i.woff2 (125KB) -└── ibm-plex/ - ├── IBMPlexSerif-400.woff2 (140KB) - ├── IBMPlexSerif-400i.woff2 (140KB) - ├── IBMPlexSerif-700.woff2 (135KB) - └── IBMPlexSerif-700i.woff2 (135KB) -``` - -**File:** `web/static/reader-fonts.css` (new file) - -```css -/* Libre reading fonts for Bookhoard ebook reader */ - -/* Literata - Designed for Google Play Books */ -@font-face { - font-family: 'Literata'; - src: url('/static/fonts/literata/Literata-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'Literata'; - src: url('/static/fonts/literata/Literata-400i.woff2') format('woff2'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'Literata'; - src: url('/static/fonts/literata/Literata-700.woff2') format('woff2'); - font-weight: 700; - font-style: normal; -} -@font-face { - font-family: 'Literata'; - src: url('/static/fonts/literata/Literata-700i.woff2') format('woff2'); - font-weight: 700; - font-style: italic; -} - -/* Crimson Text - Optimized for screen reading */ -@font-face { - font-family: 'Crimson Text'; - src: url('/static/fonts/crimson/CrimsonText-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'Crimson Text'; - src: url('/static/fonts/crimson/CrimsonText-400i.woff2') format('woff2'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'Crimson Text'; - src: url('/static/fonts/crimson/CrimsonText-700.woff2') format('woff2'); - font-weight: 700; - font-style: normal; -} -@font-face { - font-family: 'Crimson Text'; - src: url('/static/fonts/crimson/CrimsonText-700i.woff2') format('woff2'); - font-weight: 700; - font-style: italic; -} - -/* Source Serif 4 - Adobe professional quality */ -@font-face { - font-family: 'Source Serif 4'; - src: url('/static/fonts/source-serif/SourceSerif4-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'Source Serif 4'; - src: url('/static/fonts/source-serif/SourceSerif4-400i.woff2') format('woff2'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'Source Serif 4'; - src: url('/static/fonts/source-serif/SourceSerif4-700.woff2') format('woff2'); - font-weight: 700; - font-style: normal; -} -@font-face { - font-family: 'Source Serif 4'; - src: url('/static/fonts/source-serif/SourceSerif4-700i.woff2') format('woff2'); - font-weight: 700; - font-style: italic; -} - -/* EB Garamond - Classic elegance */ -@font-face { - font-family: 'EB Garamond'; - src: url('/static/fonts/eb-garamond/EBGaramond-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'EB Garamond'; - src: url('/static/fonts/eb-garamond/EBGaramond-400i.woff2') format('woff2'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'EB Garamond'; - src: url('/static/fonts/eb-garamond/EBGaramond-700.woff2') format('woff2'); - font-weight: 700; - font-style: normal; -} -@font-face { - font-family: 'EB Garamond'; - src: url('/static/fonts/eb-garamond/EBGaramond-700i.woff2') format('woff2'); - font-weight: 700; - font-style: italic; -} - -/* Libertinus Serif - Academic/technical */ -@font-face { - font-family: 'Libertinus Serif'; - src: url('/static/fonts/libertinus/LibertinusSerif-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'Libertinus Serif'; - src: url('/static/fonts/libertinus/LibertinusSerif-400i.woff2') format('woff2'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'Libertinus Serif'; - src: url('/static/fonts/libertinus/LibertinusSerif-700.woff2') format('woff2'); - font-weight: 700; - font-style: normal; -} -@font-face { - font-family: 'Libertinus Serif'; - src: url('/static/fonts/libertinus/LibertinusSerif-700i.woff2') format('woff2'); - font-weight: 700; - font-style: italic; -} - -/* Noto Serif - Maximum language support */ -@font-face { - font-family: 'Noto Serif'; - src: url('/static/fonts/noto-serif/NotoSerif-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'Noto Serif'; - src: url('/static/fonts/noto-serif/NotoSerif-400i.woff2') format('woff2'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'Noto Serif'; - src: url('/static/fonts/noto-serif/NotoSerif-700.woff2') format('woff2'); - font-weight: 700; - font-style: normal; -} -@font-face { - font-family: 'Noto Serif'; - src: url('/static/fonts/noto-serif/NotoSerif-700i.woff2') format('woff2'); - font-weight: 700; - font-style: italic; -} - -/* Charis SIL - Multilingual specialist */ -@font-face { - font-family: 'Charis SIL'; - src: url('/static/fonts/charis-sil/CharisSIL-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'Charis SIL'; - src: url('/static/fonts/charis-sil/CharisSIL-400i.woff2') format('woff2'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'Charis SIL'; - src: url('/static/fonts/charis-sil/CharisSIL-700.woff2') format('woff2'); - font-weight: 700; - font-style: normal; -} -@font-face { - font-family: 'Charis SIL'; - src: url('/static/fonts/charis-sil/CharisSIL-700i.woff2') format('woff2'); - font-weight: 700; - font-style: italic; -} - -/* IBM Plex Serif - Modern & versatile */ -@font-face { - font-family: 'IBM Plex Serif'; - src: url('/static/fonts/ibm-plex/IBMPlexSerif-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'IBM Plex Serif'; - src: url('/static/fonts/ibm-plex/IBMPlexSerif-400i.woff2') format('woff2'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'IBM Plex Serif'; - src: url('/static/fonts/ibm-plex/IBMPlexSerif-700.woff2') format('woff2'); - font-weight: 700; - font-style: normal; -} -@font-face { - font-family: 'IBM Plex Serif'; - src: url('/static/fonts/ibm-plex/IBMPlexSerif-700i.woff2') format('woff2'); - font-weight: 700; - font-style: italic; -} -``` - -**Font Loading Strategy:** - -**File:** `web/src/reader/ebook/font-loader.ts` (new file) - -```typescript -// Font loading with performance optimization - -const READING_FONTS = [ - { - id: 'literata', - name: 'Literata', - stack: 'Literata, serif', - description: 'Designed for Google Play Books' - }, - { - id: 'crimson', - name: 'Crimson Text', - stack: 'Crimson Text, serif', - description: 'Optimized for screen reading' - }, - { - id: 'source-serif', - name: 'Source Serif 4', - stack: 'Source Serif 4, serif', - description: 'Professional Adobe quality' - }, - { - id: 'eb-garamond', - name: 'EB Garamond', - stack: 'EB Garamond, serif', - description: 'Classic elegance' - }, - { - id: 'libertinus', - name: 'Libertinus Serif', - stack: 'Libertinus Serif, serif', - description: 'Excellent for technical content' - }, - { - id: 'noto-serif', - name: 'Noto Serif', - stack: 'Noto Serif, serif', - description: 'Maximum language support' - }, - { - id: 'charis-sil', - name: 'Charis SIL', - stack: 'Charis SIL, serif', - description: 'Multilingual specialist' - }, - { - id: 'ibm-plex', - name: 'IBM Plex Serif', - stack: 'IBM Plex Serif, serif', - description: 'Modern & versatile' - } -]; - -// Preload critical fonts (default font + user's last choice) -async function preloadFonts(userPreferredFont: string): Promise { - const fontsToPreload = new Set(['literata', userPreferredFont]); - - for (const fontId of fontsToPreload) { - const font = READING_FONTS.find(f => f.id === fontId); - if (font) { - document.fonts.load(`16px "${font.stack}"`); - } - } -} - -// Get font stack for CSS -function getFontStack(fontId: string): string { - const font = READING_FONTS.find(f => f.id === fontId); - return font?.stack || 'Literata, serif'; -} - -// All fonts bundled - no network requests needed -export { READING_FONTS, preloadFonts, getFontStack }; -``` - -**Important Notes:** - -- **UI Elements**: Use Bookhoard's existing font stack (not these reading fonts) -- **Ebook Content Only**: These fonts apply only to `.ebook-content` elements -- **Bundled**: All fonts ship with the app (~1.2MB total, WOFF2 compressed) -- **Offline Ready**: No network requests needed for font loading -- **Performance**: Preload default font (Literata) + user's preference -- **License**: All fonts use SIL Open Font License 1.1 (libre, commercial use OK) - -### 5.11 Typography Engine - -**File:** `web/src/reader/ebook/typography-engine.ts` - -```typescript -// Typography engine with font smoothing, hyphenation, and justification - -interface TypographyConfig { - readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; // Bundled libre fonts - fontSize: number; - lineHeight: number; - marginTop: number; - marginBottom: number; - marginLeft: number; - marginRight: number; - textAlign: 'left' | 'right' | 'center' | 'justify'; - textIndent: number; - hyphenate: boolean; - ligatures: boolean; - fontSmoothing: 'auto' | 'antialiased' | 'subpixel-antialiased'; -} - -// Typography engine for ebook text rendering -// Procedural implementation (no OOP) - -interface TypographyConfig { - readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; - fontSize: number; - lineHeight: number; - textAlign: 'left' | 'justify'; - marginTop: number; - marginBottom: number; - marginLeft: number; - marginRight: number; - textIndent: number; - fontSmoothing: 'antialiased' | 'auto' | 'grayscale'; - hyphenate: boolean; - ligatures: boolean; -} - -function applyTypography(container: HTMLElement, config: TypographyConfig): void { - const content = container.querySelector('.ebook-content'); - if (!content) return; - - const fontStack = getFontStack(config.readingFont); - - content.setAttribute('style', ` - font-family: ${fontStack}; - font-size: ${config.fontSize}px; - line-height: ${config.lineHeight}; - text-align: ${config.textAlign}; - margin-top: ${config.marginTop}px; - margin-bottom: ${config.marginBottom}px; - margin-left: ${config.marginLeft}px; - margin-right: ${config.marginRight}px; - text-indent: ${config.textIndent}px; - -webkit-font-smoothing: ${config.fontSmoothing}; - -moz-osx-font-smoothing: ${config.fontSmoothing === 'grayscale' ? 'grayscale' : 'auto'}; - `); - - if (config.hyphenate) { - enableHyphenation(container, content as HTMLElement); - } - - setLigatures(content as HTMLElement, config.ligatures); - - if (config.textAlign === 'justify') { - enableJustification(content as HTMLElement); - } -} - -function enableHyphenation(container: HTMLElement, element: HTMLElement): void { - element.style.hyphens = 'auto'; - element.style.hyphenateLimitChars = '6 3 3'; - - const lang = container.closest('[data-language]')?.getAttribute('data-language') || 'en'; - element.setAttribute('lang', lang); -} - -function setLigatures(element: HTMLElement, enabled: boolean): void { - if (enabled) { - element.style.fontVariantLigatures = 'common-ligatures'; - element.style.fontFeatureSettings = '"liga", "dlig"'; - } else { - element.style.fontVariantLigatures = 'no-common-ligatures'; - element.style.fontFeatureSettings = 'normal'; - } -} - -function enableJustification(element: HTMLElement): void { - element.style.wordBreak = 'normal'; - element.style.overflowWrap = 'break-word'; - element.style.wordWrap = 'break-word'; - element.style.letterSpacing = '0.01em'; -} - -function updateTypographyConfig( - currentConfig: TypographyConfig, - newConfig: Partial -): TypographyConfig { - return { ...currentConfig, ...newConfig }; -} - -function measureReadingTime(container: HTMLElement, wordsPerMinute: number = 250): number { - const content = container.querySelector('.ebook-content'); - if (!content) return 0; - - const text = content.textContent || ''; - const words = text.split(/\s+/).length; - const minutes = words / wordsPerMinute; - - return Math.ceil(minutes); -} - -function getWordCount(container: HTMLElement): number { - const content = container.querySelector('.ebook-content'); - if (!content) return 0; - - const text = content.textContent || ''; - return text.split(/\s+/).length; -} -``` - -### 5.13 Copy Text Handler - -**File:** `web/src/reader/ebook/copy-handler.ts` - -```typescript -// Handle text copying with citation - -// Handle text copying with citation -// Procedural implementation (no OOP) - -async function copySelection(mediaItem: MediaItemSummary): Promise { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return false; - - const selectedText = selection.toString(); - if (!selectedText.trim()) return false; - - const citation = createCitation(selectedText, mediaItem); - - try { - await navigator.clipboard.writeText(citation); - showToast('Copied to clipboard', 'success'); - return true; - } catch (error) { - console.error('Failed to copy:', error); - showToast('Failed to copy to clipboard', 'error'); - return false; - } -} - -function createCitation(text: string, mediaItem: MediaItemSummary): string { - let citation = `"${text}"\n`; - citation += `— ${mediaItem.title}`; - if (mediaItem.author) { - citation += ` by ${mediaItem.author}`; - } - citation += `\n(Source: Bookhoard)`; - - return citation; -} - -function enableContextMenuCopy(mediaItem: MediaItemSummary): void { - document.addEventListener('contextmenu', async (e) => { - const selection = window.getSelection(); - const selectedText = selection?.toString().trim(); - - if (selectedText) { - e.preventDefault(); - await copySelection(mediaItem); - } - }); -} -``` - -### 5.14 View Modes - -**File:** `web/src/reader/ebook/view-modes.ts` - -```typescript -// Different viewing modes for ebooks - -type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column'; - -// Different viewing modes for ebooks -// Procedural implementation (no OOP) - -type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column'; - -interface ViewModeState { - currentMode: ViewMode; - currentPage: number; -} - -function setViewMode(container: HTMLElement, mode: ViewMode): void { - const content = container.querySelector('.ebook-content'); - if (!content) return; - - content.classList.remove( - 'paginated', - 'scrolled', - 'single-column', - 'double-column' - ); - - switch (mode) { - case 'paginated': - applyPaginatedMode(container, content as HTMLElement); - break; - case 'scrolled': - applyScrolledMode(container, content as HTMLElement); - break; - case 'single-column': - applySingleColumn(content as HTMLElement); - break; - case 'double-column': - applyDoubleColumn(content as HTMLElement); - break; - } -} - -function applyPaginatedMode(container: HTMLElement, element: HTMLElement): void { - element.classList.add('paginated'); - - element.style.height = '100vh'; - element.style.overflow = 'hidden'; - element.style.columnCount = '1'; - element.style.columnGap = '0'; - - enablePagination(container, element); -} - -function applyScrolledMode(container: HTMLElement, element: HTMLElement): void { - element.classList.add('scrolled'); - - element.style.height = 'auto'; - element.style.overflowY = 'auto'; - element.style.columnCount = '1'; - - disablePagination(container); -} - -function applySingleColumn(element: HTMLElement): void { - element.classList.add('single-column'); - - element.style.columnCount = '1'; - element.style.columnGap = '0'; - element.style.maxWidth = '800px'; - element.style.margin = '0 auto'; -} - -function applyDoubleColumn(element: HTMLElement): void { - element.classList.add('double-column'); - - element.style.columnCount = '2'; - element.style.columnGap = '60px'; - element.style.columnRule = '1px solid var(--text-secondary)'; - element.style.maxWidth = '1400px'; - element.style.margin = '0 auto'; -} - -function enablePagination(container: HTMLElement, element: HTMLElement): void { - const totalHeight = element.scrollHeight; - const pageHeight = element.clientHeight; - const pageCount = Math.ceil(totalHeight / pageHeight); - - addPaginationControls(container, pageCount); -} - -function disablePagination(container: HTMLElement): void { - const controls = container.querySelector('.pagination-controls'); - controls?.remove(); -} - -function addPaginationControls(container: HTMLElement, pageCount: number): ViewModeState { - let currentPage = 1; - - const controls = document.createElement('div'); - controls.className = 'pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t'; - controls.innerHTML = ` - - Page ${currentPage} of ${pageCount} - - `; - - controls.querySelector('.prev-page')?.addEventListener('click', () => { - if (currentPage > 1) { - currentPage--; - goToPage(container, currentPage); - } - }); - - controls.querySelector('.next-page')?.addEventListener('click', () => { - if (currentPage < pageCount) { - currentPage++; - goToPage(container, currentPage); - } - }); - - container.appendChild(controls); - - return { currentMode: 'paginated', currentPage }; -} - -function goToPage(container: HTMLElement, pageNumber: number): void { - const content = container.querySelector('.ebook-content') as HTMLElement; - if (!content) return; - - const pageHeight = content.clientHeight; - const scrollTop = (pageNumber - 1) * pageHeight; - - content.scrollTo({ - top: scrollTop, - behavior: 'smooth' - }); - - const pageInfo = container.querySelector('.page-info'); - if (pageInfo) { - pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`; - } -} - -function getTotalPageCount(container: HTMLElement): number { - const content = container.querySelector('.ebook-content') as HTMLElement; - if (!content) return 1; - - const totalHeight = content.scrollHeight; - const pageHeight = content.clientHeight; - - return Math.ceil(totalHeight / pageHeight); -} -``` - ---- - -## 6. PDF Reader Implementation - -### 6.1 PDF.js Integration (Procedural) - -**File:** `web/src/reader/pdf/pdfjs-wrapper.ts` - -```typescript -// Mozilla pdf.js integration for PDF rendering -// Procedural style: Functions, not classes - -import * as pdfjsLib from 'pdfjs-dist'; - -// ============================================================ -// PDF.js Configuration -// ============================================================ - -export function configurePDFJS(): void { - pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.mjs'; - pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = '/static/standard_fonts/'; - pdfjsLib.GlobalWorkerOptions.cMapUrl = '/static/cmaps/'; - pdfjsLib.GlobalWorkerOptions.cMapPacked = true; -} - -// ============================================================ -// PDF Document State -// ============================================================ - -interface PDFDocumentState { - doc: pdfjsLib.PDFDocumentProxy | null; - pages: Map; - metadata: PDFMetadata | null; -} - -interface PDFMetadata { - title: string; - author: string; - subject?: string; - keywords?: string; - creator?: string; - producer?: string; - creationDate?: Date; - modificationDate?: Date; - pageCount: number; -} - -let pdfState: PDFDocumentState = { - doc: null, - pages: new Map(), - metadata: null, -}; - -// ============================================================ -// Document Loading -// ============================================================ - -export async function loadPDFDocument(pdfBlob: Blob): Promise { - // Cleanup previous document - unloadPDFDocument(); - - const arrayBuffer = await pdfBlob.arrayBuffer(); - const loadingTask = pdfjsLib.getDocument({ - data: arrayBuffer, - }); - - pdfState.doc = await loadingTask.promise; - - // Extract metadata - const metadata = await pdfState.doc.getMetadata().catch(() => null); - const info = metadata?.info || {}; - - pdfState.metadata = { - title: info.Title || 'Untitled', - author: info.Author || 'Unknown', - subject: info.Subject, - keywords: info.Keywords, - creator: info.Creator, - producer: info.Producer, - creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined, - modificationDate: info.ModDate ? new Date(info.ModDate) : undefined, - pageCount: pdfState.doc.numPages, - }; - - return pdfState.metadata; -} - -export async function getPDFPage(pageNumber: number): Promise { - if (!pdfState.doc) { - throw new Error('PDF document not loaded'); - } - - // Check cache - if (pdfState.pages.has(pageNumber)) { - return pdfState.pages.get(pageNumber)!; - } - - // Load page - const page = await pdfState.doc.getPage(pageNumber); - pdfState.pages.set(pageNumber, page); - - return page; -} - -export async function getPDFPageText(pageNumber: number): Promise { - const page = await getPDFPage(pageNumber); - return await page.getTextContent(); -} - -export function getPDFMetadata(): PDFMetadata | null { - return pdfState.metadata; -} - -export function getPDFPageCount(): number { - return pdfState.doc?.numPages || 0; -} - -export function unloadPDFDocument(): void { - pdfState.pages.clear(); - pdfState.doc = null; - pdfState.metadata = null; -} - -export function unloadPDFPage(pageNumber: number): void { - pdfState.pages.delete(pageNumber); -} -``` - ---- - -### 6.2 Text Layer Renderer (Procedural) - -**File:** `web/src/reader/pdf/text-layer-renderer.ts` - -```typescript -// Text layer rendering for PDF text selection and highlighting -// Procedural style: Functions, not classes - -// ============================================================ -// Render Functions -// ============================================================ - -export function renderTextLayer( - container: HTMLElement, - viewport: any, - textContent: any, - config: TextLayerConfig -): void { - // Clear container - container.innerHTML = ''; - - // Apply styles - applyTextLayerStyles(container, config); - - // Render text items - const { items } = textContent; - - items.forEach((item: any, index: number) => { - if (typeof item === 'string') return; - - const textDiv = createTextDiv(item, viewport, index); - container.appendChild(textDiv); - }); -} - -function createTextDiv(item: any, viewport: any, index: number): HTMLElement { - const div = document.createElement('div'); - div.className = 'pdf-text-layer-text'; - div.textContent = item.str; - div.dataset.index = index.toString(); - - // Position the text div - const tx = pdfjsLib.Util.transform( - viewport.transform, - item.transform - ); - - const fontSize = Math.sqrt((tx[0] * tx[0]) + (tx[1] * tx[1])); - - div.style.left = `${tx[4]}px`; - div.style.top = `${tx[5] - fontSize}px`; - div.style.fontSize = `${fontSize}px`; - div.style.fontFamily = item.fontName || 'sans-serif'; - - // Handle text direction - if (item.dir === 'ttb') { - div.style.writingMode = 'vertical-rl'; - } - - return div; -} - -interface TextLayerConfig { - theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; -} - -function applyTextLayerStyles(container: HTMLElement, config: TextLayerConfig): void { - const style = document.createElement('style'); - style.textContent = getTextLayerCSS(config.theme); - container.appendChild(style); -} - -function getTextLayerCSS(theme: string): string { - const colors = getThemeColors(theme); - - return ` - .pdf-text-layer { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - overflow: hidden; - opacity: 1; - line-height: 1; - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; - } - - .pdf-text-layer-text { - position: absolute; - white-space: pre; - cursor: text; - transform-origin: 0% 0%; - color: transparent; - pointer-events: auto; - } - - .pdf-text-layer-text::selection { - background: ${colors.highlight}; - color: transparent; - } - - .pdf-text-layer-text::-moz-selection { - background: ${colors.highlight}; - color: transparent; - } - - .pdf-highlight-overlay { - position: absolute; - background-color: ${colors.highlight}; - mix-blend-mode: multiply; - pointer-events: none; - } - `; -} - -function getThemeColors(theme: string): { highlight: string } { - const themes: Record = { - 'light': { highlight: 'rgba(255, 255, 0, 0.3)' }, - 'sepia': { highlight: 'rgba(255, 200, 0, 0.4)' }, - 'dark': { highlight: 'rgba(255, 255, 0, 0.3)' }, - 'night': { highlight: 'rgba(100, 150, 255, 0.3)' }, - 'high-contrast': { highlight: 'rgba(255, 255, 0, 0.5)' } - }; - - return themes[theme] || themes['dark']; -} - -// ============================================================ -// Selection Functions -// ============================================================ - -export function getPDFTextSelection(): { text: string; range: Range } | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return null; - - const range = selection.getRangeAt(0); - const text = range.toString(); - - if (!text) return null; - - return { text, range }; -} - -export function getPDFSelectionRects(): DOMRect[] { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return []; - - const rects: DOMRect[] = []; - const range = selection.getRangeAt(0); - - for (const rect of range.getClientRects()) { - rects.push(rect); - } - - return rects; -} -``` - ---- - -### 6.3 Annotation Layer (Procedural) - -**File:** `web/src/reader/pdf/annotation-layer.ts` - -```typescript -// Annotation layer for rendering highlights and notes on PDFs -// Procedural style: Functions, not classes - -interface PDFHighlight { - id: string; - pageNumber: number; - rects: DOMRect[]; - text: string; - color: string; - noteId?: string; -} - -const highlights = new Map(); - -export function renderPDFHighlights( - container: HTMLElement, - highlightList: PDFHighlight[] -): void { - // Clear existing highlights - clearPDFHighlights(container); - - for (const highlight of highlightList) { - renderSinglePDFHighlight(container, highlight); - } -} - -function renderSinglePDFHighlight(container: HTMLElement, highlight: PDFHighlight): void { - const overlay = document.createElement('div'); - overlay.className = 'pdf-highlight-annotation'; - overlay.dataset.highlightId = highlight.id; - overlay.style.backgroundColor = parseColor(highlight.color); - - // Position highlight rectangles - for (const rect of highlight.rects) { - const rectDiv = document.createElement('div'); - rectDiv.className = 'pdf-highlight-rect'; - rectDiv.style.left = `${rect.left}px`; - rectDiv.style.top = `${rect.top}px`; - rectDiv.style.width = `${rect.width}px`; - rectDiv.style.height = `${rect.height}px`; - - overlay.appendChild(rectDiv); - } - - // Add click handler for note popup - if (highlight.noteId) { - overlay.style.cursor = 'pointer'; - overlay.addEventListener('click', () => { - showNotePopup(highlight); - }); - } - - // Add hover effect - overlay.addEventListener('mouseenter', () => { - overlay.style.opacity = '0.8'; - }); - - overlay.addEventListener('mouseleave', () => { - overlay.style.opacity = '0.5'; - }); - - container.appendChild(overlay); - highlights.set(highlight.id, overlay); -} - -function parseColor(color: string): string { - if (color.startsWith('#')) { - const hex = color.slice(1); - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - return `rgba(${r}, ${g}, ${b}, 0.4)`; - } - - return color; -} - -function showNotePopup(highlight: PDFHighlight): void { - console.log('Show note for highlight:', highlight.id); -} - -export function clearPDFHighlights(container: HTMLElement): void { - highlights.forEach(element => element.remove()); - highlights.clear(); -} - -export function removePDFHighlight(highlightId: string): void { - const element = highlights.get(highlightId); - if (element) { - element.remove(); - highlights.delete(highlightId); - } -} -``` - ---- - -### 6.4 PDF Navigation (Procedural) - -**File:** `web/src/reader/pdf/pdf-navigation.ts` - -```typescript -// PDF navigation: page turning, zoom, fit modes -// Procedural style: Functions, not classes - -type PageFitMode = 'fit-width' | 'fit-page' | 'fit-height' | 'none'; - -interface PDFNavigationState { - currentPage: number; - totalPages: number; - currentScale: number; - fitMode: PageFitMode; - scrollContainer: HTMLElement | null; -} - -let navState: PDFNavigationState = { - currentPage: 1, - totalPages: 0, - currentScale: 1.0, - fitMode: 'fit-width', - scrollContainer: null, -}; - -// ============================================================ -// Initialization -// ============================================================ - -export function initializePDFNavigation( - container: HTMLElement, - onPageChange: (pageNumber: number) => void, - onZoomChange: (scale: number) => void -): void { - navState.scrollContainer = container.querySelector('.pdf-scroll-container') || container; - setupPDFKeyboardNav(onPageChange); - setupPDFScrollTracking(onPageChange); -} - -export function setPDFTotalPages(totalPages: number): void { - navState.totalPages = totalPages; -} - -// ============================================================ -// Page Navigation -// ============================================================ - -export function goToPDFPage(pageNumber: number): void { - if (pageNumber < 1 || pageNumber > navState.totalPages) return; - - navState.currentPage = pageNumber; - - const callback = (window as any).pdfOnPageChange; - if (callback) callback(pageNumber); - - scrollToPDFPage(pageNumber); -} - -export function nextPDFPage(): void { - if (navState.currentPage < navState.totalPages) { - goToPDFPage(navState.currentPage + 1); - } -} - -export function previousPDFPage(): void { - if (navState.currentPage > 1) { - goToPDFPage(navState.currentPage - 1); - } -} - -function scrollToPDFPage(pageNumber: number): void { - if (!navState.scrollContainer) return; - - const pageElement = navState.scrollContainer.querySelector(`[data-page-number="${pageNumber}"]`); - if (pageElement) { - pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } -} - -// ============================================================ -// Zoom Controls -// ============================================================ - -export function setPDFZoom(scale: number): void { - navState.currentScale = scale; - navState.fitMode = 'none'; - - const callback = (window as any).pdfOnZoomChange; - if (callback) callback(scale); - - updatePDFZoom(); -} - -export function setPDFFitMode(mode: PageFitMode): void { - navState.fitMode = mode; - updatePDFZoom(); -} - -export function zoomPDFIn(): void { - setPDFZoom(navState.currentScale * 1.2); -} - -export function zoomPDFOut(): void { - setPDFZoom(navState.currentScale / 1.2); -} - -function updatePDFZoom(): void { - if (!navState.scrollContainer) return; - - const pages = navState.scrollContainer.querySelectorAll('.pdf-page-container'); - pages.forEach((page: Element) => { - (page as HTMLElement).style.transform = `scale(${navState.currentScale})`; - (page as HTMLElement).style.transformOrigin = 'top center'; - }); -} - -// ============================================================ -// Keyboard Navigation -// ============================================================ - -function setupPDFKeyboardNav(onPageChange: (pageNumber: number) => void): void { - document.addEventListener('keydown', handlePDFKeyDown); -} - -function handlePDFKeyDown(e: KeyboardEvent): void { - switch (e.key) { - case 'ArrowRight': - case 'ArrowDown': - e.preventDefault(); - nextPDFPage(); - break; - case 'ArrowLeft': - case 'ArrowUp': - e.preventDefault(); - previousPDFPage(); - break; - case 'Home': - e.preventDefault(); - goToPDFPage(1); - break; - case 'End': - e.preventDefault(); - goToPDFPage(navState.totalPages); - break; - } -} - -// ============================================================ -// Scroll Tracking -// ============================================================ - -function setupPDFScrollTracking(onPageChange: (pageNumber: number) => void): void { - if (!navState.scrollContainer) return; - - let scrollTimeout: NodeJS.Timeout; - - navState.scrollContainer.addEventListener('scroll', () => { - clearTimeout(scrollTimeout); - - scrollTimeout = setTimeout(() => { - updateCurrentPageFromScroll(onPageChange); - }, 100); - }); -} - -function updateCurrentPageFromScroll(onPageChange: (pageNumber: number) => void): void { - if (!navState.scrollContainer) return; - - const scrollTop = navState.scrollContainer.scrollTop; - const containerHeight = navState.scrollContainer.clientHeight; - - const pages = navState.scrollContainer.querySelectorAll('[data-page-number]'); - let maxVisibility = 0; - let mostVisiblePage = navState.currentPage; - - pages.forEach((page) => { - const element = page as HTMLElement; - const pageTop = element.offsetTop; - const pageBottom = pageTop + element.offsetHeight; - - const visibleTop = Math.max(scrollTop, pageTop); - const visibleBottom = Math.min(scrollTop + containerHeight, pageBottom); - const visibleHeight = Math.max(0, visibleBottom - visibleTop); - - if (visibleHeight > maxVisibility) { - maxVisibility = visibleHeight; - mostVisiblePage = parseInt(element.dataset.pageNumber || '1'); - } - }); - - if (mostVisiblePage !== navState.currentPage) { - navState.currentPage = mostVisiblePage; - onPageChange(mostVisiblePage); - } -} - -// ============================================================ -// Getters -// ============================================================ - -export function getCurrentPDFPage(): number { - return navState.currentPage; -} - -export function getTotalPDFPages(): number { - return navState.totalPages; -} - -export function getPDFScale(): number { - return navState.currentScale; -} -``` - -### 6.5 PDF Search - -**File:** `web/src/reader/pdf/pdf-search.ts` - -```typescript -// Full-text search within PDF documents - -import { PDFDocumentProxy } from 'pdfjs-dist'; - -interface SearchResult { - pageNumber: number; - text: string; - index: number; - context: string; -} - -// Full-text search within PDF documents -// Procedural implementation (no OOP) - -interface SearchResult { - pageNumber: number; - text: string; - index: number; - context: string; -} - -interface PDFSearchState { - doc: PDFDocumentProxy | null; - searchResults: SearchResult[]; - currentResultIndex: number; -} - -async function initializePDFSearch(doc: PDFDocumentProxy): Promise { - return { - doc, - searchResults: [], - currentResultIndex: 0 - }; -} - -async function searchPDF(state: PDFSearchState, query: string): Promise { - if (!state.doc) return state; - - const searchResults: SearchResult[] = []; - const lowerQuery = query.toLowerCase(); - - for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) { - const page = await state.doc.getPage(pageNum); - const textContent = await page.getTextContent(); - - let fullText = ''; - const textItems = textContent.items.map(item => { - if (typeof item === 'string') return ''; - fullText += item.str; - return item.str; - }); - - const pageText = textItems.join(' '); - const matches = findSearchMatches(pageText, lowerQuery, pageNum); - - searchResults.push(...matches); - } - - return { ...state, searchResults }; -} - -function findSearchMatches( - text: string, - query: string, - pageNumber: number -): SearchResult[] { - const matches: SearchResult[] = []; - const lowerText = text.toLowerCase(); - let index = 0; - - while ((index = lowerText.indexOf(query, index)) !== -1) { - const start = Math.max(0, index - 50); - const end = Math.min(text.length, index + query.length + 50); - const context = text.slice(start, end); - - matches.push({ - pageNumber, - text: text.slice(index, index + query.length), - index, - context - }); - - index += query.length; - } - - return matches; -} - -function goToNextSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } { - if (state.searchResults.length === 0) { - return { ...state, result: null }; - } - - const newIndex = (state.currentResultIndex + 1) % state.searchResults.length; - return { - ...state, - currentResultIndex: newIndex, - result: state.searchResults[newIndex] - }; -} - -function goToPreviousSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } { - if (state.searchResults.length === 0) { - return { ...state, result: null }; - } - - const newIndex = (state.currentResultIndex - 1 + state.searchResults.length) % state.searchResults.length; - return { - ...state, - currentResultIndex: newIndex, - result: state.searchResults[newIndex] - }; -} - -function getSearchResultCount(state: PDFSearchState): number { - return state.searchResults.length; -} - -function clearSearchResults(state: PDFSearchState): PDFSearchState { - return { - ...state, - searchResults: [], - currentResultIndex: 0 - }; -} -``` - -### 6.6 Page Cache (Pre-rendering) - -**File:** `web/src/reader/pdf/page-cache.ts` - -```typescript -// 5-page ahead cache for PDF pages -// Pre-renders canvas and text layer for nearby pages - -import { PDFPageProxy, PageViewport } from 'pdfjs-dist'; - -interface CachedPage { - pageNumber: number; - canvas: HTMLCanvasElement; - textLayer: HTMLElement; - viewport: PageViewport; - timestamp: number; -} - -// 5-page ahead cache for PDF pages -// Procedural implementation (no OOP) - -interface CachedPage { - pageNumber: number; - canvas: HTMLCanvasElement; - textLayer: HTMLElement; - viewport: PageViewport; - timestamp: number; -} - -interface PDFPageCacheState { - cache: Map; - maxCacheSize: number; - renderCallbacks: Map void>>; -} - -function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState { - return { - cache: new Map(), - maxCacheSize, - renderCallbacks: new Map() - }; -} - -async function getCachedPage( - state: PDFPageCacheState, - pageNumber: number, - renderFn: (pageNumber: number) => Promise<{ canvas: HTMLCanvasElement; textLayer: HTMLElement; viewport: PageViewport }> -): Promise { - const cached = state.cache.get(pageNumber); - if (cached) { - cached.timestamp = Date.now(); - return { ...state, page: cached }; - } - - const { canvas, textLayer, viewport } = await renderFn(pageNumber); - - const cachedPage: CachedPage = { - pageNumber, - canvas, - textLayer, - viewport, - timestamp: Date.now() - }; - - const newCache = new Map(state.cache); - newCache.set(pageNumber, cachedPage); - - const callbacks = state.renderCallbacks.get(pageNumber); - if (callbacks) { - callbacks.forEach(cb => cb()); - const newCallbacks = new Map(state.renderCallbacks); - newCallbacks.delete(pageNumber); - return { ...state, cache: newCache, renderCallbacks: newCallbacks, page: cachedPage }; - } - - return { ...state, cache: newCache, page: cachedPage }; -} - -function preloadPages( - state: PDFPageCacheState, - currentPage: number, - totalPages: number -): PDFPageCacheState { - for (let i = 1; i <= state.maxCacheSize; i++) { - const pageNumber = currentPage + i; - if (pageNumber <= totalPages && !state.cache.has(pageNumber)) { - triggerPreload(pageNumber); - } - } - - return state; -} - -function triggerPreload(pageNumber: number): void { - console.log('Preloading page:', pageNumber); -} - -function invalidatePage( - state: PDFPageCacheState, - pageNumber: number -): PDFPageCacheState { - const cached = state.cache.get(pageNumber); - if (cached) { - cached.canvas.remove(); - cached.textLayer.remove(); - - const newCache = new Map(state.cache); - newCache.delete(pageNumber); - - return { ...state, cache: newCache }; - } - - return state; -} - -function clearPageCache(state: PDFPageCacheState): PDFPageCacheState { - state.cache.forEach(page => { - page.canvas.remove(); - page.textLayer.remove(); - }); - - return { - ...state, - cache: new Map() - }; -} - -function onPageRendered( - state: PDFPageCacheState, - pageNumber: number, - callback: () => void -): PDFPageCacheState { - const newCallbacks = new Map(state.renderCallbacks); - - if (!newCallbacks.has(pageNumber)) { - newCallbacks.set(pageNumber, []); - } - - newCallbacks.get(pageNumber)!.push(callback); - - return { ...state, renderCallbacks: newCallbacks }; -} -``` - -### 6.7 PDF Text Selection (Uses Backend API) - -**File:** `web/src/reader/pdf/pdf-text-selection.ts` - -```typescript -// PDF text selection - Uses backend API for highlight creation -// Backend handles all position calculations for PDFs -// Procedural style: Functions, not classes - -interface PDFTextSelection { - pageNumber: number; - text: string; - rects: DOMRect[]; -} - -// ============================================================ -// Get PDF Text Selection -// ============================================================ - -export function getPDFTextSelection(): PDFTextSelection | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return null; - - const range = selection.getRangeAt(0); - const text = range.toString(); - - if (!text) return null; - - // Get page number from selection - const pageElement = range.commonAncestorContainer.closest?.('[data-page-number]'); - const pageNumber = pageElement?.dataset.pageNumber - ? parseInt(pageElement.dataset.pageNumber) - : getCurrentPDFPage(); - - // Get bounding rectangles - const rects: DOMRect[] = []; - for (const rect of range.getClientRects()) { - rects.push(rect); - } - - return { - pageNumber, - text, - rects - }; -} - -// ============================================================ -// Create PDF Highlight (Backend Calculates Position) -// ============================================================ - -export async function createPDFHighlight( - mediaItemId: string, - selection: PDFTextSelection, - color: string -): Promise { - const selectionData = { - selection_text: selection.text, - page_number: selection.pageNumber, - rects: selection.rects.map(rect => ({ - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height - })), - color - }; - - // Send to backend - backend calculates all position formats - const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(selectionData) - }); - - if (!response.ok) { - throw new Error(`Failed to create highlight: ${response.statusText}`); - } - - return await response.json(); -} - -// ============================================================ -// Load and Render PDF Highlights (Backend Provides Positions) -// ============================================================ - -export async function loadAndRenderPDFHighlights( - mediaItemId: string, - container: HTMLElement -): Promise { - const response = await fetch(`/api/media-items/${mediaItemId}/highlights`); - if (!response.ok) return []; - - const highlights: Highlight[] = await response.json(); - - for (const highlight of highlights) { - renderPDFHighlight(container, highlight); - } -} - -function renderPDFHighlight(container: HTMLElement, highlight: Highlight): void { - // Backend provides position data for PDF highlights - // Check which position format is available - - if (highlight.start_position && highlight.start_position.startsWith('pdf:page:')) { - // Backend calculated page-based position - renderPDFHighlightByPosition(container, highlight); - } else if (highlight.percentage_start !== null) { - // Backend calculated percentage position - renderPDFHighlightByPercentage(container, highlight); - } -} - -function renderPDFHighlightByPosition(container: HTMLElement, highlight: Highlight): void { - // Parse position string: "pdf:page:45:offset:123" - const match = highlight.start_position.match(/pdf:page:(\d+):offset:(\d+)/); - if (!match) return; - - const pageNumber = parseInt(match[1], 10); - const offset = parseInt(match[2], 10); - - // Find the page element - const pageElement = container.querySelector(`[data-page-number="${pageNumber}"]`); - if (!pageElement) return; - - // Get text content at offset - const textContent = pageElement.querySelector('.pdf-text-layer')?.textContent; - if (!textContent) return; - - // Find the text at this offset - const textBefore = textContent.substring(0, offset); - const startChar = textBefore.length; - const endChar = startChar + (highlight.selection_text?.length || 10); - - if (startChar < textContent.length && endChar <= textContent.length) { - applyHighlightToTextContent( - pageElement as HTMLElement, - startChar, - endChar, - highlight.color - ); - } -} - -function renderPDFHighlightByPercentage(container: HTMLElement, highlight: Highlight): void { - // Backend provides percentage - estimate position - const percentage = highlight.percentage_start || 0; - - // Find spine item closest to this percentage - const totalPages = container.querySelectorAll('[data-page-number]').length; - const targetPage = Math.ceil(percentage * totalPages); - - const pageElement = container.querySelector(`[data-page-number="${targetPage}"]`); - if (pageElement) { - // Highlight entire page (coarse-grained) - applyHighlightStylesToElement(pageElement as HTMLElement, highlight.color); - } -} - -function applyHighlightToTextContent( - element: HTMLElement, - startChar: number, - endChar: number, - color: string -): void { - const text = element.textContent || ''; - const before = text.substring(0, startChar); - const selection = text.substring(startChar, endChar); - const after = text.substring(endChar); - - element.textContent = before + selection + after; - - // Use a mark to wrap the selected text - element.innerHTML = `${before}${selection}${after}`; -} -``` - ---- - -### 6.8 PDF Outline/TOC Navigation - -**File:** `web/src/reader/pdf/pdf-outline.ts` - -```typescript -// PDF outline/TOC navigation -// Procedural implementation (no OOP) - -interface PDFOutlineNode { - id: string; - title: string; - destination: number | null; - pageNumber?: number; - children: PDFOutlineNode[]; - expanded: boolean; -} - -interface PDFOutlineState { - doc: PDFDocumentProxy | null; - outline: PDFOutlineNode[]; - flatMap: Map; -} - -async function initializePDFOutline(doc: PDFDocumentProxy): Promise { - const state: PDFOutlineState = { - doc, - outline: [], - flatMap: new Map() - }; - - return await loadPDFOutline(state); -} - -async function loadPDFOutline(state: PDFOutlineState): Promise { - if (!state.doc) return state; - - const pdfOutline = await state.doc.getOutline(); - - if (!pdfOutline || pdfOutline.length === 0) { - return { ...state, outline: [] }; - } - - const outline = await parseOutlineNodes(state, pdfOutline); - - return { ...state, outline }; -} - -async function parseOutlineNodes( - state: PDFOutlineState, - nodes: OutlineTreeNode[] -): Promise { - const result: PDFOutlineNode[] = []; - - for (const node of nodes) { - const outlineNode: PDFOutlineNode = { - id: generateOutlineId(), - title: node.title, - destination: null, - children: [], - expanded: false - }; - - if (node.dest) { - const pageNumber = await resolvePDFDestination(state, node.dest); - outlineNode.destination = pageNumber; - outlineNode.pageNumber = pageNumber; - state.flatMap.set(node.title, pageNumber); - } - - if (node.items && node.items.length > 0) { - outlineNode.children = await parseOutlineNodes(state, node.items); - } - - result.push(outlineNode); - } - - return result; -} - -async function resolvePDFDestination( - state: PDFOutlineState, - dest: string | any[] -): Promise { - if (!state.doc) return 1; - - try { - let explicitDest: any[]; - - if (typeof dest === 'string') { - const destObj = await state.doc.getDestination(dest); - if (!destObj) return 1; - explicitDest = destObj; - } else { - explicitDest = dest; - } - - const ref = explicitDest[0]; - - if (typeof ref === 'object' && ref !== null) { - const pageIndex = await state.doc.getPageIndex(ref); - return pageIndex + 1; - } else if (typeof ref === 'number') { - return ref + 1; - } - - return 1; - } catch (error) { - console.error('Failed to resolve destination:', dest, error); - return 1; - } -} - -function generateOutlineId(): string { - return `outline-${Math.random().toString(36).substr(2, 9)}`; -} - -function getOutline(state: PDFOutlineState): PDFOutlineNode[] { - return state.outline; -} - -function getOutlineFlatMap(state: PDFOutlineState): Map { - return state.flatMap; -} - -function getCurrentChapter( - state: PDFOutlineState, - pageNumber: number -): PDFOutlineNode | null { - return findChapterForPage(state.outline, pageNumber); -} - -function findChapterForPage( - nodes: PDFOutlineNode[], - pageNumber: number -): PDFOutlineNode | null { - for (const node of nodes) { - if (node.pageNumber && node.pageNumber <= pageNumber) { - if (node.children.length > 0) { - const childMatch = findChapterForPage(node.children, pageNumber); - if (childMatch) return childMatch; - } - return node; - } - - if (node.children.length > 0) { - const childMatch = findChapterForPage(node.children, pageNumber); - if (childMatch) return childMatch; - } - } - - return null; -} - -function toggleOutlineNode( - state: PDFOutlineState, - nodeId: string -): PDFOutlineState { - const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => { - return nodes.map(node => { - if (node.id === nodeId) { - return { ...node, expanded: !node.expanded }; - } - if (node.children.length > 0) { - return { ...node, children: updateNode(node.children) }; - } - return node; - }); - }; - - return { ...state, outline: updateNode(state.outline) }; -} - -function findOutlineNode( - nodes: PDFOutlineNode[], - id: string -): PDFOutlineNode | null { - for (const node of nodes) { - if (node.id === id) return node; - if (node.children.length > 0) { - const found = findOutlineNode(node.children, id); - if (found) return found; - } - } - return null; -} -``` - -### 6.9 PDF Bookmarks - -**File:** `web/src/reader/pdf/pdf-bookmarks.ts` - -```typescript -// Custom bookmarks for PDF pages (saved in database) -// Procedural implementation (no OOP) - -interface MediaBookmark { - id: string; - mediaItemId: string; - userId: string; - pageNumber: number; - title: string; - createdAt: string; -} - -interface MediaBookmarksState { - mediaItemId: string; - bookmarks: MediaBookmark[]; -} - -function createMediaBookmarks(mediaItemId: string): MediaBookmarksState { - return { - mediaItemId, - bookmarks: [] - }; -} - -async function loadMediaBookmarks(state: MediaBookmarksState): Promise { - try { - const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`); - if (!response.ok) throw new Error('Failed to load bookmarks'); - - const data = await response.json(); - return { ...state, bookmarks: data.bookmarks || [] }; - } catch (error) { - console.error('Failed to load bookmarks:', error); - return { ...state, bookmarks: [] }; - } -} - -async function addMediaBookmark( - state: MediaBookmarksState, - pageNumber: number, - title?: string -): Promise { - const bookmark: MediaBookmark = { - id: crypto.randomUUID(), - mediaItemId: state.mediaItemId, - userId: '', - pageNumber, - title: title || `Page ${pageNumber}`, - createdAt: new Date().toISOString() - }; - - try { - const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - page_number: pageNumber, - title: bookmark.title, - position: `pdf:page:${pageNumber}` - }) - }); - - if (!response.ok) throw new Error('Failed to create bookmark'); - - const created = await response.json(); - - return { - ...state, - bookmarks: [...state.bookmarks, created], - bookmark: created - }; - } catch (error) { - console.error('Failed to add bookmark:', error); - throw error; - } -} - -async function removeMediaBookmark( - state: MediaBookmarksState, - bookmarkId: string -): Promise { - try { - const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`, { - method: 'DELETE' - }); - - if (!response.ok) throw new Error('Failed to remove bookmark'); - - return { - ...state, - bookmarks: state.bookmarks.filter(b => b.id !== bookmarkId) - }; - } catch (error) { - console.error('Failed to remove bookmark:', error); - throw error; - } -} - -function getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] { - return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber); -} - -function hasMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): boolean { - return state.bookmarks.some(b => b.pageNumber === pageNumber); -} - -function getMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): MediaBookmark | null { - return state.bookmarks.find(b => b.pageNumber === pageNumber) || null; -} -``` - -### 6.10 PDF Clipboard - -**File:** `web/src/reader/pdf/pdf-clipboard.ts` - -```typescript -// Copy selected text to clipboard (plain text, preserve line breaks) -// Critical for technical textbooks with code examples -// Procedural implementation (no OOP) - -function setupPDFClipboard(container: HTMLElement): void { - container.addEventListener('copy', (e) => { - handlePDFCopy(e); - }); -} - -function handlePDFCopy(event: ClipboardEvent): void { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return; - - const selectedText = selection.toString(); - - if (!selectedText) return; - - const plainText = formatPDFPlainText(selectedText); - - event.clipboardData?.setData('text/plain', plainText); - - event.preventDefault(); - - showPDFCopyFeedback(); -} - -function formatPDFPlainText(text: string): string { - let formatted = text; - - formatted = formatted.replace(/[ \t]+/g, ' '); - - formatted = formatted.split('\n').map(line => line.trim()).join('\n'); - - formatted = formatted.replace(/\n{3,}/g, '\n\n'); - - return formatted; -} - -async function copyPDFText(text: string): Promise { - const formatted = formatPDFPlainText(text); - - try { - await navigator.clipboard.writeText(formatted); - showPDFCopyFeedback(); - return true; - } catch (error) { - console.error('Failed to copy text:', error); - - const textarea = document.createElement('textarea'); - textarea.value = formatted; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - - try { - const success = document.execCommand('copy'); - if (success) { - showPDFCopyFeedback(); - } - return success; - } catch (fallbackError) { - console.error('Fallback copy failed:', fallbackError); - return false; - } finally { - document.body.removeChild(textarea); - } - } -} - -function showPDFCopyFeedback(): void { - const toast = document.createElement('div'); - toast.className = 'pdf-copy-toast'; - toast.textContent = 'Copied to clipboard'; - toast.style.cssText = ` - position: fixed; - bottom: 20px; - right: 20px; - background: var(--accent); - color: white; - padding: 8px 16px; - border-radius: 4px; - font-size: 14px; - z-index: 10000; - animation: fadeIn 0.2s ease-out; - `; - - document.body.appendChild(toast); - - setTimeout(() => { - toast.style.animation = 'fadeOut 0.2s ease-out'; - setTimeout(() => toast.remove(), 200); - }, 1500); -} -``` - -### 6.11 PDF Link Handler - -**File:** `web/src/reader/pdf/pdf-links.ts` - -```typescript -// Handle internal PDF links (cross-references, citations, TOC links) -// External links open in new tab -// Procedural implementation (no OOP) - -interface PDFLink { - url: string; - pageNumber?: number; - bounds: { x: number; y: number; width: number; height: number }; -} - -interface PDFLinkHandlerState { - doc: PDFDocumentProxy | null; - container: HTMLElement; - onPageNavigate: (pageNumber: number) => void; -} - -async function initializePDFLinkHandler( - container: HTMLElement, - onPageNavigate: (pageNumber: number) => void, - doc: PDFDocumentProxy -): Promise { - const state: PDFLinkHandlerState = { - doc, - container, - onPageNavigate - }; - - await setupPDFLinks(state); - - return state; -} - -async function setupPDFLinks(state: PDFLinkHandlerState): Promise { - if (!state.doc) return; - - for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) { - const page = await state.doc.getPage(pageNum); - const annotations = await page.getAnnotations(); - - for (const annotation of annotations) { - if (annotation.subtype === 'Link') { - createPDFLinkElement(state, annotation, pageNum); - } - } - } -} - -function createPDFLinkElement( - state: PDFLinkHandlerState, - annotation: any, - pageNumber: number -): void { - const pageElement = state.container.querySelector(`[data-page-number="${pageNumber}"]`); - if (!pageElement) return; - - const link = document.createElement('a'); - link.className = 'pdf-internal-link'; - link.href = 'javascript:void(0)'; - - if (annotation.rect) { - const rect = annotation.rect; - link.style.position = 'absolute'; - link.style.left = `${rect[0]}px`; - link.style.top = `${rect[1]}px`; - link.style.width = `${rect[2] - rect[0]}px`; - link.style.height = `${rect[3] - rect[1]}px`; - link.style.cursor = 'pointer'; - } - - link.addEventListener('click', (e) => { - e.preventDefault(); - handlePDFLinkClick(state, annotation); - }); - - pageElement.appendChild(link); -} - -async function handlePDFLinkClick( - state: PDFLinkHandlerState, - annotation: any -): Promise { - if (!state.doc) return; - - if (annotation.url) { - if (annotation.url.startsWith('http://') || annotation.url.startsWith('https://')) { - window.open(annotation.url, '_blank', 'noopener,noreferrer'); - } else { - console.warn('Unhandled URL:', annotation.url); - } - } else if (annotation.dest) { - const pageNumber = await resolvePDFLinkDestination(state, annotation.dest); - state.onPageNavigate(pageNumber); - } -} - -async function resolvePDFLinkDestination( - state: PDFLinkHandlerState, - dest: string | any[] -): Promise { - if (!state.doc) return 1; - - try { - let explicitDest: any[]; - - if (typeof dest === 'string') { - const destObj = await state.doc.getDestination(dest); - if (!destObj) return 1; - explicitDest = destObj; - } else { - explicitDest = dest; - } - - const ref = explicitDest[0]; - - if (typeof ref === 'object' && ref !== null) { - const pageIndex = await state.doc.getPageIndex(ref); - return pageIndex + 1; - } else if (typeof ref === 'number') { - return ref + 1; - } - - return 1; - } catch (error) { - console.error('Failed to resolve link destination:', error); - return 1; - } -} -``` - -### 6.12 PDF Dual Page Spread View - -**File:** `web/src/reader/pdf/pdf-dual-page.ts` - -```typescript -// Dual page spread view for PDFs -// Procedural implementation (no OOP) - -type DualPageMode = 'single' | 'dual'; - -interface PDFDualPageViewState { - currentMode: DualPageMode; - minViewportWidth: number; -} - -function createPDFDualPageView( - container: HTMLElement, - onModeChange: (mode: DualPageMode) => void -): PDFDualPageViewState { - const state: PDFDualPageViewState = { - currentMode: 'single', - minViewportWidth: 1200 - }; - - setupResponsiveDualPageToggle(container, state, onModeChange); - - return state; -} - -function setupResponsiveDualPageToggle( - container: HTMLElement, - state: PDFDualPageViewState, - onModeChange: (mode: DualPageMode) => void -): void { - const resizeObserver = new ResizeObserver(() => { - handleDualPageResize(container, state, onModeChange); - }); - - resizeObserver.observe(container); -} - -function handleDualPageResize( - container: HTMLElement, - state: PDFDualPageViewState, - onModeChange: (mode: DualPageMode) => void -): PDFDualPageViewState { - const viewportWidth = window.innerWidth; - - if (viewportWidth >= state.minViewportWidth && state.currentMode === 'single') { - if (!hasManualDualPageOverride()) { - return setDualPageMode(container, state, 'dual', false, onModeChange); - } - } else if (viewportWidth < state.minViewportWidth && state.currentMode === 'dual') { - return setDualPageMode(container, state, 'single', false, onModeChange); - } - - return state; -} - -function setDualPageMode( - container: HTMLElement, - state: PDFDualPageViewState, - mode: DualPageMode, - manual: boolean, - onModeChange: (mode: DualPageMode) => void -): PDFDualPageViewState { - if (state.currentMode === mode) return state; - - container.classList.remove('pdf-single-page', 'pdf-dual-page'); - container.classList.add(mode === 'dual' ? 'pdf-dual-page' : 'pdf-single-page'); - - if (manual) { - setManualDualPageOverride(mode); - } - - onModeChange(mode); - - return { ...state, currentMode: mode }; -} - -function toggleDualPageMode( - container: HTMLElement, - state: PDFDualPageViewState, - onModeChange: (mode: DualPageMode) => void -): PDFDualPageViewState { - const newMode = state.currentMode === 'single' ? 'dual' : 'single'; - return setDualPageMode(container, state, newMode, true, onModeChange); -} - -function getDualPagePagePair( - state: PDFDualPageViewState, - currentPage: number, - totalPages: number -): { left?: number; right: number } { - if (state.currentMode === 'single') { - return { right: currentPage }; - } - - if (currentPage % 2 === 1) { - return { - left: currentPage > 1 ? currentPage - 1 : undefined, - right: currentPage - }; - } else { - return { - left: currentPage, - right: currentPage < totalPages ? currentPage + 1 : currentPage - }; - } -} - -function hasManualDualPageOverride(): boolean { - return localStorage.getItem('pdf-dual-page-manual') === 'true'; -} - -function setManualDualPageOverride(mode: DualPageMode): void { - localStorage.setItem('pdf-dual-page-manual', 'true'); - localStorage.setItem('pdf-dual-page-mode', mode); -} - -function getDualPageStyles(): string { - return ` - .pdf-dual-page .pdf-page-container { - display: inline-block; - vertical-align: top; - width: 50%; - } - - .pdf-dual-page .pdf-scroll-container { - display: flex; - flex-wrap: wrap; - justify-content: center; - } - - .pdf-single-page .pdf-page-container { - display: block; - width: 100%; - } - `; -} -``` - -### 6.13 PDF Mini-Map Navigation - -**File:** `web/src/reader/pdf/pdf-minimap.ts` - -```typescript -// Mini-map navigation for PDF pages -// Procedural implementation (no OOP) - -interface PDFMiniMapState { - miniMap: HTMLElement; - currentPage: number; - totalPages: number; - thumbnails: Map; - onPageNavigate: (pageNumber: number) => void; -} - -function createPDFMiniMap( - container: HTMLElement, - onPageNavigate: (pageNumber: number) => void -): PDFMiniMapState { - const miniMap = createMiniMapElement(container); - container.appendChild(miniMap); - - return { - miniMap, - currentPage: 1, - totalPages: 0, - thumbnails: new Map(), - onPageNavigate - }; -} - -function createMiniMapElement(container: HTMLElement): HTMLElement { - const miniMap = document.createElement('div'); - miniMap.className = 'pdf-minimap'; - miniMap.innerHTML = ` -
Pages
-
-
- `; - - const style = document.createElement('style'); - style.textContent = getMiniMapStyles(); - miniMap.appendChild(style); - - return miniMap; -} - -async function initializePDFMiniMap( - state: PDFMiniMapState, - totalPages: number, - renderThumbnail: (page: number) => Promise -): Promise { - const newState = { ...state, totalPages }; - - await generateMiniMapThumbnails(newState, renderThumbnail); - setupMiniMapEventListeners(newState); - - return newState; -} - -async function generateMiniMapThumbnails( - state: PDFMiniMapState, - renderThumbnail: (page: number) => Promise -): Promise { - const container = state.miniMap.querySelector('.pdf-minimap-thumbnails') as HTMLElement; - container.innerHTML = ''; - - for (let page = 1; page <= state.totalPages; page++) { - try { - const thumbnail = await renderThumbnail(page); - thumbnail.className = 'pdf-minimap-thumbnail'; - thumbnail.dataset.pageNumber = page.toString(); - thumbnail.style.width = '80px'; - thumbnail.style.height = 'auto'; - thumbnail.style.cursor = 'pointer'; - thumbnail.style.marginBottom = '4px'; - - container.appendChild(thumbnail); - state.thumbnails.set(page, thumbnail); - } catch (error) { - console.error(`Failed to generate thumbnail for page ${page}:`, error); - } - } -} - -function setupMiniMapEventListeners(state: PDFMiniMapState): void { - const container = state.miniMap.querySelector('.pdf-minimap-thumbnails'); - - container?.addEventListener('click', (e) => { - const thumbnail = (e.target as HTMLElement).closest('.pdf-minimap-thumbnail') as HTMLElement; - if (thumbnail) { - const pageNumber = parseInt(thumbnail.dataset.pageNumber || '1'); - state.onPageNavigate(pageNumber); - } - }); -} - -function updateMiniMapCurrentPage(state: PDFMiniMapState, pageNumber: number): PDFMiniMapState { - const indicator = state.miniMap.querySelector('.pdf-minimap-indicator') as HTMLElement; - const thumbnail = state.thumbnails.get(pageNumber); - - if (thumbnail && indicator) { - const rect = thumbnail.getBoundingClientRect(); - indicator.style.top = `${thumbnail.offsetTop}px`; - indicator.style.height = `${rect.height}px`; - } - - state.thumbnails.forEach((thumb, page) => { - if (page === pageNumber) { - thumb.style.outline = '2px solid var(--accent)'; - thumb.style.opacity = '1'; - } else { - thumb.style.outline = 'none'; - thumb.style.opacity = '0.7'; - } - }); - - return { ...state, currentPage: pageNumber }; -} - -function showMiniMap(state: PDFMiniMapState): void { - state.miniMap.style.display = 'block'; -} - -function hideMiniMap(state: PDFMiniMapState): void { - state.miniMap.style.display = 'none'; -} - -function toggleMiniMap(state: PDFMiniMapState): void { - const isVisible = state.miniMap.style.display !== 'none'; - state.miniMap.style.display = isVisible ? 'none' : 'block'; -} - -function getMiniMapStyles(): string { - return ` - .pdf-minimap { - position: fixed; - right: 20px; - top: 50%; - transform: translateY(-50%); - width: 100px; - max-height: 80vh; - background: var(--bg-primary); - border: 1px solid var(--text-secondary); - border-radius: 8px; - padding: 8px; - overflow-y: auto; - z-index: 1000; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - } - - .pdf-minimap-header { - font-size: 12px; - font-weight: bold; - text-align: center; - margin-bottom: 8px; - color: var(--text-primary); - } - - .pdf-minimap-thumbnails { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - } - - .pdf-minimap-thumbnail { - transition: outline 0.2s, opacity 0.2s; - border-radius: 2px; - } - - .pdf-minimap-thumbnail:hover { - opacity: 1 !important; - outline: 1px solid var(--text-secondary) !important; - } - - .pdf-minimap-indicator { - position: absolute; - left: 0; - right: 0; - border-left: 3px solid var(--accent); - pointer-events: none; - transition: top 0.3s ease-out; - } - `; -} -``` - -### 6.14 Navigator Panel (Affinity Image Editor Style) - -**File:** `web/src/reader/navigator-panel.ts` - -```typescript -// Navigator panel - shows full page with draggable viewport box -// Affinity/Photoshop-style mini-map for page navigation -// Procedural implementation (no OOP) - -import { loadSettings } from "./settings-manager"; - -interface NavigatorState { - panelId: string; - container: HTMLElement; - viewport: HTMLElement; - currentPage: number; - totalPages: number; - scale: number; - contentImage: HTMLImageElement | null; - isDragging: boolean; -} - -function initializeNavigator(containerSelector: string): NavigatorState { - const container = document.querySelector(containerSelector) as HTMLElement; - if (!container) throw new Error("Navigator container not found"); - - const viewport = document.createElement('div'); - viewport.className = 'navigator-viewport-box'; - viewport.style.cssText = ` - position: absolute; - border: 2px solid var(--accent-color, #3b82f6); - background: rgba(59, 130, 246, 0.1); - cursor: move; - z-index: 10; - `; - - container.appendChild(viewport); - - const state: NavigatorState = { - panelId: 'navigator', - container, - viewport, - currentPage: 1, - totalPages: 1, - scale: 0.1, - contentImage: null, - isDragging: false - }; - - setupNavigatorDragHandler(state); - return state; -} - -// Setup draggable viewport box within navigator -function setupNavigatorDragHandler(state: NavigatorState): void { - state.viewport.addEventListener('mousedown', (e) => { - e.preventDefault(); - state.isDragging = true; - state.viewport.style.cursor = 'grabbing'; - }); - - document.addEventListener('mousemove', (e) => { - if (!state.isDragging || !state.contentImage) return; - - const containerRect = state.container.getBoundingClientRect(); - const imgRect = state.contentImage.getBoundingClientRect(); - - // Calculate position relative to scaled image - const relX = (e.clientX - imgRect.left) / imgRect.width; - const relY = (e.clientY - imgRect.top) / imgRect.height; - - // Update main viewer's position (call external handler) - const mainViewer = document.getElementById('reader-content'); - if (mainViewer) { - mainViewer.dataset.panX = relX.toString(); - mainViewer.dataset.panY = relY.toString(); - // Dispatch event for main viewer to handle - mainViewer.dispatchEvent(new CustomEvent('navigator-pan', { - detail: { x: relX, y: relY } - })); - } - }); - - document.addEventListener('mouseup', () => { - state.isDragging = false; - state.viewport.style.cursor = 'move'; - }); -} - -// Update navigator with current page image -async function updateNavigatorContent(state: NavigatorState, pageNumber: number): Promise { - state.currentPage = pageNumber; - - // Get current page image (from PDF viewer, comic reader, or manga reader) - const contentArea = document.getElementById('reader-content'); - const img = contentArea?.querySelector('img, canvas') as HTMLImageElement | HTMLCanvasElement | null; - - if (!img) return; - - // Create thumbnail version for navigator - const thumb = document.createElement('img'); - thumb.src = img.src || (img as HTMLCanvasElement).toDataURL(); - thumb.style.cssText = ` - width: 100%; - height: auto; - display: block; - pointer-events: none; - `; - - // Clear and populate container - state.container.innerHTML = ''; - state.container.appendChild(thumb); - state.contentImage = thumb; - - // Recreate viewport box - const viewport = document.createElement('div'); - viewport.className = 'navigator-viewport-box'; - viewport.style.cssText = ` - position: absolute; - border: 2px solid var(--accent-color, #3b82f6); - background: rgba(59, 130, 246, 0.1); - cursor: move; - z-index: 10; - width: ${100 / state.scale}%; - height: ${100 / state.scale}%; - `; - state.container.appendChild(viewport); - state.viewport = viewport; - - // Re-attach drag handler - setupNavigatorDragHandler(state); - - // Calculate viewport size relative to container - const containerRect = state.container.getBoundingClientRect(); - const viewportWidth = (containerRect.width / img.width) * 100; - const viewportHeight = (containerRect.height / img.height) * 100; - - viewport.style.width = `${viewportWidth}%`; - viewport.style.height = `${viewportHeight}%`; -} - -// Handle window resize -function handleNavigatorResize(state: NavigatorState): void { - if (state.contentImage) { - updateNavigatorContent(state, state.currentPage); - } -} - -export { initializeNavigator, updateNavigatorContent, handleNavigatorResize }; -``` - -**File:** `web/src/reader/pdf/pdf-rotation.ts` - -```typescript -// Handle rotated/landscape pages in PDFs -// Procedural implementation (no OOP) - -interface PDFRotationState { - rotations: Map; -} - -function createPDFRotation(): PDFRotationState { - return { - rotations: new Map() - }; -} - -async function loadPDFPageRotations( - state: PDFRotationState, - doc: any -): Promise { - const rotations = new Map(); - - for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { - const page = await doc.getPage(pageNum); - const viewport = page.getViewport({ scale: 1 }); - const rotation = viewport.rotation; - - if (rotation !== 0) { - rotations.set(pageNum, rotation); - } - } - - return { ...state, rotations }; -} - -function getPDFPageRotation(state: PDFRotationState, pageNumber: number): number { - return state.rotations.get(pageNumber) || 0; -} - -function hasPDFPageRotation(state: PDFRotationState, pageNumber: number): boolean { - return state.rotations.has(pageNumber); -} - -function applyPDFRotation( - state: PDFRotationState, - canvas: HTMLCanvasElement, - pageNumber: number -): void { - const rotation = getPDFPageRotation(state, pageNumber); - - if (rotation === 0) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - ctx.save(); - ctx.translate(canvas.width / 2, canvas.height / 2); - ctx.rotate((rotation * Math.PI) / 180); - ctx.translate(-canvas.width / 2, -canvas.height / 2); - ctx.restore(); -} - -function getPDFAdjustedViewport( - state: PDFRotationState, - pageNumber: number, - viewport: any -): any { - const rotation = getPDFPageRotation(state, pageNumber); - - if (rotation === 0 || rotation === 180) { - return viewport; - } - - return { - ...viewport, - width: viewport.height, - height: viewport.width - }; -} -``` - -### 6.15 PDF Variable Page Sizes - -**File:** `web/src/reader/pdf/pdf-page-sizes.ts` - -```typescript -// Handle PDFs with variable page sizes -// Procedural implementation (no OOP) - -interface PageInfo { - pageNumber: number; - width: number; - height: number; - rotation: number; -} - -interface PDFPageSizesState { - pageSizes: Map; - defaultSize: { width: number; height: number }; -} - -function createPDFPageSizes(): PDFPageSizesState { - return { - pageSizes: new Map(), - defaultSize: { width: 595, height: 842 } - }; -} - -async function loadPDFPageSizes( - state: PDFPageSizesState, - doc: any -): Promise { - const pageSizes = new Map(); - - for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { - const page = await doc.getPage(pageNum); - const viewport = page.getViewport({ scale: 1 }); - - const pageInfo: PageInfo = { - pageNumber: pageNum, - width: viewport.width, - height: viewport.height, - rotation: viewport.rotation - }; - - pageSizes.set(pageNum, pageInfo); - } - - return { ...state, pageSizes }; -} - -function getPDFPageSize( - state: PDFPageSizesState, - pageNumber: number -): PageInfo | null { - return state.pageSizes.get(pageNumber) || null; -} - -function isPDFPageLandscape( - state: PDFPageSizesState, - pageNumber: number -): boolean { - const size = getPDFPageSize(state, pageNumber); - if (!size) return false; - - const effectiveWidth = size.rotation === 90 || size.rotation === 270 - ? size.height - : size.width; - const effectiveHeight = size.rotation === 90 || size.rotation === 270 - ? size.width - : size.height; - - return effectiveWidth > effectiveHeight; -} - -function getPDFCommonSize(state: PDFPageSizesState): { width: number; height: number } { - if (state.pageSizes.size === 0) { - return state.defaultSize; - } - - const sizeGroups: Map = new Map(); - - state.pageSizes.forEach((size) => { - const key = getPageSizeKey(size.width, size.height); - const existing = sizeGroups.get(key); - - if (existing) { - existing.count++; - } else { - sizeGroups.set(key, { width: size.width, height: size.height, count: 1 }); - } - }); - - let mostCommon = state.defaultSize; - let maxCount = 0; - - sizeGroups.forEach((size) => { - if (size.count > maxCount) { - maxCount = size.count; - mostCommon = { width: size.width, height: size.height }; - } - }); - - return mostCommon; -} - -function getPageSizeKey(width: number, height: number): string { - const w = Math.round(width / 10) * 10; - const h = Math.round(height / 10) * 10; - return `${w}x${h}`; -} -``` - ---- - -## 7. Panel Detection Implementation - -### 7.1 Grid-Based Detection (Primary) - -**File:** `web/src/reader/comic/panel-detector.ts` - -```typescript -// Grid-based panel detection (fast, lightweight) - -interface GridConfig { - rows: number; - cols: number; -} - -function detectPanelsGrid( - imageData: ImageData, - config: GridConfig = { rows: 3, cols: 3 } -): Panel[] { - const panels: Panel[] = []; - const cellWidth = imageData.width / config.cols; - const cellHeight = imageData.height / config.rows; - - for (let y = 0; y < config.rows; y++) { - for (let x = 0; x < config.cols; x++) { - const cell = extractCell(imageData, x, y, cellWidth, cellHeight); - - if (!isEmpty(cell)) { - panels.push({ - id: `panel-${panels.length}`, - x: (x / config.cols) * 100, - y: (y / config.rows) * 100, - width: (1 / config.cols) * 100, - height: (1 / config.rows) * 100, - reading_order: panels.length - }); - } - } - } - - return mergeAdjacentPanels(panels); -} - -function isEmpty(cellData: ImageData): boolean { - // Simple edge detection to find empty space - // Count white/transparent pixels - let emptyPixels = 0; - const totalPixels = cellData.width * cellData.height; - const threshold = 0.95; // 95% empty = empty cell - - for (let i = 0; i < cellData.data.length; i += 4) { - const r = cellData.data[i]; - const g = cellData.data[i + 1]; - const b = cellData.data[i + 2]; - const a = cellData.data[i + 3]; - - // Consider white or transparent as empty - if (a < 10 || (r > 250 && g > 250 && b > 250)) { - emptyPixels++; - } - } - - return (emptyPixels / totalPixels) > threshold; -} - -function mergeAdjacentPanels(panels: Panel[]): Panel[] { - // Merge panels that are next to each other - // Simplified algorithm - can be enhanced - const merged: Panel[] = []; - const used = new Set(); - - for (let i = 0; i < panels.length; i++) { - if (used.has(i)) continue; - - let current = { ...panels[i] }; - used.add(i); - - // Look for adjacent panels - for (let j = i + 1; j < panels.length; j++) { - if (used.has(j)) continue; - if (isAdjacent(current, panels[j])) { - current = mergePanels(current, panels[j]); - used.add(j); - } - } - - merged.push(current); - } - - return merged; -} -``` - -### 7.2 ML-Based Detection (Enhancement) - -**File:** `web/src/reader/comic/panel-ml-detector.ts` - -```typescript -// ML-based panel detection (optional, lazy-loaded) -// Uses TensorFlow.js for accurate panel detection - -let modelLoaded = false; -let panelModel: any = null; - -async function loadMLModel(): Promise { - if (modelLoaded) return; - - try { - // Lazy-load TensorFlow.js - await import('@tensorflow/tfjs'); - - // Load pre-trained model for panel detection - // Model should be small (~2MB) and fast - panelModel = await loadModel('/static/models/panel-detection/model.json'); - modelLoaded = true; - } catch (error) { - console.error('Failed to load ML model:', error); - // Fall back to grid-based detection - } -} - -async function detectPanelsML(imageData: ImageData): Promise { - if (!modelLoaded) { - await loadMLModel(); - } - - if (!panelModel) { - // Fall back to grid-based - return detectPanelsGrid(imageData); - } - - // Run ML model - const predictions = await panelModel.detect(imageData); - - // Convert predictions to Panel format - return predictions.map((pred: any, index: number) => ({ - id: `ml-panel-${index}`, - x: pred.bbox.x * 100, - y: pred.bbox.y * 100, - width: pred.bbox.width * 100, - height: pred.bbox.height * 100, - reading_order: index - })); -} -``` - -### 7.3 Manual Override - -**File:** `web/src/reader/comic/panel-editor.ts` - -```typescript -// Manual panel editor for admins/power users - -function openPanelEditor(pageNumber: number): void { - const modal = document.getElementById('panel-editor-modal'); - modal?.classList.remove('hidden'); - - // Load page image - const canvas = document.getElementById('panel-editor-canvas') as HTMLCanvasElement; - const ctx = canvas?.getContext('2d'); - - // Load image and draw to canvas - loadImageForPage(pageNumber).then((image) => { - canvas!.width = image.width; - canvas!.height = image.height; - ctx?.drawImage(image, 0, 0); - - // Allow user to draw panels - enablePanelDrawing(canvas!); - }); -} - -function enablePanelDrawing(canvas: HTMLCanvasElement): void { - let isDrawing = false; - let startX = 0; - let startY = 0; - - canvas.addEventListener('mousedown', (e) => { - isDrawing = true; - startX = e.offsetX; - startY = e.offsetY; - }); - - canvas.addEventListener('mousemove', (e) => { - if (!isDrawing) return; - - // Draw selection rectangle - const ctx = canvas.getContext('2d'); - ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY); - }); - - canvas.addEventListener('mouseup', (e) => { - if (!isDrawing) return; - isDrawing = false; - - // Save panel - const panel: Panel = { - id: `manual-${Date.now()}`, - x: (startX / canvas.width) * 100, - y: (startY / canvas.height) * 100, - width: ((e.offsetX - startX) / canvas.width) * 100, - height: ((e.offsetY - startY) / canvas.height) * 100, - reading_order: 0 // Will be set by server - }; - - saveManualPanel(panel); - }); -} - -async function saveManualPanel(panel: Panel): Promise { - const mediaItemId = document.body.dataset.mediaItemId; - const pageNumber = getCurrentPageNumber(); - - await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, { - detection_method: 'manual', - panels: [panel] - }); - - // Reload with new panels - loadPage(pageNumber); -} -``` - -### 7.4 Page Order Presets - -**File:** `web/src/reader/comic/page-order.ts` - -```typescript -// Page order presets for manga/comics -// Auto-detect Japanese vs Western reading order -// Allow user override in case detection is wrong -// Procedural implementation (no OOP) - -type PageOrderMode = 'auto' | 'japanese' | 'western'; - -interface PageOrderConfig { - mode: PageOrderMode; - detectedOrder: PageOrderMode; - userOverride: boolean; -} - -interface PageOrderState { - config: PageOrderConfig; - totalPages: number; -} - -// Detect page order based on filename patterns -function detectPageOrder(pageNames: string[]): PageOrderMode { - if (pageNames.length < 2) return 'western'; - - const firstPage = pageNames[0].toLowerCase(); - const lastPage = pageNames[pageNames.length - 1].toLowerCase(); - - const hasFrontCover = /cover|front|001/.test(firstPage); - const hasBackCover = /back|end|最后的/.test(lastPage); - - if (hasFrontCover && !hasBackCover) { - return 'western'; - } - if (hasBackCover && !hasFrontCover) { - return 'japanese'; - } - - const chapterMatches = pageNames.filter(n => /ch-\d+|chapter/i.test(n)); - if (chapterMatches.length > 0) { - const firstChapter = chapterMatches[0]; - const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || '0'); - return pageNum > 0 ? 'western' : 'japanese'; - } - - return 'western'; -} - -function createPageOrderState(totalPages: number, pageNames: string[]): PageOrderState { - const detectedOrder = detectPageOrder(pageNames); - - return { - config: { - mode: 'auto', - detectedOrder, - userOverride: false - }, - totalPages - }; -} - -function setPageOrderMode(state: PageOrderState, mode: PageOrderMode): PageOrderState { - return { - ...state, - config: { - ...state.config, - mode, - userOverride: mode !== 'auto' - } - }; -} - -function getPageOrder(state: PageOrderState): PageOrderMode { - if (state.config.mode === 'auto') { - return state.config.detectedOrder; - } - return state.config.mode; -} - -function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] { - const order = getPageOrder(state); - - if (order === 'japanese') { - return [...pageNumbers].reverse(); - } - - return pageNumbers; -} - -function getDisplayPageNumber( - state: PageOrderState, - actualPage: number -): number { - const order = getPageOrder(state); - - if (order === 'japanese') { - return state.totalPages - actualPage + 1; - } - - return actualPage; -} -``` - -### 7.5 Extended Keyboard Shortcuts - -**File:** `web/src/reader/keyboard-shortcuts.ts` - -```typescript -// Extended keyboard shortcuts for all readers -// Procedural implementation (no OOP) - -interface KeyboardShortcutHandler { - onNextPage: () => void; - onPreviousPage: () => void; - onNextChapter: () => void; - onPreviousChapter: () => void; - onGoToPage: (page: number) => void; - onToggleBookmark: () => void; - onZoomIn: () => void; - onZoomOut: () => void; - onToggleFullscreen: () => void; - onClose: () => void; -} - -function setupKeyboardShortcuts( - container: HTMLElement, - handlers: KeyboardShortcutHandler, - maxPage: number -): void { - 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(); - handlers.onNextPage(); - break; - - case 'ArrowLeft': - case 'PageUp': - case 'h': - e.preventDefault(); - handlers.onPreviousPage(); - break; - - case 'ArrowUp': - case 'k': - e.preventDefault(); - handlers.onPreviousPage(); - break; - - case 'ArrowDown': - case 'j': - e.preventDefault(); - handlers.onNextPage(); - break; - - case ' ': - e.preventDefault(); - handlers.onNextPage(); - break; - - case 'Home': - e.preventDefault(); - handlers.onGoToPage(1); - break; - - case 'End': - e.preventDefault(); - handlers.onGoToPage(maxPage); - break; - - case 'b': - if (!e.ctrlKey && !e.metaKey) { - e.preventDefault(); - handlers.onToggleBookmark(); - } - break; - - case '+': - case '=': - e.preventDefault(); - handlers.onZoomIn(); - break; - - case '-': - case '_': - e.preventDefault(); - handlers.onZoomOut(); - break; - - case '0': - e.preventDefault(); - handlers.onZoomIn(); - handlers.onZoomIn(); - handlers.onZoomIn(); - break; - - case 'f': - if (!e.ctrlKey && !e.metaKey) { - e.preventDefault(); - handlers.onToggleFullscreen(); - } - break; - - case 'Escape': - e.preventDefault(); - handlers.onClose(); - break; - - default: - if (e.key >= '1' && e.key <= '9') { - const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage); - e.preventDefault(); - handlers.onGoToPage(targetPage); - } - } - }); -} - -function showShortcutHelp(): void { - 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
-
F Fullscreen
-
1-9 Jump to %
-
- -
- `; - document.body.appendChild(help); - help.addEventListener('click', (e) => { - if (e.target === help) help.remove(); - }); -} -``` - -### 7.6 Page Slider/Scrubber - -**File:** `web/src/reader/comic/page-scrubber.ts` - -```typescript -// Page slider/scrubber for quick navigation -// Procedural implementation (no OOP) - -interface PageScrubberState { - currentPage: number; - totalPages: number; - container: HTMLElement; -} - -function createPageScrubber( - container: HTMLElement, - currentPage: number, - totalPages: number -): PageScrubberState { - const state: PageScrubberState = { - currentPage, - totalPages, - container - }; - - renderPageScrubber(state); - return state; -} - -function renderPageScrubber(state: PageScrubberState): void { - const existing = state.container.querySelector('.page-scrubber'); - existing?.remove(); - - const scrubber = document.createElement('div'); - scrubber.className = 'page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40'; - scrubber.innerHTML = ` - ${state.currentPage} - - ${state.totalPages} - `; - - const slider = scrubber.querySelector('.page-slider') as HTMLInputElement; - slider.addEventListener('input', (e) => { - const targetPage = parseInt((e.target as HTMLInputElement).value); - updatePageScrubber(state, targetPage); - }); - - slider.addEventListener('change', () => { - const targetPage = parseInt(slider.value); - navigateToPage(targetPage); - }); - - state.container.appendChild(scrubber); -} - -function updatePageScrubber(state: PageScrubberState, currentPage: number): PageScrubberState { - const newState = { ...state, currentPage }; - - const label = state.container.querySelector('.page-label'); - if (label) { - label.textContent = String(currentPage); - } - - return newState; -} - -function showPageScrubber(state: PageScrubberState): void { - const scrubber = state.container.querySelector('.page-scrubber'); - scrubber?.classList.remove('hidden'); -} - -function hidePageScrubber(state: PageScrubberState): void { - const scrubber = state.container.querySelector('.page-scrubber'); - scrubber?.classList.add('hidden'); -} - -function navigateToPage(page: number): void { - window.dispatchEvent(new CustomEvent('navigate-to-page', { detail: { page } })); -} -``` - -### 7.7 Gesture Controls - -**File:** `web/src/reader/gestures.ts` - -```typescript -// Touch gesture controls for mobile/tablet -// Procedural implementation (no OOP) - -interface GestureHandlers { - onSwipeLeft: () => void; - onSwipeRight: () => void; - onSwipeUp: () => void; - onSwipeDown: () => void; - onPinch: (scale: number) => void; - onTap: () => void; - onDoubleTap: () => void; -} - -interface GestureState { - touchStartX: number; - touchStartY: number; - touchStartTime: number; - lastTapTime: number; - initialPinchDistance: number; - scale: number; -} - -function setupGestureControls( - container: HTMLElement, - handlers: GestureHandlers -): void { - let state: GestureState = { - 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) { - handlers.onDoubleTap(); - state.lastTapTime = 0; - } else { - state.lastTapTime = now; - setTimeout(() => { - if (state.lastTapTime !== 0) { - handlers.onTap(); - } - }, 300); - } - return; - } - - const minSwipeDistance = 50; - const maxSwipeTime = 500; - - if (deltaTime > maxSwipeTime) return; - - if (Math.abs(deltaX) > Math.abs(deltaY)) { - if (deltaX > minSwipeDistance) { - handlers.onSwipeRight(); - } else if (deltaX < -minSwipeDistance) { - handlers.onSwipeLeft(); - } - } else { - if (deltaY > minSwipeDistance) { - handlers.onSwipeDown(); - } else if (deltaY < -minSwipeDistance) { - handlers.onSwipeUp(); - } - } - }, { 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; - state.scale = scale; - handlers.onPinch(scale); - } - } - }, { passive: true }); -} - -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); -} -``` - -### 7.8 Panel Gap Controls - -**File:** `web/src/reader/comic/panel-gap.ts` - -```typescript -// Adjustable panel gap controls -// Procedural implementation (no OOP) - -interface PanelGapState { - gapSize: number; - showBorders: boolean; -} - -function createPanelGapState(initialGap: number = 4): PanelGapState { - return { - gapSize: initialGap, - showBorders: false - }; -} - -function setPanelGap(state: PanelGapState, gap: number): PanelGapState { - const clampedGap = Math.max(0, Math.min(20, gap)); - - document.documentElement.style.setProperty('--panel-gap', `${clampedGap}px`); - - return { ...state, gapSize: clampedGap }; -} - -function increasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { - return setPanelGap(state, state.gapSize + amount); -} - -function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { - return setPanelGap(state, state.gapSize - amount); -} - -function togglePanelBorders(state: PanelGapState): PanelGapState { - const newShowBorders = !state.showBorders; - - document.documentElement.style.setProperty( - '--panel-border-width', - newShowBorders ? '1px' : '0px' - ); - - return { ...state, showBorders: newShowBorders }; -} - -function renderPanelGapControls(container: HTMLElement, state: PanelGapState): void { - const existing = container.querySelector('.panel-gap-controls'); - existing?.remove(); - - const controls = document.createElement('div'); - controls.className = 'panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40'; - controls.innerHTML = ` - - ${state.gapSize}px - - - `; - - controls.querySelector('.panel-gap-increase')?.addEventListener('click', () => { - const newState = increasePanelGap(state); - updatePanelGapUI(controls, newState); - }); - - controls.querySelector('.panel-gap-decrease')?.addEventListener('click', () => { - const newState = decreasePanelGap(state); - updatePanelGapUI(controls, newState); - }); - - controls.querySelector('.panel-gap-borders')?.addEventListener('click', () => { - const newState = togglePanelBorders(state); - updatePanelGapUI(controls, newState); - }); - - container.appendChild(controls); -} - -function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void { - const gapLabel = container.querySelector('span'); - if (gapLabel) { - gapLabel.textContent = `${state.gapSize}px`; - } - - const bordersBtn = container.querySelector('.panel-gap-borders'); - if (bordersBtn) { - bordersBtn.textContent = state.showBorders ? '▦' : '▢'; - } -} - -const panelGapCSS = ` - :root { - --panel-gap: 4px; - --panel-border-width: 0px; - } - - .panel-zoom-container { - gap: var(--panel-gap); - } - - .panel-zoom-container.with-borders { - background: rgba(255, 255, 255, 0.1); - padding: var(--panel-gap); - } - - .panel-borders { - border: var(--panel-border-width) dashed rgba(255, 255, 255, 0.3); - } -`; -``` - -### 7.9 Background Color Options - -**File:** `web/src/reader/comic/background-color.ts` - -```typescript -// Background color options for manga/comics -// Procedural implementation (no OOP) - -type BackgroundColor = 'black' | 'white' | 'gray' | 'sepia' | 'custom'; - -interface BackgroundColorState { - current: BackgroundColor; - customColor: string; -} - -const backgroundColors: Record = { - black: '#000000', - white: '#ffffff', - gray: '#333333', - sepia: '#f4ecd8', - custom: '' -}; - -function createBackgroundColorState( - initial: BackgroundColor = 'black' -): BackgroundColorState { - return { - current: initial, - customColor: '#000000' - }; -} - -function setBackgroundColor( - state: BackgroundColorState, - color: BackgroundColor, - customColor?: string -): BackgroundColorState { - const newState: BackgroundColorState = { - current: color, - customColor: customColor || state.customColor - }; - - const bgColor = color === 'custom' - ? newState.customColor - : backgroundColors[color]; - - document.documentElement.style.setProperty('--reader-bg-color', bgColor); - - const viewer = document.querySelector('.reader-content'); - if (viewer) { - viewer.style.backgroundColor = bgColor; - } - - localStorage.setItem('reader-background-color', color); - - return newState; -} - -function toggleBackgroundColor(state: BackgroundColorState): BackgroundColorState { - const order: BackgroundColor[] = ['black', 'white', 'gray', 'sepia']; - const currentIndex = order.indexOf(state.current); - const nextIndex = (currentIndex + 1) % order.length; - - return setBackgroundColor(state, order[nextIndex]); -} - -function renderBackgroundColorPicker( - container: HTMLElement, - state: BackgroundColorState -): void { - const existing = container.querySelector('.background-color-picker'); - existing?.remove(); - - const picker = document.createElement('div'); - picker.className = 'background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40'; - - const colors: BackgroundColor[] = ['black', 'white', 'gray', 'sepia']; - - colors.forEach(color => { - const btn = document.createElement('button'); - btn.className = `w-8 h-8 rounded-full border-2 ${ - state.current === color ? 'border-blue-500' : 'border-transparent' - }`; - btn.style.backgroundColor = backgroundColors[color]; - btn.title = color.charAt(0).toUpperCase() + color.slice(1); - btn.addEventListener('click', () => { - const newState = setBackgroundColor(state, color); - updateBackgroundColorUI(picker, newState); - }); - picker.appendChild(btn); - }); - - container.appendChild(picker); -} - -function updateBackgroundColorUI(container: HTMLElement, state: BackgroundColorState): void { - const buttons = container.querySelectorAll('button'); - const colors: BackgroundColor[] = ['black', 'white', 'gray', 'sepia']; - - buttons.forEach((btn, index) => { - btn.classList.toggle('border-blue-500', colors[index] === state.current); - }); -} -``` - -### 7.10 Chapter Markers - -**File:** `web/src/reader/comic/chapter-markers.ts` - -```typescript -// Chapter markers for manga/comics -// Visual indicators for chapter boundaries -// Procedural implementation (no OOP) - -interface ChapterInfo { - chapterNumber: number; - pageStart: number; - pageEnd: number; - title?: string; -} - -interface ChapterMarkerState { - chapters: ChapterInfo[]; - currentChapter: number; - showMarkers: boolean; -} - -function createChapterMarkerState( - chapters: ChapterInfo[], - currentPage: number -): ChapterMarkerState { - const currentChapter = chapters.find( - c => currentPage >= c.pageStart && currentPage <= c.pageEnd - )?.chapterNumber || 1; - - return { - chapters, - currentChapter, - showMarkers: true - }; -} - -function renderChapterMarkers( - container: HTMLElement, - state: ChapterMarkerState -): void { - if (!state.showMarkers) return; - - const markersContainer = document.createElement('div'); - markersContainer.className = 'chapter-markers absolute left-0 right-0 pointer-events-none z-10'; - - state.chapters.forEach(chapter => { - const marker = document.createElement('div'); - marker.className = 'chapter-marker flex items-center gap-2 text-sm text-gray-400'; - - const isCurrentChapter = chapter.chapterNumber === state.currentChapter; - - marker.style.position = 'absolute'; - marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`; - marker.style.left = '10px'; - - marker.innerHTML = ` - - ${chapter.title || `Chapter ${chapter.chapterNumber}`} - - p.${chapter.pageStart} - ${isCurrentChapter ? '' : ''} - `; - - markersContainer.appendChild(marker); - }); - - const existing = container.querySelector('.chapter-markers'); - existing?.remove(); - container.appendChild(markersContainer); -} - -function updateCurrentChapter( - state: ChapterMarkerState, - currentPage: number -): ChapterMarkerState { - const currentChapter = state.chapters.find( - c => currentPage >= c.pageStart && currentPage <= c.pageEnd - )?.chapterNumber || state.currentChapter; - - if (currentChapter !== state.currentChapter) { - const newState = { ...state, currentChapter }; - - const markers = document.querySelector('.chapter-markers'); - if (markers) { - renderChapterMarkers(markers.parentElement!, newState); - } - - return newState; - } - - return state; -} - -function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState { - const newState = { ...state, showMarkers: !state.showMarkers }; - - const markers = document.querySelector('.chapter-markers'); - if (markers) { - markers.classList.toggle('hidden', !newState.showMarkers); - } - - return newState; -} - -function scrollToChapter( - state: ChapterMarkerState, - chapterNumber: number -): void { - const chapter = state.chapters.find(c => c.chapterNumber === chapterNumber); - if (chapter) { - window.dispatchEvent(new CustomEvent('navigate-to-page', { - detail: { page: chapter.pageStart } - })); - } -} - -const chapterMarkerCSS = ` - .chapter-marker { - padding: 4px 8px; - margin-left: -18px; - opacity: 0.7; - transition: opacity 0.2s; - } - - .chapter-marker:hover { - opacity: 1; - } - - .chapter-marker .current-indicator { - color: #3b82f6; - animation: pulse 2s infinite; - } - - @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } - } - - .chapter-marker-line { - position: absolute; - left: 0; - right: 0; - height: 1px; - background: linear-gradient(to right, rgba(255,255,255,0.1), transparent); - } -`; -``` - ---- - -## 8. Manga Reader Implementation - -Manga extends the comic reader with specialized reading modes: -- **RTL (Right-to-Left)** - Traditional Japanese manga reading -- **Vertical Scroll** - Webtoon/manhwa style (infinite vertical scroll) - -### 8.1 RTL Navigator (Right-to-Left Reading) - -**File:** `web/src/reader/manga/rtl-navigator.ts` - -```typescript -// Right-to-left navigation for manga -// Reverses page turn direction and key bindings - -interface RTLNavigatorState { - currentPage: number; - totalPages: number; - readingDirection: 'rtl' | 'ltr'; -} - -function createRTLNavigator(totalPages: number): RTLNavigatorState { - return { - currentPage: 1, - totalPages, - readingDirection: 'rtl' - }; -} - -function getNextPage(state: RTLNavigatorState): number { - // In RTL, "next" page means moving left (decreasing page number) - if (state.readingDirection === 'rtl') { - return Math.max(1, state.currentPage - 1); - } - return Math.min(state.totalPages, state.currentPage + 1); -} - -function getPreviousPage(state: RTLNavigatorState): number { - // In RTL, "previous" page means moving right (increasing page number) - if (state.readingDirection === 'rtl') { - return Math.min(state.totalPages, state.currentPage + 1); - } - return Math.max(1, state.currentPage - 1); -} - -function navigateToPage(state: RTLNavigatorState, pageNumber: number): RTLNavigatorState { - return { - ...state, - currentPage: Math.max(1, Math.min(state.totalPages, pageNumber)) - }; -} - -function getProgress(state: RTLNavigatorState): { current: number; total: number } { - return { - current: state.currentPage, - total: state.totalPages - }; -} - -function getReadingProgressPercentage(state: RTLNavigatorState): number { - return (state.currentPage / state.totalPages) * 100; -} -``` - -### 8.2 Vertical Scroll Mode (Webtoon Style) - -**File:** `web/src/reader/manga/vertical-scroll-mode.ts` - -```typescript -// Vertical scroll mode for webtoons/manhwa -// Infinite scroll with image loading and lazy rendering - -interface VerticalScrollState { - container: HTMLElement; - loadedPages: Set; - loadingPages: Set; - currentPage: number; - totalPages: number; - threshold: number; // Distance from bottom to trigger next page load - mediaItemId: string; -} - -function createVerticalScroll( - container: HTMLElement, - mediaItemId: string, - totalPages: number -): VerticalScrollState { - const state: VerticalScrollState = { - container, - loadedPages: new Set(), - loadingPages: new Set(), - currentPage: 1, - totalPages, - threshold: 500, // Load next page when 500px from bottom - mediaItemId - }; - - // Initial page load - loadPage(state, 1); - - // Setup scroll listener - setupScrollListener(state); - - return state; -} - -async function loadPage( - state: VerticalScrollState, - pageNumber: number -): Promise { - if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) { - return; - } - - state.loadingPages.add(pageNumber); - - try { - const token = localStorage.getItem('token'); - const response = await fetch( - `/readers/${state.mediaItemId}/pages/${pageNumber}`, - { - headers: { Authorization: `Bearer ${token}` } - } - ); - - if (!response.ok) { - throw new Error(`Failed to load page ${pageNumber}`); - } - - const blob = await response.blob(); - const imgUrl = URL.createObjectURL(blob); - - const pageContainer = document.createElement('div'); - pageContainer.className = 'vertical-page'; - pageContainer.dataset.pageNumber = pageNumber.toString(); - - const img = document.createElement('img'); - img.src = imgUrl; - img.alt = `Page ${pageNumber}`; - img.loading = 'lazy'; - - pageContainer.appendChild(img); - state.container.appendChild(pageContainer); - - state.loadedPages.add(pageNumber); - state.loadingPages.delete(pageNumber); - - // Load next pages proactively - if (pageNumber < state.totalPages) { - loadPage(state, pageNumber + 1); - if (pageNumber + 1 < state.totalPages) { - loadPage(state, pageNumber + 2); - } - } - } catch (error) { - console.error(`Failed to load page ${pageNumber}:`, error); - state.loadingPages.delete(pageNumber); - } -} - -function setupScrollListener(state: VerticalScrollState): void { - let scrollTimeout: number | undefined; - - state.container.addEventListener('scroll', () => { - clearTimeout(scrollTimeout); - scrollTimeout = window.setTimeout(() => { - checkScrollPosition(state); - }, 100); - }); -} - -function checkScrollPosition(state: VerticalScrollState): void { - const scrollBottom = state.container.scrollHeight - - state.container.scrollTop - - state.container.clientHeight; - - if (scrollBottom < state.threshold) { - const lastPage = Math.max(...state.loadedPages); - if (lastPage < state.totalPages) { - loadPage(state, lastPage + 1); - } - } - - // Update current page based on scroll position - const currentPage = getCurrentPageFromScroll(state); - if (currentPage !== state.currentPage) { - state.currentPage = currentPage; - // Dispatch event for progress tracking - window.dispatchEvent(new CustomEvent('page-change', { - detail: { page: currentPage } - })); - } -} - -function getCurrentPageFromScroll(state: VerticalScrollState): number { - const pages = state.container.querySelectorAll('.vertical-page'); - - for (const page of pages) { - const rect = page.getBoundingClientRect(); - const containerRect = state.container.getBoundingClientRect(); - - // Page is considered "current" if it's in the middle 50% of viewport - const pageMiddle = rect.top + rect.height / 2; - const viewportMiddle = containerRect.top + containerRect.height / 2; - - if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) { - return parseInt(page.dataset.pageNumber || '1'); - } - } - - return state.currentPage; -} - -function destroyVerticalScroll(state: VerticalScrollState): void { - // Clean up blob URLs - const images = state.container.querySelectorAll('img'); - images.forEach(img => { - const url = img.src; - if (url.startsWith('blob:')) { - URL.revokeObjectURL(url); - } - }); - - state.container.innerHTML = ''; - state.loadedPages.clear(); - state.loadingPages.clear(); -} -``` - -### 8.3 Reading Direction Detection - -**File:** `web/src/reader/manga/reading-direction.ts` - -```typescript -// Detect reading direction from metadata or user preference - -type ReadingDirection = 'auto' | 'ltr' | 'rtl' | 'vertical'; - -interface ReadingDirectionState { - direction: ReadingDirection; - detectedDirection: 'ltr' | 'rtl' | 'vertical'; - userPreference: ReadingDirection | null; -} - -async function detectReadingDirection( - metadata: MediaItemMetadata -): Promise { - // Check user preference first - const userPreference = await getUserReadingDirectionPreference(); - if (userPreference && userPreference !== 'auto') { - return { - direction: userPreference, - detectedDirection: 'ltr', // Default fallback - userPreference - }; - } - - // Detect from metadata - const detectedDirection = detectFromMetadata(metadata); - - return { - direction: 'auto', - detectedDirection, - userPreference: null - }; -} - -function detectFromMetadata(metadata: MediaItemMetadata): 'ltr' | 'rtl' | 'vertical' { - // Check manga_type field from database - const mangaType = (metadata as any).manga_type; - if (mangaType === 'yes_and_right_to_left' || mangaType === 'yes') { - return 'rtl'; - } - - // Check reading_direction field - const readingDirection = (metadata as any).reading_direction; - if (readingDirection === 'rtl' || readingDirection === 'vertical') { - return readingDirection; - } - - // Detect from filename - const filename = metadata.filePath.toLowerCase(); - if (filename.includes('manga') || filename.includes('manhwa') || filename.includes('webtoon')) { - return 'vertical'; - } - - // Default to LTR - return 'ltr'; -} - -async function getUserReadingDirectionPreference(): Promise { - const userId = localStorage.getItem('userId'); - if (!userId) return null; - - try { - const response = await fetch(`/api/users/${userId}/settings`); - if (!response.ok) return null; - - const settings = await response.json(); - return settings.reading_direction || null; - } catch { - return null; - } -} - -function getEffectiveDirection(state: ReadingDirectionState): 'ltr' | 'rtl' | 'vertical' { - if (state.direction !== 'auto') { - return state.direction as 'ltr' | 'rtl' | 'vertical'; - } - return state.detectedDirection; -} - -function shouldUseRTL(state: ReadingDirectionState): boolean { - return getEffectiveDirection(state) === 'rtl'; -} - -function shouldUseVerticalScroll(state: ReadingDirectionState): boolean { - return getEffectiveDirection(state) === 'vertical'; -} -``` - -### 8.4 Manga Settings Integration - -**File:** `web/src/reader/manga/settings.ts` - -```typescript -// Manga-specific settings integration -// Extends the common reader settings manager - -interface MangaSettings { - readingDirection: 'auto' | 'ltr' | 'rtl' | 'vertical'; - verticalScrollSpeed: 'slow' | 'normal' | 'fast'; - rtlPageTransition: 'slide' | 'fade' | 'none'; - webtoonMode: boolean; -} - -async function getMangaSettings(): Promise { - const defaultSettings: MangaSettings = { - readingDirection: 'auto', - verticalScrollSpeed: 'normal', - rtlPageTransition: 'slide', - webtoonMode: false - }; - - try { - const userId = localStorage.getItem('userId'); - const response = await fetch(`/api/users/${userId}/settings`); - - if (response.ok) { - const settings = await response.json(); - return { ...defaultSettings, ...settings }; - } - } catch (error) { - console.error('Failed to load manga settings:', error); - } - - return defaultSettings; -} - -async function updateMangaSettings( - settings: Partial -): Promise { - const userId = localStorage.getItem('userId'); - - try { - const response = await fetch(`/api/users/${userId}/settings`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}` - }, - body: JSON.stringify(settings) - }); - - if (!response.ok) { - throw new Error('Failed to update manga settings'); - } - } catch (error) { - console.error('Failed to save manga settings:', error); - } -} - -function applyMangaSettings(settings: MangaSettings): void { - // Apply reading direction - document.documentElement.dataset.readingDirection = settings.readingDirection; - - // Apply vertical scroll speed - if (settings.verticalScrollSpeed === 'slow') { - document.documentElement.style.scrollBehavior = 'smooth'; - } else if (settings.verticalScrollSpeed === 'fast') { - document.documentElement.style.scrollBehavior = 'auto'; - } - - // Apply RTL page transition - if (settings.rtlPageTransition !== 'none') { - document.documentElement.dataset.pageTransition = settings.rtlPageTransition; - } -} -``` - -### 8.5 Manga Page Cache (Shared with Comics) - -**File:** `web/src/reader/comic/page-cache.ts` (shared for both comics and manga) - -```typescript -// Lazy-loading page cache with 5-page ahead prefetch -// Shared by both comic and manga readers - -interface PageCacheState { - cache: Map; - loading: Set; - maxAhead: number; - mediaItemId: string; -} - -function createPageCache(mediaItemId: string): PageCacheState { - return { - cache: new Map(), - loading: new Set(), - maxAhead: 5, - mediaItemId - }; -} - -async function getCachedPage( - state: PageCacheState, - pageNumber: number -): Promise { - if (state.cache.has(pageNumber)) { - return { ...state, page: state.cache.get(pageNumber)! }; - } - - if (state.loading.has(pageNumber)) { - return new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (state.cache.has(pageNumber)) { - clearInterval(checkInterval); - resolve({ ...state, page: state.cache.get(pageNumber)! }); - } - }, 100); - }) as Promise; - } - - const newLoading = new Set(state.loading); - newLoading.add(pageNumber); - - const img = await loadComicPage(state, pageNumber); - - const newCache = new Map(state.cache); - newCache.set(pageNumber, img); - newLoading.delete(pageNumber); - - const newState = { ...state, cache: newCache, loading: newLoading }; - - prefetchPages(newState, pageNumber + 1); - cleanupPageCache(newState, pageNumber); - - return { ...state, page: img }; -} - -async function loadComicPage( - state: PageCacheState, - pageNumber: number -): Promise { - const token = localStorage.getItem('token'); - const response = await fetch( - `/readers/${state.mediaItemId}/pages/${pageNumber}`, - { - headers: { Authorization: `Bearer ${token}` } - } - ); - - if (!response.ok) { - throw new Error(`Failed to load page ${pageNumber}`); - } - - const blob = await response.blob(); - const img = new Image(); - img.src = URL.createObjectURL(blob); - await new Promise((resolve) => { - img.onload = resolve; - }); - return img; -} - -function prefetchPages(state: PageCacheState, startPage: number): void { - for (let i = startPage; i < startPage + state.maxAhead; i++) { - if (!state.cache.has(i) && !state.loading.has(i)) { - loadComicPage(state, i).then((img) => { - state.cache.set(i, img); - }); - } - } -} - -function cleanupPageCache(state: PageCacheState, currentPage: number): PageCacheState { - const keepPages = 10; - const newCache = new Map(state.cache); - - for (const [page] of state.cache) { - if (page < currentPage - keepPages) { - newCache.delete(page); - } - } - - return { ...state, cache: newCache }; -} -``` - ---- - -## 9. Offline Support (PWA) - -### 9.1 Service Worker - -**File:** `web/static/service-worker.js` (new file) - -```javascript -// Service worker for offline reading -const CACHE_NAME = 'bookhoard-reader-v1'; -const OFFLINE_CACHE = 'bookhoard-offline'; - -// Cache dictionary data for offline use -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(OFFLINE_CACHE).then((cache) => { - return cache.addAll([ - '/static/dictionary/en-US.json', - '/static/dictionary/en-GB.json' - ]); - }) - ); -}); - -// Cache reader pages -self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); - - // Cache reader pages - if (url.pathname.startsWith('/readers/') && url.pathname.includes('/pages/')) { - event.respondWith( - caches.open(CACHE_NAME).then((cache) => { - return cache.match(event.request).then((response) => { - if (response) { - return response; - } - - // Fetch and cache - return fetch(event.request).then((networkResponse) => { - cache.put(event.request, networkResponse.clone()); - return networkResponse; - }); - }); - }) - ); - } - - // Cache dictionary lookups - if (url.pathname.startsWith('/readers/dictionary/')) { - event.respondWith( - caches.open(OFFLINE_CACHE).then((cache) => { - return cache.match(event.request).then((response) => { - if (response) { - return response; - } - - return fetch(event.request).then((networkResponse) => { - // Cache dictionary responses - cache.put(event.request, networkResponse.clone()); - return networkResponse; - }); - }); - }) - ); - } -}); - -// Cleanup old caches -self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( - cacheNames.map((cacheName) => { - if (cacheName !== CACHE_NAME && cacheName !== OFFLINE_CACHE) { - return caches.delete(cacheName); - } - }) - ); - }) - ); -}); -``` - -### 9.2 PWA Manifest - -**File:** `web/static/manifest.json` (new file) - -```json -{ - "name": "Bookhoard Reader", - "short_name": "Reader", - "description": "Offline-capable ebook and comic reader", - "start_url": "/dashboard", - "display": "fullscreen", - "background_color": "#1a1b26", - "theme_color": "#1a1b26", - "icons": [ - { - "src": "/static/icons/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/static/icons/icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "offline_enabled": true -} -``` - -### 9.3 Register Service Worker - -**File:** `web/src/reader/offline-manager.ts` (new file) - -```typescript -// Offline manager for PWA functionality - -export function registerServiceWorker(): void { - 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); - }); - } -} - -export function checkOnlineStatus(): boolean { - if (typeof navigator !== 'undefined' && navigator.onLine) { - return true; - } - return false; -} - -// Listen for online/offline events -window.addEventListener('online', () => { - showToast('Back online', 'success'); - // Sync any pending changes - syncPendingChanges(); -}); - -window.addEventListener('offline', () => { - showToast('You are offline. Some features may be limited.', 'warning'); -}); -``` - ---- - -## 10. Dictionary Implementation - -### 10.1 Dictionary Data - -**File:** `web/static/dictionary/en-US.json` (new file) - -Compressed dictionary data with common words. Format: - -```json -{ - "word": { - "definition": "A single distinct meaningful element of speech or writing", - "part_of_speech": "noun", - "example": "The words 'the', 'and', and 'word' are examples of words.", - "etymology": "Old English word, of Germanic origin; related to Dutch woord and German Wort." - } -} -``` - -Use a free dictionary API (e.g., DictionaryAPI.dev) for initial lookups, then cache in database and localStorage. - -### 10.2 Dictionary Popup - -**File:** `web/src/reader/ebook/dictionary-popup.ts` - -```typescript -// Dictionary lookup popup for ebooks - -import { lookupWord } from "./api"; - -function showDictionaryPopup(word: string, position: { x: number; y: number }): void { - // Remove existing popup - const existing = document.getElementById('dictionary-popup'); - existing?.remove(); - - // Create popup - const popup = document.createElement('div'); - popup.id = 'dictionary-popup'; - popup.className = 'absolute bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50'; - popup.style.left = `${position.x}px`; - popup.style.top = `${position.y}px`; - - popup.innerHTML = '

Loading...

'; - document.body.appendChild(popup); - - // Look up word - lookupWord(word).then((entry) => { - popup.innerHTML = ` -

${entry.word}

-

${entry.part_of_speech || ''}

-

${entry.definition}

- ${entry.example ? `

"${entry.example}"

` : ''} - `; - }).catch((error) => { - popup.innerHTML = `

Definition not found for "${word}"

`; - }); - - // Close on click outside - setTimeout(() => { - document.addEventListener('click', function closePopup(e: MouseEvent) { - if (!popup.contains(e.target as Node)) { - popup.remove(); - document.removeEventListener('click', closePopup); - } - }); - }, 100); -} - -// Text selection handler for ebooks -function handleTextSelection(): void { - document.addEventListener('mouseup', () => { - const selection = window.getSelection(); - const selectedText = selection?.toString().trim(); - - if (selectedText && selectedText.split(' ').length === 1) { - // Single word selected - show dictionary - const range = selection?.getRangeAt(0); - const rect = range?.getBoundingClientRect(); - - if (rect) { - showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom }); - } - } - }); -} -``` - ---- - -## 11. Reading Statistics Integration - -### 11.1 Track Reading Speed - -**File:** `web/src/reader/reading-speed-tracker.ts` - -```typescript -// Track reading speed and update database - -// Reading speed tracker -// Procedural implementation (no OOP) - -interface ReadingSpeedTrackerState { - startTime: number | null; - pagesRead: number; - wordsRead: number; - lastSync: number; - mediaItemId: string; -} - -function createReadingSpeedTracker(mediaItemId: string): ReadingSpeedTrackerState { - return { - startTime: null, - pagesRead: 0, - wordsRead: 0, - lastSync: Date.now(), - mediaItemId - }; -} - -function startReadingSession(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState { - return { - ...state, - startTime: Date.now(), - pagesRead: 0, - wordsRead: 0 - }; -} - -function recordPageTurn(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState { - if (!state.startTime) return state; - - const newPagesRead = state.pagesRead + 1; - const now = Date.now(); - - if (newPagesRead % 5 === 0 || (now - state.lastSync) > 5 * 60 * 1000) { - syncReadingSpeed({ ...state, pagesRead: newPagesRead }); - return { ...state, pagesRead: newPagesRead, lastSync: now }; - } - - return { ...state, pagesRead: newPagesRead }; -} - -function recordWordsRead( - state: ReadingSpeedTrackerState, - wordCount: number -): ReadingSpeedTrackerState { - return { - ...state, - wordsRead: state.wordsRead + wordCount - }; -} - -async function syncReadingSpeed(state: ReadingSpeedTrackerState): Promise { - if (!state.startTime) return; - - const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60); - const pagesPerMinute = state.pagesRead / minutesElapsed; - const wordsPerMinute = state.wordsRead / minutesElapsed; - - await apiPut(`/readers/${state.mediaItemId}/reading-speed`, { - pages_per_minute: pagesPerMinute, - words_per_minute: wordsPerMinute, - pages_read: state.pagesRead, - total_reading_minutes: minutesElapsed - }); -} -``` - ---- - -## 12. UI/UX Implementation - -### 12.1 Reader Template (SSR) - -**File:** `templates/reader.templ` (new file) - -```go -package templates - -templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) { - - - - - - { metadata.title } - Bookhoard Reader - - - - - - @ReaderChrome(user, metadata, progress) - - -
- - - - - -
- -
- -
- - @DictionaryPopup() - - -} - -templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) { -
- -
-
- - ← Back - -

{ metadata.title }

- -
-
- - -
-
-
- { fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) } -
- -
- - - -
-
-
-
-} - -templ ReaderSettingsPanel() { -
-
-

⚙️ Settings

-
- - -
-
-
- -
-

Display

- - -
- - -
-

Typography

- - - -
- - -
-

Navigation

- - -
- - -
-
-} - -templ ReaderTOCPanel(metadata ReaderMetadata) { -
-
-

📖 Table of Contents

-
- - -
-
-
- -
-
-} - -templ ReaderNavigatorPanel() { - -} - -templ ReaderBookmarksPanel(bookmarks []Bookmark) { -
-
-

🔖 Bookmarks

-
- - -
-
-
- if len(bookmarks) > 0 { -
- for _, bookmark := range bookmarks { - - { bookmark.Title } - - { bookmark.Position } - - - } -
- } else { -

No bookmarks yet

- } - -
-
-} - -templ DictionaryPopup() { - -} -``` - ---- - -## 13. Integration Tests - -### 13.1 Test Setup - -**File:** `cmd/server/tests/reader_test.go` (new file) - -Follow existing test patterns from `media_test.go` and `auth_test.go`: - -```go -package tests - -import ( - "bookhoard/internal/database" - "bookhoard/internal/handlers" - "bookhoard/internal/services" - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestReaderEndpoints(t *testing.T) { - setup := setupTestServer(t) - defer setup.Teardown(t) - - // Create test user and media item - ctx := setup.Ctx() - queries := setup.Queries() - - user := createTestUser(t, ctx, queries) - admin := createTestAdmin(t, ctx, queries) - library := createTestLibrary(t, ctx, queries, user.ID) - mediaItem := createTestMediaItem(t, ctx, queries, library.ID, user.ID) - - accessToken := loginTestUser(t, setup, user.Email, "password123") - adminToken := loginTestUser(t, setup, admin.Email, "admin123") - - t.Run("Get Reader Page - User", func(t *testing.T) { - // Test SSR reader page - // Test that user can access their own media items - req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil) - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("Accept", "text/html") - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - assert.Contains(t, resp.Body.String(), mediaItem.Title) - }) - - t.Run("Get Reader Page - No User", func(t *testing.T) { - // Test 401 without authentication - req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil) - req.Header.Set("Accept", "text/html") - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - // Should redirect to login for HTML requests - assert.Equal(t, http.StatusFound, resp.Code) - }) - - t.Run("Get Reader Page - API Request", func(t *testing.T) { - // Test JSON API request returns 401 without auth - req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil) - req.Header.Set("Accept", "application/json") - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusUnauthorized, resp.Code) - }) - - t.Run("Get Page - Lazy Loading", func(t *testing.T) { - // Test page lazy loading endpoint - req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/pages/1", mediaItem.ID), nil) - req.Header.Set("Authorization", "Bearer "+accessToken) - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - // Should return 200 with page content - assert.Equal(t, http.StatusOK, resp.Code) - - var result map[string]interface{} - err := json.Unmarshal(resp.Body.Bytes(), &result) - require.NoError(t, err) - assert.Contains(t, result, "content") - assert.Contains(t, result, "page_number") - }) - - t.Run("Get Chapters", func(t *testing.T) { - // Test chapter metadata endpoint - req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/chapters", mediaItem.ID), nil) - req.Header.Set("Authorization", "Bearer "+accessToken) - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - - var result map[string]interface{} - err := json.Unmarshal(resp.Body.Bytes(), &result) - require.NoError(t, err) - assert.Contains(t, result, "chapters") - }) - - t.Run("Get Panels - Grid Detection", func(t *testing.T) { - // Test panel detection endpoint for comics - // Create a comic media item - comicItem := createTestComicMediaItem(t, ctx, queries, library.ID, user.ID) - - req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/panels/1?method=grid", comicItem.ID), nil) - req.Header.Set("Authorization", "Bearer "+accessToken) - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - - var result map[string]interface{} - err := json.Unmarshal(resp.Body.Bytes(), &result) - require.NoError(t, err) - assert.Contains(t, result, "panels") - assert.Contains(t, result, "detection_method") - }) - - t.Run("Update Panels - Manual Override", func(t *testing.T) { - // Test manual panel override (all authenticated users) - comicItem := createTestComicMediaItem(t, ctx, queries, library.ID, user.ID) - - panels := []map[string]interface{}{ - { - "id": "panel-1", - "x": 100, - "y": 50, - "width": 400, - "height": 300, - "reading_order": 1, - }, - } - - requestBody := map[string]interface{}{ - "panels": panels, - "detection_method": "manual", - } - - bodyBytes, err := json.Marshal(requestBody) - require.NoError(t, err) - - req := httptest.NewRequest("PUT", fmt.Sprintf("/readers/%s/panels/1", comicItem.ID), bytes.NewReader(bodyBytes)) - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("Content-Type", "application/json") - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - - var result map[string]interface{} - err = json.Unmarshal(resp.Body.Bytes(), &result) - require.NoError(t, err) - assert.True(t, result["success"].(bool)) - }) - - t.Run("Reading Speed - Get", func(t *testing.T) { - // Test reading speed retrieval - req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/reading-speed", mediaItem.ID), nil) - req.Header.Set("Authorization", "Bearer "+accessToken) - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - - var result map[string]interface{} - err := json.Unmarshal(resp.Body.Bytes(), &result) - require.NoError(t, err) - // May have zero values if no reading has occurred - assert.Contains(t, result, "pages_read") - }) - - t.Run("Reading Speed - Update", func(t *testing.T) { - // Test reading speed update - requestBody := map[string]interface{}{ - "pages_read": 10, - "time_spent_minutes": 15.5, - } - - bodyBytes, err := json.Marshal(requestBody) - require.NoError(t, err) - - req := httptest.NewRequest("POST", fmt.Sprintf("/readers/%s/reading-speed", mediaItem.ID), bytes.NewReader(bodyBytes)) - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("Content-Type", "application/json") - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - - var result map[string]interface{} - err = json.Unmarshal(resp.Body.Bytes(), &result) - require.NoError(t, err) - assert.True(t, result["success"].(bool)) - }) - - t.Run("Dictionary Lookup", func(t *testing.T) { - // Test dictionary endpoint - req := httptest.NewRequest("GET", "/readers/dictionary/example", nil) - req.Header.Set("Authorization", "Bearer "+accessToken) - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - // Should return 200 or 404 depending on whether word is in cache - assert.True(t, resp.Code == http.StatusOK || resp.Code == http.StatusNotFound) - - if resp.Code == http.StatusOK { - var result map[string]interface{} - err := json.Unmarshal(resp.Body.Bytes(), &result) - require.NoError(t, err) - assert.Contains(t, result, "word") - assert.Contains(t, result, "definition") - } - }) - - t.Run("Settings Management - Get", func(t *testing.T) { - // Test settings retrieval - req := httptest.NewRequest("GET", "/readers/settings", nil) - req.Header.Set("Authorization", "Bearer "+accessToken) - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - - var result map[string]interface{} - err := json.Unmarshal(resp.Body.Bytes(), &result) - require.NoError(t, err) - // Should have default settings if never set - assert.Contains(t, result, "reading_theme") - assert.Contains(t, result, "font_size") - }) - - t.Run("Settings Management - Update", func(t *testing.T) { - // Test settings update - requestBody := map[string]interface{}{ - "reading_theme": "dark", - "reading_font": "literata", - "font_size": 18, - } - - bodyBytes, err := json.Marshal(requestBody) - require.NoError(t, err) - - req := httptest.NewRequest("PUT", "/readers/settings", bytes.NewReader(bodyBytes)) - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("Content-Type", "application/json") - - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - - // Verify settings were updated - var result map[string]interface{} - err = json.Unmarshal(resp.Body.Bytes(), &result) - require.NoError(t, err) - assert.Equal(t, "dark", result["reading_theme"]) - assert.Equal(t, "literata", result["reading_font"]) - assert.Equal(t, float64(18), result["font_size"]) - }) - - t.Run("Offline Support - Service Worker", func(t *testing.T) { - // Test service worker registration - req := httptest.NewRequest("GET", "/static/sw.js", nil) - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - assert.Contains(t, resp.Body.String(), "serviceWorker") - }) - - t.Run("Offline Support - Manifest", func(t *testing.T) { - // Test PWA manifest - req := httptest.NewRequest("GET", "/static/manifest.json", nil) - resp := httptest.NewRecorder() - setup.ServeHTTP(resp, req) - - assert.Equal(t, http.StatusOK, resp.Code) - - var manifest map[string]interface{} - err := json.Unmarshal(resp.Body.Bytes(), &manifest) - require.NoError(t, err) - assert.Contains(t, manifest, "name") - assert.Contains(t, manifest, "start_url") - assert.Contains(t, manifest, "display") - }) -} - -// Helper function to create test comic media item -func createTestComicMediaItem(t *testing.T, ctx context.Context, queries *database.Queries, libraryID uuid.UUID, userID uuid.UUID) database.MediaItems { - mangaType := pgtype.Text{String: "yes", Valid: true} - readingDirection := pgtype.Text{String: "rtl", Valid: true} - - item, err := queries.CreateMediaItem(ctx, database.CreateMediaItemParams{ - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Title: "Test Comic", - FilePath: "/test/comic.cbz", - FileSize: pgtype.Int8{Int64: 1024, Valid: true}, - MimeType: pgtype.Text{String: "application/vnd.comicbook+zip", Valid: true}, - AddedByAdminID: pgtype.UUID{Bytes: userID, Valid: true}, - MangaType: mangaType, - ReadingDirection: readingDirection, - }) - - require.NoError(t, err) - return item -} -``` - ---- - -## 14. Phased Implementation - -### Phase 1: Infrastructure & Basic Reader (Week 1-2) -- [ ] Create database schema (panel_data, reading_speed, dictionary_cache, reader_settings) -- [ ] Create reader service layer (`internal/services/reader_service.go`) -- [ ] Create reader handlers (`internal/handlers/reader.go`) - - [ ] Implement all API endpoints (GetPage, GetChapters, GetPanels, etc.) - - [ ] **Stub ParseEbook to return 501 for server-side formats** (MOBI/AZW3/DOCX/RTF not implemented in Phase 1) -- [ ] Register reader routes (`internal/router/reader.go`) - - [ ] API routes: `/readers/:id/pages/:n`, `/readers/:id/chapters`, etc. - - [ ] SSR route: `/readers/:id` (in `router/frontend.go`) - - [ ] **Do NOT register parse route** - server-side parsing is Phase 2 -- [ ] Create reader template (`templates/reader.templ`) -- [ ] Implement reader shell infrastructure -- [ ] Implement settings manager (DB + localStorage) -- [ ] Implement progress indicator (KOReader-style) -- [ ] Create basic ebook reader (HTML rendering) - EPUB/FB2/TXT/HTML only -- [ ] Create basic comic reader (image display) -- [ ] Integration tests for infrastructure - -**Phase 1 Code Cleanup:** -- [ ] **Remove or comment out stub parser functions** (lines ~579-1001 in handlers/reader.go): - - [ ] Remove `ParseEbook` method (returns 501, no route registered) - - [ ] Remove `parseMOBI`, `parseAZW3`, `parseDOCX`, `parseRTF` stubs - - [ ] Remove helper functions: `extractMOBIMetadata`, `extractMOBIText`, `parseOPFXML`, `openFileFromZip`, `extractDOCXParagraphs`, `extractDOCXMetadata`, `convertDOCXToHTML`, `parseRTFText`, `convertRTFToHTML`, `extractRTFMetadata` - - [ ] Add TODO comment: "// Phase 2.5: Implement server-side MOBI/AZW3/DOCX/RTF parsers" -- [ ] Result: handlers/reader.go reduced from ~1000 lines to ~577 lines (working code only) -- [ ] File compiles without errors -- [ ] All Phase 1 API endpoints functional - -**Phase 1 Scope Limitations:** -- ✅ Client-side parsing: EPUB, FB2, TXT, HTML (TypeScript parsers) -- ❌ Server-side parsing: MOBI, AZW3, DOCX, RTF (returns "not implemented" error) -- ✅ PDF reader (using pdf.js) -- ✅ Comic/Manga readers (image display) - -### Phase 2: Comic/Manga Features (Week 3-4) -- [ ] Implement grid-based panel detection -- [ ] Implement panel zoom with animations -- [ ] Implement page cache (5-page ahead) -- [ ] Implement manga RTL navigator -- [ ] Implement manga vertical scroll mode -- [ ] Implement chapter detection for all media types -- [ ] Integration tests for comic/manga features - -### Phase 2.5: Server-Side Parsers (Week 5) -**Complete hybrid parsing strategy - add support for complex formats** - -- [ ] Implement `ParseEbook` endpoint: `POST /readers/:id/parse` - - [ ] Register route in `router/reader.go` - - [ ] Route to parser based on MIME type -- [ ] Implement MOBI parser (`parseMOBI` handler) - - [ ] Parse MOBI header and metadata - - [ ] Extract text content (handle Huffman compression) - - [ ] Convert to CIF (HTML structure) - - [ ] Unit tests for MOBI parsing -- [ ] Implement AZW3/KF8 parser (`parseAZW3` handler) - - [ ] Parse AZW3 container (ZIP-based) - - [ ] Extract EPUB3 content from KF8 container - - [ ] Convert to CIF - - [ ] Unit tests for AZW3 parsing -- [ ] Implement DOCX parser (`parseDOCX` handler) - - [ ] Parse DOCX ZIP structure - - [ ] Extract document.xml for paragraphs - - [ ] Convert formatting to HTML - - [ ] Convert to CIF - - [ ] Unit tests for DOCX parsing -- [ ] Implement RTF parser (`parseRTF` handler) - - [ ] Parse RTF markup - - [ ] Extract text and formatting - - [ ] Convert to HTML/CIF - - [ ] Unit tests for RTF parsing -- [ ] Integration tests for all server-side parsers -- [ ] Update TypeScript `parser-manager.ts` to call parse endpoint -- [ ] Update `requiresServerParsing()` to detect MOBI/AZW3/DOCX/RTF - -**Note:** These formats are complex and require specialized parsing libraries. Consider using: -- Go's `archive/zip` for ZIP-based formats (AZW3, DOCX) -- Third-party libraries for MOBI decompression -- Custom RTF parser (spec is public) - -### Phase 3: Advanced Features (Week 6-7) -- [ ] Implement ML-based panel detection (optional enhancement) -- [ ] Implement manual panel editor -- [ ] Implement dictionary popup for ebooks -- [ ] Implement offline dictionary cache -- [ ] Implement reading speed tracker -- [ ] Implement annotation manager (highlights, notes, bookmarks) -- [ ] Integration tests for advanced features - -### Phase 4: Offline Support (Week 7) -- [ ] Create service worker -- [ ] Implement page caching for offline reading -- [ ] Implement dictionary offline caching -- [ ] Create PWA manifest -- [ ] Implement online/offline detection -- [ ] Integration tests for offline support - -### Phase 5: Polish & Testing (Week 8) -- [ ] Performance optimization -- [ ] Cross-browser testing -- [ ] Mobile responsiveness testing -- [ ] Accessibility testing -- [ ] Security audit -- [ ] Documentation (user guides, API docs) -- [ ] End-to-end testing - ---- - -## 15. Code Reuse Strategy - -### 15.1 Reuse Existing Systems - -**WebSocket Sync (`internal/sync/websocket.go`)** -- Reuse for real-time progress updates -- Reuse for annotation sync -- Reuse for bookmark sync - -**Progress Tracking (`internal/sync/progress.go`)** -- Reuse EPUB CFI navigation logic -- Reuse percentage calculation -- Reuse chapter-relative page calculation - -**Format Handling (`internal/sync/format.go`)** -- Reuse format detection logic -- Reuse normalization functions - -**Annotation Tables (notes, highlights)** -- Reuse existing database schema -- Reuse existing API endpoints -- Build UI on top of existing data - -**Theme System (11 dark themes)** -- Reuse existing theme CSS variables -- Apply theme to reader UI -- Ensure consistency across app - -**Auth & User Management** -- Reuse JWT middleware -- Reuse user preferences -- Reuse role-based access control - -### 15.2 Surgical Code Edits - -**Avoid:** -- ❌ Duplicating existing logic -- ❌ Rewriting working code -- ❌ Creating parallel systems - -**Do:** -- ✅ Extend existing types where appropriate -- ✅ Add new methods to existing services -- ✅ Follow existing patterns and conventions -- ✅ Use existing test helpers - -**Example - Extending sync/format.go:** - -```go -// EXISTING CODE in sync/format.go -func CalculateProgress(currentPage, totalPages int) float64 { - if totalPages == 0 { - return 0 - } - return float64(currentPage) / float64(totalPages) * 100 -} - -// NEW CODE - Add chapter-relative progress -func CalculateChapterProgress(currentPage, chapterStartPage, chapterPages int) (int, int) { - chapterPage := currentPage - chapterStartPage + 1 - return chapterPage, chapterPages -} -``` - ---- - -## 16. Bruno API Tests - -**File:** `bruno/reader/get-reader-page.bru` - -```yaml -meta: - name: Get Reader Page - type: http - seq: 1 -http: - method: GET - url: '{{base_url}}/readers/{{media_item_id}}' - auth: inherit - body: - type: none -docs: |- - ## Get Reader Page - - Returns the reader page for a specific media item. - - **Authentication:** Required (Bearer token) - - **Path Parameters:** - - `media_item_id` (string): Media Item UUID - - **Response:** HTML reader page - - **Error Responses:** - - 401: Invalid authentication - - 404: Media item not found - - 403: User does not have access to this library -``` - -**File:** `bruno/reader/get-page.bru` - -```yaml -meta: - name: Get Book Page - type: http - seq: 2 -http: - method: GET - url: '{{base_url}}/readers/{{media_item_id}}/pages/{{page_number}}' - auth: inherit - body: - type: none -docs: |- - ## Get Book Page - - Retrieves a specific page of the book for rendering. - - **Authentication:** Required (Bearer token) - - **Path Parameters:** - - `media_item_id` (string): Media Item UUID - - `page_number` (number): Page number to retrieve - - **Query Parameters:** - - `format` (string, optional): Response format ('html', 'text', 'json') - - **Response:** - - `content` (string): Page content (HTML or text) - - `page_number` (number): Current page number - - `total_pages` (number): Total pages in book - - `chapter_title` (string, optional): Current chapter title - - **Error Responses:** - - 401: Invalid authentication - - 404: Page not found -``` - -**File:** `bruno/reader/get-chapters.bru` - -```yaml -meta: - name: Get Chapters - type: http - seq: 3 -http: - method: GET - url: '{{base_url}}/readers/{{media_item_id}}/chapters' - auth: inherit - body: - type: none -docs: |- - ## Get Chapters - - Retrieves the table of contents/chapter list for a book. - - **Authentication:** Required (Bearer token) - - **Path Parameters:** - - `media_item_id` (string): Media Item UUID - - **Response:** - ```json - { - "chapters": [ - { - "id": "chapter-1", - "title": "Chapter 1", - "start_page": 1, - "page_count": 25, - "level": 1 - } - ] - } - ``` - - **Error Responses:** - - 401: Invalid authentication - - 404: Media item not found -``` - -**File:** `bruno/reader/get-panels.bru` - -```yaml -meta: - name: Get Comic Panels - type: http - seq: 4 -http: - method: GET - url: '{{base_url}}/readers/{{media_item_id}}/panels/{{page_number}}' - auth: inherit - body: - type: none -docs: |- - ## Get Comic Panels - - Retrieves panel detection data for a comic/manga page. - - **Authentication:** Required (Bearer token) - - **Path Parameters:** - - `media_item_id` (string): Media Item UUID - - `page_number` (number): Page number - - **Query Parameters:** - - `method` (string, optional): Detection method ('grid', 'ml', 'manual') - - **Response:** - ```json - { - "page_number": 1, - "detection_method": "ml", - "panels": [ - { - "id": "panel-1", - "x": 100, - "y": 50, - "width": 400, - "height": 300, - "reading_order": 1 - } - ] - } - ``` - - **Error Responses:** - - 401: Invalid authentication - - 404: Page not found -``` - -**File:** `bruno/reader/update-panels.bru` - -```yaml -meta: - name: Update Comic Panels - type: http - seq: 5 -http: - method: PUT - url: '{{base_url}}/readers/{{media_item_id}}/panels/{{page_number}}' - auth: inherit - body: - type: json - json: { - "panels": [ - { - "id": "panel-1", - "x": 100, - "y": 50, - "width": 400, - "height": 300, - "reading_order": 1 - } - ], - "detection_method": "manual" - } -docs: |- - ## Update Comic Panels - - Updates panel detection data (manual override). - - **Authentication:** Required (Bearer token) - - **Path Parameters:** - - `media_item_id` (string): Media Item UUID - - `page_number` (number): Page number - - **Request Body:** - ```json - { - "panels": [...], - "detection_method": "manual" - } - ``` - - **Response:** - ```json - { - "success": true, - "message": "Panels updated successfully" - } - ``` - - **Error Responses:** - - 401: Invalid authentication - - 400: Invalid panel data -``` - -**File:** `bruno/reader/get-reading-speed.bru` - -```yaml -meta: - name: Get Reading Speed - type: http - seq: 6 -http: - method: GET - url: '{{base_url}}/readers/{{media_item_id}}/reading-speed' - auth: inherit - body: - type: none -docs: |- - ## Get Reading Speed - - Retrieves reading speed statistics for a book. - - **Authentication:** Required (Bearer token) - - **Path Parameters:** - - `media_item_id` (string): Media Item UUID - - **Response:** - ```json - { - "words_per_minute": 250, - "pages_per_minute": 1.5, - "pages_read": 50, - "total_reading_minutes": 33.3, - "last_read_at": "2024-01-15T10:30:00Z" - } - ``` - - **Error Responses:** - - 401: Invalid authentication - - 404: Media item not found -``` - -**File:** `bruno/reader/update-reading-speed.bru` - -```yaml -meta: - name: Update Reading Speed - type: http - seq: 7 -http: - method: POST - url: '{{base_url}}/readers/{{media_item_id}}/reading-speed' - auth: inherit - body: - type: json - json: { - "pages_read": 10, - "time_spent_minutes": 15.5 - } -docs: |- - ## Update Reading Speed - - Updates reading speed statistics. - - **Authentication:** Required (Bearer token) - - **Path Parameters:** - - `media_item_id` (string): Media Item UUID - - **Request Body:** - ```json - { - "pages_read": 10, - "time_spent_minutes": 15.5 - } - ``` - - **Response:** - ```json - { - "success": true, - "words_per_minute": 250, - "pages_per_minute": 1.5 - } - ``` - - **Error Responses:** - - 401: Invalid authentication - - 400: Invalid data -``` - -**File:** `bruno/reader/lookup-word.bru` - -```yaml -meta: - name: Dictionary Lookup - type: http - seq: 8 -http: - method: GET - url: '{{base_url}}/readers/dictionary/{{word}}' - auth: inherit - body: - type: none -docs: |- - ## Dictionary Lookup - - Looks up a word in the dictionary (offline-capable). - - **Authentication:** Required (Bearer token) - - **Path Parameters:** - - `word` (string): Word to look up - - **Query Parameters:** - - `lang` (string, optional): Language code (default: 'en') - - **Response:** - ```json - { - "word": "example", - "definition": "A representative form or pattern.", - "part_of_speech": "noun", - "example": "This is an example sentence.", - "etymology": "From Latin exemplum" - } - ``` - - **Error Responses:** - - 401: Invalid authentication - - 404: Word not found -``` - -**File:** `bruno/reader/get-settings.bru` - -```yaml -meta: - name: Get Reader Settings - type: http - seq: 9 -http: - method: GET - url: '{{base_url}}/readers/settings' - auth: inherit - body: - type: none -docs: |- - ## Get Reader Settings - - Retrieves user's reader settings. - - **Authentication:** Required (Bearer token) - - **Response:** - ```json - { - "chrome_behavior": "auto-hide", - "progress_mode": "pages", - "chrome_theme": "tokyo-night", - "reading_theme": "dark", - "reading_font": "literata", - "font_size": 16, - "line_height": 1.6, - "margin_width": 20 - } - ``` - - **Error Responses:** - - 401: Invalid authentication -``` - -**File:** `bruno/reader/update-settings.bru` - -```yaml -meta: - name: Update Reader Settings - type: http - seq: 10 -http: - method: PUT - url: '{{base_url}}/readers/settings' - auth: inherit - body: - type: json - json: { - "reading_theme": "dark", - "reading_font": "literata", - "font_size": 18 - } -docs: |- - ## Update Reader Settings - - Updates user's reader settings (partial update supported). - - **Authentication:** Required (Bearer token) - - **Request Body:** Partial settings object (only include fields to update) - ```json - { - "reading_theme": "dark", - "reading_font": "literata", - "font_size": 18 - } - ``` - - **Response:** Updated settings object (same format as GET) - - **Error Responses:** - - 401: Invalid authentication - - 400: Invalid setting value -``` - ---- - -## 17. Documentation - -### 17.1 User Documentation - -**File:** `docs/user/reader.md` (new file) - -Comprehensive user guide covering: -- How to open the reader -- Navigation controls -- Progress indicator modes -- Settings options -- Panel zoom for comics/manga -- Dictionary lookup -- Bookmarks, highlights, notes -- Offline reading -- Keyboard shortcuts - -### 17.2 Developer Documentation - -**File:** `docs/contributing/reader-architecture.md` (new file) - -Technical documentation covering: -- Reader architecture overview -- Component structure -- Data flow diagrams -- Panel detection algorithms -- Caching strategy -- Offline support implementation -- Testing strategy - ---- - -## 18. Success Criteria - -### 18.1 Functional Requirements -- ✅ User can read ebooks (EPUB) with adjustable typography -- ✅ User can read comics (CBZ/CBR/PDF) with panel zoom -- ✅ User can read manga with RTL and vertical scroll modes -- ✅ Progress syncs across devices via WebSocket -- ✅ User can create bookmarks, highlights, notes -- ✅ User can look up words in dictionary (offline) -- ✅ Reader works offline for cached content -- ✅ Settings persist across devices (DB) and browsers (localStorage) -- ✅ 8 bundled libre reading fonts (no network requests) -- ✅ UI chrome uses all 11 Bookhoard themes, ebook text uses 5 reading-optimized themes - -### 18.2 Performance Requirements -- ⚡ Initial page load: < 2 seconds -- ⚡ Page turn (comics): < 500ms with 5-page cache -- ⚡ Panel zoom animation: 300ms smooth -- ⚡ Dictionary lookup: < 1 second (cached), < 3 seconds (uncached) -- ⚡ Offline cache hit: < 100ms - -### 18.3 Quality Requirements -- ✅ Zero TypeScript errors -- ✅ All integration tests passing -- ✅ Zero known security vulnerabilities -- ✅ Mobile-responsive (320px - 4K) -- ✅ Keyboard accessible -- ✅ WCAG 2.1 AA compliant - ---- - -## 19. Future Enhancements (Out of Scope for Initial Implementation) - -- TTS (Text-to-Speech) - user excluded -- Advanced ML panel detection with custom model -- Social features (share highlights, see friends' progress) -- Advanced annotations (draw on pages, voice notes) -- PDF form filling -- EPUB audio/video media overlays -- Advanced manga panel navigation (auto-detect panel order) -- Reading goals and challenges -- Social reading (book clubs, shared annotations) - ---- - -## Conclusion - -This implementation plan provides a comprehensive roadmap for building a modern, feature-rich web reader for Bookhoard. The **universal reader architecture with pluggable parsers** provides the best balance of code reuse, maintainability, and extensibility. - -**Key principles:** -- **Universal reader**: One rendering engine for all reflowable ebooks (EPUB, FB2, TXT, HTML, MOBI, AZW3, DOCX, RTF) -- **Common Intermediate Format (CIF)**: Standardized HTML structure that all parsers produce -- **Hybrid parsing**: Client-side for simple formats (~500 KB), server-side for complex formats (no 182 MB Calibre dependency) -- **Procedural TypeScript**: Functions, not classes (per PROJECT_GUIDELINES.md) -- **Surgical code reuse**: Extend existing systems (WebSocket sync, progress tracking, annotations) -- **Progressive enhancement**: SSR-first with TypeScript enhancements -- **Privacy-first**: Per-user settings with localStorage fallback -- **Offline-capable**: PWA with service worker -- **Libre fonts only**: 8 bundled open-source reading fonts -- **Hybrid theming**: 11 themes for UI/comics, 5 reading-optimized themes for ebook text - -**Supported Formats:** - -| Format | Parser Location | Dependency Size | Status | -|--------|-----------------|-----------------|--------| -| **EPUB 2/3** | Client (TypeScript) | 0 KB (JSZip) | ✅ Planned | -| **FB2** | Client (TypeScript) | 0 KB (XML) | ✅ Planned | -| **TXT** | Client (TypeScript) | 0 KB | ✅ Planned | -| **HTML** | Client (TypeScript) | 0 KB | ✅ Planned | -| **MOBI** | Server (Go) | ~100 KB | ✅ Planned | -| **AZW3** | Server (Go) | ~50 KB | ✅ Planned | -| **DOCX** | Server (Go) | ~200 KB (mammoth) | ✅ Planned | -| **RTF** | Server (Go) | ~50 KB | ✅ Planned | -| **PDF** | Client (pdf.js) | ~500 KB | ✅ Planned | -| **Comics** | Client (canvas) | 0 KB | ✅ Planned | -| **Manga** | Client (extends comics) | 0 KB | ✅ Planned | - -**Total client-side dependencies: ~1 MB (vs. 182 MB for Calibre)** - -**Key design decisions:** -- **Architecture**: Universal reader + parser pipeline (not separate readers) -- **Parsing**: Hybrid (client for simple, server for complex) -- **Code style**: Procedural TypeScript (no OOP per guidelines) -- **Fonts**: 8 libre fonts bundled (~1.2MB WOFF2), standard weights only -- **Theming**: Hybrid - 11 themes for UI, 5 reading-optimized themes for text -- **Typography**: Optimized for extended reading (Literata default) - -**Estimated timeline:** 8 weeks for full implementation - -**Next steps:** -1. Review and approve this plan -2. Begin Phase 1: Infrastructure & Basic Reader -3. Create database schema (add media_bookmarks table with chapter_number and cfi_position fields) -4. Implement parser manager and CIF types -5. Build universal reader shell (procedural style) -6. Implement parsers (start with EPUB, TXT - simplest first) -7. Add server-side parsers for complex formats (MOBI, AZW3, DOCX) - ---- - -*Plan created: 2025* -*Last updated: 2025* -*Major revision: Universal reader architecture + procedural TypeScript*