diff --git a/web/src/reader/features/gestures.ts b/web/src/reader/features/gestures.ts deleted file mode 100644 index 1a50072..0000000 --- a/web/src/reader/features/gestures.ts +++ /dev/null @@ -1,276 +0,0 @@ -import type { Panel } from "../comic/panel-detector"; -import { detectPanels } from "../comic/panel-detection.service"; -import type { ReaderContext } from "../core/reader-context"; - -let currentPanelIndex = 0; -let currentPagePanels: Panel[] = []; -let context: ReaderContext; - -export async function init(readerContext: ReaderContext): Promise { - context = readerContext; - setupGestures(); - await loadPanelsIfComic(); -} - -function setupGestures() { - const container = context.elements.readerContent; - if (!container) return; - - const state = { - touchStartX: 0, - touchStartY: 0, - touchStartTime: 0, - lastTapTime: 0, - initialPinchDistance: 0, - scale: 1, - }; - - container.addEventListener( - "touchstart", - (e) => { - if (e.touches.length === 1) { - state.touchStartX = e.touches[0].clientX; - state.touchStartY = e.touches[0].clientY; - state.touchStartTime = Date.now(); - } else if (e.touches.length === 2) { - state.initialPinchDistance = getPinchDistance(e.touches); - } - }, - { passive: true }, - ); - - container.addEventListener( - "touchend", - (e) => { - const deltaX = e.changedTouches[0].clientX - state.touchStartX; - const deltaY = e.changedTouches[0].clientY - state.touchStartY; - const deltaTime = Date.now() - state.touchStartTime; - - if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) { - const now = Date.now(); - if (now - state.lastTapTime < 300) { - handleDoubleTap(); - state.lastTapTime = 0; - } else { - state.lastTapTime = now; - setTimeout(() => { - if (state.lastTapTime !== 0) { - handleTap(); - } - }, 300); - } - return; - } - - const minSwipeDistance = 50; - const maxSwipeTime = 500; - - if (deltaTime > maxSwipeTime) return; - - if (Math.abs(deltaX) > Math.abs(deltaY)) { - if (deltaX > minSwipeDistance) { - handleSwipeRight(); - } else if (deltaX < -minSwipeDistance) { - handleSwipeLeft(); - } - } else { - if (deltaY > minSwipeDistance) { - handleSwipeDown(); - } else if (deltaY < -minSwipeDistance) { - handleSwipeUp(); - } - } - }, - { passive: true }, - ); - - container.addEventListener( - "touchmove", - (e) => { - if (e.touches.length === 2) { - const currentDistance = getPinchDistance(e.touches); - if (state.initialPinchDistance > 0) { - const scale = currentDistance / state.initialPinchDistance; - handlePinch(scale); - } - } - }, - { passive: true }, - ); -} - -function handleSwipeLeft() { - const metadata = context.getState().readerMetadata; - if (metadata?.library_type_name === "manga") { - navigateWithPanels("next"); - } else { - context.navigation.previousPage(); - } -} - -function handleSwipeRight() { - const metadata = context.getState().readerMetadata; - if (metadata?.library_type_name === "manga") { - navigateWithPanels("previous"); - } else { - context.navigation.nextPage(); - } -} - -function handleSwipeUp() { - const chrome = context.elements.chrome; - if (chrome) chrome.classList.remove("visible"); -} - -function handleSwipeDown() { - const chrome = context.elements.chrome; - if (chrome) chrome.classList.add("visible"); -} - -function handleTap() { - const chrome = context.elements.chrome; - if (chrome) chrome.classList.toggle("visible"); -} - -function handleDoubleTap() { - const container = context.elements.readerContent; - if (container) { - const currentTransform = container.style.transform || ""; - const currentScale = currentTransform.match(/scale\(([\d.]+)\)/); - const scale = currentScale ? parseFloat(currentScale[1]) : 1; - const newScale = scale === 1 ? 1.5 : 1; - container.style.transform = `scale(${newScale})`; - container.style.transformOrigin = "center center"; - context.events.emit("zoomChanged", newScale); - } -} - -function handlePinch(scale: number) { - const container = context.elements.readerContent; - if (container && scale >= 0.5 && scale <= 3) { - container.style.transform = `scale(${scale})`; - container.style.transformOrigin = "center center"; - context.events.emit("zoomChanged", scale); - } -} - -function getPinchDistance(touches: TouchList): number { - const dx = touches[0].clientX - touches[1].clientX; - const dy = touches[0].clientY - touches[1].clientY; - return Math.sqrt(dx * dx + dy * dy); -} - -async function loadPanelsIfComic() { - const state = context.getState(); - if ( - !state.currentReader || - (state.currentReader.type !== "comic" && - state.currentReader.type !== "manga") - ) { - return; - } - - const reader = state.currentReader as any; - if ( - !reader.images || - reader.currentPage === undefined || - reader.currentPage < 0 || - reader.currentPage >= reader.images.length - ) { - currentPagePanels = []; - currentPanelIndex = 0; - return; - } - - try { - const imageBlob = reader.images[reader.currentPage]; - const imageData = await blobToImageData(imageBlob); - const result = await detectPanels(imageData, false); - currentPagePanels = result.panels; - currentPanelIndex = 0; - } catch (error) { - console.warn("Failed to load panels:", error); - currentPagePanels = []; - currentPanelIndex = 0; - } -} - -async function blobToImageData(blob: Blob): Promise { - const img = new Image(); - const url = URL.createObjectURL(blob); - - return new Promise((resolve, reject) => { - img.onload = () => { - const canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext("2d"); - if (!ctx) { - reject(new Error("Failed to get canvas context")); - return; - } - ctx.drawImage(img, 0, 0); - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - URL.revokeObjectURL(url); - resolve(imageData); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error("Failed to load image")); - }; - img.src = url; - }); -} - -function navigateWithPanels(direction: "next" | "previous") { - if (currentPagePanels.length === 0) { - if (direction === "next") { - context.navigation.nextPage(); - } else { - context.navigation.previousPage(); - } - return; - } - - if ( - direction === "next" && - currentPanelIndex < currentPagePanels.length - 1 - ) { - currentPanelIndex++; - scrollToPanel(currentPanelIndex); - return; - } - - if (direction === "previous" && currentPanelIndex > 0) { - currentPanelIndex--; - scrollToPanel(currentPanelIndex); - return; - } - - // No more panels, go to next/previous page - if (direction === "next") { - context.navigation.nextPage(); - } else { - context.navigation.previousPage(); - } -} - -function scrollToPanel(panelIndex: number) { - const panel = currentPagePanels[panelIndex]; - if (!panel) return; - - const container = context.elements.readerContent; - if (!container) return; - - const panelElement = - container.querySelector(`[data-panel-id="${panel.id}"]`) || - container.querySelector(`#${panel.id}`); - - if (panelElement) { - panelElement.scrollIntoView({ behavior: "smooth", block: "center" }); - document - .querySelectorAll(".panel-current") - .forEach((el) => el.classList.remove("panel-current")); - panelElement.classList.add("panel-current"); - } -} diff --git a/web/src/reader/features/keyboard-shortcuts.ts b/web/src/reader/features/keyboard-shortcuts.ts deleted file mode 100644 index 9bffe5d..0000000 --- a/web/src/reader/features/keyboard-shortcuts.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export async function init(readerContext: ReaderContext): Promise { - context = readerContext; - setupKeyboardShortcuts(); -} - -function setupKeyboardShortcuts() { - const container = context.elements.readerContent; - if (!container) return; - - const state = context.getState(); - let maxPage = 0; - - if (state.currentReader?.type === "ebook") { - maxPage = state.currentReader.cif.spine.length; - } else if (state.currentReader?.type === "pdf") { - maxPage = state.readerMetadata?.total_pages || 0; - } else if ( - state.currentReader?.type === "comic" || - state.currentReader?.type === "manga" - ) { - const reader = state.currentReader as any; - maxPage = reader.images.length; - } - - container.addEventListener("keydown", (e) => { - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement - ) { - return; - } - - switch (e.key) { - case "ArrowRight": - case "PageDown": - case "l": - e.preventDefault(); - context.navigation.nextPage(); - break; - - case "ArrowLeft": - case "PageUp": - case "h": - e.preventDefault(); - context.navigation.previousPage(); - break; - - case "ArrowUp": - case "k": - e.preventDefault(); - context.navigation.previousPage(); - break; - - case "ArrowDown": - case "j": - e.preventDefault(); - context.navigation.nextPage(); - break; - - case " ": - e.preventDefault(); - context.navigation.nextPage(); - break; - - case "Home": - e.preventDefault(); - context.navigation.goToPage(1); - break; - - case "End": - e.preventDefault(); - context.navigation.goToPage(maxPage); - break; - - case "b": - if (!e.ctrlKey && !e.metaKey) { - e.preventDefault(); - toggleBookmark(); - } - break; - - case "+": - case "=": - e.preventDefault(); - zoomIn(); - break; - - case "-": - case "_": - e.preventDefault(); - zoomOut(); - break; - - case "0": - e.preventDefault(); - zoomReset(); - break; - - case "?": - e.preventDefault(); - showShortcutHelp(); - break; - - case "f": - if (!e.ctrlKey && !e.metaKey) { - e.preventDefault(); - toggleFullscreen(); - } - break; - - case "Escape": - e.preventDefault(); - exitFullscreen(); - break; - - default: - if (e.key >= "1" && e.key <= "9") { - const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage); - e.preventDefault(); - context.navigation.goToPage(targetPage); - } - } - }); -} - -function toggleBookmark() { - // TODO: Implement bookmark toggle - console.log("Toggle bookmark"); -} - -function zoomIn() { - const container = context.elements.readerContent; - if (container) { - const currentTransform = container.style.transform || ""; - const currentScale = currentTransform.match(/scale\(([\d.]+)\)/); - const scale = currentScale ? parseFloat(currentScale[1]) : 1; - const newScale = Math.min(scale + 0.25, 3); - container.style.transform = `scale(${newScale})`; - container.style.transformOrigin = "center center"; - context.events.emit("zoomChanged", newScale); - } -} - -function zoomOut() { - const container = context.elements.readerContent; - if (container) { - const currentTransform = container.style.transform || ""; - const currentScale = currentTransform.match(/scale\(([\d.]+)\)/); - const scale = currentScale ? parseFloat(currentScale[1]) : 1; - const newScale = Math.max(scale - 0.25, 0.5); - container.style.transform = `scale(${newScale})`; - container.style.transformOrigin = "center center"; - context.events.emit("zoomChanged", newScale); - } -} - -function zoomReset() { - const container = context.elements.readerContent; - if (container) { - container.style.transform = "scale(1)"; - container.style.transformOrigin = "center center"; - context.events.emit("zoomChanged", 1); - } -} - -function toggleFullscreen() { - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - document.documentElement.requestFullscreen(); - } -} - -function exitFullscreen() { - if (document.fullscreenElement) { - document.exitFullscreen(); - } -} - -function showShortcutHelp() { - const help = document.createElement("div"); - help.className = - "keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50"; - help.innerHTML = ` -
-

Keyboard Shortcuts

-
-
/ Space Next page
-
Previous page
-
Home First page
-
End Last page
-
+ / - Zoom
-
B Toggle bookmark
-
? Show help
-
Esc Exit fullscreen
-
- -
- `; - document.body.appendChild(help); - help.addEventListener("click", (e) => { - if (e.target === help) help.remove(); - }); -} diff --git a/web/src/reader/features/navigator-panel.ts b/web/src/reader/features/navigator-panel.ts deleted file mode 100644 index d3a5967..0000000 --- a/web/src/reader/features/navigator-panel.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Navigator panel - shows full page with draggable viewport box -// Affinity/Photoshop-style mini-map for page navigation -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export function init(readerContext: ReaderContext): void { - context = readerContext; - let state: NavigatorState | null = null; - - context.events.on( - "navigator:initialize", - (detail: { containerSelector: string; totalPages: number }) => { - state = initializeNavigator(detail.containerSelector); - state.totalPages = detail.totalPages; - }, - ); - - context.events.on( - "navigator:update", - (detail: { currentPage: number; contentImage?: HTMLImageElement }) => { - if (state) { - state.currentPage = detail.currentPage; - if (detail.contentImage) { - state.contentImage = detail.contentImage; - updateNavigatorViewport(state); - } - } - }, - ); - - context.events.on("navigator:pan", (detail: { x: number; y: number }) => { - if (state) { - handleNavigatorPan(state, detail.x, detail.y); - } - }); -} - -interface NavigatorState { - panelId: string; - container: HTMLElement; - viewport: HTMLElement; - currentPage: number; - totalPages: number; - scale: number; - contentImage: HTMLImageElement | null; - isDragging: boolean; -} - -function initializeNavigator(containerSelector: string): NavigatorState { - const container = document.querySelector(containerSelector) as HTMLElement; - if (!container) throw new Error("Navigator container not found"); - - const viewport = document.createElement("div"); - viewport.className = "navigator-viewport-box"; - viewport.style.cssText = ` - position: absolute; - border: 2px solid var(--accent-color, #3b82f6); - background: rgba(59, 130, 246, 0.1); - cursor: move; - z-index: 10; - `; - - container.appendChild(viewport); - - const state: NavigatorState = { - panelId: "navigator", - container, - viewport, - currentPage: 1, - totalPages: 1, - scale: 0.1, - contentImage: null, - isDragging: false, - }; - - setupNavigatorDragHandler(state); - return state; -} - -function setupNavigatorDragHandler(state: NavigatorState): void { - state.viewport.addEventListener("mousedown", (e) => { - e.preventDefault(); - state.isDragging = true; - state.viewport.style.cursor = "grabbing"; - }); - - document.addEventListener("mousemove", (e) => { - if (!state.isDragging || !state.contentImage) return; - - const containerRect = state.container.getBoundingClientRect(); - const imgRect = state.contentImage.getBoundingClientRect(); - - const relX = (e.clientX - imgRect.left) / imgRect.width; - const relY = (e.clientY - imgRect.top) / imgRect.height; - - const mainViewer = document.getElementById("reader-content"); - if (mainViewer) { - mainViewer.dispatchEvent( - new CustomEvent("navigator-pan", { - detail: { x: relX, y: relY }, - }), - ); - } - }); - - document.addEventListener("mouseup", () => { - if (state.isDragging) { - state.isDragging = false; - state.viewport.style.cursor = "move"; - } - }); -} - -function updateNavigatorViewport(state: NavigatorState): void { - if (!state.contentImage) return; - - const containerRect = state.container.getBoundingClientRect(); - const imgRect = state.contentImage.getBoundingClientRect(); - - state.scale = containerRect.width / imgRect.width; - - state.viewport.style.width = `${containerRect.width}px`; - state.viewport.style.height = `${containerRect.height * state.scale}px`; - - state.viewport.style.left = "0"; - state.viewport.style.top = `${(state.currentPage - 1) * imgRect.height * state.scale}px`; -} - -function handleNavigatorPan(state: NavigatorState, x: number, y: number): void { - if (!state.contentImage) return; - - const imgRect = state.contentImage.getBoundingClientRect(); - - const viewportX = x * state.container.offsetWidth; - const viewportY = y * state.container.offsetHeight; - - state.viewport.style.left = `${viewportX}px`; - state.viewport.style.top = `${viewportY}px`; -} diff --git a/web/src/reader/features/offline-manager.ts b/web/src/reader/features/offline-manager.ts deleted file mode 100644 index c6522c1..0000000 --- a/web/src/reader/features/offline-manager.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Offline manager for PWA functionality -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export function init(readerContext: ReaderContext): void { - context = readerContext; - registerServiceWorker(); - - window.addEventListener("online", () => { - const isOnline = checkOnlineStatus(); - if (isOnline) { - context.events.emit("offline:online", {}); - syncPendingChanges(context); - } - }); - - window.addEventListener("offline", () => { - context.events.emit("offline:offline", {}); - }); - - context.events.on("offline:check", () => { - const isOnline = checkOnlineStatus(); - context.events.emit("offline:status", { isOnline }); - }); -} - -export function registerServiceWorker(): void { - if ("serviceWorker" in navigator) { - navigator.serviceWorker - .register("/static/service-worker.js") - .then((registration) => { - console.log("Service worker registered:", registration); - }) - .catch((error) => { - console.error("Service worker registration failed:", error); - }); - } -} - -export function checkOnlineStatus(): boolean { - if (typeof navigator !== "undefined" && navigator.onLine) { - return true; - } - return false; -} - -function syncPendingChanges(context: ReaderContext): void { - context.events.emit("offline:sync", {}); -} diff --git a/web/src/reader/features/panel-dock-system.ts b/web/src/reader/features/panel-dock-system.ts deleted file mode 100644 index 12b5f3e..0000000 --- a/web/src/reader/features/panel-dock-system.ts +++ /dev/null @@ -1,224 +0,0 @@ -// Modular dockable panel system - handles drag, lock, snap-back, window-shade -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export function init(readerContext: ReaderContext): void { - context = readerContext; - const dockZones: DockZone[] = [ - { side: "left", x: 0, width: 400, height: window.innerHeight }, - { - side: "right", - x: window.innerWidth - 400, - width: 400, - height: window.innerHeight, - }, - ]; - - const state: PanelDockState = { - panels: new Map(), - dragState: null, - dockZones, - }; - - context.events.on("panels:initialize", async () => { - const settings = await loadSettings(); - for (const [panelId, panelState] of Object.entries(settings.panel_layout)) { - state.panels.set(panelId, panelState as PanelState); - createPanel(context, panelId, panelState as PanelState); - } - setupGlobalDragHandlers(context, state); - }); - - context.events.on("panel:toggle", (detail: { panelId: string }) => { - const panelState = state.panels.get(detail.panelId); - if (panelState) { - panelState.visible = !panelState.visible; - updatePanelVisibility(context, detail.panelId, panelState.visible); - } - }); - - context.events.on("panel:collapse", (detail: { panelId: string }) => { - const panelState = state.panels.get(detail.panelId); - if (panelState) { - panelState.collapsed = !panelState.collapsed; - updatePanelCollapsed(context, detail.panelId, panelState.collapsed); - } - }); - - context.events.on( - "panel:move", - (detail: { panelId: string; side: "left" | "right" }) => { - movePanelToSide(context, state, detail.panelId, detail.side); - }, - ); -} - -interface PanelDockState { - panels: Map; - dragState: DragState | null; - dockZones: DockZone[]; -} - -interface DragState { - panelId: string; - startX: number; - startY: number; - currentX: number; - currentY: number; - isLocked: boolean; -} - -interface DockZone { - side: "left" | "right"; - x: number; - width: number; - height: number; -} - -interface PanelState { - side: "left" | "right"; - visible: boolean; - collapsed: boolean; - width_px: number; - order: number; - locked: boolean; - last_valid_side: "left" | "right"; -} - -async function loadSettings(): Promise { - const response = await fetch("/readers/settings"); - if (!response.ok) return {}; - return await response.json(); -} - -function createPanel( - context: ReaderContext, - panelId: string, - panelState: PanelState, -): void { - const panel = document.createElement("div"); - panel.id = `panel-${panelId}`; - panel.className = `reader-panel panel-${panelState.side}`; - panel.dataset.panelId = panelId; - panel.style.width = `${panelState.width_px}px`; - - if (!panelState.visible) { - panel.classList.add("panel-hidden"); - } - - if (panelState.collapsed) { - panel.classList.add("panel-collapsed"); - } - - context.elements.readerContent.appendChild(panel); -} - -function updatePanelVisibility( - context: ReaderContext, - panelId: string, - visible: boolean, -): void { - const panel = document.getElementById(`panel-${panelId}`); - if (panel) { - panel.classList.toggle("panel-hidden", !visible); - } -} - -function updatePanelCollapsed( - context: ReaderContext, - panelId: string, - collapsed: boolean, -): void { - const panel = document.getElementById(`panel-${panelId}`); - if (panel) { - panel.classList.toggle("panel-collapsed", collapsed); - } -} - -function setupGlobalDragHandlers( - context: ReaderContext, - state: PanelDockState, -): void { - document.addEventListener("mousedown", (e) => { - const panelHeader = (e.target as HTMLElement).closest(".panel-header"); - if (panelHeader) { - const panelId = (panelHeader as HTMLElement).dataset.panelId; - if (panelId) { - const panelState = state.panels.get(panelId); - if (panelState && !panelState.locked) { - state.dragState = { - panelId, - startX: e.clientX, - startY: e.clientY, - currentX: e.clientX, - currentY: e.clientY, - isLocked: false, - }; - } - } - } - }); - - document.addEventListener("mousemove", (e) => { - if (!state.dragState) return; - state.dragState.currentX = e.clientX; - state.dragState.currentY = e.clientY; - }); - - document.addEventListener("mouseup", () => { - if (state.dragState) { - const nearestZone = findNearestDockZone( - state.dragState.currentX, - state.dockZones, - ); - if (nearestZone) { - movePanelToSide( - context, - state, - state.dragState.panelId, - nearestZone.side, - ); - } - state.dragState = null; - } - }); -} - -function findNearestDockZone(x: number, zones: DockZone[]): DockZone | null { - let nearest: DockZone | null = null; - let minDistance = Infinity; - - for (const zone of zones) { - const distance = Math.abs(x - (zone.x + zone.width / 2)); - if (distance < minDistance) { - minDistance = distance; - nearest = zone; - } - } - - return nearest; -} - -function movePanelToSide( - context: ReaderContext, - state: PanelDockState, - panelId: string, - side: "left" | "right", -): void { - const panelState = state.panels.get(panelId); - if (!panelState) return; - - panelState.side = side; - panelState.last_valid_side = side; - - const panel = document.getElementById(`panel-${panelId}`); - if (panel) { - panel.classList.remove("panel-left", "panel-right"); - panel.classList.add(`panel-${side}`); - } - - context.events.emit("panel:moved", { panelId, side }); -} diff --git a/web/src/reader/features/progress-indicator.ts b/web/src/reader/features/progress-indicator.ts deleted file mode 100644 index f964a65..0000000 --- a/web/src/reader/features/progress-indicator.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { ReaderContext } from "../core/reader-context"; -interface ProgressDisplay { - mode: "pages" | "chapter" | "percentage" | "time-left"; - text: string; -} - -let context: ReaderContext; - -export async function init(readerContext: ReaderContext): Promise { - context = readerContext; - context.events.on("pageChanged", () => { - updateProgressDisplay(context); - }); - context.events.on("progressUpdated", () => { - updateProgressDisplay(context); - }); - context.events.on("readerReady", () => { - updateProgressDisplay(context); - }); - setupProgressModeCycling(context); -} -export { calculateProgress, cycleProgressMode }; -export type { ProgressDisplay }; -function calculateProgress( - currentPage: number, - totalPages: number, - currentChapterPage: number, - chapterPages: number, - readingSpeed?: ReadingSpeed, -): ProgressDisplay { - const mode = getCurrentProgressMode(); - switch (mode) { - case "pages": - return { - mode: "pages", - text: `${currentPage}/${totalPages}`, - }; - case "chapter": - return { - mode: "chapter", - text: `${currentChapterPage}/${chapterPages}`, - }; - case "percentage": - const percentage = Math.round((currentPage / totalPages) * 100); - return { - mode: "percentage", - text: `${percentage}%`, - }; - case "time-left": - if (!readingSpeed || readingSpeed.pages_per_minute === 0) { - return { mode: "time-left", text: "--:--" }; - } - const pagesLeft = totalPages - currentPage; - const minutesLeft = pagesLeft / readingSpeed.pages_per_minute; - const hours = Math.floor(minutesLeft / 60); - const mins = Math.round(minutesLeft % 60); - return { - mode: "time-left", - text: `${hours}h ${mins}m`, - }; - default: - return { mode: "pages", text: `${currentPage}/${totalPages}` }; - } -} -function cycleProgressMode(): void { - const modes: Array<"pages" | "chapter" | "percentage" | "time-left"> = [ - "pages", - "chapter", - "percentage", - "time-left", - ]; - const currentMode = getCurrentProgressMode(); - const currentIndex = modes.indexOf(currentMode); - const nextMode = modes[(currentIndex + 1) % modes.length]; - setProgressMode(nextMode); -} -function getCurrentProgressMode(): ReaderSettings["progress_mode"] { - const settingsStr = localStorage.getItem("reader_settings_progress_mode"); - if (settingsStr) { - try { - return JSON.parse(settingsStr); - } catch { - return "pages"; - } - } - return "pages"; -} -function setProgressMode(mode: ReaderSettings["progress_mode"]): void { - localStorage.setItem("reader_settings_progress_mode", JSON.stringify(mode)); - - const display = document.getElementById("progress-display"); - if (display) { - display.dataset.mode = mode; - } -} -async function getReadingSpeed(): Promise { - const speedStr = localStorage.getItem("reader_speed_data"); - if (speedStr) { - try { - return JSON.parse(speedStr); - } catch { - return null; - } - } - return null; -} -function updateProgressDisplay(context: ReaderContext): void { - const state = context.getState(); - const display = context.elements.progressDisplay; - if (!display || !state.currentReader) return; - let currentPage = 0; - let totalPages = 0; - let currentChapterPage = 0; - let chapterPages = 0; - if (state.currentReader.type === "ebook") { - const reader = state.currentReader as any; - - // Use currentPage directly if available (page-based navigation) - if (reader.currentPage) { - currentPage = reader.currentPage; - } else if (reader.currentSpineIndex !== undefined) { - currentPage = reader.currentSpineIndex + 1; - } - - // Get total pages from calculation result - const pageInfo = reader.pageCalculationResult; - if (pageInfo && pageInfo.totalPages > 0) { - totalPages = pageInfo.totalPages; - - // Get chapter progress - const chapter = pageInfo.chapterMap.get(reader.currentSpineIndex); - if (chapter) { - currentChapterPage = currentPage - chapter.startPage + 1; - chapterPages = chapter.pagesInChapter; - } - } else { - // Fallback - totalPages = - state.readerMetadata?.total_pages || - state.currentReader.cif.locations?.estimatedPages || - state.currentReader.cif.spine.length; - } - } else if (state.currentReader.type === "pdf") { - currentPage = state.currentReader.currentPage; - totalPages = state.readerMetadata?.total_pages || 0; - } else { - // Comic/manga - currentPage = (state.currentReader as any).currentPage + 1; - totalPages = (state.currentReader as any).images?.length || 0; - } - getReadingSpeed().then((speed) => { - const result = calculateProgress( - currentPage, - totalPages, - currentChapterPage, - chapterPages, - speed || undefined, - ); - display.textContent = result.text; - display.dataset.mode = result.mode; - }); -} -function setupProgressModeCycling(context: ReaderContext): void { - const display = context.elements.progressDisplay; - if (!display) return; - display.style.cursor = "pointer"; - display.addEventListener("click", () => { - cycleProgressMode(); - updateProgressDisplay(context); - }); -} diff --git a/web/src/reader/features/reading-speed-tracker.ts b/web/src/reader/features/reading-speed-tracker.ts deleted file mode 100644 index a278a80..0000000 --- a/web/src/reader/features/reading-speed-tracker.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Track reading speed and update database -// Feature Registration Pattern implementation - -import type { ReaderContext } from "../core/reader-context"; - -let context: ReaderContext; - -export function init(readerContext: ReaderContext): void { - context = readerContext; - let state: ReadingSpeedTrackerState | null = null; - - context.events.on("reader:loaded", (detail: { mediaItemId: string }) => { - state = createReadingSpeedTracker(detail.mediaItemId); - }); - - context.events.on("reading-session:start", () => { - if (state) { - startReadingSession(state); - } - }); - - context.events.on("page-changed", () => { - if (state) { - recordPageTurn(state); - } - }); - - context.events.on("words-read", (detail: { wordCount: number }) => { - if (state) { - recordWordsRead(state, detail.wordCount); - } - }); - - context.events.on("reading-session:end", async () => { - if (state) { - await syncReadingSpeed(state); - } - }); - - context.events.on("reader:unload", async () => { - if (state) { - await syncReadingSpeed(state); - state = null; - } - }); -} - -interface ReadingSpeedTrackerState { - startTime: number | null; - pagesRead: number; - wordsRead: number; - lastSync: number; - mediaItemId: string; -} - -function createReadingSpeedTracker( - mediaItemId: string, -): ReadingSpeedTrackerState { - return { - startTime: null, - pagesRead: 0, - wordsRead: 0, - lastSync: Date.now(), - mediaItemId, - }; -} - -function startReadingSession( - state: ReadingSpeedTrackerState, -): ReadingSpeedTrackerState { - state.startTime = Date.now(); - state.pagesRead = 0; - state.wordsRead = 0; - return state; -} - -function recordPageTurn( - state: ReadingSpeedTrackerState, -): ReadingSpeedTrackerState { - if (!state.startTime) return state; - - state.pagesRead += 1; - const now = Date.now(); - - if (state.pagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) { - syncReadingSpeed(state); - state.lastSync = now; - } - - return state; -} - -function recordWordsRead( - state: ReadingSpeedTrackerState, - wordCount: number, -): ReadingSpeedTrackerState { - state.wordsRead += wordCount; - return state; -} - -async function syncReadingSpeed( - state: ReadingSpeedTrackerState, -): Promise { - if (!state.startTime) return; - - const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60); - const pagesPerMinute = state.pagesRead / minutesElapsed; - const wordsPerMinute = state.wordsRead / minutesElapsed; - - try { - const token = localStorage.getItem("token"); - await fetch(`/readers/${state.mediaItemId}/reading-speed`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - pages_per_minute: pagesPerMinute, - words_per_minute: wordsPerMinute, - pages_read: state.pagesRead, - total_reading_minutes: minutesElapsed, - }), - }); - } catch (error) { - console.error("Failed to sync reading speed:", error); - } -}