From f91b46cfa5c88426b3fc712d2a63c2e248a7e317 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 23 May 2026 21:33:02 -0400 Subject: [PATCH] Reader core parity --- src/lib/core/reader/chapterLoader.ts | 54 ++ src/lib/core/reader/navigation.ts | 104 +++ src/lib/core/reader/pageLoader.ts | 50 ++ src/lib/core/reader/pinchZoom.ts | 68 ++ src/lib/core/reader/readerKeybinds.ts | 115 ++++ src/lib/core/reader/scrollHandler.ts | 142 ++++ src/lib/core/reader/session.ts | 117 +--- src/lib/core/reader/zoomHelpers.ts | 30 + src/lib/state/reader.svelte.ts | 29 +- .../reader/[mangaId]/[chapterId]/+page.svelte | 638 +++++++++++++++++- 10 files changed, 1234 insertions(+), 113 deletions(-) create mode 100644 src/lib/core/reader/chapterLoader.ts create mode 100644 src/lib/core/reader/navigation.ts create mode 100644 src/lib/core/reader/pageLoader.ts create mode 100644 src/lib/core/reader/pinchZoom.ts create mode 100644 src/lib/core/reader/readerKeybinds.ts create mode 100644 src/lib/core/reader/scrollHandler.ts create mode 100644 src/lib/core/reader/zoomHelpers.ts diff --git a/src/lib/core/reader/chapterLoader.ts b/src/lib/core/reader/chapterLoader.ts new file mode 100644 index 0000000..0a35d0f --- /dev/null +++ b/src/lib/core/reader/chapterLoader.ts @@ -0,0 +1,54 @@ +import {getAdapter} from '$lib/request-manager'; +import {loadChapterPages} from '$lib/request-manager/chapters'; +import {readerState} from '$lib/state/reader.svelte'; +import {sortChapters} from './navigation'; + +/** + * Load (or resume) a reader session for the given manga and chapter. + * Caches manga/chapter list when the manga ID hasn't changed to avoid redundant fetches. + * Resumes at the reader's last saved page position. + */ +export async function ensureReaderSession( + mangaId: string, + chapterId: string, +): Promise { + const adapter = getAdapter(); + + const mangaPromise = + readerState.manga && String(readerState.manga.id) === mangaId + ? Promise.resolve(readerState.manga) + : adapter.getManga(mangaId); + + const chaptersPromise = + readerState.chapters.length > 0 && + String(readerState.chapters[0]?.mangaId) === mangaId + ? Promise.resolve(readerState.chapters) + : adapter.getChapters(mangaId); + + const [manga, chapters] = await Promise.all([mangaPromise, chaptersPromise]); + + const chapter = + chapters.find((ch) => String(ch.id) === chapterId) ?? + (String(readerState.chapter?.id) === chapterId ? readerState.chapter : null) ?? + (await adapter.getChapter(chapterId)); + + readerState.manga = manga; + readerState.chapters = chapters; + readerState.chapter = chapter; + readerState.pages = []; + readerState.currentPage = 0; + readerState.pagesError = null; + + await loadChapterPages(chapterId); + + if (readerState.pages.length > 0) { + const resumeIndex = Math.max(0, (chapter.lastPageRead ?? 1) - 1); + readerState.currentPage = Math.min(resumeIndex, readerState.pages.length - 1); + } +} + +/** + * Return the sorted chapter list for the current manga ordered by source order. + * Convenience re-export for callers that only need adjacent chapter lookups. + */ +export {sortChapters}; diff --git a/src/lib/core/reader/navigation.ts b/src/lib/core/reader/navigation.ts new file mode 100644 index 0000000..236fe3e --- /dev/null +++ b/src/lib/core/reader/navigation.ts @@ -0,0 +1,104 @@ +import {getAdapter} from '$lib/request-manager'; +import {loadChapterPages, updateProgress} from '$lib/request-manager/chapters'; +import {readerState} from '$lib/state/reader.svelte'; +import type {Chapter} from '$lib/types'; + +export function sortChapters(chapters: Chapter[]): Chapter[] { + return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); +} + +function currentChapterIndex(): number { + if (!readerState.chapter) return -1; + return sortChapters(readerState.chapters).findIndex( + (ch) => String(ch.id) === String(readerState.chapter?.id), + ); +} + +function clampPageIndex(index: number): number { + if (readerState.pages.length === 0) return 0; + return Math.min(Math.max(index, 0), readerState.pages.length - 1); +} + +export function getAdjacentChapters(): { + previous: Chapter | null; + next: Chapter | null; +} { + const chapters = sortChapters(readerState.chapters); + const index = currentChapterIndex(); + return { + previous: index > 0 ? (chapters[index - 1] ?? null) : null, + next: index >= 0 && index < chapters.length - 1 ? (chapters[index + 1] ?? null) : null, + }; +} + +export async function setCurrentReaderPage(index: number): Promise { + const nextIndex = clampPageIndex(index); + readerState.currentPage = nextIndex; + + if (!readerState.chapter || readerState.pages.length === 0) return; + + const lastPageRead = nextIndex + 1; + const completed = lastPageRead >= readerState.pages.length; + + if ( + readerState.chapter.lastPageRead === lastPageRead && + readerState.chapter.read === completed + ) { + return; + } + + try { + await updateProgress(String(readerState.chapter.id), lastPageRead, completed); + } catch (error) { + readerState.pagesError = error instanceof Error ? error.message : String(error); + } +} + +export async function goToNextReaderPage(): Promise { + if (readerState.currentPage >= readerState.pages.length - 1) return false; + await setCurrentReaderPage(readerState.currentPage + 1); + return true; +} + +export async function goToPreviousReaderPage(): Promise { + if (readerState.currentPage <= 0) return false; + await setCurrentReaderPage(readerState.currentPage - 1); + return true; +} + +export async function ensureReaderSession( + mangaId: string, + chapterId: string, +): Promise { + const adapter = getAdapter(); + + const mangaPromise = + readerState.manga && String(readerState.manga.id) === mangaId + ? Promise.resolve(readerState.manga) + : adapter.getManga(mangaId); + + const chaptersPromise = + readerState.chapters.length > 0 && + String(readerState.chapters[0]?.mangaId) === mangaId + ? Promise.resolve(readerState.chapters) + : adapter.getChapters(mangaId); + + const [manga, chapters] = await Promise.all([mangaPromise, chaptersPromise]); + const chapter = + chapters.find((ch) => String(ch.id) === chapterId) ?? + (String(readerState.chapter?.id) === chapterId ? readerState.chapter : null) ?? + (await adapter.getChapter(chapterId)); + + readerState.manga = manga; + readerState.chapters = chapters; + readerState.chapter = chapter; + readerState.pages = []; + readerState.currentPage = 0; + readerState.pagesError = null; + + await loadChapterPages(chapterId); + + if (readerState.pages.length > 0) { + readerState.currentPage = clampPageIndex((chapter.lastPageRead ?? 1) - 1); + } +} diff --git a/src/lib/core/reader/pageLoader.ts b/src/lib/core/reader/pageLoader.ts new file mode 100644 index 0000000..24e8ede --- /dev/null +++ b/src/lib/core/reader/pageLoader.ts @@ -0,0 +1,50 @@ +import type {Page} from '$lib/server-adapters/types'; + +/** + * Build double-page spread groups for a given page count. + * Groups are 1-based page numbers. Wide pages (aspect ratio > 1.2) get their own group. + * `offsetSpreads` causes the first pairing to start at page 2 (common for manga with a cover). + */ +export function buildPageGroups( + count: number, + aspects: number[], + offsetSpreads: boolean, +): number[][] { + if (count === 0) return []; + + const groups: number[][] = [[1]]; + if (offsetSpreads && count > 1) groups.push([2]); + + let i = offsetSpreads ? 3 : 2; + while (i <= count) { + const aspect = aspects[i - 1] ?? 1; + if (aspect > 1.2 || i === count) { + groups.push([i++]); + } else { + groups.push([i, i + 1]); + i += 2; + } + } + return groups; +} + +/** + * Imperatively kick off browser image preloading for a URL. + * Fire-and-forget; errors are silently swallowed. + */ +export function preloadImage(url: string): void { + if (!url || typeof document === 'undefined') return; + const img = new Image(); + img.src = url; +} + +/** + * Preload a window of pages ahead of the current position. + */ +export function preloadPages(pages: Page[], currentIndex: number, windowSize = 3): void { + const end = Math.min(currentIndex + windowSize, pages.length); + for (let i = currentIndex + 1; i < end; i++) { + const p = pages[i]; + if (p) preloadImage(p.imageData ?? p.url); + } +} diff --git a/src/lib/core/reader/pinchZoom.ts b/src/lib/core/reader/pinchZoom.ts new file mode 100644 index 0000000..00e5924 --- /dev/null +++ b/src/lib/core/reader/pinchZoom.ts @@ -0,0 +1,68 @@ +import {createPinchGesture} from '$lib/core/ui/touchscreen'; +import type {PinchGesture} from '$lib/core/ui/touchscreen'; +import {clampZoom, ZOOM_MIN, ZOOM_MAX} from './zoomHelpers'; + +export type {PinchGesture as PinchTracker}; + +/** Max zoom level allowed in single-page inspect mode (pan+zoom overlay). */ +const INSPECT_ZOOM_MAX = 8; + +export interface PinchTrackerOptions { + /** Get the current reader-level zoom (longstrip scaling). */ + getZoom: () => number; + /** Set a new reader-level zoom. */ + setZoom: (value: number) => void; + /** Get the current inspect-mode zoom scale for single-page view. */ + getInspectScale: () => number; + /** Set inspect-mode zoom scale. */ + setInspectScale: (value: number) => void; + /** Reset inspect-mode pan offsets to origin. */ + resetInspectPan: () => void; + /** Returns true when the reader is in longstrip mode. */ + isLongstrip: () => boolean; +} + +/** + * Create a pinch-gesture tracker that drives reader zoom. + * + * In longstrip mode pinch controls the global strip zoom level. + * In single/double mode pinch controls the in-page inspect zoom. + * + * Usage — wire the returned handler methods to the container element: + * ```svelte + *
+ * ``` + */ +export function createPinchTracker(opts: PinchTrackerOptions): PinchGesture { + let startZoom = 0; + let startInspect = 0; + + return createPinchGesture({ + onPinch(scale) { + if (startZoom === 0) { + startZoom = opts.getZoom(); + startInspect = opts.getInspectScale(); + } + + if (opts.isLongstrip()) { + opts.setZoom(clampZoom(startZoom * scale, ZOOM_MIN, ZOOM_MAX)); + } else { + const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * scale)); + if (next !== opts.getInspectScale()) { + if (next <= 1) opts.resetInspectPan(); + opts.setInspectScale(next); + } + } + }, + + onPinchEnd() { + startZoom = 0; + startInspect = 0; + }, + }); +} diff --git a/src/lib/core/reader/readerKeybinds.ts b/src/lib/core/reader/readerKeybinds.ts new file mode 100644 index 0000000..9ca5723 --- /dev/null +++ b/src/lib/core/reader/readerKeybinds.ts @@ -0,0 +1,115 @@ +import {matchesKeybind, toggleFullscreen} from '$lib/core/keybinds/keybindEngine'; +import type {Keybinds} from '$lib/core/keybinds/defaultBinds'; + +export interface ReaderKeyActions { + /** Navigate one step forward (respects RTL). */ + goNext: () => void; + /** Navigate one step backward (respects RTL). */ + goPrev: () => void; + /** Jump to a specific 0-based page index. */ + goToPage: (index: number) => void; + /** Return the 0-based index of the last page. */ + lastPage: () => number; + /** Close the reader and return to the series page. */ + exitReader: () => void; + /** Jump to the next chapter. */ + chapterNext: () => void; + /** Jump to the previous chapter. */ + chapterPrev: () => void; + /** Adjust reader zoom by delta (positive = zoom in, negative = zoom out). */ + adjustZoom: (delta: number) => void; + /** Reset zoom to 1.0. */ + resetZoom: () => void; + /** Cycle through available page display modes. */ + cycleMode: () => void; + /** Toggle between LTR and RTL reading direction. */ + toggleDirection: () => void; + /** Open the settings panel or navigate to /settings. */ + openSettings: () => void; + /** Toggle the bookmark on the current chapter/page. */ + toggleBookmark: () => void; + /** Toggle auto-scroll in longstrip mode. */ + toggleAutoScroll: () => void; + /** Return the current keybind configuration. */ + getKeybinds: () => Keybinds; +} + +const CTRL_ZOOM_STEP = 0.1; + +/** + * Create a keydown event handler for the reader with the given action callbacks. + * Suitable for use as `svelte:window onkeydown={handler}` in the reader page. + */ +export function createReaderKeyHandler( + actions: ReaderKeyActions, +): (event: KeyboardEvent) => void { + return function onKey(event: KeyboardEvent) { + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + + // Ctrl +/-/0 zoom shortcuts (standard browser-style overrides) + if (event.ctrlKey) { + if (event.key === '=' || event.key === '+') { + event.preventDefault(); + actions.adjustZoom(CTRL_ZOOM_STEP); + return; + } + if (event.key === '-') { + event.preventDefault(); + actions.adjustZoom(-CTRL_ZOOM_STEP); + return; + } + if (event.key === '0') { + event.preventDefault(); + actions.resetZoom(); + return; + } + } + + const kb = actions.getKeybinds(); + + if (matchesKeybind(event, kb.exitReader)) { + event.preventDefault(); + actions.exitReader(); + } else if (event.key === 'Escape') { + event.preventDefault(); + actions.exitReader(); + } else if (matchesKeybind(event, kb.turnPageRight)) { + event.preventDefault(); + actions.goNext(); + } else if (matchesKeybind(event, kb.turnPageLeft)) { + event.preventDefault(); + actions.goPrev(); + } else if (matchesKeybind(event, kb.firstPage)) { + event.preventDefault(); + actions.goToPage(0); + } else if (matchesKeybind(event, kb.lastPage)) { + event.preventDefault(); + actions.goToPage(actions.lastPage()); + } else if (matchesKeybind(event, kb.turnChapterRight)) { + event.preventDefault(); + actions.chapterNext(); + } else if (matchesKeybind(event, kb.turnChapterLeft)) { + event.preventDefault(); + actions.chapterPrev(); + } else if (matchesKeybind(event, kb.togglePageStyle)) { + event.preventDefault(); + actions.cycleMode(); + } else if (matchesKeybind(event, kb.toggleReadingDirection)) { + event.preventDefault(); + actions.toggleDirection(); + } else if (matchesKeybind(event, kb.toggleFullscreen)) { + event.preventDefault(); + void toggleFullscreen(); + } else if (matchesKeybind(event, kb.openSettings)) { + event.preventDefault(); + actions.openSettings(); + } else if (matchesKeybind(event, kb.toggleBookmark)) { + event.preventDefault(); + actions.toggleBookmark(); + } else if (matchesKeybind(event, kb.toggleAutoScroll)) { + event.preventDefault(); + actions.toggleAutoScroll(); + } + }; +} diff --git a/src/lib/core/reader/scrollHandler.ts b/src/lib/core/reader/scrollHandler.ts new file mode 100644 index 0000000..836584f --- /dev/null +++ b/src/lib/core/reader/scrollHandler.ts @@ -0,0 +1,142 @@ +/** Fraction from the top of the viewport used as the "active page" read line. */ +export const READ_LINE_PCT = 0.5; + +export interface StripChapter { + chapterId: string; + chapterName: string; + pageCount: number; +} + +export interface ScrollHandlerCallbacks { + /** Called when the visible page index changes (0-based). */ + onPageChange: (pageIndex: number) => void; + /** Called when the visible chapter changes in multi-chapter strip mode. */ + onChapterChange: (chapterId: string) => void; + /** Called when a chapter has been fully scrolled past (auto-mark-read). */ + onMarkRead: (chapterId: string) => void; + /** Called when the reader is near the bottom and should load the next chapter. */ + onAppend: () => void; + /** Return the current list of strip chapters for auto-mark calculations. */ + getStripChapters: () => StripChapter[]; + /** Whether to automatically mark chapters read on scroll. */ + shouldAutoMark: () => boolean; +} + +/** + * Attach scroll-position tracking to a longstrip container element. + * Returns a cleanup function to remove all listeners. + * + * Images in the container must have `data-page-index` (0-based) and optionally + * `data-chapter-id` attributes for multi-chapter strip tracking. + */ +export function setupScrollTracking( + containerEl: HTMLElement, + callbacks: ScrollHandlerCallbacks, +): () => void { + const { + onPageChange, + onChapterChange, + onMarkRead, + onAppend, + getStripChapters, + shouldAutoMark, + } = callbacks; + + let rafId: number | null = null; + + function tick() { + rafId = null; + + const imgs = containerEl.querySelectorAll('img[data-page-index]'); + if (!imgs.length) return; + + const containerTop = containerEl.getBoundingClientRect().top; + const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT; + + // Binary search for the last image whose top edge is above the read line + let lo = 0, hi = imgs.length - 1, best = 0; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if ((imgs[mid] as HTMLElement).getBoundingClientRect().top <= readLineY) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + const active = imgs[best] as HTMLElement; + const pageIndex = Number(active.dataset.pageIndex); + const chapterId = active.dataset.chapterId ?? null; + + onPageChange(pageIndex); + if (chapterId) onChapterChange(chapterId); + + if (shouldAutoMark() && chapterId) { + const chunks = getStripChapters(); + const chunk = chunks.find((c) => c.chapterId === chapterId); + if (chunk && pageIndex >= chunk.pageCount - 1) { + onMarkRead(chapterId); + } + + const atBottom = + containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 60; + if (atBottom) { + const last = chunks[chunks.length - 1]; + if (last) onMarkRead(last.chapterId); + } + } + + // Trigger appending next chapter when 80% scrolled + const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight; + if (pct >= 0.8) onAppend(); + } + + function onScroll() { + if (rafId !== null) return; + rafId = requestAnimationFrame(tick); + } + + containerEl.addEventListener('scroll', onScroll, {passive: true}); + + return () => { + containerEl.removeEventListener('scroll', onScroll); + if (rafId !== null) cancelAnimationFrame(rafId); + }; +} + +/** + * Append the next chapter's pages to a strip view. + * + * Finds the chapter after the last currently-loaded strip chapter, fetches its + * pages, and calls `onAppended` with the new chunk. Calls `onDone` when finished + * (success or no-op). + */ +export async function appendNextChapter( + stripChapters: StripChapter[], + chapterList: {id: string; name: string;}[], + fetchPageCount: (chapterId: string) => Promise, + onAppended: (next: StripChapter) => void, + onDone: () => void, +): Promise { + if (!stripChapters.length) {onDone(); return; } + + const lastChunk = stripChapters[stripChapters.length - 1]; + if (!lastChunk) {onDone(); return; } + + const lastIdx = chapterList.findIndex((c) => c.id === lastChunk.chapterId); + if (lastIdx < 0 || lastIdx >= chapterList.length - 1) {onDone(); return; } + + const next = chapterList[lastIdx + 1]; + if (!next || stripChapters.some((c) => c.chapterId === next.id)) {onDone(); return; } + + try { + const pageCount = await fetchPageCount(next.id); + if (stripChapters.some((c) => c.chapterId === next.id)) {onDone(); return; } + onAppended({chapterId: next.id, chapterName: next.name, pageCount}); + } catch { + // swallow – caller retries on next scroll trigger + } finally { + onDone(); + } +} diff --git a/src/lib/core/reader/session.ts b/src/lib/core/reader/session.ts index a802a35..f38a432 100644 --- a/src/lib/core/reader/session.ts +++ b/src/lib/core/reader/session.ts @@ -1,101 +1,18 @@ -import {getAdapter} from '$lib/request-manager'; -import {loadChapterPages, updateProgress} from '$lib/request-manager/chapters'; -import {readerState} from '$lib/state/reader.svelte'; -import type {Chapter} from '$lib/types'; +/** + * @deprecated Import directly from the specific reader core modules: + * - chapterLoader.ts → ensureReaderSession, sortChapters + * - navigation.ts → getAdjacentChapters, setCurrentReaderPage, goToNextReaderPage, goToPreviousReaderPage + * + * This file is kept for backward-compatibility only. + */ +export { + ensureReaderSession, +} from './chapterLoader'; -function sortChapters(chapters: Chapter[]): Chapter[] { - return [...chapters].sort((left, right) => left.sourceOrder - right.sourceOrder); -} - -function currentChapterIndex(): number { - if (!readerState.chapter) return -1; - - return sortChapters(readerState.chapters).findIndex( - (chapter) => String(chapter.id) === String(readerState.chapter?.id) - ); -} - -function clampPageIndex(index: number): number { - if (readerState.pages.length === 0) return 0; - return Math.min(Math.max(index, 0), readerState.pages.length - 1); -} - -export function getAdjacentChapters() { - const chapters = sortChapters(readerState.chapters); - const index = currentChapterIndex(); - - return { - previous: index > 0 ? chapters[index - 1] : null, - next: index >= 0 && index < chapters.length - 1 ? chapters[index + 1] : null, - }; -} - -export async function ensureReaderSession(mangaId: string, chapterId: string) { - const adapter = getAdapter(); - - const mangaPromise = - readerState.manga && String(readerState.manga.id) === mangaId - ? Promise.resolve(readerState.manga) - : adapter.getManga(mangaId); - - const chaptersPromise = - readerState.chapters.length > 0 && String(readerState.chapters[0]?.mangaId) === mangaId - ? Promise.resolve(readerState.chapters) - : adapter.getChapters(mangaId); - - const [manga, chapters] = await Promise.all([mangaPromise, chaptersPromise]); - const chapter = - chapters.find((entry) => String(entry.id) === chapterId) ?? - (String(readerState.chapter?.id) === chapterId ? readerState.chapter : null) ?? - (await adapter.getChapter(chapterId)); - - readerState.manga = manga; - readerState.chapters = chapters; - readerState.chapter = chapter; - readerState.pages = []; - readerState.currentPage = 0; - readerState.pagesError = null; - - await loadChapterPages(chapterId); - - if (readerState.pages.length > 0) { - readerState.currentPage = clampPageIndex((chapter.lastPageRead ?? 1) - 1); - } -} - -export async function setCurrentReaderPage(index: number) { - const nextIndex = clampPageIndex(index); - readerState.currentPage = nextIndex; - - if (!readerState.chapter || readerState.pages.length === 0) return; - - const lastPageRead = nextIndex + 1; - const completed = lastPageRead >= readerState.pages.length; - - if ( - readerState.chapter.lastPageRead === lastPageRead && - readerState.chapter.read === completed - ) { - return; - } - - try { - await updateProgress(String(readerState.chapter.id), lastPageRead, completed); - } catch (error) { - readerState.pagesError = error instanceof Error ? error.message : String(error); - } -} - -export async function goToNextReaderPage(): Promise { - if (readerState.currentPage >= readerState.pages.length - 1) return false; - - await setCurrentReaderPage(readerState.currentPage + 1); - return true; -} - -export async function goToPreviousReaderPage(): Promise { - if (readerState.currentPage <= 0) return false; - - await setCurrentReaderPage(readerState.currentPage - 1); - return true; -} \ No newline at end of file +export { + sortChapters, + getAdjacentChapters, + setCurrentReaderPage, + goToNextReaderPage, + goToPreviousReaderPage, +} from './navigation'; diff --git a/src/lib/core/reader/zoomHelpers.ts b/src/lib/core/reader/zoomHelpers.ts new file mode 100644 index 0000000..5cd3e9d --- /dev/null +++ b/src/lib/core/reader/zoomHelpers.ts @@ -0,0 +1,30 @@ +export const ZOOM_MIN = 0.25; +export const ZOOM_MAX = 4.0; +export const ZOOM_STEP = 0.1; + +/** + * Clamp a zoom value between the reader's min/max bounds. + */ +export function clampZoom(value: number, min = ZOOM_MIN, max = ZOOM_MAX): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Return the next zoom level after applying a delta, clamped to valid bounds. + * Rounded to avoid floating point drift. + */ +export function adjustZoom(current: number, delta: number): number { + return clampZoom(Math.round((current + delta) * 1000) / 1000); +} + +/** + * Snap to a list of named presets (0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0). + * Returns the nearest preset value to the given zoom. + */ +export const ZOOM_PRESETS = [0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0] as const; + +export function snapToPreset(value: number): number { + return ZOOM_PRESETS.reduce((best, preset) => + Math.abs(preset - value) < Math.abs(best - value) ? preset : best + ); +} diff --git a/src/lib/state/reader.svelte.ts b/src/lib/state/reader.svelte.ts index a5528f3..e23aed1 100644 --- a/src/lib/state/reader.svelte.ts +++ b/src/lib/state/reader.svelte.ts @@ -1,9 +1,9 @@ -import type { Manga, Chapter } from '$lib/types' -import type { Page } from '$lib/server-adapters/types' +import type {Manga, Chapter} from '$lib/types'; +import type {Page} from '$lib/server-adapters/types'; -export type ReadMode = 'single' | 'strip' -export type FitMode = 'width' | 'height' | 'original' -export type ReadDirection = 'ltr' | 'rtl' +export type ReadMode = 'single' | 'strip'; +export type FitMode = 'width' | 'height' | 'original'; +export type ReadDirection = 'ltr' | 'rtl'; export const readerState = $state({ manga: null as Manga | null, @@ -20,22 +20,31 @@ export const readerState = $state({ direction: 'ltr' as ReadDirection, zoom: 1, + /** Inspect-mode zoom for single-page view (1 = no magnification). */ + inspectScale: 1, + /** Inspect-mode pan offset in CSS pixels. */ + inspectPanX: 0, + inspectPanY: 0, + + /** Whether auto-scroll is currently active in longstrip mode. */ + autoScrollActive: false, + showControls: false, showSettings: false, fullscreen: false, -}) +}); export const currentPageData = $derived( readerState.pages[readerState.currentPage] ?? null -) +); export const progress = $derived( readerState.pages.length > 0 ? (readerState.currentPage + 1) / readerState.pages.length : 0 -) +); -export const hasPrev = $derived(readerState.currentPage > 0) +export const hasPrev = $derived(readerState.currentPage > 0); export const hasNext = $derived( readerState.currentPage < readerState.pages.length - 1 -) +); diff --git a/src/routes/reader/[mangaId]/[chapterId]/+page.svelte b/src/routes/reader/[mangaId]/[chapterId]/+page.svelte index a1120c5..4c76554 100644 --- a/src/routes/reader/[mangaId]/[chapterId]/+page.svelte +++ b/src/routes/reader/[mangaId]/[chapterId]/+page.svelte @@ -1,14 +1,646 @@ + + + +
+
+
+ + +
+

{readerState.manga?.title ?? 'Reader'}

+

{readerState.chapter?.name ?? 'Loading chapter'}

+

{chapterLabel} · {pageLabel}

+
+
+ +
+
+ + +
+ + + +
+ + + +
+
+
+ +
+
+ {progressPercent}% read + {pageLabel} +
+ +
+ +
+ {#if initializing && readerState.pages.length === 0} +
+ +

Loading chapter pages...

+
+ {:else if routeError || readerState.pagesError} +
+

{routeError ?? readerState.pagesError}

+ +
+ {:else if totalPages === 0} +
+

No pages were returned for this chapter.

+
+ {:else if readerState.mode === 'strip'} +
+ {#each readerState.pages as pageData, index (pageData.index)} + + {/each} +
+ {:else} +
1 + ? `cursor: grab; overflow: hidden;` + : ''} + > + + + {#if currentPageData} + {`Page 1 + ? `transform: scale(${readerState.inspectScale}) translate(${readerState.inspectPanX}px, ${readerState.inspectPanY}px); transform-origin: center; transition: transform 0.1s ease;` + : `zoom: ${readerState.zoom}`} + /> + {/if} + + +
+ {/if} +
+ +
+ + + +
+
+ + + let initializing = $state(true) let routeError = $state(null) let requestVersion = 0