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:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user