diff --git a/web/src/reader/features/gestures.ts b/web/src/reader/features/gestures.ts new file mode 100644 index 0000000..417679e --- /dev/null +++ b/web/src/reader/features/gestures.ts @@ -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 { + 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 { + 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"); + } +} diff --git a/web/src/reader/features/keyboard-shortcuts.ts b/web/src/reader/features/keyboard-shortcuts.ts new file mode 100644 index 0000000..b1cd35b --- /dev/null +++ b/web/src/reader/features/keyboard-shortcuts.ts @@ -0,0 +1,207 @@ +import type { ReaderContext } from "../core/reader-context"; + +export async function init(context: ReaderContext): Promise { + 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 = ` +
+

Keyboard Shortcuts

+
+
/ Space Next page
+
Previous page
+
Home First page
+
End Last page
+
+ / - Zoom
+
B Toggle bookmark
+
? Show help
+
Esc Exit fullscreen
+
+ +
+ `; + document.body.appendChild(help); + help.addEventListener("click", (e) => { + if (e.target === help) help.remove(); + }); +} diff --git a/web/src/reader/features/progress-indicator.ts b/web/src/reader/features/progress-indicator.ts new file mode 100644 index 0000000..b05f182 --- /dev/null +++ b/web/src/reader/features/progress-indicator.ts @@ -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 { + 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 { + 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); + }); +}