diff --git a/web/src/reader/comic/background-color.ts b/web/src/reader/comic/background-color.ts index c2306f1..57f810d 100644 --- a/web/src/reader/comic/background-color.ts +++ b/web/src/reader/comic/background-color.ts @@ -7,17 +7,23 @@ 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: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( + "ui:show-settings", + (detail: { container: HTMLElement }) => { + renderBackgroundColorPicker(detail.container, state); + }, + ); context.events.on("reader:unload", () => { const picker = document.querySelector(".background-color-picker"); @@ -43,7 +49,9 @@ const backgroundColors: Record = { function createBackgroundColorState( initial: BackgroundColor = "black", ): BackgroundColorState { - const saved = localStorage.getItem("reader-background-color") as BackgroundColor; + const saved = localStorage.getItem( + "reader-background-color", + ) as BackgroundColor; return { current: saved || initial, customColor: "#000000", @@ -67,7 +75,8 @@ function setBackgroundColor( state.current = color; state.customColor = customColor || state.customColor; - const bgColor = color === "custom" ? state.customColor : backgroundColors[color]; + const bgColor = + color === "custom" ? state.customColor : backgroundColors[color]; document.documentElement.style.setProperty("--reader-bg-color", bgColor); const viewer = document.querySelector(".reader-content") as HTMLElement; @@ -85,7 +94,9 @@ function setBackgroundColor( return state; } -function toggleBackgroundColor(state: BackgroundColorState): BackgroundColorState { +function toggleBackgroundColor( + state: BackgroundColorState, +): BackgroundColorState { const order: BackgroundColor[] = ["black", "white", "gray", "sepia"]; const currentIndex = order.indexOf(state.current); const nextIndex = (currentIndex + 1) % order.length; @@ -132,4 +143,5 @@ function updateBackgroundColorUI( buttons.forEach((btn, index) => { btn.classList.toggle("border-blue-500", colors[index] === state.current); }); -} \ No newline at end of file +} + diff --git a/web/src/reader/comic/chapter-markers.ts b/web/src/reader/comic/chapter-markers.ts index 1132a7f..1e1678d 100644 --- a/web/src/reader/comic/chapter-markers.ts +++ b/web/src/reader/comic/chapter-markers.ts @@ -2,15 +2,18 @@ // Visual indicators for chapter boundaries // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +import { 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( + "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) { @@ -24,11 +27,14 @@ export function init(context: ReaderContext): void { } }); - context.events.on("chapter-markers:navigate", (detail: { chapterNumber: number }) => { - if (state) { - scrollToChapter(state, detail.chapterNumber); - } - }); + 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"); @@ -145,4 +151,5 @@ function scrollToChapter( }), ); } -} \ No newline at end of file +} + diff --git a/web/src/reader/comic/image-parser.ts b/web/src/reader/comic/image-parser.ts deleted file mode 100644 index c87e867..0000000 --- a/web/src/reader/comic/image-parser.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Comic/Manga Reader - Image-based pages -// Handles CBZ, comic archives, image directories -interface ReaderMetadata { - media_item_id: string; - title: string; - author: string; - cover_image_path: string; - library_type: "ebook" | "comic" | "manga" | "pdf"; - mime_type: string; - file_path: string; - total_pages?: number; -} -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/comic/page-cache.ts b/web/src/reader/comic/page-cache.ts deleted file mode 100644 index 1dece20..0000000 --- a/web/src/reader/comic/page-cache.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Lazy-loading page cache with 5-page ahead prefetch -// Shared by both comic and manga readers -// Feature Registration Pattern implementation - -import type { 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; -} \ No newline at end of file diff --git a/web/src/reader/comic/page-order.ts b/web/src/reader/comic/page-order.ts index 340637b..b26bbb7 100644 --- a/web/src/reader/comic/page-order.ts +++ b/web/src/reader/comic/page-order.ts @@ -2,14 +2,17 @@ // Auto-detect Japanese vs Western reading order // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +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( + "reader:loaded", + (detail: { totalPages: number; pageNames: string[] }) => { + state = createPageOrderState(detail.totalPages, detail.pageNames); + }, + ); context.events.on("page-order:set", (detail: { mode: PageOrderMode }) => { if (state) { @@ -24,19 +27,25 @@ export function init(context: ReaderContext): void { } }); - 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: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 }); - } - }); + 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"; @@ -139,4 +148,5 @@ function getDisplayPageNumber( } return actualPage; -} \ No newline at end of file +} + diff --git a/web/src/reader/comic/page-scrubber.ts b/web/src/reader/comic/page-scrubber.ts deleted file mode 100644 index 1ee94d2..0000000 --- a/web/src/reader/comic/page-scrubber.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Page slider/scrubber for quick navigation -// Feature Registration Pattern implementation - -import type { 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 } }), - ); -} \ No newline at end of file diff --git a/web/src/reader/comic/panel-detection.ml.ts b/web/src/reader/comic/panel-detection.ml.ts deleted file mode 100644 index 9d1a26b..0000000 --- a/web/src/reader/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/comic/panel-detection.opencv.ts b/web/src/reader/comic/panel-detection.opencv.ts deleted file mode 100644 index fa4c9c7..0000000 --- a/web/src/reader/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/comic/panel-detection.service.ts b/web/src/reader/comic/panel-detection.service.ts deleted file mode 100644 index 516d5bd..0000000 --- a/web/src/reader/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/comic/panel-detector.ts b/web/src/reader/comic/panel-detector.ts deleted file mode 100644 index 7172749..0000000 --- a/web/src/reader/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/comic/panel-editor.ts b/web/src/reader/comic/panel-editor.ts deleted file mode 100644 index d6509e7..0000000 --- a/web/src/reader/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/comic/panel-gap.ts b/web/src/reader/comic/panel-gap.ts index 3e84e24..537cac4 100644 --- a/web/src/reader/comic/panel-gap.ts +++ b/web/src/reader/comic/panel-gap.ts @@ -1,7 +1,7 @@ // Adjustable panel gap controls // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +import { ReaderContext } from "../core/reader-context"; export function init(context: ReaderContext): void { const state = createPanelGapState(); @@ -23,9 +23,12 @@ export function init(context: ReaderContext): void { togglePanelBorders(state); }); - context.events.on("ui:show-settings", (detail: { container: HTMLElement }) => { - renderPanelGapControls(detail.container, 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"); @@ -70,11 +73,17 @@ function setPanelGap(state: PanelGapState, gap: number): PanelGapState { return state; } -function increasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { +function increasePanelGap( + state: PanelGapState, + amount: number = 2, +): PanelGapState { return setPanelGap(state, state.gapSize + amount); } -function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { +function decreasePanelGap( + state: PanelGapState, + amount: number = 2, +): PanelGapState { return setPanelGap(state, state.gapSize - amount); } @@ -147,4 +156,5 @@ function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void { if (bordersBtn) { bordersBtn.textContent = state.showBorders ? "▦" : "▢"; } -} \ No newline at end of file +} + diff --git a/web/src/reader/ebook/cfi-navigator.ts b/web/src/reader/ebook/cfi-navigator.ts deleted file mode 100644 index b2bd83e..0000000 --- a/web/src/reader/ebook/cfi-navigator.ts +++ /dev/null @@ -1,149 +0,0 @@ -// EPUB CFI (Canonical Fragment Identifier) navigation -// Reuses logic from internal/sync/format.go -// Procedural style: Functions, not classes - -interface CFIComponent { - type: "index" | "indirection-step" | "text-location"; - value: number; - id?: string; - textOffset?: number; -} - -// ============================================================ -// CFI Parsing Functions -// ============================================================ - -export function parseCFI(cfi: string): CFIComponent[] { - const components: CFIComponent[] = []; - - const cleanCFI = cfi.startsWith("!") ? cfi.substring(1) : cfi; - const parts = cleanCFI.split("/").filter(Boolean); - - for (const part of parts) { - const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/); - if (match) { - const component: CFIComponent = { - type: match[3] !== undefined ? "text-location" : "index", - value: parseInt(match[1], 10), - id: match[2], - textOffset: match[3] !== undefined ? parseInt(match[3], 10) : undefined, - }; - - components.push(component); - } - } - - return components; -} - -export function generateCFI( - spineIndex: number, - elementPath: number[], - textOffset: number = 0, - spineItemId?: string, -): string { - let cfi = `/6/${spineIndex}`; - - if (spineItemId) { - cfi += `[${spineItemId}]`; - } - - for (const index of elementPath) { - cfi += `/${index}`; - } - - if (textOffset > 0) { - cfi += `:${textOffset}`; - } - - return cfi; -} - -export function navigateToCFI( - doc: Document, - cfi: string, -): Element | Text | null { - const components = parseCFI(cfi); - - if (components.length === 0) return null; - - let current: Node | null = doc.body; - - for (let i = 1; i < components.length; i++) { - const component = components[i]; - - if (component.type === "index") { - if (current instanceof Element) { - const children = getElementChildren(current); - current = children[component.value] || null; - } - } - } - - return current as Element | Text; -} - -export function getSelectionCFI(doc: Document): string | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return null; - - const range = selection.getRangeAt(0); - const startContainer = range.startContainer; - - // Build path to start container - const path: number[] = []; - let current: Node | null = startContainer; - - while (current && current !== doc.body) { - const parent = current.parentElement; - if (parent) { - const siblings = getElementChildren(parent); - const index = siblings.indexOf(current as Element); - path.unshift(index); - } - current = parent; - } - - const spineIndex = 0; - const textOffset = range.startOffset; - - return generateCFI(spineIndex, path, textOffset); -} - -export function getPercentageFromCFI(cfi: string): number { - const components = parseCFI(cfi); - const textLocation = components.find((c) => c.type === "text-location"); - - if (textLocation && textLocation.textOffset !== undefined) { - return Math.min(textLocation.textOffset / 10, 100); - } - - return 0; -} - -export function compareCFIs(cfi1: string, cfi2: string): number { - const components1 = parseCFI(cfi1); - const components2 = parseCFI(cfi2); - - const maxLen = Math.max(components1.length, components2.length); - - for (let i = 0; i < maxLen; i++) { - const comp1 = components1[i]; - const comp2 = components2[i]; - - if (!comp1) return -1; - if (!comp2) return 1; - - if (comp1.value !== comp2.value) { - return comp1.value - comp2.value; - } - } - - return 0; -} - -function getElementChildren(element: Element): Element[] { - return Array.from(element.children).filter( - (el) => el.nodeType === Node.ELEMENT_NODE, - ) as Element[]; -} diff --git a/web/src/reader/ebook/copy-handler.ts b/web/src/reader/ebook/copy-handler.ts deleted file mode 100644 index c9e20a1..0000000 --- a/web/src/reader/ebook/copy-handler.ts +++ /dev/null @@ -1,67 +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); - } - }); -} \ No newline at end of file diff --git a/web/src/reader/ebook/dictionary-popup.ts b/web/src/reader/ebook/dictionary-popup.ts deleted file mode 100644 index 92c7944..0000000 --- a/web/src/reader/ebook/dictionary-popup.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Dictionary lookup popup for ebooks -// Feature Registration Pattern implementation - -import type { 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(); -} \ No newline at end of file diff --git a/web/src/reader/ebook/font-loader.ts b/web/src/reader/ebook/font-loader.ts index 25691e1..f680ffa 100644 --- a/web/src/reader/ebook/font-loader.ts +++ b/web/src/reader/ebook/font-loader.ts @@ -1,7 +1,7 @@ // Font loading with performance optimization // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +import { ReaderContext } from "../core/reader-context"; export function init(context: ReaderContext): void { const userPreferredFont = localStorage.getItem("reader-font") || "literata"; @@ -93,4 +93,5 @@ function applyFontStack(stack: string): void { document.documentElement.style.setProperty("--reader-font-family", stack); } -export { READING_FONTS, preloadFonts, getFontStack }; \ No newline at end of file +export { READING_FONTS, preloadFonts, getFontStack }; + diff --git a/web/src/reader/ebook/html-renderer.ts b/web/src/reader/ebook/html-renderer.ts deleted file mode 100644 index f89613a..0000000 --- a/web/src/reader/ebook/html-renderer.ts +++ /dev/null @@ -1,248 +0,0 @@ -// HTML rendering with theme support, font loading, and image handling -// Procedural style: Functions, not classes - -interface RendererConfig { - readingTheme: "light" | "sepia" | "dark" | "night" | "high-contrast"; - readingFont: - | "literata" - | "crimson" - | "source-serif" - | "eb-garamond" - | "libertinus" - | "noto-serif" - | "charis-sil" - | "ibm-plex"; - fontSize: number; - lineHeight: number; - marginWidth: number; - textAlign: "left" | "justify"; - columnCount: 1 | 2; -} - -// ============================================================ -// Main Render Function -// ============================================================ - -export async function renderHTMLDocument( - doc: HTMLDocument, - container: HTMLElement, - config: RendererConfig, -): Promise { - // Apply theme - applyHTMLTheme(container, config.readingTheme); - - // Apply typography settings - applyHTMLTypography(container, config); - - // Inject custom styles for reader - injectHTMLReaderStyles(container); - - // Handle embedded fonts - await loadEmbeddedHTMLFonts(doc, container); - - // Handle images - processHTMLImages(doc, container); - - // Clear container and append content - container.innerHTML = ""; - container.appendChild(doc.body); - - // Apply column layout - applyHTMLColumnLayout(container, config.columnCount); -} - -// ============================================================ -// Theme Application -// ============================================================ - -function applyHTMLTheme(container: HTMLElement, theme: string): void { - const readingThemes: Record> = { - light: { - "--bg-primary": "#ffffff", - "--text-primary": "#1a1a1a", - "--text-secondary": "#666666", - "--accent": "#0066cc", - }, - sepia: { - "--bg-primary": "#f4ecd8", - "--text-primary": "#5f4b32", - "--text-secondary": "#8b7355", - "--accent": "#8b4513", - }, - dark: { - "--bg-primary": "#1a1b26", - "--text-primary": "#c0caf5", - "--text-secondary": "#565f89", - "--accent": "#7aa2f7", - }, - night: { - "--bg-primary": "#0d1117", - "--text-primary": "#c9d1d9", - "--text-secondary": "#8b949e", - "--accent": "#58a6ff", - }, - "high-contrast": { - "--bg-primary": "#000000", - "--text-primary": "#ffffff", - "--text-secondary": "#cccccc", - "--accent": "#ffff00", - }, - }; - - const themeConfig = readingThemes[theme] || readingThemes["dark"]; - - for (const [key, value] of Object.entries(themeConfig)) { - container.style.setProperty(key, value); - } -} - -function applyHTMLTypography( - container: HTMLElement, - config: RendererConfig, -): void { - const style = document.createElement("style"); - const fontStack = getFontStack(config.readingFont); - - style.textContent = ` - .ebook-content { - font-family: ${fontStack}; - font-size: ${config.fontSize}px; - line-height: ${config.lineHeight}; - text-align: ${config.textAlign}; - padding: 0 ${config.marginWidth}px; - max-width: 100%; - overflow-wrap: break-word; - } - - .ebook-content p { - margin-bottom: 1em; - text-indent: ${config.textAlign === "justify" ? "1.5em" : "0"}; - } - - .ebook-content img { - max-width: 100%; - height: auto; - display: block; - margin: 1em auto; - } - - .ebook-content a { - color: var(--accent); - text-decoration: underline; - } - - .ebook-content a:active { - color: var(--text-secondary); - } - `; - - container.appendChild(style); -} - -function injectHTMLReaderStyles(container: HTMLElement): void { - container.setAttribute("role", "main"); - container.setAttribute("aria-label", "Book content"); -} - -async function loadEmbeddedHTMLFonts( - doc: HTMLDocument, - container: HTMLElement, -): Promise { - const styleSheets = doc.querySelectorAll("style"); - - for (const sheet of styleSheets) { - const fontFaceRegex = /@font-face\s*{([^}]+)}/g; - const matches = sheet.textContent?.matchAll(fontFaceRegex) || []; - - for (const match of matches) { - const fontFace = match[1]; - const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace); - - if (urlMatch) { - const fontUrl = urlMatch[1]; - await loadHTMLFont(fontUrl, container); - } - } - } -} - -async function loadHTMLFont( - fontUrl: string, - container: HTMLElement, -): Promise { - const loadedFonts = container.dataset.loadedFonts - ? JSON.parse(container.dataset.loadedFonts) - : []; - - if (loadedFonts.includes(fontUrl)) return; - - try { - const fontFace = new FontFace("custom-font", `url(${fontUrl})`); - await fontFace.load(); - document.fonts.add(fontFace); - - loadedFonts.push(fontUrl); - container.dataset.loadedFonts = JSON.stringify(loadedFonts); - } catch (error) { - console.error("Failed to load font:", fontUrl, error); - } -} - -function processHTMLImages(doc: HTMLDocument): void { - const images = doc.querySelectorAll("img"); - - images.forEach((img) => { - img.setAttribute("loading", "lazy"); - - if (!img.alt) { - img.alt = "Image from book"; - } - - img.style.cursor = "pointer"; - img.addEventListener("click", () => { - showImageFullscreen(img.src); - }); - }); -} - -function showImageFullscreen(src: string): void { - const modal = document.createElement("div"); - modal.className = - "fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50"; - modal.onclick = () => modal.remove(); - - const img = document.createElement("img"); - img.src = src; - img.className = "max-w-full max-h-full object-contain"; - - modal.appendChild(img); - document.body.appendChild(modal); -} - -function applyHTMLColumnLayout( - container: HTMLElement, - columnCount: number, -): void { - if (columnCount === 2) { - container.style.columnCount = "2"; - container.style.columnGap = "20px"; - container.style.columnRule = "1px solid var(--text-secondary)"; - } else { - container.style.columnCount = "auto"; - } -} - -function getFontStack(font: string): string { - const stacks: Record = { - literata: '"Literata", serif', - crimson: '"Crimson Text", serif', - "source-serif": '"Source Serif 4", serif', - "eb-garamond": '"EB Garamond", serif', - libertinus: '"Libertinus Serif", serif', - "noto-serif": '"Noto Serif", serif', - "charis-sil": '"Charis SIL", serif', - "ibm-plex": '"IBM Plex Serif", serif', - }; - - return stacks[font] || stacks["literata"]; -} diff --git a/web/src/reader/ebook/page-calculator.ts b/web/src/reader/ebook/page-calculator.ts deleted file mode 100644 index 9fe92a6..0000000 --- a/web/src/reader/ebook/page-calculator.ts +++ /dev/null @@ -1,134 +0,0 @@ -export interface SpineInfo { - spineIndex: number; - spineItemId: string; - content: string; - charCount: number; - estimatedPages: number; -} -export interface PageCalculationResult { - totalPages: number; - spines: SpineInfo[]; - spineMap: Map; - calculatedAt: number; -} -export interface PageCalculationSettings { - fontSize: number; - lineHeight: number; - marginWidth: number; - viewportHeight: number; -} -export async function calculatePagesForEbook( - cif: EbookCIF, - _viewportWidth: number, - settings: { - fontSize?: number; - lineHeight?: number; - marginWidth?: number; - }, -): Promise { - const fontSize = settings.fontSize || 16; - const lineHeight = settings.lineHeight || 1.6; - const charsPerPage = Math.round(2000 * (16 / fontSize) * (lineHeight / 1.6)); - const spines: SpineInfo[] = []; - let estimatedTotalPages = 0; - for (let i = 0; i < cif.spine.length; i++) { - const spineItem = cif.spine[i]; - if (spineItem.type !== "html") { - spines.push({ - spineIndex: i, - spineItemId: spineItem.id, - content: "", - charCount: 0, - estimatedPages: 1, - }); - estimatedTotalPages += 1; - continue; - } - const contentBlob = cif.resources.get(spineItem.content); - if (!contentBlob) { - spines.push({ - spineIndex: i, - spineItemId: spineItem.id, - content: "", - charCount: 0, - estimatedPages: 1, - }); - estimatedTotalPages += 1; - continue; - } - const contentText = await contentBlob.text(); - const charCount = contentText.replace(/<[^>]*>/g, "").length; - const estimatedPages = Math.max(1, Math.ceil(charCount / charsPerPage)); - estimatedTotalPages += estimatedPages; - spines.push({ - spineIndex: i, - spineItemId: spineItem.id, - content: contentText, - charCount, - estimatedPages, - }); - } - const spineMap = new Map(); - for (const spine of spines) { - spineMap.set(spine.spineIndex, spine); - } - return { - totalPages: estimatedTotalPages, - spines, - spineMap, - calculatedAt: Date.now(), - }; -} -export function getCurrentPageFromScroll( - pageInfo: PageCalculationResult, - currentSpineIndex: number, - scrollPosition: number, - viewportHeight: number, -): number { - const spine = pageInfo.spineMap.get(currentSpineIndex); - if (!spine) return 1; - const charsBeforeSpine = pageInfo.spines - .slice(0, currentSpineIndex) - .reduce((sum, s) => sum + s.charCount, 0); - const charsInSpine = spine.charCount; - const charsAtPosition = (scrollPosition / viewportHeight) * charsInSpine; - const totalCharsAtPosition = charsBeforeSpine + charsAtPosition; - const charsPerPage = 2000; - return Math.ceil(totalCharsAtPosition / charsPerPage); -} -export function getScrollPositionForPage( - pageInfo: PageCalculationResult, - targetPage: number, - viewportHeight: number, -): { spineIndex: number; scrollTop: number } | null { - const charsPerPage = 2000; - const targetChar = (targetPage - 1) * charsPerPage; - let charsAccumulated = 0; - for (const spine of pageInfo.spines) { - charsAccumulated += spine.charCount; - if (targetChar < charsAccumulated) { - const charsBefore = charsAccumulated - spine.charCount; - const charsIntoSpine = targetChar - charsBefore; - const scrollTop = (charsIntoSpine / spine.charCount) * viewportHeight; - return { - spineIndex: spine.spineIndex, - scrollTop: Math.max( - 0, - Math.min(scrollTop, viewportHeight * spine.estimatedPages), - ), - }; - } - } - const lastSpine = pageInfo.spines[pageInfo.spines.length - 1]; - return { - spineIndex: lastSpine?.spineIndex || 0, - scrollTop: 0, - }; -} -export function calculateProgressPercentage( - pageInfo: PageCalculationResult, - currentPage: number, -): number { - if (pageInfo.totalPages <= 0) return 0; - return Math.round((currentPage / pageInfo.totalPages) * 100); -} diff --git a/web/src/reader/ebook/page-splitter.ts b/web/src/reader/ebook/page-splitter.ts deleted file mode 100644 index c4a8e1d..0000000 --- a/web/src/reader/ebook/page-splitter.ts +++ /dev/null @@ -1,276 +0,0 @@ -// Page splitter for ebooks - extracts discrete page content -// Uses CFI for EPUB when available, falls back to height-based splitting -import { generateCFI } from "./cfi-navigator"; -export interface PageContent { - pageNumber: number; - spineIndex: number; - startCFI?: string; - endCFI?: string; - html: string; - charCount: number; -} -export interface PageSplitResult { - pages: PageContent[]; - totalPages: number; -} -// Split content by viewport height (fallback for non-EPUB) -export function splitByHeight( - html: string, - spineIndex: number, - viewportHeight: number, - settings: { fontSize: number; lineHeight: number; marginWidth: number }, -): PageSplitResult { - const pages: PageContent[] = []; - const doc = new DOMParser().parseFromString(html, "text/html"); - - // Clone body content - const content = doc.body; - const contentHeight = estimateContentHeight(content, settings); - const contentWidth = doc.body.scrollWidth || 600; - const pageCount = Math.max(1, Math.ceil(contentHeight / viewportHeight)); - - // For height-based splitting, we'll use a different approach: - // Wrap each block element and measure cumulative height - const blocks = Array.from(content.children); - - let currentPageHTML = ""; - let currentHeight = 0; - let pageNumber = 1; - - for (const block of blocks) { - const blockHeight = estimateBlockHeight(block, settings, contentWidth); - - if ( - currentHeight + blockHeight > viewportHeight && - currentPageHTML.length > 0 - ) { - // Save current page and start new one - pages.push({ - pageNumber, - spineIndex, - html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount), - charCount: currentPageHTML.replace(/<[^>]*>/g, "").length, - }); - pageNumber++; - currentPageHTML = ""; - currentHeight = 0; - } - - currentPageHTML += block.outerHTML; - currentHeight += blockHeight; - } - - // Push final page - if (currentPageHTML.length > 0 || pages.length === 0) { - pages.push({ - pageNumber, - spineIndex, - html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount), - charCount: currentPageHTML.replace(/<[^>]*>/g, "").length, - }); - } - - return { pages, totalPages: pages.length }; -} -// Split content using CFI (for EPUB) -export function splitByCFI( - html: string, - spineIndex: number, - spineItemId: string, - viewportHeight: number, - settings: { fontSize: number; lineHeight: number; marginWidth: number }, -): PageSplitResult { - console.log("splitByCFI called:", { - htmlLength: html.length, - viewportHeight, - spineIndex, - }); - const pages: PageContent[] = []; - const doc = new DOMParser().parseFromString(html, "text/html"); - - // Find all text-containing elements (paragraphs, divs, etc.) - const elements = findTextElements(doc.body); - - let currentPageElements: Element[] = []; - let currentHeight = 0; - const contentWidth = doc.body.scrollWidth || 600; - let pageNumber = 1; - let elementIndex = 0; - - for (const element of elements) { - const elementHeight = estimateBlockHeight(element, settings, contentWidth); - - if ( - currentHeight + elementHeight > viewportHeight && - currentPageElements.length > 0 - ) { - // Create page from accumulated elements - const pageHTML = elementsToHTML(currentPageElements); - const startIndex = elements.indexOf(currentPageElements[0]); - const endIndex = elements.indexOf( - currentPageElements[currentPageElements.length - 1], - ); - - pages.push({ - pageNumber, - spineIndex, - startCFI: generateCFI( - spineIndex, - buildElementPath(elements[startIndex], doc.body), - 0, - spineItemId, - ), - endCFI: generateCFI( - spineIndex, - buildElementPath(elements[endIndex], doc.body), - 0, - spineItemId, - ), - html: wrapInPageContainer(pageHTML, pageNumber, 0), // pageCount TBD - charCount: pageHTML.replace(/<[^>]*>/g, "").length, - }); - - pageNumber++; - currentPageElements = []; - currentHeight = 0; - } - - currentPageElements.push(element); - currentHeight += elementHeight; - elementIndex++; - } - - // Push final page - if (currentPageElements.length > 0) { - const pageHTML = elementsToHTML(currentPageElements); - const startIndex = elements.indexOf(currentPageElements[0]); - const endIndex = elements.indexOf( - currentPageElements[currentPageElements.length - 1], - ); - - pages.push({ - pageNumber, - spineIndex, - startCFI: generateCFI( - spineIndex, - buildElementPath(elements[startIndex], doc.body), - 0, - spineItemId, - ), - endCFI: generateCFI( - spineIndex, - buildElementPath(elements[endIndex], doc.body), - 0, - spineItemId, - ), - html: wrapInPageContainer(pageHTML, pageNumber, pageNumber), - charCount: pageHTML.replace(/<[^>]*>/g, "").length, - }); - } - console.log("splitByCFI result:", { - totalPages: pages.length, - elementsFound: elements.length, - contentWidth, - }); - return { pages, totalPages: pages.length }; -} -// Helper: Estimate content height -function estimateContentHeight(element: Element, settings: any): number { - const text = element.textContent || ""; - const charCount = text.length; - const charsPerLine = Math.floor( - (800 - settings.marginWidth * 2) / (settings.fontSize * 0.6), - ); - const lineCount = Math.ceil(charCount / charsPerLine); - return lineCount * settings.fontSize * settings.lineHeight; -} -// Helper: Estimate block height -function estimateBlockHeight( - element: Element, - settings: any, - contentWidth: number, -): number { - const text = element.textContent || ""; - const approxChars = text.length; - const lineHeight = settings.fontSize * settings.lineHeight; - // Use actual content width instead of hardcoded 800 - const charsPerLine = Math.floor(contentWidth / (settings.fontSize * 0.6)); - const lineCount = Math.max(1, Math.ceil(approxChars / charsPerLine)); - return lineCount * lineHeight + 16; -} -// Helper: Find text-containing elements using querySelectorAll -function findTextElements(element: Element): Element[] { - const selector = [ - "p", - "div", - "span", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "li", - "td", - "th", - "article", - "section", - "blockquote", - "pre", - "figcaption", - "header", - "footer", - "main", - "aside", - ].join(","); - - return Array.from(element.querySelectorAll(selector)).filter((el) => { - const text = el.textContent?.trim() || ""; - return text.length > 0; - }); -} -// Helper: Convert elements to HTML string -function elementsToHTML(elements: Element[]): string { - return elements.map((el) => el.outerHTML).join(""); -} -// Helper: Build CFI element path -function buildElementPath(element: Element, root: Element): number[] { - const path: number[] = []; - let current: Element | null = element; - - while (current && current !== root) { - const parent = current.parentElement; - if (parent) { - const siblings = Array.from(parent.children); - const index = siblings.indexOf(current); - path.unshift(index); - current = parent; - } else { - break; - } - } - - return path; -} -// Helper: Wrap content in page container -function wrapInPageContainer( - html: string, - current: number, - total: number, -): string { - return `
${html}
`; -} -// Main entry point - choose method based on format -export function splitContent( - html: string, - spineIndex: number, - spineItemId: string | null, - viewportHeight: number, - settings: { fontSize: number; lineHeight: number; marginWidth: number }, - useCFI: boolean = true, -): PageSplitResult { - if (useCFI && spineItemId) { - return splitByCFI(html, spineIndex, spineItemId, viewportHeight, settings); - } - return splitByHeight(html, spineIndex, viewportHeight, settings); -} diff --git a/web/src/reader/ebook/search.ts b/web/src/reader/ebook/search.ts index fe1077e..4a8ee24 100644 --- a/web/src/reader/ebook/search.ts +++ b/web/src/reader/ebook/search.ts @@ -1,7 +1,7 @@ // Search within ebook content // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +import { ReaderContext } from "../core/reader-context"; export function init(context: ReaderContext): void { let ebookData: any = null; @@ -139,4 +139,5 @@ function extractSnippet(text: string, offset: number, length: number): string { if (end < text.length) snippet = snippet + "..."; return snippet; -} \ No newline at end of file +} + diff --git a/web/src/reader/ebook/typography-engine.ts b/web/src/reader/ebook/typography-engine.ts deleted file mode 100644 index 4b93120..0000000 --- a/web/src/reader/ebook/typography-engine.ts +++ /dev/null @@ -1,162 +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 }; \ No newline at end of file diff --git a/web/src/reader/ebook/view-modes.ts b/web/src/reader/ebook/view-modes.ts index 3cdb0f7..52f4b5b 100644 --- a/web/src/reader/ebook/view-modes.ts +++ b/web/src/reader/ebook/view-modes.ts @@ -1,7 +1,7 @@ // Different viewing modes for ebooks // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +import { ReaderContext } from "../core/reader-context"; export function init(context: ReaderContext): void { let state: ViewModeState | null = null; diff --git a/web/src/reader/manga/reading-direction.ts b/web/src/reader/manga/reading-direction.ts deleted file mode 100644 index 6aaea6b..0000000 --- a/web/src/reader/manga/reading-direction.ts +++ /dev/null @@ -1,129 +0,0 @@ -// Detect reading direction from metadata or user preference -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -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: any, -): "ltr" | "rtl" | "vertical" { - const mangaType = (metadata as any).manga_type; - if (mangaType === "yes_and_right_to_left" || mangaType === "yes") { - return "rtl"; - } - - const readingDirection = (metadata as any).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"; -} \ No newline at end of file diff --git a/web/src/reader/manga/rtl-navigator.ts b/web/src/reader/manga/rtl-navigator.ts deleted file mode 100644 index 9da25bd..0000000 --- a/web/src/reader/manga/rtl-navigator.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Right-to-left navigation for manga -// Feature Registration Pattern implementation - -import type { 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; -} \ No newline at end of file diff --git a/web/src/reader/manga/settings.ts b/web/src/reader/manga/settings.ts deleted file mode 100644 index cbbd3b5..0000000 --- a/web/src/reader/manga/settings.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Manga-specific settings integration -// Feature Registration Pattern implementation - -import type { 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); -} \ No newline at end of file diff --git a/web/src/reader/manga/vertical-scroll-mode.ts b/web/src/reader/manga/vertical-scroll-mode.ts index d78632f..01eaa4d 100644 --- a/web/src/reader/manga/vertical-scroll-mode.ts +++ b/web/src/reader/manga/vertical-scroll-mode.ts @@ -1,25 +1,41 @@ // Vertical scroll mode for webtoons/manhwa // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +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( + "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: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.emit("vertical-scroll:current-page", { + page: currentPage, + }); } }); @@ -180,4 +196,5 @@ function destroyVerticalScroll(state: VerticalScrollState): void { state.container.innerHTML = ""; state.loadedPages.clear(); state.loadingPages.clear(); -} \ No newline at end of file +} + diff --git a/web/src/reader/pdf/annotation-layer.ts b/web/src/reader/pdf/annotation-layer.ts index 859f9ee..5b06569 100644 --- a/web/src/reader/pdf/annotation-layer.ts +++ b/web/src/reader/pdf/annotation-layer.ts @@ -1,25 +1,34 @@ // Annotation layer for rendering highlights and notes on PDFs // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +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: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:highlights:clear", + (detail: { container: HTMLElement }) => { + clearPDFHighlights(detail.container); + }, + ); - context.events.on("pdf:highlight:remove", (detail: { highlightId: string }) => { - removePDFHighlight(detail.highlightId, highlights); - }); + context.events.on( + "pdf:highlight:remove", + (detail: { highlightId: string }) => { + removePDFHighlight(detail.highlightId, highlights); + }, + ); context.events.on("reader:unload", () => { highlights.forEach((element) => element.remove()); @@ -90,7 +99,9 @@ function parseColor(color: string): string { function showNotePopup(highlight: PDFHighlight): void { console.log("Show note for highlight:", highlight.id); - const event = new CustomEvent("pdf:note-show", { detail: { highlightId: highlight.id } }); + const event = new CustomEvent("pdf:note-show", { + detail: { highlightId: highlight.id }, + }); window.dispatchEvent(event); } @@ -99,10 +110,14 @@ export function clearPDFHighlights(container: HTMLElement): void { Array.from(highlights).forEach((element) => element.remove()); } -export function removePDFHighlight(highlightId: string, highlights: Map): void { +export function removePDFHighlight( + highlightId: string, + highlights: Map, +): void { const element = highlights.get(highlightId); if (element) { element.remove(); highlights.delete(highlightId); } -} \ No newline at end of file +} + diff --git a/web/src/reader/pdf/page-cache.ts b/web/src/reader/pdf/page-cache.ts deleted file mode 100644 index 0d64e52..0000000 --- a/web/src/reader/pdf/page-cache.ts +++ /dev/null @@ -1,148 +0,0 @@ -// 5-page ahead cache for PDF pages -// Pre-renders canvas and text layer for nearby pages - -import { PDFPageProxy, PageViewport } from "pdfjs-dist"; - -interface CachedPage { - pageNumber: number; - canvas: HTMLCanvasElement; - textLayer: HTMLElement; - viewport: PageViewport; - timestamp: number; -} - -// 5-page ahead cache for PDF pages -// Procedural implementation (no OOP) - -interface CachedPage { - pageNumber: number; - canvas: HTMLCanvasElement; - textLayer: HTMLElement; - viewport: PageViewport; - timestamp: number; -} - -interface PDFPageCacheState { - cache: Map; - maxCacheSize: number; - renderCallbacks: Map void>>; -} - -function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState { - return { - cache: new Map(), - maxCacheSize, - renderCallbacks: new Map(), - }; -} - -async function getCachedPage( - state: PDFPageCacheState, - pageNumber: number, - renderFn: ( - pageNumber: number, - ) => Promise<{ - canvas: HTMLCanvasElement; - textLayer: HTMLElement; - viewport: PageViewport; - }>, -): Promise { - const cached = state.cache.get(pageNumber); - if (cached) { - cached.timestamp = Date.now(); - return { ...state, page: cached }; - } - - const { canvas, textLayer, viewport } = await renderFn(pageNumber); - - const cachedPage: CachedPage = { - pageNumber, - canvas, - textLayer, - viewport, - timestamp: Date.now(), - }; - - const newCache = new Map(state.cache); - newCache.set(pageNumber, cachedPage); - - const callbacks = state.renderCallbacks.get(pageNumber); - if (callbacks) { - callbacks.forEach((cb) => cb()); - const newCallbacks = new Map(state.renderCallbacks); - newCallbacks.delete(pageNumber); - return { - ...state, - cache: newCache, - renderCallbacks: newCallbacks, - page: cachedPage, - }; - } - - return { ...state, cache: newCache, page: cachedPage }; -} - -function preloadPages( - state: PDFPageCacheState, - currentPage: number, - totalPages: number, -): PDFPageCacheState { - for (let i = 1; i <= state.maxCacheSize; i++) { - const pageNumber = currentPage + i; - if (pageNumber <= totalPages && !state.cache.has(pageNumber)) { - triggerPreload(pageNumber); - } - } - - return state; -} - -function triggerPreload(pageNumber: number): void { - console.log("Preloading page:", pageNumber); -} - -function invalidatePage( - state: PDFPageCacheState, - pageNumber: number, -): PDFPageCacheState { - const cached = state.cache.get(pageNumber); - if (cached) { - cached.canvas.remove(); - cached.textLayer.remove(); - - const newCache = new Map(state.cache); - newCache.delete(pageNumber); - - return { ...state, cache: newCache }; - } - - return state; -} - -function clearPageCache(state: PDFPageCacheState): PDFPageCacheState { - state.cache.forEach((page) => { - page.canvas.remove(); - page.textLayer.remove(); - }); - - return { - ...state, - cache: new Map(), - }; -} - -function onPageRendered( - state: PDFPageCacheState, - pageNumber: number, - callback: () => void, -): PDFPageCacheState { - const newCallbacks = new Map(state.renderCallbacks); - - if (!newCallbacks.has(pageNumber)) { - newCallbacks.set(pageNumber, []); - } - - newCallbacks.get(pageNumber)!.push(callback); - - return { ...state, renderCallbacks: newCallbacks }; -} diff --git a/web/src/reader/pdf/pdf-bookmarks.ts b/web/src/reader/pdf/pdf-bookmarks.ts deleted file mode 100644 index 629811d..0000000 --- a/web/src/reader/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/pdf/pdf-clipbooard.ts b/web/src/reader/pdf/pdf-clipbooard.ts deleted file mode 100644 index 70fd1d6..0000000 --- a/web/src/reader/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/pdf/pdf-dual-page.ts b/web/src/reader/pdf/pdf-dual-page.ts deleted file mode 100644 index 3d8af1a..0000000 --- a/web/src/reader/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/pdf/pdf-links.ts b/web/src/reader/pdf/pdf-links.ts deleted file mode 100644 index 9099eb6..0000000 --- a/web/src/reader/pdf/pdf-links.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Handle internal PDF links (cross-references, citations, TOC links) -// External links open in new tab -// Procedural implementation (no OOP) - -interface PDFLink { - url: string; - pageNumber?: number; - bounds: { x: number; y: number; width: number; height: number }; -} - -interface PDFLinkHandlerState { - doc: PDFDocumentProxy | null; - container: HTMLElement; - onPageNavigate: (pageNumber: number) => void; -} - -async function initializePDFLinkHandler( - container: HTMLElement, - onPageNavigate: (pageNumber: number) => void, - doc: PDFDocumentProxy, -): Promise { - const state: PDFLinkHandlerState = { - doc, - container, - onPageNavigate, - }; - - await setupPDFLinks(state); - - return state; -} - -async function setupPDFLinks(state: PDFLinkHandlerState): Promise { - if (!state.doc) return; - - for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) { - const page = await state.doc.getPage(pageNum); - const annotations = await page.getAnnotations(); - - for (const annotation of annotations) { - if (annotation.subtype === "Link") { - createPDFLinkElement(state, annotation, pageNum); - } - } - } -} - -function createPDFLinkElement( - state: PDFLinkHandlerState, - annotation: any, - pageNumber: number, -): void { - const pageElement = state.container.querySelector( - `[data-page-number="${pageNumber}"]`, - ); - if (!pageElement) return; - - const link = document.createElement("a"); - link.className = "pdf-internal-link"; - link.href = "javascript:void(0)"; - - if (annotation.rect) { - const rect = annotation.rect; - link.style.position = "absolute"; - link.style.left = `${rect[0]}px`; - link.style.top = `${rect[1]}px`; - link.style.width = `${rect[2] - rect[0]}px`; - link.style.height = `${rect[3] - rect[1]}px`; - link.style.cursor = "pointer"; - } - - link.addEventListener("click", (e) => { - e.preventDefault(); - handlePDFLinkClick(state, annotation); - }); - - pageElement.appendChild(link); -} - -async function handlePDFLinkClick( - state: PDFLinkHandlerState, - annotation: any, -): Promise { - if (!state.doc) return; - - if (annotation.url) { - if ( - annotation.url.startsWith("http://") || - annotation.url.startsWith("https://") - ) { - window.open(annotation.url, "_blank", "noopener,noreferrer"); - } else { - console.warn("Unhandled URL:", annotation.url); - } - } else if (annotation.dest) { - const pageNumber = await resolvePDFLinkDestination(state, annotation.dest); - state.onPageNavigate(pageNumber); - } -} - -async function resolvePDFLinkDestination( - state: PDFLinkHandlerState, - dest: string | any[], -): Promise { - if (!state.doc) return 1; - - try { - let explicitDest: any[]; - - if (typeof dest === "string") { - const destObj = await state.doc.getDestination(dest); - if (!destObj) return 1; - explicitDest = destObj; - } else { - explicitDest = dest; - } - - const ref = explicitDest[0]; - - if (typeof ref === "object" && ref !== null) { - const pageIndex = await state.doc.getPageIndex(ref); - return pageIndex + 1; - } else if (typeof ref === "number") { - return ref + 1; - } - - return 1; - } catch (error) { - console.error("Failed to resolve link destination:", error); - return 1; - } -} diff --git a/web/src/reader/pdf/pdf-minimap.ts b/web/src/reader/pdf/pdf-minimap.ts deleted file mode 100644 index 1bbcc41..0000000 --- a/web/src/reader/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); - container.appendChild(miniMap); - - return { - miniMap, - currentPage: 1, - totalPages: 0, - thumbnails: new Map(), - onPageNavigate, - }; -} - -function createMiniMapElement(container: HTMLElement): HTMLElement { - const miniMap = document.createElement("div"); - miniMap.className = "pdf-minimap"; - miniMap.innerHTML = ` -
Pages
-
-
- `; - - const style = document.createElement("style"); - style.textContent = getMiniMapStyles(); - miniMap.appendChild(style); - - return miniMap; -} - -async function initializePDFMiniMap( - state: PDFMiniMapState, - totalPages: number, - renderThumbnail: (page: number) => Promise, -): Promise { - const newState = { ...state, totalPages }; - - await generateMiniMapThumbnails(newState, renderThumbnail); - setupMiniMapEventListeners(newState); - - return newState; -} - -async function generateMiniMapThumbnails( - state: PDFMiniMapState, - renderThumbnail: (page: number) => Promise, -): Promise { - const container = state.miniMap.querySelector( - ".pdf-minimap-thumbnails", - ) as HTMLElement; - container.innerHTML = ""; - - for (let page = 1; page <= state.totalPages; page++) { - try { - const thumbnail = await renderThumbnail(page); - thumbnail.className = "pdf-minimap-thumbnail"; - thumbnail.dataset.pageNumber = page.toString(); - thumbnail.style.width = "80px"; - thumbnail.style.height = "auto"; - thumbnail.style.cursor = "pointer"; - thumbnail.style.marginBottom = "4px"; - - container.appendChild(thumbnail); - state.thumbnails.set(page, thumbnail); - } catch (error) { - console.error(`Failed to generate thumbnail for page ${page}:`, error); - } - } -} - -function setupMiniMapEventListeners(state: PDFMiniMapState): void { - const container = state.miniMap.querySelector(".pdf-minimap-thumbnails"); - - container?.addEventListener("click", (e) => { - const thumbnail = (e.target as HTMLElement).closest( - ".pdf-minimap-thumbnail", - ) as HTMLElement; - if (thumbnail) { - const pageNumber = parseInt(thumbnail.dataset.pageNumber || "1"); - state.onPageNavigate(pageNumber); - } - }); -} - -function updateMiniMapCurrentPage( - state: PDFMiniMapState, - pageNumber: number, -): PDFMiniMapState { - const indicator = state.miniMap.querySelector( - ".pdf-minimap-indicator", - ) as HTMLElement; - const thumbnail = state.thumbnails.get(pageNumber); - - if (thumbnail && indicator) { - const rect = thumbnail.getBoundingClientRect(); - indicator.style.top = `${thumbnail.offsetTop}px`; - indicator.style.height = `${rect.height}px`; - } - - state.thumbnails.forEach((thumb, page) => { - if (page === pageNumber) { - thumb.style.outline = "2px solid var(--accent)"; - thumb.style.opacity = "1"; - } else { - thumb.style.outline = "none"; - thumb.style.opacity = "0.7"; - } - }); - - return { ...state, currentPage: pageNumber }; -} - -function showMiniMap(state: PDFMiniMapState): void { - state.miniMap.style.display = "block"; -} - -function hideMiniMap(state: PDFMiniMapState): void { - state.miniMap.style.display = "none"; -} - -function toggleMiniMap(state: PDFMiniMapState): void { - const isVisible = state.miniMap.style.display !== "none"; - state.miniMap.style.display = isVisible ? "none" : "block"; -} - -function getMiniMapStyles(): string { - return ` - .pdf-minimap { - position: fixed; - right: 20px; - top: 50%; - transform: translateY(-50%); - width: 100px; - max-height: 80vh; - background: var(--bg-primary); - border: 1px solid var(--text-secondary); - border-radius: 8px; - padding: 8px; - overflow-y: auto; - z-index: 1000; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - } - - .pdf-minimap-header { - font-size: 12px; - font-weight: bold; - text-align: center; - margin-bottom: 8px; - color: var(--text-primary); - } - - .pdf-minimap-thumbnails { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - } - - .pdf-minimap-thumbnail { - transition: outline 0.2s, opacity 0.2s; - border-radius: 2px; - } - - .pdf-minimap-thumbnail:hover { - opacity: 1 !important; - outline: 1px solid var(--text-secondary) !important; - } - - .pdf-minimap-indicator { - position: absolute; - left: 0; - right: 0; - border-left: 3px solid var(--accent); - pointer-events: none; - transition: top 0.3s ease-out; - } - `; -} diff --git a/web/src/reader/pdf/pdf-navigation.ts b/web/src/reader/pdf/pdf-navigation.ts index 84b5b87..a304bea 100644 --- a/web/src/reader/pdf/pdf-navigation.ts +++ b/web/src/reader/pdf/pdf-navigation.ts @@ -1,22 +1,27 @@ // PDF navigation: page turning, zoom, fit modes // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +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( + "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) { @@ -54,11 +59,14 @@ export function init(context: ReaderContext): void { } }); - 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( + "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; @@ -75,7 +83,11 @@ interface PDFNavigationState { scrollContainer: HTMLElement | null; } -function goToPDFPage(state: PDFNavigationState, pageNumber: number, context: ReaderContext): void { +function goToPDFPage( + state: PDFNavigationState, + pageNumber: number, + context: ReaderContext, +): void { if (pageNumber < 1 || pageNumber > state.totalPages) return; state.currentPage = pageNumber; @@ -89,7 +101,10 @@ function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void { } } -function previousPDFPage(state: PDFNavigationState, context: ReaderContext): void { +function previousPDFPage( + state: PDFNavigationState, + context: ReaderContext, +): void { if (state.currentPage > 1) { goToPDFPage(state, state.currentPage - 1, context); } @@ -106,14 +121,22 @@ function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void { } } -function setPDFZoom(state: PDFNavigationState, scale: number, context: ReaderContext): void { +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 { +function setPDFFitMode( + state: PDFNavigationState, + mode: PageFitMode, + context: ReaderContext, +): void { state.fitMode = mode; updatePDFZoom(state); context.events.emit("pdf:fit-changed", { mode }); @@ -137,7 +160,10 @@ function updatePDFZoom(state: PDFNavigationState): void { window.dispatchEvent(event); } -function setupPDFKeyboardNav(context: ReaderContext, state: PDFNavigationState): void { +function setupPDFKeyboardNav( + context: ReaderContext, + state: PDFNavigationState, +): void { document.addEventListener("keydown", (e) => { if (e.key === "ArrowRight" || e.key === "ArrowDown") { nextPDFPage(state, context); @@ -151,7 +177,10 @@ function setupPDFKeyboardNav(context: ReaderContext, state: PDFNavigationState): }); } -function setupPDFScrollTracking(context: ReaderContext, state: PDFNavigationState): void { +function setupPDFScrollTracking( + context: ReaderContext, + state: PDFNavigationState, +): void { if (!state.scrollContainer) return; state.scrollContainer.addEventListener("scroll", () => { @@ -182,4 +211,5 @@ function getCurrentPDFPage(state: PDFNavigationState): number { } return state.currentPage; -} \ No newline at end of file +} + diff --git a/web/src/reader/pdf/pdf-outline.ts b/web/src/reader/pdf/pdf-outline.ts deleted file mode 100644 index 90b6f50..0000000 --- a/web/src/reader/pdf/pdf-outline.ts +++ /dev/null @@ -1,184 +0,0 @@ -// PDF outline/TOC navigation -// Procedural implementation (no OOP) - -interface PDFOutlineNode { - id: string; - title: string; - destination: number | null; - pageNumber?: number; - children: PDFOutlineNode[]; - expanded: boolean; -} - -interface PDFOutlineState { - doc: PDFDocumentProxy | null; - outline: PDFOutlineNode[]; - flatMap: Map; -} - -async function initializePDFOutline( - doc: PDFDocumentProxy, -): Promise { - const state: PDFOutlineState = { - doc, - outline: [], - flatMap: new Map(), - }; - - return await loadPDFOutline(state); -} - -async function loadPDFOutline( - state: PDFOutlineState, -): Promise { - if (!state.doc) return state; - - const pdfOutline = await state.doc.getOutline(); - - if (!pdfOutline || pdfOutline.length === 0) { - return { ...state, outline: [] }; - } - - const outline = await parseOutlineNodes(state, pdfOutline); - - return { ...state, outline }; -} - -async function parseOutlineNodes( - state: PDFOutlineState, - nodes: OutlineTreeNode[], -): Promise { - const result: PDFOutlineNode[] = []; - - for (const node of nodes) { - const outlineNode: PDFOutlineNode = { - id: generateOutlineId(), - title: node.title, - destination: null, - children: [], - expanded: false, - }; - - if (node.dest) { - const pageNumber = await resolvePDFDestination(state, node.dest); - outlineNode.destination = pageNumber; - outlineNode.pageNumber = pageNumber; - state.flatMap.set(node.title, pageNumber); - } - - if (node.items && node.items.length > 0) { - outlineNode.children = await parseOutlineNodes(state, node.items); - } - - result.push(outlineNode); - } - - return result; -} - -async function resolvePDFDestination( - state: PDFOutlineState, - dest: string | any[], -): Promise { - if (!state.doc) return 1; - - try { - let explicitDest: any[]; - - if (typeof dest === "string") { - const destObj = await state.doc.getDestination(dest); - if (!destObj) return 1; - explicitDest = destObj; - } else { - explicitDest = dest; - } - - const ref = explicitDest[0]; - - if (typeof ref === "object" && ref !== null) { - const pageIndex = await state.doc.getPageIndex(ref); - return pageIndex + 1; - } else if (typeof ref === "number") { - return ref + 1; - } - - return 1; - } catch (error) { - console.error("Failed to resolve destination:", dest, error); - return 1; - } -} - -function generateOutlineId(): string { - return `outline-${Math.random().toString(36).substr(2, 9)}`; -} - -function getOutline(state: PDFOutlineState): PDFOutlineNode[] { - return state.outline; -} - -function getOutlineFlatMap(state: PDFOutlineState): Map { - return state.flatMap; -} - -function getCurrentChapter( - state: PDFOutlineState, - pageNumber: number, -): PDFOutlineNode | null { - return findChapterForPage(state.outline, pageNumber); -} - -function findChapterForPage( - nodes: PDFOutlineNode[], - pageNumber: number, -): PDFOutlineNode | null { - for (const node of nodes) { - if (node.pageNumber && node.pageNumber <= pageNumber) { - if (node.children.length > 0) { - const childMatch = findChapterForPage(node.children, pageNumber); - if (childMatch) return childMatch; - } - return node; - } - - if (node.children.length > 0) { - const childMatch = findChapterForPage(node.children, pageNumber); - if (childMatch) return childMatch; - } - } - - return null; -} - -function toggleOutlineNode( - state: PDFOutlineState, - nodeId: string, -): PDFOutlineState { - const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => { - return nodes.map((node) => { - if (node.id === nodeId) { - return { ...node, expanded: !node.expanded }; - } - if (node.children.length > 0) { - return { ...node, children: updateNode(node.children) }; - } - return node; - }); - }; - - return { ...state, outline: updateNode(state.outline) }; -} - -function findOutlineNode( - nodes: PDFOutlineNode[], - id: string, -): PDFOutlineNode | null { - for (const node of nodes) { - if (node.id === id) return node; - if (node.children.length > 0) { - const found = findOutlineNode(node.children, id); - if (found) return found; - } - } - return null; -} diff --git a/web/src/reader/pdf/pdf-page-sizes.ts b/web/src/reader/pdf/pdf-page-sizes.ts deleted file mode 100644 index 6e29ffe..0000000 --- a/web/src/reader/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/pdf/pdf-rotation.ts b/web/src/reader/pdf/pdf-rotation.ts deleted file mode 100644 index 6eaaad5..0000000 --- a/web/src/reader/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/pdf/pdf-search.ts b/web/src/reader/pdf/pdf-search.ts deleted file mode 100644 index 2df7dbd..0000000 --- a/web/src/reader/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/pdf/pdf-text-selection.ts b/web/src/reader/pdf/pdf-text-selection.ts index 21f4337..8533928 100644 --- a/web/src/reader/pdf/pdf-text-selection.ts +++ b/web/src/reader/pdf/pdf-text-selection.ts @@ -1,7 +1,7 @@ // PDF text selection - Uses backend API for highlight creation // Feature Registration Pattern implementation -import type { ReaderContext } from "../core/reader-context"; +import { ReaderContext } from "../core/reader-context"; export function init(context: ReaderContext): void { let currentMediaItemId: string | null = null; @@ -15,22 +15,32 @@ export function init(context: ReaderContext): void { 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: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( + "pdf:highlights:load", + async (detail: { container: HTMLElement }) => { + if (currentMediaItemId) { + await loadAndRenderPDFHighlights(currentMediaItemId, detail.container); + } + }, + ); context.events.on("reader:unload", () => { currentMediaItemId = null; @@ -52,8 +62,9 @@ export function getPDFTextSelection(): PDFTextSelection | null { if (!text) return null; - const pageElement = - range.commonAncestorContainer.parentElement?.closest?.("[data-page-number]") as HTMLElement; + const pageElement = range.commonAncestorContainer.parentElement?.closest?.( + "[data-page-number]", + ) as HTMLElement; const pageNumber = pageElement?.dataset.pageNumber ? parseInt(pageElement.dataset.pageNumber) : getCurrentPDFPage(); @@ -114,10 +125,7 @@ export async function loadAndRenderPDFHighlights( } } -function renderPDFHighlight( - container: HTMLElement, - highlight: any, -): void { +function renderPDFHighlight(container: HTMLElement, highlight: any): void { const overlay = document.createElement("div"); overlay.className = "pdf-highlight-annotation"; overlay.dataset.highlightId = highlight.id; @@ -150,5 +158,8 @@ function parseColor(color: string): string { function getCurrentPDFPage(): number { const pageElement = document.querySelector("[data-page-number]"); - return pageElement ? parseInt(pageElement.getAttribute("data-page-number") || "1") : 1; -} \ No newline at end of file + return pageElement + ? parseInt(pageElement.getAttribute("data-page-number") || "1") + : 1; +} + diff --git a/web/src/reader/pdf/pdfjs-wrapper.ts b/web/src/reader/pdf/pdfjs-wrapper.ts deleted file mode 100644 index 4da7202..0000000 --- a/web/src/reader/pdf/pdfjs-wrapper.ts +++ /dev/null @@ -1,151 +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"; - pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = "/static/standard_fonts/"; - pdfjsLib.GlobalWorkerOptions.cMapUrl = "/static/cmaps/"; - pdfjsLib.GlobalWorkerOptions.cMapPacked = true; -} - -// ============================================================ -// PDF Document State -// ============================================================ - -interface PDFDocumentState { - doc: pdfjsLib.PDFDocumentProxy | null; - pages: Map; - metadata: PDFMetadata | null; -} - -interface PDFMetadata { - title: string; - author: string; - subject?: string; - keywords?: string; - creator?: string; - producer?: string; - creationDate?: Date; - modificationDate?: Date; - pageCount: number; -} - -let pdfState: PDFDocumentState = { - doc: null, - pages: new Map(), - metadata: null, -}; - -// ============================================================ -// Document Loading -// ============================================================ - -export async function loadPDFDocument(pdfBlob: Blob): Promise { - // Cleanup previous document - unloadPDFDocument(); - - const arrayBuffer = await pdfBlob.arrayBuffer(); - const loadingTask = pdfjsLib.getDocument({ - data: arrayBuffer, - }); - - pdfState.doc = await loadingTask.promise; - - // Extract metadata - const metadata = await pdfState.doc.getMetadata().catch(() => null); - const info = metadata?.info || {}; - - pdfState.metadata = { - title: info.Title || "Untitled", - author: info.Author || "Unknown", - subject: info.Subject, - keywords: info.Keywords, - creator: info.Creator, - producer: info.Producer, - creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined, - modificationDate: info.ModDate ? new Date(info.ModDate) : undefined, - pageCount: pdfState.doc.numPages, - }; - - return pdfState.metadata; -} - -export async function getPDFPage( - pageNumber: number, -): Promise { - if (!pdfState.doc) { - throw new Error("PDF document not loaded"); - } - - // Check cache - if (pdfState.pages.has(pageNumber)) { - return pdfState.pages.get(pageNumber)!; - } - - // Load page - const page = await pdfState.doc.getPage(pageNumber); - pdfState.pages.set(pageNumber, page); - - return page; -} - -// ============================================================ -// Reader Initialization -// ============================================================ -interface ReaderMetadata { - media_item_id: string; - title: string; - author: string; - cover_image_path: string; - library_type: "ebook" | "comic" | "manga" | "pdf"; - mime_type: string; - file_path: string; - total_pages?: number; -} -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(); - const pdfMetadata = 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/pdf/text-layer-renderer.ts b/web/src/reader/pdf/text-layer-renderer.ts deleted file mode 100644 index 6d93014..0000000 --- a/web/src/reader/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; -}