mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-14 01:39:56 -05:00
Compare commits
4 Commits
7b2ae74c02
...
244447da9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 244447da9b | |||
| f05f781b5b | |||
| f7c5aebf29 | |||
| e09ae9d2e7 |
@@ -47,12 +47,22 @@ mod windows_hello {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn authenticate(reason: &str) -> Result<(), String> {
|
pub fn authenticate(reason: &str) -> Result<(), String> {
|
||||||
nudge_focus(5, 250);
|
let reason = reason.to_owned();
|
||||||
let result = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason))
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
.and_then(|op| {
|
|
||||||
nudge_focus(5, 250);
|
std::thread::spawn(move || {
|
||||||
op.get()
|
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:?}"))?;
|
.map_err(|e| format!("internalError:{e:?}"))?;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|||||||
Vendored
+10
@@ -112,7 +112,17 @@ export function deprioritizeQueue(): void {
|
|||||||
queue.sort((a, b) => b.priority - a.priority);
|
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 {
|
export function clearBlobCache(): void {
|
||||||
|
cancelQueuedFetches();
|
||||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
inflight.clear();
|
||||||
}
|
}
|
||||||
Vendored
+12
-7
@@ -1,12 +1,11 @@
|
|||||||
import { gql, getServerUrl } from "@api/client";
|
import { gql, getServerUrl } from "@api/client";
|
||||||
import { getBlobUrl } from "@core/cache/imageCache";
|
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||||
import { dedupeRequest } from "@core/async/batchRequests";
|
import { dedupeRequest } from "@core/async/batchRequests";
|
||||||
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
const resolvedUrlCache = new Map<string, Promise<string>>();
|
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||||
const preloadedUrls = new Set<string>();
|
|
||||||
const aspectCache = new Map<string, number>();
|
const aspectCache = new Map<string, number>();
|
||||||
|
|
||||||
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
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 {
|
export function preloadImage(url: string, useBlob: boolean): void {
|
||||||
if (preloadedUrls.has(url)) return;
|
if (useBlob) {
|
||||||
preloadedUrls.add(url);
|
preloadBlobUrls([url], 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearResolvedUrlCache(): void {
|
||||||
|
resolvedUrlCache.clear();
|
||||||
|
aspectCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
export function clearPageCache(chapterId?: number): void {
|
export function clearPageCache(chapterId?: number): void {
|
||||||
if (chapterId !== undefined) {
|
if (chapterId !== undefined) {
|
||||||
pageCache.delete(chapterId);
|
pageCache.delete(chapterId);
|
||||||
@@ -76,7 +82,6 @@ export function clearPageCache(chapterId?: number): void {
|
|||||||
pageCache.clear();
|
pageCache.clear();
|
||||||
inflight.clear();
|
inflight.clear();
|
||||||
resolvedUrlCache.clear();
|
resolvedUrlCache.clear();
|
||||||
preloadedUrls.clear();
|
|
||||||
aspectCache.clear();
|
aspectCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+17
-8
@@ -31,13 +31,13 @@ export function formatReadTime(m: number): string {
|
|||||||
|
|
||||||
const STRICT_TAGS: string[] = [
|
const STRICT_TAGS: string[] = [
|
||||||
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
"18+", "smut", "lemon", "explicit", "sexual violence",
|
"18+", "smut", "explicit", "sexual violence",
|
||||||
"gore", "guro", "graphic violence", "torture", "body horror",
|
"gore", "guro", "graphic violence", "torture", "body horror",
|
||||||
];
|
];
|
||||||
|
|
||||||
const MODERATE_TAGS: string[] = [
|
const MODERATE_TAGS: string[] = [
|
||||||
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
"18+", "smut", "lemon", "explicit", "sexual violence",
|
"18+", "smut", "explicit", "sexual violence",
|
||||||
];
|
];
|
||||||
|
|
||||||
type ContentFilterSettings = Pick<
|
type ContentFilterSettings = Pick<
|
||||||
@@ -53,7 +53,16 @@ function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
|||||||
|
|
||||||
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
|
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
|
||||||
if (!blockedTags.length) return false;
|
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(
|
export function shouldHideNsfw(
|
||||||
@@ -69,10 +78,10 @@ export function shouldHideNsfw(
|
|||||||
if (srcId && blocked.includes(srcId)) return true;
|
if (srcId && blocked.includes(srcId)) return true;
|
||||||
|
|
||||||
const sourceAllowed = !!(srcId && allowed.includes(srcId));
|
const sourceAllowed = !!(srcId && allowed.includes(srcId));
|
||||||
const blockedTags = blockedTagsForSettings(settings);
|
|
||||||
|
|
||||||
if (!sourceAllowed && manga.source?.isNsfw && settings.contentLevel === "strict") return true;
|
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||||
return genreMatchesBlocklist(manga.genre ?? [], blockedTags);
|
|
||||||
|
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldHideSource(
|
export function shouldHideSource(
|
||||||
@@ -83,10 +92,10 @@ export function shouldHideSource(
|
|||||||
|
|
||||||
if (settings.sourceOverridesEnabled) {
|
if (settings.sourceOverridesEnabled) {
|
||||||
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
|
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(
|
export function dedupeSourcesByLang(
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
let filtered = allSources;
|
let filtered = allSources;
|
||||||
if (kw_selectedLangs.size > 0)
|
if (kw_selectedLangs.size > 0)
|
||||||
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
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));
|
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { deprioritizeQueue } from "@core/cache/imageCache";
|
import { deprioritizeQueue } from "@core/cache/imageCache";
|
||||||
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||||
import { shouldHideNsfw } from "@core/util";
|
import { shouldHideNsfw } from "@core/util";
|
||||||
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte";
|
import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
|
||||||
import {
|
import {
|
||||||
toCachedManga,
|
toCachedManga,
|
||||||
type CachedManga,
|
type CachedManga,
|
||||||
@@ -288,6 +288,8 @@
|
|||||||
popularResults={popular_results}
|
popularResults={popular_results}
|
||||||
popularLoading={popular_loading}
|
popularLoading={popular_loading}
|
||||||
{sourceCache}
|
{sourceCache}
|
||||||
|
query={store.searchQuery}
|
||||||
|
onQueryChange={setSearchQuery}
|
||||||
onPrefillConsumed={() => (pendingPrefill = "")}
|
onPrefillConsumed={() => (pendingPrefill = "")}
|
||||||
onPreview={setPreviewManga}
|
onPreview={setPreviewManga}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
store, setCategories, setLibraryUpdates, addToast,
|
store, setCategories, setLibraryUpdates, addToast,
|
||||||
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
||||||
} from "../store/libraryState.svelte";
|
} from "../store/libraryState.svelte";
|
||||||
|
import { saveScroll, getScroll } from "@store/state.svelte";
|
||||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
||||||
import type { Manga, Category, Chapter } from "@types";
|
import type { Manga, Category, Chapter } from "@types";
|
||||||
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
|
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
|
||||||
@@ -171,7 +172,18 @@
|
|||||||
|
|
||||||
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
||||||
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
|
$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(() => {
|
$effect(() => {
|
||||||
const f = tab;
|
const f = tab;
|
||||||
if (f === "library" || f === "downloaded") return;
|
if (f === "library" || f === "downloaded") return;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
fadingOut: boolean;
|
fadingOut: boolean;
|
||||||
tapToToggleBar: boolean;
|
tapToToggleBar: boolean;
|
||||||
pinchZoomEnabled: boolean;
|
pinchZoomEnabled: boolean;
|
||||||
|
chapterEpoch: number;
|
||||||
onGetZoom: () => number;
|
onGetZoom: () => number;
|
||||||
onSetZoom: (z: number) => void;
|
onSetZoom: (z: number) => void;
|
||||||
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||||
@@ -31,10 +32,152 @@
|
|||||||
const {
|
const {
|
||||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||||
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom,
|
tapToToggleBar, pinchZoomEnabled, chapterEpoch, onGetZoom, onSetZoom,
|
||||||
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||||
}: Props = $props();
|
}: 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_STEP = 0.15;
|
||||||
const INSPECT_ZOOM_MAX = 8;
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
@@ -194,7 +337,17 @@
|
|||||||
function setContainer(el: HTMLDivElement) {
|
function setContainer(el: HTMLDivElement) {
|
||||||
containerEl = el;
|
containerEl = el;
|
||||||
bindContainer(el);
|
bindContainer(el);
|
||||||
|
if (style === "longstrip") setupObserver(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (style === "longstrip" && containerEl) {
|
||||||
|
setupObserver(containerEl);
|
||||||
|
} else if (style !== "longstrip") {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -221,61 +374,77 @@
|
|||||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if style === "longstrip"}
|
{#key chapterEpoch}
|
||||||
{#each stripToRender as chunk}
|
{#if style === "longstrip"}
|
||||||
{#each chunk.urls as url, i}
|
{#each flatPages as page, gi}
|
||||||
{#if i < 8}
|
{@const src = resolvedSrc[gi]}
|
||||||
{#await resolveUrl(url, 8 - i)}
|
{@const isLoaded = loadedSet.has(gi)}
|
||||||
<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" />
|
<div
|
||||||
{:then src}
|
class="strip-slot"
|
||||||
<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" />
|
use:observePage={gi}
|
||||||
{/await}
|
data-gi={gi}
|
||||||
{:else}
|
>
|
||||||
{#await resolveUrl(url, 0)}
|
{#if isLoaded}
|
||||||
<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" />
|
<img
|
||||||
{:then src}
|
src={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" />
|
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
||||||
{/await}
|
data-local-page={page.localIndex + 1}
|
||||||
{/if}
|
data-chapter={page.chapterId}
|
||||||
{/each}
|
data-total={page.total}
|
||||||
{/each}
|
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
|
||||||
<div style="height:1px;flex-shrink:0"></div>
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
{:else if style === "fade" && pageReady}
|
onload={(e) => {
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
const slot = img.closest<HTMLElement>(".strip-slot");
|
||||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
if (slot && img.naturalWidth > 0) {
|
||||||
{:then src}
|
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
}
|
||||||
{/await}
|
}}
|
||||||
</div>
|
/>
|
||||||
|
{:else}
|
||||||
{:else if style === "double" && pageReady}
|
<div class="strip-placeholder"></div>
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
{/if}
|
||||||
{#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>
|
</div>
|
||||||
{:else}
|
{/each}
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div style="height:1px;flex-shrink:0"></div>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if pageReady}
|
{: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)">
|
<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)}
|
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
||||||
{:then src}
|
{:then src}
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
{: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>
|
</div>
|
||||||
|
|
||||||
@@ -290,6 +459,20 @@
|
|||||||
|
|
||||||
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
.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 { display: block; user-select: none; image-rendering: auto; }
|
||||||
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||||
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||||
|
|||||||
@@ -420,23 +420,28 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ahead = store.settings.preloadPages ?? 3;
|
const ahead = store.settings.preloadPages ?? 3;
|
||||||
const current = store.pageUrls[store.pageNumber - 1];
|
const current = store.pageUrls[store.pageNumber - 1];
|
||||||
|
const pageNum = store.pageNumber;
|
||||||
|
const urls = store.pageUrls;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
if (useBlob) {
|
const t = setTimeout(() => {
|
||||||
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
if (useBlob) {
|
||||||
getBlobUrl(current, 999);
|
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
||||||
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
|
getBlobUrl(current, 999);
|
||||||
const behind = store.pageUrls[store.pageNumber - 2];
|
const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
|
||||||
preloadBlobUrls(upcoming, ahead);
|
const behind = urls[pageNum - 2];
|
||||||
if (behind) preloadBlobUrls([behind], 0);
|
preloadBlobUrls(upcoming, ahead);
|
||||||
});
|
if (behind) preloadBlobUrls([behind], 0);
|
||||||
} else {
|
});
|
||||||
for (let i = 1; i <= ahead; i++) {
|
} else {
|
||||||
const url = store.pageUrls[store.pageNumber - 1 + i];
|
for (let i = 1; i <= ahead; i++) {
|
||||||
if (url) preloadImage(url, useBlob);
|
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];
|
}, 150);
|
||||||
if (behind) preloadImage(behind, useBlob);
|
return () => clearTimeout(t);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { store, openReader } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { readerState } from "../store/readerState.svelte";
|
import { readerState } from "../store/readerState.svelte";
|
||||||
import { fetchPages } from "./pageLoader";
|
import { fetchPages } from "./pageLoader";
|
||||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
import { cancelQueuedFetches } from "@core/cache/imageCache";
|
||||||
|
import { clearResolvedUrlCache } from "@core/cache/pageCache";
|
||||||
|
|
||||||
export function scheduleResumeDismiss() {
|
export function scheduleResumeDismiss() {
|
||||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||||
@@ -19,6 +21,10 @@ export async function loadChapter(
|
|||||||
abortCtrl.current?.abort();
|
abortCtrl.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortCtrl.current = ctrl;
|
abortCtrl.current = ctrl;
|
||||||
|
|
||||||
|
cancelQueuedFetches();
|
||||||
|
if (useBlob) clearResolvedUrlCache();
|
||||||
|
|
||||||
startAtLastPage.current = false;
|
startAtLastPage.current = false;
|
||||||
markedRead.clear();
|
markedRead.clear();
|
||||||
readerState.resetForChapter();
|
readerState.resetForChapter();
|
||||||
@@ -43,7 +49,7 @@ export async function loadChapter(
|
|||||||
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||||
readerState.pageReady = true;
|
readerState.pageReady = true;
|
||||||
readerState.loading = false;
|
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) {
|
} catch (e: any) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
readerState.error = e instanceof Error ? e.message : String(e);
|
readerState.error = e instanceof Error ? e.message : String(e);
|
||||||
|
|||||||
@@ -25,57 +25,58 @@ export function setupScrollTracking(
|
|||||||
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
||||||
} = callbacks;
|
} = callbacks;
|
||||||
|
|
||||||
function onScroll() {
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
rafId = null;
|
||||||
|
|
||||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
if (!imgs.length) return;
|
if (!imgs.length) return;
|
||||||
|
|
||||||
const containerTop = containerEl.getBoundingClientRect().top;
|
const containerTop = containerEl.getBoundingClientRect().top;
|
||||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
||||||
|
|
||||||
let activePage: number | null = null;
|
let lo = 0, hi = imgs.length - 1, best = 0;
|
||||||
let activeChId: number | null = null;
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
for (const img of imgs) {
|
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
|
||||||
if (img.getBoundingClientRect().top <= readLineY) {
|
else hi = mid - 1;
|
||||||
activePage = Number(img.dataset.localPage);
|
|
||||||
activeChId = Number(img.dataset.chapter);
|
|
||||||
} else break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePage === null) {
|
const active = imgs[best];
|
||||||
activePage = Number(imgs[0].dataset.localPage);
|
const activePage = Number(active.dataset.localPage);
|
||||||
activeChId = Number(imgs[0].dataset.chapter);
|
const activeChId = Number(active.dataset.chapter);
|
||||||
}
|
|
||||||
|
|
||||||
if (activePage !== null) onPageChange(activePage);
|
onPageChange(activePage);
|
||||||
if (activeChId) onChapterChange(activeChId);
|
if (activeChId) onChapterChange(activeChId);
|
||||||
|
|
||||||
if (shouldAutoMark() && activePage !== null && activeChId) {
|
if (shouldAutoMark() && activeChId) {
|
||||||
const chunks = getStripChapters();
|
const chunks = getStripChapters();
|
||||||
const chunk = chunks.find(c => c.chapterId === activeChId);
|
const chunk = chunks.find(c => c.chapterId === activeChId);
|
||||||
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
||||||
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
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;
|
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||||
if (pct >= 0.80) onAppend();
|
if (pct >= 0.80) onAppend();
|
||||||
}
|
}
|
||||||
|
|
||||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
function onScroll() {
|
||||||
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
|
if (rafId !== null) return;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
containerEl.removeEventListener("scroll", onScroll);
|
containerEl.removeEventListener("scroll", onScroll);
|
||||||
containerEl.removeEventListener("scroll", onScrollAppend);
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
enqueueing: Set<number>;
|
enqueueing: Set<number>;
|
||||||
chapterPage: number;
|
chapterPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
scrollEl?: HTMLDivElement | null;
|
||||||
onOpen: (ch: Chapter, inProgress: boolean) => void;
|
onOpen: (ch: Chapter, inProgress: boolean) => void;
|
||||||
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
|
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
|
||||||
onEnqueue: (ch: Chapter, e: MouseEvent) => void;
|
onEnqueue: (ch: Chapter, e: MouseEvent) => void;
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
let {
|
let {
|
||||||
pageChapters, sortedChapters, viewMode, loadingChapters,
|
pageChapters, sortedChapters, viewMode, loadingChapters,
|
||||||
selectedIds, enqueueing, chapterPage, totalPages,
|
selectedIds, enqueueing, chapterPage, totalPages,
|
||||||
|
scrollEl = $bindable(null),
|
||||||
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
|
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
|
||||||
onPageChange, buildCtxItems,
|
onPageChange, buildCtxItems,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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 loadingChapters && sortedChapters.length === 0}
|
||||||
{#if viewMode === "grid"}
|
{#if viewMode === "grid"}
|
||||||
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
addBookmark, acknowledgeUpdate,
|
addBookmark, acknowledgeUpdate,
|
||||||
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
||||||
clearMarkersForManga,
|
clearMarkersForManga,
|
||||||
|
saveScroll, getScroll,
|
||||||
} from "@store/state.svelte";
|
} from "@store/state.svelte";
|
||||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
import type { MangaPrefs } from "@store/state.svelte";
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
@@ -583,6 +584,20 @@
|
|||||||
} catch (e) { console.error(e); }
|
} 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(); });
|
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -665,6 +680,7 @@
|
|||||||
{enqueueing}
|
{enqueueing}
|
||||||
{chapterPage}
|
{chapterPage}
|
||||||
{totalPages}
|
{totalPages}
|
||||||
|
bind:scrollEl={chapterListEl}
|
||||||
onOpen={openReaderWithAhead}
|
onOpen={openReaderWithAhead}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
onEnqueue={enqueue}
|
onEnqueue={enqueue}
|
||||||
|
|||||||
@@ -318,6 +318,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.s-presets {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── Select Dropdown ──────────────────────────────────────────────── */
|
/* ── Select Dropdown ──────────────────────────────────────────────── */
|
||||||
.s-select { position: relative; flex-shrink: 0; }
|
.s-select { position: relative; flex-shrink: 0; }
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Render Limit</p>
|
<p class="s-section-title">Render Limit</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
<div class="s-row">
|
<div class="s-slider-row">
|
||||||
<div class="s-row-info">
|
<div class="s-row-info">
|
||||||
<span class="s-label">Items per page</span>
|
<span class="s-label">Items per page</span>
|
||||||
<span class="s-desc">Lower = faster on large libraries</span>
|
<span class="s-desc">Lower = faster on large libraries</span>
|
||||||
@@ -95,16 +95,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Session Cache</p>
|
<p class="s-section-title">Session Cache</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
function openManga() {
|
function openManga() {
|
||||||
if (!record.manga) return;
|
if (!record.manga) return;
|
||||||
setActiveManga(record.manga as any);
|
setActiveManga(record.manga as any);
|
||||||
setNavPage("library");
|
setNavPage(store.navPage);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
store.activeManga = null;
|
store.activeManga = null;
|
||||||
store.activeSource = null;
|
store.activeSource = null;
|
||||||
store.genreFilter = "";
|
store.genreFilter = "";
|
||||||
|
store.searchQuery = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
store.activeManga = null;
|
store.activeManga = null;
|
||||||
store.libraryFilter = "library";
|
store.libraryFilter = "library";
|
||||||
store.genreFilter = "";
|
store.genreFilter = "";
|
||||||
|
store.searchQuery = "";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
let loadingLinkList = $state(false);
|
let loadingLinkList = $state(false);
|
||||||
let coverPickerOpen = $state(false);
|
let coverPickerOpen = $state(false);
|
||||||
|
|
||||||
|
let originNavPage = store.navPage;
|
||||||
|
|
||||||
const linkedIds = $derived(
|
const linkedIds = $derived(
|
||||||
store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [],
|
store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [],
|
||||||
);
|
);
|
||||||
@@ -152,6 +154,7 @@
|
|||||||
const shouldAutoLink = store.settings.autoLinkOnOpen;
|
const shouldAutoLink = store.settings.autoLinkOnOpen;
|
||||||
const focal = store.previewManga;
|
const focal = store.previewManga;
|
||||||
if (focal) {
|
if (focal) {
|
||||||
|
originNavPage = store.navPage;
|
||||||
load(focal.id);
|
load(focal.id);
|
||||||
loadCategories(focal.id);
|
loadCategories(focal.id);
|
||||||
if (shouldAutoLink) {
|
if (shouldAutoLink) {
|
||||||
@@ -256,7 +259,7 @@
|
|||||||
function openSeriesDetail() {
|
function openSeriesDetail() {
|
||||||
if (!displayManga) return;
|
if (!displayManga) return;
|
||||||
setActiveManga(displayManga);
|
setActiveManga(displayManga);
|
||||||
setNavPage("library");
|
setNavPage(originNavPage);
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-12
@@ -3,20 +3,32 @@ export type NavPage =
|
|||||||
| "downloads" | "extensions" | "history" | "search" | "tracking";
|
| "downloads" | "extensions" | "history" | "search" | "tracking";
|
||||||
|
|
||||||
class AppStore {
|
class AppStore {
|
||||||
navPage: NavPage = $state("home");
|
navPage: NavPage = $state("home");
|
||||||
settingsOpen: boolean = $state(false);
|
settingsOpen: boolean = $state(false);
|
||||||
searchPrefill: string = $state("");
|
searchPrefill: string = $state("");
|
||||||
genreFilter: string = $state("");
|
searchQuery: string = $state("");
|
||||||
|
genreFilter: string = $state("");
|
||||||
|
scrollPositions: Map<string, number> = $state(new Map());
|
||||||
|
|
||||||
setNavPage(next: NavPage) { this.navPage = next; }
|
setNavPage(next: NavPage) { this.navPage = next; }
|
||||||
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
||||||
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
||||||
setGenreFilter(next: string) { this.genreFilter = 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 const app = new AppStore();
|
||||||
|
|
||||||
export function setNavPage(next: NavPage) { app.setNavPage(next); }
|
export function setNavPage(next: NavPage) { app.setNavPage(next); }
|
||||||
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); }
|
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); }
|
||||||
export function setSearchPrefill(next: string) { app.setSearchPrefill(next); }
|
export function setSearchPrefill(next: string) { app.setSearchPrefill(next); }
|
||||||
export function setGenreFilter(next: string) { app.setGenreFilter(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 ?? {},
|
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
|
||||||
customThemes: saved?.settings?.customThemes ?? [],
|
customThemes: saved?.settings?.customThemes ?? [],
|
||||||
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
||||||
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags,
|
|
||||||
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
||||||
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
||||||
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||||
@@ -94,6 +93,8 @@ class Store {
|
|||||||
set settingsOpen(v) { app.setSettingsOpen(v); }
|
set settingsOpen(v) { app.setSettingsOpen(v); }
|
||||||
get searchPrefill() { return app.searchPrefill; }
|
get searchPrefill() { return app.searchPrefill; }
|
||||||
set searchPrefill(v) { app.setSearchPrefill(v); }
|
set searchPrefill(v) { app.setSearchPrefill(v); }
|
||||||
|
get searchQuery() { return app.searchQuery; }
|
||||||
|
set searchQuery(v) { app.setSearchQuery(v); }
|
||||||
get genreFilter() { return app.genreFilter; }
|
get genreFilter() { return app.genreFilter; }
|
||||||
set genreFilter(v) { app.setGenreFilter(v); }
|
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); }
|
): Promise<void> { return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus); }
|
||||||
|
|
||||||
export { addToast, dismissToast, setActiveDownloads } from "./notifications.svelte";
|
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