mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Reworked ENTIRE Project for Readability
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { gql } from "@api/client";
|
||||
import { store, addHistory, addBookmark, removeBookmark,
|
||||
checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||
import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
|
||||
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||
|
||||
const AVG_MIN_PER_PAGE = 0.33;
|
||||
|
||||
export function getMangaPrefs() {
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (!mangaId) return DEFAULT_MANGA_PREFS;
|
||||
return { ...DEFAULT_MANGA_PREFS, ...(store.settings.mangaPrefs?.[mangaId] ?? {}) };
|
||||
}
|
||||
|
||||
export function markChapterRead(id: number, markedRead: Set<number>) {
|
||||
if (markedRead.has(id)) return;
|
||||
markedRead.add(id);
|
||||
const chapter = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
|
||||
const pages = chapter?.pageCount ?? store.pageUrls.length ?? 15;
|
||||
const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE));
|
||||
if (store.activeManga && chapter) {
|
||||
addHistory(
|
||||
{ mangaId: store.activeManga.id, mangaTitle: store.activeManga.title, thumbnailUrl: store.activeManga.thumbnailUrl, chapterId: id, chapterName: chapter.name, readAt: Date.now() },
|
||||
true, minutes,
|
||||
);
|
||||
}
|
||||
gql(MARK_CHAPTER_READ, { id, isRead: true })
|
||||
.then(() => {
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (!mangaId) return;
|
||||
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
||||
checkAndMarkCompleted(mangaId, updated);
|
||||
const prefs = getMangaPrefs();
|
||||
if (prefs.deleteOnRead) {
|
||||
const ch = store.activeChapterList.find(c => c.id === id);
|
||||
if (ch?.isDownloaded) {
|
||||
const delayMs = (prefs.deleteDelayHours ?? 0) * 3_600_000;
|
||||
const doDelete = () => gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [id] }).catch(console.error);
|
||||
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs);
|
||||
}
|
||||
}
|
||||
if (prefs.downloadAhead > 0) {
|
||||
const list = store.activeChapterList;
|
||||
const idx = list.findIndex(c => c.id === id);
|
||||
if (idx >= 0) {
|
||||
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead).filter(c => !c.isDownloaded && !c.isRead).map(c => c.id);
|
||||
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
|
||||
}
|
||||
}
|
||||
if (prefs.maxKeepChapters > 0) {
|
||||
const downloaded = store.activeChapterList.filter(c => c.isDownloaded).sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const excess = downloaded.slice(0, Math.max(0, downloaded.length - prefs.maxKeepChapters));
|
||||
if (excess.length) gql(DELETE_DOWNLOADED_CHAPTERS, { ids: excess.map(c => c.id) }).catch(console.error);
|
||||
}
|
||||
})
|
||||
.catch(e => { markedRead.delete(id); console.error(e); });
|
||||
}
|
||||
|
||||
export function toggleBookmark(
|
||||
displayChapter: import("@types").Chapter | null | undefined,
|
||||
pageNumber: number,
|
||||
) {
|
||||
const ch = displayChapter;
|
||||
const manga = store.activeManga;
|
||||
if (!ch || !manga) return;
|
||||
const isBookmarked = !!store.bookmarks.find(
|
||||
b => b.mangaId === manga.id && b.chapterId === ch.id && b.pageNumber === pageNumber,
|
||||
);
|
||||
if (isBookmarked) {
|
||||
removeBookmark(ch.id);
|
||||
} else {
|
||||
const existing = store.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== ch.id);
|
||||
if (existing) removeBookmark(existing.chapterId);
|
||||
addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { store, openReader } from "@store/state.svelte";
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
import { fetchPages } from "./pageLoader";
|
||||
|
||||
export function scheduleResumeDismiss() {
|
||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||
setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500);
|
||||
}
|
||||
|
||||
export async function loadChapter(
|
||||
id: number,
|
||||
useBlob: boolean,
|
||||
abortCtrl: { current: AbortController | null },
|
||||
startAtLastPage: { current: boolean },
|
||||
markedRead: Set<number>,
|
||||
adjacent: { next: { id: number } | null },
|
||||
) {
|
||||
abortCtrl.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl.current = ctrl;
|
||||
startAtLastPage.current = false;
|
||||
markedRead.clear();
|
||||
readerState.resetForChapter();
|
||||
store.pageUrls = [];
|
||||
|
||||
const bookmark = store.bookmarks.find(b => b.chapterId === id);
|
||||
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
||||
readerState.resumeDismissed = false;
|
||||
readerState.resumeVisible = resumeTo > 1;
|
||||
if (resumeTo > 1) scheduleResumeDismiss();
|
||||
|
||||
store.pageNumber = 1;
|
||||
try {
|
||||
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
||||
if (ctrl.signal.aborted) return;
|
||||
store.pageUrls = urls;
|
||||
if (startAtLastPage.current) store.pageNumber = urls.length;
|
||||
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||
readerState.pageReady = true;
|
||||
readerState.loading = false;
|
||||
if (adjacent.next) fetchPages(adjacent.next.id, useBlob).catch(() => {});
|
||||
} catch (e: any) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
readerState.error = e instanceof Error ? e.message : String(e);
|
||||
readerState.loading = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { readerState } from "./store/readerState.svelte";
|
||||
export type { PageStyle } from "./store/readerState.svelte";
|
||||
export { PAGE_STYLES, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "./store/readerState.svelte";
|
||||
|
||||
export { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups, clearPageCache } from "./lib/pageLoader";
|
||||
export { setupScrollTracking, appendNextChapter } from "./lib/scrollHandler";
|
||||
export type { StripChapter, ScrollHandlerCallbacks } from "./lib/scrollHandler";
|
||||
export { createReaderKeyHandler } from "./lib/readerKeybinds";
|
||||
export type { ReaderKeyActions } from "./lib/readerKeybinds";
|
||||
|
||||
export { markChapterRead, getMangaPrefs, toggleBookmark } from "./lib/chapterActions";
|
||||
export { goForward, goBack, jumpToPage, animateFade } from "./lib/navigation";
|
||||
export { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "./lib/zoomHelpers";
|
||||
export { loadChapter, scheduleResumeDismiss } from "./lib/chapterLoader";
|
||||
@@ -0,0 +1,84 @@
|
||||
import { store, openReader, closeReader } from "@store/state.svelte";
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
import type { Chapter } from "@types";
|
||||
|
||||
interface Adjacent {
|
||||
prev: Chapter | null;
|
||||
next: Chapter | null;
|
||||
}
|
||||
|
||||
export function advanceGroup(forward: boolean, adjacent: Adjacent, startAtLastPage: () => void) {
|
||||
if (!readerState.pageGroups.length) return;
|
||||
const gi = readerState.pageGroups.findIndex(g => g.includes(store.pageNumber));
|
||||
if (forward) {
|
||||
if (gi < readerState.pageGroups.length - 1) store.pageNumber = readerState.pageGroups[gi + 1][0];
|
||||
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
||||
else closeReader();
|
||||
} else {
|
||||
if (gi > 0) store.pageNumber = readerState.pageGroups[gi - 1][0];
|
||||
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); }
|
||||
}
|
||||
}
|
||||
|
||||
export async function animateFade(fn: () => void) {
|
||||
readerState.fadingOut = true;
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
fn();
|
||||
readerState.fadingOut = false;
|
||||
}
|
||||
|
||||
export function goForward(
|
||||
style: string,
|
||||
adjacent: Adjacent,
|
||||
lastPage: number,
|
||||
onMaybeMarkRead: () => void,
|
||||
startAtLastPage: () => void,
|
||||
) {
|
||||
if (readerState.loading) return;
|
||||
if (style === "longstrip") {
|
||||
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); }
|
||||
return;
|
||||
}
|
||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
|
||||
if (!store.pageUrls.length) return;
|
||||
if (store.pageNumber < lastPage) {
|
||||
if (style === "fade") animateFade(() => { store.pageNumber++; });
|
||||
else store.pageNumber++;
|
||||
} else if (adjacent.next) {
|
||||
onMaybeMarkRead();
|
||||
store.pageNumber = 1;
|
||||
openReader(adjacent.next, store.activeChapterList);
|
||||
} else closeReader();
|
||||
}
|
||||
|
||||
export function goBack(
|
||||
style: string,
|
||||
adjacent: Adjacent,
|
||||
startAtLastPage: () => void,
|
||||
) {
|
||||
if (readerState.loading) return;
|
||||
if (style === "longstrip") {
|
||||
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); }
|
||||
return;
|
||||
}
|
||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
|
||||
if (!store.pageUrls.length) return;
|
||||
if (store.pageNumber > 1) {
|
||||
if (style === "fade") animateFade(() => { store.pageNumber--; });
|
||||
else store.pageNumber--;
|
||||
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); }
|
||||
}
|
||||
|
||||
export function jumpToPage(page: number, style: string, lastPage: number, containerEl: HTMLElement | null) {
|
||||
if (style === "longstrip") {
|
||||
const chId = readerState.visibleChapterId ?? store.activeChapter?.id;
|
||||
containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" });
|
||||
return;
|
||||
}
|
||||
if (style === "double" && readerState.pageGroups.length) {
|
||||
const group = readerState.pageGroups[page - 1];
|
||||
if (group) store.pageNumber = group[0];
|
||||
} else {
|
||||
store.pageNumber = Math.max(1, Math.min(lastPage, page));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { gql, plainThumbUrl } from "@api/client";
|
||||
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||
import { dedupeRequest } from "@core/async/batchRequests";
|
||||
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||
|
||||
export interface PageLoaderOptions {
|
||||
useBlob: () => boolean;
|
||||
}
|
||||
|
||||
const pageCache = new Map<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||
const preloadedUrls = new Set<string>();
|
||||
const aspectCache = new Map<string, number>();
|
||||
|
||||
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||
if (!useBlob) return Promise.resolve(url);
|
||||
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority));
|
||||
return resolvedUrlCache.get(url)!;
|
||||
}
|
||||
|
||||
export function fetchPages(
|
||||
chapterId: number,
|
||||
useBlob: boolean,
|
||||
signal?: AbortSignal,
|
||||
priorityPage = 0,
|
||||
): Promise<string[]> {
|
||||
const cached = pageCache.get(chapterId);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
|
||||
if (!inflight.has(chapterId)) {
|
||||
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
||||
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||
.then(d => {
|
||||
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
|
||||
if (useBlob) {
|
||||
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
|
||||
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
|
||||
}
|
||||
pageCache.set(chapterId, urls);
|
||||
return urls;
|
||||
})
|
||||
).finally(() => inflight.delete(chapterId));
|
||||
inflight.set(chapterId, p);
|
||||
}
|
||||
|
||||
const base = inflight.get(chapterId)!;
|
||||
if (!signal) return base;
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||
base.then(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
||||
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
||||
return resolveUrl(url, useBlob).then(src => new Promise(res => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
|
||||
aspectCache.set(url, r);
|
||||
res(r);
|
||||
};
|
||||
img.onerror = () => res(0.67);
|
||||
img.src = src;
|
||||
}));
|
||||
}
|
||||
|
||||
export function preloadImage(url: string, useBlob: boolean): void {
|
||||
if (preloadedUrls.has(url)) return;
|
||||
preloadedUrls.add(url);
|
||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||
}
|
||||
|
||||
export function buildPageGroups(
|
||||
urls: string[],
|
||||
aspects: number[],
|
||||
offsetSpreads: boolean,
|
||||
): number[][] {
|
||||
const groups: number[][] = [[1]];
|
||||
if (offsetSpreads) groups.push([2]);
|
||||
let i = offsetSpreads ? 3 : 2;
|
||||
while (i <= urls.length) {
|
||||
const a = aspects[i - 1];
|
||||
if (a > 1.2 || i === urls.length) { groups.push([i++]); }
|
||||
else { groups.push([i, i + 1]); i += 2; }
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function clearPageCache(chapterId?: number): void {
|
||||
if (chapterId !== undefined) {
|
||||
pageCache.delete(chapterId);
|
||||
inflight.delete(chapterId);
|
||||
} else {
|
||||
pageCache.clear();
|
||||
inflight.clear();
|
||||
resolvedUrlCache.clear();
|
||||
preloadedUrls.clear();
|
||||
aspectCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "@core/keybinds";
|
||||
import type { Keybinds } from "@core/keybinds";
|
||||
|
||||
export interface ReaderKeyActions {
|
||||
goNext: () => void;
|
||||
goPrev: () => void;
|
||||
closeReader: () => void;
|
||||
goToPage: (page: number) => void;
|
||||
lastPage: () => number;
|
||||
adjustZoom: (delta: number) => void;
|
||||
resetZoom: () => void;
|
||||
cycleStyle: () => void;
|
||||
toggleDirection: () => void;
|
||||
openSettings: () => void;
|
||||
toggleBookmark: () => void;
|
||||
toggleMarker: () => void;
|
||||
chapterNext: () => void;
|
||||
chapterPrev: () => void;
|
||||
closePopovers: () => boolean;
|
||||
getKeybinds: () => Keybinds;
|
||||
}
|
||||
|
||||
const ZOOM_STEP = 0.10;
|
||||
|
||||
export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardEvent) => void {
|
||||
return function onKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (actions.closePopovers()) return;
|
||||
actions.closeReader();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.ctrlKey) {
|
||||
if (e.key === "=" || e.key === "+") { e.preventDefault(); actions.adjustZoom(ZOOM_STEP); return; }
|
||||
if (e.key === "-") { e.preventDefault(); actions.adjustZoom(-ZOOM_STEP); return; }
|
||||
if (e.key === "0") { e.preventDefault(); actions.resetZoom(); return; }
|
||||
}
|
||||
|
||||
const kb = actions.getKeybinds();
|
||||
|
||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); actions.closeReader(); }
|
||||
else if (matchesKeybind(e, kb.turnPageRight)) { e.preventDefault(); actions.goNext(); }
|
||||
else if (matchesKeybind(e, kb.turnPageLeft)) { e.preventDefault(); actions.goPrev(); }
|
||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); actions.goToPage(1); }
|
||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); actions.goToPage(actions.lastPage()); }
|
||||
else if (matchesKeybind(e, kb.turnChapterRight)) { e.preventDefault(); actions.chapterNext(); }
|
||||
else if (matchesKeybind(e, kb.turnChapterLeft)) { e.preventDefault(); actions.chapterPrev(); }
|
||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); actions.cycleStyle(); }
|
||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); actions.toggleDirection(); }
|
||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); actions.openSettings(); }
|
||||
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); }
|
||||
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
export const READ_LINE_PCT = 0.50;
|
||||
|
||||
export interface StripChapter {
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export interface ScrollHandlerCallbacks {
|
||||
onPageChange: (page: number) => void;
|
||||
onChapterChange: (chapterId: number) => void;
|
||||
onMarkRead: (chapterId: number) => void;
|
||||
onAppend: () => void;
|
||||
getStripChapters: () => StripChapter[];
|
||||
getPageUrls: () => string[];
|
||||
shouldAutoMark: () => boolean;
|
||||
}
|
||||
|
||||
export function setupScrollTracking(
|
||||
containerEl: HTMLElement,
|
||||
callbacks: ScrollHandlerCallbacks,
|
||||
): () => void {
|
||||
const {
|
||||
onPageChange, onChapterChange, onMarkRead,
|
||||
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
||||
} = callbacks;
|
||||
|
||||
function onScroll() {
|
||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||
if (!imgs.length) return;
|
||||
|
||||
const containerTop = containerEl.getBoundingClientRect().top;
|
||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
||||
|
||||
let activePage: number | null = null;
|
||||
let activeChId: number | null = null;
|
||||
|
||||
for (const img of imgs) {
|
||||
if (img.getBoundingClientRect().top <= readLineY) {
|
||||
activePage = Number(img.dataset.localPage);
|
||||
activeChId = Number(img.dataset.chapter);
|
||||
} else break;
|
||||
}
|
||||
|
||||
if (activePage === null) {
|
||||
activePage = Number(imgs[0].dataset.localPage);
|
||||
activeChId = Number(imgs[0].dataset.chapter);
|
||||
}
|
||||
|
||||
if (activePage !== null) onPageChange(activePage);
|
||||
if (activeChId) onChapterChange(activeChId);
|
||||
|
||||
if (shouldAutoMark() && activePage !== null && activeChId) {
|
||||
const chunks = getStripChapters();
|
||||
const chunk = chunks.find(c => c.chapterId === activeChId);
|
||||
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
||||
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
||||
}
|
||||
|
||||
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
|
||||
if (atBottom && shouldAutoMark()) {
|
||||
const chunks = getStripChapters();
|
||||
const last = chunks[chunks.length - 1];
|
||||
if (last) onMarkRead(last.chapterId);
|
||||
}
|
||||
}
|
||||
|
||||
function onScrollAppend() {
|
||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||
if (pct >= 0.80) onAppend();
|
||||
}
|
||||
|
||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
|
||||
|
||||
return () => {
|
||||
containerEl.removeEventListener("scroll", onScroll);
|
||||
containerEl.removeEventListener("scroll", onScrollAppend);
|
||||
};
|
||||
}
|
||||
|
||||
export function appendNextChapter(
|
||||
stripChapters: StripChapter[],
|
||||
chapterList: { id: number; name: string }[],
|
||||
fetchPages: (chapterId: number) => Promise<string[]>,
|
||||
preloadImage: (url: string) => void,
|
||||
onAppended: (next: StripChapter) => void,
|
||||
onDone: () => void,
|
||||
): void {
|
||||
if (!stripChapters.length) return;
|
||||
|
||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||
const lastIdx = chapterList.findIndex(c => c.id === lastChunk.chapterId);
|
||||
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) return;
|
||||
|
||||
const next = chapterList[lastIdx + 1];
|
||||
if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
|
||||
|
||||
fetchPages(next.id)
|
||||
.then(urls => {
|
||||
urls.slice(0, 6).forEach(preloadImage);
|
||||
return urls;
|
||||
})
|
||||
.then(urls => {
|
||||
if (stripChapters.some(c => c.chapterId === next.id)) { onDone(); return; }
|
||||
onAppended({ chapterId: next.id, chapterName: next.name, urls });
|
||||
onDone();
|
||||
})
|
||||
.catch(() => onDone());
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
|
||||
export function clampZoom(z: number): number {
|
||||
const { ZOOM_MIN, ZOOM_MAX } = readerState;
|
||||
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
|
||||
}
|
||||
|
||||
export function captureZoomAnchor(
|
||||
containerEl: HTMLElement | null,
|
||||
style: string,
|
||||
out: { el: HTMLElement | null; offset: number },
|
||||
) {
|
||||
if (!containerEl || style !== "longstrip") return;
|
||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||
const containerTop = containerEl.getBoundingClientRect().top;
|
||||
for (const img of imgs) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
if (rect.bottom > containerTop) {
|
||||
out.el = img;
|
||||
out.offset = rect.top - containerTop;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreZoomAnchor(
|
||||
containerEl: HTMLElement | null,
|
||||
out: { el: HTMLElement | null; offset: number },
|
||||
) {
|
||||
if (!out.el || !containerEl) return;
|
||||
const el = out.el;
|
||||
out.el = null;
|
||||
requestAnimationFrame(() => {
|
||||
const containerTop = containerEl!.getBoundingClientRect().top;
|
||||
const newRect = el.getBoundingClientRect();
|
||||
containerEl!.scrollTop += (newRect.top - containerTop) - out.offset;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user