mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Reader core parity
This commit is contained in:
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user