Feat: Reworked ENTIRE Project for Readability

This commit is contained in:
Youwes09
2026-04-20 00:19:22 -05:00
parent 005680394e
commit 4b97f4a6c9
191 changed files with 19210 additions and 15915 deletions
+76
View File
@@ -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 });
}
}
+48
View File
@@ -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;
}
}
+14
View File
@@ -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";
+84
View File
@@ -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));
}
}
+103
View File
@@ -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();
}
}
+59
View File
@@ -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(); }
};
}
+110
View File
@@ -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());
}
+38
View File
@@ -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;
});
}