Reader core parity

This commit is contained in:
Zerebos
2026-05-23 21:33:02 -04:00
parent 71ee4052f3
commit f91b46cfa5
10 changed files with 1234 additions and 113 deletions
+54
View File
@@ -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<void> {
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};
+104
View File
@@ -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<void> {
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<boolean> {
if (readerState.currentPage >= readerState.pages.length - 1) return false;
await setCurrentReaderPage(readerState.currentPage + 1);
return true;
}
export async function goToPreviousReaderPage(): Promise<boolean> {
if (readerState.currentPage <= 0) return false;
await setCurrentReaderPage(readerState.currentPage - 1);
return true;
}
export async function ensureReaderSession(
mangaId: string,
chapterId: string,
): Promise<void> {
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);
}
}
+50
View File
@@ -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);
}
}
+68
View File
@@ -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
* <div
* onpointerdown={tracker.onPointerDown}
* onpointermove={tracker.onPointerMove}
* onpointerup={tracker.onPointerUp}
* onpointercancel={tracker.onPointerUp}
* >
* ```
*/
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;
},
});
}
+115
View File
@@ -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();
}
};
}
+142
View File
@@ -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<HTMLElement>('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<number>,
onAppended: (next: StripChapter) => void,
onDone: () => void,
): Promise<void> {
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();
}
}
+17 -100
View File
@@ -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<boolean> {
if (readerState.currentPage >= readerState.pages.length - 1) return false;
await setCurrentReaderPage(readerState.currentPage + 1);
return true;
}
export async function goToPreviousReaderPage(): Promise<boolean> {
if (readerState.currentPage <= 0) return false;
await setCurrentReaderPage(readerState.currentPage - 1);
return true;
}
export {
sortChapters,
getAdjacentChapters,
setCurrentReaderPage,
goToNextReaderPage,
goToPreviousReaderPage,
} from './navigation';
+30
View File
@@ -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
);
}
+19 -10
View File
@@ -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
)
);
@@ -1,14 +1,646 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { ArrowArcLeft, CaretLeft, CaretRight, Columns, List, SpinnerGap, TextAlignRight } from 'phosphor-svelte'
import { onMount } from 'svelte'
import { ArrowArcLeft, CaretLeft, CaretRight, Columns, List, MagnifyingGlass, SpinnerGap, TextAlignRight } from 'phosphor-svelte'
import { currentPageData, progress, readerState } from '$lib/state/reader.svelte'
import { ensureReaderSession, getAdjacentChapters, goToNextReaderPage, goToPreviousReaderPage, setCurrentReaderPage } from '$lib/core/reader/session'
import { ensureReaderSession } from '$lib/core/reader/chapterLoader'
import { getAdjacentChapters, goToNextReaderPage, goToPreviousReaderPage, setCurrentReaderPage } from '$lib/core/reader/navigation'
import { createReaderKeyHandler } from '$lib/core/reader/readerKeybinds'
import { createPinchTracker } from '$lib/core/reader/pinchZoom'
import { setupScrollTracking } from '$lib/core/reader/scrollHandler'
import { adjustZoom, ZOOM_STEP } from '$lib/core/reader/zoomHelpers'
import { preloadPages } from '$lib/core/reader/pageLoader'
import { settingsState } from '$lib/state/settings.svelte'
import { matchesKeybind, toggleFullscreen } from '$lib/core/keybinds/keybindEngine'
import { addBookmark, getBookmark, removeBookmark } from '$lib/state/history.svelte'
import Button from '$lib/ui/primitives/Button.svelte'
let initializing = $state(true)
let routeError = $state<string | null>(null)
let requestVersion = 0
let stageEl = $state<HTMLElement | null>(null)
const mangaId = $derived($page.params.mangaId ?? '')
const chapterId = $derived($page.params.chapterId ?? '')
const chapterNeighbors = $derived.by(() => getAdjacentChapters())
const currentPageNumber = $derived(readerState.currentPage + 1)
const totalPages = $derived(readerState.pages.length)
const progressPercent = $derived(Math.round(progress * 100))
const pageLabel = $derived(totalPages > 0 ? `${currentPageNumber} / ${totalPages}` : '0 / 0')
const chapterLabel = $derived(
readerState.chapter
? `Ch. ${readerState.chapter.chapterNumber}`
: 'Chapter'
)
const zoomPct = $derived(Math.round(readerState.zoom * 100))
// ---- Session loading ----
$effect(() => {
const activeMangaId = mangaId
const activeChapterId = chapterId
if (!activeMangaId || !activeChapterId) return
const version = ++requestVersion
initializing = true
routeError = null
void ensureReaderSession(activeMangaId, activeChapterId)
.catch((error) => {
if (version !== requestVersion) return
routeError = error instanceof Error ? error.message : String(error)
})
.finally(() => {
if (version !== requestVersion) return
initializing = false
})
})
// ---- Preload upcoming pages ----
$effect(() => {
preloadPages(readerState.pages, readerState.currentPage, settingsState.preloadPages ?? 3)
})
// ---- Auto-scroll in strip mode ----
$effect(() => {
if (!readerState.autoScrollActive || readerState.mode !== 'strip' || !stageEl) return
const speed = settingsState.autoScrollSpeed ?? 5
let id: ReturnType<typeof setInterval>
id = setInterval(() => {
if (!stageEl) return
stageEl.scrollTop += speed
}, 16)
return () => clearInterval(id)
})
// ---- Longstrip scroll tracking ----
$effect(() => {
const el = stageEl
if (!el || readerState.mode !== 'strip') return
return setupScrollTracking(el, {
onPageChange: (idx) => { readerState.currentPage = idx },
onChapterChange: (_id) => {},
onMarkRead: (_id) => {},
onAppend: () => {},
getStripChapters: () => [],
shouldAutoMark: () => settingsState.autoMarkRead ?? true,
})
})
// ---- Pinch zoom ----
const pinchTracker = createPinchTracker({
getZoom: () => readerState.zoom,
setZoom: (v) => { readerState.zoom = v },
getInspectScale: () => readerState.inspectScale,
setInspectScale: (v) => { readerState.inspectScale = v },
resetInspectPan: () => { readerState.inspectPanX = 0; readerState.inspectPanY = 0 },
isLongstrip: () => readerState.mode === 'strip',
})
// ---- Navigation helpers ----
async function stepForward() {
if (readerState.mode === 'strip') {
if (chapterNeighbors.next && readerState.manga) {
await goto(`/reader/${readerState.manga.id}/${chapterNeighbors.next.id}`)
}
return
}
const advanced = await goToNextReaderPage()
if (advanced) return
if (chapterNeighbors.next && readerState.manga) {
await goto(`/reader/${readerState.manga.id}/${chapterNeighbors.next.id}`)
}
}
async function stepBackward() {
if (readerState.mode === 'strip') {
if (chapterNeighbors.previous && readerState.manga) {
await goto(`/reader/${readerState.manga.id}/${chapterNeighbors.previous.id}`)
}
return
}
const moved = await goToPreviousReaderPage()
if (moved) return
if (chapterNeighbors.previous && readerState.manga) {
await goto(`/reader/${readerState.manga.id}/${chapterNeighbors.previous.id}`)
}
}
async function handleRangeInput(event: Event) {
const target = event.currentTarget as HTMLInputElement
await setCurrentReaderPage(Number(target.value) - 1)
}
function retryLoad() {
requestVersion += 1
initializing = true
routeError = null
void ensureReaderSession(mangaId, chapterId)
.catch((error) => { routeError = error instanceof Error ? error.message : String(error) })
.finally(() => { initializing = false })
}
async function returnToSeries() {
if (!readerState.manga) return
await goto(`/series/${readerState.manga.id}`)
}
function cycleMode() {
readerState.mode = readerState.mode === 'single' ? 'strip' : 'single'
if (readerState.mode === 'single') {
readerState.inspectScale = 1
readerState.inspectPanX = 0
readerState.inspectPanY = 0
}
}
function toggleBookmarkAction() {
if (!readerState.chapter || !readerState.manga) return
const cid = readerState.chapter.id
if (getBookmark(cid)) {
removeBookmark(cid)
} else {
addBookmark({
mangaId: readerState.manga.id,
chapterId: cid,
pageNumber: readerState.currentPage,
mangaTitle: readerState.manga.title,
chapterName: readerState.chapter.name,
thumbnailUrl: readerState.manga.thumbnailUrl,
})
}
}
// ---- Keybind handler (via shared factory) ----
const handleKeydown = createReaderKeyHandler({
goNext: () => void (readerState.direction === 'rtl' ? stepBackward() : stepForward()),
goPrev: () => void (readerState.direction === 'rtl' ? stepForward() : stepBackward()),
goToPage: (idx) => void setCurrentReaderPage(idx),
lastPage: () => readerState.pages.length - 1,
exitReader: () => void returnToSeries(),
chapterNext: () => {
const n = getAdjacentChapters()
if (readerState.manga && n.next) void goto(`/reader/${readerState.manga.id}/${n.next.id}`)
},
chapterPrev: () => {
const n = getAdjacentChapters()
if (readerState.manga && n.previous) void goto(`/reader/${readerState.manga.id}/${n.previous.id}`)
},
adjustZoom: (delta) => { readerState.zoom = adjustZoom(readerState.zoom, delta) },
resetZoom: () => { readerState.zoom = 1; readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0 },
cycleMode,
toggleDirection: () => { readerState.direction = readerState.direction === 'ltr' ? 'rtl' : 'ltr' },
openSettings: () => void goto('/settings/general'),
toggleBookmark: toggleBookmarkAction,
toggleAutoScroll: () => { readerState.autoScrollActive = !readerState.autoScrollActive },
getKeybinds: () => settingsState.keybinds,
})
</script>
<svelte:window onkeydown={handleKeydown} />
<section class="reader-page">
<header class="reader-toolbar">
<div class="reader-meta">
<Button variant="ghost" size="sm" onclick={returnToSeries}>
<ArrowArcLeft size={16} weight="bold" />
Back
</Button>
<div class="reader-titles">
<p class="eyebrow">{readerState.manga?.title ?? 'Reader'}</p>
<h1>{readerState.chapter?.name ?? 'Loading chapter'}</h1>
<p class="subcopy">{chapterLabel} · {pageLabel}</p>
</div>
</div>
<div class="reader-actions">
<div class="toggle-group">
<button
class:active={readerState.mode === 'single'}
type="button"
onclick={() => (readerState.mode = 'single')}
aria-pressed={readerState.mode === 'single'}
>
<Columns size={16} weight="bold" />
Single
</button>
<button
class:active={readerState.mode === 'strip'}
type="button"
onclick={() => (readerState.mode = 'strip')}
aria-pressed={readerState.mode === 'strip'}
>
<List size={16} weight="bold" />
Strip
</button>
</div>
<button
class="direction-toggle"
type="button"
onclick={() => (readerState.direction = readerState.direction === 'ltr' ? 'rtl' : 'ltr')}
>
<TextAlignRight size={16} weight="bold" />
{readerState.direction.toUpperCase()}
</button>
<div class="zoom-controls">
<button
class="zoom-btn"
type="button"
onclick={() => { readerState.zoom = adjustZoom(readerState.zoom, -ZOOM_STEP) }}
aria-label="Zoom out"
title="Zoom out (Ctrl -)"
></button>
<button
class="zoom-label"
type="button"
onclick={() => { readerState.zoom = 1; readerState.inspectScale = 1 }}
title="Reset zoom"
aria-label="Reset zoom"
>
<MagnifyingGlass size={12} weight="bold" />
{zoomPct}%
</button>
<button
class="zoom-btn"
type="button"
onclick={() => { readerState.zoom = adjustZoom(readerState.zoom, ZOOM_STEP) }}
aria-label="Zoom in"
title="Zoom in (Ctrl +)"
>+</button>
</div>
</div>
</header>
<div class="reader-progress">
<div class="progress-copy">
<span>{progressPercent}% read</span>
<span>{pageLabel}</span>
</div>
<input
type="range"
min="1"
max={Math.max(totalPages, 1)}
value={Math.min(Math.max(currentPageNumber, 1), Math.max(totalPages, 1))}
oninput={handleRangeInput}
disabled={totalPages === 0}
aria-label="Reader progress"
/>
</div>
<div
class="reader-stage"
bind:this={stageEl}
onpointerdown={pinchTracker.onPointerDown}
onpointermove={pinchTracker.onPointerMove}
onpointerup={pinchTracker.onPointerUp}
onpointercancel={pinchTracker.onPointerUp}
>
{#if initializing && readerState.pages.length === 0}
<div class="reader-status">
<span class="spin"><SpinnerGap size={22} weight="bold" /></span>
<p>Loading chapter pages...</p>
</div>
{:else if routeError || readerState.pagesError}
<div class="reader-status error">
<p>{routeError ?? readerState.pagesError}</p>
<Button onclick={retryLoad}>Retry</Button>
</div>
{:else if totalPages === 0}
<div class="reader-status">
<p>No pages were returned for this chapter.</p>
</div>
{:else if readerState.mode === 'strip'}
<div class="strip-view" style="zoom: {readerState.zoom}">
{#each readerState.pages as pageData, index (pageData.index)}
<button
class="strip-page"
class:current={index === readerState.currentPage}
type="button"
onclick={() => void setCurrentReaderPage(index)}
aria-label={`Open page ${index + 1}`}
>
<img
src={pageData.imageData ?? pageData.url}
alt={`Page ${index + 1}`}
loading="lazy"
data-page-index={index}
/>
<span>Page {index + 1}</span>
</button>
{/each}
</div>
{:else}
<div
class="single-view"
style={readerState.inspectScale > 1
? `cursor: grab; overflow: hidden;`
: ''}
>
<button class="edge-nav left" type="button" onclick={() => void stepBackward()} aria-label="Previous page">
<CaretLeft size={28} weight="bold" />
</button>
{#if currentPageData}
<img
class="single-page"
src={currentPageData.imageData ?? currentPageData.url}
alt={`Page ${currentPageNumber}`}
style={readerState.inspectScale > 1
? `transform: scale(${readerState.inspectScale}) translate(${readerState.inspectPanX}px, ${readerState.inspectPanY}px); transform-origin: center; transition: transform 0.1s ease;`
: `zoom: ${readerState.zoom}`}
/>
{/if}
<button class="edge-nav right" type="button" onclick={() => void stepForward()} aria-label="Next page">
<CaretRight size={28} weight="bold" />
</button>
</div>
{/if}
</div>
<footer class="reader-footer">
<Button variant="ghost" onclick={() => void stepBackward()}>
<CaretLeft size={16} weight="bold" />
{chapterNeighbors.previous && readerState.currentPage === 0 ? 'Prev chapter' : 'Prev page'}
</Button>
<Button onclick={() => void stepForward()}>
{readerState.currentPage >= totalPages - 1 && chapterNeighbors.next ? 'Next chapter' : 'Next page'}
<CaretRight size={16} weight="bold" />
</Button>
</footer>
</section>
<style>
.reader-page {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: var(--sp-3);
height: 100%;
padding: var(--sp-4);
color: var(--text-primary);
}
.reader-toolbar,
.reader-progress,
.reader-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
backdrop-filter: blur(18px);
}
.reader-meta,
.reader-actions,
.reader-footer {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.reader-titles {
min-width: 0;
}
.reader-titles h1 {
margin: 2px 0;
font-family: var(--font-display);
font-size: var(--text-xl);
line-height: var(--leading-tight);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.eyebrow,
.subcopy,
.progress-copy,
.strip-page span {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.reader-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.toggle-group,
.direction-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
background: var(--bg-base);
}
.toggle-group button,
.direction-toggle {
height: 34px;
padding: 0 12px;
border: 0;
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.toggle-group button.active,
.direction-toggle:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.zoom-controls {
display: inline-flex;
align-items: center;
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
background: var(--bg-base);
overflow: hidden;
}
.zoom-btn,
.zoom-label {
height: 34px;
padding: 0 10px;
border: 0;
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
}
.zoom-btn:hover,
.zoom-label:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.zoom-label {
min-width: 62px;
justify-content: center;
border-left: 1px solid var(--border-dim);
border-right: 1px solid var(--border-dim);
font-size: var(--text-xs);
}
.reader-progress {
flex-direction: column;
align-items: stretch;
}
.progress-copy {
display: flex;
justify-content: space-between;
gap: var(--sp-2);
}
.reader-progress input {
width: 100%;
}
.reader-stage {
min-height: 0;
border: 1px solid var(--border-dim);
border-radius: var(--radius-2xl);
background: color-mix(in srgb, var(--bg-base) 92%, black 8%);
overflow: auto;
touch-action: pan-x pan-y;
}
.reader-status,
.single-view,
.strip-view {
min-height: 100%;
}
.reader-status {
display: grid;
place-items: center;
gap: var(--sp-3);
padding: var(--sp-6);
text-align: center;
color: var(--text-muted);
}
.reader-status.error {
color: var(--color-error);
}
.spin {
animation: spin 0.8s linear infinite;
}
.single-view {
display: grid;
grid-template-columns: minmax(56px, 96px) 1fr minmax(56px, 96px);
align-items: center;
height: 100%;
}
.single-page {
width: 100%;
max-height: 100%;
object-fit: contain;
justify-self: center;
}
.edge-nav {
display: grid;
place-items: center;
width: 100%;
height: 100%;
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
}
.edge-nav:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--bg-overlay) 44%, transparent);
}
.strip-view {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
}
.strip-page {
display: grid;
gap: var(--sp-2);
justify-items: center;
padding: var(--sp-3);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
cursor: pointer;
}
.strip-page.current {
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
}
.strip-page img {
width: min(100%, 1100px);
object-fit: contain;
}
@media (max-width: 900px) {
.reader-page {
padding: var(--sp-3);
}
.reader-toolbar,
.reader-footer {
flex-direction: column;
align-items: stretch;
}
.reader-meta,
.reader-actions,
.reader-footer {
justify-content: space-between;
}
.single-view {
grid-template-columns: 56px 1fr 56px;
}
}
</style>
let initializing = $state(true)
let routeError = $state<string | null>(null)
let requestVersion = 0