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.
This commit is contained in:
2026-04-09 14:53:22 -04:00
parent e4c18e51f9
commit d65ac1362c
8 changed files with 1276 additions and 0 deletions
+276
View File
@@ -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<void> {
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<ImageData> {
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");
}
}
+210
View File
@@ -0,0 +1,210 @@
import type { ReaderContext } from "../core/reader-context";
let context: ReaderContext;
export async function init(readerContext: ReaderContext): Promise<void> {
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 = `
<div class="bg-gray-800 rounded-lg p-6 max-w-md">
<h2 class="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><kbd class="bg-gray-700 px-2 py-1 rounded">→</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">Space</kbd> Next page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">←</kbd> Previous page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Home</kbd> First page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">End</kbd> Last page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">+</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">-</kbd> Zoom</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">B</kbd> Toggle bookmark</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">?</kbd> Show help</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Esc</kbd> Exit fullscreen</div>
</div>
<button class="mt-4 px-4 py-2 bg-blue-600 rounded" onclick="this.closest('.keyboard-shortcut-help').remove()">
Close
</button>
</div>
`;
document.body.appendChild(help);
help.addEventListener("click", (e) => {
if (e.target === help) help.remove();
});
}
+142
View File
@@ -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`;
}
+52
View File
@@ -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", {});
}
+73
View File
@@ -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();
}
+224
View File
@@ -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<string, PanelState>;
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<any> {
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 });
}
+171
View File
@@ -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<void> {
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<ReadingSpeed | null> {
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);
});
}
+128
View File
@@ -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<void> {
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);
}
}