refactor: Remove duplicate feature files from reader root

Delete old versions of features that are now in /reader/features/:
- offline-manager.ts (moved to features/)
- reading-speed-tracker.ts (moved to features/)
- panel-dock-system.ts (replaced by converted version in features/)
- navigator-panel.ts (replaced by converted version in features/)

All imports now use the /reader/features/ directory. This eliminates
duplication and makes it clear which files are the active ones.
This commit is contained in:
2026-04-04 14:24:08 -04:00
parent e04eb90d53
commit 779f34d4f5
5 changed files with 1 additions and 574 deletions
-152
View File
@@ -1,152 +0,0 @@
// Navigator panel - shows full page with draggable viewport box
// Affinity/Photoshop-style mini-map for page navigation
// Procedural implementation (no OOP)
import { loadSettings } from "./settings-manager";
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;
}
// Setup draggable viewport box within navigator
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();
// Calculate position relative to scaled image
const relX = (e.clientX - imgRect.left) / imgRect.width;
const relY = (e.clientY - imgRect.top) / imgRect.height;
// Update main viewer's position (call external handler)
const mainViewer = document.getElementById("reader-content");
if (mainViewer) {
mainViewer.dataset.panX = relX.toString();
mainViewer.dataset.panY = relY.toString();
// Dispatch event for main viewer to handle
mainViewer.dispatchEvent(
new CustomEvent("navigator-pan", {
detail: { x: relX, y: relY },
}),
);
}
});
document.addEventListener("mouseup", () => {
state.isDragging = false;
state.viewport.style.cursor = "move";
});
}
// Update navigator with current page image
async function updateNavigatorContent(
state: NavigatorState,
pageNumber: number,
): Promise<void> {
state.currentPage = pageNumber;
// Get current page image (from PDF viewer, comic reader, or manga reader)
const contentArea = document.getElementById("reader-content");
const img = contentArea?.querySelector("img, canvas") as
| HTMLImageElement
| HTMLCanvasElement
| null;
if (!img) return;
// Create thumbnail version for navigator
const thumb = document.createElement("img");
thumb.src = img.src || (img as HTMLCanvasElement).toDataURL();
thumb.style.cssText = `
width: 100%;
height: auto;
display: block;
pointer-events: none;
`;
// Clear and populate container
state.container.innerHTML = "";
state.container.appendChild(thumb);
state.contentImage = thumb;
// Recreate viewport box
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;
width: ${100 / state.scale}%;
height: ${100 / state.scale}%;
`;
state.container.appendChild(viewport);
state.viewport = viewport;
// Re-attach drag handler
setupNavigatorDragHandler(state);
// Calculate viewport size relative to container
const containerRect = state.container.getBoundingClientRect();
const viewportWidth = (containerRect.width / img.width) * 100;
const viewportHeight = (containerRect.height / img.height) * 100;
viewport.style.width = `${viewportWidth}%`;
viewport.style.height = `${viewportHeight}%`;
}
// Handle window resize
function handleNavigatorResize(state: NavigatorState): void {
if (state.contentImage) {
updateNavigatorContent(state, state.currentPage);
}
}
export { initializeNavigator, updateNavigatorContent, handleNavigatorResize };
-49
View File
@@ -1,49 +0,0 @@
// Offline manager for PWA functionality
// Feature Registration Pattern implementation
import type { ReaderContext } from "./core/reader-context";
export function init(context: ReaderContext): void {
registerServiceWorker();
window.addEventListener("online", () => {
const isOnline = checkOnlineStatus();
if (isOnline) {
context.events.emit("offline:online", {});
syncPendingChanges(context);
}
});
window.addEventListener("offline", () => {
context.events.emit("offline:offline", {});
});
context.events.on("offline:check", () => {
const isOnline = checkOnlineStatus();
context.events.emit("offline:status", { isOnline });
});
}
export function registerServiceWorker(): void {
if ("serviceWorker" in navigator) {
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", {});
}
-247
View File
@@ -1,247 +0,0 @@
// Modular dockable panel system - handles drag, lock, snap-back, window-shade
// Procedural style: Functions, not classes
import {
saveSettings,
loadSettings,
getDefaultSettings,
} from "./settings-manager";
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;
}
const state: PanelDockState = {
panels: new Map(),
dragState: null,
dockZones: [
{ side: "left", x: 0, width: 400, height: window.innerHeight },
{
side: "right",
x: window.innerWidth - 400,
width: 400,
height: window.innerHeight,
},
],
};
// Initialize all panels from settings
function initializePanelDockSystem(): void {
const settings = loadSettings();
for (const [panelId, panelState] of Object.entries(settings.panel_layout)) {
registerPanel(panelId, panelState);
}
setupDragHandlers();
setupWindowShadeHandlers();
setupLockHandlers();
}
// Register a panel with the dock system
function registerPanel(panelId: string, panelState: PanelState): void {
state.panels.set(panelId, panelState);
applyPanelState(panelId, panelState);
}
// Apply panel state to DOM
function applyPanelState(panelId: string, panelState: PanelState): void {
const panel = document.querySelector(`[data-panel="${panelId}"]`);
if (!panel) return;
const container = panel.parentElement;
if (!container) return;
// Apply side positioning
if (panelState.side === "left") {
container.style.left = "0";
container.style.right = "auto";
} else if (panelState.side === "right") {
container.style.right = "0";
container.style.left = "auto";
} else {
container.style.left = "-9999px";
}
// Apply width
panel.style.width = `${panelState.width_px}px`;
// Apply collapsed (window-shade) state
if (panelState.collapsed) {
panel.classList.add("panel-collapsed");
panel.querySelector(".panel-content")?.classList.add("hidden");
} else {
panel.classList.remove("panel-collapsed");
panel.querySelector(".panel-content")?.classList.remove("hidden");
}
// Apply lock state
const lockBtn = panel.querySelector(".panel-lock");
if (lockBtn) {
lockBtn.textContent = panelState.locked ? "🔒" : "🔓";
}
}
// Setup mouse/touch drag handlers
function setupDragHandlers(): void {
document
.querySelectorAll(".dockable-panel .panel-header")
.forEach((header) => {
header.addEventListener("mousedown", handleDragStart);
header.addEventListener("touchstart", handleDragStart, {
passive: false,
});
});
document.addEventListener("mousemove", handleDragMove);
document.addEventListener("touchmove", handleDragMove, { passive: false });
document.addEventListener("mouseup", handleDragEnd);
document.addEventListener("touchend", handleDragEnd);
}
function handleDragStart(e: MouseEvent | TouchEvent): void {
const header = e.target.closest(".panel-header") as HTMLElement;
const panel = header?.closest(".dockable-panel") as HTMLElement;
if (!panel) return;
const panelId = panel.dataset.panel;
const panelState = state.panels.get(panelId);
// Check if locked
if (panelState?.locked) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
state.dragState = {
panelId: panelId!,
startX: clientX,
startY: clientY,
currentX: clientX,
currentY: clientY,
isLocked: panelState?.locked || false,
};
panel.classList.add("dragging");
}
function handleDragMove(e: MouseEvent | TouchEvent): void {
if (!state.dragState) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
state.dragState.currentX = clientX;
state.dragState.currentY = clientY;
const panel = document.querySelector(
`[data-panel="${state.dragState.panelId}"]`,
);
const container = panel?.parentElement;
if (container) {
container.style.transform = `translateX(${clientX - state.dragState.startX}px)`;
}
}
function handleDragEnd(e: MouseEvent | TouchEvent): void {
if (!state.dragState) return;
const { panelId, currentX } = state.dragState;
const panel = document.querySelector(`[data-panel="${panelId}"]`);
const container = panel?.parentElement;
// Reset transform
container.style.transform = "";
panel?.classList.remove("dragging");
// Determine drop zone
const newSide = currentX < window.innerWidth / 2 ? "left" : "right";
const panelState = state.panels.get(panelId);
if (panelState) {
// Check if dropped in valid zone
const isValidDrop = newSide === "left" || newSide === "right";
if (isValidDrop) {
panelState.last_valid_side = panelState.side; // Save previous valid position
panelState.side = newSide;
} else {
// Snap back to last valid position
panelState.side = panelState.last_valid_side;
}
applyPanelState(panelId, panelState);
savePanelState(panelId, panelState);
}
state.dragState = null;
}
// Setup window-shade (collapse/expand) handlers
function setupWindowShadeHandlers(): void {
document.querySelectorAll(".window-shade-toggle").forEach((btn) => {
btn.addEventListener("click", (e) => {
const panel = (e.target as HTMLElement).closest(
".dockable-panel",
) as HTMLElement;
const panelId = panel.dataset.panel;
const panelState = state.panels.get(panelId);
if (panelState) {
panelState.collapsed = !panelState.collapsed;
applyPanelState(panelId, panelState);
savePanelState(panelId, panelState);
}
});
});
}
// Setup lock toggle handlers
function setupLockHandlers(): void {
document.querySelectorAll(".panel-lock").forEach((btn) => {
btn.addEventListener("click", (e) => {
const panel = (e.target as HTMLElement).closest(
".dockable-panel",
) as HTMLElement;
const panelId = panel.dataset.panel;
const panelState = state.panels.get(panelId);
if (panelState) {
panelState.locked = !panelState.locked;
applyPanelState(panelId, panelState);
savePanelState(panelId, panelState);
}
});
});
}
// Persist panel state to settings
async function savePanelState(
panelId: string,
panelState: PanelState,
): Promise<void> {
const settings = loadSettings();
settings.panel_layout[panelId as keyof typeof settings.panel_layout] =
panelState;
await saveSettings(settings);
}
export { initializePanelDockSystem, registerPanel, applyPanelState };
-125
View File
@@ -1,125 +0,0 @@
// Track reading speed and update database
// Feature Registration Pattern implementation
import type { ReaderContext } from "./core/reader-context";
export function init(context: ReaderContext): void {
let state: ReadingSpeedTrackerState | null = null;
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
state = createReadingSpeedTracker(detail.mediaItemId);
});
context.events.on("reading-session:start", () => {
if (state) {
startReadingSession(state);
}
});
context.events.on("page-changed", () => {
if (state) {
recordPageTurn(state);
}
});
context.events.on("words-read", (detail: { wordCount: number }) => {
if (state) {
recordWordsRead(state, detail.wordCount);
}
});
context.events.on("reading-session:end", async () => {
if (state) {
await syncReadingSpeed(state);
}
});
context.events.on("reader:unload", async () => {
if (state) {
await syncReadingSpeed(state);
state = null;
}
});
}
interface ReadingSpeedTrackerState {
startTime: number | null;
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);
}
}