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:
@@ -0,0 +1,274 @@
|
|||||||
|
import type { Panel } from "../comic/panel-detector";
|
||||||
|
import { detectPanels } from "../comic/panel-detection.service";
|
||||||
|
import type { ReaderContext } from "../core/reader-context";
|
||||||
|
|
||||||
|
let currentPanelIndex = 0;
|
||||||
|
let currentPagePanels: Panel[] = [];
|
||||||
|
|
||||||
|
export async function init(context: ReaderContext): Promise<void> {
|
||||||
|
setupGestures();
|
||||||
|
await loadPanelsIfComic();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGestures() {
|
||||||
|
const container = context.elements.readerContent;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
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) {
|
||||||
|
handleDoubleTap();
|
||||||
|
state.lastTapTime = 0;
|
||||||
|
} else {
|
||||||
|
state.lastTapTime = now;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (state.lastTapTime !== 0) {
|
||||||
|
handleTap();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minSwipeDistance = 50;
|
||||||
|
const maxSwipeTime = 500;
|
||||||
|
|
||||||
|
if (deltaTime > maxSwipeTime) return;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||||
|
if (deltaX > minSwipeDistance) {
|
||||||
|
handleSwipeRight();
|
||||||
|
} else if (deltaX < -minSwipeDistance) {
|
||||||
|
handleSwipeLeft();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (deltaY > minSwipeDistance) {
|
||||||
|
handleSwipeDown();
|
||||||
|
} else if (deltaY < -minSwipeDistance) {
|
||||||
|
handleSwipeUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ 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;
|
||||||
|
handlePinch(scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeLeft() {
|
||||||
|
const metadata = context.getState().readerMetadata;
|
||||||
|
if (metadata?.library_type === "manga") {
|
||||||
|
navigateWithPanels("next");
|
||||||
|
} else {
|
||||||
|
context.navigation.previousPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeRight() {
|
||||||
|
const metadata = context.getState().readerMetadata;
|
||||||
|
if (metadata?.library_type === "manga") {
|
||||||
|
navigateWithPanels("previous");
|
||||||
|
} else {
|
||||||
|
context.navigation.nextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeUp() {
|
||||||
|
const chrome = context.elements.chrome;
|
||||||
|
if (chrome) chrome.classList.remove("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeDown() {
|
||||||
|
const chrome = context.elements.chrome;
|
||||||
|
if (chrome) chrome.classList.add("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTap() {
|
||||||
|
const chrome = context.elements.chrome;
|
||||||
|
if (chrome) chrome.classList.toggle("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDoubleTap() {
|
||||||
|
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 = scale === 1 ? 1.5 : 1;
|
||||||
|
container.style.transform = `scale(${newScale})`;
|
||||||
|
container.style.transformOrigin = "center center";
|
||||||
|
context.events.emit("zoomChanged", newScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePinch(scale: number) {
|
||||||
|
const container = context.elements.readerContent;
|
||||||
|
if (container && scale >= 0.5 && scale <= 3) {
|
||||||
|
container.style.transform = `scale(${scale})`;
|
||||||
|
container.style.transformOrigin = "center center";
|
||||||
|
context.events.emit("zoomChanged", scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPanelsIfComic() {
|
||||||
|
const state = context.getState();
|
||||||
|
if (
|
||||||
|
!state.currentReader ||
|
||||||
|
(state.currentReader.type !== "comic" &&
|
||||||
|
state.currentReader.type !== "manga")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = state.currentReader as any;
|
||||||
|
if (
|
||||||
|
!reader.images ||
|
||||||
|
reader.currentPage === undefined ||
|
||||||
|
reader.currentPage < 0 ||
|
||||||
|
reader.currentPage >= reader.images.length
|
||||||
|
) {
|
||||||
|
currentPagePanels = [];
|
||||||
|
currentPanelIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageBlob = reader.images[reader.currentPage];
|
||||||
|
const imageData = await blobToImageData(imageBlob);
|
||||||
|
const result = await detectPanels(imageData, false);
|
||||||
|
currentPagePanels = result.panels;
|
||||||
|
currentPanelIndex = 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load panels:", error);
|
||||||
|
currentPagePanels = [];
|
||||||
|
currentPanelIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToImageData(blob: Blob): Promise<ImageData> {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error("Failed to get canvas context"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(imageData);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error("Failed to load image"));
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateWithPanels(direction: "next" | "previous") {
|
||||||
|
if (currentPagePanels.length === 0) {
|
||||||
|
if (direction === "next") {
|
||||||
|
context.navigation.nextPage();
|
||||||
|
} else {
|
||||||
|
context.navigation.previousPage();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
direction === "next" &&
|
||||||
|
currentPanelIndex < currentPagePanels.length - 1
|
||||||
|
) {
|
||||||
|
currentPanelIndex++;
|
||||||
|
scrollToPanel(currentPanelIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "previous" && currentPanelIndex > 0) {
|
||||||
|
currentPanelIndex--;
|
||||||
|
scrollToPanel(currentPanelIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more panels, go to next/previous page
|
||||||
|
if (direction === "next") {
|
||||||
|
context.navigation.nextPage();
|
||||||
|
} else {
|
||||||
|
context.navigation.previousPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToPanel(panelIndex: number) {
|
||||||
|
const panel = currentPagePanels[panelIndex];
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const container = context.elements.readerContent;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const panelElement =
|
||||||
|
container.querySelector(`[data-panel-id="${panel.id}"]`) ||
|
||||||
|
container.querySelector(`#${panel.id}`);
|
||||||
|
|
||||||
|
if (panelElement) {
|
||||||
|
panelElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
document
|
||||||
|
.querySelectorAll(".panel-current")
|
||||||
|
.forEach((el) => el.classList.remove("panel-current"));
|
||||||
|
panelElement.classList.add("panel-current");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import type { ReaderContext } from "../core/reader-context";
|
||||||
|
|
||||||
|
export async function init(context: ReaderContext): Promise<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user