mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 244447da9b | |||
| f05f781b5b | |||
| f7c5aebf29 | |||
| e09ae9d2e7 |
@@ -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 {
|
||||
|
||||
Vendored
+10
@@ -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();
|
||||
}
|
||||
Vendored
+12
-7
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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); }
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user