diff --git a/web/src/reader/core/parser-manager.ts b/web/src/reader/core/parser-manager.ts deleted file mode 100644 index 4cc9d99..0000000 --- a/web/src/reader/core/parser-manager.ts +++ /dev/null @@ -1,166 +0,0 @@ -// Parser Manager - Routes files to appropriate parsers -// Procedural style: Functions, not classes - -// ============================================================ -// 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": { - const { parseEPUB } = await import("../parsers/epub-parsers"); - return parseEPUB(file); - } - case "fb2": { - const { parseFB2 } = await import("../parsers/fb2-parser"); - return parseFB2(file); - } - case "txt": { - const { parseTXT } = await import("../parsers/txt-parser"); - return parseTXT(file); - } - case "html": { - const { parseHTML } = await import("../parsers/html-parser"); - return parseHTML(file); - } - default: - throw new Error(`Client-side parser not implemented for: ${format}`); - } -} - -// ============================================================ -// Server-Side Parsing (API Call) -// ============================================================ - -async function parseEbookOnServer( - file: Blob, - format: string, -): Promise { - const formData = new FormData(); - formData.append("file", file); - formData.append("format", format); - - const response = await fetch("/readers/parse", { - method: "POST", - body: formData, - }); - - if (!response.ok) { - throw new Error(`Server parsing failed: ${response.statusText}`); - } - - return await response.json(); -} diff --git a/web/src/reader/core/reader-navigation.ts b/web/src/reader/core/reader-navigation.ts deleted file mode 100644 index 9d3471c..0000000 --- a/web/src/reader/core/reader-navigation.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { getDefaultSettings } from "../settings-manager"; -import { getState, setState } from "./reader-state"; -import { readerEvents } from "./reader-events"; -import { updateReadingProgress } from "./reader-services"; -import { ComicReader, MangaReader, UniversalReader } from "../reader-shell"; -import * as reflowableNav from "../formats/reflowable/navigation"; -import * as progressTracker from "../formats/reflowable/progress-tracker"; -import * as contentRenderer from "../formats/reflowable/content-renderer"; -import { CurrentReader } from "./reader-context"; - -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 UniversalReader; - - if (!reflowableNav.canGoNext(book)) { - console.log("canGoNext returned false:", { - currentPage: book.position.currentPage, - totalPages: book.pagination?.totalPages, - pagination: book.pagination, - }); - 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 - contentRenderer.renderPage(container, content); - - // Update UI - updatePageIndicator(updatedBook); - sendProgressUpdate(); - // Emit progress event for page counter - readerEvents.emit("progressUpdated", { - currentPage: updatedBook.currentPage, - totalPages: updatedBook.pagination?.totalPages || 0, - percentage: updatedBook.position?.progress || 0, - }); - } - } 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" - ) { - 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 UniversalReader; - - 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 - contentRenderer.renderPage(container, content); - - // Update UI - updatePageIndicator(updatedBook); - sendProgressUpdate(); - - // Emit progress event for page counter - readerEvents.emit("progressUpdated", { - currentPage: updatedBook.currentPage, - totalPages: updatedBook.pagination?.totalPages || 0, - percentage: updatedBook.position?.progress || 0, - }); - } - } else if (state.currentReader.type === "pdf") { - if (state.currentReader.currentPage > 1) { - state.currentReader.currentPage--; - renderPDFPage(); - sendProgressUpdate(); - } - } else if ( - state.currentReader.type === "comic" || - state.currentReader.type === "manga" - ) { - if (state.currentReader.currentPage > 0) { - state.currentReader.currentPage--; - renderComicPage(); - sendProgressUpdate(); - } - } - setState({ currentReader: state.currentReader }); - 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") { - // NEW: Use reflowable navigation - const book = state.currentReader as UniversalReader; - - 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 - contentRenderer.renderPage(container, content); - - // Update UI - updatePageIndicator(updatedBook); - sendProgressUpdate(); - } - } else if (state.currentReader.type === "pdf") { - if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) { - state.currentReader.currentPage = page; - renderPDFPage(); - sendProgressUpdate(); - } - } else if ( - state.currentReader && - isComicOrMangaReader(state.currentReader) - ) { - if (page >= 0 && page < state.currentReader.images.length) { - state.currentReader.currentPage = page; - renderComicPage(); - sendProgressUpdate(); - } - } - setState({ currentReader: state.currentReader }); - readerEvents.emit("pageChanged", page); - readerEvents.emit("afterPageChange", state.currentReader); - }, - goToChapter: (chapterIndex: number) => { - const state = getState(); - if (!state.readerMetadata?.chapter_metadata?.chapters) return; - if (state.currentReader?.type !== "ebook") return; - const chapters = state.readerMetadata.chapter_metadata.chapters; - if (chapterIndex < 0 || chapterIndex >= chapters.length) return; - const chapter = chapters[chapterIndex]; - if (chapter.spine_index !== undefined) { - state.currentReader.currentSpineIndex = chapter.spine_index; - setState({ currentReader: state.currentReader }); - renderSpineItem(); - sendProgressUpdate(); - } else { - const pageAPI = createNavigationAPI(); - pageAPI.goToPage(chapter.start_page); - } - readerEvents.emit("chapterChanged", chapter); - }, - }; -} - -function isComicOrMangaReader( - reader: CurrentReader, -): reader is ComicReader | MangaReader { - return reader.type === "comic" || reader.type === "manga"; -} - -// Helper function to update page indicator -function updatePageIndicator(book: UniversalReader): 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}%`; - } -} - -export async function initializePageCalculation() { - setupScrollTracking(); - console.log("Page tracking initialized (CSS columns mode)"); -} -function setupScrollTracking(): void { - let scrollTimeout: ReturnType | null = null; - document.addEventListener( - "scroll", - () => { - const container = document.getElementById("reader-content"); - if (!container) return; - const state = getState(); - if (!state.currentReader || state.currentReader.type !== "ebook") return; - if (scrollTimeout) clearTimeout(scrollTimeout); - scrollTimeout = setTimeout(() => { - updatePageFromScroll(container); - }, 50); - }, - { passive: true }, - ); -} -function updatePageFromScroll(container: HTMLElement): void { - const state = getState(); - if (!state.currentReader || state.currentReader.type !== "ebook") return; - const scrollTop = container.scrollTop; - const contentHeight = container.scrollHeight; - const percentage = contentHeight > 0 ? scrollTop / contentHeight : 0; - setState({ - currentReader: { - ...state.currentReader, - currentScrollPosition: scrollTop, - }, - }); - readerEvents.emit("progressUpdated", { - currentPage: state.currentReader.currentPage, - totalPages: state.currentReader.pagination?.totalPages || 0, - percentage, - scrollTop, - }); -} - -export async function renderSpineItem(): Promise { - const state = getState(); - if (state.currentReader?.type !== "ebook") return; - const container = document.getElementById("reader-content"); - if (!container) return; - const spineItem = - state.currentReader.cif.spine[state.currentReader.currentSpineIndex]; - if (!spineItem) return; - const resources = state.currentReader.cif.resources; - const contentBlob = resources?.get(spineItem.content); - if (!contentBlob) { - console.error("Spine item content not found:", spineItem.content); - container.innerHTML = `

Error: Could not load chapter content

