diff --git a/web/src/reader/comic/background-color.ts b/web/src/reader/comic/background-color.ts deleted file mode 100644 index 57f810d..0000000 --- a/web/src/reader/comic/background-color.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Background color options for manga/comics -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - const state = createBackgroundColorState(); - applyBackgroundColor(state.current); - - context.events.on( - "background-color:set", - (detail: { color: BackgroundColor; customColor?: string }) => { - setBackgroundColor(state, detail.color, detail.customColor); - }, - ); - - context.events.on("background-color:toggle", () => { - toggleBackgroundColor(state); - }); - - context.events.on( - "ui:show-settings", - (detail: { container: HTMLElement }) => { - renderBackgroundColorPicker(detail.container, state); - }, - ); - - context.events.on("reader:unload", () => { - const picker = document.querySelector(".background-color-picker"); - picker?.remove(); - }); -} - -type BackgroundColor = "black" | "white" | "gray" | "sepia" | "custom"; - -interface BackgroundColorState { - current: BackgroundColor; - customColor: string; -} - -const backgroundColors: Record = { - black: "#000000", - white: "#ffffff", - gray: "#333333", - sepia: "#f4ecd8", - custom: "", -}; - -function createBackgroundColorState( - initial: BackgroundColor = "black", -): BackgroundColorState { - const saved = localStorage.getItem( - "reader-background-color", - ) as BackgroundColor; - return { - current: saved || initial, - customColor: "#000000", - }; -} - -function applyBackgroundColor(color: BackgroundColor): void { - const bgColor = backgroundColors[color]; - document.documentElement.style.setProperty("--reader-bg-color", bgColor); - const viewer = document.querySelector(".reader-content") as HTMLElement; - if (viewer) { - viewer.style.backgroundColor = bgColor; - } -} - -function setBackgroundColor( - state: BackgroundColorState, - color: BackgroundColor, - customColor?: string, -): BackgroundColorState { - state.current = color; - state.customColor = customColor || state.customColor; - - const bgColor = - color === "custom" ? state.customColor : backgroundColors[color]; - document.documentElement.style.setProperty("--reader-bg-color", bgColor); - - const viewer = document.querySelector(".reader-content") as HTMLElement; - if (viewer) { - viewer.style.backgroundColor = bgColor; - } - - localStorage.setItem("reader-background-color", color); - - const picker = document.querySelector(".background-color-picker"); - if (picker) { - updateBackgroundColorUI(picker as HTMLElement, state); - } - - return state; -} - -function toggleBackgroundColor( - state: BackgroundColorState, -): BackgroundColorState { - const order: BackgroundColor[] = ["black", "white", "gray", "sepia"]; - const currentIndex = order.indexOf(state.current); - const nextIndex = (currentIndex + 1) % order.length; - return setBackgroundColor(state, order[nextIndex]); -} - -function renderBackgroundColorPicker( - container: HTMLElement, - state: BackgroundColorState, -): void { - const existing = container.querySelector(".background-color-picker"); - existing?.remove(); - - const picker = document.createElement("div"); - picker.className = - "background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40"; - - const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"]; - - colors.forEach((color) => { - const btn = document.createElement("button"); - btn.className = `w-8 h-8 rounded-full border-2 ${ - state.current === color ? "border-blue-500" : "border-transparent" - }`; - btn.style.backgroundColor = backgroundColors[color]; - btn.title = color.charAt(0).toUpperCase() + color.slice(1); - btn.addEventListener("click", () => { - setBackgroundColor(state, color); - updateBackgroundColorUI(picker, state); - }); - picker.appendChild(btn); - }); - - container.appendChild(picker); -} - -function updateBackgroundColorUI( - container: HTMLElement, - state: BackgroundColorState, -): void { - const buttons = container.querySelectorAll("button"); - const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"]; - - buttons.forEach((btn, index) => { - btn.classList.toggle("border-blue-500", colors[index] === state.current); - }); -} - diff --git a/web/src/reader/comic/chapter-markers.ts b/web/src/reader/comic/chapter-markers.ts deleted file mode 100644 index 1e1678d..0000000 --- a/web/src/reader/comic/chapter-markers.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Chapter markers for manga/comics -// Visual indicators for chapter boundaries -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - let state: ChapterMarkerState | null = null; - - context.events.on( - "reader:loaded", - (detail: { chapters: ChapterInfo[]; currentPage: number }) => { - state = createChapterMarkerState(detail.chapters, detail.currentPage); - renderChapterMarkers(context.elements.readerContent, state); - }, - ); - - context.events.on("page-changed", (detail: { page: number }) => { - if (state) { - updateCurrentChapter(state, detail.page); - } - }); - - context.events.on("chapter-markers:toggle", () => { - if (state) { - toggleChapterMarkers(state); - } - }); - - context.events.on( - "chapter-markers:navigate", - (detail: { chapterNumber: number }) => { - if (state) { - scrollToChapter(state, detail.chapterNumber); - } - }, - ); - - context.events.on("reader:unload", () => { - const markers = document.querySelector(".chapter-markers"); - markers?.remove(); - }); -} - -interface ChapterInfo { - chapterNumber: number; - pageStart: number; - pageEnd: number; - title?: string; -} - -interface ChapterMarkerState { - chapters: ChapterInfo[]; - currentChapter: number; - showMarkers: boolean; -} - -function createChapterMarkerState( - chapters: ChapterInfo[], - currentPage: number, -): ChapterMarkerState { - const currentChapter = - chapters.find((c) => currentPage >= c.pageStart && currentPage <= c.pageEnd) - ?.chapterNumber || 1; - - return { - chapters, - currentChapter, - showMarkers: true, - }; -} - -function renderChapterMarkers( - container: HTMLElement, - state: ChapterMarkerState, -): void { - if (!state.showMarkers) return; - - const markersContainer = document.createElement("div"); - markersContainer.className = - "chapter-markers absolute left-0 right-0 pointer-events-none z-10"; - - state.chapters.forEach((chapter) => { - const marker = document.createElement("div"); - marker.className = - "chapter-marker flex items-center gap-2 text-sm text-gray-400"; - - const isCurrentChapter = chapter.chapterNumber === state.currentChapter; - - marker.style.position = "absolute"; - marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`; - marker.style.left = "10px"; - - marker.innerHTML = ` - - ${chapter.title || `Chapter ${chapter.chapterNumber}`} - - p.${chapter.pageStart} - ${isCurrentChapter ? '' : ""} - `; - - markersContainer.appendChild(marker); - }); - - const existing = container.querySelector(".chapter-markers"); - existing?.remove(); - container.appendChild(markersContainer); -} - -function updateCurrentChapter( - state: ChapterMarkerState, - currentPage: number, -): ChapterMarkerState { - const currentChapter = - state.chapters.find( - (c) => currentPage >= c.pageStart && currentPage <= c.pageEnd, - )?.chapterNumber || state.currentChapter; - - if (currentChapter !== state.currentChapter) { - state.currentChapter = currentChapter; - - const markers = document.querySelector(".chapter-markers"); - if (markers) { - renderChapterMarkers(markers.parentElement!, state); - } - } - - return state; -} - -function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState { - state.showMarkers = !state.showMarkers; - - const markers = document.querySelector(".chapter-markers"); - if (markers) { - markers.classList.toggle("hidden", !state.showMarkers); - } - - return state; -} - -function scrollToChapter( - state: ChapterMarkerState, - chapterNumber: number, -): void { - const chapter = state.chapters.find((c) => c.chapterNumber === chapterNumber); - if (chapter) { - window.dispatchEvent( - new CustomEvent("navigate-to-page", { - detail: { page: chapter.pageStart }, - }), - ); - } -} - diff --git a/web/src/reader/comic/page-order.ts b/web/src/reader/comic/page-order.ts deleted file mode 100644 index b26bbb7..0000000 --- a/web/src/reader/comic/page-order.ts +++ /dev/null @@ -1,152 +0,0 @@ -// Page order presets for manga/comics -// Auto-detect Japanese vs Western reading order -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - let state: PageOrderState | null = null; - - context.events.on( - "reader:loaded", - (detail: { totalPages: number; pageNames: string[] }) => { - state = createPageOrderState(detail.totalPages, detail.pageNames); - }, - ); - - context.events.on("page-order:set", (detail: { mode: PageOrderMode }) => { - if (state) { - setPageOrderMode(state, detail.mode, context); - } - }); - - context.events.on("page-order:get", () => { - if (state) { - const order = getPageOrder(state); - context.events.emit("page-order:current", { order }); - } - }); - - context.events.on( - "page-order:reorder", - (detail: { pageNumbers: number[] }) => { - if (state) { - const reordered = reorderPages(state, detail.pageNumbers); - context.events.emit("page-order:reordered", { pages: reordered }); - } - }, - ); - - context.events.on( - "page-order:display-number", - (detail: { actualPage: number }) => { - if (state) { - const displayPage = getDisplayPageNumber(state, detail.actualPage); - context.events.emit("page-order:display-page", { displayPage }); - } - }, - ); -} - -type PageOrderMode = "auto" | "japanese" | "western"; - -interface PageOrderConfig { - mode: PageOrderMode; - detectedOrder: PageOrderMode; - userOverride: boolean; -} - -interface PageOrderState { - config: PageOrderConfig; - totalPages: number; -} - -function detectPageOrder(pageNames: string[]): PageOrderMode { - if (pageNames.length < 2) return "western"; - - const firstPage = pageNames[0].toLowerCase(); - const lastPage = pageNames[pageNames.length - 1].toLowerCase(); - - const hasFrontCover = /cover|front|001/.test(firstPage); - const hasBackCover = /back|end|最后的/.test(lastPage); - - if (hasFrontCover && !hasBackCover) { - return "western"; - } - if (hasBackCover && !hasFrontCover) { - return "japanese"; - } - - const chapterMatches = pageNames.filter((n) => /ch-\d+|chapter/i.test(n)); - if (chapterMatches.length > 0) { - const firstChapter = chapterMatches[0]; - const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || "0"); - return pageNum > 0 ? "western" : "japanese"; - } - - return "western"; -} - -function createPageOrderState( - totalPages: number, - pageNames: string[], -): PageOrderState { - const detectedOrder = detectPageOrder(pageNames); - - return { - config: { - mode: "auto", - detectedOrder, - userOverride: false, - }, - totalPages, - }; -} - -function setPageOrderMode( - state: PageOrderState, - mode: PageOrderMode, - context: ReaderContext, -): PageOrderState { - state.config = { - ...state.config, - mode, - userOverride: mode !== "auto", - }; - - const order = getPageOrder(state); - context.events.emit("page-order:changed", { mode, order }); - - return state; -} - -function getPageOrder(state: PageOrderState): PageOrderMode { - if (state.config.mode === "auto") { - return state.config.detectedOrder; - } - return state.config.mode; -} - -function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] { - const order = getPageOrder(state); - - if (order === "japanese") { - return [...pageNumbers].reverse(); - } - - return pageNumbers; -} - -function getDisplayPageNumber( - state: PageOrderState, - actualPage: number, -): number { - const order = getPageOrder(state); - - if (order === "japanese") { - return state.totalPages - actualPage + 1; - } - - return actualPage; -} - diff --git a/web/src/reader/comic/panel-gap.ts b/web/src/reader/comic/panel-gap.ts deleted file mode 100644 index 537cac4..0000000 --- a/web/src/reader/comic/panel-gap.ts +++ /dev/null @@ -1,160 +0,0 @@ -// Adjustable panel gap controls -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - const state = createPanelGapState(); - applyPanelGap(state.gapSize, state.showBorders); - - context.events.on("panel-gap:set", (detail: { gap: number }) => { - setPanelGap(state, detail.gap); - }); - - context.events.on("panel-gap:increase", (detail?: { amount: number }) => { - increasePanelGap(state, detail?.amount); - }); - - context.events.on("panel-gap:decrease", (detail?: { amount: number }) => { - decreasePanelGap(state, detail?.amount); - }); - - context.events.on("panel-gap:borders:toggle", () => { - togglePanelBorders(state); - }); - - context.events.on( - "ui:show-settings", - (detail: { container: HTMLElement }) => { - renderPanelGapControls(detail.container, state); - }, - ); - - context.events.on("reader:unload", () => { - const controls = document.querySelector(".panel-gap-controls"); - controls?.remove(); - }); -} - -interface PanelGapState { - gapSize: number; - showBorders: boolean; -} - -function createPanelGapState(initialGap: number = 4): PanelGapState { - const saved = localStorage.getItem("reader-panel-gap"); - return { - gapSize: saved ? parseInt(saved) : initialGap, - showBorders: false, - }; -} - -function applyPanelGap(gap: number, showBorders: boolean): void { - document.documentElement.style.setProperty("--panel-gap", `${gap}px`); - document.documentElement.style.setProperty( - "--panel-border-width", - showBorders ? "1px" : "0px", - ); - localStorage.setItem("reader-panel-gap", String(gap)); -} - -function setPanelGap(state: PanelGapState, gap: number): PanelGapState { - const clampedGap = Math.max(0, Math.min(20, gap)); - state.gapSize = clampedGap; - - document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`); - localStorage.setItem("reader-panel-gap", String(clampedGap)); - - const controls = document.querySelector(".panel-gap-controls"); - if (controls) { - updatePanelGapUI(controls as HTMLElement, state); - } - - return state; -} - -function increasePanelGap( - state: PanelGapState, - amount: number = 2, -): PanelGapState { - return setPanelGap(state, state.gapSize + amount); -} - -function decreasePanelGap( - state: PanelGapState, - amount: number = 2, -): PanelGapState { - return setPanelGap(state, state.gapSize - amount); -} - -function togglePanelBorders(state: PanelGapState): PanelGapState { - state.showBorders = !state.showBorders; - - document.documentElement.style.setProperty( - "--panel-border-width", - state.showBorders ? "1px" : "0px", - ); - - const controls = document.querySelector(".panel-gap-controls"); - if (controls) { - updatePanelGapUI(controls as HTMLElement, state); - } - - return state; -} - -function renderPanelGapControls( - container: HTMLElement, - state: PanelGapState, -): void { - const existing = container.querySelector(".panel-gap-controls"); - existing?.remove(); - - const controls = document.createElement("div"); - controls.className = - "panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40"; - controls.innerHTML = ` - - ${state.gapSize}px - - - `; - - controls - .querySelector(".panel-gap-increase") - ?.addEventListener("click", () => { - increasePanelGap(state); - updatePanelGapUI(controls, state); - }); - - controls - .querySelector(".panel-gap-decrease") - ?.addEventListener("click", () => { - decreasePanelGap(state); - updatePanelGapUI(controls, state); - }); - - controls - .querySelector(".panel-gap-borders") - ?.addEventListener("click", () => { - togglePanelBorders(state); - updatePanelGapUI(controls, state); - }); - - container.appendChild(controls); -} - -function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void { - const gapLabel = container.querySelector("span"); - if (gapLabel) { - gapLabel.textContent = `${state.gapSize}px`; - } - - const bordersBtn = container.querySelector(".panel-gap-borders"); - if (bordersBtn) { - bordersBtn.textContent = state.showBorders ? "▦" : "▢"; - } -} - diff --git a/web/src/reader/ebook/font-loader.ts b/web/src/reader/ebook/font-loader.ts deleted file mode 100644 index f680ffa..0000000 --- a/web/src/reader/ebook/font-loader.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Font loading with performance optimization -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - const userPreferredFont = localStorage.getItem("reader-font") || "literata"; - - context.events.on("reader:loaded", async () => { - await preloadFonts(userPreferredFont); - }); - - context.events.on("font:change", async (detail: { fontId: string }) => { - const stack = getFontStack(detail.fontId); - applyFontStack(stack); - await preloadFonts(detail.fontId); - }); - - context.events.on("font:get-stack", (detail: { fontId: string }) => { - const stack = getFontStack(detail.fontId); - context.events.emit("font:stack-ready", { stack }); - }); -} - -const READING_FONTS = [ - { - id: "literata", - name: "Literata", - stack: "Literata, serif", - description: "Designed for Google Play Books", - }, - { - id: "crimson", - name: "Crimson Text", - stack: "Crimson Text, serif", - description: "Optimized for screen reading", - }, - { - id: "source-serif", - name: "Source Serif 4", - stack: "Source Serif 4, serif", - description: "Professional Adobe quality", - }, - { - id: "eb-garamond", - name: "EB Garamond", - stack: "EB Garamond, serif", - description: "Classic elegance", - }, - { - id: "libertinus", - name: "Libertinus Serif", - stack: "Libertinus Serif, serif", - description: "Excellent for technical content", - }, - { - id: "noto-serif", - name: "Noto Serif", - stack: "Noto Serif, serif", - description: "Maximum language support", - }, - { - id: "charis-sil", - name: "Charis SIL", - stack: "Charis SIL, serif", - description: "Multilingual specialist", - }, - { - id: "ibm-plex", - name: "IBM Plex Serif", - stack: "IBM Plex Serif, serif", - description: "Modern & versatile", - }, -]; - -async function preloadFonts(userPreferredFont: string): Promise { - const fontsToPreload = new Set(["literata", userPreferredFont]); - - for (const fontId of fontsToPreload) { - const font = READING_FONTS.find((f) => f.id === fontId); - if (font) { - document.fonts.load(`16px "${font.stack}"`); - } - } -} - -function getFontStack(fontId: string): string { - const font = READING_FONTS.find((f) => f.id === fontId); - return font?.stack || "Literata, serif"; -} - -function applyFontStack(stack: string): void { - document.documentElement.style.setProperty("--reader-font-family", stack); -} - -export { READING_FONTS, preloadFonts, getFontStack }; - diff --git a/web/src/reader/ebook/search.ts b/web/src/reader/ebook/search.ts deleted file mode 100644 index 4a8ee24..0000000 --- a/web/src/reader/ebook/search.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Search within ebook content -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - let ebookData: any = null; - - context.events.on("reader:loaded", (detail: { ebookData: any }) => { - ebookData = detail.ebookData; - }); - - context.events.on("search:execute", async (detail: { query: string }) => { - if (ebookData) { - const results = await searchEbook(ebookData, detail.query); - context.events.emit("search:results", { results }); - } - }); - - context.events.on("reader:unload", () => { - ebookData = null; - }); -} - -interface SearchResult { - cfi: string; - snippet: string; - chapterTitle: string; -} - -export async function searchEbook( - ebookData: any, - query: string, -): Promise { - const results: SearchResult[] = []; - const lowerQuery = query.toLowerCase(); - - if (!ebookData.spine) return results; - - for (const spineItem of ebookData.spine) { - const doc = await getSpineItemDocument(ebookData, spineItem); - - if (!doc) continue; - - const chapterTitle = getChapterTitle(spineItem); - const textNodes = findTextNodes(doc.body); - - for (const node of textNodes) { - const text = node.textContent || ""; - const lowerText = text.toLowerCase(); - - let foundAt = 0; - while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) { - const cfi = generateCFIForNode(node, foundAt); - const snippet = extractSnippet(text, foundAt, query.length); - - results.push({ - cfi, - snippet, - chapterTitle, - }); - - foundAt += lowerQuery.length; - } - } - } - - return results; -} - -async function getSpineItemDocument( - ebookData: any, - spineItem: any, -): Promise { - try { - const resources = ebookData.resources; - if (!resources) return null; - - const content = await resources.get(spineItem.href)?.text(); - if (!content) return null; - - const parser = new DOMParser(); - return parser.parseFromString(content, "text/html"); - } catch (error) { - console.error("Failed to load spine item:", spineItem.href, error); - return null; - } -} - -function getChapterTitle(spineItem: any): string { - return spineItem.id || `Section ${spineItem.index || ""}`; -} - -function findTextNodes(root: Node): Text[] { - const textNodes: Text[] = []; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { - acceptNode: (node) => { - const parent = node.parentElement; - if (parent && ["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) { - return NodeFilter.FILTER_REJECT; - } - - if (!node.textContent?.trim()) { - return NodeFilter.FILTER_REJECT; - } - - return NodeFilter.FILTER_ACCEPT; - }, - }); - - let node: Node | null; - while ((node = walker.nextNode())) { - textNodes.push(node as Text); - } - - return textNodes; -} - -function generateCFIForNode(node: Node, offset: number): string { - const path: number[] = []; - let current: Node | null = node; - - while (current && current.parentNode) { - const siblings = Array.from(current.parentNode.childNodes); - const index = siblings.indexOf(current as ChildNode); - path.unshift(index); - current = current.parentNode; - } - - return `/6/4${path.map((i) => `/${i + 2}`).join("")}:${offset}`; -} - -function extractSnippet(text: string, offset: number, length: number): string { - const start = Math.max(0, offset - 40); - const end = Math.min(text.length, offset + length + 40); - let snippet = text.substring(start, end); - - if (start > 0) snippet = "..." + snippet; - if (end < text.length) snippet = snippet + "..."; - - return snippet; -} - diff --git a/web/src/reader/ebook/view-modes.ts b/web/src/reader/ebook/view-modes.ts deleted file mode 100644 index 52f4b5b..0000000 --- a/web/src/reader/ebook/view-modes.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Different viewing modes for ebooks -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - let state: ViewModeState | null = null; - - context.events.on("reader:loaded", (detail: { container: HTMLElement }) => { - state = { currentMode: "paginated", currentPage: 1 }; - setViewMode(detail.container, "paginated"); - }); - - context.events.on("view-mode:set", (detail: { mode: ViewMode }) => { - if (state && context.elements.readerContent) { - state.currentMode = detail.mode; - setViewMode(context.elements.readerContent, detail.mode); - context.events.emit("view-mode:changed", { mode: detail.mode }); - } - }); - - context.events.on("view-mode:get", () => { - if (state) { - context.events.emit("view-mode:current", state); - } - }); - - context.events.on("pagination:go-to-page", (detail: { page: number }) => { - if (state && context.elements.readerContent) { - state.currentPage = detail.page; - goToPage(context.elements.readerContent, detail.page); - context.events.emit("pagination:page-changed", { page: detail.page }); - } - }); -} - -type ViewMode = "paginated" | "scrolled" | "single-column" | "double-column"; - -interface ViewModeState { - currentMode: ViewMode; - currentPage: number; -} - -function setViewMode(container: HTMLElement, mode: ViewMode): void { - const content = container.querySelector(".ebook-content"); - if (!content) return; - - const element = content as HTMLElement; - - element.classList.remove( - "paginated", - "scrolled", - "single-column", - "double-column", - ); - - switch (mode) { - case "paginated": - applyPaginatedMode(element); - break; - case "scrolled": - applyScrolledMode(element); - break; - case "single-column": - applySingleColumn(element); - break; - case "double-column": - applyDoubleColumn(element); - break; - } -} - -function applyPaginatedMode(element: HTMLElement): void { - element.classList.add("paginated"); - // Set up for CSS columns - element.style.height = "calc(100vh - 120px)"; - element.style.overflowY = "auto"; - element.style.overflowX = "hidden"; - element.style.columnCount = "1"; - element.style.columnFill = "auto"; - element.style.columnGap = "0"; - element.style.position = "relative"; - // Inject CSS for CSS column pagination - injectPaginatedStyles(); -} - -function injectPaginatedStyles(): void { - // Remove existing injected styles if any - const existing = document.getElementById("paginated-styles"); - existing?.remove(); - - const style = document.createElement("style"); - style.id = "paginated-styles"; - style.textContent = ` - .paginated { - height: calc(100vh - 120px) !important; - overflow-y: auto !important; - overflow-x: hidden !important; - column-fill: auto !important; - column-count: 1 !important; - column-gap: 0 !important; - position: relative !important; - } - .paginated > * { - max-width: 100%; - column-fill: auto; - } - .paginated .ebook-content { - height: auto !important; - min-height: 100%; - } - `; - document.head.appendChild(style); -} - -function applyScrolledMode(element: HTMLElement): void { - element.classList.add("scrolled"); - - element.style.height = "auto"; - element.style.overflowY = "auto"; - element.style.columnCount = "1"; -} - -function applySingleColumn(element: HTMLElement): void { - element.classList.add("single-column"); - - element.style.columnCount = "1"; - element.style.columnGap = "0"; - element.style.maxWidth = "800px"; - element.style.margin = "0 auto"; -} - -function applyDoubleColumn(element: HTMLElement): void { - element.classList.add("double-column"); - - element.style.columnCount = "2"; - element.style.columnGap = "60px"; - element.style.columnRule = "1px solid var(--text-secondary)"; - element.style.maxWidth = "1400px"; - element.style.margin = "0 auto"; -} - -function goToPage(container: HTMLElement, pageNumber: number): void { - const content = container.querySelector(".ebook-content") as HTMLElement; - if (!content) return; - const totalPages = getTotalPageCount(container); - const targetPage = Math.min(Math.max(1, pageNumber), totalPages); - const viewportHeight = window.innerHeight - 120; - const scrollTop = (targetPage - 1) * viewportHeight; - content.scrollTo({ - top: scrollTop, - behavior: "smooth", - }); -} - -function getTotalPageCount(container: HTMLElement): number { - const content = container.querySelector(".ebook-content") as HTMLElement; - if (!content) return 1; - const viewportHeight = window.innerHeight - 120; - const contentHeight = content.scrollHeight; - return Math.max(1, Math.ceil(contentHeight / viewportHeight)); -} - -export function getCurrentViewMode(container: HTMLElement): ViewMode { - const content = container.querySelector(".ebook-content") as HTMLElement; - if (!content) return "paginated"; - - if (content.classList.contains("paginated")) return "paginated"; - if (content.classList.contains("scrolled")) return "scrolled"; - if (content.classList.contains("single-column")) return "single-column"; - if (content.classList.contains("double-column")) return "double-column"; - - return "paginated"; -} diff --git a/web/src/reader/manga/vertical-scroll-mode.ts b/web/src/reader/manga/vertical-scroll-mode.ts deleted file mode 100644 index 01eaa4d..0000000 --- a/web/src/reader/manga/vertical-scroll-mode.ts +++ /dev/null @@ -1,200 +0,0 @@ -// Vertical scroll mode for webtoons/manhwa -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - let state: VerticalScrollState | null = null; - - context.events.on( - "reader:loaded", - (detail: { - container: HTMLElement; - mediaItemId: string; - totalPages: number; - }) => { - state = createVerticalScroll( - detail.container, - detail.mediaItemId, - detail.totalPages, - ); - }, - ); - - context.events.on( - "vertical-scroll:load-page", - async (detail: { pageNumber: number }) => { - if (state) { - await loadPage(state, detail.pageNumber); - } - }, - ); - - context.events.on("vertical-scroll:get-current", () => { - if (state) { - const currentPage = getCurrentPageFromScroll(state); - context.events.emit("vertical-scroll:current-page", { - page: currentPage, - }); - } - }); - - context.events.on("reader:unload", () => { - if (state) { - destroyVerticalScroll(state); - state = null; - } - }); -} - -interface VerticalScrollState { - container: HTMLElement; - loadedPages: Set; - loadingPages: Set; - currentPage: number; - totalPages: number; - threshold: number; - mediaItemId: string; -} - -function createVerticalScroll( - container: HTMLElement, - mediaItemId: string, - totalPages: number, -): VerticalScrollState { - const state: VerticalScrollState = { - container, - loadedPages: new Set(), - loadingPages: new Set(), - currentPage: 1, - totalPages, - threshold: 500, - mediaItemId, - }; - - loadPage(state, 1); - setupScrollListener(state); - - return state; -} - -async function loadPage( - state: VerticalScrollState, - pageNumber: number, -): Promise { - if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) { - return; - } - - state.loadingPages.add(pageNumber); - - try { - const token = localStorage.getItem("token"); - const response = await fetch( - `/readers/${state.mediaItemId}/pages/${pageNumber}`, - { - headers: { Authorization: `Bearer ${token}` }, - }, - ); - - if (!response.ok) { - throw new Error(`Failed to load page ${pageNumber}`); - } - - const blob = await response.blob(); - const imgUrl = URL.createObjectURL(blob); - - const pageContainer = document.createElement("div"); - pageContainer.className = "vertical-page"; - pageContainer.dataset.pageNumber = pageNumber.toString(); - - const img = document.createElement("img"); - img.src = imgUrl; - img.alt = `Page ${pageNumber}`; - img.loading = "lazy"; - - pageContainer.appendChild(img); - state.container.appendChild(pageContainer); - - state.loadedPages.add(pageNumber); - state.loadingPages.delete(pageNumber); - - if (pageNumber < state.totalPages) { - loadPage(state, pageNumber + 1); - if (pageNumber + 1 < state.totalPages) { - loadPage(state, pageNumber + 2); - } - } - } catch (error) { - console.error(`Failed to load page ${pageNumber}:`, error); - state.loadingPages.delete(pageNumber); - } -} - -function setupScrollListener(state: VerticalScrollState): void { - let scrollTimeout: number | undefined; - - state.container.addEventListener("scroll", () => { - clearTimeout(scrollTimeout); - scrollTimeout = window.setTimeout(() => { - checkScrollPosition(state); - }, 100); - }); -} - -function checkScrollPosition(state: VerticalScrollState): void { - const scrollBottom = - state.container.scrollHeight - - state.container.scrollTop - - state.container.clientHeight; - - if (scrollBottom < state.threshold) { - const lastPage = Math.max(...state.loadedPages); - if (lastPage < state.totalPages) { - loadPage(state, lastPage + 1); - } - } - - const currentPage = getCurrentPageFromScroll(state); - if (currentPage !== state.currentPage) { - state.currentPage = currentPage; - window.dispatchEvent( - new CustomEvent("page-change", { - detail: { page: currentPage }, - }), - ); - } -} - -function getCurrentPageFromScroll(state: VerticalScrollState): number { - const pages = Array.from(state.container.querySelectorAll(".vertical-page")); - - for (const page of pages) { - const rect = page.getBoundingClientRect(); - const containerRect = state.container.getBoundingClientRect(); - - const pageMiddle = rect.top + rect.height / 2; - const viewportMiddle = containerRect.top + containerRect.height / 2; - - if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) { - return parseInt((page as HTMLElement).dataset.pageNumber || "1"); - } - } - - return state.currentPage; -} - -function destroyVerticalScroll(state: VerticalScrollState): void { - const images = Array.from(state.container.querySelectorAll("img")); - images.forEach((img) => { - const url = img.src; - if (url.startsWith("blob:")) { - URL.revokeObjectURL(url); - } - }); - - state.container.innerHTML = ""; - state.loadedPages.clear(); - state.loadingPages.clear(); -} - diff --git a/web/src/reader/pdf/annotation-layer.ts b/web/src/reader/pdf/annotation-layer.ts deleted file mode 100644 index 5b06569..0000000 --- a/web/src/reader/pdf/annotation-layer.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Annotation layer for rendering highlights and notes on PDFs -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - const highlights = new Map(); - - context.events.on( - "pdf:highlights:render", - (detail: { container: HTMLElement; highlights: any[] }) => { - clearPDFHighlights(detail.container); - for (const highlight of detail.highlights) { - renderSinglePDFHighlight(detail.container, highlight, highlights); - } - }, - ); - - context.events.on( - "pdf:highlights:clear", - (detail: { container: HTMLElement }) => { - clearPDFHighlights(detail.container); - }, - ); - - context.events.on( - "pdf:highlight:remove", - (detail: { highlightId: string }) => { - removePDFHighlight(detail.highlightId, highlights); - }, - ); - - context.events.on("reader:unload", () => { - highlights.forEach((element) => element.remove()); - highlights.clear(); - }); -} - -interface PDFHighlight { - id: string; - pageNumber: number; - rects: DOMRect[]; - text: string; - color: string; - noteId?: string; -} - -function renderSinglePDFHighlight( - container: HTMLElement, - highlight: PDFHighlight, - highlights: Map, -): void { - const overlay = document.createElement("div"); - overlay.className = "pdf-highlight-annotation"; - overlay.dataset.highlightId = highlight.id; - overlay.style.backgroundColor = parseColor(highlight.color); - - for (const rect of highlight.rects) { - const rectDiv = document.createElement("div"); - rectDiv.className = "pdf-highlight-rect"; - rectDiv.style.left = `${rect.left}px`; - rectDiv.style.top = `${rect.top}px`; - rectDiv.style.width = `${rect.width}px`; - rectDiv.style.height = `${rect.height}px`; - - overlay.appendChild(rectDiv); - } - - if (highlight.noteId) { - overlay.style.cursor = "pointer"; - overlay.addEventListener("click", () => { - showNotePopup(highlight); - }); - } - - overlay.addEventListener("mouseenter", () => { - overlay.style.opacity = "0.8"; - }); - - overlay.addEventListener("mouseleave", () => { - overlay.style.opacity = "0.5"; - }); - - container.appendChild(overlay); - highlights.set(highlight.id, overlay); -} - -function parseColor(color: string): string { - if (color.startsWith("#")) { - const hex = color.slice(1); - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - return `rgba(${r}, ${g}, ${b}, 0.4)`; - } - - return color; -} - -function showNotePopup(highlight: PDFHighlight): void { - console.log("Show note for highlight:", highlight.id); - const event = new CustomEvent("pdf:note-show", { - detail: { highlightId: highlight.id }, - }); - window.dispatchEvent(event); -} - -export function clearPDFHighlights(container: HTMLElement): void { - const highlights = container.querySelectorAll(".pdf-highlight-annotation"); - Array.from(highlights).forEach((element) => element.remove()); -} - -export function removePDFHighlight( - highlightId: string, - highlights: Map, -): void { - const element = highlights.get(highlightId); - if (element) { - element.remove(); - highlights.delete(highlightId); - } -} - diff --git a/web/src/reader/pdf/pdf-navigation.ts b/web/src/reader/pdf/pdf-navigation.ts deleted file mode 100644 index a304bea..0000000 --- a/web/src/reader/pdf/pdf-navigation.ts +++ /dev/null @@ -1,215 +0,0 @@ -// PDF navigation: page turning, zoom, fit modes -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - let navState: PDFNavigationState | null = null; - - context.events.on( - "reader:loaded", - (detail: { container: HTMLElement; totalPages: number }) => { - navState = { - currentPage: 1, - totalPages: detail.totalPages, - currentScale: 1.0, - fitMode: "fit-width", - scrollContainer: - detail.container.querySelector(".pdf-scroll-container") || - detail.container, - }; - setupPDFKeyboardNav(context, navState); - setupPDFScrollTracking(context, navState); - }, - ); - - context.events.on("pdf:navigate:to-page", (detail: { page: number }) => { - if (navState) { - goToPDFPage(navState, detail.page, context); - } - }); - - context.events.on("pdf:navigate:next", () => { - if (navState) { - nextPDFPage(navState, context); - } - }); - - context.events.on("pdf:navigate:previous", () => { - if (navState) { - previousPDFPage(navState, context); - } - }); - - context.events.on("pdf:zoom:set", (detail: { scale: number }) => { - if (navState) { - setPDFZoom(navState, detail.scale, context); - } - }); - - context.events.on("pdf:zoom:in", () => { - if (navState) { - zoomPDFIn(navState, context); - } - }); - - context.events.on("pdf:zoom:out", () => { - if (navState) { - zoomPDFOut(navState, context); - } - }); - - context.events.on( - "pdf:fit:set", - (detail: { mode: "fit-width" | "fit-page" | "fit-height" | "none" }) => { - if (navState) { - setPDFFitMode(navState, detail.mode, context); - } - }, - ); - - context.events.on("reader:unload", () => { - navState = null; - }); -} - -type PageFitMode = "fit-width" | "fit-page" | "fit-height" | "none"; - -interface PDFNavigationState { - currentPage: number; - totalPages: number; - currentScale: number; - fitMode: PageFitMode; - scrollContainer: HTMLElement | null; -} - -function goToPDFPage( - state: PDFNavigationState, - pageNumber: number, - context: ReaderContext, -): void { - if (pageNumber < 1 || pageNumber > state.totalPages) return; - - state.currentPage = pageNumber; - scrollToPDFPage(state, pageNumber); - context.events.emit("pdf:page-changed", { page: pageNumber }); -} - -function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void { - if (state.currentPage < state.totalPages) { - goToPDFPage(state, state.currentPage + 1, context); - } -} - -function previousPDFPage( - state: PDFNavigationState, - context: ReaderContext, -): void { - if (state.currentPage > 1) { - goToPDFPage(state, state.currentPage - 1, context); - } -} - -function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void { - if (!state.scrollContainer) return; - - const pageElement = state.scrollContainer.querySelector( - `[data-page-number="${pageNumber}"]`, - ); - if (pageElement) { - pageElement.scrollIntoView({ behavior: "smooth", block: "start" }); - } -} - -function setPDFZoom( - state: PDFNavigationState, - scale: number, - context: ReaderContext, -): void { - state.currentScale = scale; - state.fitMode = "none"; - updatePDFZoom(state); - context.events.emit("pdf:zoom-changed", { scale }); -} - -function setPDFFitMode( - state: PDFNavigationState, - mode: PageFitMode, - context: ReaderContext, -): void { - state.fitMode = mode; - updatePDFZoom(state); - context.events.emit("pdf:fit-changed", { mode }); -} - -function zoomPDFIn(state: PDFNavigationState, context: ReaderContext): void { - setPDFZoom(state, state.currentScale * 1.2, context); -} - -function zoomPDFOut(state: PDFNavigationState, context: ReaderContext): void { - setPDFZoom(state, state.currentScale / 1.2, context); -} - -function updatePDFZoom(state: PDFNavigationState): void { - const event = new CustomEvent("pdf-update-zoom", { - detail: { - scale: state.currentScale, - fitMode: state.fitMode, - }, - }); - window.dispatchEvent(event); -} - -function setupPDFKeyboardNav( - context: ReaderContext, - state: PDFNavigationState, -): void { - document.addEventListener("keydown", (e) => { - if (e.key === "ArrowRight" || e.key === "ArrowDown") { - nextPDFPage(state, context); - } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { - previousPDFPage(state, context); - } else if (e.key === "+" || e.key === "=") { - zoomPDFIn(state, context); - } else if (e.key === "-" || e.key === "_") { - zoomPDFOut(state, context); - } - }); -} - -function setupPDFScrollTracking( - context: ReaderContext, - state: PDFNavigationState, -): void { - if (!state.scrollContainer) return; - - state.scrollContainer.addEventListener("scroll", () => { - const currentPage = getCurrentPDFPage(state); - if (currentPage !== state.currentPage) { - state.currentPage = currentPage; - context.events.emit("pdf:page-changed", { page: currentPage }); - } - }); -} - -function getCurrentPDFPage(state: PDFNavigationState): number { - if (!state.scrollContainer) return state.currentPage; - - const containerRect = state.scrollContainer.getBoundingClientRect(); - const viewportMiddle = containerRect.top + containerRect.height / 2; - - for (let i = 1; i <= state.totalPages; i++) { - const pageElement = state.scrollContainer.querySelector( - `[data-page-number="${i}"]`, - ); - if (pageElement) { - const rect = pageElement.getBoundingClientRect(); - if (rect.top <= viewportMiddle && rect.bottom >= viewportMiddle) { - return i; - } - } - } - - return state.currentPage; -} - diff --git a/web/src/reader/pdf/pdf-text-selection.ts b/web/src/reader/pdf/pdf-text-selection.ts deleted file mode 100644 index 8533928..0000000 --- a/web/src/reader/pdf/pdf-text-selection.ts +++ /dev/null @@ -1,165 +0,0 @@ -// PDF text selection - Uses backend API for highlight creation -// Feature Registration Pattern implementation - -import { ReaderContext } from "../core/reader-context"; - -export function init(context: ReaderContext): void { - let currentMediaItemId: string | null = null; - - context.events.on("reader:loaded", (detail: { mediaItemId: string }) => { - currentMediaItemId = detail.mediaItemId; - }); - - context.events.on("pdf:selection:get", () => { - const selection = getPDFTextSelection(); - context.events.emit("pdf:selection-current", selection); - }); - - context.events.on( - "pdf:highlight:create", - async (detail: { selection: PDFTextSelection; color: string }) => { - if (currentMediaItemId) { - try { - const highlight = await createPDFHighlight( - currentMediaItemId, - detail.selection, - detail.color, - ); - context.events.emit("pdf:highlight-created", highlight); - } catch (error) { - console.error("Failed to create highlight:", error); - } - } - }, - ); - - context.events.on( - "pdf:highlights:load", - async (detail: { container: HTMLElement }) => { - if (currentMediaItemId) { - await loadAndRenderPDFHighlights(currentMediaItemId, detail.container); - } - }, - ); - - context.events.on("reader:unload", () => { - currentMediaItemId = null; - }); -} - -interface PDFTextSelection { - pageNumber: number; - text: string; - rects: DOMRect[]; -} - -export function getPDFTextSelection(): PDFTextSelection | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return null; - - const range = selection.getRangeAt(0); - const text = range.toString(); - - if (!text) return null; - - const pageElement = range.commonAncestorContainer.parentElement?.closest?.( - "[data-page-number]", - ) as HTMLElement; - const pageNumber = pageElement?.dataset.pageNumber - ? parseInt(pageElement.dataset.pageNumber) - : getCurrentPDFPage(); - - const rects: DOMRect[] = []; - for (const rect of Array.from(range.getClientRects())) { - rects.push(rect); - } - - return { - pageNumber, - text, - rects, - }; -} - -export async function createPDFHighlight( - mediaItemId: string, - selection: PDFTextSelection, - color: string, -): Promise { - const selectionData = { - selection_text: selection.text, - page_number: selection.pageNumber, - rects: selection.rects.map((rect) => ({ - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - })), - color, - }; - - const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(selectionData), - }); - - if (!response.ok) { - throw new Error(`Failed to create highlight: ${response.statusText}`); - } - - return await response.json(); -} - -export async function loadAndRenderPDFHighlights( - mediaItemId: string, - container: HTMLElement, -): Promise { - const response = await fetch(`/api/media-items/${mediaItemId}/highlights`); - if (!response.ok) return; - - const highlights: any[] = await response.json(); - - for (const highlight of highlights) { - renderPDFHighlight(container, highlight); - } -} - -function renderPDFHighlight(container: HTMLElement, highlight: any): void { - const overlay = document.createElement("div"); - overlay.className = "pdf-highlight-annotation"; - overlay.dataset.highlightId = highlight.id; - overlay.style.backgroundColor = parseColor(highlight.color || "#ffff00"); - - for (const rect of highlight.rects || []) { - const rectDiv = document.createElement("div"); - rectDiv.className = "pdf-highlight-rect"; - rectDiv.style.left = `${rect.x}px`; - rectDiv.style.top = `${rect.y}px`; - rectDiv.style.width = `${rect.width}px`; - rectDiv.style.height = `${rect.height}px`; - overlay.appendChild(rectDiv); - } - - container.appendChild(overlay); -} - -function parseColor(color: string): string { - if (color.startsWith("#")) { - const hex = color.slice(1); - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - return `rgba(${r}, ${g}, ${b}, 0.4)`; - } - - return color; -} - -function getCurrentPDFPage(): number { - const pageElement = document.querySelector("[data-page-number]"); - return pageElement - ? parseInt(pageElement.getAttribute("data-page-number") || "1") - : 1; -} -