diff --git a/web/src/reader/comic/background-color.ts b/web/src/reader/comic/background-color.ts index 41c6192..c2306f1 100644 --- a/web/src/reader/comic/background-color.ts +++ b/web/src/reader/comic/background-color.ts @@ -1,5 +1,29 @@ // Background color options for manga/comics -// Procedural implementation (no OOP) +// 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"; @@ -19,25 +43,31 @@ const backgroundColors: Record = { function createBackgroundColorState( initial: BackgroundColor = "black", ): BackgroundColorState { + const saved = localStorage.getItem("reader-background-color") as BackgroundColor; return { - current: initial, + 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 { - const newState: BackgroundColorState = { - current: color, - customColor: customColor || state.customColor, - }; - - const bgColor = - color === "custom" ? newState.customColor : backgroundColors[color]; + 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; @@ -47,16 +77,18 @@ function setBackgroundColor( localStorage.setItem("reader-background-color", color); - return newState; + const picker = document.querySelector(".background-color-picker"); + if (picker) { + updateBackgroundColorUI(picker as HTMLElement, state); + } + + 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; - return setBackgroundColor(state, order[nextIndex]); } @@ -81,8 +113,8 @@ function renderBackgroundColorPicker( btn.style.backgroundColor = backgroundColors[color]; btn.title = color.charAt(0).toUpperCase() + color.slice(1); btn.addEventListener("click", () => { - const newState = setBackgroundColor(state, color); - updateBackgroundColorUI(picker, newState); + setBackgroundColor(state, color); + updateBackgroundColorUI(picker, state); }); picker.appendChild(btn); }); @@ -100,4 +132,4 @@ 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 6b5afd7..1132a7f 100644 --- a/web/src/reader/comic/chapter-markers.ts +++ b/web/src/reader/comic/chapter-markers.ts @@ -1,6 +1,40 @@ // Chapter markers for manga/comics // Visual indicators for chapter boundaries -// Procedural implementation (no OOP) +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let state: ChapterMarkerState | null = null; + + context.events.on("reader:loaded", (detail: { chapters: ChapterInfo[]; currentPage: number }) => { + state = createChapterMarkerState(detail.chapters, detail.currentPage); + renderChapterMarkers(context.elements.readerContent, state); + }); + + context.events.on("page-changed", (detail: { page: number }) => { + if (state) { + updateCurrentChapter(state, detail.page); + } + }); + + context.events.on("chapter-markers:toggle", () => { + if (state) { + toggleChapterMarkers(state); + } + }); + + context.events.on("chapter-markers:navigate", (detail: { chapterNumber: number }) => { + if (state) { + scrollToChapter(state, detail.chapterNumber); + } + }); + + context.events.on("reader:unload", () => { + const markers = document.querySelector(".chapter-markers"); + markers?.remove(); + }); +} interface ChapterInfo { chapterNumber: number; @@ -77,28 +111,26 @@ function updateCurrentChapter( )?.chapterNumber || state.currentChapter; if (currentChapter !== state.currentChapter) { - const newState = { ...state, currentChapter }; + state.currentChapter = currentChapter; const markers = document.querySelector(".chapter-markers"); if (markers) { - renderChapterMarkers(markers.parentElement!, newState); + renderChapterMarkers(markers.parentElement!, state); } - - return newState; } return state; } function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState { - const newState = { ...state, showMarkers: !state.showMarkers }; + state.showMarkers = !state.showMarkers; const markers = document.querySelector(".chapter-markers"); if (markers) { - markers.classList.toggle("hidden", !newState.showMarkers); + markers.classList.toggle("hidden", !state.showMarkers); } - return newState; + return state; } function scrollToChapter( @@ -113,35 +145,4 @@ function scrollToChapter( }), ); } -} - -const chapterMarkerCSS = ` - .chapter-marker { - padding: 4px 8px; - margin-left: -18px; - opacity: 0.7; - transition: opacity 0.2s; - } - - .chapter-marker:hover { - opacity: 1; - } - - .chapter-marker .current-indicator { - color: #3b82f6; - animation: pulse 2s infinite; - } - - @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } - } - - .chapter-marker-line { - position: absolute; - left: 0; - right: 0; - height: 1px; - background: linear-gradient(to right, rgba(255,255,255,0.1), transparent); - } -`; +} \ No newline at end of file diff --git a/web/src/reader/comic/page-cache.ts b/web/src/reader/comic/page-cache.ts index 89578cc..1dece20 100644 --- a/web/src/reader/comic/page-cache.ts +++ b/web/src/reader/comic/page-cache.ts @@ -1,12 +1,59 @@ // Lazy-loading page cache with 5-page ahead prefetch // Shared by both comic and manga readers +// Feature Registration Pattern implementation -// Lazy-loading page cache with 5-page ahead prefetch -// Procedural implementation (no OOP) - +import type { ReaderContext } from "../core/reader-context"; import { detectPanels } from "./panel-detection.service"; -interface PageCacheState { +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; @@ -14,7 +61,7 @@ interface PageCacheState { panelData: Map; } -function createPageCache(mediaItemId: string): PageCacheState { +export function createPageCache(mediaItemId: string): PageCacheState { return { cache: new Map(), loading: new Set(), @@ -24,7 +71,7 @@ function createPageCache(mediaItemId: string): PageCacheState { }; } -async function getCachedPage( +export async function getCachedPage( state: PageCacheState, pageNumber: number, ): Promise { @@ -43,24 +90,20 @@ async function getCachedPage( }) as Promise; } - const newLoading = new Set(state.loading); - newLoading.add(pageNumber); + state.loading.add(pageNumber); const img = await loadComicPage(state, pageNumber); - const newCache = new Map(state.cache); - newCache.set(pageNumber, img); - newLoading.delete(pageNumber); + state.cache.set(pageNumber, img); + state.loading.delete(pageNumber); - const newState = { ...state, cache: newCache, loading: newLoading }; + prefetchPages(state, pageNumber + 1); + cleanupPageCache(state, pageNumber); - prefetchPages(newState, pageNumber + 1); - cleanupPageCache(newState, pageNumber); - - return { ...newState, page: img }; + return { ...state, page: img }; } -async function loadComicPage( +export async function loadComicPage( state: PageCacheState, pageNumber: number, ): Promise { @@ -85,7 +128,7 @@ async function loadComicPage( return img; } -function prefetchPages(state: PageCacheState, startPage: number): void { +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) => { @@ -95,7 +138,7 @@ function prefetchPages(state: PageCacheState, startPage: number): void { } } -function cleanupPageCache( +export function cleanupPageCache( state: PageCacheState, currentPage: number, ): PageCacheState { @@ -108,20 +151,18 @@ function cleanupPageCache( } } - return { ...state, cache: newCache }; + state.cache = newCache; + return state; } -// Add this function -async function detectPagePanels( +export async function detectPagePanels( state: PageCacheState, pageNumber: number, ): Promise { - // Check if already detected if (state.panelData?.has(pageNumber)) { return state.panelData.get(pageNumber)!.panels; } - // Get or create image let image: HTMLImageElement; if (state.cache.has(pageNumber)) { image = state.cache.get(pageNumber)!; @@ -129,7 +170,6 @@ async function detectPagePanels( image = await loadComicPage(state, pageNumber); } - // Run detection on demand const canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height; @@ -145,7 +185,4 @@ async function detectPagePanels( state.panelData.set(pageNumber, result); return result.panels; -} - -// Export the new function -export { createPageCache, getCachedPage, loadComicPage, detectPagePanels }; +} \ 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 9270133..340637b 100644 --- a/web/src/reader/comic/page-order.ts +++ b/web/src/reader/comic/page-order.ts @@ -1,7 +1,43 @@ // Page order presets for manga/comics // Auto-detect Japanese vs Western reading order -// Allow user override in case detection is wrong -// Procedural implementation (no OOP) +// Feature Registration Pattern implementation + +import type { 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"; @@ -16,7 +52,6 @@ interface PageOrderState { totalPages: number; } -// Detect page order based on filename patterns function detectPageOrder(pageNames: string[]): PageOrderMode { if (pageNames.length < 2) return "western"; @@ -62,15 +97,18 @@ function createPageOrderState( function setPageOrderMode( state: PageOrderState, mode: PageOrderMode, + context: ReaderContext, ): PageOrderState { - return { - ...state, - config: { - ...state.config, - mode, - userOverride: mode !== "auto", - }, + 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 { @@ -101,4 +139,4 @@ 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 index 8f319e8..1ee94d2 100644 --- a/web/src/reader/comic/page-scrubber.ts +++ b/web/src/reader/comic/page-scrubber.ts @@ -1,5 +1,39 @@ // Page slider/scrubber for quick navigation -// Procedural implementation (no OOP) +// 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; @@ -59,14 +93,14 @@ function updatePageScrubber( state: PageScrubberState, currentPage: number, ): PageScrubberState { - const newState = { ...state, currentPage }; + state.currentPage = currentPage; const label = state.container.querySelector(".page-label"); if (label) { label.textContent = String(currentPage); } - return newState; + return state; } function showPageScrubber(state: PageScrubberState): void { @@ -83,4 +117,4 @@ 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-gap.ts b/web/src/reader/comic/panel-gap.ts index 3d16b0c..3e84e24 100644 --- a/web/src/reader/comic/panel-gap.ts +++ b/web/src/reader/comic/panel-gap.ts @@ -1,5 +1,37 @@ // Adjustable panel gap controls -// Procedural implementation (no OOP) +// Feature Registration Pattern implementation + +import type { 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; @@ -7,43 +39,59 @@ interface PanelGapState { } function createPanelGapState(initialGap: number = 4): PanelGapState { + const saved = localStorage.getItem("reader-panel-gap"); return { - gapSize: initialGap, + gapSize: saved ? parseInt(saved) : initialGap, showBorders: false, }; } -function setPanelGap(state: PanelGapState, gap: number): PanelGapState { - const clampedGap = Math.max(0, Math.min(20, gap)); - - document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`); - - return { ...state, gapSize: clampedGap }; +function 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 increasePanelGap( - state: PanelGapState, - amount: number = 2, -): PanelGapState { +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 { +function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { return setPanelGap(state, state.gapSize - amount); } function togglePanelBorders(state: PanelGapState): PanelGapState { - const newShowBorders = !state.showBorders; + state.showBorders = !state.showBorders; document.documentElement.style.setProperty( "--panel-border-width", - newShowBorders ? "1px" : "0px", + state.showBorders ? "1px" : "0px", ); - return { ...state, showBorders: newShowBorders }; + const controls = document.querySelector(".panel-gap-controls"); + if (controls) { + updatePanelGapUI(controls as HTMLElement, state); + } + + return state; } function renderPanelGapControls( @@ -68,22 +116,22 @@ function renderPanelGapControls( controls .querySelector(".panel-gap-increase") ?.addEventListener("click", () => { - const newState = increasePanelGap(state); - updatePanelGapUI(controls, newState); + increasePanelGap(state); + updatePanelGapUI(controls, state); }); controls .querySelector(".panel-gap-decrease") ?.addEventListener("click", () => { - const newState = decreasePanelGap(state); - updatePanelGapUI(controls, newState); + decreasePanelGap(state); + updatePanelGapUI(controls, state); }); controls .querySelector(".panel-gap-borders") ?.addEventListener("click", () => { - const newState = togglePanelBorders(state); - updatePanelGapUI(controls, newState); + togglePanelBorders(state); + updatePanelGapUI(controls, state); }); container.appendChild(controls); @@ -99,24 +147,4 @@ function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void { if (bordersBtn) { bordersBtn.textContent = state.showBorders ? "▦" : "▢"; } -} - -const panelGapCSS = ` - :root { - --panel-gap: 4px; - --panel-border-width: 0px; - } - - .panel-zoom-container { - gap: var(--panel-gap); - } - - .panel-zoom-container.with-borders { - background: rgba(255, 255, 255, 0.1); - padding: var(--panel-gap); - } - - .panel-borders { - border: var(--panel-border-width) dashed rgba(255, 255, 255, 0.3); - } -`; +} \ No newline at end of file diff --git a/web/src/reader/core/parser-manager.ts b/web/src/reader/core/parser-manager.ts index 6489485..369eebe 100644 --- a/web/src/reader/core/parser-manager.ts +++ b/web/src/reader/core/parser-manager.ts @@ -1,11 +1,10 @@ // Parser Manager - Routes files to appropriate parsers // Procedural style: Functions, not classes -import JSZip from "jszip"; -import { parseEPUB } from "../parsers/epub-parserss"; -import { parseFB2 } from "../parsers/fb2-parserr"; -import { parseTXT } from "../parsers/txt-parserr"; -import { parseHTML } from "../parsers/html-parserr"; +import { parseEPUB } from "../parsers/epub-parsers"; +import { parseFB2 } from "../parsers/fb2-parser"; +import { parseTXT } from "../parsers/txt-parser"; +import { parseHTML } from "../parsers/html-parser"; // ============================================================ // Parser Registry diff --git a/web/src/reader/ebook/copy-handler.ts b/web/src/reader/ebook/copy-handler.ts index c346cc5..c9e20a1 100644 --- a/web/src/reader/ebook/copy-handler.ts +++ b/web/src/reader/ebook/copy-handler.ts @@ -1,10 +1,28 @@ // Handle text copying with citation +// Feature Registration Pattern implementation -// Handle text copying with citation -// Procedural implementation (no OOP) - +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; @@ -46,6 +64,4 @@ function enableContextMenuCopy(mediaItem: MediaItemSummary): void { await copySelection(mediaItem); } }); -} - -export { enableContextMenuCopy, copySelection }; +} \ No newline at end of file diff --git a/web/src/reader/ebook/dictionary-popup.ts b/web/src/reader/ebook/dictionary-popup.ts index 3580ecf..92c7944 100644 --- a/web/src/reader/ebook/dictionary-popup.ts +++ b/web/src/reader/ebook/dictionary-popup.ts @@ -1,16 +1,30 @@ // Dictionary lookup popup for ebooks +// Feature Registration Pattern implementation -import { lookupWord } from "./api"; +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 { - // Remove existing popup const existing = document.getElementById("dictionary-popup"); existing?.remove(); - // Create popup const popup = document.createElement("div"); popup.id = "dictionary-popup"; popup.className = @@ -21,7 +35,6 @@ function showDictionaryPopup( popup.innerHTML = '

