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.
211 lines
5.8 KiB
TypeScript
211 lines
5.8 KiB
TypeScript
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();
|
|
});
|
|
}
|