refactor(reader): create format-agnostic UI component layer
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.
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user