Loading...

'; document.body.appendChild(popup); - // Look up word lookupWord(word) .then((entry) => { popup.innerHTML = ` @@ -31,11 +44,10 @@ function showDictionaryPopup( ${entry.example ? `

"${entry.example}"

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

Definition not found for "${word}"

`; }); - // Close on click outside setTimeout(() => { document.addEventListener("click", function closePopup(e: MouseEvent) { if (!popup.contains(e.target as Node)) { @@ -46,14 +58,12 @@ function showDictionaryPopup( }, 100); } -// Text selection handler for ebooks function handleTextSelection(): void { document.addEventListener("mouseup", () => { const selection = window.getSelection(); const selectedText = selection?.toString().trim(); if (selectedText && selectedText.split(" ").length === 1) { - // Single word selected - show dictionary const range = selection?.getRangeAt(0); const rect = range?.getBoundingClientRect(); @@ -63,3 +73,11 @@ function handleTextSelection(): void { } }); } + +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 40f4256..25691e1 100644 --- a/web/src/reader/ebook/font-loader.ts +++ b/web/src/reader/ebook/font-loader.ts @@ -1,4 +1,26 @@ // Font loading with performance optimization +// Feature Registration Pattern implementation + +import type { 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 = [ { @@ -51,7 +73,6 @@ const READING_FONTS = [ }, ]; -// Preload critical fonts (default font + user's last choice) async function preloadFonts(userPreferredFont: string): Promise { const fontsToPreload = new Set(["literata", userPreferredFont]); @@ -63,11 +84,13 @@ async function preloadFonts(userPreferredFont: string): Promise { } } -// Get font stack for CSS function getFontStack(fontId: string): string { const font = READING_FONTS.find((f) => f.id === fontId); return font?.stack || "Literata, serif"; } -// All fonts bundled - no network requests needed -export { READING_FONTS, preloadFonts, getFontStack }; +function applyFontStack(stack: string): void { + document.documentElement.style.setProperty("--reader-font-family", stack); +} + +export { READING_FONTS, preloadFonts, getFontStack }; \ No newline at end of file diff --git a/web/src/reader/ebook/search.ts b/web/src/reader/ebook/search.ts index c84340e..fe1077e 100644 --- a/web/src/reader/ebook/search.ts +++ b/web/src/reader/ebook/search.ts @@ -1,5 +1,26 @@ // Search within ebook content -// Procedural style: Functions, not classes +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let ebookData: any = null; + + context.events.on("reader:loaded", (detail: { ebookData: any }) => { + ebookData = detail.ebookData; + }); + + context.events.on("search:execute", async (detail: { query: string }) => { + if (ebookData) { + const results = await searchEbook(ebookData, detail.query); + context.events.emit("search:results", { results }); + } + }); + + context.events.on("reader:unload", () => { + ebookData = null; + }); +} interface SearchResult { cfi: string; @@ -7,30 +28,21 @@ interface SearchResult { chapterTitle: string; } -interface EbookSearchConfig { - epubPackage: EPUBPackage; -} - -// ============================================================ -// Main Search Function -// ============================================================ - export async function searchEbook( - epubPackage: EPUBPackage, + ebookData: any, query: string, ): Promise { const results: SearchResult[] = []; const lowerQuery = query.toLowerCase(); - // Search all spine items - for (const [index, spineItem] of epubPackage.spine.entries()) { - const doc = await getSpineItemDocument(epubPackage, spineItem); + if (!ebookData.spine) return results; + + for (const spineItem of ebookData.spine) { + const doc = await getSpineItemDocument(ebookData, spineItem); if (!doc) continue; const chapterTitle = getChapterTitle(spineItem); - - // Search in text nodes const textNodes = findTextNodes(doc.body); for (const node of textNodes) { @@ -57,11 +69,14 @@ export async function searchEbook( } async function getSpineItemDocument( - epubPackage: EPUBPackage, - spineItem: EPUBSpineItem, + ebookData: any, + spineItem: any, ): Promise { try { - const content = await epubPackage.resources.get(spineItem.href)?.text(); + const resources = ebookData.resources; + if (!resources) return null; + + const content = await resources.get(spineItem.href)?.text(); if (!content) return null; const parser = new DOMParser(); @@ -72,9 +87,8 @@ async function getSpineItemDocument( } } -function getChapterTitle(spineItem: EPUBSpineItem): string { - // Extract title from spine item or use default - return spineItem.id || `Section ${spineItem.index}`; +function getChapterTitle(spineItem: any): string { + return spineItem.id || `Section ${spineItem.index || ""}`; } function findTextNodes(root: Node): Text[] { @@ -102,32 +116,27 @@ function findTextNodes(root: Node): Text[] { return textNodes; } -function generateCFIForNode(node: Text, offset: number): string { +function generateCFIForNode(node: Node, offset: number): string { const path: number[] = []; let current: Node | null = node; while (current && current.parentNode) { - const parent = current.parentNode; - const siblings = Array.from(parent.childNodes).filter( - (n) => n.nodeType === Node.ELEMENT_NODE, - ); - const index = siblings.indexOf(current as Node); - + const siblings = Array.from(current.parentNode.childNodes); + const index = siblings.indexOf(current as ChildNode); path.unshift(index); - current = parent; + current = current.parentNode; } - const spineIndex = 0; // Would come from parent context - - return generateCFI(spineIndex, path, offset); + return `/6/4${path.map((i) => `/${i + 2}`).join("")}:${offset}`; } function extractSnippet(text: string, offset: number, length: number): string { - const contextBefore = 30; - const contextAfter = 50; + const start = Math.max(0, offset - 40); + const end = Math.min(text.length, offset + length + 40); + let snippet = text.substring(start, end); - const start = Math.max(0, offset - contextBefore); - const end = Math.min(text.length, offset + length + contextAfter); + if (start > 0) snippet = "..." + snippet; + if (end < text.length) snippet = snippet + "..."; - return text.slice(start, end); -} + 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 index 76c7516..4b93120 100644 --- a/web/src/reader/ebook/typography-engine.ts +++ b/web/src/reader/ebook/typography-engine.ts @@ -1,5 +1,42 @@ // Typography engine for ebook rendering -// Procedural style: Functions, not classes +// 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: @@ -10,7 +47,7 @@ interface TypographyConfig { | "libertinus" | "noto-serif" | "charis-sil" - | "ibm-plex"; // Bundled libre fonts + | "ibm-plex"; fontSize: number; lineHeight: number; marginTop: number; @@ -46,7 +83,7 @@ function applyTypography( margin-right: ${config.marginRight}px; text-indent: ${config.textIndent}px; -webkit-font-smoothing: ${config.fontSmoothing}; - -moz-osx-font-smoothing: ${config.fontSmoothing === "grayscale" ? "grayscale" : "auto"}; + -moz-osx-font-smoothing: auto; `, ); @@ -61,6 +98,20 @@ function applyTypography( } } +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"; @@ -108,12 +159,4 @@ function measureReadingTime( return Math.ceil(minutes); } -function getWordCount(container: HTMLElement): number { - const content = container.querySelector(".ebook-content"); - if (!content) return 0; - - const text = content.textContent || ""; - return text.split(/\s+/).length; -} - -export { applyTypography, getFontStack }; +export { applyTypography, getFontStack }; \ No newline at end of file diff --git a/web/src/reader/manga/reading-direction.ts b/web/src/reader/manga/reading-direction.ts index a16ec2b..6aaea6b 100644 --- a/web/src/reader/manga/reading-direction.ts +++ b/web/src/reader/manga/reading-direction.ts @@ -1,4 +1,46 @@ // 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"; @@ -9,19 +51,17 @@ interface ReadingDirectionState { } async function detectReadingDirection( - metadata: MediaItemMetadata, + metadata: any, ): Promise { - // Check user preference first const userPreference = await getUserReadingDirectionPreference(); if (userPreference && userPreference !== "auto") { return { direction: userPreference, - detectedDirection: "ltr", // Default fallback + detectedDirection: "ltr", userPreference, }; } - // Detect from metadata const detectedDirection = detectFromMetadata(metadata); return { @@ -32,21 +72,18 @@ async function detectReadingDirection( } function detectFromMetadata( - metadata: MediaItemMetadata, + metadata: any, ): "ltr" | "rtl" | "vertical" { - // Check manga_type field from database const mangaType = (metadata as any).manga_type; if (mangaType === "yes_and_right_to_left" || mangaType === "yes") { return "rtl"; } - // Check reading_direction field const readingDirection = (metadata as any).reading_direction; if (readingDirection === "rtl" || readingDirection === "vertical") { return readingDirection; } - // Detect from filename const filename = metadata.filePath.toLowerCase(); if ( filename.includes("manga") || @@ -56,7 +93,6 @@ function detectFromMetadata( return "vertical"; } - // Default to LTR return "ltr"; } @@ -90,4 +126,4 @@ function shouldUseRTL(state: ReadingDirectionState): boolean { 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 index 1610d99..9da25bd 100644 --- a/web/src/reader/manga/rtl-navigator.ts +++ b/web/src/reader/manga/rtl-navigator.ts @@ -1,5 +1,49 @@ // Right-to-left navigation for manga -// Reverses page turn direction and key bindings +// 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; @@ -16,7 +60,6 @@ function createRTLNavigator(totalPages: number): RTLNavigatorState { } function getNextPage(state: RTLNavigatorState): number { - // In RTL, "next" page means moving left (decreasing page number) if (state.readingDirection === "rtl") { return Math.max(1, state.currentPage - 1); } @@ -24,7 +67,6 @@ function getNextPage(state: RTLNavigatorState): number { } function getPreviousPage(state: RTLNavigatorState): number { - // In RTL, "previous" page means moving right (increasing page number) if (state.readingDirection === "rtl") { return Math.min(state.totalPages, state.currentPage + 1); } @@ -35,10 +77,8 @@ function navigateToPage( state: RTLNavigatorState, pageNumber: number, ): RTLNavigatorState { - return { - ...state, - currentPage: Math.max(1, Math.min(state.totalPages, pageNumber)), - }; + state.currentPage = Math.max(1, Math.min(state.totalPages, pageNumber)); + return state; } function getProgress(state: RTLNavigatorState): { @@ -53,4 +93,4 @@ function getProgress(state: RTLNavigatorState): { 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 index aa53474..cbbd3b5 100644 --- a/web/src/reader/manga/settings.ts +++ b/web/src/reader/manga/settings.ts @@ -1,5 +1,32 @@ // Manga-specific settings integration -// Extends the common reader settings manager +// 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"; @@ -55,19 +82,18 @@ async function updateMangaSettings( } function applyMangaSettings(settings: MangaSettings): void { - // Apply reading direction document.documentElement.dataset.readingDirection = settings.readingDirection; - // Apply vertical scroll speed if (settings.verticalScrollSpeed === "slow") { document.documentElement.style.scrollBehavior = "smooth"; } else if (settings.verticalScrollSpeed === "fast") { document.documentElement.style.scrollBehavior = "auto"; } - // Apply RTL page transition if (settings.rtlPageTransition !== "none") { document.documentElement.dataset.pageTransition = settings.rtlPageTransition; } -} + + 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 34684b1..d78632f 100644 --- a/web/src/reader/manga/vertical-scroll-mode.ts +++ b/web/src/reader/manga/vertical-scroll-mode.ts @@ -1,5 +1,35 @@ // Vertical scroll mode for webtoons/manhwa -// Infinite scroll with image loading and lazy rendering +// Feature Registration Pattern implementation + +import type { 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; @@ -7,7 +37,7 @@ interface VerticalScrollState { loadingPages: Set; currentPage: number; totalPages: number; - threshold: number; // Distance from bottom to trigger next page load + threshold: number; mediaItemId: string; } @@ -22,14 +52,11 @@ function createVerticalScroll( loadingPages: new Set(), currentPage: 1, totalPages, - threshold: 500, // Load next page when 500px from bottom + threshold: 500, mediaItemId, }; - // Initial page load loadPage(state, 1); - - // Setup scroll listener setupScrollListener(state); return state; @@ -76,7 +103,6 @@ async function loadPage( state.loadedPages.add(pageNumber); state.loadingPages.delete(pageNumber); - // Load next pages proactively if (pageNumber < state.totalPages) { loadPage(state, pageNumber + 1); if (pageNumber + 1 < state.totalPages) { @@ -113,11 +139,9 @@ function checkScrollPosition(state: VerticalScrollState): void { } } - // Update current page based on scroll position const currentPage = getCurrentPageFromScroll(state); if (currentPage !== state.currentPage) { state.currentPage = currentPage; - // Dispatch event for progress tracking window.dispatchEvent( new CustomEvent("page-change", { detail: { page: currentPage }, @@ -127,18 +151,17 @@ function checkScrollPosition(state: VerticalScrollState): void { } function getCurrentPageFromScroll(state: VerticalScrollState): number { - const pages = state.container.querySelectorAll(".vertical-page"); + const pages = Array.from(state.container.querySelectorAll(".vertical-page")); for (const page of pages) { const rect = page.getBoundingClientRect(); const containerRect = state.container.getBoundingClientRect(); - // Page is considered "current" if it's in the middle 50% of viewport const pageMiddle = rect.top + rect.height / 2; const viewportMiddle = containerRect.top + containerRect.height / 2; if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) { - return parseInt(page.dataset.pageNumber || "1"); + return parseInt((page as HTMLElement).dataset.pageNumber || "1"); } } @@ -146,8 +169,7 @@ function getCurrentPageFromScroll(state: VerticalScrollState): number { } function destroyVerticalScroll(state: VerticalScrollState): void { - // Clean up blob URLs - const images = state.container.querySelectorAll("img"); + const images = Array.from(state.container.querySelectorAll("img")); images.forEach((img) => { const url = img.src; if (url.startsWith("blob:")) { @@ -158,4 +180,4 @@ 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/offline-manager.ts b/web/src/reader/offline-manager.ts index 8b62b5d..0aab3f1 100644 --- a/web/src/reader/offline-manager.ts +++ b/web/src/reader/offline-manager.ts @@ -1,4 +1,28 @@ // Offline manager for PWA functionality +// Feature Registration Pattern implementation + +import type { ReaderContext } from "./core/reader-context"; + +export function init(context: ReaderContext): void { + registerServiceWorker(); + + window.addEventListener("online", () => { + const isOnline = checkOnlineStatus(); + if (isOnline) { + context.events.emit("offline:online", {}); + syncPendingChanges(context); + } + }); + + window.addEventListener("offline", () => { + context.events.emit("offline:offline", {}); + }); + + context.events.on("offline:check", () => { + const isOnline = checkOnlineStatus(); + context.events.emit("offline:status", { isOnline }); + }); +} export function registerServiceWorker(): void { if ("serviceWorker" in navigator) { @@ -20,13 +44,6 @@ export function checkOnlineStatus(): boolean { return false; } -// Listen for online/offline events -window.addEventListener("online", () => { - showToast("Back online", "success"); - // Sync any pending changes - syncPendingChanges(); -}); - -window.addEventListener("offline", () => { - showToast("You are offline. Some features may be limited.", "warning"); -}); +function syncPendingChanges(context: ReaderContext): void { + context.events.emit("offline:sync", {}); +} \ 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 e2958b6..859f9ee 100644 --- a/web/src/reader/pdf/annotation-layer.ts +++ b/web/src/reader/pdf/annotation-layer.ts @@ -1,5 +1,31 @@ // Annotation layer for rendering highlights and notes on PDFs -// Procedural style: Functions, not classes +// Feature Registration Pattern implementation + +import type { 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; @@ -10,30 +36,16 @@ interface PDFHighlight { noteId?: string; } -const highlights = new Map(); - -export function renderPDFHighlights( - container: HTMLElement, - highlightList: PDFHighlight[], -): void { - // Clear existing highlights - clearPDFHighlights(container); - - for (const highlight of highlightList) { - renderSinglePDFHighlight(container, highlight); - } -} - function renderSinglePDFHighlight( container: HTMLElement, highlight: PDFHighlight, + highlights: Map, ): void { const overlay = document.createElement("div"); overlay.className = "pdf-highlight-annotation"; overlay.dataset.highlightId = highlight.id; overlay.style.backgroundColor = parseColor(highlight.color); - // Position highlight rectangles for (const rect of highlight.rects) { const rectDiv = document.createElement("div"); rectDiv.className = "pdf-highlight-rect"; @@ -45,7 +57,6 @@ function renderSinglePDFHighlight( overlay.appendChild(rectDiv); } - // Add click handler for note popup if (highlight.noteId) { overlay.style.cursor = "pointer"; overlay.addEventListener("click", () => { @@ -53,7 +64,6 @@ function renderSinglePDFHighlight( }); } - // Add hover effect overlay.addEventListener("mouseenter", () => { overlay.style.opacity = "0.8"; }); @@ -80,17 +90,19 @@ 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 } }); + window.dispatchEvent(event); } export function clearPDFHighlights(container: HTMLElement): void { - highlights.forEach((element) => element.remove()); - highlights.clear(); + const highlights = container.querySelectorAll(".pdf-highlight-annotation"); + Array.from(highlights).forEach((element) => element.remove()); } -export function removePDFHighlight(highlightId: string): 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/pdf-navigation.ts b/web/src/reader/pdf/pdf-navigation.ts index ea2e5b0..84b5b87 100644 --- a/web/src/reader/pdf/pdf-navigation.ts +++ b/web/src/reader/pdf/pdf-navigation.ts @@ -1,5 +1,69 @@ // PDF navigation: page turning, zoom, fit modes -// Procedural style: Functions, not classes +// Feature Registration Pattern implementation + +import type { 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"; @@ -11,64 +75,30 @@ interface PDFNavigationState { scrollContainer: HTMLElement | null; } -let navState: PDFNavigationState = { - currentPage: 1, - totalPages: 0, - currentScale: 1.0, - fitMode: "fit-width", - scrollContainer: null, -}; +function goToPDFPage(state: PDFNavigationState, pageNumber: number, context: ReaderContext): void { + if (pageNumber < 1 || pageNumber > state.totalPages) return; -// ============================================================ -// Initialization -// ============================================================ - -export function initializePDFNavigation( - container: HTMLElement, - onPageChange: (pageNumber: number) => void, - onZoomChange: (scale: number) => void, -): void { - navState.scrollContainer = - container.querySelector(".pdf-scroll-container") || container; - setupPDFKeyboardNav(onPageChange); - setupPDFScrollTracking(onPageChange); + state.currentPage = pageNumber; + scrollToPDFPage(state, pageNumber); + context.events.emit("pdf:page-changed", { page: pageNumber }); } -export function setPDFTotalPages(totalPages: number): void { - navState.totalPages = totalPages; -} - -// ============================================================ -// Page Navigation -// ============================================================ - -export function goToPDFPage(pageNumber: number): void { - if (pageNumber < 1 || pageNumber > navState.totalPages) return; - - navState.currentPage = pageNumber; - - const callback = (window as any).pdfOnPageChange; - if (callback) callback(pageNumber); - - scrollToPDFPage(pageNumber); -} - -export function nextPDFPage(): void { - if (navState.currentPage < navState.totalPages) { - goToPDFPage(navState.currentPage + 1); +function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void { + if (state.currentPage < state.totalPages) { + goToPDFPage(state, state.currentPage + 1, context); } } -export function previousPDFPage(): void { - if (navState.currentPage > 1) { - goToPDFPage(navState.currentPage - 1); +function previousPDFPage(state: PDFNavigationState, context: ReaderContext): void { + if (state.currentPage > 1) { + goToPDFPage(state, state.currentPage - 1, context); } } -function scrollToPDFPage(pageNumber: number): void { - if (!navState.scrollContainer) return; +function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void { + if (!state.scrollContainer) return; - const pageElement = navState.scrollContainer.querySelector( + const pageElement = state.scrollContainer.querySelector( `[data-page-number="${pageNumber}"]`, ); if (pageElement) { @@ -76,141 +106,80 @@ function scrollToPDFPage(pageNumber: number): void { } } -// ============================================================ -// Zoom Controls -// ============================================================ - -export function setPDFZoom(scale: number): void { - navState.currentScale = scale; - navState.fitMode = "none"; - - const callback = (window as any).pdfOnZoomChange; - if (callback) callback(scale); - - updatePDFZoom(); +function setPDFZoom(state: PDFNavigationState, scale: number, context: ReaderContext): void { + state.currentScale = scale; + state.fitMode = "none"; + updatePDFZoom(state); + context.events.emit("pdf:zoom-changed", { scale }); } -export function setPDFFitMode(mode: PageFitMode): void { - navState.fitMode = mode; - updatePDFZoom(); +function setPDFFitMode(state: PDFNavigationState, mode: PageFitMode, context: ReaderContext): void { + state.fitMode = mode; + updatePDFZoom(state); + context.events.emit("pdf:fit-changed", { mode }); } -export function zoomPDFIn(): void { - setPDFZoom(navState.currentScale * 1.2); +function zoomPDFIn(state: PDFNavigationState, context: ReaderContext): void { + setPDFZoom(state, state.currentScale * 1.2, context); } -export function zoomPDFOut(): void { - setPDFZoom(navState.currentScale / 1.2); +function zoomPDFOut(state: PDFNavigationState, context: ReaderContext): void { + setPDFZoom(state, state.currentScale / 1.2, context); } -function updatePDFZoom(): void { - if (!navState.scrollContainer) return; - - const pages = navState.scrollContainer.querySelectorAll( - ".pdf-page-container", - ); - pages.forEach((page: Element) => { - (page as HTMLElement).style.transform = `scale(${navState.currentScale})`; - (page as HTMLElement).style.transformOrigin = "top center"; +function updatePDFZoom(state: PDFNavigationState): void { + const event = new CustomEvent("pdf-update-zoom", { + detail: { + scale: state.currentScale, + fitMode: state.fitMode, + }, }); + window.dispatchEvent(event); } -// ============================================================ -// Keyboard Navigation -// ============================================================ - -function setupPDFKeyboardNav(onPageChange: (pageNumber: number) => void): void { - document.addEventListener("keydown", handlePDFKeyDown); -} - -function handlePDFKeyDown(e: KeyboardEvent): void { - switch (e.key) { - case "ArrowRight": - case "ArrowDown": - e.preventDefault(); - nextPDFPage(); - break; - case "ArrowLeft": - case "ArrowUp": - e.preventDefault(); - previousPDFPage(); - break; - case "Home": - e.preventDefault(); - goToPDFPage(1); - break; - case "End": - e.preventDefault(); - goToPDFPage(navState.totalPages); - break; - } -} - -// ============================================================ -// Scroll Tracking -// ============================================================ - -function setupPDFScrollTracking( - onPageChange: (pageNumber: number) => void, -): void { - if (!navState.scrollContainer) return; - - let scrollTimeout: NodeJS.Timeout; - - navState.scrollContainer.addEventListener("scroll", () => { - clearTimeout(scrollTimeout); - - scrollTimeout = setTimeout(() => { - updateCurrentPageFromScroll(onPageChange); - }, 100); - }); -} - -function updateCurrentPageFromScroll( - onPageChange: (pageNumber: number) => void, -): void { - if (!navState.scrollContainer) return; - - const scrollTop = navState.scrollContainer.scrollTop; - const containerHeight = navState.scrollContainer.clientHeight; - - const pages = navState.scrollContainer.querySelectorAll("[data-page-number]"); - let maxVisibility = 0; - let mostVisiblePage = navState.currentPage; - - pages.forEach((page) => { - const element = page as HTMLElement; - const pageTop = element.offsetTop; - const pageBottom = pageTop + element.offsetHeight; - - const visibleTop = Math.max(scrollTop, pageTop); - const visibleBottom = Math.min(scrollTop + containerHeight, pageBottom); - const visibleHeight = Math.max(0, visibleBottom - visibleTop); - - if (visibleHeight > maxVisibility) { - maxVisibility = visibleHeight; - mostVisiblePage = parseInt(element.dataset.pageNumber || "1"); +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); } }); +} - if (mostVisiblePage !== navState.currentPage) { - navState.currentPage = mostVisiblePage; - onPageChange(mostVisiblePage); +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; + } + } } -} -// ============================================================ -// Getters -// ============================================================ - -export function getCurrentPDFPage(): number { - return navState.currentPage; -} - -export function getTotalPDFPages(): number { - return navState.totalPages; -} - -export function getPDFScale(): number { - return navState.currentScale; -} + return state.currentPage; +} \ No newline at end of file diff --git a/web/src/reader/pdf/pdf-text-selection.ts b/web/src/reader/pdf/pdf-text-selection.ts index ac713fe..21f4337 100644 --- a/web/src/reader/pdf/pdf-text-selection.ts +++ b/web/src/reader/pdf/pdf-text-selection.ts @@ -1,6 +1,41 @@ // PDF text selection - Uses backend API for highlight creation -// Backend handles all position calculations for PDFs -// Procedural style: Functions, not classes +// Feature Registration Pattern implementation + +import type { 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; @@ -8,10 +43,6 @@ interface PDFTextSelection { rects: DOMRect[]; } -// ============================================================ -// Get PDF Text Selection -// ============================================================ - export function getPDFTextSelection(): PDFTextSelection | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; @@ -21,16 +52,14 @@ export function getPDFTextSelection(): PDFTextSelection | null { if (!text) return null; - // Get page number from selection const pageElement = - range.commonAncestorContainer.closest?.("[data-page-number]"); + range.commonAncestorContainer.parentElement?.closest?.("[data-page-number]") as HTMLElement; const pageNumber = pageElement?.dataset.pageNumber ? parseInt(pageElement.dataset.pageNumber) : getCurrentPDFPage(); - // Get bounding rectangles const rects: DOMRect[] = []; - for (const rect of range.getClientRects()) { + for (const rect of Array.from(range.getClientRects())) { rects.push(rect); } @@ -41,15 +70,11 @@ export function getPDFTextSelection(): PDFTextSelection | null { }; } -// ============================================================ -// Create PDF Highlight (Backend Calculates Position) -// ============================================================ - export async function createPDFHighlight( mediaItemId: string, selection: PDFTextSelection, color: string, -): Promise { +): Promise { const selectionData = { selection_text: selection.text, page_number: selection.pageNumber, @@ -62,7 +87,6 @@ export async function createPDFHighlight( color, }; - // Send to backend - backend calculates all position formats const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -76,18 +100,14 @@ export async function createPDFHighlight( return await response.json(); } -// ============================================================ -// Load and Render PDF Highlights (Backend Provides Positions) -// ============================================================ - export async function loadAndRenderPDFHighlights( mediaItemId: string, container: HTMLElement, ): Promise { const response = await fetch(`/api/media-items/${mediaItemId}/highlights`); - if (!response.ok) return []; + if (!response.ok) return; - const highlights: Highlight[] = await response.json(); + const highlights: any[] = await response.json(); for (const highlight of highlights) { renderPDFHighlight(container, highlight); @@ -96,92 +116,39 @@ export async function loadAndRenderPDFHighlights( function renderPDFHighlight( container: HTMLElement, - highlight: Highlight, + highlight: any, ): void { - // Backend provides position data for PDF highlights - // Check which position format is available + const overlay = document.createElement("div"); + overlay.className = "pdf-highlight-annotation"; + overlay.dataset.highlightId = highlight.id; + overlay.style.backgroundColor = parseColor(highlight.color || "#ffff00"); - if ( - highlight.start_position && - highlight.start_position.startsWith("pdf:page:") - ) { - // Backend calculated page-based position - renderPDFHighlightByPosition(container, highlight); - } else if (highlight.percentage_start !== null) { - // Backend calculated percentage position - renderPDFHighlightByPercentage(container, highlight); + 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 renderPDFHighlightByPosition( - container: HTMLElement, - highlight: Highlight, -): void { - // Parse position string: "pdf:page:45:offset:123" - const match = highlight.start_position.match(/pdf:page:(\d+):offset:(\d+)/); - if (!match) return; - - const pageNumber = parseInt(match[1], 10); - const offset = parseInt(match[2], 10); - - // Find the page element - const pageElement = container.querySelector( - `[data-page-number="${pageNumber}"]`, - ); - if (!pageElement) return; - - // Get text content at offset - const textContent = pageElement.querySelector(".pdf-text-layer")?.textContent; - if (!textContent) return; - - // Find the text at this offset - const textBefore = textContent.substring(0, offset); - const startChar = textBefore.length; - const endChar = startChar + (highlight.selection_text?.length || 10); - - if (startChar < textContent.length && endChar <= textContent.length) { - applyHighlightToTextContent( - pageElement as HTMLElement, - startChar, - endChar, - highlight.color, - ); +function 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 renderPDFHighlightByPercentage( - container: HTMLElement, - highlight: Highlight, -): void { - // Backend provides percentage - estimate position - const percentage = highlight.percentage_start || 0; - - // Find spine item closest to this percentage - const totalPages = container.querySelectorAll("[data-page-number]").length; - const targetPage = Math.ceil(percentage * totalPages); - - const pageElement = container.querySelector( - `[data-page-number="${targetPage}"]`, - ); - if (pageElement) { - // Highlight entire page (coarse-grained) - applyHighlightStylesToElement(pageElement as HTMLElement, highlight.color); - } -} - -function applyHighlightToTextContent( - element: HTMLElement, - startChar: number, - endChar: number, - color: string, -): void { - const text = element.textContent || ""; - const before = text.substring(0, startChar); - const selection = text.substring(startChar, endChar); - const after = text.substring(endChar); - - element.textContent = before + selection + after; - - // Use a mark to wrap the selected text - element.innerHTML = `${before}${selection}${after}`; -} +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 diff --git a/web/src/reader/reading-speed-tracker.ts b/web/src/reader/reading-speed-tracker.ts index 602d245..b663402 100644 --- a/web/src/reader/reading-speed-tracker.ts +++ b/web/src/reader/reading-speed-tracker.ts @@ -1,7 +1,46 @@ // Track reading speed and update database +// Feature Registration Pattern implementation -// Reading speed tracker -// Procedural implementation (no OOP) +import type { ReaderContext } from "./core/reader-context"; + +export function init(context: ReaderContext): void { + let state: ReadingSpeedTrackerState | null = null; + + context.events.on("reader:loaded", (detail: { mediaItemId: string }) => { + state = createReadingSpeedTracker(detail.mediaItemId); + }); + + context.events.on("reading-session:start", () => { + if (state) { + startReadingSession(state); + } + }); + + context.events.on("page-changed", () => { + if (state) { + recordPageTurn(state); + } + }); + + context.events.on("words-read", (detail: { wordCount: number }) => { + if (state) { + recordWordsRead(state, detail.wordCount); + } + }); + + context.events.on("reading-session:end", async () => { + if (state) { + await syncReadingSpeed(state); + } + }); + + context.events.on("reader:unload", async () => { + if (state) { + await syncReadingSpeed(state); + state = null; + } + }); +} interface ReadingSpeedTrackerState { startTime: number | null; @@ -26,12 +65,10 @@ function createReadingSpeedTracker( function startReadingSession( state: ReadingSpeedTrackerState, ): ReadingSpeedTrackerState { - return { - ...state, - startTime: Date.now(), - pagesRead: 0, - wordsRead: 0, - }; + state.startTime = Date.now(); + state.pagesRead = 0; + state.wordsRead = 0; + return state; } function recordPageTurn( @@ -39,25 +76,23 @@ function recordPageTurn( ): ReadingSpeedTrackerState { if (!state.startTime) return state; - const newPagesRead = state.pagesRead + 1; + state.pagesRead += 1; const now = Date.now(); - if (newPagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) { - syncReadingSpeed({ ...state, pagesRead: newPagesRead }); - return { ...state, pagesRead: newPagesRead, lastSync: now }; + if (state.pagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) { + syncReadingSpeed(state); + state.lastSync = now; } - return { ...state, pagesRead: newPagesRead }; + return state; } function recordWordsRead( state: ReadingSpeedTrackerState, wordCount: number, ): ReadingSpeedTrackerState { - return { - ...state, - wordsRead: state.wordsRead + wordCount, - }; + state.wordsRead += wordCount; + return state; } async function syncReadingSpeed( @@ -69,10 +104,22 @@ async function syncReadingSpeed( const pagesPerMinute = state.pagesRead / minutesElapsed; const wordsPerMinute = state.wordsRead / minutesElapsed; - await apiPut(`/readers/${state.mediaItemId}/reading-speed`, { - pages_per_minute: pagesPerMinute, - words_per_minute: wordsPerMinute, - pages_read: state.pagesRead, - total_reading_minutes: minutesElapsed, - }); -} + try { + const token = localStorage.getItem("token"); + await fetch(`/readers/${state.mediaItemId}/reading-speed`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + pages_per_minute: pagesPerMinute, + words_per_minute: wordsPerMinute, + pages_read: state.pagesRead, + total_reading_minutes: minutesElapsed, + }), + }); + } catch (error) { + console.error("Failed to sync reading speed:", error); + } +} \ No newline at end of file diff --git a/web/src/reader/settings-manager.ts b/web/src/reader/settings-manager.ts index c2829a0..01310e7 100644 --- a/web/src/reader/settings-manager.ts +++ b/web/src/reader/settings-manager.ts @@ -1,61 +1,95 @@ // Per-user settings with localStorage fallback +// Feature Registration Pattern implementation +import type { ReaderContext } from "./core/reader-context"; import { apiGet, apiPut } from "../api"; -import { getToken, setItem, getItem } from "../storage"; +import { getToken } from "../storage"; -const SETTINGS_KEY = "reader_settings"; const LOCALSTORAGE_KEY = "reader_settings_local"; -interface SettingsManager { - load(): Promise; - save(settings: Partial): Promise; - sync(): Promise; // Sync localStorage → DB - get(key: keyof ReaderSettings): any; - set(key: keyof ReaderSettings, value: any): Promise; +export function init(context: ReaderContext): void { + let currentSettings: ReaderSettings | null = null; + + context.events.on("reader:init", async () => { + currentSettings = await loadSettings(); + context.events.emit("settings:loaded", currentSettings); + }); + + context.events.on("settings:save", async (detail: { settings: Partial }) => { + await saveSettings(detail.settings); + currentSettings = await loadSettings(); + context.events.emit("settings:changed", currentSettings); + }); + + context.events.on("settings:get", (detail: { key?: keyof ReaderSettings }) => { + if (currentSettings) { + const value = detail.key ? currentSettings[detail.key] : currentSettings; + context.events.emit("settings:current", { value }); + } + }); + + context.events.on("settings:set", async (detail: { key: keyof ReaderSettings; value: any }) => { + await saveSettings({ [detail.key]: detail.value }); + currentSettings = await loadSettings(); + context.events.emit("settings:changed", currentSettings); + }); + + context.events.on("settings:sync", async () => { + await syncSettings(); + currentSettings = await loadSettings(); + context.events.emit("settings:synced", currentSettings); + }); } async function loadSettings(): Promise { const token = getToken(); if (!token) { - // Fallback to localStorage - const local = getItem(LOCALSTORAGE_KEY); + const local = localStorage.getItem(LOCALSTORAGE_KEY); return local ? JSON.parse(local) : getDefaultSettings(); } try { const response = await apiGet("/readers/settings"); const settings = await response.json(); - // Cache in localStorage - setItem(LOCALSTORAGE_KEY, JSON.stringify(settings)); + localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(settings)); return settings; } catch (error) { - // Fallback to localStorage on error - const local = getItem(LOCALSTORAGE_KEY); + const local = localStorage.getItem(LOCALSTORAGE_KEY); return local ? JSON.parse(local) : getDefaultSettings(); } } async function saveSettings(settings: Partial): Promise { const token = getToken(); + const current = await loadSettings(); + const updated = { ...current, ...settings }; + if (!token) { - // Save to localStorage only - const current = loadSettings(); - const updated = { ...current, ...settings }; - setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); + localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); return; } try { await apiPut("/readers/settings", settings); - // Update localStorage cache - const current = loadSettings(); - const updated = { ...current, ...settings }; - setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); + localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); } catch (error) { - // Fallback to localStorage - const current = loadSettings(); - const updated = { ...current, ...settings }; - setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); + localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); + } +} + +async function syncSettings(): Promise { + const local = localStorage.getItem(LOCALSTORAGE_KEY); + if (!local) return; + + const settings = JSON.parse(local); + const token = getToken(); + + if (token) { + try { + await apiPut("/readers/settings", settings); + } catch (error) { + console.error("Failed to sync settings:", error); + } } } @@ -63,9 +97,9 @@ function getDefaultSettings(): ReaderSettings { return { chrome_behavior: "auto-hide", progress_mode: "pages", - chrome_theme: "tokyo-night", // UI chrome: All 11 themes available - reading_theme: "dark", // Ebook text: 5 reading-optimized themes - reading_font: "literata", // Default reading font (designed for ebooks) + chrome_theme: "tokyo-night", + reading_theme: "dark", + reading_font: "literata", tap_zone_size: 30, auto_scroll: false, panel_zoom_enabled: true, @@ -75,8 +109,6 @@ function getDefaultSettings(): ReaderSettings { double_page_spread: false, reading_direction: "ltr", hardware_acceleration: true, - - // Dockable panel defaults by media type panel_layout: { toc: { side: "left", @@ -98,10 +130,10 @@ function getDefaultSettings(): ReaderSettings { }, navigator: { side: "right", - visible: true, - collapsed: false, - width_px: 200, - order: 1, + visible: false, + collapsed: true, + width_px: 280, + order: 3, locked: false, last_valid_side: "right", }, @@ -110,11 +142,11 @@ function getDefaultSettings(): ReaderSettings { visible: false, collapsed: true, width_px: 280, - order: 2, + order: 4, locked: false, last_valid_side: "right", }, - mobile_nav_visible: false, + mobile_nav_visible: true, }, - }; -} + } as ReaderSettings; +} \ No newline at end of file