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';
|
* @deprecated Import directly from the specific reader core modules:
|
||||||
import {readerState} from '$lib/state/reader.svelte';
|
* - chapterLoader.ts → ensureReaderSession, sortChapters
|
||||||
import type {Chapter} from '$lib/types';
|
* - navigation.ts → getAdjacentChapters, setCurrentReaderPage, goToNextReaderPage, goToPreviousReaderPage
|
||||||
|
*
|
||||||
|
* This file is kept for backward-compatibility only.
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
ensureReaderSession,
|
||||||
|
} from './chapterLoader';
|
||||||
|
|
||||||
function sortChapters(chapters: Chapter[]): Chapter[] {
|
export {
|
||||||
return [...chapters].sort((left, right) => left.sourceOrder - right.sourceOrder);
|
sortChapters,
|
||||||
}
|
getAdjacentChapters,
|
||||||
|
setCurrentReaderPage,
|
||||||
function currentChapterIndex(): number {
|
goToNextReaderPage,
|
||||||
if (!readerState.chapter) return -1;
|
goToPreviousReaderPage,
|
||||||
|
} from './navigation';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {Manga, Chapter} from '$lib/types';
|
||||||
import type { Page } from '$lib/server-adapters/types'
|
import type {Page} from '$lib/server-adapters/types';
|
||||||
|
|
||||||
export type ReadMode = 'single' | 'strip'
|
export type ReadMode = 'single' | 'strip';
|
||||||
export type FitMode = 'width' | 'height' | 'original'
|
export type FitMode = 'width' | 'height' | 'original';
|
||||||
export type ReadDirection = 'ltr' | 'rtl'
|
export type ReadDirection = 'ltr' | 'rtl';
|
||||||
|
|
||||||
export const readerState = $state({
|
export const readerState = $state({
|
||||||
manga: null as Manga | null,
|
manga: null as Manga | null,
|
||||||
@@ -20,22 +20,31 @@ export const readerState = $state({
|
|||||||
direction: 'ltr' as ReadDirection,
|
direction: 'ltr' as ReadDirection,
|
||||||
zoom: 1,
|
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,
|
showControls: false,
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const currentPageData = $derived(
|
export const currentPageData = $derived(
|
||||||
readerState.pages[readerState.currentPage] ?? null
|
readerState.pages[readerState.currentPage] ?? null
|
||||||
)
|
);
|
||||||
|
|
||||||
export const progress = $derived(
|
export const progress = $derived(
|
||||||
readerState.pages.length > 0
|
readerState.pages.length > 0
|
||||||
? (readerState.currentPage + 1) / readerState.pages.length
|
? (readerState.currentPage + 1) / readerState.pages.length
|
||||||
: 0
|
: 0
|
||||||
)
|
);
|
||||||
|
|
||||||
export const hasPrev = $derived(readerState.currentPage > 0)
|
export const hasPrev = $derived(readerState.currentPage > 0);
|
||||||
export const hasNext = $derived(
|
export const hasNext = $derived(
|
||||||
readerState.currentPage < readerState.pages.length - 1
|
readerState.currentPage < readerState.pages.length - 1
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { page } from '$app/stores'
|
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 { 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 { settingsState } from '$lib/state/settings.svelte'
|
||||||
import { matchesKeybind, toggleFullscreen } from '$lib/core/keybinds/keybindEngine'
|
|
||||||
import { addBookmark, getBookmark, removeBookmark } from '$lib/state/history.svelte'
|
import { addBookmark, getBookmark, removeBookmark } from '$lib/state/history.svelte'
|
||||||
import Button from '$lib/ui/primitives/Button.svelte'
|
import Button from '$lib/ui/primitives/Button.svelte'
|
||||||
|
|
||||||
@@ -13,6 +19,632 @@
|
|||||||
let routeError = $state<string | null>(null)
|
let routeError = $state<string | null>(null)
|
||||||
let requestVersion = 0
|
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
|
||||||
|
|
||||||
const mangaId = $derived($page.params.mangaId ?? '')
|
const mangaId = $derived($page.params.mangaId ?? '')
|
||||||
const chapterId = $derived($page.params.chapterId ?? '')
|
const chapterId = $derived($page.params.chapterId ?? '')
|
||||||
const chapterNeighbors = $derived.by(() => getAdjacentChapters())
|
const chapterNeighbors = $derived.by(() => getAdjacentChapters())
|
||||||
|
|||||||
Reference in New Issue
Block a user