Compare commits

..

4 Commits

Author SHA1 Message Date
Youwes09 244447da9b Feat: Backtracing + NavPage Store 2026-05-10 04:31:27 -05:00
Youwes09 f05f781b5b Fix: Biometric Revision V1 2026-05-10 03:00:08 -05:00
Youwes09 f7c5aebf29 Fix: PerformanceSettings RenderLimit CSS Revision (#63) 2026-05-10 02:50:48 -05:00
Youwes09 e09ae9d2e7 Fix: Respect Page-Order in Loading & Memory Eviction (#61, #63, #68) 2026-05-10 02:17:25 -05:00
20 changed files with 431 additions and 156 deletions
+16 -6
View File
@@ -47,12 +47,22 @@ mod windows_hello {
}
pub fn authenticate(reason: &str) -> Result<(), String> {
nudge_focus(5, 250);
let result = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason))
.and_then(|op| {
nudge_focus(5, 250);
op.get()
})
let reason = reason.to_owned();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
nudge_focus(5, 250);
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
.and_then(|op| {
nudge_focus(5, 250);
op.get()
});
let _ = tx.send(outcome);
});
let result = rx
.recv()
.map_err(|e| format!("internalError:{e:?}"))?
.map_err(|e| format!("internalError:{e:?}"))?;
match result {
+10
View File
@@ -112,7 +112,17 @@ export function deprioritizeQueue(): void {
queue.sort((a, b) => b.priority - a.priority);
}
export function cancelQueuedFetches(): void {
const dropped = queue.splice(0);
for (const entry of dropped) {
inflight.delete(entry.url);
entry.reject(new DOMException("Cancelled", "AbortError"));
}
}
export function clearBlobCache(): void {
cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
inflight.clear();
}
+12 -7
View File
@@ -1,12 +1,11 @@
import { gql, getServerUrl } from "@api/client";
import { getBlobUrl } from "@core/cache/imageCache";
import { dedupeRequest } from "@core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
import { dedupeRequest } from "@core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
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> {
@@ -63,11 +62,18 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
}
export function preloadImage(url: string, useBlob: boolean): void {
if (preloadedUrls.has(url)) return;
preloadedUrls.add(url);
if (useBlob) {
preloadBlobUrls([url], 0);
return;
}
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
}
export function clearResolvedUrlCache(): void {
resolvedUrlCache.clear();
aspectCache.clear();
}
export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) {
pageCache.delete(chapterId);
@@ -76,7 +82,6 @@ export function clearPageCache(chapterId?: number): void {
pageCache.clear();
inflight.clear();
resolvedUrlCache.clear();
preloadedUrls.clear();
aspectCache.clear();
}
}
+17 -8
View File
@@ -31,13 +31,13 @@ export function formatReadTime(m: number): string {
const STRICT_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "lemon", "explicit", "sexual violence",
"18+", "smut", "explicit", "sexual violence",
"gore", "guro", "graphic violence", "torture", "body horror",
];
const MODERATE_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "lemon", "explicit", "sexual violence",
"18+", "smut", "explicit", "sexual violence",
];
type ContentFilterSettings = Pick<
@@ -53,7 +53,16 @@ function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
if (!blockedTags.length) return false;
return genre.some(g => blockedTags.some(tag => g.toLowerCase().trim().includes(tag)));
return genre.some(g => {
const norm = g.toLowerCase().trim();
return blockedTags.some(tag => {
const idx = norm.indexOf(tag);
if (idx === -1) return false;
const before = idx === 0 || /\W/.test(norm[idx - 1]);
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
return before && after;
});
});
}
export function shouldHideNsfw(
@@ -69,10 +78,10 @@ export function shouldHideNsfw(
if (srcId && blocked.includes(srcId)) return true;
const sourceAllowed = !!(srcId && allowed.includes(srcId));
const blockedTags = blockedTagsForSettings(settings);
if (!sourceAllowed && manga.source?.isNsfw && settings.contentLevel === "strict") return true;
return genreMatchesBlocklist(manga.genre ?? [], blockedTags);
if (!sourceAllowed && manga.source?.isNsfw) return true;
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
}
export function shouldHideSource(
@@ -83,10 +92,10 @@ export function shouldHideSource(
if (settings.sourceOverridesEnabled) {
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return settings.contentLevel === "strict";
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
}
return source.isNsfw && settings.contentLevel === "strict";
return source.isNsfw;
}
export function dedupeSourcesByLang(
@@ -76,7 +76,7 @@
let filtered = allSources;
if (kw_selectedLangs.size > 0)
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
if (!store.settings.showNsfw)
if (store.settings.contentLevel !== "unrestricted")
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
return filtered;
}
@@ -8,7 +8,7 @@
import { deprioritizeQueue } from "@core/cache/imageCache";
import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw } from "@core/util";
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte";
import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
import {
toCachedManga,
type CachedManga,
@@ -288,6 +288,8 @@
popularResults={popular_results}
popularLoading={popular_loading}
{sourceCache}
query={store.searchQuery}
onQueryChange={setSearchQuery}
onPrefillConsumed={() => (pendingPrefill = "")}
onPreview={setPreviewManga}
/>
+13 -1
View File
@@ -18,6 +18,7 @@
store, setCategories, setLibraryUpdates, addToast,
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
} from "../store/libraryState.svelte";
import { saveScroll, getScroll } from "@store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
import type { Manga, Category, Chapter } from "@types";
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
@@ -171,7 +172,18 @@
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
let prevTab = tab;
$effect(() => {
const nextTab = tab;
if (scrollEl && nextTab !== prevTab) {
saveScroll(`library:${prevTab}`, scrollEl.scrollTop);
const saved = getScroll(`library:${nextTab}`);
untrack(() => { scrollEl.scrollTo({ top: saved }); });
prevTab = nextTab;
} else if (scrollEl && nextTab === prevTab) {
scrollEl.scrollTo({ top: 0 });
}
});
$effect(() => {
const f = tab;
if (f === "library" || f === "downloaded") return;
+237 -54
View File
@@ -19,6 +19,7 @@
fadingOut: boolean;
tapToToggleBar: boolean;
pinchZoomEnabled: boolean;
chapterEpoch: number;
onGetZoom: () => number;
onSetZoom: (z: number) => void;
resolveUrl: (url: string, priority?: number) => Promise<string>;
@@ -31,10 +32,152 @@
const {
style, imgCls, effectiveWidth, loading, error, pageReady,
pageGroups, currentGroup, stripToRender, fadingOut,
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom,
tapToToggleBar, pinchZoomEnabled, chapterEpoch, onGetZoom, onSetZoom,
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
}: Props = $props();
const LOAD_RADIUS = 5;
const UNLOAD_RADIUS = 10;
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
const flatPages = $derived.by<FlatPage[]>(() => {
const out: FlatPage[] = [];
for (const chunk of stripToRender) {
for (let i = 0; i < chunk.urls.length; i++) {
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
}
}
return out;
});
let loadedSet = $state(new Set<number>());
let resolvedSrc = $state<Record<number, string>>({});
let revokeQueue: string[] = [];
let observer: IntersectionObserver | null = null;
const elementIndex = new Map<Element, number>();
let viewportCenter = $state(0);
function scheduleRevoke(src: string) {
if (!src || !src.startsWith("blob:")) return;
revokeQueue.push(src);
requestAnimationFrame(() => {
const url = revokeQueue.shift();
if (url) {
try { URL.revokeObjectURL(url); } catch { }
}
});
}
function loadPage(idx: number) {
if (loadedSet.has(idx)) return;
const page = flatPages[idx];
if (!page) return;
const newSet = new Set(loadedSet);
newSet.add(idx);
loadedSet = newSet;
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
resolveUrl(page.url, priority).then(src => {
if (loadedSet.has(idx)) {
resolvedSrc = { ...resolvedSrc, [idx]: src };
} else {
scheduleRevoke(src);
}
});
}
function unloadPage(idx: number) {
if (!loadedSet.has(idx)) return;
const newSet = new Set(loadedSet);
newSet.delete(idx);
loadedSet = newSet;
const oldSrc = resolvedSrc[idx];
if (oldSrc) {
const next = { ...resolvedSrc };
delete next[idx];
resolvedSrc = next;
scheduleRevoke(oldSrc);
}
}
function recalcWindow() {
const center = viewportCenter;
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
const evictLo = center - UNLOAD_RADIUS;
const evictHi = center + UNLOAD_RADIUS;
for (let i = 0; i < flatPages.length; i++) {
if (i >= lo && i <= hi) {
loadPage(i);
} else if (i < evictLo || i > evictHi) {
unloadPage(i);
}
}
}
$effect(() => {
void viewportCenter;
recalcWindow();
});
$effect(() => {
void flatPages.length;
recalcWindow();
});
function setupObserver(containerEl: HTMLElement) {
observer?.disconnect();
elementIndex.clear();
observer = new IntersectionObserver(
(entries) => {
let best = -1;
let bestRatio = -1;
for (const entry of entries) {
const idx = elementIndex.get(entry.target);
if (idx === undefined) continue;
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
bestRatio = entry.intersectionRatio;
best = idx;
}
}
if (best >= 0 && best !== viewportCenter) {
viewportCenter = best;
}
},
{
root: containerEl,
rootMargin: "0px",
threshold: [0, 0.1, 0.5, 1.0],
}
);
}
function observePage(el: HTMLDivElement, idx: number) {
elementIndex.set(el, idx);
observer?.observe(el);
return {
update(newIdx: number) {
elementIndex.set(el, newIdx);
},
destroy() {
observer?.unobserve(el);
elementIndex.delete(el);
}
};
}
$effect(() => {
void chapterEpoch;
loadedSet = new Set<number>();
resolvedSrc = {};
const resume = readerState.resumePage;
viewportCenter = resume > 1 ? resume - 1 : 0;
});
const INSPECT_ZOOM_STEP = 0.15;
const INSPECT_ZOOM_MAX = 8;
@@ -194,7 +337,17 @@
function setContainer(el: HTMLDivElement) {
containerEl = el;
bindContainer(el);
if (style === "longstrip") setupObserver(el);
}
$effect(() => {
if (style === "longstrip" && containerEl) {
setupObserver(containerEl);
} else if (style !== "longstrip") {
observer?.disconnect();
observer = null;
}
});
</script>
<div
@@ -221,61 +374,77 @@
<div class="center-overlay"><p class="error-msg">{error}</p></div>
{/if}
{#if style === "longstrip"}
{#each stripToRender as chunk}
{#each chunk.urls as url, i}
{#if i < 8}
{#await resolveUrl(url, 8 - i)}
<img src="" alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading="eager" decoding="async" />
{:then src}
<img {src} alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading="eager" decoding="async" />
{/await}
{:else}
{#await resolveUrl(url, 0)}
<img src="" alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading="lazy" decoding="async" />
{:then src}
<img {src} alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading="lazy" decoding="async" />
{/await}
{/if}
{/each}
{/each}
<div style="height:1px;flex-shrink:0"></div>
{:else if style === "fade" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
{/await}
</div>
{:else if style === "double" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#if pageGroups.length}
<div class="double-wrap">
{#each currentGroup as pg, i}
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
{:then src}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
{/await}
{/each}
{#key chapterEpoch}
{#if style === "longstrip"}
{#each flatPages as page, gi}
{@const src = resolvedSrc[gi]}
{@const isLoaded = loadedSet.has(gi)}
<div
class="strip-slot"
use:observePage={gi}
data-gi={gi}
>
{#if isLoaded}
<img
src={src ?? ""}
alt="{page.chapterName} Page {page.localIndex + 1}"
data-local-page={page.localIndex + 1}
data-chapter={page.chapterId}
data-total={page.total}
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
loading="eager"
decoding="async"
onload={(e) => {
const img = e.currentTarget as HTMLImageElement;
const slot = img.closest<HTMLElement>(".strip-slot");
if (slot && img.naturalWidth > 0) {
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
}
}}
/>
{:else}
<div class="strip-placeholder"></div>
{/if}
</div>
{:else}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if}
</div>
{/each}
<div style="height:1px;flex-shrink:0"></div>
{:else if pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{/await}
</div>
{/if}
{:else if style === "fade" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
{/await}
</div>
{:else if style === "double" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#if pageGroups.length}
<div class="double-wrap">
{#each currentGroup as pg, i}
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
{:then src}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
{/await}
{/each}
</div>
{:else}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if}
</div>
{:else if pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{/await}
</div>
{/if}
{/key}
</div>
@@ -290,6 +459,20 @@
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.strip-slot {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.strip-placeholder {
width: var(--effective-width, 100%);
max-width: var(--effective-width, 100%);
aspect-ratio: var(--aspect, 0.667);
background: transparent;
}
.img { display: block; user-select: none; image-rendering: auto; }
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
+20 -15
View File
@@ -420,23 +420,28 @@
$effect(() => {
const ahead = store.settings.preloadPages ?? 3;
const current = store.pageUrls[store.pageNumber - 1];
const pageNum = store.pageNumber;
const urls = store.pageUrls;
if (!current) return;
if (useBlob) {
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
getBlobUrl(current, 999);
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
const behind = store.pageUrls[store.pageNumber - 2];
preloadBlobUrls(upcoming, ahead);
if (behind) preloadBlobUrls([behind], 0);
});
} else {
for (let i = 1; i <= ahead; i++) {
const url = store.pageUrls[store.pageNumber - 1 + i];
if (url) preloadImage(url, useBlob);
const t = setTimeout(() => {
if (useBlob) {
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
getBlobUrl(current, 999);
const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
const behind = urls[pageNum - 2];
preloadBlobUrls(upcoming, ahead);
if (behind) preloadBlobUrls([behind], 0);
});
} else {
for (let i = 1; i <= ahead; i++) {
const url = urls[pageNum - 1 + i];
if (url) preloadImage(url, useBlob);
}
const behind = urls[pageNum - 2];
if (behind) preloadImage(behind, useBlob);
}
const behind = store.pageUrls[store.pageNumber - 2];
if (behind) preloadImage(behind, useBlob);
}
}, 150);
return () => clearTimeout(t);
});
$effect(() => {
+11 -5
View File
@@ -1,7 +1,9 @@
import { store, openReader } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import { fetchPages } from "./pageLoader";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import { fetchPages } from "./pageLoader";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import { cancelQueuedFetches } from "@core/cache/imageCache";
import { clearResolvedUrlCache } from "@core/cache/pageCache";
export function scheduleResumeDismiss() {
setTimeout(() => { readerState.resumeFading = true; }, 1500);
@@ -19,6 +21,10 @@ export async function loadChapter(
abortCtrl.current?.abort();
const ctrl = new AbortController();
abortCtrl.current = ctrl;
cancelQueuedFetches();
if (useBlob) clearResolvedUrlCache();
startAtLastPage.current = false;
markedRead.clear();
readerState.resetForChapter();
@@ -43,7 +49,7 @@ export async function loadChapter(
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(() => {});
if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
} catch (e: any) {
if (ctrl.signal.aborted) return;
readerState.error = e instanceof Error ? e.message : String(e);
+30 -29
View File
@@ -25,57 +25,58 @@ export function setupScrollTracking(
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
} = callbacks;
function onScroll() {
let rafId: number | null = null;
function tick() {
rafId = null;
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;
let lo = 0, hi = imgs.length - 1, best = 0;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
else hi = mid - 1;
}
if (activePage === null) {
activePage = Number(imgs[0].dataset.localPage);
activeChId = Number(imgs[0].dataset.chapter);
}
const active = imgs[best];
const activePage = Number(active.dataset.localPage);
const activeChId = Number(active.dataset.chapter);
if (activePage !== null) onPageChange(activePage);
if (activeChId) onChapterChange(activeChId);
onPageChange(activePage);
if (activeChId) onChapterChange(activeChId);
if (shouldAutoMark() && activePage !== null && activeChId) {
if (shouldAutoMark() && 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) {
const last = chunks[chunks.length - 1];
if (last) onMarkRead(last.chapterId);
}
}
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 });
function onScroll() {
if (rafId !== null) return;
rafId = requestAnimationFrame(tick);
}
containerEl.addEventListener("scroll", onScroll, { passive: true });
return () => {
containerEl.removeEventListener("scroll", onScroll);
containerEl.removeEventListener("scroll", onScrollAppend);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}
@@ -107,4 +108,4 @@ export function appendNextChapter(
onDone();
})
.catch(() => onDone());
}
}
@@ -14,6 +14,7 @@
enqueueing: Set<number>;
chapterPage: number;
totalPages: number;
scrollEl?: HTMLDivElement | null;
onOpen: (ch: Chapter, inProgress: boolean) => void;
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
onEnqueue: (ch: Chapter, e: MouseEvent) => void;
@@ -25,6 +26,7 @@
let {
pageChapters, sortedChapters, viewMode, loadingChapters,
selectedIds, enqueueing, chapterPage, totalPages,
scrollEl = $bindable(null),
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
onPageChange, buildCtxItems,
}: Props = $props();
@@ -48,7 +50,7 @@
}
</script>
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}>
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"} bind:this={scrollEl}>
{#if loadingChapters && sortedChapters.length === 0}
{#if viewMode === "grid"}
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
@@ -17,6 +17,7 @@
addBookmark, acknowledgeUpdate,
checkAndMarkCompleted as storeCheckAndMarkCompleted,
clearMarkersForManga,
saveScroll, getScroll,
} from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import type { MangaPrefs } from "@store/state.svelte";
@@ -583,6 +584,20 @@
} catch (e) { console.error(e); }
}
let chapterListEl: HTMLDivElement | null = $state(null);
let prevMangaId: number | null = null;
$effect(() => {
const mangaId = store.activeManga?.id ?? null;
if (mangaId === prevMangaId) return;
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop);
prevMangaId = mangaId;
if (chapterListEl && mangaId !== null) {
const saved = getScroll(`series:${mangaId}`);
chapterListEl.scrollTo({ top: saved });
}
});
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script>
@@ -665,6 +680,7 @@
{enqueueing}
{chapterPage}
{totalPages}
bind:scrollEl={chapterListEl}
onOpen={openReaderWithAhead}
onToggleSelect={toggleSelect}
onEnqueue={enqueue}
@@ -318,6 +318,12 @@
text-align: center;
}
.s-presets {
display: flex;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
}
/* ── Select Dropdown ──────────────────────────────────────────────── */
.s-select { position: relative; flex-shrink: 0; }
@@ -56,7 +56,7 @@
<div class="s-section">
<p class="s-section-title">Render Limit</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-slider-row">
<div class="s-row-info">
<span class="s-label">Items per page</span>
<span class="s-desc">Lower = faster on large libraries</span>
@@ -95,16 +95,6 @@
</div>
</div>
<div class="s-section">
<p class="s-section-title">Interface</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Compact sidebar</span><span class="s-desc">Collapses the sidebar to icons only</span></div>
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="s-toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Session Cache</p>
<div class="s-section-body">
@@ -115,7 +115,7 @@
function openManga() {
if (!record.manga) return;
setActiveManga(record.manga as any);
setNavPage("library");
setNavPage(store.navPage);
onClose();
}
+3 -1
View File
@@ -25,6 +25,7 @@
store.activeManga = null;
store.activeSource = null;
store.genreFilter = "";
store.searchQuery = "";
}
function goHome() {
@@ -33,6 +34,7 @@
store.activeManga = null;
store.libraryFilter = "library";
store.genreFilter = "";
store.searchQuery = "";
}
</script>
@@ -91,4 +93,4 @@
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.settings-btn.anims { transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
.settings-btn.anims:hover { transform: rotate(30deg); }
</style>
</style>
+4 -1
View File
@@ -42,6 +42,8 @@
let loadingLinkList = $state(false);
let coverPickerOpen = $state(false);
let originNavPage = store.navPage;
const linkedIds = $derived(
store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [],
);
@@ -152,6 +154,7 @@
const shouldAutoLink = store.settings.autoLinkOnOpen;
const focal = store.previewManga;
if (focal) {
originNavPage = store.navPage;
load(focal.id);
loadCategories(focal.id);
if (shouldAutoLink) {
@@ -256,7 +259,7 @@
function openSeriesDetail() {
if (!displayManga) return;
setActiveManga(displayManga);
setNavPage("library");
setNavPage(originNavPage);
close();
}
+24 -12
View File
@@ -3,20 +3,32 @@ export type NavPage =
| "downloads" | "extensions" | "history" | "search" | "tracking";
class AppStore {
navPage: NavPage = $state("home");
settingsOpen: boolean = $state(false);
searchPrefill: string = $state("");
genreFilter: string = $state("");
navPage: NavPage = $state("home");
settingsOpen: boolean = $state(false);
searchPrefill: string = $state("");
searchQuery: string = $state("");
genreFilter: string = $state("");
scrollPositions: Map<string, number> = $state(new Map());
setNavPage(next: NavPage) { this.navPage = next; }
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
setSearchPrefill(next: string) { this.searchPrefill = next; }
setGenreFilter(next: string) { this.genreFilter = next; }
setNavPage(next: NavPage) { this.navPage = next; }
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
setSearchPrefill(next: string) { this.searchPrefill = next; }
setSearchQuery(next: string) { this.searchQuery = next; }
setGenreFilter(next: string) { this.genreFilter = next; }
saveScroll(key: string, top: number) {
const m = new Map(this.scrollPositions);
m.set(key, top);
this.scrollPositions = m;
}
getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0; }
}
export const app = new AppStore();
export function setNavPage(next: NavPage) { app.setNavPage(next); }
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); }
export function setSearchPrefill(next: string) { app.setSearchPrefill(next); }
export function setGenreFilter(next: string) { app.setGenreFilter(next); }
export function setNavPage(next: NavPage) { app.setNavPage(next); }
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); }
export function setSearchPrefill(next: string) { app.setSearchPrefill(next); }
export function setSearchQuery(next: string) { app.setSearchQuery(next); }
export function setGenreFilter(next: string) { app.setGenreFilter(next); }
export function saveScroll(key: string, top: number) { app.saveScroll(key, top); }
export function getScroll(key: string): number { return app.getScroll(key); }
+3 -2
View File
@@ -43,7 +43,6 @@ function mergeSettings(saved: any): Settings {
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
customThemes: saved?.settings?.customThemes ?? [],
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags,
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
@@ -94,6 +93,8 @@ class Store {
set settingsOpen(v) { app.setSettingsOpen(v); }
get searchPrefill() { return app.searchPrefill; }
set searchPrefill(v) { app.setSearchPrefill(v); }
get searchQuery() { return app.searchQuery; }
set searchQuery(v) { app.setSearchQuery(v); }
get genreFilter() { return app.genreFilter; }
set genreFilter(v) { app.setGenreFilter(v); }
@@ -401,4 +402,4 @@ export async function checkAndMarkCompleted(
): Promise<void> { return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus); }
export { addToast, dismissToast, setActiveDownloads } from "./notifications.svelte";
export { setNavPage, setSettingsOpen, setSearchPrefill, setGenreFilter } from "./app.svelte";
export { setNavPage, setSettingsOpen, setSearchPrefill, setSearchQuery, setGenreFilter, saveScroll, getScroll } from "./app.svelte";