diff --git a/READER_REFACTOR_MODULARIZATION_AND_PAGINATION.md b/READER_REFACTOR_MODULARIZATION_AND_PAGINATION.md deleted file mode 100644 index addc8c0..0000000 --- a/READER_REFACTOR_MODULARIZATION_AND_PAGINATION.md +++ /dev/null @@ -1,1656 +0,0 @@ -# Implementation Guide: Modularization + Page-Based Pagination - -## Overview - -This guide implements **Option 2**: Restructure reader code into format-specific modules AND implement page-based pagination for reflowable formats (EPUB, FB2, TXT, HTML). - -**Key Principles:** -- No OOP - use functional programming with plain objects -- Pre-calculate page boundaries using word count estimation -- Use CFI for progress tracking (already in database) -- Discrete page navigation (no scrolling within pages) -- Keep existing PDF/Comic/Manga code untouched - ---- - -## Files Summary - -### 📝 NEW FILES TO CREATE (8 total) - -**Reflowable module (6 files):** -1. `web/src/reader/formats/reflowable/types.ts` - Type definitions -2. `web/src/reader/formats/reflowable/page-calculator.ts` - Word count pagination -3. `web/src/reader/formats/reflowable/navigation.ts` - Page-based navigation -4. `web/src/reader/formats/reflowable/progress-tracker.ts` - CFI progress tracking -5. `web/src/reader/formats/reflowable/content-renderer.ts` - DOM rendering -6. `web/src/reader/formats/reflowable/parser.ts` - Unified parser interface - -**UI components (2 files):** -7. `web/src/reader/ui/page-display.ts` - Page X of Y display -8. `web/src/reader/ui/progress-indicator.ts` - Progress bar (moved from features/) - -### ✏️ FILES TO MODIFY (2 total) - -1. `web/src/reader/core/reader-navigation.ts` - Integrate reflowable navigation -2. `web/src/reader/reader-shell.ts` - Initialize reflowable books - -### 🗑️ FILES TO DELETE (5+ individual files + 5 directories) - -**Individual files:** -- `web/src/reader/ebook/page-calculator.ts` (replaced) -- `web/src/reader/ebook/view-modes.ts` (replaced) -- `web/src/reader/ebook/page-splitter.ts` (merged) -- `web/src/reader/ebook/cfi-navigator.ts` (merged) -- `web/src/reader/ebook/html-renderer.ts` (replaced) - -**Entire directories (after moving contents):** -- `web/src/reader/ebook/` → moved to `formats/reflowable/` -- `web/src/reader/pdf/` → moved to `formats/pdf/` -- `web/src/reader/comic/` → moved to `formats/comic/` -- `web/src/reader/manga/` → moved to `formats/manga/` -- `web/src/reader/features/` → moved to `ui/` - ---- - -## New Directory Structure - -``` -web/src/reader/ -├── core/ # SHARED INFRASTRUCTURE (unchanged) -│ ├── reader-state.ts -│ ├── reader-events.ts -│ ├── reader-context.ts -│ └── reader-services.ts -│ -├── formats/ # NEW: Format-specific modules -│ ├── reflowable/ # NEW: EPUB, FB2, TXT, HTML -│ │ ├── parser.ts # Unified parser for all reflowable formats -│ │ ├── page-calculator.ts # Pre-calculate page boundaries -│ │ ├── navigation.ts # Page-based navigation logic -│ │ ├── progress-tracker.ts # CFI-based progress tracking -│ │ ├── content-renderer.ts # Render content to DOM -│ │ └── types.ts # Shared types for reflowable -│ │ -│ ├── pdf/ # MOVE from pdf/ (unchanged) -│ │ └── (existing files) -│ │ -│ ├── comic/ # MOVE from comic/ (unchanged) -│ │ └── (existing files) -│ │ -│ └── manga/ # MOVE from manga/ (unchanged) -│ └── (existing files) -│ -├── ui/ # NEW: Format-agnostic UI -│ ├── progress-indicator.ts # MOVE from features/ -│ └── page-display.ts # NEW: Page X of Y display -│ -└── parsers/ # EXISTING: Format-specific parsers - ├── epub-parsers.ts - ├── fb2-parser.ts - ├── txt-parser.ts - └── html-parser.ts -``` - ---- - -## Implementation: File by File - -### 1. `web/src/reader/formats/reflowable/types.ts` - -**NEW FILE** - Shared types for reflowable format handling - -```typescript -// Line 1: Spine item structure from parsed EPUB/FB2/etc -export interface SpineItem { - id: string; - type: "html" | "image" | "other"; - content: string; // Blob URL or content ID - href?: string; // For CFI generation -} - -// Line 9: Information about a single spine item -export interface SpineInfo { - spineIndex: number; - spineItemId: string; - content: string; // Full HTML content - charCount: number; // Total characters - wordCount: number; // Total words (for pagination) - cfiStart: string; // CFI at start of this spine - pages: PageBoundary[]; // Page boundaries within this spine -} - -// Line 20: A single page boundary within a spine -export interface PageBoundary { - pageIndex: number; // Global page index - localPageIndex: number; // Page index within this spine - charStart: number; // Character offset from start of spine - charEnd: number; // Character offset at end of page - wordStart: number; // Word offset from start of spine - wordEnd: number; // Word offset at end of page - cfi: string; // CFI for this position -} - -// Line 31: Complete pagination data -export interface PaginationData { - totalPages: number; - spines: SpineInfo[]; - spineMap: Map; - pageMap: Map; // pageIndex -> PageBoundary - calculatedAt: number; - settings: PaginationSettings; -} - -// Line 40: Settings used for calculation -export interface PaginationSettings { - fontSize: number; - lineHeight: number; - viewportWidth: number; - viewportHeight: number; - wordsPerPage: number; // Calculated from above -} - -// Line 48: Current reading position -export interface ReadingPosition { - currentPage: number; - spineIndex: number; - localPageIndex: number; - cfi: string; - progress: number; // 0-1 -} - -// Line 56: Reflowable book data -export interface ReflowableBook { - type: "epub" | "fb2" | "txt" | "html"; - spine: SpineItem[]; - resources: Map; - toc: TOCItem[]; - metadata: BookMetadata; - pagination: PaginationData | null; - position: ReadingPosition; -} - -// Line 68: Table of contents item -export interface TOCItem { - id: string; - title: string; - href: string; - children: TOCItem[]; -} - -// Line 75: Book metadata -export interface BookMetadata { - title: string; - author: string; - identifier: string; - [key: string]: any; -} -``` - ---- - -### 2. `web/src/reader/formats/reflowable/page-calculator.ts` - -**NEW FILE** - Pre-calculate page boundaries using word count - -```typescript -// Line 1: Import types -import type { SpineItem, SpineInfo, PageBoundary, PaginationData, PaginationSettings } from "./types"; - -// Line 4: Constants for word count estimation (from Kavita) -const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height - -// Line 8: Calculate words per page based on settings -function calculateWordsPerPage(settings: PaginationSettings): number { - const fontSizeFactor = 16 / settings.fontSize; - const lineHeightFactor = 1.6 / settings.lineHeight; - const areaFactor = (settings.viewportWidth * settings.viewportHeight) / (800 * 600); - - return Math.round(WORDS_PER_PAGE_BASE * fontSizeFactor * lineHeightFactor * areaFactor); -} - -// Line 17: Extract plain text from HTML -function extractTextFromHTML(html: string): string { - // Remove script and style tags - const withoutScripts = html.replace(/)<[^<]*)*<\/script>/gi, ""); - const withoutStyles = withoutScripts.replace(/)<[^<]*)*<\/style>/gi, ""); - - // Extract text content (simple version, no DOM) - return withoutStyles.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); -} - -// Line 28: Count words in text -function countWords(text: string): number { - return text.trim().split(/\s+/).filter(w => w.length > 0).length; -} - -// Line 33: Split text into word ranges for pages -function splitIntoWordRanges(wordCount: number, wordsPerPage: number): Array<{start: number; end: number}> { - const ranges: Array<{start: number; end: number}> = []; - let start = 0; - - while (start < wordCount) { - const end = Math.min(start + wordsPerPage, wordCount); - ranges.push({ start, end }); - start = end; - } - - return ranges; -} - -// Line 48: Escape special characters in CFI -function escapeCFIString(str: string): string { - return str - .replace(/\[/g, "\\[") - .replace(/\]/g, "\\]") - .replace(/\(/g, "\\(") - .replace(/\)/g, "\\)") - .replace(/,/g, "\\,") - .replace(/;/g, "\\;") - .replace(/=/g, "\\="); -} - -// Line 54: Generate EPUB CFI for a position in spine -// Follows EPUB CFI spec: https://www.w3.org/TR/epub-cfi/ -// Format: epubcfi(/6/spine_index!/path/element/offset) -function generateCFI( - spineIndex: number, - charOffset: number, - totalChars: number, - spineItemId: string -): string { - const escapedId = spineItemId ? `[${escapeCFIString(spineItemId)}]` : ""; - const offset = Math.min(charOffset, totalChars); - const spinePath = `/6/${spineIndex + 2}${escapedId}`; - - return `epubcfi(${spinePath}!/4/2/1:${offset})`; -} - -// Line 80: Parse EPUB CFI to extract position -function parseCFI(cfi: string): { spineIndex: number; charOffset: number } | null { - if (!cfi.startsWith("epubcfi(")) { - return null; - } - - // Remove epubcfi( wrapper - const inner = cfi.slice(8, -1); - if (!inner) return null; - - // Split on ! to separate spine path from content path - const parts = inner.split("!"); - if (parts.length < 2) return null; - - // Extract spine index from /6/4 or /6/4[id] format - const spineMatch = parts[0].match(/\/6\/(\d+)/); - if (!spineMatch) return null; - - const spineIndex = parseInt(spineMatch[1]) - 2; // Adjust for offset - if (spineIndex < 0) return null; - - // Extract character offset from :123 format - const offsetMatch = parts[1].match(/:(\d+)$/); - if (!offsetMatch) return null; - - const charOffset = parseInt(offsetMatch[1]); - - return { spineIndex, charOffset }; -} - -// Line 56: Calculate pagination for entire book -export async function calculatePagination( - spineItems: SpineItem[], - contentMap: Map, - settings: PaginationSettings -): Promise { - const wordsPerPage = calculateWordsPerPage(settings); - const spines: SpineInfo[] = []; - const pageMap = new Map(); - let globalPageIndex = 0; - - // Process each spine item - for (let i = 0; i < spineItems.length; i++) { - const spineItem = spineItems[i]; - - // Skip non-HTML items (cover pages, etc) - if (spineItem.type !== "html") { - spines.push({ - spineIndex: i, - spineItemId: spineItem.id, - content: "", - charCount: 0, - wordCount: 0, - cfiStart: "", - pages: [], - }); - continue; - } - - // Get content - const contentBlob = contentMap.get(spineItem.content); - if (!contentBlob) { - console.warn(`Content not found for spine ${spineItem.id}`); - continue; - } - - const contentHTML = await contentBlob.text(); - const plainText = extractTextFromHTML(contentHTML); - const wordCount = countWords(plainText); - const charCount = plainText.length; - - // Skip empty spines - if (wordCount === 0) { - spines.push({ - spineIndex: i, - spineItemId: spineItem.id, - content: contentHTML, - charCount, - wordCount, - cfiStart: generateCFI(i, 0, charCount, spineItem.id), - pages: [], - }); - continue; - } - - // Split into pages - const wordRanges = splitIntoWordRanges(wordCount, wordsPerPage); - const pages: PageBoundary[] = []; - - for (let j = 0; j < wordRanges.length; j++) { - const range = wordRanges[j]; - const page: PageBoundary = { - pageIndex: globalPageIndex, - localPageIndex: j, - charStart: Math.round((range.start / wordCount) * charCount), - charEnd: Math.round((range.end / wordCount) * charCount), - wordStart: range.start, - wordEnd: range.end, - cfi: generateCFI(i, Math.round((range.start / wordCount) * charCount), charCount, spineItem.id), - }; - - pages.push(page); - pageMap.set(globalPageIndex, page); - globalPageIndex++; - } - - spines.push({ - spineIndex: i, - spineItemId: spineItem.id, - content: contentHTML, - charCount, - wordCount, - cfiStart: generateCFI(i, 0, charCount), - pages, - }); - } - - // Build map - const spineMap = new Map(); - for (const spine of spines) { - spineMap.set(spine.spineIndex, spine); - } - - return { - totalPages: globalPageIndex, - spines, - spineMap, - pageMap, - calculatedAt: Date.now(), - settings: { ...settings, wordsPerPage }, - }; -} - -// Line 162: Find which page contains a CFI -export function findPageByCFI(pagination: PaginationData, targetCFI: string): number { - const parsed = parseCFI(targetCFI); - if (!parsed) return 1; - - const { spineIndex, charOffset } = parsed; - const spine = pagination.spineMap.get(spineIndex); - - if (!spine || spine.pages.length === 0) return 1; - - // Find page containing this character offset - for (const page of spine.pages) { - if (charOffset >= page.charStart && charOffset < page.charEnd) { - return page.pageIndex + 1; // 1-indexed - } - } - - return 1; -} - -// Line 186: Extract text content from HTML (for word counting) -function extractTextFromHTML(html: string): string { - const withoutScripts = html.replace(/)<[^<]*)*<\/script>/gi, ""); - const withoutStyles = withoutScripts.replace(/)<[^<]*)*<\/style>/gi, ""); - return withoutScripts.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); -} - -// Line 195: Count words in text -function countWords(text: string): number { - return text.trim().split(/\s+/).filter(w => w.length > 0).length; -} - -// Line 200: Extract HTML slice between character offsets -function extractHTMLSlice(html: string, charStart: number, charEnd: number): string { - if (charStart === 0 && charEnd >= html.length) { - return html; - } - - // Parse HTML and extract text nodes within the character range - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - const body = doc.body; - - // Find all text nodes and their cumulative character counts - type TextNodeInfo = { node: Text; startChar: number; endChar: number }; - const textNodes: TextNodeInfo[] = []; - let cumulativeChars = 0; - - function traverse(node: Node) { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent || ""; - const startChar = cumulativeChars; - cumulativeChars += text.length; - const endChar = cumulativeChars; - - textNodes.push({ node: node as Text, startChar, endChar }); - } else if (node.nodeType === Node.ELEMENT_NODE) { - // Skip script and style tags - if (node instanceof HTMLElement) { - const tagName = node.tagName.toLowerCase(); - if (tagName === "script" || tagName === "style") { - return; - } - } - - // Recursively traverse children - for (const child of Array.from(node.childNodes)) { - traverse(child); - } - } - } - - traverse(body); - - // Find which text nodes intersect with the requested range - const relevantNodes: { node: Text; before: string; after: string }[] = []; - - for (const { node, startChar, endChar } of textNodes) { - if (endChar <= charStart || startChar >= charEnd) { - // No overlap - continue; - } - - const text = node.textContent || ""; - let afterText = text; - - if (startChar < charStart) { - afterText = text.substring(charStart - startChar); - } - - if (endChar > charEnd) { - const charsFromStart = Math.max(0, charEnd - startChar); - afterText = text.substring(0, charsFromStart); - } - - relevantNodes.push({ node, before: "", after: afterText }); - } - - // Preserve original HTML structure for nodes in range - const startNode = textNodes.find(n => n.endChar > charStart); - const endNode = textNodes.find(n => n.startChar < charEnd); - - if (!startNode || !endNode) { - return html; - } - - // Find element boundaries - let startElement: Node | null = startNode.node; - while (startElement && startElement.parentNode !== body) { - startElement = startElement.parentNode; - } - - let endElement: Node | null = endNode.node; - while (endElement && endElement.parentNode !== body) { - endElement = endElement.parentNode; - } - - // Extract and modify the relevant portion - if (startElement && endElement) { - const fragment = document.createDocumentFragment(); - - let currentElement: Node | null = startElement; - let foundEnd = false; - - while (currentElement && !foundEnd) { - if (currentElement.nodeType === Node.ELEMENT_NODE) { - const clone = (currentElement as Element).cloneNode(false); - fragment.appendChild(clone); - - // Process children - for (const child of Array.from(currentElement.childNodes)) { - if (child.nodeType === Node.TEXT_NODE) { - const textNodeInfo = textNodes.find(n => n.node === child); - if (textNodeInfo) { - const modified = document.createTextNode( - relevantNodes.find(n => n.node === child)?.after || "" - ); - clone.appendChild(modified); - } - } else if (child.nodeType === Node.ELEMENT_NODE) { - // Recursively handle element children - const childClone = child.cloneNode(true); - clone.appendChild(childClone); - } - } - - if (currentElement === endElement) { - foundEnd = true; - } - } - - currentElement = currentElement.nextSibling; - } - - // Serialize fragment back to HTML - const tempDiv = document.createElement("div"); - tempDiv.appendChild(fragment); - return tempDiv.innerHTML; - } - - // Fallback: return original HTML if extraction fails - return html; -} - -// Line 323: Get page content (HTML slice for a page) -export function getPageContent(pagination: PaginationData, pageIndex: number): string { - const page = pagination.pageMap.get(pageIndex); - if (!page) return ""; - - // Find the spine that contains this page - // Pages are stored in order, so we can find the spine by checking which pages it contains - let spine: SpineInfo | undefined; - for (const s of pagination.spines) { - if (s.pages.some(p => p.pageIndex === pageIndex)) { - spine = s; - break; - } - } - - if (!spine) return ""; - - // Extract HTML content between page boundaries - const htmlSlice = extractHTMLSlice(spine.content, page.charStart, page.charEnd); - - // Wrap in a div to ensure valid HTML structure - return `
${htmlSlice}
`; -} - -// Line 200: Recalculate pagination on viewport change -export function shouldRecalculate( - pagination: PaginationData | null, - newSettings: PaginationSettings -): boolean { - if (!pagination) return true; - - const sizeChanged = - Math.abs(pagination.settings.viewportWidth - newSettings.viewportWidth) > 50 || - Math.abs(pagination.settings.viewportHeight - newSettings.viewportHeight) > 50; - - const fontChanged = pagination.settings.fontSize !== newSettings.fontSize; - const lineChanged = pagination.settings.lineHeight !== newSettings.lineHeight; - - return sizeChanged || fontChanged || lineChanged; -} - -// Line 200: Create position object from page number -export function createPositionFromPage( - book: ReflowableBook, - page: number -): ReadingPosition { - if (!book.pagination) { - return { - currentPage: 1, - spineIndex: 0, - localPageIndex: 0, - cfi: "", - progress: 0, - }; - } - - const pageIndex = page - 1; - const pageData = book.pagination.pageMap.get(pageIndex); - - if (!pageData) { - return { - currentPage: 1, - spineIndex: 0, - localPageIndex: 0, - cfi: "", - progress: 0, - }; - } - - // Find which spine this page belongs to - let spineIndex = 0; - for (const spine of book.pagination.spines) { - if (pageData.localPageIndex < spine.pages.length) { - spineIndex = spine.spineIndex; - break; - } - } - - return { - currentPage: page, - spineIndex, - localPageIndex: pageData.localPageIndex, - cfi: pageData.cfi, - progress: book.pagination.totalPages > 0 ? page / book.pagination.totalPages : 0, - }; -} -``` - ---- - -### 3. `web/src/reader/formats/reflowable/navigation.ts` - -**NEW FILE** - Page-based navigation for reflowable formats - -```typescript -// Line 1: Import types -import type { PaginationData, ReadingPosition, ReflowableBook } from "./types"; -import { getPageContent, findPageByCFI, createPositionFromPage } from "./page-calculator"; - -// Line 5: Navigate to specific page -export function goToPage(book: ReflowableBook, targetPage: number): { - success: boolean; - position: ReadingPosition; - content: string; -} { - if (!book.pagination) { - return { success: false, position: createDefaultPosition(), content: "" }; - } - - const pageIndex = Math.max(0, Math.min(targetPage - 1, book.pagination.totalPages - 1)); - const content = getPageContent(book.pagination, pageIndex); - const position = createPositionFromPage(book, pageIndex + 1); - - return { success: true, position, content }; -} - -// Line 22: Navigate to next page -export function nextPage(book: ReflowableBook): { - success: boolean; - position: ReadingPosition; - content: string; -} { - const nextPageNum = book.position.currentPage + 1; - return goToPage(book, nextPageNum); -} - -// Line 32: Navigate to previous page -export function previousPage(book: ReflowableBook): { - success: boolean; - position: ReadingPosition; - content: string; -} { - const prevPageNum = book.position.currentPage - 1; - return goToPage(book, prevPageNum); -} - -// Line 42: Jump to specific CFI -export function goToCFI(book: ReflowableBook, cfi: string): { - success: boolean; - position: ReadingPosition; - content: string; -} { - if (!book.pagination) { - return { success: false, position: createDefaultPosition(), content: "" }; - } - - const pageNum = findPageByCFI(book.pagination, cfi); - return goToPage(book, pageNum); -} - -// Line 57: Create default position -function createDefaultPosition(): ReadingPosition { - return { - currentPage: 1, - spineIndex: 0, - localPageIndex: 0, - cfi: "", - progress: 0, - }; -} - -// Line 97: Check if navigation is possible -export function canGoNext(book: ReflowableBook): boolean { - return book.position.currentPage < (book.pagination?.totalPages || 1); -} - -// Line 102: Check if previous navigation is possible -export function canGoPrevious(book: ReflowableBook): boolean { - return book.position.currentPage > 1; -} - -// Line 107: Get progress percentage -export function getProgressPercentage(book: ReflowableBook): number { - return Math.round(book.position.progress * 100); -} - -// Line 112: Update book position (after resize/recalculation) -export function updatePosition( - book: ReflowableBook, - newCFI?: string -): ReadingPosition { - if (newCFI && book.pagination) { - const pageNum = findPageByCFI(book.pagination, newCFI); - return createPositionFromPage(book, pageNum); - } - - return book.position; -} -``` - ---- - -### 4. `web/src/reader/formats/reflowable/progress-tracker.ts` - -**NEW FILE** - Track and sync reading progress using CFI - -```typescript -// Line 1: Import types -import type { ReflowableBook, ReadingPosition } from "./types"; -import { findPageByCFI, createPositionFromPage } from "./page-calculator"; - -// Line 6: Update current position -export function updateCurrentPosition( - book: ReflowableBook, - position: ReadingPosition -): ReflowableBook { - return { - ...book, - position, - }; -} - -// Line 13: Extract CFI from position -export function getCurrentCFI(book: ReflowableBook): string { - return book.position.cfi; -} - -// Line 18: Calculate progress for display -export function calculateProgress(book: ReflowableBook): { - currentPage: number; - totalPages: number; - percentage: number; -} { - const totalPages = book.pagination?.totalPages || 1; - const currentPage = book.position.currentPage; - const percentage = totalPages > 0 ? Math.round((currentPage / totalPages) * 100) : 0; - - return { currentPage, totalPages, percentage }; -} - -// Line 31: Get position for saving to database -export function getPositionForSave(book: ReflowableBook): { - cfi: string; - progress: number; - page: number; -} { - return { - cfi: book.position.cfi, - progress: book.position.progress, - page: book.position.currentPage, - }; -} - -// Line 42: Restore position from database -export function restorePosition( - book: ReflowableBook, - savedCFI: string, - savedPage?: number -): ReadingPosition { - if (!book.pagination) { - return book.position; - } - - // If we have saved CFI, try to find exact position - if (savedCFI) { - const pageNum = findPageByCFI(book.pagination, savedCFI); - return createPositionFromPage(book, pageNum); - } - - // Otherwise use saved page number - if (savedPage && savedPage > 0) { - return createPositionFromPage(book, savedPage); - } - - return book.position; -} - -// Line 68: Check if position changed significantly -export function didPositionChange( - oldPos: ReadingPosition, - newPos: ReadingPosition -): boolean { - return oldPos.currentPage !== newPos.currentPage || - oldPos.cfi !== newPos.cfi || - Math.abs(oldPos.progress - newPos.progress) > 0.01; -} -``` - ---- - -### 5. `web/src/reader/formats/reflowable/content-renderer.ts` - -**NEW FILE** - Render page content to DOM - -```typescript -// Line 1: Import types -import type { PageBoundary } from "./types"; - -// Line 4: Render a page's content to the DOM -export function renderPage( - container: HTMLElement, - content: string, - pageData: PageBoundary | null -): void { - container.innerHTML = ""; - - const wrapper = document.createElement("div"); - wrapper.className = "reflowable-page"; - wrapper.style.height = "calc(100vh - 120px)"; - wrapper.style.overflow = "hidden"; - wrapper.style.position = "relative"; - wrapper.style.display = "flex"; - wrapper.style.flexDirection = "column"; - - // Parse the HTML content (which is already sliced by getPageContent) - const tempDiv = document.createElement("div"); - tempDiv.innerHTML = content; - const pageContent = tempDiv.querySelector(".page-content-wrapper"); - - if (!pageContent) { - // Fallback if wrapper not found - const contentDiv = document.createElement("div"); - contentDiv.className = "page-content"; - contentDiv.innerHTML = content; - contentDiv.style.height = "100%"; - contentDiv.style.overflow = "hidden"; - contentDiv.style.flex = "1"; - contentDiv.style.overflowY = "auto"; - wrapper.appendChild(contentDiv); - } else { - // Transfer the sliced content to our wrapper - const contentDiv = document.createElement("div"); - contentDiv.className = "page-content"; - contentDiv.style.height = "100%"; - contentDiv.style.overflow = "hidden"; - contentDiv.style.flex = "1"; - contentDiv.style.padding = "20px"; - - while (pageContent.firstChild) { - contentDiv.appendChild(pageContent.firstChild); - } - - wrapper.appendChild(contentDiv); - } - - container.appendChild(wrapper); -} - -// Line 29: Update container styles for paginated mode -export function applyPaginatedStyles(container: HTMLElement): void { - const existing = document.getElementById("reflowable-styles"); - existing?.remove(); - - const style = document.createElement("style"); - style.id = "reflowable-styles"; - style.textContent = ` - .reflowable-page { - height: calc(100vh - 120px) !important; - overflow: hidden !important; - position: relative !important; - } - - .page-content { - height: 100% !important; - overflow: hidden !important; - -webkit-column-width: auto !important; - column-width: auto !important; - -webkit-column-count: 1 !important; - column-count: 1 !important; - -webkit-column-fill: auto !important; - column-fill: auto !important; - } - - .page-content img { - max-width: 100% !important; - height: auto !important; - display: block !important; - } - - .page-content p { - margin: 0.5em 0 !important; - text-align: justify !important; - } - - .page-content h1, - .page-content h2, - .page-content h3, - .page-content h4, - .page-content h5, - .page-content h6 { - margin: 1em 0 0.5em 0 !important; - page-break-after: avoid !important; - break-after: avoid !important; - } - `; - document.head.appendChild(style); -} - -// Line 76: Clear all styles -export function clearPaginatedStyles(): void { - const existing = document.getElementById("reflowable-styles"); - existing?.remove(); -} -``` - ---- - -### 6. `web/src/reader/formats/reflowable/parser.ts` - -**NEW FILE** - Unified parser interface for reflowable formats - -```typescript -// Line 1: Import types and existing parsers -import type { ReflowableBook, SpineItem, TOCItem } from "./types"; -import { parseEPUB } from "../../parsers/epub-parsers"; -import { parseFB2 } from "../../parsers/fb2-parser"; -import { parseTXT } from "../../parsers/txt-parser"; -import { parseHTML } from "../../parsers/html-parser"; - -// Line 9: Parse any reflowable format -export async function parseReflowable( - file: File, - format: "epub" | "fb2" | "txt" | "html" -): Promise { - switch (format) { - case "epub": - return await parseEPUB(file); - case "fb2": - return await parseFB2(file); - case "txt": - return await parseTXT(file); - case "html": - return await parseHTML(file); - default: - throw new Error(`Unsupported reflowable format: ${format}`); - } -} - -// Line 30: Validate parsed book data -export function validateBook(book: ReflowableBook): boolean { - return book.spine.length > 0 && book.metadata.title !== ""; -} - -// Line 35: Get book title -export function getBookTitle(book: ReflowableBook): string { - return book.metadata.title || "Untitled"; -} - -// Line 40: Get book author -export function getBookAuthor(book: ReflowableBook): string { - return book.metadata.author || "Unknown"; -} - -// Line 45: Get total spine count -export function getSpineCount(book: ReflowableBook): number { - return book.spine.length; -} - -// Line 50: Get TOC as flat list -export function getFlatTOC(book: ReflowableBook): TOCItem[] { - const flat: TOCItem[] = []; - - function traverse(items: TOCItem[]) { - for (const item of items) { - flat.push(item); - if (item.children.length > 0) { - traverse(item.children); - } - } - } - - traverse(book.toc); - return flat; -} -``` - ---- - -### 7. `web/src/reader/ui/page-display.ts` - -**NEW FILE** - Format-agnostic page display component - -```typescript -// Line 1: Display page info (Page X of Y) -export function updatePageDisplay( - container: HTMLElement, - currentPage: number, - totalPages: number -): void { - const existing = container.querySelector(".page-display"); - existing?.remove(); - - const display = document.createElement("div"); - display.className = "page-display"; - display.textContent = `Page ${currentPage} of ${totalPages}`; - display.style.cssText = ` - position: fixed; - bottom: 20px; - right: 20px; - background: var(--bg-secondary); - color: var(--text-primary); - padding: 8px 16px; - border-radius: 4px; - font-size: 14px; - z-index: 100; - `; - - container.appendChild(display); -} - -// Line 25: Remove page display -export function removePageDisplay(container: HTMLElement): void { - const existing = container.querySelector(".page-display"); - existing?.remove(); -} - -// Line 31: Update progress bar -export function updateProgressBar( - container: HTMLElement, - percentage: number -): void { - let bar = container.querySelector(".progress-bar-fill") as HTMLElement; - - if (!bar) { - const wrapper = document.createElement("div"); - wrapper.className = "progress-bar"; - wrapper.style.cssText = ` - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: 3px; - background: var(--bg-secondary); - z-index: 100; - `; - - bar = document.createElement("div"); - bar.className = "progress-bar-fill"; - bar.style.cssText = ` - height: 100%; - background: var(--accent); - transition: width 0.3s ease; - `; - - wrapper.appendChild(bar); - container.appendChild(wrapper); - } - - bar.style.width = `${percentage}%`; -} - -// Line 65: Remove progress bar -export function removeProgressBar(container: HTMLElement): void { - const existing = container.querySelector(".progress-bar"); - existing?.remove(); -} -``` - ---- - -### 8. UPDATE `web/src/reader/core/reader-navigation.ts` - -**MODIFY** - Integrate reflowable navigation module - -```typescript -// Line 1-6: Keep existing imports -import { getDefaultSettings } from "../settings-manager"; -import { getState, setState } from "./reader-state"; -import { readerEvents } from "./reader-events"; -import { updateReadingProgress } from "./reader-services"; -import type { UniversalReader } from "../reader-shell"; - -// Line 8: NEW - Import reflowable navigation -import * as reflowableNav from "../formats/reflowable/navigation"; -import * as progressTracker from "../formats/reflowable/progress-tracker"; -import * as contentRenderer from "../formats/reflowable/content-renderer"; -import type { ReflowableBook } from "../formats/reflowable/types"; - -// Line 14: Modify nextPage function -export function createNavigationAPI() { - return { - nextPage: () => { - const state = getState(); - if (!state.currentReader) return; - readerEvents.emit("beforePageChange", state.currentReader); - - if (state.currentReader.type === "ebook") { - // NEW: Use reflowable navigation - const book = state.currentReader as ReflowableBook; - - if (!reflowableNav.canGoNext(book)) { - return; // Already at last page - } - - const { success, position, content } = reflowableNav.nextPage(book); - - if (success) { - const container = document.getElementById("reader-content"); - if (!container) return; - - // Update position - const updatedBook = progressTracker.updateCurrentPosition(book, position); - setState({ currentReader: updatedBook }); - - // Render content - const pageData = updatedBook.pagination?.pageMap.get(position.currentPage - 1) || null; - contentRenderer.renderPage(container, content, pageData); - - // Update UI - updatePageIndicator(updatedBook); - sendProgressUpdate(); - } - } else if (state.currentReader.type === "pdf") { - // Keep existing PDF code (lines 45-51) - const totalPages = state.readerMetadata?.total_pages || 0; - if (state.currentReader.currentPage < totalPages) { - state.currentReader.currentPage++; - renderPDFPage(); - sendProgressUpdate(); - } - } else if ( - state.currentReader.type === "comic" || - state.currentReader.type === "manga" - ) { - // Keep existing comic/manga code (lines 52-64) - if ( - state.currentReader.currentPage < - state.currentReader.images.length - 1 - ) { - state.currentReader.currentPage++; - renderComicPage(); - sendProgressUpdate(); - } - } - - setState({ currentReader: state.currentReader }); - readerEvents.emit("afterPageChange", state.currentReader); - }, - - previousPage: () => { - const state = getState(); - if (!state.currentReader) return; - readerEvents.emit("beforePageChange", state.currentReader); - - if (state.currentReader.type === "ebook") { - // NEW: Use reflowable navigation - const book = state.currentReader as ReflowableBook; - - if (!reflowableNav.canGoPrevious(book)) { - return; // Already at first page - } - - const { success, position, content } = reflowableNav.previousPage(book); - - if (success) { - const container = document.getElementById("reader-content"); - if (!container) return; - - // Update position - const updatedBook = progressTracker.updateCurrentPosition(book, position); - setState({ currentReader: updatedBook }); - - // Render content - const pageData = updatedBook.pagination?.pageMap.get(position.currentPage - 1) || null; - contentRenderer.renderPage(container, content, pageData); - - // Update UI - updatePageIndicator(updatedBook); - sendProgressUpdate(); - } - } else if (state.currentReader.type === "pdf") { - // Keep existing PDF code (lines 98-105) - if (state.currentReader.currentPage > 1) { - state.currentReader.currentPage--; - renderPDFPage(); - sendProgressUpdate(); - } - } - // ... rest of existing code - }, - - goToPage: (page: number) => { - const state = getState(); - if (!state.currentReader) return; - readerEvents.emit("beforePageChange", state.currentReader); - - if (state.currentReader.type === "ebook") { - // NEW: Use reflowable navigation - const book = state.currentReader as ReflowableBook; - - const { success, position, content } = reflowableNav.goToPage(book, page); - - if (success) { - const container = document.getElementById("reader-content"); - if (!container) return; - - // Update position - const updatedBook = progressTracker.updateCurrentPosition(book, position); - setState({ currentReader: updatedBook }); - - // Render content - const pageData = updatedBook.pagination?.pageMap.get(position.currentPage - 1) || null; - contentRenderer.renderPage(container, content, pageData); - - // Update UI - updatePageIndicator(updatedBook); - sendProgressUpdate(); - } - } else if (state.currentReader.type === "pdf") { - // Keep existing PDF code - const totalPages = state.readerMetadata?.total_pages || 0; - if (page >= 1 && page <= totalPages) { - state.currentReader.currentPage = page; - renderPDFPage(); - sendProgressUpdate(); - } - } else if ( - state.currentReader.type === "comic" || - state.currentReader.type === "manga" - ) { - // Keep existing comic/manga code - if (page >= 0 && page < state.currentReader.images.length) { - state.currentReader.currentPage = page; - renderComicPage(); - sendProgressUpdate(); - } - } - - setState({ currentReader: state.currentReader }); - readerEvents.emit("afterPageChange", state.currentReader); - } - }; -} - -// Line 200+: NEW - Helper function to update page indicator -function updatePageIndicator(book: ReflowableBook): void { - const { currentPage, totalPages, percentage } = progressTracker.calculateProgress(book); - - const container = document.getElementById("reader-container"); - if (!container) return; - - // Update page display - const pageDisplay = document.querySelector(".page-display"); - if (pageDisplay) { - pageDisplay.textContent = `Page ${currentPage} of ${totalPages}`; - } - - // Update progress bar - const progressBar = document.querySelector(".progress-bar-fill") as HTMLElement; - if (progressBar) { - progressBar.style.width = `${percentage}%`; - } -} - -// Line 220+: Keep existing sendProgressUpdate function but modify for reflowable -function sendProgressUpdate(): void { - const state = getState(); - if (!state.currentReader) return; - - if (state.currentReader.type === "ebook") { - const book = state.currentReader as ReflowableBook; - const posData = progressTracker.getPositionForSave(book); - - updateReadingProgress({ - book_id: state.currentReader.id, - page: posData.page, - cfi: posData.cfi, - progress: posData.progress, - }); - } else if (state.currentReader.type === "pdf") { - // Keep existing PDF progress update - updateReadingProgress({ - book_id: state.currentReader.id, - page: state.currentReader.currentPage, - progress: state.currentReader.currentPage / (state.readerMetadata?.total_pages || 1), - }); - } - // ... rest of existing code -} -``` - ---- - -### 9. UPDATE `web/src/reader/reader-shell.ts` - -**MODIFY** - Initialize reflowable book with pagination - -```typescript -// Line 1-20: Keep existing imports -import { readerEvents } from "./core/reader-events"; -import { createNavigationAPI } from "./core/reader-navigation"; -import { initializeReader } from "./core/reader-context"; -import type { ReaderConfig } from "./core/reader-context"; -import type { UniversalReader } from "./types"; - -// Line 23: NEW - Import reflowable modules -import { parseReflowable } from "./formats/reflowable/parser"; -import { calculatePagination, shouldRecalculate } from "./formats/reflowable/page-calculator"; -import { restorePosition } from "./formats/reflowable/progress-tracker"; -import { applyPaginatedStyles } from "./formats/reflowable/content-renderer"; -import type { ReflowableBook, PaginationSettings } from "./formats/reflowable/types"; -import { updatePageDisplay, updateProgressBar } from "./ui/page-display"; - -// Line 31: Find loadEbook function (around line 100-150) -export async function loadEbook(file: File, savedProgress?: any): Promise { - const format = detectFormat(file); // epub, fb2, txt, html - - // Parse the book - const parsedBook = await parseReflowable(file, format as any); - - // Calculate pagination - const settings: PaginationSettings = { - fontSize: 16, - lineHeight: 1.6, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight - 120, - wordsPerPage: 250, // Will be recalculated - }; - - const pagination = await calculatePagination(parsedBook.spine, parsedBook.resources, settings); - - // Create book with pagination - const book: ReflowableBook = { - ...parsedBook, - pagination, - position: restorePosition( - { ...parsedBook, pagination }, - savedProgress?.cfi, - savedProgress?.page - ), - }; - - // Apply paginated styles - const container = document.getElementById("reader-content"); - if (container) { - applyPaginatedStyles(container); - } - - // Update state - const state = getState(); - setState({ - currentReader: book, - readerMetadata: { - title: book.metadata.title, - total_pages: pagination.totalPages, - format, - }, - }); - - // Render first page - const { content } = await import("./formats/reflowable/navigation"); - const { success, position, content: pageContent } = content.goToPage(book, book.position.currentPage); - - if (success && container) { - const { renderPage } = await import("./formats/reflowable/content-renderer"); - const pageData = pagination.pageMap.get(position.currentPage - 1) || null; - renderPage(container, pageContent, pageData); - - // Update UI - updatePageDisplay(container, position.currentPage, pagination.totalPages); - updateProgressBar(container, Math.round(position.progress * 100)); - } -} - -// Line 95+: NEW - Handle viewport resize -export function handleResize(): void { - const state = getState(); - if (!state.currentReader || state.currentReader.type !== "ebook") return; - - const book = state.currentReader as ReflowableBook; - - // New settings - const newSettings: PaginationSettings = { - fontSize: 16, // Could get from settings manager - lineHeight: 1.6, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight - 120, - wordsPerPage: 250, - }; - - // Check if recalculation needed - if (shouldRecalculate(book.pagination, newSettings)) { - // Save current CFI - const currentCFI = book.position.cfi; - - // Recalculate pagination - calculatePagination(book.spine, book.resources, newSettings).then((newPagination) => { - const updatedBook = { - ...book, - pagination: newPagination, - position: restorePosition({ ...book, pagination: newPagination }, currentCFI), - }; - - setState({ currentReader: updatedBook }); - - // Re-render current page - const { content } = require("./formats/reflowable/navigation"); - const { success, position, content: pageContent } = content.goToPage(updatedBook, updatedBook.position.currentPage); - - if (success) { - const container = document.getElementById("reader-content"); - if (container) { - const { renderPage } = require("./formats/reflowable/content-renderer"); - const pageData = newPagination.pageMap.get(position.currentPage - 1) || null; - renderPage(container, pageContent, pageData); - } - } - }); - } -} - -// Line 135+: Add resize listener -window.addEventListener("resize", debounce(handleResize, 300)); - -function debounce(func: Function, wait: number): Function { - let timeout: any; - return function(...args: any[]) { - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), wait); - }; -} -``` - ---- - -### 10. MOVE `web/src/reader/features/progress-indicator.ts` - -**MOVE TO** `web/src/reader/ui/progress-indicator.ts` - -No changes needed - just move the file. - ---- - -### 11. DELETE obsolete files and directories - -After migration, delete these old files and directories: - -**Individual files to delete:** -``` -DELETE: -- web/src/reader/ebook/page-calculator.ts → Replaced by formats/reflowable/page-calculator.ts -- web/src/reader/ebook/view-modes.ts → Replaced by formats/reflowable/content-renderer.ts -- web/src/reader/ebook/page-splitter.ts → Merged into formats/reflowable/page-calculator.ts -- web/src/reader/ebook/cfi-navigator.ts → Merged into formats/reflowable/page-calculator.ts -- web/src/reader/ebook/html-renderer.ts → Replaced by formats/reflowable/content-renderer.ts -- web/src/reader/ebook/epub-parsers.ts → Keep, but verify imports work -``` - -**Entire directories to delete (after moving contents):** -``` -DELETE DIRECTORIES: -- web/src/reader/ebook/ → All functionality moved to formats/reflowable/ -- web/src/reader/pdf/ → Moved to formats/pdf/ -- web/src/reader/comic/ → Moved to formats/comic/ -- web/src/reader/manga/ → Moved to formats/manga/ -- web/src/reader/features/ → Moved to ui/ (keep what's needed) -``` - -**Why these deletions:** -- `ebook/` was misleading - it handled multiple reflowable formats, not just EPUB -- Format-specific code mixed with shared code - now separated -- Old pagination logic was scroll-based - new is page-based -- Old CFI handling was incomplete - new version integrated into pagination - ---- - -## Migration Steps - -### Step 1: Create new directory structure - -```bash -mkdir -p web/src/reader/formats/reflowable -mkdir -p web/src/reader/formats/pdf -mkdir -p web/src/reader/formats/comic -mkdir -p web/src/reader/formats/manga -mkdir -p web/src/reader/ui -``` - -### Step 2: Move existing format-specific code - -```bash -# Move PDF files -mv web/src/reader/pdf/* web/src/reader/formats/pdf/ - -# Move comic files -mv web/src/reader/comic/* web/src/reader/formats/comic/ - -# Move manga files -mv web/src/reader/manga/* web/src/reader/formats/manga/ - -# Move UI files -mv web/src/reader/features/progress-indicator.ts web/src/reader/ui/ -``` - -### Step 3: Create new reflowable files - -Copy the code from sections 1-6 above into new files: -- `types.ts` -- `page-calculator.ts` -- `navigation.ts` -- `progress-tracker.ts` -- `content-renderer.ts` -- `parser.ts` - -### Step 4: Update existing files - -Apply changes from sections 7-9 to: -- `reader-navigation.ts` -- `reader-shell.ts` - -### Step 5: Update imports across codebase - -```bash -# Update import statements in files that reference moved modules -find web/src/reader -name "*.ts" -exec sed -i 's|from "../ebook/|from "../formats/reflowable/|g' {} \; -find web/src/reader -name "*.ts" -exec sed -i 's|from "../pdf/|from "../formats/pdf/|g' {} \; -find web/src/reader -name "*.ts" -exec sed -i 's|from "../comic/|from "../formats/comic/|g' {} \; -find web/src/reader -name "*.ts" -exec sed -i 's|from "../features/|from "../ui/|g' {} \; -``` - -### Step 6: Delete obsolete files and directories - -⚠️ **CRITICAL**: Verify all files have been moved/copied before deleting! - -```bash -# First, verify the new directories exist and have content -echo "Checking new directories..." -ls -la web/src/reader/formats/reflowable/ # Should have 6 new .ts files -ls -la web/src/reader/formats/pdf/ # Should have moved PDF files -ls -la web/src/reader/formats/comic/ # Should have moved comic files -ls -la web/src/reader/formats/manga/ # Should have moved manga files -ls -la web/src/reader/ui/ # Should have progress-indicator.ts - -# If any directory is empty, STOP and investigate before proceeding! - -# Delete individual obsolete files (if they still exist) -rm -f web/src/reader/ebook/page-calculator.ts -rm -f web/src/reader/ebook/view-modes.ts -rm -f web/src/reader/ebook/page-splitter.ts -rm -f web/src/reader/ebook/cfi-navigator.ts -rm -f web/src/reader/ebook/html-renderer.ts - -# Delete entire old directories (after moving contents) -rm -rf web/src/reader/ebook/ -rm -rf web/src/reader/pdf/ -rm -rf web/src/reader/comic/ -rm -rf web/src/reader/manga/ -rm -rf web/src/reader/features/ - -# Verification: List what remains in reader/ -echo "Remaining reader structure:" -ls -la web/src/reader/ -# Should show: core/, formats/, ui/, parsers/ (and nothing else) -``` - -### Step 7: Test - -```bash -cd web -npm run build -npm run typecheck -npm run lint -``` - ---- - -## Testing Checklist - -- [ ] EPUB loads and displays first page -- [ ] Next/previous page navigation works -- [ ] Page count displays correctly -- [ ] Progress bar updates -- [ ] CFI is saved to database on page change -- [ ] Position is restored on reload -- [ ] Pagination recalculates on window resize (±50px) -- [ ] Pagination recalculates on font size change -- [ ] PDF reader still works -- [ ] Comic reader still works -- [ ] Manga reader still works - ---- - -## Key Improvements - -1. **Modularization**: Format-specific code is now separated -2. **No OOP**: All functions, plain objects -3. **Pre-calculated pages**: No scrolling within pages -4. **Word count pagination**: More stable than viewport measurement -5. **CFI tracking**: Accurate progress sync -6. **Resize handling**: Automatic recalculation with position restoration - ---- - -## Next Steps After Implementation - -1. ✅ ~~Refine `getPageContent`~~ - **COMPLETED**: HTML slicing implemented using DOMParser -2. ✅ ~~Page rendering~~ - **COMPLETED**: Properly renders sliced content with overflow handling -3. ✅ ~~CFI generation~~ - **COMPLETED**: Full EPUB CFI spec compliance with escaping and parsing -4. Add chapter boundary detection (start new chapters on new pages) -5. Add reading time estimates -6. Implement search within book -7. Add highlight/annotation support