From d65ac1362c47bf3a3bffb1b0d9ae29c79d72509b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 9 Apr 2026 14:53:22 -0400 Subject: [PATCH] refactor(reader): create format-agnostic UI component layer Extract UI components from format-specific code into dedicated ui/ directory. This creates a clean separation between functionality and presentation, making it easier to maintain and share UI elements across different reader formats. ## New UI Components ### Core UI Elements - **gestures.ts**: Touch and mouse gesture handling - **keyboard-shortcuts.ts**: Keyboard navigation and shortcuts - **navigator-panel.ts**: Table of contents and navigation panel - **offline-manager.ts**: Offline reading support and caching ### Progress Display - **progress-indicator.ts**: Reading progress bar and percentage - **reading-speed-tracker.ts**: Words per minute calculation - **page-display.ts**: Current page / total pages display ### Panel System - **panel-dock-system.ts**: Draggable, resizable panel dock interface ## Architecture Benefits 1. **Format Independence**: UI components work with any format 2. **Reusability**: Same components work for PDF, EPUB, comic, manga 3. **Maintainability**: UI logic separated from format-specific code 4. **Testability**: UI can be tested independently of readers 5. **Consistency**: Uniform UX across all format types ## Migration Notes - Moved from reader/features/ to reader/ui/ - Components use ReaderContext interface for format-agnostic access - Maintains all existing functionality during transition - Feature registration pattern preserved for backward compatibility This creates the foundation for a unified user interface that works seamlessly across all reader format types while maintaining format-specific flexibility. --- web/src/reader/ui/gestures.ts | 276 +++++++++++++++++++++ web/src/reader/ui/keyboard-shortcuts.ts | 210 ++++++++++++++++ web/src/reader/ui/navigator-panel.ts | 142 +++++++++++ web/src/reader/ui/offline-manager.ts | 52 ++++ web/src/reader/ui/page-display.ts | 73 ++++++ web/src/reader/ui/panel-dock-system.ts | 224 +++++++++++++++++ web/src/reader/ui/progress-indicator.ts | 171 +++++++++++++ web/src/reader/ui/reading-speed-tracker.ts | 128 ++++++++++ 8 files changed, 1276 insertions(+) create mode 100644 web/src/reader/ui/gestures.ts create mode 100644 web/src/reader/ui/keyboard-shortcuts.ts create mode 100644 web/src/reader/ui/navigator-panel.ts create mode 100644 web/src/reader/ui/offline-manager.ts create mode 100644 web/src/reader/ui/page-display.ts create mode 100644 web/src/reader/ui/panel-dock-system.ts create mode 100644 web/src/reader/ui/progress-indicator.ts create mode 100644 web/src/reader/ui/reading-speed-tracker.ts diff --git a/web/src/reader/ui/gestures.ts b/web/src/reader/ui/gestures.ts new file mode 100644 index 0000000..1a50072 --- /dev/null +++ b/web/src/reader/ui/gestures.ts @@ -0,0 +1,276 @@ +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/ui/keyboard-shortcuts.ts b/web/src/reader/ui/keyboard-shortcuts.ts new file mode 100644 index 0000000..9bffe5d --- /dev/null +++ b/web/src/reader/ui/keyboard-shortcuts.ts @@ -0,0 +1,210 @@ +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/ui/navigator-panel.ts b/web/src/reader/ui/navigator-panel.ts new file mode 100644 index 0000000..d3a5967 --- /dev/null +++ b/web/src/reader/ui/navigator-panel.ts @@ -0,0 +1,142 @@ +// 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/ui/offline-manager.ts b/web/src/reader/ui/offline-manager.ts new file mode 100644 index 0000000..c6522c1 --- /dev/null +++ b/web/src/reader/ui/offline-manager.ts @@ -0,0 +1,52 @@ +// Offline manager for PWA functionality +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +let context: ReaderContext; + +export function init(readerContext: ReaderContext): void { + context = readerContext; + registerServiceWorker(); + + window.addEventListener("online", () => { + const isOnline = checkOnlineStatus(); + if (isOnline) { + context.events.emit("offline:online", {}); + syncPendingChanges(context); + } + }); + + window.addEventListener("offline", () => { + context.events.emit("offline:offline", {}); + }); + + context.events.on("offline:check", () => { + const isOnline = checkOnlineStatus(); + context.events.emit("offline:status", { isOnline }); + }); +} + +export function registerServiceWorker(): void { + if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("/static/service-worker.js") + .then((registration) => { + console.log("Service worker registered:", registration); + }) + .catch((error) => { + console.error("Service worker registration failed:", error); + }); + } +} + +export function checkOnlineStatus(): boolean { + if (typeof navigator !== "undefined" && navigator.onLine) { + return true; + } + return false; +} + +function syncPendingChanges(context: ReaderContext): void { + context.events.emit("offline:sync", {}); +} diff --git a/web/src/reader/ui/page-display.ts b/web/src/reader/ui/page-display.ts new file mode 100644 index 0000000..dda9b0f --- /dev/null +++ b/web/src/reader/ui/page-display.ts @@ -0,0 +1,73 @@ +// Display page info (Page X of Y) +export function updatePageDisplay( + container: HTMLElement, + currentPage: number, + totalPages: number, +): void { + const existing = container.querySelector(".page-display"); + existing?.remove(); + + const display = document.createElement("div"); + display.className = "page-display"; + display.textContent = `Page ${currentPage} of ${totalPages}`; + display.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: var(--bg-secondary); + color: var(--text-primary); + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + z-index: 100; + `; + + container.appendChild(display); +} + +// Remove page display +export function removePageDisplay(container: HTMLElement): void { + const existing = container.querySelector(".page-display"); + existing?.remove(); +} + +// Update progress bar +export function updateProgressBar( + container: HTMLElement, + percentage: number, +): void { + let bar = container.querySelector(".progress-bar-fill") as HTMLElement; + + if (!bar) { + const wrapper = document.createElement("div"); + wrapper.className = "progress-bar"; + wrapper.style.cssText = ` + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: var(--bg-secondary); + z-index: 100; + `; + + bar = document.createElement("div"); + bar.className = "progress-bar-fill"; + bar.style.cssText = ` + height: 100%; + background: var(--accent); + transition: width 0.3s ease; + `; + + wrapper.appendChild(bar); + container.appendChild(wrapper); + } + + bar.style.width = `${percentage}%`; +} + +// Remove progress bar +export function removeProgressBar(container: HTMLElement): void { + const existing = container.querySelector(".progress-bar"); + existing?.remove(); +} diff --git a/web/src/reader/ui/panel-dock-system.ts b/web/src/reader/ui/panel-dock-system.ts new file mode 100644 index 0000000..12b5f3e --- /dev/null +++ b/web/src/reader/ui/panel-dock-system.ts @@ -0,0 +1,224 @@ +// 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/ui/progress-indicator.ts b/web/src/reader/ui/progress-indicator.ts new file mode 100644 index 0000000..f964a65 --- /dev/null +++ b/web/src/reader/ui/progress-indicator.ts @@ -0,0 +1,171 @@ +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/ui/reading-speed-tracker.ts b/web/src/reader/ui/reading-speed-tracker.ts new file mode 100644 index 0000000..a278a80 --- /dev/null +++ b/web/src/reader/ui/reading-speed-tracker.ts @@ -0,0 +1,128 @@ +// 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); + } +}