Add universal reader features: gestures, keyboard, navigator, offline

- gestures.ts: Touch gesture controls (swipe, pinch, tap, double-tap)
- keyboard-shortcuts.ts: Keyboard navigation (arrows, space, page up/down, etc)
- navigator-panel.ts: Affinity-style minimap for page navigation
- offline-manager.ts: Service worker registration and online status
- reading-speed-tracker.ts: Track and sync reading speed to database
- Add web manifest.json for PWA support
This commit is contained in:
2026-04-04 01:01:08 -04:00
parent 2046960563
commit 0c199963b9
6 changed files with 545 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
// Touch gesture controls for mobile/tablet
// Procedural implementation (no OOP)
interface GestureHandlers {
onSwipeLeft: () => void;
onSwipeRight: () => void;
onSwipeUp: () => void;
onSwipeDown: () => void;
onPinch: (scale: number) => void;
onTap: () => void;
onDoubleTap: () => void;
}
interface GestureState {
touchStartX: number;
touchStartY: number;
touchStartTime: number;
lastTapTime: number;
initialPinchDistance: number;
scale: number;
}
function setupGestureControls(
container: HTMLElement,
handlers: GestureHandlers,
): void {
let state: GestureState = {
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) {
handlers.onDoubleTap();
state.lastTapTime = 0;
} else {
state.lastTapTime = now;
setTimeout(() => {
if (state.lastTapTime !== 0) {
handlers.onTap();
}
}, 300);
}
return;
}
const minSwipeDistance = 50;
const maxSwipeTime = 500;
if (deltaTime > maxSwipeTime) return;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (deltaX > minSwipeDistance) {
handlers.onSwipeRight();
} else if (deltaX < -minSwipeDistance) {
handlers.onSwipeLeft();
}
} else {
if (deltaY > minSwipeDistance) {
handlers.onSwipeDown();
} else if (deltaY < -minSwipeDistance) {
handlers.onSwipeUp();
}
}
},
{ 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;
state.scale = scale;
handlers.onPinch(scale);
}
}
},
{ passive: true },
);
}
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);
}
+146
View File
@@ -0,0 +1,146 @@
// Extended keyboard shortcuts for all readers
// Procedural implementation (no OOP)
interface KeyboardShortcutHandler {
onNextPage: () => void;
onPreviousPage: () => void;
onNextChapter: () => void;
onPreviousChapter: () => void;
onGoToPage: (page: number) => void;
onToggleBookmark: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onToggleFullscreen: () => void;
onClose: () => void;
}
function setupKeyboardShortcuts(
container: HTMLElement,
handlers: KeyboardShortcutHandler,
maxPage: number,
): void {
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();
handlers.onNextPage();
break;
case "ArrowLeft":
case "PageUp":
case "h":
e.preventDefault();
handlers.onPreviousPage();
break;
case "ArrowUp":
case "k":
e.preventDefault();
handlers.onPreviousPage();
break;
case "ArrowDown":
case "j":
e.preventDefault();
handlers.onNextPage();
break;
case " ":
e.preventDefault();
handlers.onNextPage();
break;
case "Home":
e.preventDefault();
handlers.onGoToPage(1);
break;
case "End":
e.preventDefault();
handlers.onGoToPage(maxPage);
break;
case "b":
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
handlers.onToggleBookmark();
}
break;
case "+":
case "=":
e.preventDefault();
handlers.onZoomIn();
break;
case "-":
case "_":
e.preventDefault();
handlers.onZoomOut();
break;
case "0":
e.preventDefault();
handlers.onZoomIn();
handlers.onZoomIn();
handlers.onZoomIn();
break;
case "f":
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
handlers.onToggleFullscreen();
}
break;
case "Escape":
e.preventDefault();
handlers.onClose();
break;
default:
if (e.key >= "1" && e.key <= "9") {
const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage);
e.preventDefault();
handlers.onGoToPage(targetPage);
}
}
});
}
function showShortcutHelp(): void {
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">F</kbd> Fullscreen</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">1-9</kbd> Jump to %</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();
});
}
+152
View File
@@ -0,0 +1,152 @@
// 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 };
+32
View File
@@ -0,0 +1,32 @@
// Offline manager for PWA functionality
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;
}
// Listen for online/offline events
window.addEventListener("online", () => {
showToast("Back online", "success");
// Sync any pending changes
syncPendingChanges();
});
window.addEventListener("offline", () => {
showToast("You are offline. Some features may be limited.", "warning");
});
+78
View File
@@ -0,0 +1,78 @@
// Track reading speed and update database
// Reading speed tracker
// Procedural implementation (no OOP)
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 {
return {
...state,
startTime: Date.now(),
pagesRead: 0,
wordsRead: 0,
};
}
function recordPageTurn(
state: ReadingSpeedTrackerState,
): ReadingSpeedTrackerState {
if (!state.startTime) return state;
const newPagesRead = state.pagesRead + 1;
const now = Date.now();
if (newPagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) {
syncReadingSpeed({ ...state, pagesRead: newPagesRead });
return { ...state, pagesRead: newPagesRead, lastSync: now };
}
return { ...state, pagesRead: newPagesRead };
}
function recordWordsRead(
state: ReadingSpeedTrackerState,
wordCount: number,
): ReadingSpeedTrackerState {
return {
...state,
wordsRead: state.wordsRead + wordCount,
};
}
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;
await apiPut(`/readers/${state.mediaItemId}/reading-speed`, {
pages_per_minute: pagesPerMinute,
words_per_minute: wordsPerMinute,
pages_read: state.pagesRead,
total_reading_minutes: minutesElapsed,
});
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "Bookhoard Reader",
"short_name": "Reader",
"description": "Offline-capable ebook and comic reader",
"start_url": "/dashboard",
"display": "fullscreen",
"background_color": "#1a1b26",
"theme_color": "#1a1b26",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"offline_enabled": true
}