`; - return; - } - let currentPageContent = await contentBlob.text(); - const modifiedContent = rewriteImageUrls( - currentPageContent, - state.currentReader.cif.resources, - ); - const parser = new DOMParser(); - const doc = parser.parseFromString(modifiedContent, "text/html"); - const imgElements = Array.from(doc.querySelectorAll("img")); - for (const img of imgElements) { - const src = img.getAttribute("src"); - if (!src) continue; - const blob = findImageInResources(state.currentReader.cif.resources, src); - if (blob) { - img.setAttribute("src", URL.createObjectURL(blob)); - } - } - const svgImgElements = Array.from(doc.querySelectorAll("image")); - for (const img of svgImgElements) { - const src = img.getAttribute("xlink:href"); - if (!src) continue; - const blob = findImageInResources(state.currentReader.cif.resources, src); - if (blob) { - img.setAttribute("xlink:href", URL.createObjectURL(blob)); - } - } - container.innerHTML = `
${doc.body.innerHTML}
`; - applyReaderTheme(); - applyTypography(); - requestAnimationFrame(() => { - updatePageFromScroll(container); - }); -} - -function rewriteImageUrls( - htmlContent: string, - resources: Map, -): string { - // Find all img tags and rewrite their src to blob URLs - const imgRegex = /]*src="([^"]+)"[^>]*>/gi; - return htmlContent.replace(imgRegex, (match, src) => { - // Try to find the image in resources - const imageBlob = findImageInResources(resources, src); - - if (imageBlob) { - const blobUrl = URL.createObjectURL(imageBlob); - return match.replace(src, blobUrl); - } - return match; // Keep original if not found - }); -} - -export function findImageInResources( - resources: Map, - src: string, -): Blob | undefined { - // Try full path as stored - if (resources.has(src)) return resources.get(src); - // Try relative path (everything after first /) - const firstSlash = src.indexOf("/"); - if (firstSlash > 0) { - const relativePath = src.substring(firstSlash + 1); - if (resources.has(relativePath)) return resources.get(relativePath); - } - // Try filename only - const filename = src.split("/").pop(); - if (filename && resources.has(filename)) return resources.get(filename); - // Try without extension - const withoutExt = filename?.replace(/\.[^.]+$/, ""); - if (withoutExt && resources.has(withoutExt)) return resources.get(withoutExt); - // Try with common extensions - const extensions = [".jpg", ".jpeg", ".gif", ".webp", ".svg", ".png"]; - for (const ext of extensions) { - const withExt = withoutExt + ext; - if (resources.has(withExt)) return resources.get(withExt); - } - return undefined; -} - -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("../formats/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 settings = getDefaultSettings(); - const container = document.getElementById("reader-content"); - if (!container) return; - - container.classList.add(`theme-${settings.reading_theme}`); -} - -function applyTypography() { - const settings = getDefaultSettings(); - 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 sendProgressUpdate(): void { - const state = getState(); - if (!state.currentReader || !state.readerMetadata) return; - const mediaItemId = state.readerMetadata.id; - if (!mediaItemId) { - console.warn("No mediaItemId available for progress update"); - return; - } - let currentPage = 1; - let totalPages = 1; - let percentage = 0; - if (state.currentReader.type === "ebook") { - const book = state.currentReader as UniversalReader; - const posData = progressTracker.getPositionForSave(book); - - currentPage = posData.page; - totalPages = book.pagination?.totalPages || 1; - percentage = posData.progress; - - // Correct: 3 separate parameters - updateReadingProgress( - mediaItemId, // 1st param: mediaItemId - { - // 2nd param: ReadingProgress object - current_page: posData.page, - total_pages: totalPages, - cfi: posData.cfi, - percentage, - last_read_at: new Date().toISOString(), - }, - { - // 3rd param (optional): ExtendedProgress - percentage, - }, - ); - } else if (state.currentReader.type === "pdf") { - totalPages = state.readerMetadata.total_pages || 1; - currentPage = state.currentReader.currentPage; - percentage = currentPage / totalPages; - - updateReadingProgress( - mediaItemId, - { - current_page: currentPage, - total_pages: totalPages, - last_read_at: new Date().toISOString(), - percentage, - }, - { percentage }, - ); - } else if ( - state.currentReader.type === "comic" || - state.currentReader.type === "manga" - ) { - totalPages = state.currentReader.images.length; - currentPage = state.currentReader.currentPage; - percentage = currentPage / totalPages; - - updateReadingProgress( - mediaItemId, - { - current_page: currentPage, - total_pages: totalPages, - last_read_at: new Date().toISOString(), - percentage, - }, - { percentage }, - ); - } - readerEvents.emit("progressUpdated", { currentPage, totalPages, percentage }); -} diff --git a/web/src/reader/core/reader-services.ts b/web/src/reader/core/reader-services.ts deleted file mode 100644 index e702dfe..0000000 --- a/web/src/reader/core/reader-services.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { apiPut, ReadingProgress } from "../../api"; - -interface ExtendedProgress { - character?: number; - chapter?: number; - percentage?: number; -} - -export async function updateReadingProgress( - mediaItemId: string, - progress: ReadingProgress, - extended?: ExtendedProgress, -): Promise { - if (!mediaItemId || mediaItemId === "undefined") { - console.warn("Skipping progress update - no valid mediaItemId"); - return; - } - - const payload: ReadingProgress = { - current_page: progress.current_page, - total_pages: progress.total_pages, - cfi: progress.cfi || "", - percentage: extended?.percentage || 0, - last_read_at: new Date().toISOString(), - }; - const response = await apiPut( - `/media-items/${mediaItemId}/progress`, - payload, - ); - if (!response.ok) { - const clonedResponse = response.clone(); - const errorText = await clonedResponse.text(); - console.warn("Progress update failed:", clonedResponse.status, errorText); - return; - } -} - -export function getChapterNavigation(chapters: Chapter[]) { - 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; - }, - }; -} diff --git a/web/src/reader/core/reader-state.ts b/web/src/reader/core/reader-state.ts deleted file mode 100644 index 10c10a2..0000000 --- a/web/src/reader/core/reader-state.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { 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.currentPage ?? reader.currentSpineIndex + 1; - } - return reader.currentPage; -} - -import { readerEvents } from "./reader-events"; diff --git a/web/src/reader/formats/comic/background-color.ts b/web/src/reader/formats/comic/background-color.ts deleted file mode 100644 index 4bf912b..0000000 --- a/web/src/reader/formats/comic/background-color.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Background color options for manga/comics -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - const state = createBackgroundColorState(); - applyBackgroundColor(state.current); - - context.events.on( - "background-color:set", - (detail: { color: BackgroundColor; customColor?: string }) => { - setBackgroundColor(state, detail.color, detail.customColor); - }, - ); - - context.events.on("background-color:toggle", () => { - toggleBackgroundColor(state); - }); - - context.events.on( - "ui:show-settings", - (detail: { container: HTMLElement }) => { - renderBackgroundColorPicker(detail.container, state); - }, - ); - - context.events.on("reader:unload", () => { - const picker = document.querySelector(".background-color-picker"); - picker?.remove(); - }); -} - -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 { - const saved = localStorage.getItem( - "reader-background-color", - ) as BackgroundColor; - return { - current: saved || initial, - customColor: "#000000", - }; -} - -function applyBackgroundColor(color: BackgroundColor): void { - const bgColor = backgroundColors[color]; - document.documentElement.style.setProperty("--reader-bg-color", bgColor); - const viewer = document.querySelector(".reader-content") as HTMLElement; - if (viewer) { - viewer.style.backgroundColor = bgColor; - } -} - -function setBackgroundColor( - state: BackgroundColorState, - color: BackgroundColor, - customColor?: string, -): BackgroundColorState { - state.current = color; - state.customColor = customColor || state.customColor; - - const bgColor = - color === "custom" ? state.customColor : backgroundColors[color]; - document.documentElement.style.setProperty("--reader-bg-color", bgColor); - - const viewer = document.querySelector(".reader-content") as HTMLElement; - if (viewer) { - viewer.style.backgroundColor = bgColor; - } - - localStorage.setItem("reader-background-color", color); - - const picker = document.querySelector(".background-color-picker"); - if (picker) { - updateBackgroundColorUI(picker as HTMLElement, state); - } - - return state; -} - -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", () => { - setBackgroundColor(state, color); - updateBackgroundColorUI(picker, state); - }); - 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); - }); -} - diff --git a/web/src/reader/formats/comic/chapter-markers.ts b/web/src/reader/formats/comic/chapter-markers.ts deleted file mode 100644 index bfc4512..0000000 --- a/web/src/reader/formats/comic/chapter-markers.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Chapter markers for manga/comics -// Visual indicators for chapter boundaries -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - let state: ChapterMarkerState | null = null; - - context.events.on( - "reader:loaded", - (detail: { chapters: ChapterInfo[]; currentPage: number }) => { - state = createChapterMarkerState(detail.chapters, detail.currentPage); - renderChapterMarkers(context.elements.readerContent, state); - }, - ); - - context.events.on("page-changed", (detail: { page: number }) => { - if (state) { - updateCurrentChapter(state, detail.page); - } - }); - - context.events.on("chapter-markers:toggle", () => { - if (state) { - toggleChapterMarkers(state); - } - }); - - context.events.on( - "chapter-markers:navigate", - (detail: { chapterNumber: number }) => { - if (state) { - scrollToChapter(state, detail.chapterNumber); - } - }, - ); - - context.events.on("reader:unload", () => { - const markers = document.querySelector(".chapter-markers"); - markers?.remove(); - }); -} - -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) { - state.currentChapter = currentChapter; - - const markers = document.querySelector(".chapter-markers"); - if (markers) { - renderChapterMarkers(markers.parentElement!, state); - } - } - - return state; -} - -function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState { - state.showMarkers = !state.showMarkers; - - const markers = document.querySelector(".chapter-markers"); - if (markers) { - markers.classList.toggle("hidden", !state.showMarkers); - } - - return state; -} - -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 }, - }), - ); - } -} - diff --git a/web/src/reader/formats/comic/image-parser.ts b/web/src/reader/formats/comic/image-parser.ts deleted file mode 100644 index 468fc3d..0000000 --- a/web/src/reader/formats/comic/image-parser.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Comic/Manga Reader - Image-based pages -// Handles CBZ, comic archives, image directories -interface ComicReader { - type: "comic"; - images: Blob[]; - currentPage: number; -} -interface MangaReader { - type: "manga"; - images: Blob[]; - currentPage: number; - readingDirection: "rtl" | "vertical"; -} -// ============================================================ -// Comic Reader Initialization -// ============================================================ -export async function initializeComicReader( - metadata: ReaderMetadata, -): Promise { - const response = await fetch(metadata.file_path); - const archiveBlob = await response.blob(); - // Parse comic archive (CBZ) or image directory - const images = await parseComicArchive(archiveBlob); - return { - type: "comic", - images, - currentPage: 1, - }; -} -// ============================================================ -// Manga Reader Initialization -// ============================================================ -export async function initializeMangaReader( - metadata: ReaderMetadata, -): Promise { - const response = await fetch(metadata.file_path); - const archiveBlob = await response.blob(); - const images = await parseComicArchive(archiveBlob); - return { - type: "manga", - images, - currentPage: 1, - readingDirection: "rtl", // Default for manga - }; -} -// ============================================================ -// Comic Archive Parser -// ============================================================ -async function parseComicArchive(archiveBlob: Blob): Promise { - const JSZip = (await import("jszip")).default; - const zip = await JSZip.loadAsync(archiveBlob); - const images: Blob[] = []; - // Get all image files from archive - const files = Object.keys(zip.files).filter((filename) => - filename.match(/\.(jpg|jpeg|png|gif|webp)$/i), - ); - // Sort files naturally (page-01.jpg, page-02.jpg, etc.) - files.sort((a, b) => { - const aName = a.split("/").pop() || a; - const bName = b.split("/").pop() || b; - return aName.localeCompare(bName, undefined, { numeric: true }); - }); - // Extract images - for (const file of files) { - const fileData = await zip.file(file)?.async("blob"); - if (fileData) { - images.push(fileData); - } - } - return images; -} diff --git a/web/src/reader/formats/comic/page-cache.ts b/web/src/reader/formats/comic/page-cache.ts deleted file mode 100644 index 93d2d4f..0000000 --- a/web/src/reader/formats/comic/page-cache.ts +++ /dev/null @@ -1,195 +0,0 @@ -// Lazy-loading page cache with 5-page ahead prefetch -// Shared by both comic and manga readers -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; -import { detectPanels } from "./panel-detection.service"; - -export function init(context: ReaderContext): void { - let state: PageCacheState | null = null; - - context.events.on("reader:loaded", (detail: { mediaItemId: string }) => { - state = createPageCache(detail.mediaItemId); - }); - - context.events.on( - "page-cache:get", - async (detail: { pageNumber: number }) => { - if (state) { - const result = await getCachedPage(state, detail.pageNumber); - context.events.emit("page-cache:loaded", { - page: detail.pageNumber, - image: result.page, - }); - } - }, - ); - - context.events.on("page-cache:prefetch", (detail: { startPage: number }) => { - if (state) { - prefetchPages(state, detail.startPage); - } - }); - - context.events.on("page-cache:cleanup", (detail: { currentPage: number }) => { - if (state) { - cleanupPageCache(state, detail.currentPage); - } - }); - - context.events.on( - "page-cache:detected-panels", - async (detail: { pageNumber: number }) => { - if (state) { - const panels = await detectPagePanels(state, detail.pageNumber); - context.events.emit("page-cache:panels-ready", { - pageNumber: detail.pageNumber, - panels, - }); - } - }, - ); - - context.events.on("reader:unload", () => { - if (state) { - state.cache.clear(); - state.loading.clear(); - state.panelData.clear(); - } - }); -} - -export interface PageCacheState { - cache: Map; - loading: Set; - maxAhead: number; - mediaItemId: string; - panelData: Map; -} - -export function createPageCache(mediaItemId: string): PageCacheState { - return { - cache: new Map(), - loading: new Set(), - maxAhead: 5, - mediaItemId, - panelData: new Map(), - }; -} - -export 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; - } - - state.loading.add(pageNumber); - - const img = await loadComicPage(state, pageNumber); - - state.cache.set(pageNumber, img); - state.loading.delete(pageNumber); - - prefetchPages(state, pageNumber + 1); - cleanupPageCache(state, pageNumber); - - return { ...state, page: img }; -} - -export 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; -} - -export 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); - }); - } - } -} - -export 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); - } - } - - state.cache = newCache; - return state; -} - -export async function detectPagePanels( - state: PageCacheState, - pageNumber: number, -): Promise { - if (state.panelData?.has(pageNumber)) { - return state.panelData.get(pageNumber)!.panels; - } - - let image: HTMLImageElement; - if (state.cache.has(pageNumber)) { - image = state.cache.get(pageNumber)!; - } else { - image = await loadComicPage(state, pageNumber); - } - - const canvas = document.createElement("canvas"); - canvas.width = image.width; - canvas.height = image.height; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(image, 0, 0); - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - - const result = await detectPanels(imageData, true); - - if (!state.panelData) { - state.panelData = new Map(); - } - state.panelData.set(pageNumber, result); - - return result.panels; -} - diff --git a/web/src/reader/formats/comic/page-order.ts b/web/src/reader/formats/comic/page-order.ts deleted file mode 100644 index e6a021c..0000000 --- a/web/src/reader/formats/comic/page-order.ts +++ /dev/null @@ -1,152 +0,0 @@ -// Page order presets for manga/comics -// Auto-detect Japanese vs Western reading order -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - let state: PageOrderState | null = null; - - context.events.on( - "reader:loaded", - (detail: { totalPages: number; pageNames: string[] }) => { - state = createPageOrderState(detail.totalPages, detail.pageNames); - }, - ); - - context.events.on("page-order:set", (detail: { mode: PageOrderMode }) => { - if (state) { - setPageOrderMode(state, detail.mode, context); - } - }); - - context.events.on("page-order:get", () => { - if (state) { - const order = getPageOrder(state); - context.events.emit("page-order:current", { order }); - } - }); - - context.events.on( - "page-order:reorder", - (detail: { pageNumbers: number[] }) => { - if (state) { - const reordered = reorderPages(state, detail.pageNumbers); - context.events.emit("page-order:reordered", { pages: reordered }); - } - }, - ); - - context.events.on( - "page-order:display-number", - (detail: { actualPage: number }) => { - if (state) { - const displayPage = getDisplayPageNumber(state, detail.actualPage); - context.events.emit("page-order:display-page", { displayPage }); - } - }, - ); -} - -type PageOrderMode = "auto" | "japanese" | "western"; - -interface PageOrderConfig { - mode: PageOrderMode; - detectedOrder: PageOrderMode; - userOverride: boolean; -} - -interface PageOrderState { - config: PageOrderConfig; - totalPages: number; -} - -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, - context: ReaderContext, -): PageOrderState { - state.config = { - ...state.config, - mode, - userOverride: mode !== "auto", - }; - - const order = getPageOrder(state); - context.events.emit("page-order:changed", { mode, order }); - - return state; -} - -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; -} - diff --git a/web/src/reader/formats/comic/page-scrubber.ts b/web/src/reader/formats/comic/page-scrubber.ts deleted file mode 100644 index 5faf4bb..0000000 --- a/web/src/reader/formats/comic/page-scrubber.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Page slider/scrubber for quick navigation -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - let state: PageScrubberState | null = null; - let scrubberElement: HTMLElement | null = null; - - context.events.on( - "reader:loaded", - (detail: { - currentPage: number; - totalPages: number; - container: HTMLElement; - }) => { - state = createPageScrubber( - detail.container, - detail.currentPage, - detail.totalPages, - ); - }, - ); - - context.events.on("page-scrubber:show", () => { - if (state) { - showPageScrubber(state); - } - }); - - context.events.on("page-scrubber:hide", () => { - if (state) { - hidePageScrubber(state); - } - }); - - context.events.on("page-changed", (detail: { page: number }) => { - if (state) { - updatePageScrubber(state, detail.page); - } - }); - - context.events.on("reader:unload", () => { - scrubberElement?.remove(); - scrubberElement = null; - }); -} - -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); - dispatchPageNavigationEvent(targetPage); - }); - - state.container.appendChild(scrubber); -} - -function updatePageScrubber( - state: PageScrubberState, - currentPage: number, -): PageScrubberState { - state.currentPage = currentPage; - - const label = state.container.querySelector(".page-label"); - if (label) { - label.textContent = String(currentPage); - } - - return state; -} - -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 dispatchPageNavigationEvent(page: number): void { - window.dispatchEvent( - new CustomEvent("navigate-to-page", { detail: { page } }), - ); -} - diff --git a/web/src/reader/formats/comic/panel-detection.ml.ts b/web/src/reader/formats/comic/panel-detection.ml.ts deleted file mode 100644 index 9d1a26b..0000000 --- a/web/src/reader/formats/comic/panel-detection.ml.ts +++ /dev/null @@ -1,93 +0,0 @@ -// ML-based panel detection using COCO-SSD pre-trained model - -interface Panel { - id: string; - x: number; - y: number; - width: number; - height: number; - reading_order: number; -} - -let model: any = null; -let tfLoaded = false; - -async function loadTF(): Promise { - if (tfLoaded) return; - - // Load TensorFlow.js - await import("@tensorflow/tfjs"); - tfLoaded = true; -} - -async function loadModel(): Promise { - if (model) return; - - await loadTF(); - - // Load COCO-SSD model (pre-trained on millions of images) - const cocoSsd = await import("@tensorflow-models/coco-ssd"); - model = await cocoSsd.load({ - base: "lite_mobilenet_v2", // Smaller, faster model - }); -} - -async function detectPanelsML(imageData: ImageData): Promise { - await loadModel(); - - // Create HTMLCanvasElement to run model inference - const canvas = document.createElement("canvas"); - canvas.width = imageData.width; - canvas.height = imageData.height; - const ctx = canvas.getContext("2d")!; - ctx.putImageData(imageData, 0, 0); - - // Run COCO-SSD model - const predictions = await model.detect(canvas); - - // Filter predictions to find rectangular regions (panels) - // COCO-SSD detects common objects, we look for rectangular ones - const panels: Panel[] = []; - const imgWidth = imageData.width; - const imgHeight = imageData.height; - - for (let i = 0; i < predictions.length; i++) { - const pred = predictions[i]; - - // COCO-SSD detects "book" and similar objects - // We filter for reasonable panel-like detections - const [x, y, w, h] = pred.bbox; - const aspectRatio = w / h; - - const isRectangular = - aspectRatio > 0.3 && // Not too tall/thin - aspectRatio < 5 && // Not too wide - w > imgWidth * 0.05 && // Not too small - h > imgHeight * 0.05; - - if (isRectangular) { - panels.push({ - id: `ml-panel-${i}`, - x: (x / imgWidth) * 100, - y: (y / imgHeight) * 100, - width: (w / imgWidth) * 100, - height: (h / imgHeight) * 100, - reading_order: i, - }); - } - } - - // Sort panels by reading order - panels.sort((a, b) => { - const rowA = Math.floor(a.y / 25); - const rowB = Math.floor(b.y / 25); - if (rowA !== rowB) return rowA - rowB; - return a.x - b.x; - }); - - panels.forEach((p, i) => (p.reading_order = i)); - - return panels; -} - -export { detectPanelsML, loadModel }; diff --git a/web/src/reader/formats/comic/panel-detection.opencv.ts b/web/src/reader/formats/comic/panel-detection.opencv.ts deleted file mode 100644 index fa4c9c7..0000000 --- a/web/src/reader/formats/comic/panel-detection.opencv.ts +++ /dev/null @@ -1,113 +0,0 @@ -// OpenCV.js-based edge detection for panel boundaries - -interface Panel { - id: string; - x: number; - y: number; - width: number; - height: number; - reading_order: number; -} - -let openCVLoaded = false; - -async function loadOpenCV(): Promise { - if (openCVLoaded) return; - - // OpenCV.js loads asynchronously and registers globally - await import("@techstark/opencv-js"); - - // Wait for OpenCV to be ready - return new Promise((resolve) => { - const check = () => { - if ((window as any).cv && (window as any).cv.Mat) { - openCVLoaded = true; - resolve(); - } else { - setTimeout(check, 50); - } - }; - check(); - }); -} - -async function detectPanelsOpenCV(imageData: ImageData): Promise { - await loadOpenCV(); - - const cv = (window as any).cv; - - // Create matrices from ImageData - const src = cv.matFromImageData(imageData); - const gray = new cv.Mat(); - const blurred = new cv.Mat(); - const edges = new cv.Mat(); - const contours = new cv.Mat(); - const hierarchy = new cv.Mat(); - - try { - // Convert to grayscale - cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0); - - // Apply Gaussian blur to reduce noise - cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT); - - // Detect edges using Canny - cv.Canny(blurred, edges, 50, 150, 3, false); - - // Find contours - cv.findContours( - edges, - contours, - hierarchy, - cv.RETR_EXTERNAL, - cv.CHAIN_APPROX_SIMPLE, - ); - - // Convert contours to panels - const panels: Panel[] = []; - const imgWidth = imageData.width; - const imgHeight = imageData.height; - - for (let i = 0; i < contours.size(); i++) { - const rect = cv.boundingRect(contours.get(i)); - const aspectRatio = rect.width / rect.height; - - // Filter: reject very small or very thin contours - const minSize = Math.min(imgWidth, imgHeight) * 0.05; - if (rect.width < minSize || rect.height < minSize) continue; - if (aspectRatio < 0.1 || aspectRatio > 10) continue; - - panels.push({ - id: `opencv-panel-${i}`, - x: (rect.x / imgWidth) * 100, - y: (rect.y / imgHeight) * 100, - width: (rect.width / imgWidth) * 100, - height: (rect.height / imgHeight) * 100, - reading_order: i, - }); - } - - // Sort panels by reading order (top-left to bottom-right) - panels.sort((a, b) => { - const rowA = Math.floor(a.y / 25); - const rowB = Math.floor(b.y / 25); - if (rowA !== rowB) return rowA - rowB; - return a.x - b.x; - }); - - // Reassign reading order after sorting - panels.forEach((p, i) => (p.reading_order = i)); - - return panels; - } finally { - // Clean up OpenCV matrices - src.delete(); - gray.delete(); - blurred.delete(); - edges.delete(); - contours.delete(); - hierarchy.delete(); - } -} - -export { detectPanelsOpenCV, loadOpenCV }; diff --git a/web/src/reader/formats/comic/panel-detection.service.ts b/web/src/reader/formats/comic/panel-detection.service.ts deleted file mode 100644 index 516d5bd..0000000 --- a/web/src/reader/formats/comic/panel-detection.service.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Main panel detection service with fallback chain -// Priority: OpenCV → ML → Grid → Manual Editor -import { detectPanelsOpenCV } from "./panel-detection.opencv"; -import { detectPanelsML } from "./panel-detection.ml"; -import { detectPanelsGrid } from "./panel-detector"; - -interface DetectionResult { - panels: Panel[]; - method: "opencv" | "ml" | "grid" | "manual"; - confidence: number; -} - -interface Panel { - id: string; - x: number; - y: number; - width: number; - height: number; - reading_order: number; -} - -async function detectPanels( - imageData: ImageData, - allowManual: boolean = true, -): Promise { - // Tier 1: OpenCV Edge Detection - try { - const panels = await detectPanelsOpenCV(imageData); - if (validatePanels(panels, imageData)) { - return { panels, method: "opencv", confidence: 0.85 }; - } - } catch (e) { - console.warn("OpenCV detection failed:", e); - } - - // Tier 2: ML Detection (COCO-SSD) - try { - const panels = await detectPanelsML(imageData); - if (validatePanels(panels, imageData)) { - return { panels, method: "ml", confidence: 0.9 }; - } - } catch (e) { - console.warn("ML detection failed:", e); - } - - // Tier 3: Grid Detection (baseline) - const panels = detectPanelsGrid(imageData); - if (allowManual && panels.length === 0) { - return { - panels: [], - method: "manual" as const, - confidence: 0, - }; - } - return { panels, method: "grid", confidence: 0.5 }; -} - -function validatePanels(panels: Panel[], imageData: ImageData): boolean { - // Must have at least 1 panel - if (panels.length === 0) return false; - // Should not have too many panels (probably noise) - if (panels.length > 30) return false; - // Panels should cover reasonable area (not all empty space) - let totalArea = panels.reduce((sum, p) => sum + p.width * p.height, 0); - if (totalArea < 10 || totalArea > 100) return false; - // Check panel sizes are reasonable relative to image dimensions - const minPanelSize = Math.min(imageData.width, imageData.height) * 0.02; - const tooSmall = panels.some( - (p) => - (p.width / 100) * imageData.width < minPanelSize || - (p.height / 100) * imageData.height < minPanelSize, - ); - if (tooSmall) return false; - return true; -} - -export { detectPanels, DetectionResult, Panel }; diff --git a/web/src/reader/formats/comic/panel-detector.ts b/web/src/reader/formats/comic/panel-detector.ts deleted file mode 100644 index 7172749..0000000 --- a/web/src/reader/formats/comic/panel-detector.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Grid-based panel detection (fast, lightweight) -// Keep as final fallback - -export interface Panel { - id: string; - x: number; - y: number; - width: number; - height: number; - reading_order: number; -} - -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; -} - -function extractCell( - imageData: ImageData, - gridX: number, - gridY: number, - cellWidth: number, - cellHeight: number, -): ImageData { - const startX = Math.floor(gridX * cellWidth); - const startY = Math.floor(gridY * cellHeight); - const width = Math.floor(cellWidth); - const height = Math.floor(cellHeight); - - const cellData = new Uint8ClampedArray(width * height * 4); - // Copy pixels for the cell region - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4; - const destIdx = (y * width + x) * 4; - cellData[destIdx] = imageData.data[srcIdx]; - cellData[destIdx + 1] = imageData.data[srcIdx + 1]; - cellData[destIdx + 2] = imageData.data[srcIdx + 2]; - cellData[destIdx + 3] = imageData.data[srcIdx + 3]; - } - } - - return new ImageData(cellData, width, height); -} - -function isAdjacent(p1: Panel, p2: Panel): boolean { - const tolerance = 5; // 5% tolerance for alignment - // Check horizontal adjacency - if ( - Math.abs(p1.y - p2.y) < tolerance && - Math.abs(p1.height - p2.height) < tolerance - ) { - return ( - Math.abs(p1.x + p1.width - p2.x) < tolerance || - Math.abs(p2.x + p2.width - p1.x) < tolerance - ); - } - // Check vertical adjacency - if ( - Math.abs(p1.x - p2.x) < tolerance && - Math.abs(p1.width - p2.width) < tolerance - ) { - return ( - Math.abs(p1.y + p1.height - p2.y) < tolerance || - Math.abs(p2.y + p2.height - p1.y) < tolerance - ); - } - return false; -} - -function mergePanels(p1: Panel, p2: Panel): Panel { - const minX = Math.min(p1.x, p2.x); - const minY = Math.min(p1.y, p2.y); - const maxX = Math.max(p1.x + p1.width, p2.x + p2.width); - const maxY = Math.max(p1.y + p1.height, p2.y + p2.height); - - return { - id: p1.id, - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - reading_order: Math.min(p1.reading_order, p2.reading_order), - }; -} - -// ADD THIS EXPORT AT THE END OF THE FILE -export { - detectPanelsGrid, - isEmpty, - mergeAdjacentPanels, - extractCell, - isAdjacent, - mergePanels, -}; diff --git a/web/src/reader/formats/comic/panel-editor.ts b/web/src/reader/formats/comic/panel-editor.ts deleted file mode 100644 index b431cfd..0000000 --- a/web/src/reader/formats/comic/panel-editor.ts +++ /dev/null @@ -1,168 +0,0 @@ -// Manual panel editor for admins/power users - -import { Alpine } from "../../../alpine"; -import { apiPut } from "../../../api"; -import { Panel } from "./panel-detector"; -import { detectPanels } from "./panel-detection.service"; - -async function loadImageForPage(pageNumber: number): Promise { - const mediaItemId = document.body.dataset.mediaItemId; - if (!mediaItemId) { - throw new Error("No mediaItemId found"); - } - - const token = localStorage.getItem("token"); - const response = await fetch(`/readers/${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 getCurrentPageNumber(): number { - // Try Alpine first - const Alpine = (window as any).Alpine; - if (Alpine) { - const readerEl = document.querySelector('[x-data="readerShell"]'); - if (readerEl) { - const readerShell = Alpine.$data(readerEl); - if (readerShell?.currentPage) { - return readerShell.currentPage; - } - } - } - - // Fallback: check for dataset attribute on reader content - const content = document.getElementById("reader-content"); - const pageFromDataset = content?.dataset.currentPage; - if (pageFromDataset) { - return parseInt(pageFromDataset, 10); - } - - // Final fallback - return 1; -} -function loadPage(pageNumber: number): void { - // Dispatch event for reader to handle navigation - window.dispatchEvent( - new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }), - ); -} - -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); -} - -// Re-detect panels using detection service -async function reDetectPanels(pageNumber: number): Promise { - const image = await loadImageForPage(pageNumber); - - const canvas = document.createElement("canvas"); - canvas.width = image.width; - canvas.height = image.height; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(image, 0, 0); - - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const result = await detectPanels(imageData, true); - - return result.panels; -} - -// Alpine component -Alpine.data("panelEditor", () => ({ - get isComicOrManga(): boolean { - const libraryType = document.body.dataset.mediaType; - return libraryType === "comic" || libraryType === "manga"; - }, - - openPanelEditor(pageNumber: number) { - openPanelEditor(pageNumber); - }, - - async reDetectPanels(pageNumber: number) { - const panels = await reDetectPanels(pageNumber); - return panels; - }, -})); - -export { openPanelEditor, reDetectPanels }; diff --git a/web/src/reader/formats/comic/panel-gap.ts b/web/src/reader/formats/comic/panel-gap.ts deleted file mode 100644 index d91e538..0000000 --- a/web/src/reader/formats/comic/panel-gap.ts +++ /dev/null @@ -1,160 +0,0 @@ -// Adjustable panel gap controls -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - const state = createPanelGapState(); - applyPanelGap(state.gapSize, state.showBorders); - - context.events.on("panel-gap:set", (detail: { gap: number }) => { - setPanelGap(state, detail.gap); - }); - - context.events.on("panel-gap:increase", (detail?: { amount: number }) => { - increasePanelGap(state, detail?.amount); - }); - - context.events.on("panel-gap:decrease", (detail?: { amount: number }) => { - decreasePanelGap(state, detail?.amount); - }); - - context.events.on("panel-gap:borders:toggle", () => { - togglePanelBorders(state); - }); - - context.events.on( - "ui:show-settings", - (detail: { container: HTMLElement }) => { - renderPanelGapControls(detail.container, state); - }, - ); - - context.events.on("reader:unload", () => { - const controls = document.querySelector(".panel-gap-controls"); - controls?.remove(); - }); -} - -interface PanelGapState { - gapSize: number; - showBorders: boolean; -} - -function createPanelGapState(initialGap: number = 4): PanelGapState { - const saved = localStorage.getItem("reader-panel-gap"); - return { - gapSize: saved ? parseInt(saved) : initialGap, - showBorders: false, - }; -} - -function applyPanelGap(gap: number, showBorders: boolean): void { - document.documentElement.style.setProperty("--panel-gap", `${gap}px`); - document.documentElement.style.setProperty( - "--panel-border-width", - showBorders ? "1px" : "0px", - ); - localStorage.setItem("reader-panel-gap", String(gap)); -} - -function setPanelGap(state: PanelGapState, gap: number): PanelGapState { - const clampedGap = Math.max(0, Math.min(20, gap)); - state.gapSize = clampedGap; - - document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`); - localStorage.setItem("reader-panel-gap", String(clampedGap)); - - const controls = document.querySelector(".panel-gap-controls"); - if (controls) { - updatePanelGapUI(controls as HTMLElement, state); - } - - return state; -} - -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 { - state.showBorders = !state.showBorders; - - document.documentElement.style.setProperty( - "--panel-border-width", - state.showBorders ? "1px" : "0px", - ); - - const controls = document.querySelector(".panel-gap-controls"); - if (controls) { - updatePanelGapUI(controls as HTMLElement, state); - } - - return state; -} - -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", () => { - increasePanelGap(state); - updatePanelGapUI(controls, state); - }); - - controls - .querySelector(".panel-gap-decrease") - ?.addEventListener("click", () => { - decreasePanelGap(state); - updatePanelGapUI(controls, state); - }); - - controls - .querySelector(".panel-gap-borders") - ?.addEventListener("click", () => { - togglePanelBorders(state); - updatePanelGapUI(controls, state); - }); - - 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 ? "▦" : "▢"; - } -} - diff --git a/web/src/reader/formats/manga/reading-direction.ts b/web/src/reader/formats/manga/reading-direction.ts deleted file mode 100644 index a75ca98..0000000 --- a/web/src/reader/formats/manga/reading-direction.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Detect reading direction from metadata or user preference -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -interface MangaMetadata { - manga_type?: string; - reading_direction?: string; - filePath: string; -} - -export function init(context: ReaderContext): void { - let state: ReadingDirectionState | null = null; - - context.events.on("reader:loaded", async (detail: { metadata: any }) => { - state = await detectReadingDirection(detail.metadata); - const effectiveDirection = getEffectiveDirection(state); - context.events.emit("reading-direction:detected", { - direction: effectiveDirection, - }); - }); - - context.events.on( - "reading-direction:set", - (detail: { direction: "auto" | "ltr" | "rtl" | "vertical" }) => { - if (state) { - state.direction = detail.direction; - const effectiveDirection = getEffectiveDirection(state); - context.events.emit("reading-direction:changed", { - direction: effectiveDirection, - }); - } - }, - ); - - context.events.on("reading-direction:get", () => { - if (state) { - const effectiveDirection = getEffectiveDirection(state); - context.events.emit("reading-direction:current", { - direction: effectiveDirection, - }); - } - }); - - context.events.on("reading-direction:is-rtl", () => { - if (state) { - const isRTL = shouldUseRTL(state); - context.events.emit("reading-direction:is-rtl-result", { isRTL }); - } - }); - - context.events.on("reading-direction:is-vertical", () => { - if (state) { - const isVertical = shouldUseVerticalScroll(state); - context.events.emit("reading-direction:is-vertical-result", { - isVertical, - }); - } - }); -} - -type ReadingDirection = "auto" | "ltr" | "rtl" | "vertical"; - -interface ReadingDirectionState { - direction: ReadingDirection; - detectedDirection: "ltr" | "rtl" | "vertical"; - userPreference: ReadingDirection | null; -} - -async function detectReadingDirection( - metadata: any, -): Promise { - const userPreference = await getUserReadingDirectionPreference(); - if (userPreference && userPreference !== "auto") { - return { - direction: userPreference, - detectedDirection: "ltr", - userPreference, - }; - } - - const detectedDirection = detectFromMetadata(metadata); - - return { - direction: "auto", - detectedDirection, - userPreference: null, - }; -} - -function detectFromMetadata( - metadata: MangaMetadata, -): "ltr" | "rtl" | "vertical" { - const mangaType = metadata.manga_type; - if (mangaType === "yes_and_right_to_left" || mangaType === "yes") { - return "rtl"; - } - - const readingDirection = metadata.reading_direction; - if (readingDirection === "rtl" || readingDirection === "vertical") { - return readingDirection; - } - - const filename = metadata.filePath.toLowerCase(); - if ( - filename.includes("manga") || - filename.includes("manhwa") || - filename.includes("webtoon") - ) { - return "vertical"; - } - - 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"; -} diff --git a/web/src/reader/formats/manga/rtl-navigator.ts b/web/src/reader/formats/manga/rtl-navigator.ts deleted file mode 100644 index 88789da..0000000 --- a/web/src/reader/formats/manga/rtl-navigator.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Right-to-left navigation for manga -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - let state: RTLNavigatorState | null = null; - - context.events.on( - "reader:loaded", - (detail: { totalPages: number; currentPage?: number }) => { - state = createRTLNavigator(detail.totalPages); - if (detail.currentPage) { - state.currentPage = detail.currentPage; - } - }, - ); - - context.events.on("navigation:next-page", () => { - if (state) { - const nextPage = getNextPage(state); - state.currentPage = nextPage; - context.events.emit("navigation:to-page", { page: nextPage }); - } - }); - - context.events.on("navigation:previous-page", () => { - if (state) { - const previousPage = getPreviousPage(state); - state.currentPage = previousPage; - context.events.emit("navigation:to-page", { page: previousPage }); - } - }); - - context.events.on("navigation:to-page", (detail: { page: number }) => { - if (state) { - state = navigateToPage(state, detail.page); - const progress = getReadingProgressPercentage(state); - context.events.emit("navigation:progress", { progress }); - } - }); - - context.events.on("navigation:get-progress", () => { - if (state) { - const progress = getProgress(state); - context.events.emit("navigation:progress-current", progress); - } - }); -} - -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 { - if (state.readingDirection === "rtl") { - return Math.max(1, state.currentPage - 1); - } - return Math.min(state.totalPages, state.currentPage + 1); -} - -function getPreviousPage(state: RTLNavigatorState): 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 { - state.currentPage = Math.max(1, Math.min(state.totalPages, pageNumber)); - return state; -} - -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; -} - diff --git a/web/src/reader/formats/manga/settings.ts b/web/src/reader/formats/manga/settings.ts deleted file mode 100644 index 0741cb0..0000000 --- a/web/src/reader/formats/manga/settings.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Manga-specific settings integration -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - let currentSettings: MangaSettings | null = null; - - context.events.on("reader:loaded", async () => { - currentSettings = await getMangaSettings(); - applyMangaSettings(currentSettings); - context.events.emit("manga-settings:loaded", currentSettings); - }); - - context.events.on( - "manga-settings:update", - async (detail: { settings: Partial }) => { - if (currentSettings) { - currentSettings = { ...currentSettings, ...detail.settings }; - await updateMangaSettings(detail.settings); - applyMangaSettings(currentSettings); - context.events.emit("manga-settings:changed", currentSettings); - } - }, - ); - - context.events.on("manga-settings:get", () => { - if (currentSettings) { - context.events.emit("manga-settings:current", currentSettings); - } - }); -} - -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 { - document.documentElement.dataset.readingDirection = settings.readingDirection; - - if (settings.verticalScrollSpeed === "slow") { - document.documentElement.style.scrollBehavior = "smooth"; - } else if (settings.verticalScrollSpeed === "fast") { - document.documentElement.style.scrollBehavior = "auto"; - } - - if (settings.rtlPageTransition !== "none") { - document.documentElement.dataset.pageTransition = - settings.rtlPageTransition; - } - - document.documentElement.dataset.webtoonMode = String(settings.webtoonMode); -} - diff --git a/web/src/reader/formats/manga/vertical-scroll-mode.ts b/web/src/reader/formats/manga/vertical-scroll-mode.ts deleted file mode 100644 index ea966d8..0000000 --- a/web/src/reader/formats/manga/vertical-scroll-mode.ts +++ /dev/null @@ -1,200 +0,0 @@ -// Vertical scroll mode for webtoons/manhwa -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - let state: VerticalScrollState | null = null; - - context.events.on( - "reader:loaded", - (detail: { - container: HTMLElement; - mediaItemId: string; - totalPages: number; - }) => { - state = createVerticalScroll( - detail.container, - detail.mediaItemId, - detail.totalPages, - ); - }, - ); - - context.events.on( - "vertical-scroll:load-page", - async (detail: { pageNumber: number }) => { - if (state) { - await loadPage(state, detail.pageNumber); - } - }, - ); - - context.events.on("vertical-scroll:get-current", () => { - if (state) { - const currentPage = getCurrentPageFromScroll(state); - context.events.emit("vertical-scroll:current-page", { - page: currentPage, - }); - } - }); - - context.events.on("reader:unload", () => { - if (state) { - destroyVerticalScroll(state); - state = null; - } - }); -} - -interface VerticalScrollState { - container: HTMLElement; - loadedPages: Set; - loadingPages: Set; - currentPage: number; - totalPages: number; - threshold: number; - 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, - mediaItemId, - }; - - loadPage(state, 1); - 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); - - 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); - } - } - - const currentPage = getCurrentPageFromScroll(state); - if (currentPage !== state.currentPage) { - state.currentPage = currentPage; - window.dispatchEvent( - new CustomEvent("page-change", { - detail: { page: currentPage }, - }), - ); - } -} - -function getCurrentPageFromScroll(state: VerticalScrollState): number { - const pages = Array.from(state.container.querySelectorAll(".vertical-page")); - - for (const page of pages) { - const rect = page.getBoundingClientRect(); - const containerRect = state.container.getBoundingClientRect(); - - 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 as HTMLElement).dataset.pageNumber || "1"); - } - } - - return state.currentPage; -} - -function destroyVerticalScroll(state: VerticalScrollState): void { - const images = Array.from(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(); -} - diff --git a/web/src/reader/formats/pdf/annotation-layer.ts b/web/src/reader/formats/pdf/annotation-layer.ts deleted file mode 100644 index c19268d..0000000 --- a/web/src/reader/formats/pdf/annotation-layer.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Annotation layer for rendering highlights and notes on PDFs -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - const highlights = new Map(); - - context.events.on( - "pdf:highlights:render", - (detail: { container: HTMLElement; highlights: any[] }) => { - clearPDFHighlights(detail.container); - for (const highlight of detail.highlights) { - renderSinglePDFHighlight(detail.container, highlight, highlights); - } - }, - ); - - context.events.on( - "pdf:highlights:clear", - (detail: { container: HTMLElement }) => { - clearPDFHighlights(detail.container); - }, - ); - - context.events.on( - "pdf:highlight:remove", - (detail: { highlightId: string }) => { - removePDFHighlight(detail.highlightId, highlights); - }, - ); - - context.events.on("reader:unload", () => { - highlights.forEach((element) => element.remove()); - highlights.clear(); - }); -} - -interface PDFHighlight { - id: string; - pageNumber: number; - rects: DOMRect[]; - text: string; - color: string; - noteId?: string; -} - -function renderSinglePDFHighlight( - container: HTMLElement, - highlight: PDFHighlight, - highlights: Map, -): void { - const overlay = document.createElement("div"); - overlay.className = "pdf-highlight-annotation"; - overlay.dataset.highlightId = highlight.id; - overlay.style.backgroundColor = parseColor(highlight.color); - - 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); - } - - if (highlight.noteId) { - overlay.style.cursor = "pointer"; - overlay.addEventListener("click", () => { - showNotePopup(highlight); - }); - } - - 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); - const event = new CustomEvent("pdf:note-show", { - detail: { highlightId: highlight.id }, - }); - window.dispatchEvent(event); -} - -export function clearPDFHighlights(container: HTMLElement): void { - const highlights = container.querySelectorAll(".pdf-highlight-annotation"); - Array.from(highlights).forEach((element) => element.remove()); -} - -export function removePDFHighlight( - highlightId: string, - highlights: Map, -): void { - const element = highlights.get(highlightId); - if (element) { - element.remove(); - highlights.delete(highlightId); - } -} - diff --git a/web/src/reader/formats/pdf/pdf-bookmarks.ts b/web/src/reader/formats/pdf/pdf-bookmarks.ts deleted file mode 100644 index 629811d..0000000 --- a/web/src/reader/formats/pdf/pdf-bookmarks.ts +++ /dev/null @@ -1,125 +0,0 @@ -// 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; -} diff --git a/web/src/reader/formats/pdf/pdf-clipbooard.ts b/web/src/reader/formats/pdf/pdf-clipbooard.ts deleted file mode 100644 index 70fd1d6..0000000 --- a/web/src/reader/formats/pdf/pdf-clipbooard.ts +++ /dev/null @@ -1,98 +0,0 @@ -// 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); -} diff --git a/web/src/reader/formats/pdf/pdf-dual-page.ts b/web/src/reader/formats/pdf/pdf-dual-page.ts deleted file mode 100644 index 3d8af1a..0000000 --- a/web/src/reader/formats/pdf/pdf-dual-page.ts +++ /dev/null @@ -1,143 +0,0 @@ -// 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%; - } - `; -} diff --git a/web/src/reader/formats/pdf/pdf-links.ts b/web/src/reader/formats/pdf/pdf-links.ts deleted file mode 100644 index 8397097..0000000 --- a/web/src/reader/formats/pdf/pdf-links.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Handle internal PDF links (cross-references, citations, TOC links) -// External links open in new tab -// Procedural implementation (no OOP) - -import { PDFDocumentProxy } from "pdfjs-dist"; - -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; - } -} diff --git a/web/src/reader/formats/pdf/pdf-minimap.ts b/web/src/reader/formats/pdf/pdf-minimap.ts deleted file mode 100644 index 639aca2..0000000 --- a/web/src/reader/formats/pdf/pdf-minimap.ts +++ /dev/null @@ -1,191 +0,0 @@ -// 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.appendChild(miniMap); - - return { - miniMap, - currentPage: 1, - totalPages: 0, - thumbnails: new Map(), - onPageNavigate, - }; -} - -function createMiniMapElement(): 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; - } - `; -} diff --git a/web/src/reader/formats/pdf/pdf-navigation.ts b/web/src/reader/formats/pdf/pdf-navigation.ts deleted file mode 100644 index 1dfc1da..0000000 --- a/web/src/reader/formats/pdf/pdf-navigation.ts +++ /dev/null @@ -1,215 +0,0 @@ -// PDF navigation: page turning, zoom, fit modes -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - let navState: PDFNavigationState | null = null; - - context.events.on( - "reader:loaded", - (detail: { container: HTMLElement; totalPages: number }) => { - navState = { - currentPage: 1, - totalPages: detail.totalPages, - currentScale: 1.0, - fitMode: "fit-width", - scrollContainer: - detail.container.querySelector(".pdf-scroll-container") || - detail.container, - }; - setupPDFKeyboardNav(context, navState); - setupPDFScrollTracking(context, navState); - }, - ); - - context.events.on("pdf:navigate:to-page", (detail: { page: number }) => { - if (navState) { - goToPDFPage(navState, detail.page, context); - } - }); - - context.events.on("pdf:navigate:next", () => { - if (navState) { - nextPDFPage(navState, context); - } - }); - - context.events.on("pdf:navigate:previous", () => { - if (navState) { - previousPDFPage(navState, context); - } - }); - - context.events.on("pdf:zoom:set", (detail: { scale: number }) => { - if (navState) { - setPDFZoom(navState, detail.scale, context); - } - }); - - context.events.on("pdf:zoom:in", () => { - if (navState) { - zoomPDFIn(navState, context); - } - }); - - context.events.on("pdf:zoom:out", () => { - if (navState) { - zoomPDFOut(navState, context); - } - }); - - context.events.on( - "pdf:fit:set", - (detail: { mode: "fit-width" | "fit-page" | "fit-height" | "none" }) => { - if (navState) { - setPDFFitMode(navState, detail.mode, context); - } - }, - ); - - context.events.on("reader:unload", () => { - navState = null; - }); -} - -type PageFitMode = "fit-width" | "fit-page" | "fit-height" | "none"; - -interface PDFNavigationState { - currentPage: number; - totalPages: number; - currentScale: number; - fitMode: PageFitMode; - scrollContainer: HTMLElement | null; -} - -function goToPDFPage( - state: PDFNavigationState, - pageNumber: number, - context: ReaderContext, -): void { - if (pageNumber < 1 || pageNumber > state.totalPages) return; - - state.currentPage = pageNumber; - scrollToPDFPage(state, pageNumber); - context.events.emit("pdf:page-changed", { page: pageNumber }); -} - -function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void { - if (state.currentPage < state.totalPages) { - goToPDFPage(state, state.currentPage + 1, context); - } -} - -function previousPDFPage( - state: PDFNavigationState, - context: ReaderContext, -): void { - if (state.currentPage > 1) { - goToPDFPage(state, state.currentPage - 1, context); - } -} - -function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void { - if (!state.scrollContainer) return; - - const pageElement = state.scrollContainer.querySelector( - `[data-page-number="${pageNumber}"]`, - ); - if (pageElement) { - pageElement.scrollIntoView({ behavior: "smooth", block: "start" }); - } -} - -function setPDFZoom( - state: PDFNavigationState, - scale: number, - context: ReaderContext, -): void { - state.currentScale = scale; - state.fitMode = "none"; - updatePDFZoom(state); - context.events.emit("pdf:zoom-changed", { scale }); -} - -function setPDFFitMode( - state: PDFNavigationState, - mode: PageFitMode, - context: ReaderContext, -): void { - state.fitMode = mode; - updatePDFZoom(state); - context.events.emit("pdf:fit-changed", { mode }); -} - -function zoomPDFIn(state: PDFNavigationState, context: ReaderContext): void { - setPDFZoom(state, state.currentScale * 1.2, context); -} - -function zoomPDFOut(state: PDFNavigationState, context: ReaderContext): void { - setPDFZoom(state, state.currentScale / 1.2, context); -} - -function updatePDFZoom(state: PDFNavigationState): void { - const event = new CustomEvent("pdf-update-zoom", { - detail: { - scale: state.currentScale, - fitMode: state.fitMode, - }, - }); - window.dispatchEvent(event); -} - -function setupPDFKeyboardNav( - context: ReaderContext, - state: PDFNavigationState, -): void { - document.addEventListener("keydown", (e) => { - if (e.key === "ArrowRight" || e.key === "ArrowDown") { - nextPDFPage(state, context); - } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { - previousPDFPage(state, context); - } else if (e.key === "+" || e.key === "=") { - zoomPDFIn(state, context); - } else if (e.key === "-" || e.key === "_") { - zoomPDFOut(state, context); - } - }); -} - -function setupPDFScrollTracking( - context: ReaderContext, - state: PDFNavigationState, -): void { - if (!state.scrollContainer) return; - - state.scrollContainer.addEventListener("scroll", () => { - const currentPage = getCurrentPDFPage(state); - if (currentPage !== state.currentPage) { - state.currentPage = currentPage; - context.events.emit("pdf:page-changed", { page: currentPage }); - } - }); -} - -function getCurrentPDFPage(state: PDFNavigationState): number { - if (!state.scrollContainer) return state.currentPage; - - const containerRect = state.scrollContainer.getBoundingClientRect(); - const viewportMiddle = containerRect.top + containerRect.height / 2; - - for (let i = 1; i <= state.totalPages; i++) { - const pageElement = state.scrollContainer.querySelector( - `[data-page-number="${i}"]`, - ); - if (pageElement) { - const rect = pageElement.getBoundingClientRect(); - if (rect.top <= viewportMiddle && rect.bottom >= viewportMiddle) { - return i; - } - } - } - - return state.currentPage; -} - diff --git a/web/src/reader/formats/pdf/pdf-outline.ts b/web/src/reader/formats/pdf/pdf-outline.ts deleted file mode 100644 index 3397c08..0000000 --- a/web/src/reader/formats/pdf/pdf-outline.ts +++ /dev/null @@ -1,186 +0,0 @@ -// PDF outline/TOC navigation -// Procedural implementation (no OOP) - -import { PDFDocumentProxy } from "pdfjs-dist"; - -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; -} diff --git a/web/src/reader/formats/pdf/pdf-page-sizes.ts b/web/src/reader/formats/pdf/pdf-page-sizes.ts deleted file mode 100644 index 6e29ffe..0000000 --- a/web/src/reader/formats/pdf/pdf-page-sizes.ts +++ /dev/null @@ -1,109 +0,0 @@ -// 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< - string, - { width: number; height: number; count: number } - > = 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}`; -} diff --git a/web/src/reader/formats/pdf/pdf-rotation.ts b/web/src/reader/formats/pdf/pdf-rotation.ts deleted file mode 100644 index 6eaaad5..0000000 --- a/web/src/reader/formats/pdf/pdf-rotation.ts +++ /dev/null @@ -1,82 +0,0 @@ -// 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, - }; -} diff --git a/web/src/reader/formats/pdf/pdf-search.ts b/web/src/reader/formats/pdf/pdf-search.ts deleted file mode 100644 index 2df7dbd..0000000 --- a/web/src/reader/formats/pdf/pdf-search.ts +++ /dev/null @@ -1,136 +0,0 @@ -// 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, - }; -} diff --git a/web/src/reader/formats/pdf/pdf-text-selection.ts b/web/src/reader/formats/pdf/pdf-text-selection.ts deleted file mode 100644 index 222e25a..0000000 --- a/web/src/reader/formats/pdf/pdf-text-selection.ts +++ /dev/null @@ -1,165 +0,0 @@ -// PDF text selection - Uses backend API for highlight creation -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../core/reader-context"; - -export function init(context: ReaderContext): void { - let currentMediaItemId: string | null = null; - - context.events.on("reader:loaded", (detail: { mediaItemId: string }) => { - currentMediaItemId = detail.mediaItemId; - }); - - context.events.on("pdf:selection:get", () => { - const selection = getPDFTextSelection(); - context.events.emit("pdf:selection-current", selection); - }); - - context.events.on( - "pdf:highlight:create", - async (detail: { selection: PDFTextSelection; color: string }) => { - if (currentMediaItemId) { - try { - const highlight = await createPDFHighlight( - currentMediaItemId, - detail.selection, - detail.color, - ); - context.events.emit("pdf:highlight-created", highlight); - } catch (error) { - console.error("Failed to create highlight:", error); - } - } - }, - ); - - context.events.on( - "pdf:highlights:load", - async (detail: { container: HTMLElement }) => { - if (currentMediaItemId) { - await loadAndRenderPDFHighlights(currentMediaItemId, detail.container); - } - }, - ); - - context.events.on("reader:unload", () => { - currentMediaItemId = null; - }); -} - -interface PDFTextSelection { - pageNumber: number; - text: string; - rects: DOMRect[]; -} - -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; - - const pageElement = range.commonAncestorContainer.parentElement?.closest?.( - "[data-page-number]", - ) as HTMLElement; - const pageNumber = pageElement?.dataset.pageNumber - ? parseInt(pageElement.dataset.pageNumber) - : getCurrentPDFPage(); - - const rects: DOMRect[] = []; - for (const rect of Array.from(range.getClientRects())) { - rects.push(rect); - } - - return { - pageNumber, - text, - rects, - }; -} - -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, - }; - - 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(); -} - -export async function loadAndRenderPDFHighlights( - mediaItemId: string, - container: HTMLElement, -): Promise { - const response = await fetch(`/api/media-items/${mediaItemId}/highlights`); - if (!response.ok) return; - - const highlights: any[] = await response.json(); - - for (const highlight of highlights) { - renderPDFHighlight(container, highlight); - } -} - -function renderPDFHighlight(container: HTMLElement, highlight: any): void { - const overlay = document.createElement("div"); - overlay.className = "pdf-highlight-annotation"; - overlay.dataset.highlightId = highlight.id; - overlay.style.backgroundColor = parseColor(highlight.color || "#ffff00"); - - for (const rect of highlight.rects || []) { - const rectDiv = document.createElement("div"); - rectDiv.className = "pdf-highlight-rect"; - rectDiv.style.left = `${rect.x}px`; - rectDiv.style.top = `${rect.y}px`; - rectDiv.style.width = `${rect.width}px`; - rectDiv.style.height = `${rect.height}px`; - overlay.appendChild(rectDiv); - } - - container.appendChild(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 getCurrentPDFPage(): number { - const pageElement = document.querySelector("[data-page-number]"); - return pageElement - ? parseInt(pageElement.getAttribute("data-page-number") || "1") - : 1; -} - diff --git a/web/src/reader/formats/pdf/pdfjs-wrapper.ts b/web/src/reader/formats/pdf/pdfjs-wrapper.ts deleted file mode 100644 index 3709ae6..0000000 --- a/web/src/reader/formats/pdf/pdfjs-wrapper.ts +++ /dev/null @@ -1,138 +0,0 @@ -// 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"; -} - -// ============================================================ -// 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; -} - -// ============================================================ -// Reader Initialization -// ============================================================ -interface PDFReader { - type: "pdf"; - doc: any; - currentPage: number; -} -export async function initializePDFReader( - metadata: ReaderMetadata, -): Promise { - configurePDFJS(); - const response = await fetch(metadata.file_path); - const pdfBlob = await response.blob(); - await loadPDFDocument(pdfBlob); - return { - type: "pdf", - doc: pdfState.doc, - currentPage: 1, - }; -} - -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); -} diff --git a/web/src/reader/formats/pdf/text-layer-renderer.ts b/web/src/reader/formats/pdf/text-layer-renderer.ts deleted file mode 100644 index 6d93014..0000000 --- a/web/src/reader/formats/pdf/text-layer-renderer.ts +++ /dev/null @@ -1,155 +0,0 @@ -// 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; -} diff --git a/web/src/reader/formats/reflowable/content-renderer.ts b/web/src/reader/formats/reflowable/content-renderer.ts deleted file mode 100644 index 2b51733..0000000 --- a/web/src/reader/formats/reflowable/content-renderer.ts +++ /dev/null @@ -1,101 +0,0 @@ -// Render a page's content to the DOM -export function renderPage(container: HTMLElement, content: string): 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); -} - -// Update container styles for paginated mode -export function applyPaginatedStyles(): 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); -} - -// Clear all styles -export function clearPaginatedStyles(): void { - const existing = document.getElementById("reflowable-styles"); - existing?.remove(); -} diff --git a/web/src/reader/formats/reflowable/ebook/copy-handler.ts b/web/src/reader/formats/reflowable/ebook/copy-handler.ts deleted file mode 100644 index dd389bd..0000000 --- a/web/src/reader/formats/reflowable/ebook/copy-handler.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Handle text copying with citation -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../../../core/reader-context"; -import { showToast } from "../../../../toast"; - -export function init(context: ReaderContext): void { - let mediaItem: MediaItemSummary | null = null; - - context.events.on( - "reader:loaded", - (detail: { mediaItem: MediaItemSummary }) => { - mediaItem = detail.mediaItem; - enableContextMenuCopy(mediaItem); - }, - ); - - context.events.on("copy:selection", async () => { - if (mediaItem) { - await copySelection(mediaItem); - } - }); - - context.events.on("reader:unload", () => { - mediaItem = null; - }); -} - -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); - } - }); -} - diff --git a/web/src/reader/formats/reflowable/ebook/dictionary-popup.ts b/web/src/reader/formats/reflowable/ebook/dictionary-popup.ts deleted file mode 100644 index 82a8554..0000000 --- a/web/src/reader/formats/reflowable/ebook/dictionary-popup.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Dictionary lookup popup for ebooks -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../../core/reader-context"; - -export function init(context: ReaderContext): void { - context.events.on( - "dictionary:lookup", - (detail: { word: string; position: { x: number; y: number } }) => { - showDictionaryPopup(detail.word, detail.position); - }, - ); - - context.events.on("reader:loaded", () => { - handleTextSelection(); - }); - - context.events.on("reader:unload", () => { - const popup = document.getElementById("dictionary-popup"); - popup?.remove(); - }); -} - -function showDictionaryPopup( - word: string, - position: { x: number; y: number }, -): void { - const existing = document.getElementById("dictionary-popup"); - existing?.remove(); - - 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); - - lookupWord(word) - .then((entry) => { - popup.innerHTML = ` -

${entry.word}

-

${entry.part_of_speech || ""}

-

${entry.definition}

- ${entry.example ? `

"${entry.example}"

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

Definition not found for "${word}"

`; - }); - - setTimeout(() => { - document.addEventListener("click", function closePopup(e: MouseEvent) { - if (!popup.contains(e.target as Node)) { - popup.remove(); - document.removeEventListener("click", closePopup); - } - }); - }, 100); -} - -function handleTextSelection(): void { - document.addEventListener("mouseup", () => { - const selection = window.getSelection(); - const selectedText = selection?.toString().trim(); - - if (selectedText && selectedText.split(" ").length === 1) { - const range = selection?.getRangeAt(0); - const rect = range?.getBoundingClientRect(); - - if (rect) { - showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom }); - } - } - }); -} - -async function lookupWord(word: string): Promise { - const response = await fetch(`/api/dictionary/${word}`); - if (!response.ok) { - throw new Error(`Failed to lookup word: ${word}`); - } - return await response.json(); -} - diff --git a/web/src/reader/formats/reflowable/ebook/font-loader.ts b/web/src/reader/formats/reflowable/ebook/font-loader.ts deleted file mode 100644 index 08cd9c2..0000000 --- a/web/src/reader/formats/reflowable/ebook/font-loader.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Font loading with performance optimization -// Feature Registration Pattern implementation - -import { ReaderContext } from "../../../core/reader-context"; - -export function init(context: ReaderContext): void { - const userPreferredFont = localStorage.getItem("reader-font") || "literata"; - - context.events.on("reader:loaded", async () => { - await preloadFonts(userPreferredFont); - }); - - context.events.on("font:change", async (detail: { fontId: string }) => { - const stack = getFontStack(detail.fontId); - applyFontStack(stack); - await preloadFonts(detail.fontId); - }); - - context.events.on("font:get-stack", (detail: { fontId: string }) => { - const stack = getFontStack(detail.fontId); - context.events.emit("font:stack-ready", { stack }); - }); -} - -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", - }, -]; - -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}"`); - } - } -} - -function getFontStack(fontId: string): string { - const font = READING_FONTS.find((f) => f.id === fontId); - return font?.stack || "Literata, serif"; -} - -function applyFontStack(stack: string): void { - document.documentElement.style.setProperty("--reader-font-family", stack); -} - -export { READING_FONTS, preloadFonts, getFontStack }; - diff --git a/web/src/reader/formats/reflowable/ebook/search.ts b/web/src/reader/formats/reflowable/ebook/search.ts deleted file mode 100644 index d06c270..0000000 --- a/web/src/reader/formats/reflowable/ebook/search.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Search within ebook content -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../../../core/reader-context"; - -export function init(context: ReaderContext): void { - let ebookData: any = null; - - context.events.on("reader:loaded", (detail: { ebookData: any }) => { - ebookData = detail.ebookData; - }); - - context.events.on("search:execute", async (detail: { query: string }) => { - if (ebookData) { - const results = await searchEbook(ebookData, detail.query); - context.events.emit("search:results", { results }); - } - }); - - context.events.on("reader:unload", () => { - ebookData = null; - }); -} - -interface SearchResult { - cfi: string; - snippet: string; - chapterTitle: string; -} - -export async function searchEbook( - ebookData: any, - query: string, -): Promise { - const results: SearchResult[] = []; - const lowerQuery = query.toLowerCase(); - - if (!ebookData.spine) return results; - - for (const spineItem of ebookData.spine) { - const doc = await getSpineItemDocument(ebookData, spineItem); - - if (!doc) continue; - - const chapterTitle = getChapterTitle(spineItem); - 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( - ebookData: any, - spineItem: any, -): Promise { - try { - const resources = ebookData.resources; - if (!resources) return null; - - const content = await 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: any): string { - 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: Node, offset: number): string { - const path: number[] = []; - let current: Node | null = node; - - while (current && current.parentNode) { - const siblings = Array.from(current.parentNode.childNodes); - const index = siblings.indexOf(current as ChildNode); - path.unshift(index); - current = current.parentNode; - } - - return `/6/4${path.map((i) => `/${i + 2}`).join("")}:${offset}`; -} - -function extractSnippet(text: string, offset: number, length: number): string { - const start = Math.max(0, offset - 40); - const end = Math.min(text.length, offset + length + 40); - let snippet = text.substring(start, end); - - if (start > 0) snippet = "..." + snippet; - if (end < text.length) snippet = snippet + "..."; - - return snippet; -} - diff --git a/web/src/reader/formats/reflowable/ebook/typography-engine.ts b/web/src/reader/formats/reflowable/ebook/typography-engine.ts deleted file mode 100644 index 2a7ef3d..0000000 --- a/web/src/reader/formats/reflowable/ebook/typography-engine.ts +++ /dev/null @@ -1,175 +0,0 @@ -// Typography engine for ebook rendering -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../../../core/reader-context"; - -export function init(context: ReaderContext): void { - let currentConfig: TypographyConfig | null = null; - - context.events.on( - "reader:loaded", - (detail: { - container: HTMLElement; - config?: Partial; - }) => { - currentConfig = { - readingFont: "literata", - fontSize: 18, - lineHeight: 1.6, - marginTop: 0, - marginBottom: 16, - marginLeft: 0, - marginRight: 0, - textAlign: "left", - textIndent: 0, - hyphenate: false, - ligatures: true, - fontSmoothing: "auto", - ...detail.config, - }; - applyTypography(detail.container, currentConfig); - }, - ); - - context.events.on( - "typography:update", - (detail: { container: HTMLElement; config: Partial }) => { - if (currentConfig) { - currentConfig = updateTypographyConfig(currentConfig, detail.config); - applyTypography(detail.container, currentConfig); - } - }, - ); - - context.events.on( - "typography:measure", - (detail: { container: HTMLElement }) => { - const time = measureReadingTime(detail.container); - context.events.emit("typography:reading-time", { minutes: time }); - }, - ); -} - -interface TypographyConfig { - readingFont: - | "literata" - | "crimson" - | "source-serif" - | "eb-garamond" - | "libertinus" - | "noto-serif" - | "charis-sil" - | "ibm-plex"; - 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"; -} - -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: auto; - `, - ); - - if (config.hyphenate) { - enableHyphenation(container, content as HTMLElement); - } - - setLigatures(content as HTMLElement, config.ligatures); - - if (config.textAlign === "justify") { - enableJustification(content as HTMLElement); - } -} - -function getFontStack(fontId: string): string { - const fonts: 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 fonts[fontId] || "Literata, serif"; -} - -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); -} - -export { applyTypography, getFontStack }; - diff --git a/web/src/reader/formats/reflowable/navigation.ts b/web/src/reader/formats/reflowable/navigation.ts deleted file mode 100644 index e15ee5c..0000000 --- a/web/src/reader/formats/reflowable/navigation.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Import types -import type { ReadingPosition } from "./types"; -import { - getPageContent, - findPageByCFI, - createPositionFromPage, -} from "./page-calculator"; -import { UniversalReader } from "../../reader-shell"; - -// Navigate to specific page -export function goToPage( - book: UniversalReader, - 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 }; -} - -// Navigate to next page -export function nextPage(book: UniversalReader): { - success: boolean; - position: ReadingPosition; - content: string; -} { - const nextPageNum = book.position.currentPage + 1; - return goToPage(book, nextPageNum); -} - -// Navigate to previous page -export function previousPage(book: UniversalReader): { - success: boolean; - position: ReadingPosition; - content: string; -} { - const prevPageNum = book.position.currentPage - 1; - return goToPage(book, prevPageNum); -} - -// Jump to specific CFI -export function goToCFI( - book: UniversalReader, - 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); -} - -// Create default position -function createDefaultPosition(): ReadingPosition { - return { - currentPage: 1, - spineIndex: 0, - localPageIndex: 0, - cfi: "", - progress: 0, - }; -} - -// Check if navigation is possible -export function canGoNext(book: UniversalReader): boolean { - return book.position.currentPage < (book.pagination?.totalPages || 1); -} - -// Check if previous navigation is possible -export function canGoPrevious(book: UniversalReader): boolean { - return book.position.currentPage > 1; -} - -// Get progress percentage -export function getProgressPercentage(book: UniversalReader): number { - return Math.round(book.position.progress * 100); -} - -// Update book position (after resize/recalculation) -export function updatePosition( - book: UniversalReader, - newCFI?: string, -): ReadingPosition { - if (newCFI && book.pagination) { - const pageNum = findPageByCFI(book.pagination, newCFI); - return createPositionFromPage(book, pageNum); - } - - return book.position; -} diff --git a/web/src/reader/formats/reflowable/page-calculator.ts b/web/src/reader/formats/reflowable/page-calculator.ts deleted file mode 100644 index 997604b..0000000 --- a/web/src/reader/formats/reflowable/page-calculator.ts +++ /dev/null @@ -1,564 +0,0 @@ -// Import types -import { UniversalReader } from "../../reader-shell"; -import type { - SpineItem, - SpineInfo, - PageBoundary, - PaginationData, - PaginationSettings, - ReadingPosition, -} from "./types"; - -interface TextNodeInfo { - node: Text; - startChar: number; // Character position in HTML - endChar: number; // Character position in HTML - textStart: number; // Word position in plain text - textEnd: number; // Word position in plain text -} -function buildTextNodeMapping(html: string): TextNodeInfo[] { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - const textNodes: TextNodeInfo[] = []; - - let htmlCharPos = 0; - let textWordPos = 0; - - function traverse(node: Node) { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent || ""; - const words = countWords(text); - - textNodes.push({ - node: node as Text, - startChar: htmlCharPos, - endChar: htmlCharPos + text.length, - textStart: textWordPos, - textEnd: textWordPos + words, - }); - - textWordPos += words; - htmlCharPos += text.length; - } else { - // For element nodes, just count the opening tag length - if (node.nodeType === Node.ELEMENT_NODE) { - const outerHTML = (node as Element).outerHTML; - const tagEnd = outerHTML.indexOf(">") + 1; - htmlCharPos += tagEnd; - - // Recurse into children - node.childNodes.forEach(traverse); - - // Count closing tag - const tagName = (node as Element).tagName; - htmlCharPos += ``.length; - } - } - } - - doc.body.childNodes.forEach(traverse); - return textNodes; -} - -function mapWordToHtmlChar(mapping: TextNodeInfo[], wordPos: number): number { - for (const info of mapping) { - if (wordPos >= info.textStart && wordPos <= info.textEnd) { - // Word is in this text node - const ratio = - (wordPos - info.textStart) / (info.textEnd - info.textStart); - return Math.round( - info.startChar + ratio * (info.endChar - info.startChar), - ); - } - } - return 0; // Fallback -} - -// Constants for word count estimation (from Kavita) -const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height - -// 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, - ); -} - -// 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(); -} - -// Count words in text -function countWords(text: string): number { - return text - .trim() - .split(/\s+/) - .filter((w) => w.length > 0).length; -} - -// 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; -} - -// 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, "\\="); -} - -// 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})`; -} - -// 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 }; -} - -// 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 based on TEXT content, not HTML - const wordRanges = splitIntoWordRanges(wordCount, wordsPerPage); - const pages: PageBoundary[] = []; - // Build a map of word positions to HTML character positions - const textNodeInfo = buildTextNodeMapping(contentHTML); - for (let j = 0; j < wordRanges.length; j++) { - const range = wordRanges[j]; - - // Map word positions to ACTUAL HTML character positions - const htmlCharStart = mapWordToHtmlChar(textNodeInfo, range.start); - const htmlCharEnd = mapWordToHtmlChar(textNodeInfo, range.end); - - const page: PageBoundary = { - pageIndex: globalPageIndex, - localPageIndex: j, - charStart: htmlCharStart, - charEnd: htmlCharEnd, - wordStart: range.start, - wordEnd: range.end, - cfi: generateCFI(i, htmlCharStart, 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, spineItem.id), - 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 }, - }; -} - -// 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; -} - -// 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 resultText = text; - // Trim from left if node starts before page - if (startChar < charStart) { - resultText = text.substring(charStart - startChar); - } - // Trim from right if node extends past page end - if (endChar > charEnd) { - // Calculate where to cut within the (potentially already trimmed) text - const cutPosition = charEnd - startChar; - resultText = text.substring(0, cutPosition); - } - // Handle case where both trims are needed - if (startChar < charStart && endChar > charEnd) { - const leftTrim = charStart - startChar; - const rightTrim = endChar - charEnd; - resultText = text.substring(leftTrim, text.length - rightTrim); - } - relevantNodes.push({ node, before: "", after: resultText }); - } - - // 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; -} - -// 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}
`; -} - -// 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; -} - -// Create position object from page number -export function createPositionFromPage( - book: UniversalReader, - 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, - }; -} diff --git a/web/src/reader/formats/reflowable/parser.ts b/web/src/reader/formats/reflowable/parser.ts deleted file mode 100644 index 4a3cba7..0000000 --- a/web/src/reader/formats/reflowable/parser.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Import types and existing parsers -import type { 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"; -import { UniversalReader } from "../../reader-shell"; - -// Parse any reflowable format -export async function parseReflowable( - file: File, - format: "epub" | "fb2" | "txt" | "html", -): Promise { - switch (format) { - case "epub": { - const cif = await parseEPUB(file); // Get EbookCIF - return { - // Wrap in UniversalReader structure - type: "ebook", - cif, // ← The EbookCIF goes here - currentSpineIndex: 0, - currentPage: 1, - pagination: null, - position: { - currentPage: 1, - spineIndex: 0, - localPageIndex: 0, - cfi: "", - progress: 0, - }, - }; - } - case "fb2": { - const cif = await parseFB2(file); - return { - type: "ebook", - cif, - currentSpineIndex: 0, - currentPage: 1, - pagination: null, - position: { - currentPage: 1, - spineIndex: 0, - localPageIndex: 0, - cfi: "", - progress: 0, - }, - }; - } - case "txt": { - const cif = await parseTXT(file); - return { - type: "ebook", - cif, - currentSpineIndex: 0, - currentPage: 1, - pagination: null, - position: { - currentPage: 1, - spineIndex: 0, - localPageIndex: 0, - cfi: "", - progress: 0, - }, - }; - } - case "html": { - const cif = await parseHTML(file); - return { - type: "ebook", - cif, - currentSpineIndex: 0, - currentPage: 1, - pagination: null, - position: { - currentPage: 1, - spineIndex: 0, - localPageIndex: 0, - cfi: "", - progress: 0, - }, - }; - } - default: - throw new Error(`Unsupported reflowable format: ${format}`); - } -} - -// Validate parsed book data -export function validateBook(book: UniversalReader): boolean { - return book.cif.spine.length > 0 && book.cif.metadata.title !== ""; -} - -// Get book title -export function getBookTitle(book: UniversalReader): string { - return book.cif.metadata.title || "Untitled"; -} - -// Get book author -export function getBookAuthor(book: UniversalReader): string { - return book.cif.metadata.author || "Unknown"; -} - -// Get total spine count -export function getSpineCount(book: UniversalReader): number { - return book.cif.spine.length; -} - -// Get TOC as flat list -export function getFlatTOC(book: UniversalReader): 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.cif.toc); - return flat; -} diff --git a/web/src/reader/formats/reflowable/progress-tracker.ts b/web/src/reader/formats/reflowable/progress-tracker.ts deleted file mode 100644 index e39d05f..0000000 --- a/web/src/reader/formats/reflowable/progress-tracker.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Import types -import type { ReadingPosition } from "./types"; -import { findPageByCFI, createPositionFromPage } from "./page-calculator"; -import { UniversalReader } from "../../reader-shell"; - -// Update current position -export function updateCurrentPosition( - book: UniversalReader, - position: ReadingPosition, -): UniversalReader { - return { - ...book, - position, - currentSpineIndex: position.spineIndex, - }; -} - -// Extract CFI from position -export function getCurrentCFI(book: UniversalReader): string { - return book.position.cfi; -} - -// Calculate progress for display -export function calculateProgress(book: UniversalReader): { - 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 }; -} - -// Get position for saving to database -export function getPositionForSave(book: UniversalReader): { - cfi: string; - progress: number; - page: number; -} { - return { - cfi: book.position.cfi, - progress: book.position.progress, - page: book.position.currentPage, - }; -} - -// Restore position from database -export function restorePosition( - book: UniversalReader, - 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; -} - -// 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 - ); -} diff --git a/web/src/reader/formats/reflowable/types.ts b/web/src/reader/formats/reflowable/types.ts deleted file mode 100644 index b02f97e..0000000 --- a/web/src/reader/formats/reflowable/types.ts +++ /dev/null @@ -1,84 +0,0 @@ -// 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 -} - -// 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 -} - -// 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 -} - -// Complete pagination data -export interface PaginationData { - totalPages: number; - spines: SpineInfo[]; - spineMap: Map; - pageMap: Map; // pageIndex -> PageBoundary - calculatedAt: number; - settings: PaginationSettings; -} - -// Settings used for calculation -export interface PaginationSettings { - fontSize: number; - lineHeight: number; - viewportWidth: number; - viewportHeight: number; - wordsPerPage: number; // Calculated from above -} - -// Current reading position -export interface ReadingPosition { - currentPage: number; - spineIndex: number; - localPageIndex: number; - cfi: string; - progress: number; // 0-1 -} - -// Reflowable book data -export interface ReflowableBook { - type: "epub" | "fb2" | "txt" | "html"; - spine: SpineItem[]; - resources: Map; - toc: TOCItem[]; - metadata: BookMetadata; - pagination: PaginationData | null; - position: ReadingPosition; -} - -// Table of contents item -export interface TOCItem { - id: string; - title: string; - href: string; - children: TOCItem[]; -} - -// Book metadata -export interface BookMetadata { - title: string; - author: string; - identifier: string; - [key: string]: any; -} diff --git a/web/src/reader/parsers/epub-parsers.ts b/web/src/reader/parsers/epub-parsers.ts deleted file mode 100644 index c1918b7..0000000 --- a/web/src/reader/parsers/epub-parsers.ts +++ /dev/null @@ -1,335 +0,0 @@ -// 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 JSZip = (await import("jszip")).default; - 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"); - } - - const opfXml = await getZipFileContent(zip, opfPath); - const packageDoc = parseXML(opfXml); - - const metadata = extractMetadata(packageDoc); - const spine = parseSpine(packageDoc, opfPath); - 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: { - ...metadata, - coverImage, - }, - toc, - spine, - resources, - locations: { - totalCharacters, - estimatedPages: Math.ceil(totalCharacters / 1500), - }, - }; -} - -// ============================================================ -// Helper Functions -// ============================================================ - -async function getZipFileContent(zip: any, 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, - opfPath: string, -): 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"); - console.log("Spine item idref:", idref); // Your debug log - keep or remove - if (!idref) return; - const manifestItem = manifest.querySelector(`[id="${idref}"]`); - console.log("Manifest item:", manifestItem); // Your debug log - if (!manifestItem) return; - const href = manifestItem.getAttribute("href"); - console.log("Href:", href); // Your debug log - if (!href) return; - const resolvedPath = resolvePath(opfPath, href); - result.push({ - id: idref, - type: "html" as const, - content: resolvedPath, - properties: (itemref.getAttribute("properties") || "") - .split(" ") - .filter(Boolean), - index: result.length, - }); - }); - return result; -} - -async function parseTOC( - zip: any, - 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 spine = packageDoc.querySelector("spine"); - 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: any, - 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 Array.from(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: any, - 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: any): 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"); - - // Store with full path (e.g., "OEBPS/image/1.png") - resources.set(path, blob); - - // Store with filename only (e.g., "1.png") - const filename = path.split("/").pop(); - if (filename && filename !== path) { - if (!resources.has(filename)) { - resources.set(filename, blob); - } - } - - // Store with relative path (everything after first /) - // e.g., "OEBPS/image/1.png" -> "image/1.png" - const firstSlash = path.indexOf("/"); - if (firstSlash > 0) { - const relativePath = path.substring(firstSlash + 1); - if (!resources.has(relativePath)) { - resources.set(relativePath, blob); - } - } - } - } - return resources; -} - -async function extractCover( - zip: any, - 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; -} - -// ============================================================ -// 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); -} diff --git a/web/src/reader/parsers/fb2-parser.ts b/web/src/reader/parsers/fb2-parser.ts deleted file mode 100644 index d1090f1..0000000 --- a/web/src/reader/parsers/fb2-parser.ts +++ /dev/null @@ -1,249 +0,0 @@ -// FB2 Parser - Converts FictionBook 2 to Common Intermediate Format -// FB2 is XML-based, similar to EPUB structure -// Procedural style: Functions, not classes - -// ============================================================ -// 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 JSZip = (await import("jszip")).default; - 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 getFB2Metadata( - fb2Blob: Blob, -): Promise> { - let xmlContent: string; - - if ( - fb2Blob.type === "application/zip" || - fb2Blob.type === "application/x-zip-compressed" - ) { - const JSZip = (await import("jszip")).default; - 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) return {}; - - xmlContent = await zip.file(fb2File)!.async("text"); - } else { - xmlContent = await fb2Blob.text(); - } - - const xmlDoc = parseXML(xmlContent); - return extractFB2Metadata(xmlDoc); -} diff --git a/web/src/reader/parsers/html-parser.ts b/web/src/reader/parsers/html-parser.ts deleted file mode 100644 index 024bdde..0000000 --- a/web/src/reader/parsers/html-parser.ts +++ /dev/null @@ -1,158 +0,0 @@ -// 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 getHTMLMetadata( - htmlBlob: Blob, -): Promise> { - const htmlContent = await htmlBlob.text(); - return extractHTMLMetadata(htmlBlob, htmlContent); -} diff --git a/web/src/reader/parsers/txt-parser.ts b/web/src/reader/parsers/txt-parser.ts deleted file mode 100644 index 54c6f7d..0000000 --- a/web/src/reader/parsers/txt-parser.ts +++ /dev/null @@ -1,121 +0,0 @@ -// 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 getTXTMetadata( - txtBlob: Blob, -): Promise> { - return extractTXTMetadata(txtBlob); -} diff --git a/web/src/reader/reader-shell.ts b/web/src/reader/reader-shell.ts deleted file mode 100644 index 8c6eabe..0000000 --- a/web/src/reader/reader-shell.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { Alpine } from "../alpine"; -import { getReaderMetadata, getReadingProgress } from "../api"; -import "./formats/comic/panel-editor"; -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"; -import { renderSpineItem } from "./core/reader-navigation"; -import { loadSettings } from "./settings-manager"; -import { - calculatePagination, - shouldRecalculate, -} from "./formats/reflowable/page-calculator"; -import { restorePosition } from "./formats/reflowable/progress-tracker"; -import { applyPaginatedStyles } from "./formats/reflowable/content-renderer"; -import type { - PaginationSettings, - PaginationData, - ReadingPosition, -} from "./formats/reflowable/types"; -import { updatePageDisplay, updateProgressBar } from "./ui/page-display"; -import { PDFDocumentProxy } from "pdfjs-dist"; - -export interface UniversalReader { - type: "ebook"; - cif: EbookCIF; - currentSpineIndex: number; - currentScrollPosition?: number; - currentPage: number; - pagination?: PaginationData; - position?: ReadingPosition; -} - -export interface PDFReader { - type: "pdf"; - doc: PDFDocumentProxy; - currentPage: number; -} - -export interface ComicReader { - type: "comic"; - images: Blob[]; - currentPage: number; -} - -export interface MangaReader { - type: "manga"; - images: Blob[]; - currentPage: number; - readingDirection: "rtl" | "vertical"; -} - -type CurrentReader = UniversalReader | PDFReader | ComicReader | MangaReader; - -let currentReader: CurrentReader | null = null; -let readerMetadata: ReaderMetadata | null = null; - -// ============================================================ -// Feature Registry -// ============================================================ - -type FeatureInit = (context: ReaderContext) => void | Promise; - -const featureModules: FeatureInit[] = [ - // Core features - (ctx) => import("./ui/gestures").then((m) => m.init(ctx)), - (ctx) => import("./ui/keyboard-shortcuts").then((m) => m.init(ctx)), - (ctx) => import("./ui/panel-dock-system").then((m) => m.init(ctx)), - (ctx) => import("./ui/navigator-panel").then((m) => m.init(ctx)), - (ctx) => import("./ui/offline-manager").then((m) => m.init(ctx)), - (ctx) => import("./ui/reading-speed-tracker").then((m) => m.init(ctx)), - (ctx) => import("./ui/progress-indicator").then((m) => m.init(ctx)), - - // Comic features - (ctx) => - import("./formats/comic/background-color").then((m) => m.init?.(ctx)), - (ctx) => import("./formats/comic/chapter-markers").then((m) => m.init?.(ctx)), - (ctx) => import("./formats/comic/page-cache").then((m) => m.init?.(ctx)), - - // PDF features - (ctx) => import("./formats/pdf/pdf-navigation").then((m) => m.init?.(ctx)), - (ctx) => - import("./formats/pdf/pdf-text-selection").then((m) => m.init?.(ctx)), - (ctx) => import("./formats/pdf/annotation-layer").then((m) => m.init?.(ctx)), - - // Ebook features - (ctx) => - import("./formats/reflowable/ebook/copy-handler").then((m) => - m.init?.(ctx), - ), - (ctx) => - import("./formats/reflowable/ebook/font-loader").then((m) => m.init?.(ctx)), - - // Manga features - (ctx) => - import("./formats/manga/reading-direction").then((m) => m.init?.(ctx)), - (ctx) => - import("./formats/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; - console.log("Initializing reader for:", mediaItemId); - if (!mediaItemId) { - console.error("No mediaItemId on body"); - return; - } - - try { - readerMetadata = await getReaderMetadata(mediaItemId); - console.log("Got metadata:", readerMetadata); - console.log("Library type:", readerMetadata.library_type_name); - console.log("Mime type:", readerMetadata.mime_type); - switch (readerMetadata.format_group) { - case "reflowable": - if ( - readerMetadata.mime_type === "application/epub+zip" || - readerMetadata.file_path.endsWith(".epub") - ) { - currentReader = await initializeEbookReader(readerMetadata); - } - break; - case "fixed_layout": - currentReader = await initializePDFReader(readerMetadata); - break; - case "comic_archive": - // Check manga_type or library_type_name - if ( - readerMetadata.manga_type === "yes" || - readerMetadata.manga_type === "yes_and_right_to_left" || - readerMetadata.library_type_name === "manga" - ) { - currentReader = await initializeMangaReader(readerMetadata); - } else { - currentReader = await initializeComicReader(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); - // Initialize page calculation FIRST, before any rendering - const { initializePageCalculation } = - await import("./core/reader-navigation"); - await initializePageCalculation(); - // Then render the initial chapter content - await renderSpineItem(); - // Focus the content container so keyboard navigation works immediately - const container = document.getElementById("reader-content"); - container?.focus(); - } catch (error) { - console.error("Render initialization failed:", error); - } -} - -async function initializeEbookReader( - metadata: ReaderMetadata, -): Promise { - const { parseEbook, requiresServerParsing } = - await import("./core/parser-manager"); - const needsServer = requiresServerParsing( - metadata.mime_type, - getFileExtension(metadata.file_path), - ); - let ebookFile: Blob; - if (needsServer) { - const response = await fetch(`/readers/${metadata.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), - ); - console.log("EPUB Parse Result:", { - spineLength: cif.spine?.length, - spineItems: cif.spine?.map((s: SpineItem) => s.content), - tocLength: cif.toc?.length, - keys: Object.keys(cif), - }); - // ============================================================ - // Fetch saved progress from backend - // ============================================================ - const savedProgress = await getReadingProgress(metadata.id); - const savedCFI = savedProgress?.cfi; - const savedPage = savedProgress?.current_page; - console.log("Saved progress:", { savedCFI, savedPage }); - // ============================================================ - // Calculate pagination - // ============================================================ - const userSettings = await loadSettings().catch(() => ({ - font_size: 16, - line_height: 1.6, - margin_width: 20, - })); - const settings: PaginationSettings = { - fontSize: userSettings.font_size, - lineHeight: userSettings.line_height, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight - 120, - wordsPerPage: 250, - }; - console.log("Using settings:", { - fontSize: userSettings.font_size, - lineHeight: userSettings.line_height, - marginWidth: userSettings.margin_width, - }); - const spineItems = cif.spine.map((s: SpineItem) => ({ - id: s.id, - type: "html" as const, - content: s.content, - index: s.index, - })); - const pagination = await calculatePagination( - spineItems, - cif.resources, - settings, - ); - console.log("Pagination calculated:", { - totalPages: pagination.totalPages, - spines: pagination.spines.length, - }); - // ============================================================ - // Restore saved position or start at page 1 - // ============================================================ - const position: ReadingPosition = restorePosition( - { - type: "ebook", - cif: { - spine: spineItems, - resources: cif.resources, - toc: cif.toc || [], - metadata: { - title: cif.metadata.title || metadata.title, - author: cif.metadata.author || metadata.author, - language: cif.metadata.language || "en", - publisher: cif.metadata.publisher, - isbn: cif.metadata.isbn, - coverImage: cif.metadata.coverImage, - }, - locations: cif.locations || { - totalCharacters: 0, - estimatedPages: 0, - }, - }, - currentSpineIndex: 0, - currentPage: 1, - pagination, - position: { - currentPage: 1, - spineIndex: 0, - localPageIndex: 0, - cfi: "", - progress: 0, - }, - }, - savedCFI || "", - savedPage, - ); - console.log("Restored position:", position); - // ============================================================ - // Apply paginated styles to container - // ============================================================ - const container = document.getElementById("reader-content"); - if (container) { - applyPaginatedStyles(); - } - // ============================================================ - // Render the first page - // ============================================================ - const reflowableNav = await import("./formats/reflowable/navigation"); - const { success, content: pageContent } = reflowableNav.goToPage( - { - type: "ebook", - cif: { - spine: spineItems, - resources: cif.resources, - toc: cif.toc || [], - metadata: { - title: cif.metadata.title || metadata.title, - author: cif.metadata.author || metadata.author, - language: cif.metadata.language || "en", - publisher: cif.metadata.publisher, - isbn: cif.metadata.isbn, - coverImage: cif.metadata.coverImage, - }, - locations: cif.locations || { - totalCharacters: 0, - estimatedPages: 0, - }, - }, - currentSpineIndex: position.spineIndex, - currentPage: position.currentPage, - pagination, - position, - }, - position.currentPage, - ); - if (success && container) { - const { renderPage } = - await import("./formats/reflowable/content-renderer"); - renderPage(container, pageContent); - // Update UI - updatePageDisplay(container, position.currentPage, pagination.totalPages); - updateProgressBar(container, Math.round(position.progress * 100)); - } - return { - type: "ebook", - cif, - currentSpineIndex: position.spineIndex, - currentPage: position.currentPage, - pagination, - position, - }; -} - -async function initializePDFReader( - metadata: ReaderMetadata, -): Promise { - const { initializePDFReader } = await import("./formats/pdf/pdfjs-wrapper"); - return initializePDFReader(metadata); -} - -async function initializeComicReader( - metadata: ReaderMetadata, -): Promise { - const { initializeComicReader } = - await import("./formats/comic/image-parser"); - return initializeComicReader(metadata); -} - -async function initializeMangaReader( - metadata: ReaderMetadata, -): Promise { - const { initializeMangaReader } = - await import("./formats/comic/image-parser"); - return initializeMangaReader(metadata); -} - -function getFileExtension(filepath: string): string { - const match = filepath.match(/\.([^.]+)$/); - return match ? `.${match[1]}` : ""; -} - -// ============================================================ -// Viewport Resize Handler -// ============================================================ -function handleResize(): void { - const state = getState(); - if (!state.currentReader || state.currentReader.type !== "ebook") return; - const book = state.currentReader as UniversalReader; - if (!book.pagination) return; - loadSettings() - .then((userSettings) => { - const newSettings: PaginationSettings = { - fontSize: userSettings.font_size, - lineHeight: userSettings.line_height, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight - 120, - wordsPerPage: 250, - }; - if (shouldRecalculate(book.pagination, newSettings)) { - const currentCFI = book.position.cfi; - const currentPage = book.position.currentPage; - calculatePagination(book.cif.spine, book.cif.resources, newSettings) - .then((newPagination) => { - const updatedBook: UniversalReader = { - type: "ebook", - cif: book.cif, - currentSpineIndex: book.position.spineIndex, - currentPage: book.position.currentPage, - pagination: newPagination, - position: { - currentPage: currentPage, - spineIndex: book.position.spineIndex, - localPageIndex: book.position.localPageIndex, - cfi: currentCFI, - progress: currentPage / newPagination.totalPages, - }, - }; - setState({ currentReader: updatedBook }); - const container = document.getElementById("reader-content"); - if (container) { - const reflowableNav = require("./formats/reflowable/navigation"); - const { success, content: pageContent } = reflowableNav.goToPage( - updatedBook, - updatedBook.position.currentPage, - ); - if (success) { - const { - renderPage, - } = require("./formats/reflowable/content-renderer"); - renderPage(container, pageContent); - updatePageDisplay( - container, - updatedBook.position.currentPage, - newPagination.totalPages, - ); - updateProgressBar( - container, - Math.round(updatedBook.position.progress * 100), - ); - } - } - }) - .catch((error) => { - console.error("Pagination recalculation failed:", error); - }); - } - }) - .catch((error) => { - console.warn( - "Failed to load user settings for resize, using defaults:", - error, - ); - const newSettings: PaginationSettings = { - fontSize: 16, - lineHeight: 1.6, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight - 120, - wordsPerPage: 250, - }; - if (shouldRecalculate(book.pagination, newSettings)) { - const currentCFI = book.position.cfi; - const currentPage = book.position.currentPage; - calculatePagination(book.cif.spine, book.cif.resources, newSettings) - .then((newPagination) => { - const updatedBook: UniversalReader = { - type: "ebook", - cif: book.cif, - currentSpineIndex: book.position.spineIndex, - currentPage: book.position.currentPage, - pagination: newPagination, - position: { - currentPage: currentPage, - spineIndex: book.position.spineIndex, - localPageIndex: book.position.localPageIndex, - cfi: currentCFI, - progress: currentPage / newPagination.totalPages, - }, - }; - setState({ currentReader: updatedBook }); - const container = document.getElementById("reader-content"); - if (container) { - const reflowableNav = require("./formats/reflowable/navigation"); - const { success, content: pageContent } = reflowableNav.goToPage( - updatedBook, - updatedBook.position.currentPage, - ); - if (success) { - const { - renderPage, - } = require("./formats/reflowable/content-renderer"); - renderPage(container, pageContent); - updatePageDisplay( - container, - updatedBook.position.currentPage, - newPagination.totalPages, - ); - updateProgressBar( - container, - Math.round(updatedBook.position.progress * 100), - ); - } - } - }) - .catch((error) => { - console.error("Pagination recalculation failed:", error); - }); - } - }); -} -function debounce( - func: (...args: any[]) => void, - wait: number, -): (...args: any[]) => void { - let timeout: any; - return function (this: any, ...args: any[]) { - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), wait); - }; -} -window.addEventListener("resize", debounce(handleResize, 300)); -// ============================================================ -// Alpine.js Integration -// ============================================================ - -Alpine.data("readerShell", () => ({ - initReader() { - initializeReader(); - }, - - get currentPage() { - const state = getState(); - if (!state.currentReader) return 0; - - if (state.currentReader.type === "ebook") { - // Use stored computed page if available - if (state.currentReader.currentPage) { - return state.currentReader.currentPage; - } - 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") { - // Use pagination if available - if ((state.currentReader as UniversalReader).pagination) { - return (state.currentReader as UniversalReader).pagination!.totalPages; - } - return state.currentReader.cif.spine.length; - } else if (state.currentReader.type === "pdf") { - return state.readerMetadata.total_pages || 0; - } else { - return state.currentReader.images.length; - } - }, -})); diff --git a/web/src/reader/ui/gestures.ts b/web/src/reader/ui/gestures.ts deleted file mode 100644 index 444279f..0000000 --- a/web/src/reader/ui/gestures.ts +++ /dev/null @@ -1,276 +0,0 @@ -import type { Panel } from "../formats/comic/panel-detector"; -import { detectPanels } from "../formats/comic/panel-detection.service"; -import type { ReaderContext } from "../core/reader-context"; - -let currentPanelIndex = 0; -let currentPagePanels: Panel[] = []; -let context: ReaderContext; - -export async function init(readerContext: ReaderContext): Promise { - context = readerContext; - 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_name === "manga") { - navigateWithPanels("next"); - } else { - context.navigation.previousPage(); - } -} - -function handleSwipeRight() { - const metadata = context.getState().readerMetadata; - if (metadata?.library_type_name === "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; - 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"); - } -} diff --git a/web/src/reader/ui/keyboard-shortcuts.ts b/web/src/reader/ui/keyboard-shortcuts.ts deleted file mode 100644 index 752da14..0000000 --- a/web/src/reader/ui/keyboard-shortcuts.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export async function init(readerContext: ReaderContext): Promise { - context = readerContext; - 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; - 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(); - }); -} diff --git a/web/src/reader/ui/navigator-panel.ts b/web/src/reader/ui/navigator-panel.ts deleted file mode 100644 index 99f569c..0000000 --- a/web/src/reader/ui/navigator-panel.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Navigator panel - shows full page with draggable viewport box -// Affinity/Photoshop-style mini-map for page navigation -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export function init(readerContext: ReaderContext): void { - context = readerContext; - let state: NavigatorState | null = null; - - context.events.on( - "navigator:initialize", - (detail: { containerSelector: string; totalPages: number }) => { - state = initializeNavigator(detail.containerSelector); - state.totalPages = detail.totalPages; - }, - ); - - context.events.on( - "navigator:update", - (detail: { currentPage: number; contentImage?: HTMLImageElement }) => { - if (state) { - state.currentPage = detail.currentPage; - if (detail.contentImage) { - state.contentImage = detail.contentImage; - updateNavigatorViewport(state); - } - } - }, - ); - - context.events.on("navigator:pan", (detail: { x: number; y: number }) => { - if (state) { - handleNavigatorPan(state, detail.x, detail.y); - } - }); -} - -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; -} - -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 imgRect = state.contentImage.getBoundingClientRect(); - - const relX = (e.clientX - imgRect.left) / imgRect.width; - const relY = (e.clientY - imgRect.top) / imgRect.height; - - const mainViewer = document.getElementById("reader-content"); - if (mainViewer) { - mainViewer.dispatchEvent( - new CustomEvent("navigator-pan", { - detail: { x: relX, y: relY }, - }), - ); - } - }); - - document.addEventListener("mouseup", () => { - if (state.isDragging) { - state.isDragging = false; - state.viewport.style.cursor = "move"; - } - }); -} - -function updateNavigatorViewport(state: NavigatorState): void { - if (!state.contentImage) return; - - const containerRect = state.container.getBoundingClientRect(); - const imgRect = state.contentImage.getBoundingClientRect(); - - state.scale = containerRect.width / imgRect.width; - - state.viewport.style.width = `${containerRect.width}px`; - state.viewport.style.height = `${containerRect.height * state.scale}px`; - - state.viewport.style.left = "0"; - state.viewport.style.top = `${(state.currentPage - 1) * imgRect.height * state.scale}px`; -} - -function handleNavigatorPan(state: NavigatorState, x: number, y: number): void { - if (!state.contentImage) return; - - const viewportX = x * state.container.offsetWidth; - const viewportY = y * state.container.offsetHeight; - - state.viewport.style.left = `${viewportX}px`; - state.viewport.style.top = `${viewportY}px`; -} diff --git a/web/src/reader/ui/offline-manager.ts b/web/src/reader/ui/offline-manager.ts deleted file mode 100644 index c6522c1..0000000 --- a/web/src/reader/ui/offline-manager.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Offline manager for PWA functionality -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export function init(readerContext: ReaderContext): void { - context = readerContext; - registerServiceWorker(); - - window.addEventListener("online", () => { - const isOnline = checkOnlineStatus(); - if (isOnline) { - context.events.emit("offline:online", {}); - syncPendingChanges(context); - } - }); - - window.addEventListener("offline", () => { - context.events.emit("offline:offline", {}); - }); - - context.events.on("offline:check", () => { - const isOnline = checkOnlineStatus(); - context.events.emit("offline:status", { isOnline }); - }); -} - -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; -} - -function syncPendingChanges(context: ReaderContext): void { - context.events.emit("offline:sync", {}); -} diff --git a/web/src/reader/ui/page-display.ts b/web/src/reader/ui/page-display.ts deleted file mode 100644 index dda9b0f..0000000 --- a/web/src/reader/ui/page-display.ts +++ /dev/null @@ -1,73 +0,0 @@ -// 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); -} - -// Remove page display -export function removePageDisplay(container: HTMLElement): void { - const existing = container.querySelector(".page-display"); - existing?.remove(); -} - -// 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}%`; -} - -// Remove progress bar -export function removeProgressBar(container: HTMLElement): void { - const existing = container.querySelector(".progress-bar"); - existing?.remove(); -} diff --git a/web/src/reader/ui/panel-dock-system.ts b/web/src/reader/ui/panel-dock-system.ts deleted file mode 100644 index 03fe2bd..0000000 --- a/web/src/reader/ui/panel-dock-system.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Modular dockable panel system - handles drag, lock, snap-back, window-shade -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export function init(readerContext: ReaderContext): void { - context = readerContext; - const dockZones: DockZone[] = [ - { side: "left", x: 0, width: 400, height: window.innerHeight }, - { - side: "right", - x: window.innerWidth - 400, - width: 400, - height: window.innerHeight, - }, - ]; - - const state: PanelDockState = { - panels: new Map(), - dragState: null, - dockZones, - }; - - context.events.on("panels:initialize", async () => { - const settings = await loadSettings(); - for (const [panelId, panelState] of Object.entries(settings.panel_layout)) { - state.panels.set(panelId, panelState as PanelState); - createPanel(context, panelId, panelState as PanelState); - } - setupGlobalDragHandlers(context, state); - }); - - context.events.on("panel:toggle", (detail: { panelId: string }) => { - const panelState = state.panels.get(detail.panelId); - if (panelState) { - panelState.visible = !panelState.visible; - updatePanelVisibility(detail.panelId, panelState.visible); - } - }); - - context.events.on("panel:collapse", (detail: { panelId: string }) => { - const panelState = state.panels.get(detail.panelId); - if (panelState) { - panelState.collapsed = !panelState.collapsed; - updatePanelCollapsed(detail.panelId, panelState.collapsed); - } - }); - - context.events.on( - "panel:move", - (detail: { panelId: string; side: "left" | "right" }) => { - movePanelToSide(context, state, detail.panelId, detail.side); - }, - ); -} - -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; -} - -interface PanelState { - side: "left" | "right"; - visible: boolean; - collapsed: boolean; - width_px: number; - order: number; - locked: boolean; - last_valid_side: "left" | "right"; -} - -async function loadSettings(): Promise { - const response = await fetch("/readers/settings"); - if (!response.ok) return {}; - return await response.json(); -} - -function createPanel( - context: ReaderContext, - panelId: string, - panelState: PanelState, -): void { - const panel = document.createElement("div"); - panel.id = `panel-${panelId}`; - panel.className = `reader-panel panel-${panelState.side}`; - panel.dataset.panelId = panelId; - panel.style.width = `${panelState.width_px}px`; - - if (!panelState.visible) { - panel.classList.add("panel-hidden"); - } - - if (panelState.collapsed) { - panel.classList.add("panel-collapsed"); - } - - context.elements.readerContent.appendChild(panel); -} - -function updatePanelVisibility(panelId: string, visible: boolean): void { - const panel = document.getElementById(`panel-${panelId}`); - if (panel) { - panel.classList.toggle("panel-hidden", !visible); - } -} - -function updatePanelCollapsed(panelId: string, collapsed: boolean): void { - const panel = document.getElementById(`panel-${panelId}`); - if (panel) { - panel.classList.toggle("panel-collapsed", collapsed); - } -} - -function setupGlobalDragHandlers( - context: ReaderContext, - state: PanelDockState, -): void { - document.addEventListener("mousedown", (e) => { - const panelHeader = (e.target as HTMLElement).closest(".panel-header"); - if (panelHeader) { - const panelId = (panelHeader as HTMLElement).dataset.panelId; - if (panelId) { - const panelState = state.panels.get(panelId); - if (panelState && !panelState.locked) { - state.dragState = { - panelId, - startX: e.clientX, - startY: e.clientY, - currentX: e.clientX, - currentY: e.clientY, - isLocked: false, - }; - } - } - } - }); - - document.addEventListener("mousemove", (e) => { - if (!state.dragState) return; - state.dragState.currentX = e.clientX; - state.dragState.currentY = e.clientY; - }); - - document.addEventListener("mouseup", () => { - if (state.dragState) { - const nearestZone = findNearestDockZone( - state.dragState.currentX, - state.dockZones, - ); - if (nearestZone) { - movePanelToSide( - context, - state, - state.dragState.panelId, - nearestZone.side, - ); - } - state.dragState = null; - } - }); -} - -function findNearestDockZone(x: number, zones: DockZone[]): DockZone | null { - let nearest: DockZone | null = null; - let minDistance = Infinity; - - for (const zone of zones) { - const distance = Math.abs(x - (zone.x + zone.width / 2)); - if (distance < minDistance) { - minDistance = distance; - nearest = zone; - } - } - - return nearest; -} - -function movePanelToSide( - context: ReaderContext, - state: PanelDockState, - panelId: string, - side: "left" | "right", -): void { - const panelState = state.panels.get(panelId); - if (!panelState) return; - - panelState.side = side; - panelState.last_valid_side = side; - - const panel = document.getElementById(`panel-${panelId}`); - if (panel) { - panel.classList.remove("panel-left", "panel-right"); - panel.classList.add(`panel-${side}`); - } - - context.events.emit("panel:moved", { panelId, side }); -} diff --git a/web/src/reader/ui/progress-indicator.ts b/web/src/reader/ui/progress-indicator.ts deleted file mode 100644 index eb51dc2..0000000 --- a/web/src/reader/ui/progress-indicator.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { ReaderContext } from "../core/reader-context"; -interface ProgressDisplay { - mode: "pages" | "chapter" | "percentage" | "time-left"; - text: string; -} - -let context: ReaderContext; - -export async function init(readerContext: ReaderContext): Promise { - context = readerContext; - context.events.on("pageChanged", () => { - updateProgressDisplay(context); - }); - context.events.on("progressUpdated", () => { - updateProgressDisplay(context); - }); - context.events.on("readerReady", () => { - updateProgressDisplay(context); - }); - setupProgressModeCycling(context); -} -export { calculateProgress, cycleProgressMode }; -export type { ProgressDisplay }; -function calculateProgress( - currentPage: number, - totalPages: number, - currentChapterPage: number, - chapterPages: number, - readingSpeed?: ReadingSpeed, -): ProgressDisplay { - const mode = getCurrentProgressMode(); - 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 || readingSpeed.pages_per_minute === 0) { - 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`, - }; - default: - return { mode: "pages", text: `${currentPage}/${totalPages}` }; - } -} -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); -} -function getCurrentProgressMode(): ReaderSettings["progress_mode"] { - const settingsStr = localStorage.getItem("reader_settings_progress_mode"); - if (settingsStr) { - try { - return JSON.parse(settingsStr); - } catch { - return "pages"; - } - } - return "pages"; -} -function setProgressMode(mode: ReaderSettings["progress_mode"]): void { - localStorage.setItem("reader_settings_progress_mode", JSON.stringify(mode)); - - const display = document.getElementById("progress-display"); - if (display) { - display.dataset.mode = mode; - } -} -async function getReadingSpeed(): Promise { - const speedStr = localStorage.getItem("reader_speed_data"); - if (speedStr) { - try { - return JSON.parse(speedStr); - } catch { - return null; - } - } - return null; -} -function updateProgressDisplay(context: ReaderContext): void { - const state = context.getState(); - const display = context.elements.progressDisplay; - if (!display || !state.currentReader) return; - let currentPage = 0; - let totalPages = 0; - let currentChapterPage = 0; - let chapterPages = 0; - if (state.currentReader.type === "ebook") { - const reader = state.currentReader; - - // Use currentPage directly if available (page-based navigation) - if (reader.currentPage) { - currentPage = reader.currentPage; - } else if (reader.currentSpineIndex !== undefined) { - currentPage = reader.currentSpineIndex + 1; - } - - // Get total pages from calculation result - const pageInfo = reader.pagination; - if (pageInfo && pageInfo.totalPages > 0) { - totalPages = pageInfo.totalPages; - - // Get chapter progress from spine info - const spine = pageInfo.spineMap.get(reader.currentSpineIndex); - if (spine && spine.pages.length > 0) { - const chapterStartPage = spine.pages[0].pageIndex; - currentChapterPage = currentPage - chapterStartPage + 1; - chapterPages = spine.pages.length; - } - } else { - // Fallback - totalPages = - state.readerMetadata?.total_pages || - state.currentReader.cif.locations?.estimatedPages || - state.currentReader.cif.spine.length; - } - } else if (state.currentReader.type === "pdf") { - currentPage = state.currentReader.currentPage; - totalPages = state.readerMetadata?.total_pages || 0; - } else { - // Comic/manga - const reader = state.currentReader; - if (reader.type === "comic" || reader.type === "manga") { - currentPage = reader.currentPage + 1; - totalPages = reader.images?.length || 0; - } - } - getReadingSpeed().then((speed) => { - const result = calculateProgress( - currentPage, - totalPages, - currentChapterPage, - chapterPages, - speed || undefined, - ); - display.textContent = result.text; - display.dataset.mode = result.mode; - }); -} -function setupProgressModeCycling(context: ReaderContext): void { - const display = context.elements.progressDisplay; - if (!display) return; - display.style.cursor = "pointer"; - display.addEventListener("click", () => { - cycleProgressMode(); - updateProgressDisplay(context); - }); -} diff --git a/web/src/reader/ui/reading-speed-tracker.ts b/web/src/reader/ui/reading-speed-tracker.ts deleted file mode 100644 index a278a80..0000000 --- a/web/src/reader/ui/reading-speed-tracker.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Track reading speed and update database -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export function init(readerContext: ReaderContext): void { - context = readerContext; - let state: ReadingSpeedTrackerState | null = null; - - context.events.on("reader:loaded", (detail: { mediaItemId: string }) => { - state = createReadingSpeedTracker(detail.mediaItemId); - }); - - context.events.on("reading-session:start", () => { - if (state) { - startReadingSession(state); - } - }); - - context.events.on("page-changed", () => { - if (state) { - recordPageTurn(state); - } - }); - - context.events.on("words-read", (detail: { wordCount: number }) => { - if (state) { - recordWordsRead(state, detail.wordCount); - } - }); - - context.events.on("reading-session:end", async () => { - if (state) { - await syncReadingSpeed(state); - } - }); - - context.events.on("reader:unload", async () => { - if (state) { - await syncReadingSpeed(state); - state = null; - } - }); -} - -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 { - state.startTime = Date.now(); - state.pagesRead = 0; - state.wordsRead = 0; - return state; -} - -function recordPageTurn( - state: ReadingSpeedTrackerState, -): ReadingSpeedTrackerState { - if (!state.startTime) return state; - - state.pagesRead += 1; - const now = Date.now(); - - if (state.pagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) { - syncReadingSpeed(state); - state.lastSync = now; - } - - return state; -} - -function recordWordsRead( - state: ReadingSpeedTrackerState, - wordCount: number, -): ReadingSpeedTrackerState { - state.wordsRead += wordCount; - return state; -} - -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; - - try { - const token = localStorage.getItem("token"); - await fetch(`/readers/${state.mediaItemId}/reading-speed`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - pages_per_minute: pagesPerMinute, - words_per_minute: wordsPerMinute, - pages_read: state.pagesRead, - total_reading_minutes: minutesElapsed, - }), - }); - } catch (error) { - console.error("Failed to sync reading speed:", error); - } -}