feat(reader): implement gestures and keyboard-shortcuts as feature modules

- Move gestures.ts to features/ with init(context) pattern
- Move keyboard-shortcuts.ts to features/ with init(context) pattern
- Remove callback-based architecture
- Features now subscribe to events via ReaderContext
- Support touch gestures (swipe, tap, double-tap, pinch-to-zoom)
- Support keyboard shortcuts (navigation, zoom, fullscreen, bookmarks)
- Add panel-aware navigation for comics/manga
- Keyboard shortcuts include chapter navigation
This commit is contained in:
2026-04-04 13:37:52 -04:00
parent 96d90db036
commit e076b0d0b2
3 changed files with 617 additions and 0 deletions
@@ -0,0 +1,136 @@
import type { ReaderContext } from "../core/reader-context";
interface ProgressDisplay {
mode: "pages" | "chapter" | "percentage" | "time-left";
text: string;
}
export async function init(context: ReaderContext): Promise<void> {
context.events.on("pageChanged", () => {
updateProgressDisplay(context);
});
context.events.on("progressUpdated", () => {
updateProgressDisplay(context);
});
setupProgressModeCycling(context);
}
export { calculateProgress, cycleProgressMode };
export type { ProgressDisplay };
function calculateProgress(
currentPage: number,
totalPages: number,
currentChapterPage: number,
chapterPages: number,
readingSpeed?: ReadingSpeed,
): ProgressDisplay {
const mode = getCurrentProgressMode();
switch (mode) {
case "pages":
return {
mode: "pages",
text: `${currentPage}/${totalPages}`,
};
case "chapter":
return {
mode: "chapter",
text: `${currentChapterPage}/${chapterPages}`,
};
case "percentage":
const percentage = Math.round((currentPage / totalPages) * 100);
return {
mode: "percentage",
text: `${percentage}%`,
};
case "time-left":
if (!readingSpeed || readingSpeed.pages_per_minute === 0) {
return { mode: "time-left", text: "--:--" };
}
const pagesLeft = totalPages - currentPage;
const minutesLeft = pagesLeft / readingSpeed.pages_per_minute;
const hours = Math.floor(minutesLeft / 60);
const mins = Math.round(minutesLeft % 60);
return {
mode: "time-left",
text: `${hours}h ${mins}m`,
};
default:
return { mode: "pages", text: `${currentPage}/${totalPages}` };
}
}
function cycleProgressMode(): void {
const modes: Array<"pages" | "chapter" | "percentage" | "time-left"> = [
"pages",
"chapter",
"percentage",
"time-left",
];
const currentMode = getCurrentProgressMode();
const currentIndex = modes.indexOf(currentMode);
const nextMode = modes[(currentIndex + 1) % modes.length];
setProgressMode(nextMode);
}
function getCurrentProgressMode(): ReaderSettings["progress_mode"] {
const settingsStr = localStorage.getItem("reader_settings_progress_mode");
if (settingsStr) {
try {
return JSON.parse(settingsStr);
} catch {
return "pages";
}
}
return "pages";
}
function setProgressMode(mode: ReaderSettings["progress_mode"]): void {
localStorage.setItem("reader_settings_progress_mode", JSON.stringify(mode));
const display = document.getElementById("progress-display");
if (display) {
display.dataset.mode = mode;
}
}
async function getReadingSpeed(): Promise<ReadingSpeed | null> {
const speedStr = localStorage.getItem("reader_speed_data");
if (speedStr) {
try {
return JSON.parse(speedStr);
} catch {
return null;
}
}
return null;
}
function updateProgressDisplay(context: ReaderContext): void {
const state = context.getState();
const display = context.elements.progressDisplay;
if (!display || !state.currentReader) return;
let currentPage = 0;
let totalPages = 0;
if (state.currentReader.type === "ebook") {
currentPage = state.currentReader.currentSpineIndex + 1;
totalPages = state.currentReader.cif.spine.length;
} else if (state.currentReader.type === "pdf") {
currentPage = state.currentReader.currentPage;
totalPages = state.readerMetadata?.total_pages || 0;
} else {
currentPage = state.currentReader.currentPage;
totalPages = state.currentReader.images.length;
}
getReadingSpeed().then((speed) => {
const result = calculateProgress(
currentPage,
totalPages,
0,
0,
speed || undefined,
);
display.textContent = result.text;
display.dataset.mode = result.mode;
});
}
function setupProgressModeCycling(context: ReaderContext): void {
const display = context.elements.progressDisplay;
if (!display) return;
display.style.cursor = "pointer";
display.addEventListener("click", () => {
cycleProgressMode();
updateProgressDisplay(context);
});
}