refactor(reader): remove obsolete features directory
Remove the old reader/features/ directory as all functionality has been migrated to the new reader/ui/ directory structure. ## Migration Complete All features have been successfully moved to the format-agnostic UI layer: - gestures.ts → ui/gestures.ts - keyboard-shortcuts.ts → ui/keyboard-shortcuts.ts - navigator-panel.ts → ui/navigator-panel.ts - offline-manager.ts → ui/offline-manager.ts - panel-dock-system.ts → ui/panel-dock-system.ts - progress-indicator.ts → ui/progress-indicator.ts - reading-speed-tracker.ts → ui/reading-speed-tracker.ts ## Architecture Improvement The old features/ directory mixed format-specific and format-agnostic code, making it difficult to maintain and reuse components. The new ui/ directory provides: 1. **Clear separation**: UI components are format-independent 2. **Better organization**: Logical grouping by functionality 3. **Enhanced reusability**: Components work across all formats 4. **Easier maintenance**: Updates benefit all reader types ## Impact - No functionality lost - all features preserved in new location - Import paths updated throughout the codebase - Feature registration pattern maintained - Backward compatibility maintained during transition This completes the structural migration to the new modular architecture. All reader functionality is now properly organized by format (formats/) and presentation layer (ui/).
This commit is contained in:
@@ -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<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");
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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", {});
|
||||
}
|
||||
@@ -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<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 });
|
||||
}
|
||||
@@ -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<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);
|
||||
});
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user