mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Feat: Longstrip Viewer(s) & Lag Improvements
This commit is contained in:
@@ -39,6 +39,8 @@
|
|||||||
goto(u.toString(), { replaceState: true, noScroll: true });
|
goto(u.toString(), { replaceState: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingPrefill = $state("");
|
||||||
|
|
||||||
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||||
let tabIndicator = $state({ left: 0, width: 0 });
|
let tabIndicator = $state({ left: 0, width: 0 });
|
||||||
|
|
||||||
@@ -248,11 +250,13 @@
|
|||||||
{availableLangs}
|
{availableLangs}
|
||||||
{hasMultipleLangs}
|
{hasMultipleLangs}
|
||||||
{loadingSources}
|
{loadingSources}
|
||||||
|
{pendingPrefill}
|
||||||
popularResults={popular_results}
|
popularResults={popular_results}
|
||||||
popularLoading={popular_loading}
|
popularLoading={popular_loading}
|
||||||
{sourceCache}
|
{sourceCache}
|
||||||
query={urlQuery}
|
query={urlQuery}
|
||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
|
onPrefillConsumed={() => { pendingPrefill = ""; }}
|
||||||
onPreview={(m) => setPreviewManga(m)}
|
onPreview={(m) => setPreviewManga(m)}
|
||||||
/>
|
/>
|
||||||
{:else if urlTab === "tag"}
|
{:else if urlTab === "tag"}
|
||||||
|
|||||||
@@ -53,7 +53,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) tagLoadMoreLocal();
|
const _hasNext = tag_localHasNext;
|
||||||
|
const _loadingMore = tag_loadingMoreLocal;
|
||||||
|
const _loadingLocal = tag_loadingLocal;
|
||||||
|
untrack(() => {
|
||||||
|
if (_hasNext && !_loadingMore && !_loadingLocal) tagLoadMoreLocal();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
|
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
|
||||||
@@ -191,11 +196,15 @@
|
|||||||
|
|
||||||
let tag_autoSearchFired = $state(false);
|
let tag_autoSearchFired = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
tag_activeTags;
|
const _tags = tag_activeTags;
|
||||||
tag_activeStatuses;
|
const _statuses = tag_activeStatuses;
|
||||||
|
const _loadingLocal = tag_loadingLocal;
|
||||||
|
const _hasFilters = tag_hasActiveFilters;
|
||||||
|
const _resultLen = tag_localResults.length;
|
||||||
|
const _cacheReady = sourceCacheReady;
|
||||||
untrack(() => { tag_autoSearchFired = false; });
|
untrack(() => { tag_autoSearchFired = false; });
|
||||||
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
|
if (!_loadingLocal && _hasFilters && !tag_autoSearchFired && !tag_searchSources && _cacheReady) {
|
||||||
if (tag_localResults.length < 20) {
|
if (_resultLen < 20) {
|
||||||
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
|
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,17 +27,14 @@
|
|||||||
|
|
||||||
const isLocal = pkgName === '__local__';
|
const isLocal = pkgName === '__local__';
|
||||||
|
|
||||||
// ── Library mode state ──────────────────────────────────────────────
|
|
||||||
let groups: SourceLibrary[] = $state([]);
|
let groups: SourceLibrary[] = $state([]);
|
||||||
let sourceNodes: SourceNode[] = $state([]);
|
let sourceNodes: SourceNode[] = $state([]);
|
||||||
|
|
||||||
// ── Local/browse mode state ──────────────────────────────────────────
|
|
||||||
let localItems: any[] = $state([]);
|
let localItems: any[] = $state([]);
|
||||||
let localPage: number = $state(1);
|
let localPage: number = $state(1);
|
||||||
let localHasNext: boolean = $state(false);
|
let localHasNext: boolean = $state(false);
|
||||||
let localLoadingMore: boolean = $state(false);
|
let localLoadingMore: boolean = $state(false);
|
||||||
|
|
||||||
// ── Shared state ─────────────────────────────────────────────────────
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let search = $state("");
|
let search = $state("");
|
||||||
let searchInput = $state("");
|
let searchInput = $state("");
|
||||||
@@ -49,8 +46,6 @@
|
|||||||
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
|
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
|
||||||
|
|
||||||
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
||||||
|
|
||||||
// ── Derived filtered lists ────────────────────────────────────────────
|
|
||||||
const allManga = $derived(isLocal ? localItems : groups.flatMap(g => g.manga));
|
const allManga = $derived(isLocal ? localItems : groups.flatMap(g => g.manga));
|
||||||
|
|
||||||
const filtered = $derived((() => {
|
const filtered = $derived((() => {
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
|
||||||
import { readerState } from "$lib/state/reader.svelte";
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
|
||||||
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
||||||
import type { PinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
import type { PinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
||||||
import type { StripChapter } from "$lib/components/reader/lib/scrollHandler";
|
import { READ_LINE_PCT } from "$lib/components/reader/lib/scrollHandler";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import LongstripViewer from "$lib/components/reader/viewer/LongstripViewer.svelte";
|
||||||
|
import SingleViewer from "$lib/components/reader/viewer/SingleViewer.svelte";
|
||||||
|
import DoubleViewer from "$lib/components/reader/viewer/DoubleViewer.svelte";
|
||||||
|
|
||||||
|
export interface StripChapter {
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
urls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlatPage = {
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
localIndex: number;
|
||||||
|
url: string;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
style: string;
|
style: string;
|
||||||
@@ -15,10 +31,10 @@
|
|||||||
pageReady: boolean;
|
pageReady: boolean;
|
||||||
pageGroups: number[][];
|
pageGroups: number[][];
|
||||||
currentGroup: number[];
|
currentGroup: number[];
|
||||||
stripToRender: StripChapter[];
|
|
||||||
fadingOut: boolean;
|
fadingOut: boolean;
|
||||||
tapToToggleBar: boolean;
|
tapToToggleBar: boolean;
|
||||||
pinchZoomEnabled: boolean;
|
pinchZoomEnabled: boolean;
|
||||||
|
useBlob: boolean;
|
||||||
barPosition: "top" | "left" | "right";
|
barPosition: "top" | "left" | "right";
|
||||||
onGetZoom: () => number;
|
onGetZoom: () => number;
|
||||||
onSetZoom: (z: number) => void;
|
onSetZoom: (z: number) => void;
|
||||||
@@ -27,44 +43,58 @@
|
|||||||
onWheel: (e: WheelEvent) => void;
|
onWheel: (e: WheelEvent) => void;
|
||||||
onToggleUi: () => void;
|
onToggleUi: () => void;
|
||||||
bindContainer: (el: HTMLDivElement) => void;
|
bindContainer: (el: HTMLDivElement) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onChapterChange: (chapterId: number) => void;
|
||||||
|
onCenterIdxChange:(flatIdx: number) => void;
|
||||||
|
onMarkRead: (chapterId: number) => void;
|
||||||
|
onAppend: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
pageGroups, currentGroup, fadingOut,
|
||||||
tapToToggleBar, pinchZoomEnabled, barPosition,
|
tapToToggleBar, pinchZoomEnabled, useBlob, barPosition,
|
||||||
onGetZoom, onSetZoom, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
onGetZoom, onSetZoom, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||||
|
onPageChange, onChapterChange, onCenterIdxChange, onMarkRead, onAppend,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const LOAD_RADIUS = 5;
|
let stripChunks = $state<StripChapter[]>([]);
|
||||||
const UNLOAD_RADIUS = 10;
|
|
||||||
|
|
||||||
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
|
export function loadStrip(chapterId: number, chapterName: string, urls: string[], resumeTo = 0) {
|
||||||
|
stripChunks = [{ chapterId, chapterName, urls }];
|
||||||
|
if (resumeTo > 1) {
|
||||||
|
setTimeout(() => scrollToFlatIndex(resumeTo - 1), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendStripChunk(chapterId: number, chapterName: string, urls: string[]) {
|
||||||
|
if (stripChunks.some(c => c.chapterId === chapterId)) return;
|
||||||
|
stripChunks = [...stripChunks, { chapterId, chapterName, urls }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStripChunks(): StripChapter[] {
|
||||||
|
return stripChunks;
|
||||||
|
}
|
||||||
|
|
||||||
const flatPages = $derived.by<FlatPage[]>(() => {
|
const flatPages = $derived.by<FlatPage[]>(() => {
|
||||||
const out: FlatPage[] = [];
|
const out: FlatPage[] = [];
|
||||||
for (const chunk of stripToRender) {
|
for (const chunk of stripChunks) {
|
||||||
for (let i = 0; i < chunk.urls.length; i++) {
|
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 });
|
out.push({
|
||||||
|
chapterId: chunk.chapterId,
|
||||||
|
chapterName: chunk.chapterName,
|
||||||
|
localIndex: i,
|
||||||
|
url: chunk.urls[i],
|
||||||
|
total: chunk.urls.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
let loadedSet = $state(new Set<number>());
|
let currentSrc = $state<string | null>(null);
|
||||||
let resolvedSrc = $state<Record<number, string>>({});
|
let currentGroupSrcs = $state<(string | null)[]>([]);
|
||||||
let revokeQueue: string[] = [];
|
|
||||||
|
|
||||||
// Aspect ratios (w/h) keyed by flat index, written by the img onload handler.
|
|
||||||
// Retained as a fallback for scrollToFlatIndex when a slot is not yet in DOM.
|
|
||||||
const aspectMap = new Map<number, number>();
|
|
||||||
|
|
||||||
let currentSrc = $state<string | null>(null);
|
|
||||||
let currentGroupSrcs = $state<(string | null)[]>([]);
|
|
||||||
|
|
||||||
let centerIdx = $state(0);
|
|
||||||
|
|
||||||
// ── Non-longstrip page src resolution ────────────────────────────────────
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (style === "longstrip" || !pageReady) return;
|
if (style === "longstrip" || !pageReady) return;
|
||||||
const pageNum = readerState.pageNumber;
|
const pageNum = readerState.pageNumber;
|
||||||
@@ -89,218 +119,75 @@
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Non-longstrip: scroll to top on every page change ────────────────────
|
|
||||||
// Ported from Suwayomi's useReaderScrollToStartOnPageChange.
|
|
||||||
// Prevents stale pan position carrying over when flipping pages.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void readerState.pageNumber;
|
void readerState.pageNumber;
|
||||||
if (style !== "longstrip" && containerEl) {
|
if (style !== "longstrip" && containerEl) containerEl.scrollTo(0, 0);
|
||||||
containerEl.scrollTo(0, 0);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Blob URL revocation ───────────────────────────────────────────────────
|
let lastTrackedPage = 0;
|
||||||
function scheduleRevoke(src: string) {
|
let lastTrackedChapter = 0;
|
||||||
if (!src || !src.startsWith("blob:")) return;
|
|
||||||
revokeQueue.push(src);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const url = revokeQueue.shift();
|
|
||||||
if (url) { try { URL.revokeObjectURL(url); } catch {} }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Load window management ────────────────────────────────────────────────
|
function handleScroll() {
|
||||||
function loadPage(idx: number) {
|
if (style !== "longstrip" || !containerEl || !flatPages.length) return;
|
||||||
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) {
|
const containerRect = containerEl.getBoundingClientRect();
|
||||||
if (!loadedSet.has(idx)) return;
|
const readY = containerRect.top + containerEl.clientHeight * READ_LINE_PCT;
|
||||||
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(center: number) {
|
|
||||||
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(() => { recalcWindow(centerIdx); });
|
|
||||||
$effect(() => { void flatPages.length; recalcWindow(centerIdx); });
|
|
||||||
|
|
||||||
// ── Scroll position preservation on image resize above viewport ───────────
|
|
||||||
// Ported from Suwayomi's usePreserveOnLeadingPageRender.
|
|
||||||
//
|
|
||||||
// Problem: when a placeholder above the current scroll position loads its
|
|
||||||
// real image and changes height, the browser shifts the scroll position
|
|
||||||
// relative to the viewport (layout shift). This corrects for that by:
|
|
||||||
// 1. Tracking the first visible image and its offsetTop at last scroll.
|
|
||||||
// 2. On every ResizeObserver entry for an image above scrollTop, computing
|
|
||||||
// the delta and applying it as a scroll correction.
|
|
||||||
//
|
|
||||||
// MutationObserver watches for images being added/removed so the
|
|
||||||
// ResizeObserver stays in sync with the actual DOM without needing
|
|
||||||
// querySelectorAll on every scroll tick.
|
|
||||||
$effect(() => {
|
|
||||||
if (style !== "longstrip" || !containerEl) return;
|
|
||||||
|
|
||||||
let visibleImg: HTMLElement | undefined;
|
|
||||||
let visibleImgTop = 0;
|
|
||||||
let lastScrollTop = 0;
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
lastScrollTop = containerEl.scrollTop;
|
|
||||||
if (visibleImg) {
|
|
||||||
visibleImgTop = visibleImg.offsetTop;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
|
|
||||||
const intersectionObs = new IntersectionObserver((entries) => {
|
|
||||||
const first = entries.find(e => e.isIntersecting);
|
|
||||||
if (first?.target instanceof HTMLElement) {
|
|
||||||
visibleImg = first.target;
|
|
||||||
visibleImgTop = first.target.offsetTop;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const resizeObs = new ResizeObserver((entries) => {
|
|
||||||
if (!visibleImg) return;
|
|
||||||
const hasEntryBeforeScroll = entries.some(e => {
|
|
||||||
if (!(e.target instanceof HTMLElement)) return false;
|
|
||||||
// Skip zero-size preload placeholders (they are outside the load window)
|
|
||||||
if (!e.target.clientWidth && !e.target.clientHeight) return false;
|
|
||||||
return e.target.offsetTop < lastScrollTop;
|
|
||||||
});
|
|
||||||
if (!hasEntryBeforeScroll) return;
|
|
||||||
const newTop = lastScrollTop - visibleImgTop + visibleImg.offsetTop;
|
|
||||||
containerEl.scrollTo({ top: newTop, behavior: "instant" } as ScrollToOptions);
|
|
||||||
});
|
|
||||||
|
|
||||||
const observe = (el: Element) => { intersectionObs.observe(el); resizeObs.observe(el); };
|
|
||||||
const unobserve = (el: Element) => { intersectionObs.unobserve(el); resizeObs.unobserve(el); };
|
|
||||||
|
|
||||||
const mutationObs = new MutationObserver((mutations) => {
|
|
||||||
for (const m of mutations) {
|
|
||||||
m.addedNodes.forEach(n => {
|
|
||||||
if (!(n instanceof HTMLElement)) return;
|
|
||||||
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
|
|
||||||
imgs.forEach(observe);
|
|
||||||
});
|
|
||||||
m.removedNodes.forEach(n => {
|
|
||||||
if (!(n instanceof HTMLElement)) return;
|
|
||||||
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
|
|
||||||
imgs.forEach(unobserve);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mutationObs.observe(containerEl, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
// Observe images already in the DOM at setup time
|
|
||||||
containerEl.querySelectorAll("img").forEach(observe);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
containerEl.removeEventListener("scroll", onScroll);
|
|
||||||
mutationObs.disconnect();
|
|
||||||
resizeObs.disconnect();
|
|
||||||
intersectionObs.disconnect();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Cursor hide on inactivity (longstrip) ─────────────────────────────────
|
|
||||||
// Ported from Suwayomi's useReaderHideCursorOnInactivity.
|
|
||||||
// Hides the cursor after 5 s of mouse inactivity, restores on movement.
|
|
||||||
$effect(() => {
|
|
||||||
if (style !== "longstrip" || !containerEl) return;
|
|
||||||
|
|
||||||
const HIDE_AFTER_MS = 5_000;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const show = () => {
|
|
||||||
containerEl.style.cursor = "";
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => { containerEl.style.cursor = "none"; }, HIDE_AFTER_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
show(); // start the timer immediately
|
|
||||||
window.addEventListener("mousemove", show, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
containerEl.style.cursor = "";
|
|
||||||
window.removeEventListener("mousemove", show);
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Scroll to target flat index ───────────────────────────────────────────
|
|
||||||
export function notifyScrollCenter(idx: number) {
|
|
||||||
centerIdx = idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function scrollToFlatIndex(idx: number) {
|
|
||||||
if (!containerEl || !flatPages.length) return;
|
|
||||||
centerIdx = idx;
|
|
||||||
recalcWindow(idx);
|
|
||||||
// Wait for Svelte to render any newly-in-window slots.
|
|
||||||
await tick();
|
|
||||||
if (!containerEl) return;
|
|
||||||
// Use scrollIntoView — the browser knows the exact element position
|
|
||||||
// regardless of image load state or aspect ratio. This is the same approach
|
|
||||||
// used by Suwayomi's useReaderHandlePageSelection (imageRef.scrollIntoView).
|
|
||||||
const slots = containerEl.querySelectorAll<HTMLElement>(".strip-slot");
|
const slots = containerEl.querySelectorAll<HTMLElement>(".strip-slot");
|
||||||
const slot = slots[idx];
|
let centerFlatIdx = 0;
|
||||||
if (slot) {
|
let bestDist = Infinity;
|
||||||
slot.scrollIntoView({ block: "start", behavior: "instant" });
|
|
||||||
} else {
|
slots.forEach((slot, idx) => {
|
||||||
// Slot not in DOM — proportional fallback (very unlikely after tick).
|
const rect = slot.getBoundingClientRect();
|
||||||
containerEl.scrollTop = (idx / flatPages.length) * containerEl.scrollHeight;
|
const mid = (rect.top + rect.bottom) / 2;
|
||||||
|
const dist = Math.abs(mid - readY);
|
||||||
|
if (dist < bestDist) { bestDist = dist; centerFlatIdx = idx; }
|
||||||
|
});
|
||||||
|
|
||||||
|
onCenterIdxChange(centerFlatIdx);
|
||||||
|
|
||||||
|
const page = flatPages[centerFlatIdx];
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
const localPage = page.localIndex + 1;
|
||||||
|
if (localPage !== lastTrackedPage || page.chapterId !== lastTrackedChapter) {
|
||||||
|
lastTrackedPage = localPage;
|
||||||
|
lastTrackedChapter = page.chapterId;
|
||||||
|
onPageChange(localPage);
|
||||||
|
onChapterChange(page.chapterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const chunk of stripChunks) {
|
||||||
|
const lastLocalIdx = chunk.urls.length - 1;
|
||||||
|
let flatLastIdx = -1;
|
||||||
|
for (let i = 0; i < flatPages.length; i++) {
|
||||||
|
if (flatPages[i].chapterId === chunk.chapterId && flatPages[i].localIndex === lastLocalIdx) {
|
||||||
|
flatLastIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flatLastIdx < 0) continue;
|
||||||
|
const lastSlot = slots[flatLastIdx];
|
||||||
|
if (!lastSlot) continue;
|
||||||
|
const lastRect = lastSlot.getBoundingClientRect();
|
||||||
|
if (lastRect.bottom < readY) onMarkRead(chunk.chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollBottom = containerEl.scrollTop + containerEl.clientHeight;
|
||||||
|
const scrollTotal = containerEl.scrollHeight;
|
||||||
|
if (scrollTotal - scrollBottom < containerEl.clientHeight * 1.5) onAppend();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reset on chapter change ───────────────────────────────────────────────
|
|
||||||
let lastChapterId = 0;
|
|
||||||
$effect(() => {
|
|
||||||
const chapterId = readerState.activeChapter?.id ?? 0;
|
|
||||||
if (chapterId === lastChapterId) return;
|
|
||||||
lastChapterId = chapterId;
|
|
||||||
loadedSet = new Set<number>();
|
|
||||||
resolvedSrc = {};
|
|
||||||
centerIdx = 0;
|
|
||||||
aspectMap.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Inspect / zoom helpers ────────────────────────────────────────────────
|
|
||||||
const INSPECT_ZOOM_STEP = 0.15;
|
const INSPECT_ZOOM_STEP = 0.15;
|
||||||
const INSPECT_ZOOM_MAX = 8;
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
let containerEl = $state<HTMLDivElement | undefined>();
|
||||||
|
let stripRef: LongstripViewer | undefined = $state();
|
||||||
|
|
||||||
|
export function captureAnchor() { stripRef?.captureAnchor(); }
|
||||||
|
export function restoreAnchor() { stripRef?.restoreAnchor(); }
|
||||||
|
export function notifyScrollCenter(idx: number) { stripRef?.notifyScrollCenter(idx); }
|
||||||
|
export async function scrollToFlatIndex(idx: number) { await stripRef?.scrollToFlatIndex(idx); }
|
||||||
|
|
||||||
function getInspectImageEl(): HTMLElement | null {
|
function getInspectImageEl(): HTMLElement | null {
|
||||||
if (!containerEl) return null;
|
if (!containerEl) return null;
|
||||||
@@ -325,66 +212,6 @@
|
|||||||
let inspectPanStartX = 0;
|
let inspectPanStartX = 0;
|
||||||
let inspectPanStartY = 0;
|
let inspectPanStartY = 0;
|
||||||
|
|
||||||
let stripDragging = $state(false);
|
|
||||||
let stripDragMoved = false;
|
|
||||||
let stripDragStartY = 0;
|
|
||||||
let stripScrollStart = 0;
|
|
||||||
|
|
||||||
let autoScrollPaused = false;
|
|
||||||
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
let midScrollActive = $state(false);
|
|
||||||
let midScrollOriginY = $state(0);
|
|
||||||
let midScrollCurrentY = 0;
|
|
||||||
let midScrollDisplayLevel = $state(0);
|
|
||||||
let midScrollRaf: number | null = null;
|
|
||||||
|
|
||||||
function startMidScroll(originY: number) {
|
|
||||||
midScrollActive = true;
|
|
||||||
midScrollOriginY = originY;
|
|
||||||
midScrollDisplayLevel = 0;
|
|
||||||
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
|
|
||||||
const tick = () => {
|
|
||||||
if (!midScrollActive || !containerEl) return;
|
|
||||||
const dy = midScrollCurrentY - midScrollOriginY;
|
|
||||||
const deadZone = 24;
|
|
||||||
const excess = Math.max(0, Math.abs(dy) - deadZone);
|
|
||||||
const speed = Math.sign(dy) * excess * 0.12;
|
|
||||||
containerEl.scrollTop += speed;
|
|
||||||
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
|
|
||||||
midScrollRaf = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
midScrollRaf = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopMidScroll() {
|
|
||||||
midScrollActive = false;
|
|
||||||
midScrollDisplayLevel = 0;
|
|
||||||
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseAutoScroll() {
|
|
||||||
autoScrollPaused = true;
|
|
||||||
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
|
|
||||||
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (style !== "longstrip" || !settingsState.settings.autoScroll) return;
|
|
||||||
let rafId: number;
|
|
||||||
const tick = () => {
|
|
||||||
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (settingsState.settings.autoScrollSpeed ?? 5) * 0.5;
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
return () => cancelAnimationFrame(rafId);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (style !== "longstrip") stopMidScroll();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Pinch zoom ────────────────────────────────────────────────────────────
|
|
||||||
let pinch: PinchTracker | null = null;
|
let pinch: PinchTracker | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -402,28 +229,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Pointer / mouse / wheel event handlers ────────────────────────────────
|
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
||||||
|
|
||||||
export function onInspectMouseDown(e: MouseEvent) {
|
export function onInspectMouseDown(e: MouseEvent) {
|
||||||
if ((e.target as Element).closest(".bar")) return;
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
if (e.button === 1 && style === "longstrip") {
|
if (style === "longstrip") { stripRef?.onMouseDown(e); return; }
|
||||||
e.preventDefault();
|
|
||||||
if (midScrollActive) {
|
|
||||||
stopMidScroll();
|
|
||||||
} else {
|
|
||||||
settingsState.settings.autoScroll = false;
|
|
||||||
startMidScroll(e.clientY);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (style === "longstrip") {
|
|
||||||
stripDragging = true;
|
|
||||||
stripDragMoved = false;
|
|
||||||
stripDragStartY = e.clientY;
|
|
||||||
stripScrollStart = containerEl?.scrollTop ?? 0;
|
|
||||||
pauseAutoScroll();
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (readerState.inspectScale <= 1) return;
|
if (readerState.inspectScale <= 1) return;
|
||||||
inspectDragging = true;
|
inspectDragging = true;
|
||||||
inspectDragMoved = false;
|
inspectDragMoved = false;
|
||||||
@@ -435,13 +245,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onInspectMouseMove(e: MouseEvent) {
|
export function onInspectMouseMove(e: MouseEvent) {
|
||||||
midScrollCurrentY = e.clientY;
|
if (style === "longstrip") { stripRef?.onMouseMove(e); return; }
|
||||||
if (stripDragging) {
|
|
||||||
const dy = e.clientY - stripDragStartY;
|
|
||||||
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
|
||||||
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!inspectDragging) return;
|
if (!inspectDragging) return;
|
||||||
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||||
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||||
@@ -452,22 +256,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onInspectMouseUp() {
|
export function onInspectMouseUp() {
|
||||||
stripDragging = false;
|
if (style === "longstrip") { stripRef?.onMouseUp(); return; }
|
||||||
inspectDragging = false;
|
inspectDragging = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onPointerDown(e: PointerEvent) {
|
export function onPointerDown(e: PointerEvent) {
|
||||||
if ((e.target as Element).closest(".bar")) return;
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
pinch?.onPointerDown(e);
|
pinch?.onPointerDown(e);
|
||||||
|
if (style === "longstrip") stripRef?.onPointerDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onPointerMove(e: PointerEvent) {
|
export function onPointerMove(e: PointerEvent) {
|
||||||
if (pinch?.isPinching()) { pinch.onPointerMove(e); return; }
|
if (pinch?.isPinching()) { pinch.onPointerMove(e); return; }
|
||||||
if (stripDragging) {
|
if (style === "longstrip") { stripRef?.onPointerMove(e); return; }
|
||||||
const dy = e.clientY - stripDragStartY;
|
|
||||||
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
|
||||||
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
|
||||||
}
|
|
||||||
if (inspectDragging) {
|
if (inspectDragging) {
|
||||||
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||||
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||||
@@ -480,13 +281,16 @@
|
|||||||
|
|
||||||
export function onPointerUp(e: PointerEvent) {
|
export function onPointerUp(e: PointerEvent) {
|
||||||
pinch?.onPointerUp(e);
|
pinch?.onPointerUp(e);
|
||||||
if (!pinch?.isPinching()) { stripDragging = false; inspectDragging = false; }
|
if (!pinch?.isPinching()) {
|
||||||
|
if (style === "longstrip") stripRef?.onPointerUp();
|
||||||
|
else inspectDragging = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleWheel(e: WheelEvent) {
|
export function handleWheel(e: WheelEvent) {
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (e.ctrlKey) { onWheel(e); }
|
if (e.ctrlKey) onWheel(e);
|
||||||
else pauseAutoScroll();
|
else stripRef?.onWheel(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!e.ctrlKey) { onWheel(e); return; }
|
if (!e.ctrlKey) { onWheel(e); return; }
|
||||||
@@ -496,14 +300,12 @@
|
|||||||
if (next === readerState.inspectScale) return;
|
if (next === readerState.inspectScale) return;
|
||||||
if (next === 1) { readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0; return; }
|
if (next === 1) { readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0; return; }
|
||||||
const img = getInspectImageEl();
|
const img = getInspectImageEl();
|
||||||
const anchor = img ?? containerEl;
|
const anchor = img ?? containerEl ?? null;
|
||||||
const rect = anchor?.getBoundingClientRect();
|
const rect = anchor?.getBoundingClientRect();
|
||||||
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
|
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
|
||||||
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
|
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
|
||||||
const ratio = next / readerState.inspectScale;
|
const ratio = next / readerState.inspectScale;
|
||||||
const rawPanX = cx + (readerState.inspectPanX - cx) * ratio;
|
const [clampedX, clampedY] = clampInspectPan(next, cx + (readerState.inspectPanX - cx) * ratio, cy + (readerState.inspectPanY - cy) * ratio);
|
||||||
const rawPanY = cy + (readerState.inspectPanY - cy) * ratio;
|
|
||||||
const [clampedX, clampedY] = clampInspectPan(next, rawPanX, rawPanY);
|
|
||||||
readerState.inspectScale = next;
|
readerState.inspectScale = next;
|
||||||
readerState.inspectPanX = clampedX;
|
readerState.inspectPanX = clampedX;
|
||||||
readerState.inspectPanY = clampedY;
|
readerState.inspectPanY = clampedY;
|
||||||
@@ -513,11 +315,10 @@
|
|||||||
|
|
||||||
function handleTap(e: MouseEvent) {
|
function handleTap(e: MouseEvent) {
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
if (stripRef?.consumeTap()) return;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
|
||||||
if (tapToToggleBar) {
|
if (tapToToggleBar) {
|
||||||
if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; return; }
|
if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; return; }
|
||||||
tapTimer = setTimeout(() => { tapTimer = null; onTap(e); }, 220);
|
tapTimer = setTimeout(() => { tapTimer = null; onTap(e); }, 220);
|
||||||
@@ -550,10 +351,10 @@
|
|||||||
onclick={handleTap}
|
onclick={handleTap}
|
||||||
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
|
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
|
||||||
ondblclick={handleDblClick}
|
ondblclick={handleDblClick}
|
||||||
|
onscroll={style === "longstrip" ? handleScroll : undefined}
|
||||||
onmousedown={onInspectMouseDown}
|
onmousedown={onInspectMouseDown}
|
||||||
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
||||||
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
||||||
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
|
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === " " && style === "longstrip") {
|
if (e.key === " " && style === "longstrip") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -563,28 +364,9 @@
|
|||||||
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
|
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if midScrollActive}
|
|
||||||
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
|
|
||||||
<div class="midscroll-segments">
|
|
||||||
{#each [5,4,3,2,1] as n}
|
|
||||||
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
|
|
||||||
{/each}
|
|
||||||
<div class="midscroll-origin-dot"></div>
|
|
||||||
{#each [1,2,3,4,5] as n}
|
|
||||||
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
|
|
||||||
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="center-overlay">
|
<div class="center-overlay">
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
{@render skeleton()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -593,76 +375,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if style === "longstrip"}
|
{#if style === "longstrip"}
|
||||||
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
<LongstripViewer
|
||||||
{@const src = resolvedSrc[gi]}
|
bind:this={stripRef}
|
||||||
{@const isLoaded = loadedSet.has(gi)}
|
{containerEl}
|
||||||
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId}>
|
{flatPages}
|
||||||
{#if isLoaded && src}
|
{imgCls}
|
||||||
<img
|
{effectiveWidth}
|
||||||
{src}
|
{resolveUrl}
|
||||||
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
{barPosition}
|
||||||
data-local-page={page.localIndex + 1}
|
/>
|
||||||
data-chapter={page.chapterId}
|
|
||||||
data-total={page.total}
|
|
||||||
class="{imgCls}{settingsState.settings.pageGap ? ' strip-gap' : ''}"
|
|
||||||
loading="eager"
|
|
||||||
decoding="async"
|
|
||||||
draggable="false"
|
|
||||||
onload={(e) => {
|
|
||||||
const img = e.currentTarget as HTMLImageElement;
|
|
||||||
const slot = img.closest<HTMLElement>(".strip-slot");
|
|
||||||
if (slot && img.naturalWidth > 0) {
|
|
||||||
const aspect = img.naturalWidth / img.naturalHeight;
|
|
||||||
slot.style.setProperty("--aspect", String(aspect));
|
|
||||||
aspectMap.set(gi, aspect);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="strip-placeholder" aria-hidden="true">
|
|
||||||
{@render skeleton()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/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)">
|
|
||||||
{#if currentSrc}
|
|
||||||
<img src={currentSrc} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
|
|
||||||
{:else}
|
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if style === "double" && pageReady}
|
{: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)">
|
<DoubleViewer {imgCls} {currentGroup} srcs={currentGroupSrcs} {pageGroups} />
|
||||||
{#if pageGroups.length}
|
|
||||||
<div class="double-wrap">
|
|
||||||
{#each currentGroup as pg, i (pg)}
|
|
||||||
{#if currentGroupSrcs[i]}
|
|
||||||
<img src={currentGroupSrcs[i]} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
|
||||||
{:else}
|
|
||||||
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">{@render skeleton()}</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="center-overlay">
|
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if pageReady}
|
{:else if pageReady}
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<SingleViewer {imgCls} src={currentSrc} {fadingOut} isFade={style === "fade"} />
|
||||||
{#if currentSrc}
|
|
||||||
<img src={currentSrc} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
|
||||||
{:else}
|
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -685,19 +412,6 @@
|
|||||||
|
|
||||||
:global(.pinch-active) .viewer { touch-action: none; }
|
:global(.pinch-active) .viewer { touch-action: none; }
|
||||||
|
|
||||||
.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);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
||||||
.page-loader-single {
|
.page-loader-single {
|
||||||
width: min(100%, var(--effective-width, 100%));
|
width: min(100%, var(--effective-width, 100%));
|
||||||
@@ -728,47 +442,14 @@
|
|||||||
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
:global(.img) { display: block; user-select: none; image-rendering: auto; }
|
||||||
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
:global(.img.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; }
|
||||||
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||||||
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
|
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
|
||||||
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
||||||
:global(.strip-gap) { margin-bottom: 8px; }
|
:global(.strip-gap) { margin-bottom: 8px; }
|
||||||
|
|
||||||
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
|
|
||||||
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
|
||||||
.gap-left { margin-right: 2px; }
|
|
||||||
.gap-right { margin-left: 2px; }
|
|
||||||
|
|
||||||
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||||
|
|
||||||
.midscroll-bar {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 6px;
|
|
||||||
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
|
|
||||||
pointer-events: auto;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
.midscroll-bar-right { right: 8px; }
|
|
||||||
.midscroll-bar-left { left: 8px; }
|
|
||||||
|
|
||||||
.midscroll-segments { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
|
||||||
.midscroll-origin-dot { width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--accent-fg); opacity: 0.6; flex-shrink: 0; margin: 2px 0; }
|
|
||||||
.midscroll-seg { width: 4px; height: 14px; border-radius: 2px; background: var(--border-strong); transition: background 0.06s ease; flex-shrink: 0; }
|
|
||||||
.midscroll-seg-lit { background: var(--accent-fg); }
|
|
||||||
.midscroll-stop { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); flex-shrink: 0; }
|
|
||||||
.midscroll-stop:hover { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
|
||||||
</style>
|
</style>
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
import { app, appState } from "$lib/state/app.svelte";
|
import { app, appState } from "$lib/state/app.svelte";
|
||||||
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
|
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
|
||||||
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
|
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
|
||||||
import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler";
|
|
||||||
import { createReaderKeyHandler } from "$lib/components/reader/lib/readerKeybinds";
|
import { createReaderKeyHandler } from "$lib/components/reader/lib/readerKeybinds";
|
||||||
import { markChapterRead, getMangaPrefs, toggleBookmark } from "$lib/components/reader/lib/chapterActions";
|
import { markChapterRead, getMangaPrefs, toggleBookmark } from "$lib/components/reader/lib/chapterActions";
|
||||||
import { goForward, goBack, jumpToPage } from "$lib/components/reader/lib/navigation";
|
import { goForward, goBack, jumpToPage } from "$lib/components/reader/lib/navigation";
|
||||||
@@ -46,9 +45,11 @@
|
|||||||
const pinchZoomEnabled = $derived(settingsState.settings.pinchZoom ?? false);
|
const pinchZoomEnabled = $derived(settingsState.settings.pinchZoom ?? false);
|
||||||
const containerized = $derived(settingsState.settings.readerContainerized ?? false);
|
const containerized = $derived(settingsState.settings.readerContainerized ?? false);
|
||||||
|
|
||||||
|
let visibleChapterId = $state<number | null>(null);
|
||||||
|
|
||||||
const displayChapter = $derived(
|
const displayChapter = $derived(
|
||||||
style === "longstrip" && readerState.visibleChapterId
|
style === "longstrip" && visibleChapterId
|
||||||
? (readerState.activeChapterList.find(c => c.id === readerState.visibleChapterId) ?? readerState.activeChapter)
|
? (readerState.activeChapterList.find(c => c.id === visibleChapterId) ?? readerState.activeChapter)
|
||||||
: readerState.activeChapter
|
: readerState.activeChapter
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
|
|
||||||
const showResumeBanner = $derived(
|
const showResumeBanner = $derived(
|
||||||
readerState.resumeVisible && readerState.resumePage > 1 &&
|
readerState.resumeVisible && readerState.resumePage > 1 &&
|
||||||
(style === "longstrip" ? readerState.stripResumeReady : readerState.pageNumber === readerState.resumePage)
|
readerState.pageNumber === readerState.resumePage
|
||||||
);
|
);
|
||||||
|
|
||||||
const adjacent = $derived.by(() => {
|
const adjacent = $derived.by(() => {
|
||||||
@@ -87,9 +88,9 @@
|
|||||||
|
|
||||||
const visibleChunkLastPage = $derived.by(() => {
|
const visibleChunkLastPage = $derived.by(() => {
|
||||||
if (style !== "longstrip") return lastPage;
|
if (style !== "longstrip") return lastPage;
|
||||||
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
const chunks = pageViewRef?.getStripChunks() ?? [];
|
||||||
const chunk = readerState.stripChapters.find(c => c.chapterId === chId);
|
const chId = visibleChapterId ?? readerState.activeChapter?.id;
|
||||||
return chunk?.urls.length ?? lastPage;
|
return chunks.find(c => c.chapterId === chId)?.urls.length ?? lastPage;
|
||||||
});
|
});
|
||||||
|
|
||||||
const imgCls = $derived([
|
const imgCls = $derived([
|
||||||
@@ -101,14 +102,6 @@
|
|||||||
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
||||||
].filter(Boolean).join(" "));
|
].filter(Boolean).join(" "));
|
||||||
|
|
||||||
const stripToRender = $derived(
|
|
||||||
style === "longstrip"
|
|
||||||
? (readerState.stripChapters.length > 0
|
|
||||||
? readerState.stripChapters
|
|
||||||
: [{ chapterId: readerState.activeChapter?.id ?? 0, chapterName: readerState.activeChapter?.name ?? "", urls: readerState.pageUrls }])
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentGroup = $derived.by(() => {
|
const currentGroup = $derived.by(() => {
|
||||||
const group = style === "double" && readerState.pageGroups.length
|
const group = style === "double" && readerState.pageGroups.length
|
||||||
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
|
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
|
||||||
@@ -145,13 +138,9 @@
|
|||||||
let abortCtrl = { current: null as AbortController | null };
|
let abortCtrl = { current: null as AbortController | null };
|
||||||
let hasNavigated = false;
|
let hasNavigated = false;
|
||||||
let startAtLastPageRef = { current: false };
|
let startAtLastPageRef = { current: false };
|
||||||
let cleanupScroll: () => void = () => {};
|
|
||||||
let stripChaptersRef = readerState.stripChapters;
|
|
||||||
let tickTimer: ReturnType<typeof setTimeout> | null = null;
|
let tickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
$effect(() => { stripChaptersRef = readerState.stripChapters; });
|
|
||||||
|
|
||||||
function maybeMarkCurrentRead() {
|
function maybeMarkCurrentRead() {
|
||||||
const ch = displayChapter ?? readerState.activeChapter;
|
const ch = displayChapter ?? readerState.activeChapter;
|
||||||
if (ch && markOnNext) markChapterRead(ch.id, markedRead);
|
if (ch && markOnNext) markChapterRead(ch.id, markedRead);
|
||||||
@@ -212,17 +201,6 @@
|
|||||||
|
|
||||||
const startAtLast = () => { startAtLastPageRef.current = true; };
|
const startAtLast = () => { startAtLastPageRef.current = true; };
|
||||||
|
|
||||||
function flatIndexForPage(page: number): number {
|
|
||||||
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
|
||||||
const chunks = readerState.stripChapters;
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
if (chunk.chapterId === chId) return offset + Math.max(0, page - 1);
|
|
||||||
offset += chunk.urls.length;
|
|
||||||
}
|
|
||||||
return Math.max(0, page - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function primedJump(page: number, commit = true) {
|
function primedJump(page: number, commit = true) {
|
||||||
if (useBlob && commit && style !== "longstrip") {
|
if (useBlob && commit && style !== "longstrip") {
|
||||||
cancelQueuedFetches();
|
cancelQueuedFetches();
|
||||||
@@ -236,9 +214,8 @@
|
|||||||
style,
|
style,
|
||||||
lastPage,
|
lastPage,
|
||||||
style === "longstrip" ? (idx) => pageViewRef.scrollToFlatIndex(idx) : null,
|
style === "longstrip" ? (idx) => pageViewRef.scrollToFlatIndex(idx) : null,
|
||||||
stripToRender.reduce((s, c) => s + c.urls.length, 0),
|
visibleChapterId ?? readerState.activeChapter?.id ?? 0,
|
||||||
readerState.visibleChapterId ?? readerState.activeChapter?.id ?? 0,
|
pageViewRef?.getStripChunks() ?? [],
|
||||||
readerState.stripChapters,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,9 +229,6 @@
|
|||||||
function handleCloseReader() {
|
function handleCloseReader() {
|
||||||
clearReading().catch(() => {});
|
clearReading().catch(() => {});
|
||||||
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
||||||
for (const strip of readerState.stripChapters) {
|
|
||||||
for (const url of strip.urls) revokeBlobUrl(url);
|
|
||||||
}
|
|
||||||
readerState.closeReader();
|
readerState.closeReader();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,10 +316,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ch = readerState.activeChapter;
|
const ch = readerState.activeChapter;
|
||||||
const manga = readerState.activeManga;
|
if (ch) {
|
||||||
if (ch && manga) {
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
|
const manga = readerState.activeManga;
|
||||||
|
if (!manga) return;
|
||||||
historyState.openSession(
|
historyState.openSession(
|
||||||
manga.id, manga.title, manga.thumbnailUrl,
|
manga.id, manga.title, manga.thumbnailUrl,
|
||||||
ch.id, ch.name, readerState.pageNumber,
|
ch.id, ch.name, readerState.pageNumber,
|
||||||
@@ -367,7 +342,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const page = readerState.pageNumber;
|
const page = readerState.pageNumber;
|
||||||
const chId = style === "longstrip"
|
const chId = style === "longstrip"
|
||||||
? (readerState.visibleChapterId ?? readerState.activeChapter?.id)
|
? (visibleChapterId ?? readerState.activeChapter?.id)
|
||||||
: readerState.activeChapter?.id;
|
: readerState.activeChapter?.id;
|
||||||
const chName = style === "longstrip"
|
const chName = style === "longstrip"
|
||||||
? (readerState.activeChapterList.find(c => c.id === chId)?.name ?? readerState.activeChapter?.name ?? "")
|
? (readerState.activeChapterList.find(c => c.id === chId)?.name ?? readerState.activeChapter?.name ?? "")
|
||||||
@@ -391,99 +366,33 @@
|
|||||||
const ch = readerState.activeChapter;
|
const ch = readerState.activeChapter;
|
||||||
const urls = readerState.pageUrls;
|
const urls = readerState.pageUrls;
|
||||||
const resumeTo = untrack(() => readerState.resumePage);
|
const resumeTo = untrack(() => readerState.resumePage);
|
||||||
appending = false;
|
visibleChapterId = ch.id;
|
||||||
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
appending = false;
|
||||||
readerState.visibleChapterId = ch.id;
|
pageViewRef.loadStrip(ch.id, ch.name, urls, resumeTo);
|
||||||
tick().then(() => {
|
|
||||||
if (!containerEl) return;
|
|
||||||
if (resumeTo > 1) {
|
|
||||||
pageViewRef.scrollToFlatIndex(resumeTo - 1);
|
|
||||||
readerState.stripResumeReady = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
containerEl.scrollTop = 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const chId = readerState.visibleChapterId;
|
const chId = visibleChapterId;
|
||||||
if (!chId || style !== "longstrip") return;
|
if (!chId || style !== "longstrip") return;
|
||||||
if (chId === readerState.activeChapter?.id) return;
|
if (chId === readerState.activeChapter?.id) return;
|
||||||
const wasAppended = untrack(() => readerState.stripChapters.findIndex(c => c.chapterId === chId)) > 0;
|
|
||||||
if (wasAppended) {
|
|
||||||
untrack(() => {
|
|
||||||
readerState.resumePage = 0;
|
|
||||||
readerState.resumeVisible = false;
|
|
||||||
const prefs = getMangaPrefs(chId);
|
|
||||||
if (prefs.downloadAhead > 0) {
|
|
||||||
const list = readerState.activeChapterList;
|
|
||||||
const idx = list.findIndex(c => c.id === chId);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
|
||||||
.filter(c => !c.downloaded && !c.read)
|
|
||||||
.map(c => c.id);
|
|
||||||
if (toQueue.length) getAdapter().enqueueDownloads(toQueue.map(String)).catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bookmark = readerState.bookmarks.find(b => b.chapterId === chId);
|
|
||||||
if (bookmark && bookmark.pageNumber > 1) {
|
|
||||||
untrack(() => {
|
|
||||||
readerState.resumePage = bookmark.pageNumber;
|
|
||||||
readerState.resumeDismissed = false;
|
|
||||||
readerState.resumeVisible = true;
|
|
||||||
readerState.stripResumeReady = true;
|
|
||||||
scheduleResumeDismiss();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
untrack(() => readerState.resetResume());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void style;
|
|
||||||
if (!containerEl) return;
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
cleanupScroll();
|
readerState.resumePage = 0;
|
||||||
cleanupScroll = setupScrollTracking(containerEl!, {
|
readerState.resumeVisible = false;
|
||||||
onPageChange: (p) => { readerState.pageNumber = p; },
|
const prefs = getMangaPrefs(chId);
|
||||||
onChapterChange: (id) => { readerState.visibleChapterId = id; },
|
if (prefs.downloadAhead > 0) {
|
||||||
onCenterIdxChange: (idx) => { pageViewRef?.notifyScrollCenter(idx); },
|
const list = readerState.activeChapterList;
|
||||||
onMarkRead: (id) => markChapterRead(id, markedRead),
|
const idx = list.findIndex(c => c.id === chId);
|
||||||
onAppend: () => {
|
if (idx >= 0) {
|
||||||
if (appending || !readerState.stripChapters.length) return;
|
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||||||
appending = true;
|
.filter(c => !c.downloaded && !c.read)
|
||||||
appendNextChapter(
|
.map(c => c.id);
|
||||||
stripChaptersRef,
|
if (toQueue.length) getAdapter().enqueueDownloads(toQueue.map(String)).catch(console.error);
|
||||||
readerState.activeChapterList,
|
}
|
||||||
(id) => fetchPages(id, useBlob),
|
|
||||||
(url) => preloadImage(url, useBlob),
|
|
||||||
(next) => { readerState.stripChapters = [...readerState.stripChapters, next]; appending = false; },
|
|
||||||
() => { appending = false; },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getStripChapters: () => stripChaptersRef,
|
|
||||||
getPageUrls: () => readerState.pageUrls,
|
|
||||||
shouldAutoMark: () => settingsState.settings.autoMarkRead ?? true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (readerState.activeChapter && readerState.activeChapterList.length) {
|
|
||||||
const idx = readerState.activeChapterList.findIndex(c => c.id === readerState.activeChapter!.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const next = readerState.activeChapterList[idx + 1];
|
|
||||||
const prev = readerState.activeChapterList[idx - 1];
|
|
||||||
if (next) fetchPages(next.id, useBlob).then(urls => urls.slice(0, 8).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
|
||||||
if (prev) fetchPages(prev.id, useBlob).then(urls => urls.slice(0, 2).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -552,7 +461,7 @@
|
|||||||
if (pageNum > 1) hasNavigated = true;
|
if (pageNum > 1) hasNavigated = true;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (!hasNavigated) return;
|
if (!hasNavigated) return;
|
||||||
if (style === "longstrip" && readerState.visibleChapterId && chapterId !== readerState.visibleChapterId) return;
|
if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return;
|
||||||
if (settingsState.settings.autoBookmark ?? true) {
|
if (settingsState.settings.autoBookmark ?? true) {
|
||||||
const existing = readerState.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
|
const existing = readerState.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
|
||||||
if (existing) readerState.removeBookmark(existing.chapterId);
|
if (existing) readerState.removeBookmark(existing.chapterId);
|
||||||
@@ -606,7 +515,6 @@
|
|||||||
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||||
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
|
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
|
||||||
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
|
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
|
||||||
cleanupScroll();
|
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -687,10 +595,11 @@
|
|||||||
error={readerState.error}
|
error={readerState.error}
|
||||||
pageReady={readerState.pageReady}
|
pageReady={readerState.pageReady}
|
||||||
pageGroups={readerState.pageGroups}
|
pageGroups={readerState.pageGroups}
|
||||||
{currentGroup} {stripToRender}
|
{currentGroup}
|
||||||
fadingOut={readerState.fadingOut}
|
fadingOut={readerState.fadingOut}
|
||||||
{tapToToggleBar}
|
{tapToToggleBar}
|
||||||
{pinchZoomEnabled}
|
{pinchZoomEnabled}
|
||||||
|
{useBlob}
|
||||||
{barPosition}
|
{barPosition}
|
||||||
onGetZoom={() => zoom}
|
onGetZoom={() => zoom}
|
||||||
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
||||||
@@ -699,6 +608,28 @@
|
|||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onToggleUi={toggleUiVisibility}
|
onToggleUi={toggleUiVisibility}
|
||||||
{bindContainer}
|
{bindContainer}
|
||||||
|
onPageChange={(p) => { readerState.pageNumber = p; }}
|
||||||
|
onChapterChange={(id) => { visibleChapterId = id; }}
|
||||||
|
onCenterIdxChange={(idx) => { pageViewRef?.notifyScrollCenter(idx); }}
|
||||||
|
onMarkRead={(id) => markChapterRead(id, markedRead)}
|
||||||
|
onAppend={() => {
|
||||||
|
if (appending) return;
|
||||||
|
const chunks = pageViewRef?.getStripChunks() ?? [];
|
||||||
|
if (!chunks.length) return;
|
||||||
|
const lastChunk = chunks[chunks.length - 1];
|
||||||
|
const list = readerState.activeChapterList;
|
||||||
|
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
|
||||||
|
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
||||||
|
const next = list[lastIdx + 1];
|
||||||
|
if (!next || chunks.some(c => c.chapterId === next.id)) return;
|
||||||
|
appending = true;
|
||||||
|
fetchPages(next.id, useBlob)
|
||||||
|
.then(urls => {
|
||||||
|
urls.slice(0, 6).forEach(url => preloadImage(url, useBlob));
|
||||||
|
return pageViewRef.appendStripChunk(next.id, next.name, urls);
|
||||||
|
})
|
||||||
|
.finally(() => { appending = false; });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#snippet progressBarSnippet()}
|
{#snippet progressBarSnippet()}
|
||||||
@@ -742,4 +673,4 @@
|
|||||||
.root.bar-right :global(.viewer) { margin-right: 40px; }
|
.root.bar-right :global(.viewer) { margin-right: 40px; }
|
||||||
|
|
||||||
.root.pinch-active :global(.viewer) { touch-action: none; }
|
.root.pinch-active :global(.viewer) { touch-action: none; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
<div class="bar-divider"></div>
|
<div class="bar-divider"></div>
|
||||||
|
|
||||||
<button class="icon-btn"
|
<button class="icon-btn"
|
||||||
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev, readerState.activeChapterList); } }}
|
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev); } }}
|
||||||
disabled={!adjacent.prev}
|
disabled={!adjacent.prev}
|
||||||
title="Previous chapter">
|
title="Previous chapter">
|
||||||
{#if isVertical}<CaretUp size={13} weight="regular" />{:else}<CaretLeft size={13} weight="regular" />{/if}
|
{#if isVertical}<CaretUp size={13} weight="regular" />{:else}<CaretLeft size={13} weight="regular" />{/if}
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="icon-btn"
|
<button class="icon-btn"
|
||||||
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next, readerState.activeChapterList); } }}
|
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next); } }}
|
||||||
disabled={!adjacent.next}
|
disabled={!adjacent.next}
|
||||||
title="Next chapter">
|
title="Next chapter">
|
||||||
{#if isVertical}<CaretDown size={13} weight="regular" />{:else}<CaretRight size={13} weight="regular" />{/if}
|
{#if isVertical}<CaretDown size={13} weight="regular" />{:else}<CaretRight size={13} weight="regular" />{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readerState, DEFAULT_MANGA_PREFS } from "$lib/state/reader.svelte";
|
import { readerState, DEFAULT_MANGA_PREFS } from "$lib/state/reader.svelte";
|
||||||
|
import { seriesState } from "$lib/state/series.svelte";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import type { MangaPrefs } from "$lib/types/settings";
|
import type { MangaPrefs } from "$lib/types/settings";
|
||||||
@@ -35,8 +36,8 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
|
|||||||
const mangaId = readerState.activeManga?.id;
|
const mangaId = readerState.activeManga?.id;
|
||||||
if (!mangaId) return;
|
if (!mangaId) return;
|
||||||
|
|
||||||
readerState.activeChapterList = readerState.activeChapterList.map(c =>
|
seriesState.patchChapters(mangaId, chapters =>
|
||||||
c.id === id ? { ...c, read: true } : c
|
chapters.map(c => c.id === id ? { ...c, read: true } : c),
|
||||||
);
|
);
|
||||||
|
|
||||||
const prefs = getMangaPrefs(mangaId);
|
const prefs = getMangaPrefs(mangaId);
|
||||||
@@ -79,15 +80,15 @@ export function toggleBookmark(chapter: typeof readerState.activeChapter, pageNu
|
|||||||
const manga = readerState.activeManga;
|
const manga = readerState.activeManga;
|
||||||
if (!chapter || !manga) return;
|
if (!chapter || !manga) return;
|
||||||
|
|
||||||
const existing = readerState.bookmarks.find(
|
const existing = seriesState.bookmarks.find(
|
||||||
b => b.mangaId === manga.id && b.chapterId === chapter.id && b.pageNumber === pageNumber,
|
b => b.mangaId === manga.id && b.chapterId === chapter.id && b.pageNumber === pageNumber,
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
readerState.removeBookmark(chapter.id);
|
seriesState.removeBookmark(chapter.id);
|
||||||
} else {
|
} else {
|
||||||
const other = readerState.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== chapter.id);
|
const other = seriesState.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== chapter.id);
|
||||||
if (other) readerState.removeBookmark(other.chapterId);
|
if (other) seriesState.removeBookmark(other.chapterId);
|
||||||
readerState.addBookmark({
|
seriesState.addBookmark({
|
||||||
mangaId: manga.id,
|
mangaId: manga.id,
|
||||||
mangaTitle: manga.title,
|
mangaTitle: manga.title,
|
||||||
thumbnailUrl: manga.thumbnailUrl,
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
@@ -103,9 +104,9 @@ export function commitMarker(color: MarkerColor, note: string, editId: string) {
|
|||||||
const manga = readerState.activeManga;
|
const manga = readerState.activeManga;
|
||||||
if (!chapter || !manga) return;
|
if (!chapter || !manga) return;
|
||||||
if (editId) {
|
if (editId) {
|
||||||
readerState.updateMarker(editId, { note: note.trim(), color });
|
seriesState.updateMarker(editId, { note: note.trim(), color });
|
||||||
} else {
|
} else {
|
||||||
readerState.addMarker({
|
seriesState.addMarker({
|
||||||
mangaId: manga.id,
|
mangaId: manga.id,
|
||||||
mangaTitle: manga.title,
|
mangaTitle: manga.title,
|
||||||
thumbnailUrl: manga.thumbnailUrl,
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readerState } from "$lib/state/reader.svelte";
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
import { seriesState } from "$lib/state/series.svelte";
|
||||||
import { fetchPages } from "./pageLoader";
|
import { fetchPages } from "./pageLoader";
|
||||||
import { cancelQueuedFetches, revokeBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
import { cancelQueuedFetches, revokeBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||||
import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache";
|
import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache";
|
||||||
@@ -9,6 +10,7 @@ export function scheduleResumeDismiss() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let prefetchedChapterId: number | null = null;
|
let prefetchedChapterId: number | null = null;
|
||||||
|
let prefetchedUrls: string[] = [];
|
||||||
|
|
||||||
export async function loadChapter(
|
export async function loadChapter(
|
||||||
id: number,
|
id: number,
|
||||||
@@ -26,15 +28,12 @@ export async function loadChapter(
|
|||||||
if (useBlob) {
|
if (useBlob) {
|
||||||
clearResolvedUrlCache();
|
clearResolvedUrlCache();
|
||||||
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
||||||
for (const strip of readerState.stripChapters) {
|
|
||||||
for (const url of strip.urls) revokeBlobUrl(url);
|
|
||||||
}
|
|
||||||
if (prefetchedChapterId !== null && prefetchedChapterId !== id) {
|
if (prefetchedChapterId !== null && prefetchedChapterId !== id) {
|
||||||
const prefetchedUrls = await fetchPages(prefetchedChapterId, false).catch(() => [] as string[]);
|
|
||||||
for (const url of prefetchedUrls) revokeBlobUrl(url);
|
for (const url of prefetchedUrls) revokeBlobUrl(url);
|
||||||
clearPageCache(prefetchedChapterId);
|
clearPageCache(prefetchedChapterId);
|
||||||
}
|
}
|
||||||
prefetchedChapterId = null;
|
prefetchedChapterId = null;
|
||||||
|
prefetchedUrls = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
startAtLastPage.current = false;
|
startAtLastPage.current = false;
|
||||||
@@ -42,7 +41,7 @@ export async function loadChapter(
|
|||||||
readerState.resetForChapter();
|
readerState.resetForChapter();
|
||||||
readerState.pageUrls = [];
|
readerState.pageUrls = [];
|
||||||
|
|
||||||
const bookmark = readerState.bookmarks.find(b => b.chapterId === id);
|
const bookmark = seriesState.bookmarks.find(b => b.chapterId === id);
|
||||||
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||||
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
||||||
readerState.resumeDismissed = false;
|
readerState.resumeDismissed = false;
|
||||||
@@ -63,9 +62,16 @@ export async function loadChapter(
|
|||||||
readerState.pageReady = true;
|
readerState.pageReady = true;
|
||||||
readerState.loading = false;
|
readerState.loading = false;
|
||||||
if (resumeTo > 1) readerState.resumeVisible = true;
|
if (resumeTo > 1) readerState.resumeVisible = true;
|
||||||
|
|
||||||
if (adjacent.next) {
|
if (adjacent.next) {
|
||||||
prefetchedChapterId = adjacent.next.id;
|
prefetchedChapterId = adjacent.next.id;
|
||||||
fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
|
fetchPages(adjacent.next.id, useBlob, ctrl.signal)
|
||||||
|
.then(fetched => {
|
||||||
|
if (!ctrl.signal.aborted && prefetchedChapterId === adjacent.next!.id) {
|
||||||
|
prefetchedUrls = fetched;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ function advanceGroup(forward: boolean, adjacent: Adjacent, startAtLastPage: ()
|
|||||||
const gi = readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber));
|
const gi = readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber));
|
||||||
if (forward) {
|
if (forward) {
|
||||||
if (gi < readerState.pageGroups.length - 1) readerState.pageNumber = readerState.pageGroups[gi + 1][0];
|
if (gi < readerState.pageGroups.length - 1) readerState.pageNumber = readerState.pageGroups[gi + 1][0];
|
||||||
else if (adjacent.next) { readerState.pageNumber = 1; openReader(adjacent.next, readerState.activeChapterList); }
|
else if (adjacent.next) { readerState.pageNumber = 1; openReader(adjacent.next); }
|
||||||
else closeReader();
|
else closeReader();
|
||||||
} else {
|
} else {
|
||||||
if (gi > 0) readerState.pageNumber = readerState.pageGroups[gi - 1][0];
|
if (gi > 0) readerState.pageNumber = readerState.pageGroups[gi - 1][0];
|
||||||
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export function goForward(
|
|||||||
) {
|
) {
|
||||||
if (readerState.loading) return;
|
if (readerState.loading) return;
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, readerState.activeChapterList); }
|
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
|
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
|
||||||
@@ -46,14 +46,14 @@ export function goForward(
|
|||||||
} else if (adjacent.next) {
|
} else if (adjacent.next) {
|
||||||
onMaybeMarkRead();
|
onMaybeMarkRead();
|
||||||
readerState.pageNumber = 1;
|
readerState.pageNumber = 1;
|
||||||
openReader(adjacent.next, readerState.activeChapterList);
|
openReader(adjacent.next);
|
||||||
} else closeReader();
|
} else closeReader();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () => void) {
|
export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () => void) {
|
||||||
if (readerState.loading) return;
|
if (readerState.loading) return;
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
|
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
|
||||||
@@ -61,7 +61,7 @@ export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () =>
|
|||||||
if (readerState.pageNumber > 1) {
|
if (readerState.pageNumber > 1) {
|
||||||
if (style === "fade") animateFade(() => { readerState.pageNumber--; });
|
if (style === "fade") animateFade(() => { readerState.pageNumber--; });
|
||||||
else readerState.pageNumber--;
|
else readerState.pageNumber--;
|
||||||
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jumpToPage(
|
export function jumpToPage(
|
||||||
@@ -69,14 +69,13 @@ export function jumpToPage(
|
|||||||
style: string,
|
style: string,
|
||||||
lastPage: number,
|
lastPage: number,
|
||||||
scrollToFlatIndex: ((idx: number) => void) | null,
|
scrollToFlatIndex: ((idx: number) => void) | null,
|
||||||
flatPageCount: number,
|
|
||||||
activeChapterId: number,
|
activeChapterId: number,
|
||||||
stripChapters: { chapterId: number; urls: string[] }[],
|
chunks: { chapterId: number; urls: string[] }[],
|
||||||
) {
|
) {
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (!scrollToFlatIndex || flatPageCount === 0) return;
|
if (!scrollToFlatIndex || !chunks.length) return;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const chunk of stripChapters) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.chapterId === activeChapterId) {
|
if (chunk.chapterId === activeChapterId) {
|
||||||
scrollToFlatIndex(offset + Math.max(0, page - 1));
|
scrollToFlatIndex(offset + Math.max(0, page - 1));
|
||||||
return;
|
return;
|
||||||
@@ -92,4 +91,4 @@ export function jumpToPage(
|
|||||||
} else {
|
} else {
|
||||||
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
|
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache, clearResolvedUrlCache } from "$lib/core/cache/pageCache";
|
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache, clearResolvedUrlCache, getCachedAspect } from "$lib/core/cache/pageCache";
|
||||||
|
|
||||||
export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads: boolean): number[][] {
|
export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads: boolean): number[][] {
|
||||||
const groups: number[][] = [[1]];
|
const groups: number[][] = [[1]];
|
||||||
@@ -10,4 +10,4 @@ export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads
|
|||||||
else { groups.push([i, i + 1]); i += 2; }
|
else { groups.push([i, i + 1]); i += 2; }
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
@@ -1,141 +1 @@
|
|||||||
export const READ_LINE_PCT = 0.50;
|
export const READ_LINE_PCT = 0.50;
|
||||||
|
|
||||||
export interface StripChapter {
|
|
||||||
chapterId: number;
|
|
||||||
chapterName: string;
|
|
||||||
urls: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScrollHandlerCallbacks {
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
onChapterChange: (chapterId: number) => void;
|
|
||||||
onCenterIdxChange: (flatIdx: number) => void;
|
|
||||||
onMarkRead: (chapterId: number) => void;
|
|
||||||
onAppend: () => void;
|
|
||||||
getStripChapters: () => StripChapter[];
|
|
||||||
getPageUrls: () => string[];
|
|
||||||
shouldAutoMark: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the element is considered "at" the read-line.
|
|
||||||
*
|
|
||||||
* Ported from Suwayomi's ReaderPager.utils `isPageInViewport`:
|
|
||||||
* - If the element's top is above the line AND its bottom is below it → fully covers the line
|
|
||||||
* (handles a single page that is taller than the viewport).
|
|
||||||
* - If the element's top is at or below the line AND its bottom is also below it → leading edge
|
|
||||||
* has crossed the line (normal scroll-past case).
|
|
||||||
*
|
|
||||||
* Using Math.trunc to avoid floating-point jitter from getBoundingClientRect.
|
|
||||||
*/
|
|
||||||
function isPageAtReadLine(el: HTMLElement, readLineY: number): boolean {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const top = Math.trunc(rect.top);
|
|
||||||
const bottom = Math.trunc(rect.bottom);
|
|
||||||
const line = Math.trunc(readLineY);
|
|
||||||
// Element completely spans the read line (taller than viewport or very tall image)
|
|
||||||
if (top <= line && bottom >= line) return true;
|
|
||||||
// Element's top edge is at or above the line
|
|
||||||
if (top <= line) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupScrollTracking(
|
|
||||||
containerEl: HTMLElement,
|
|
||||||
callbacks: ScrollHandlerCallbacks,
|
|
||||||
): () => void {
|
|
||||||
const {
|
|
||||||
onPageChange, onChapterChange, onCenterIdxChange,
|
|
||||||
onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
|
||||||
} = callbacks;
|
|
||||||
|
|
||||||
let rafId: number | null = null;
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
rafId = null;
|
|
||||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
|
||||||
|
|
||||||
if (!imgs.length) return;
|
|
||||||
|
|
||||||
const containerRect = containerEl.getBoundingClientRect();
|
|
||||||
const readLineY = containerRect.top + containerEl.clientHeight * READ_LINE_PCT;
|
|
||||||
|
|
||||||
// Find the last image whose top is at or above the read line.
|
|
||||||
// Binary search is still valid here since images are ordered top-to-bottom.
|
|
||||||
let lo = 0, hi = imgs.length - 1, best = 0;
|
|
||||||
while (lo <= hi) {
|
|
||||||
const mid = (lo + hi) >>> 1;
|
|
||||||
if (isPageAtReadLine(imgs[mid], readLineY)) { best = mid; lo = mid + 1; }
|
|
||||||
else hi = mid - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = imgs[best];
|
|
||||||
const activePage = Number(active.dataset.localPage);
|
|
||||||
const activeChId = Number(active.dataset.chapter);
|
|
||||||
|
|
||||||
onPageChange(activePage);
|
|
||||||
if (activeChId) onChapterChange(activeChId);
|
|
||||||
|
|
||||||
const chunks = getStripChapters();
|
|
||||||
let flatOffset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
if (chunk.chapterId === activeChId) {
|
|
||||||
onCenterIdxChange(flatOffset + activePage - 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
flatOffset += chunk.urls.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldAutoMark() && activeChId) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight >= 0.80) {
|
|
||||||
onAppend();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScroll() {
|
|
||||||
if (rafId !== null) return;
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
containerEl.removeEventListener("scroll", onScroll);
|
|
||||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendNextChapter(
|
|
||||||
stripChapters: StripChapter[],
|
|
||||||
chapterList: { id: number; name: string }[],
|
|
||||||
fetchPages: (chapterId: number) => Promise<string[]>,
|
|
||||||
preloadImage: (url: string) => void,
|
|
||||||
onAppended: (next: StripChapter) => void,
|
|
||||||
onDone: () => void,
|
|
||||||
): void {
|
|
||||||
if (!stripChapters.length) return;
|
|
||||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
|
||||||
const lastIdx = chapterList.findIndex(c => c.id === lastChunk.chapterId);
|
|
||||||
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) return;
|
|
||||||
const next = chapterList[lastIdx + 1];
|
|
||||||
if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
|
|
||||||
|
|
||||||
fetchPages(next.id)
|
|
||||||
.then(urls => { urls.slice(0, 6).forEach(preloadImage); return urls; })
|
|
||||||
.then(urls => {
|
|
||||||
if (stripChapters.some(c => c.chapterId === next.id)) { onDone(); return; }
|
|
||||||
onAppended({ chapterId: next.id, chapterName: next.name, urls });
|
|
||||||
onDone();
|
|
||||||
})
|
|
||||||
.catch(() => onDone());
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
imgCls: string;
|
||||||
|
currentGroup: number[];
|
||||||
|
srcs: (string | null)[];
|
||||||
|
pageGroups: number[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imgCls, currentGroup, srcs, pageGroups }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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 (pg)}
|
||||||
|
{#if srcs[i]}
|
||||||
|
<img
|
||||||
|
src={srcs[i]}
|
||||||
|
alt="Page {pg}"
|
||||||
|
class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">
|
||||||
|
{@render skeleton()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="center-overlay">
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||||
|
|
||||||
|
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
|
||||||
|
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
||||||
|
.gap-left { margin-right: 2px; }
|
||||||
|
.gap-right { margin-left: 2px; }
|
||||||
|
|
||||||
|
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
||||||
|
.page-loader-single {
|
||||||
|
width: min(100%, var(--effective-width, 100%));
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
max-height: calc(var(--visual-vh, 100vh) - 80px);
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-skeleton { width: 100%; height: 100%; }
|
||||||
|
.panel-skeleton :global(.ps-r) {
|
||||||
|
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
|
||||||
|
stroke-dasharray: 400; stroke-dashoffset: 400;
|
||||||
|
animation: ps-shimmer 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
|
||||||
|
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
|
||||||
|
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
|
||||||
|
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
|
||||||
|
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
@keyframes ps-shimmer {
|
||||||
|
0% { stroke-dashoffset: 400; opacity: 0.25; }
|
||||||
|
40% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
70% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import { getCachedAspect } from "$lib/components/reader/lib/pageLoader";
|
||||||
|
|
||||||
|
export interface StripPage {
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
localIndex: number;
|
||||||
|
url: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
containerEl: HTMLDivElement | undefined;
|
||||||
|
flatPages: StripPage[];
|
||||||
|
imgCls: string;
|
||||||
|
effectiveWidth: number | undefined;
|
||||||
|
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { containerEl, flatPages, imgCls, effectiveWidth, resolveUrl, barPosition }: Props = $props();
|
||||||
|
|
||||||
|
const LOAD_RADIUS = 5;
|
||||||
|
const UNLOAD_RADIUS = 10;
|
||||||
|
|
||||||
|
let _loadedSet: Set<number> = new Set();
|
||||||
|
let _resolvedSrc: Record<number, string> = {};
|
||||||
|
let _version = $state(0);
|
||||||
|
|
||||||
|
const loadedSet = { has: (i: number) => _loadedSet.has(i) };
|
||||||
|
const resolvedSrc = { get: (i: number) => _resolvedSrc[i] as string | undefined };
|
||||||
|
let revokeQueue: string[] = [];
|
||||||
|
|
||||||
|
let centerIdx = $state(0);
|
||||||
|
const aspectMap = new Map<number, number>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
_loadedSet.add(idx);
|
||||||
|
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[idx] = src;
|
||||||
|
_version++;
|
||||||
|
} else {
|
||||||
|
scheduleRevoke(src);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadPage(idx: number) {
|
||||||
|
if (!_loadedSet.has(idx)) return;
|
||||||
|
_loadedSet.delete(idx);
|
||||||
|
const aspect = aspectMap.get(idx);
|
||||||
|
if (aspect !== undefined && containerEl) {
|
||||||
|
const slot = containerEl.querySelectorAll<HTMLElement>(".strip-slot")[idx];
|
||||||
|
slot?.style.setProperty("--aspect", String(aspect));
|
||||||
|
}
|
||||||
|
const oldSrc = _resolvedSrc[idx];
|
||||||
|
if (oldSrc) {
|
||||||
|
delete _resolvedSrc[idx];
|
||||||
|
scheduleRevoke(oldSrc);
|
||||||
|
}
|
||||||
|
_version++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recalcTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function recalcWindow(center: number) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRecalc(center: number) {
|
||||||
|
if (recalcTimer) return;
|
||||||
|
recalcTimer = setTimeout(() => { recalcTimer = null; recalcWindow(center); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { void _version; });
|
||||||
|
$effect(() => { recalcWindow(centerIdx); });
|
||||||
|
$effect(() => { void flatPages.length; tick().then(() => recalcWindow(centerIdx)); });
|
||||||
|
|
||||||
|
let lastChapterId = 0;
|
||||||
|
$effect(() => {
|
||||||
|
let chapterId: number;
|
||||||
|
try { chapterId = readerState.activeChapter?.id ?? 0; } catch { return; }
|
||||||
|
if (chapterId === lastChapterId) return;
|
||||||
|
lastChapterId = chapterId;
|
||||||
|
_loadedSet = new Set<number>();
|
||||||
|
_resolvedSrc = {};
|
||||||
|
centerIdx = 0;
|
||||||
|
_version++;
|
||||||
|
aspectMap.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export function notifyScrollCenter(idx: number) {
|
||||||
|
centerIdx = idx;
|
||||||
|
scheduleRecalc(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrollToFlatIndex(idx: number) {
|
||||||
|
if (!containerEl || !flatPages.length) return;
|
||||||
|
centerIdx = idx;
|
||||||
|
recalcWindow(idx);
|
||||||
|
await tick();
|
||||||
|
if (!containerEl) return;
|
||||||
|
const slot = containerEl.querySelectorAll<HTMLElement>(".strip-slot")[idx];
|
||||||
|
if (slot) slot.scrollIntoView({ block: "start", behavior: "instant" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let anchorEl: HTMLElement | null = null;
|
||||||
|
let anchorOffset = 0;
|
||||||
|
|
||||||
|
export function captureAnchor() {
|
||||||
|
if (!containerEl) return;
|
||||||
|
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
|
||||||
|
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
|
let best: HTMLElement | null = null;
|
||||||
|
let bestTop = -Infinity;
|
||||||
|
for (const img of imgs) {
|
||||||
|
const top = img.getBoundingClientRect().top;
|
||||||
|
if (top <= readY && top > bestTop) { bestTop = top; best = img; }
|
||||||
|
}
|
||||||
|
anchorEl = best;
|
||||||
|
anchorOffset = best ? readY - best.getBoundingClientRect().top : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreAnchor() {
|
||||||
|
if (!containerEl || !anchorEl) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!anchorEl || !containerEl) return;
|
||||||
|
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
|
||||||
|
const delta = (readY - anchorEl.getBoundingClientRect().top) - anchorOffset;
|
||||||
|
containerEl.scrollTop -= delta;
|
||||||
|
anchorEl = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let autoScrollPaused = false;
|
||||||
|
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function pauseAutoScroll() {
|
||||||
|
autoScrollPaused = true;
|
||||||
|
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
|
||||||
|
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!settingsState.settings.autoScroll || !containerEl) return;
|
||||||
|
let rafId: number;
|
||||||
|
const tick = () => {
|
||||||
|
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (settingsState.settings.autoScrollSpeed ?? 5) * 0.5;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const HIDE_AFTER_MS = 5_000;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const show = () => {
|
||||||
|
containerEl.style.cursor = "";
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => { if (containerEl) containerEl.style.cursor = "none"; }, HIDE_AFTER_MS);
|
||||||
|
};
|
||||||
|
show();
|
||||||
|
window.addEventListener("mousemove", show, { passive: true });
|
||||||
|
return () => {
|
||||||
|
if (containerEl) containerEl.style.cursor = "";
|
||||||
|
window.removeEventListener("mousemove", show);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let midScrollActive = $state(false);
|
||||||
|
let midScrollOriginY = $state(0);
|
||||||
|
let midScrollCurrentY = 0;
|
||||||
|
let midScrollDisplayLevel = $state(0);
|
||||||
|
let midScrollRaf: number | null = null;
|
||||||
|
|
||||||
|
function startMidScroll(originY: number) {
|
||||||
|
midScrollActive = true;
|
||||||
|
midScrollOriginY = originY;
|
||||||
|
midScrollDisplayLevel = 0;
|
||||||
|
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
|
||||||
|
const frame = () => {
|
||||||
|
if (!midScrollActive || !containerEl) return;
|
||||||
|
const dy = midScrollCurrentY - midScrollOriginY;
|
||||||
|
const excess = Math.max(0, Math.abs(dy) - 24);
|
||||||
|
containerEl.scrollTop += Math.sign(dy) * excess * 0.12;
|
||||||
|
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
|
||||||
|
midScrollRaf = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
midScrollRaf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopMidScroll() {
|
||||||
|
midScrollActive = false;
|
||||||
|
midScrollDisplayLevel = 0;
|
||||||
|
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let stripDragging = false;
|
||||||
|
let stripDragMoved = false;
|
||||||
|
let stripDragStartY = 0;
|
||||||
|
let stripScrollStart = 0;
|
||||||
|
|
||||||
|
function setDragCursor(dragging: boolean) {
|
||||||
|
if (containerEl) containerEl.style.cursor = dragging ? "grabbing" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
|
if (e.button === 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (midScrollActive) stopMidScroll();
|
||||||
|
else { settingsState.settings.autoScroll = false; startMidScroll(e.clientY); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stripDragging = true;
|
||||||
|
stripDragMoved = false;
|
||||||
|
stripDragStartY = e.clientY;
|
||||||
|
stripScrollStart = containerEl?.scrollTop ?? 0;
|
||||||
|
setDragCursor(true);
|
||||||
|
pauseAutoScroll();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMouseMove(e: MouseEvent) {
|
||||||
|
midScrollCurrentY = e.clientY;
|
||||||
|
if (!stripDragging) return;
|
||||||
|
const dy = e.clientY - stripDragStartY;
|
||||||
|
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||||
|
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMouseUp() {
|
||||||
|
stripDragging = false;
|
||||||
|
setDragCursor(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerDown(e: PointerEvent) {
|
||||||
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
|
stripDragging = true;
|
||||||
|
stripDragMoved = false;
|
||||||
|
stripDragStartY = e.clientY;
|
||||||
|
stripScrollStart = containerEl?.scrollTop ?? 0;
|
||||||
|
setDragCursor(true);
|
||||||
|
pauseAutoScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!stripDragging) return;
|
||||||
|
const dy = e.clientY - stripDragStartY;
|
||||||
|
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||||
|
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerUp() {
|
||||||
|
stripDragging = false;
|
||||||
|
setDragCursor(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeTap(): boolean {
|
||||||
|
if (stripDragMoved) { stripDragMoved = false; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onWheel(e: WheelEvent) {
|
||||||
|
if (!e.ctrlKey) pauseAutoScroll();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if midScrollActive}
|
||||||
|
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
|
||||||
|
<div class="midscroll-segments">
|
||||||
|
{#each [5,4,3,2,1] as n}
|
||||||
|
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
|
||||||
|
{/each}
|
||||||
|
<div class="midscroll-origin-dot"></div>
|
||||||
|
{#each [1,2,3,4,5] as n}
|
||||||
|
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
|
||||||
|
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
||||||
|
{@const src = (_version, resolvedSrc.get(gi))}
|
||||||
|
{@const isLoaded = (_version, loadedSet.has(gi))}
|
||||||
|
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId} style={getCachedAspect(page.url) != null ? `--aspect:${getCachedAspect(page.url)}` : undefined}>
|
||||||
|
{#if isLoaded && src}
|
||||||
|
<img
|
||||||
|
{src}
|
||||||
|
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
||||||
|
data-local-page={page.localIndex + 1}
|
||||||
|
data-chapter={page.chapterId}
|
||||||
|
data-total={page.total}
|
||||||
|
class="{imgCls}{settingsState.settings.pageGap ? ' strip-gap' : ''}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
onload={(e) => {
|
||||||
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
|
const slot = img.closest<HTMLElement>(".strip-slot");
|
||||||
|
if (slot && img.naturalWidth > 0) {
|
||||||
|
const aspect = img.naturalWidth / img.naturalHeight;
|
||||||
|
slot.style.setProperty("--aspect", String(aspect));
|
||||||
|
aspectMap.set(gi, aspect);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="strip-placeholder" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div style="height:1px;flex-shrink:0"></div>
|
||||||
|
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-skeleton { width: 100%; height: 100%; }
|
||||||
|
.panel-skeleton :global(.ps-r) {
|
||||||
|
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
|
||||||
|
stroke-dasharray: 400; stroke-dashoffset: 400;
|
||||||
|
animation: ps-shimmer 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
|
||||||
|
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
|
||||||
|
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
|
||||||
|
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
|
||||||
|
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
@keyframes ps-shimmer {
|
||||||
|
0% { stroke-dashoffset: 400; opacity: 0.25; }
|
||||||
|
40% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
70% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.midscroll-bar {
|
||||||
|
position: fixed; top: 50%; transform: translateY(-50%);
|
||||||
|
z-index: 200; display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 8px; padding: 10px 6px;
|
||||||
|
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
|
||||||
|
border: 1px solid var(--border-base); border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
|
||||||
|
pointer-events: auto; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.midscroll-bar-right { right: 8px; }
|
||||||
|
.midscroll-bar-left { left: 8px; }
|
||||||
|
|
||||||
|
.midscroll-segments { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||||
|
.midscroll-origin-dot { width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--accent-fg); opacity: 0.6; flex-shrink: 0; margin: 2px 0; }
|
||||||
|
.midscroll-seg { width: 4px; height: 14px; border-radius: 2px; background: var(--border-strong); transition: background 0.06s ease; flex-shrink: 0; }
|
||||||
|
.midscroll-seg-lit { background: var(--accent-fg); }
|
||||||
|
.midscroll-stop { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); flex-shrink: 0; }
|
||||||
|
.midscroll-stop:hover { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
imgCls: string;
|
||||||
|
src: string | null;
|
||||||
|
fadingOut: boolean;
|
||||||
|
isFade: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imgCls, src, fadingOut, isFade }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inspect-wrap"
|
||||||
|
style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"
|
||||||
|
>
|
||||||
|
{#if src}
|
||||||
|
<img
|
||||||
|
{src}
|
||||||
|
alt="Page {readerState.pageNumber}"
|
||||||
|
class={imgCls}
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
style={isFade ? `opacity:${fadingOut ? 0 : 1};transition:opacity 0.1s ease` : undefined}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||||
|
|
||||||
|
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
||||||
|
.page-loader-single {
|
||||||
|
width: min(100%, var(--effective-width, 100%));
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
max-height: calc(var(--visual-vh, 100vh) - 80px);
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-skeleton { width: 100%; height: 100%; }
|
||||||
|
.panel-skeleton :global(.ps-r) {
|
||||||
|
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
|
||||||
|
stroke-dasharray: 400; stroke-dashoffset: 400;
|
||||||
|
animation: ps-shimmer 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
|
||||||
|
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
|
||||||
|
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
|
||||||
|
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
|
||||||
|
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
@keyframes ps-shimmer {
|
||||||
|
0% { stroke-dashoffset: 400; opacity: 0.25; }
|
||||||
|
40% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
70% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,9 +4,8 @@
|
|||||||
import { cache, CACHE_KEYS, CACHE_GROUPS } from '$lib/core/cache/queryCache'
|
import { cache, CACHE_KEYS, CACHE_GROUPS } from '$lib/core/cache/queryCache'
|
||||||
import { homeState, clearHistory } from '$lib/state/home.svelte'
|
import { homeState, clearHistory } from '$lib/state/home.svelte'
|
||||||
import { historyState } from '$lib/state/history.svelte'
|
import { historyState } from '$lib/state/history.svelte'
|
||||||
import { setActiveManga, openReader, setPreviewManga } from '$lib/state/series.svelte'
|
import { setActiveManga, openReaderForChapter, setPreviewManga } from '$lib/state/series.svelte'
|
||||||
import { addToast } from '$lib/state/notifications.svelte'
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
import { buildChapterList } from '$lib/components/series/lib/chapterList'
|
|
||||||
import { groupByDay } from './lib/recentHistory'
|
import { groupByDay } from './lib/recentHistory'
|
||||||
import { fetchedAtMs, parseServerTimestamp, groupUpdatesByDay } from './lib/recentUpdates'
|
import { fetchedAtMs, parseServerTimestamp, groupUpdatesByDay } from './lib/recentUpdates'
|
||||||
import RecentToolbar from './RecentToolbar.svelte'
|
import RecentToolbar from './RecentToolbar.svelte'
|
||||||
@@ -168,13 +167,11 @@
|
|||||||
const manga = mangaStub(item)
|
const manga = mangaStub(item)
|
||||||
try {
|
try {
|
||||||
const chapters = await getAdapter().getChapters(String(item.mangaId))
|
const chapters = await getAdapter().getChapters(String(item.mangaId))
|
||||||
const sorted = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
const target = chapters.find(ch => ch.id === item.id)
|
||||||
const list = buildChapterList(sorted, {})
|
if (target) openReaderForChapter(target, manga)
|
||||||
const target = list.find(ch => ch.id === item.id)
|
else setPreviewManga(manga)
|
||||||
if (target) { setActiveManga(manga); openReader(target, list) }
|
|
||||||
else setActiveManga(manga)
|
|
||||||
} catch {
|
} catch {
|
||||||
setActiveManga(manga)
|
setPreviewManga(manga)
|
||||||
addToast({ kind: 'error', title: "Couldn't open chapter", body: 'Opened the series instead.' })
|
addToast({ kind: 'error', title: "Couldn't open chapter", body: 'Opened the series instead.' })
|
||||||
} finally {
|
} finally {
|
||||||
openingId = null
|
openingId = null
|
||||||
|
|||||||
@@ -65,15 +65,13 @@
|
|||||||
|
|
||||||
{:else if viewMode === 'grid'}
|
{:else if viewMode === 'grid'}
|
||||||
{#each sortedChapters as ch, i}
|
{#each sortedChapters as ch, i}
|
||||||
{@const inProgress = !ch.read && (ch.lastPageRead ?? 0) > 0}
|
|
||||||
{@const isGridSelected = selectedIds.has(ch.id)}
|
{@const isGridSelected = selectedIds.has(ch.id)}
|
||||||
<button
|
<button
|
||||||
class="grid-cell"
|
class="grid-cell"
|
||||||
class:read={ch.read}
|
class:read={ch.read}
|
||||||
class:in-progress={inProgress}
|
|
||||||
class:grid-selected={isGridSelected}
|
class:grid-selected={isGridSelected}
|
||||||
use:chapterLongPress={[ch, i]}
|
use:chapterLongPress={[ch, i]}
|
||||||
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
|
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, !ch.read && (ch.lastPageRead ?? 0) > 0)}
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i } }}
|
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i } }}
|
||||||
title={ch.name}
|
title={ch.name}
|
||||||
>
|
>
|
||||||
@@ -185,7 +183,6 @@
|
|||||||
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
|
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
|
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
|
||||||
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.grid-cell-num { font-size: 10px; }
|
.grid-cell-num { font-size: 10px; }
|
||||||
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
||||||
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
|
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
import type { Chapter, Category } from '$lib/types'
|
import type { Chapter, Category } from '$lib/types'
|
||||||
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
|
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
|
||||||
import { updateSettings } from '$lib/state/settings.svelte'
|
|
||||||
|
|
||||||
interface ContinueChapter {
|
interface ContinueChapter {
|
||||||
chapter: Chapter
|
chapter: Chapter
|
||||||
@@ -52,6 +51,8 @@
|
|||||||
onSetScanlatorBlacklist: (v: string[]) => void
|
onSetScanlatorBlacklist: (v: string[]) => void
|
||||||
onSetScanlatorForce: (v: boolean) => void
|
onSetScanlatorForce: (v: boolean) => void
|
||||||
onOpenFolder: () => void
|
onOpenFolder: () => void
|
||||||
|
onSortModeChange: (v: ChapterSortMode) => void
|
||||||
|
onSortDirChange: (v: ChapterSortDir) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
|
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
|
||||||
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
|
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
|
||||||
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
|
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
|
||||||
onOpenFolder,
|
onOpenFolder, onSortModeChange, onSortDirChange,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
let sortMenuOpen: boolean = $state(false)
|
let sortMenuOpen: boolean = $state(false)
|
||||||
@@ -166,11 +167,11 @@
|
|||||||
<button
|
<button
|
||||||
class="sort-option"
|
class="sort-option"
|
||||||
class:active={sortMode === val}
|
class:active={sortMode === val}
|
||||||
onclick={() => { updateSettings({ chapterSortMode: val as ChapterSortMode }); onPageChange(1); sortMenuOpen = false }}
|
onclick={() => { onSortModeChange(val as ChapterSortMode); onPageChange(1); sortMenuOpen = false }}
|
||||||
>{label}</button>
|
>{label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="sort-divider"></div>
|
<div class="sort-divider"></div>
|
||||||
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === 'desc' ? 'asc' : 'desc' }); onPageChange(1); sortMenuOpen = false }}>
|
<button class="sort-option" onclick={() => { onSortDirChange(sortDir === 'desc' ? 'asc' : 'desc'); onPageChange(1); sortMenuOpen = false }}>
|
||||||
{sortDir === 'desc' ? '↑ Ascending' : '↓ Descending'}
|
{sortDir === 'desc' ? '↑ Ascending' : '↓ Descending'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,19 +10,17 @@
|
|||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
|
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||||
import { getManga, getMangaList } from '$lib/request-manager/manga'
|
import { getManga, getMangaList } from '$lib/request-manager/manga'
|
||||||
import { getChapters, fetchChapters, markChapterRead, markChaptersRead, deleteDownloadedChapters } from '$lib/request-manager/chapters'
|
import { markChapterRead, markChaptersRead, deleteDownloadedChapters, fetchChapters } from '$lib/request-manager/chapters'
|
||||||
import { downloadStore } from '$lib/state/downloads.svelte'
|
import { downloadStore } from '$lib/state/downloads.svelte'
|
||||||
import { getCategories, updateMangaCategories, createCategory as createCategoryReq, updateManga } from '$lib/request-manager/manga'
|
import { getCategories, updateMangaCategories, createCategory as createCategoryReq, updateManga } from '$lib/request-manager/manga'
|
||||||
import { saveScroll, getScroll } from '$lib/state/app.svelte'
|
import { saveScroll, getScroll } from '$lib/state/app.svelte'
|
||||||
import { seriesState, openReader, addBookmark,
|
import { seriesState, openReaderForChapter, acknowledgeUpdate, addBookmark, clearMarkersForManga } from '$lib/state/series.svelte'
|
||||||
acknowledgeUpdate, clearMarkersForManga } from '$lib/state/series.svelte'
|
import { DEFAULT_MANGA_PREFS } from '$lib/state/series.svelte'
|
||||||
import { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
|
|
||||||
import type { MangaPrefs } from '$lib/types/settings'
|
import type { MangaPrefs } from '$lib/types/settings'
|
||||||
import { addToast } from '$lib/state/notifications.svelte'
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
import { trackingState } from '$lib/state/tracking.svelte'
|
import { trackingState } from '$lib/state/tracking.svelte'
|
||||||
import { autoLinkLibrary } from '$lib/core/cover/autoLink'
|
import { autoLinkLibrary } from '$lib/core/cover/autoLink'
|
||||||
import { buildChapterList } from '$lib/components/series/lib/chapterList'
|
import { getPref, setPref } from '$lib/state/series.svelte'
|
||||||
import { getPref, setPref } from '$lib/components/series/lib/mangaPrefs'
|
|
||||||
import { openMangaFolder } from '$lib/core/filesystem'
|
import { openMangaFolder } from '$lib/core/filesystem'
|
||||||
import type { Manga, Chapter, Category } from '$lib/types'
|
import type { Manga, Chapter, Category } from '$lib/types'
|
||||||
import AutomationPanel from '$lib/components/series/panels/AutomationPanel.svelte'
|
import AutomationPanel from '$lib/components/series/panels/AutomationPanel.svelte'
|
||||||
@@ -37,15 +35,11 @@
|
|||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25
|
const CHAPTERS_PER_PAGE = 25
|
||||||
const MANGA_TTL_MS = 5 * 60 * 1000
|
const MANGA_TTL_MS = 5 * 60 * 1000
|
||||||
const CHAPTER_TTL_MS = 2 * 60 * 1000
|
|
||||||
|
|
||||||
const mangaCache: Map<number, { data: Manga; fetchedAt: number }> = new Map()
|
const mangaCache: Map<number, { data: Manga; fetchedAt: number }> = new Map()
|
||||||
const chapterCache: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map()
|
|
||||||
|
|
||||||
let manga: Manga | null = $state(null)
|
let manga: Manga | null = $state(null)
|
||||||
let chapters: Chapter[] = $state([])
|
|
||||||
let loadingManga: boolean = $state(false)
|
let loadingManga: boolean = $state(false)
|
||||||
let loadingChapters: boolean = $state(true)
|
|
||||||
let enqueueing: Set<number> = $state(new Set())
|
let enqueueing: Set<number> = $state(new Set())
|
||||||
let togglingLibrary: boolean = $state(false)
|
let togglingLibrary: boolean = $state(false)
|
||||||
let chapterPage: number = $state(1)
|
let chapterPage: number = $state(1)
|
||||||
@@ -66,40 +60,26 @@
|
|||||||
let catsLoading: boolean = $state(false)
|
let catsLoading: boolean = $state(false)
|
||||||
let chapterListEl: HTMLDivElement | null = $state(null)
|
let chapterListEl: HTMLDivElement | null = $state(null)
|
||||||
|
|
||||||
let mangaAbort: AbortController | null = null
|
let mangaAbort: AbortController | null = null
|
||||||
let chapterAbort: AbortController | null = null
|
let prevMangaId: number | null = null
|
||||||
let loadingFor: number | null = null
|
|
||||||
let prevChapterIds = new Set<number>()
|
|
||||||
let prevMangaId: number | null = null
|
|
||||||
|
|
||||||
const get = <K extends keyof MangaPrefs>(key: K) =>
|
const get = <K extends keyof MangaPrefs>(key: K) => getPref(mangaId, key)
|
||||||
mangaId ? getPref(mangaId, key) : DEFAULT_MANGA_PREFS[key]
|
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value)
|
||||||
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => {
|
|
||||||
if (mangaId) setPref(mangaId, key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasSelection = $derived(selectedIds.size > 0)
|
const chapters = $derived(seriesState.chaptersFor(mangaId))
|
||||||
const sortDir = $derived(seriesState.settings.chapterSortDir)
|
const loadingChapters = $derived(seriesState.isLoadingChapters(mangaId))
|
||||||
const sortMode = $derived(seriesState.settings.chapterSortMode ?? 'source')
|
const sortedChapters = $derived(seriesState.activeChapterList)
|
||||||
const scanlatorFilter = $derived((get('scanlatorFilter') ?? []) as string[])
|
const hasSelection = $derived(selectedIds.size > 0)
|
||||||
const scanlatorBlacklist = $derived((get('scanlatorBlacklist') ?? []) as string[])
|
|
||||||
const scanlatorForce = $derived((get('scanlatorForce') ?? false) as boolean)
|
|
||||||
|
|
||||||
const currentPrefs = $derived({
|
|
||||||
sortMode,
|
|
||||||
sortDir,
|
|
||||||
preferredScanlator: get('preferredScanlator') as string,
|
|
||||||
scanlatorFilter: scanlatorFilter as string[],
|
|
||||||
scanlatorBlacklist: scanlatorBlacklist as string[],
|
|
||||||
scanlatorForce: scanlatorForce as boolean,
|
|
||||||
})
|
|
||||||
|
|
||||||
const availableScanlators = $derived(
|
const availableScanlators = $derived(
|
||||||
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
|
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
|
||||||
.sort((a, b) => a.localeCompare(b))
|
.sort((a, b) => a.localeCompare(b))
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedChapters = $derived(buildChapterList(chapters, currentPrefs))
|
const scanlatorFilter = $derived(get('scanlatorFilter') as string[])
|
||||||
|
const scanlatorBlacklist = $derived(get('scanlatorBlacklist') as string[])
|
||||||
|
const scanlatorForce = $derived(get('scanlatorForce') as boolean)
|
||||||
|
|
||||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE))
|
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE))
|
||||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE))
|
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE))
|
||||||
const readCount = $derived(sortedChapters.filter(c => c.read).length)
|
const readCount = $derived(sortedChapters.filter(c => c.read).length)
|
||||||
@@ -111,13 +91,10 @@
|
|||||||
if (!sortedChapters.length) return null
|
if (!sortedChapters.length) return null
|
||||||
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
||||||
const anyRead = asc.some(c => c.read)
|
const anyRead = asc.some(c => c.read)
|
||||||
const bookmark = mangaId
|
const bookmark = seriesState.bookmarks.find(b => b.mangaId === mangaId)
|
||||||
? seriesState.bookmarks.find(b => b.mangaId === mangaId)
|
|
||||||
: null
|
|
||||||
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null
|
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null
|
||||||
if (bookmarkedCh && !bookmarkedCh.read) {
|
if (bookmarkedCh && !bookmarkedCh.read)
|
||||||
return { chapter: bookmarkedCh, type: (anyRead ? 'continue' : 'start') as const, resumePage: bookmark!.pageNumber }
|
return { chapter: bookmarkedCh, type: (anyRead ? 'continue' : 'start') as const, resumePage: bookmark!.pageNumber }
|
||||||
}
|
|
||||||
const inProgress = asc.find(c => !c.read && (c.lastPageRead ?? 0) > 0)
|
const inProgress = asc.find(c => !c.read && (c.lastPageRead ?? 0) > 0)
|
||||||
const firstUnread = asc.find(c => !c.read)
|
const firstUnread = asc.find(c => !c.read)
|
||||||
const target = inProgress ?? firstUnread
|
const target = inProgress ?? firstUnread
|
||||||
@@ -146,17 +123,6 @@
|
|||||||
selectedIds = next
|
selectedIds = next
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyChapters(nodes: Chapter[]) {
|
|
||||||
if (get('autoDownload') && prevChapterIds.size > 0) {
|
|
||||||
const filtered = buildChapterList(nodes, currentPrefs)
|
|
||||||
const newChapters = filtered.filter(c => !prevChapterIds.has(c.id) && !c.downloaded)
|
|
||||||
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id))
|
|
||||||
}
|
|
||||||
prevChapterIds = new Set(nodes.map(c => c.id))
|
|
||||||
chapters = nodes
|
|
||||||
if (mangaId && nodes.length > 0) checkAndMarkCompleted(mangaId, nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadCategories(id: number) {
|
function loadCategories(id: number) {
|
||||||
catsLoading = true
|
catsLoading = true
|
||||||
getCategories()
|
getCategories()
|
||||||
@@ -169,96 +135,59 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
|
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
|
||||||
if (chaps.length && manga?.status !== 'ONGOING') {
|
if (!chaps.length || manga?.status === 'ONGOING') return
|
||||||
const allRead = chaps.every(c => c.read)
|
const allRead = chaps.every(c => c.read)
|
||||||
const completed = allCategories.find(c => c.name === 'Completed')
|
const completed = allCategories.find(c => c.name === 'Completed')
|
||||||
if (completed) {
|
if (!completed) return
|
||||||
const inCompleted = mangaCategories.some(c => c.id === completed.id)
|
const inCompleted = mangaCategories.some(c => c.id === completed.id)
|
||||||
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed]
|
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed]
|
||||||
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id)
|
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMangaData(id: number) {
|
function loadMangaData(id: number) {
|
||||||
mangaAbort?.abort()
|
mangaAbort?.abort()
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
mangaAbort = ctrl; loadingFor = id
|
mangaAbort = ctrl
|
||||||
const cached = mangaCache.get(id)
|
const cached = mangaCache.get(id)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
manga = cached.data; loadingManga = false
|
manga = cached.data
|
||||||
|
loadingManga = false
|
||||||
|
seriesState.setActiveManga(cached.data)
|
||||||
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return
|
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return
|
||||||
getManga(id, ctrl.signal).then(m => {
|
// stale-while-revalidate: update cache + store in background
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
getManga(id, ctrl.signal)
|
||||||
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
|
.then(m => {
|
||||||
manga = m
|
if (ctrl.signal.aborted) return
|
||||||
}).catch(() => {})
|
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
|
||||||
|
manga = m
|
||||||
|
seriesState.setActiveManga(m)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadingManga = true
|
loadingManga = true
|
||||||
getManga(id, ctrl.signal).then(m => {
|
getManga(id, ctrl.signal)
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
.then(m => {
|
||||||
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
|
if (ctrl.signal.aborted) return
|
||||||
manga = m
|
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
|
||||||
}).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false })
|
manga = m
|
||||||
}
|
seriesState.setActiveManga(m)
|
||||||
|
})
|
||||||
function loadChaptersData(id: number) {
|
.catch(() => {})
|
||||||
chapterAbort?.abort()
|
.finally(() => { if (!ctrl.signal.aborted) loadingManga = false })
|
||||||
const ctrl = new AbortController()
|
|
||||||
chapterAbort = ctrl
|
|
||||||
const cached = chapterCache.get(id)
|
|
||||||
if (cached) {
|
|
||||||
applyChapters(cached.data); loadingChapters = false
|
|
||||||
if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return
|
|
||||||
fetchChapters(id, ctrl.signal)
|
|
||||||
.then(() => getChapters(id, ctrl.signal))
|
|
||||||
.then(nodes => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
|
||||||
chapterCache.set(id, { data: nodes, fetchedAt: Date.now() })
|
|
||||||
applyChapters(nodes)
|
|
||||||
}).catch(() => {})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chapters = []; loadingChapters = true
|
|
||||||
getChapters(id, ctrl.signal).then(nodes => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
|
||||||
applyChapters(nodes); loadingChapters = false
|
|
||||||
return fetchChapters(id, ctrl.signal)
|
|
||||||
.then(() => getChapters(id, ctrl.signal))
|
|
||||||
.then(fresh => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
|
||||||
chapterCache.set(id, { data: fresh, fetchedAt: Date.now() })
|
|
||||||
applyChapters(fresh)
|
|
||||||
})
|
|
||||||
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncTrackersIntoChapters(id: number, chaps: Chapter[]) {
|
|
||||||
if (!seriesState.settings.trackerSyncBack) return
|
|
||||||
const records = trackingState.recordsFor(id)
|
|
||||||
if (!records.length) return
|
|
||||||
for (const record of records) {
|
|
||||||
try {
|
|
||||||
const { markedIds } = await trackingState.syncFromRemote(id, record, chaps, currentPrefs)
|
|
||||||
if (markedIds.length > 0) {
|
|
||||||
const idSet = new Set(markedIds)
|
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: true } : c)
|
|
||||||
chapterCache.set(id, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = mangaId
|
const id = mangaId
|
||||||
const shouldAutoLink = seriesState.settings.autoLinkOnOpen
|
const shouldAutoLink = seriesState.settings.autoLinkOnOpen
|
||||||
if (id) untrack(() => {
|
untrack(() => {
|
||||||
acknowledgeUpdate(id)
|
acknowledgeUpdate(id)
|
||||||
loadMangaData(id)
|
loadMangaData(id)
|
||||||
loadChaptersData(id)
|
seriesState.loadChapters(id).then(() => {
|
||||||
|
checkAndMarkCompleted(id, seriesState.chaptersFor(id))
|
||||||
|
trackingState.loadForManga(id).then(() => syncTrackersIntoChapters(id))
|
||||||
|
})
|
||||||
loadCategories(id)
|
loadCategories(id)
|
||||||
trackingState.loadForManga(id).then(() => syncTrackersIntoChapters(id, chapters))
|
|
||||||
if (shouldAutoLink) {
|
if (shouldAutoLink) {
|
||||||
if (allMangaForLink.length) {
|
if (allMangaForLink.length) {
|
||||||
autoLinkLibrary(manga, allMangaForLink)
|
autoLinkLibrary(manga, allMangaForLink)
|
||||||
@@ -266,10 +195,7 @@
|
|||||||
} else {
|
} else {
|
||||||
loadingLinkList = true
|
loadingLinkList = true
|
||||||
getMangaList()
|
getMangaList()
|
||||||
.then(list => {
|
.then(list => { allMangaForLink = list; return autoLinkLibrary(manga, list) })
|
||||||
allMangaForLink = list
|
|
||||||
return autoLinkLibrary(manga, list)
|
|
||||||
})
|
|
||||||
.then(n => { if (n > 0) addToast({ kind: 'success', title: 'Series linked', body: `${n} new link${n === 1 ? '' : 's'} found` }) })
|
.then(n => { if (n > 0) addToast({ kind: 'success', title: 'Series linked', body: `${n} new link${n === 1 ? '' : 's'} found` }) })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { loadingLinkList = false })
|
.finally(() => { loadingLinkList = false })
|
||||||
@@ -278,13 +204,9 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
let prevChapterId: number | null = null
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const wasOpen = prevChapterId !== null
|
const wasOpen = seriesState.activeChapter !== null
|
||||||
prevChapterId = seriesState.activeChapter?.id ?? null
|
if (!wasOpen) untrack(() => seriesState.loadChapters(mangaId, { force: true }))
|
||||||
if (wasOpen && !seriesState.activeChapter) {
|
|
||||||
untrack(() => { reloadChapters(mangaId) })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -292,12 +214,33 @@
|
|||||||
if (id === prevMangaId) return
|
if (id === prevMangaId) return
|
||||||
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop)
|
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop)
|
||||||
prevMangaId = id
|
prevMangaId = id
|
||||||
if (chapterListEl && id !== null) {
|
if (chapterListEl) chapterListEl.scrollTo({ top: getScroll(`series:${id}`) })
|
||||||
chapterListEl.scrollTo({ top: getScroll(`series:${id}`) })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => () => { mangaAbort?.abort(); chapterAbort?.abort() })
|
$effect(() => () => { mangaAbort?.abort() })
|
||||||
|
|
||||||
|
async function syncTrackersIntoChapters(id: number) {
|
||||||
|
if (!seriesState.settings.trackerSyncBack) return
|
||||||
|
const records = trackingState.recordsFor(id)
|
||||||
|
if (!records.length) return
|
||||||
|
const prefs = {
|
||||||
|
sortMode: get('sortMode'),
|
||||||
|
sortDir: get('sortDir'),
|
||||||
|
preferredScanlator: get('preferredScanlator') as string,
|
||||||
|
scanlatorFilter: scanlatorFilter,
|
||||||
|
scanlatorBlacklist: scanlatorBlacklist,
|
||||||
|
scanlatorForce: scanlatorForce,
|
||||||
|
}
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
const { markedIds } = await trackingState.syncFromRemote(id, record, seriesState.chaptersFor(id), prefs)
|
||||||
|
if (markedIds.length > 0) {
|
||||||
|
const idSet = new Set(markedIds)
|
||||||
|
seriesState.patchChapters(id, chaps => chaps.map(c => idSet.has(c.id) ? { ...c, read: true } : c))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleLibrary() {
|
async function toggleLibrary() {
|
||||||
if (!manga) return
|
if (!manga) return
|
||||||
@@ -305,23 +248,18 @@
|
|||||||
const next = !manga.inLibrary
|
const next = !manga.inLibrary
|
||||||
await updateManga(manga.id, { inLibrary: next }).catch(console.error)
|
await updateManga(manga.id, { inLibrary: next }).catch(console.error)
|
||||||
manga = { ...manga, inLibrary: next }
|
manga = { ...manga, inLibrary: next }
|
||||||
if (mangaCache.has(manga.id)) { const e = mangaCache.get(manga.id)!; mangaCache.set(manga.id, { ...e, data: manga }) }
|
seriesState.setActiveManga(manga)
|
||||||
|
if (mangaCache.has(manga.id)) mangaCache.set(manga.id, { data: manga, fetchedAt: mangaCache.get(manga.id)!.fetchedAt })
|
||||||
togglingLibrary = false
|
togglingLibrary = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadChapters(id: number) {
|
|
||||||
const nodes = await getChapters(id)
|
|
||||||
chapterCache.set(id, { data: nodes, fetchedAt: Date.now() })
|
|
||||||
applyChapters(nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueue(ch: Chapter, e: MouseEvent) {
|
async function enqueue(ch: Chapter, e: MouseEvent) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
enqueueing = new Set(enqueueing).add(ch.id)
|
enqueueing = new Set(enqueueing).add(ch.id)
|
||||||
const allowed = await downloadStore.enqueue(ch.id)
|
const allowed = await downloadStore.enqueue(ch.id)
|
||||||
if (allowed) addToast({ kind: 'download', title: 'Download queued', body: ch.name })
|
if (allowed) addToast({ kind: 'download', title: 'Download queued', body: ch.name })
|
||||||
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing)
|
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing)
|
||||||
reloadChapters(mangaId)
|
seriesState.loadChapters(mangaId, { force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
async function enqueueMultiple(chapterIds: number[]) {
|
||||||
@@ -331,26 +269,28 @@
|
|||||||
if (!allowed) return
|
if (!allowed) return
|
||||||
}
|
}
|
||||||
addToast({ kind: 'download', title: 'Download queued', body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? 's' : ''} added` })
|
addToast({ kind: 'download', title: 'Download queued', body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? 's' : ''} added` })
|
||||||
reloadChapters(mangaId)
|
seriesState.loadChapters(mangaId, { force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
async function markRead(chapterId: number, isRead: boolean) {
|
||||||
await markChapterRead(chapterId, isRead).catch(console.error)
|
await markChapterRead(chapterId, isRead).catch(console.error)
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, read: isRead } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => c.id === chapterId ? { ...c, read: isRead } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId))
|
||||||
checkAndMarkCompleted(mangaId, chapters)
|
const ch = seriesState.chaptersFor(mangaId).find(c => c.id === chapterId)
|
||||||
const ch = chapters.find(c => c.id === chapterId)
|
const currentPrefs = {
|
||||||
|
sortMode: get('sortMode'), sortDir: get('sortDir'),
|
||||||
|
preferredScanlator: get('preferredScanlator') as string,
|
||||||
|
scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
||||||
|
}
|
||||||
if (ch) {
|
if (ch) {
|
||||||
if (isRead) await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs)
|
if (isRead) await trackingState.updateFromRead(mangaId, ch, seriesState.chaptersFor(mangaId), currentPrefs)
|
||||||
else await trackingState.updateFromUnread(mangaId, chapters, currentPrefs)
|
else await trackingState.updateFromUnread(mangaId, seriesState.chaptersFor(mangaId), currentPrefs)
|
||||||
}
|
}
|
||||||
if (isRead) {
|
if (isRead) {
|
||||||
if (get('deleteOnRead')) {
|
if (get('deleteOnRead') && ch?.downloaded) {
|
||||||
if (ch?.downloaded) {
|
const delayMs = (get('deleteDelayHours') as number) * 3_600_000
|
||||||
const delayMs = (get('deleteDelayHours') as number) * 60 * 60 * 1000
|
const doDelete = () => deleteDownloaded(chapterId)
|
||||||
if (delayMs === 0) deleteDownloaded(chapterId)
|
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs)
|
||||||
else setTimeout(() => deleteDownloaded(chapterId), delayMs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const ahead = get('downloadAhead') as number
|
const ahead = get('downloadAhead') as number
|
||||||
if (ahead > 0) {
|
if (ahead > 0) {
|
||||||
@@ -367,24 +307,27 @@
|
|||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
await markChaptersRead(ids, isRead).catch(console.error)
|
await markChaptersRead(ids, isRead).catch(console.error)
|
||||||
const idSet = new Set(ids)
|
const idSet = new Set(ids)
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId))
|
||||||
checkAndMarkCompleted(mangaId, chapters)
|
const currentPrefs = {
|
||||||
|
sortMode: get('sortMode'), sortDir: get('sortDir'),
|
||||||
|
preferredScanlator: get('preferredScanlator') as string,
|
||||||
|
scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
||||||
|
}
|
||||||
if (isRead) {
|
if (isRead) {
|
||||||
const ascending = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
const chaps = seriesState.chaptersFor(mangaId)
|
||||||
const lastInBatch = ascending.filter(c => idSet.has(c.id)).at(-1)
|
const lastRead = [...chaps].sort((a, b) => a.sourceOrder - b.sourceOrder).filter(c => idSet.has(c.id)).at(-1)
|
||||||
if (lastInBatch) await trackingState.updateFromRead(mangaId, lastInBatch, chapters, currentPrefs)
|
if (lastRead) await trackingState.updateFromRead(mangaId, lastRead, chaps, currentPrefs)
|
||||||
} else {
|
} else {
|
||||||
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs)
|
await trackingState.updateFromUnread(mangaId, seriesState.chaptersFor(mangaId), currentPrefs)
|
||||||
}
|
}
|
||||||
if (isRead && get('deleteOnRead')) {
|
if (isRead && get('deleteOnRead')) {
|
||||||
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.downloaded)
|
const toDelete = ids.filter(id => seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded)
|
||||||
if (toDelete.length) {
|
if (toDelete.length) {
|
||||||
const delayMs = (get('deleteDelayHours') as number) * 60 * 60 * 1000
|
const delayMs = (get('deleteDelayHours') as number) * 3_600_000
|
||||||
const doDelete = async () => {
|
const doDelete = async () => {
|
||||||
await deleteDownloadedChapters(toDelete).catch(console.error)
|
await deleteDownloadedChapters(toDelete).catch(console.error)
|
||||||
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, downloaded: false } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => toDelete.includes(c.id) ? { ...c, downloaded: false } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
}
|
}
|
||||||
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs)
|
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs)
|
||||||
}
|
}
|
||||||
@@ -392,17 +335,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.downloaded)
|
const ids = [...selectedIds].filter(id => seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded)
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
await deleteDownloadedChapters(ids).catch(console.error)
|
await deleteDownloadedChapters(ids).catch(console.error)
|
||||||
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, downloaded: false } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => ids.includes(c.id) ? { ...c, downloaded: false } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
}
|
}
|
||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadSelected() {
|
async function downloadSelected() {
|
||||||
await enqueueMultiple([...selectedIds].filter(id => !chapters.find(c => c.id === id)?.downloaded))
|
await enqueueMultiple([...selectedIds].filter(id => !seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded))
|
||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,29 +360,30 @@
|
|||||||
|
|
||||||
async function deleteDownloaded(chapterId: number) {
|
async function deleteDownloaded(chapterId: number) {
|
||||||
await deleteDownloadedChapters([chapterId]).catch(console.error)
|
await deleteDownloadedChapters([chapterId]).catch(console.error)
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, downloaded: false } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => c.id === chapterId ? { ...c, downloaded: false } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
async function deleteAllDownloads() {
|
||||||
const ids = chapters.filter(c => c.downloaded).map(c => c.id)
|
const ids = seriesState.chaptersFor(mangaId).filter(c => c.downloaded).map(c => c.id)
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
deletingAll = true
|
deletingAll = true
|
||||||
await deleteDownloadedChapters(ids).catch(console.error)
|
await deleteDownloadedChapters(ids).catch(console.error)
|
||||||
chapters = chapters.map(c => ({ ...c, downloaded: false }))
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => ({ ...c, downloaded: false })))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
deletingAll = false
|
deletingAll = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshChapters() {
|
async function refreshChapters() {
|
||||||
if (refreshing) return
|
if (refreshing) return
|
||||||
refreshing = true
|
refreshing = true
|
||||||
chapterCache.delete(mangaId)
|
seriesState.invalidateChapters(mangaId)
|
||||||
fetchChapters(mangaId)
|
fetchChapters(mangaId)
|
||||||
.then(() => reloadChapters(mangaId))
|
.then(() => seriesState.loadChapters(mangaId, { force: true }))
|
||||||
.then(() => addToast({ kind: 'success', title: 'Chapters refreshed', body: `${chapters.length} chapter${chapters.length !== 1 ? 's' : ''} available` }))
|
.then(() => {
|
||||||
|
const count = seriesState.chaptersFor(mangaId).length
|
||||||
|
addToast({ kind: 'success', title: 'Chapters refreshed', body: `${count} chapter${count !== 1 ? 's' : ''} available` })
|
||||||
|
})
|
||||||
.catch(e => addToast({ kind: 'error', title: 'Refresh failed', body: e?.message }))
|
.catch(e => addToast({ kind: 'error', title: 'Refresh failed', body: e?.message }))
|
||||||
.finally(() => refreshing = false)
|
.finally(() => { refreshing = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
|
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
|
||||||
@@ -472,43 +415,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openReaderWithAhead(ch: Chapter, inProgress: boolean) {
|
function openReaderWithAhead(ch: Chapter, inProgress: boolean) {
|
||||||
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
if (inProgress && ch.lastPageRead && ch.lastPageRead > 1) {
|
||||||
const resumePage = inProgress ? ch.lastPageRead ?? null : null
|
|
||||||
const ahead = get('downloadAhead') as number
|
|
||||||
if (ahead > 0) {
|
|
||||||
const idx = ascList.indexOf(ch)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.downloaded).map(c => c.id)
|
|
||||||
if (toQueue.length) enqueueMultiple(toQueue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (inProgress && resumePage && resumePage > 1) {
|
|
||||||
const existing = seriesState.bookmarks.find(b => b.chapterId === ch.id)
|
const existing = seriesState.bookmarks.find(b => b.chapterId === ch.id)
|
||||||
if (!existing || existing.pageNumber < resumePage) {
|
if (!existing || existing.pageNumber < ch.lastPageRead) {
|
||||||
addBookmark({
|
addBookmark({
|
||||||
mangaId,
|
mangaId,
|
||||||
mangaTitle: manga!.title,
|
mangaTitle: manga!.title,
|
||||||
thumbnailUrl: manga!.thumbnailUrl,
|
thumbnailUrl: manga!.thumbnailUrl,
|
||||||
chapterId: ch.id,
|
chapterId: ch.id,
|
||||||
chapterName: ch.name,
|
chapterName: ch.name,
|
||||||
pageNumber: resumePage,
|
pageNumber: ch.lastPageRead,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
openReader(ch, ascList, manga)
|
openReaderForChapter(ch, manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContinue(cc: typeof continueChapter) {
|
function handleContinue(cc: typeof continueChapter) {
|
||||||
if (!cc) return
|
if (!cc) return
|
||||||
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
|
||||||
const ahead = get('downloadAhead') as number
|
|
||||||
if (ahead > 0) {
|
|
||||||
const idx = ascList.indexOf(cc.chapter)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.downloaded).map(c => c.id)
|
|
||||||
if (toQueue.length) enqueueMultiple(toQueue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cc.type === 'continue' && cc.resumePage && cc.resumePage > 1) {
|
if (cc.type === 'continue' && cc.resumePage && cc.resumePage > 1) {
|
||||||
const existing = seriesState.bookmarks.find(b => b.chapterId === cc.chapter.id)
|
const existing = seriesState.bookmarks.find(b => b.chapterId === cc.chapter.id)
|
||||||
if (!existing || existing.pageNumber < cc.resumePage) {
|
if (!existing || existing.pageNumber < cc.resumePage) {
|
||||||
@@ -522,7 +446,7 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
openReader(cc.chapter, ascList, manga)
|
openReaderForChapter(cc.chapter, manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openLinkPicker() {
|
async function openLinkPicker() {
|
||||||
@@ -551,7 +475,7 @@
|
|||||||
await updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : [])
|
await updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : [])
|
||||||
if (!inCat && !manga?.inLibrary) {
|
if (!inCat && !manga?.inLibrary) {
|
||||||
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
||||||
if (manga) manga = { ...manga, inLibrary: true }
|
if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) }
|
||||||
}
|
}
|
||||||
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat]
|
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat]
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
@@ -564,7 +488,7 @@
|
|||||||
await updateMangaCategories(mangaId, [cat.id], [])
|
await updateMangaCategories(mangaId, [cat.id], [])
|
||||||
if (!manga?.inLibrary) {
|
if (!manga?.inLibrary) {
|
||||||
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
||||||
if (manga) manga = { ...manga, inLibrary: true }
|
if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) }
|
||||||
}
|
}
|
||||||
allCategories = [...allCategories, cat]
|
allCategories = [...allCategories, cat]
|
||||||
mangaCategories = [...mangaCategories, cat]
|
mangaCategories = [...mangaCategories, cat]
|
||||||
@@ -606,8 +530,8 @@
|
|||||||
<SeriesActions
|
<SeriesActions
|
||||||
{chapters}
|
{chapters}
|
||||||
{sortedChapters}
|
{sortedChapters}
|
||||||
{sortMode}
|
sortMode={get('sortMode')}
|
||||||
{sortDir}
|
sortDir={get('sortDir')}
|
||||||
{viewMode}
|
{viewMode}
|
||||||
{chapterPage}
|
{chapterPage}
|
||||||
{totalPages}
|
{totalPages}
|
||||||
@@ -640,6 +564,8 @@
|
|||||||
onSetScanlatorFilter={(v) => set('scanlatorFilter', v)}
|
onSetScanlatorFilter={(v) => set('scanlatorFilter', v)}
|
||||||
onSetScanlatorBlacklist={(v) => set('scanlatorBlacklist', v)}
|
onSetScanlatorBlacklist={(v) => set('scanlatorBlacklist', v)}
|
||||||
onSetScanlatorForce={(v) => set('scanlatorForce', v)}
|
onSetScanlatorForce={(v) => set('scanlatorForce', v)}
|
||||||
|
onSortModeChange={(v) => set('sortMode', v)}
|
||||||
|
onSortDirChange={(v) => set('sortDir', v)}
|
||||||
onOpenFolder={() => manga && openMangaFolder(manga)}
|
onOpenFolder={() => manga && openMangaFolder(manga)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -666,7 +592,7 @@
|
|||||||
{#if markersOpen && manga}
|
{#if markersOpen && manga}
|
||||||
<div class="panel-overlay" role="presentation" onclick={() => markersOpen = false}>
|
<div class="panel-overlay" role="presentation" onclick={() => markersOpen = false}>
|
||||||
<div class="panel-drawer" role="presentation" onclick={(e) => e.stopPropagation()}>
|
<div class="panel-drawer" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||||
<MarkersPanel mangaId={manga.id} {chapters} onClose={() => markersOpen = false} />
|
<MarkersPanel mangaId={manga.id} chapters={seriesState.chaptersFor(manga.id)} onClose={() => markersOpen = false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -702,7 +628,7 @@
|
|||||||
{#if migrateOpen && manga}
|
{#if migrateOpen && manga}
|
||||||
<MigrateModal
|
<MigrateModal
|
||||||
{manga}
|
{manga}
|
||||||
currentChapters={chapters}
|
currentChapters={seriesState.chaptersFor(manga.id)}
|
||||||
onClose={() => migrateOpen = false}
|
onClose={() => migrateOpen = false}
|
||||||
onMigrated={(newManga) => { goto(`/series/${newManga.id}`); migrateOpen = false }}
|
onMigrated={(newManga) => { goto(`/series/${newManga.id}`); migrateOpen = false }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -64,16 +64,4 @@ export function buildChapterList(chapters: Chapter[], prefs: ChapterDisplayPrefs
|
|||||||
|
|
||||||
export function chaptersAscending(chapters: Chapter[]): Chapter[] {
|
export function chaptersAscending(chapters: Chapter[]): Chapter[] {
|
||||||
return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
||||||
}
|
|
||||||
|
|
||||||
export function buildReaderChapterList(
|
|
||||||
chapters: Chapter[],
|
|
||||||
prefs: Pick<ChapterDisplayPrefs, 'preferredScanlator' | 'scanlatorFilter'> | undefined,
|
|
||||||
): Chapter[] {
|
|
||||||
return buildChapterList(chapters, {
|
|
||||||
sortMode: 'source',
|
|
||||||
sortDir: 'asc',
|
|
||||||
preferredScanlator: prefs?.preferredScanlator,
|
|
||||||
scanlatorFilter: prefs?.scanlatorFilter,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
|
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
|
||||||
import { seriesState, updateMarker, removeMarker, openReader } from "$lib/state/series.svelte";
|
import { seriesState, updateMarker, removeMarker, openReaderForChapter } from "$lib/state/series.svelte";
|
||||||
import type { MarkerEntry, MarkerColor } from "$lib/types/history";
|
import type { MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||||
import type { Chapter } from "$lib/types";
|
import type { Chapter } from "$lib/types";
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
const chapter = chapters.find(c => c.id === m.chapterId);
|
const chapter = chapters.find(c => c.id === m.chapterId);
|
||||||
if (!chapter) return;
|
if (!chapter) return;
|
||||||
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
openReader(chapter, chaptersAsc);
|
openReaderForChapter(chapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
function formatDate(ts: number): string {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy, untrack } from "svelte";
|
||||||
import {
|
import {
|
||||||
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
|
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
|
||||||
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
|
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
|
||||||
@@ -16,17 +16,13 @@
|
|||||||
import { addToast } from "$lib/state/notifications.svelte";
|
import { addToast } from "$lib/state/notifications.svelte";
|
||||||
import {
|
import {
|
||||||
seriesState,
|
seriesState,
|
||||||
setPreviewManga, setActiveManga, openReader, addBookmark,
|
setPreviewManga, addBookmark, openReaderForChapter,
|
||||||
} from "$lib/state/series.svelte";
|
} from "$lib/state/series.svelte";
|
||||||
import { app } from "$lib/state/app.svelte";
|
|
||||||
import type { Manga, Chapter, Category } from "$lib/types";
|
import type { Manga, Chapter, Category } from "$lib/types";
|
||||||
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
|
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte';
|
||||||
|
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
let manga: Manga | null = $state(null);
|
||||||
let chapters: Chapter[] = $state([]);
|
|
||||||
let loadingDetail = $state(false);
|
let loadingDetail = $state(false);
|
||||||
let loadingChapters = $state(false);
|
|
||||||
let togglingLib = $state(false);
|
let togglingLib = $state(false);
|
||||||
let descExpanded = $state(false);
|
let descExpanded = $state(false);
|
||||||
let folderOpen = $state(false);
|
let folderOpen = $state(false);
|
||||||
@@ -44,8 +40,6 @@
|
|||||||
let loadingLinkList = $state(false);
|
let loadingLinkList = $state(false);
|
||||||
let coverPickerOpen = $state(false);
|
let coverPickerOpen = $state(false);
|
||||||
|
|
||||||
let originNavPage = app.navPage;
|
|
||||||
|
|
||||||
const linkedIds = $derived(
|
const linkedIds = $derived(
|
||||||
seriesState.previewManga
|
seriesState.previewManga
|
||||||
? (settingsState.settings.mangaLinks?.[seriesState.previewManga.id] ?? [])
|
? (settingsState.settings.mangaLinks?.[seriesState.previewManga.id] ?? [])
|
||||||
@@ -57,6 +51,9 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const displayManga = $derived(manga ?? seriesState.previewManga);
|
const displayManga = $derived(manga ?? seriesState.previewManga);
|
||||||
|
const mangaId = $derived(seriesState.previewManga?.id ?? null);
|
||||||
|
const chapters = $derived(mangaId != null ? seriesState.chaptersFor(mangaId) : []);
|
||||||
|
const loadingChapters = $derived(mangaId != null ? seriesState.isLoadingChapters(mangaId) : false);
|
||||||
const totalCount = $derived(chapters.length);
|
const totalCount = $derived(chapters.length);
|
||||||
const readCount = $derived(chapters.filter((c) => c.read).length);
|
const readCount = $derived(chapters.filter((c) => c.read).length);
|
||||||
const unreadCount = $derived(totalCount - readCount);
|
const unreadCount = $derived(totalCount - readCount);
|
||||||
@@ -113,16 +110,12 @@
|
|||||||
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
|
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let detailAbort: AbortController | null = null;
|
||||||
let detailAbort: AbortController | null = null;
|
|
||||||
let chapterAbort: AbortController | null = null;
|
|
||||||
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
detailAbort?.abort();
|
detailAbort?.abort();
|
||||||
chapterAbort?.abort();
|
|
||||||
setPreviewManga(null);
|
setPreviewManga(null);
|
||||||
manga = null; chapters = []; descExpanded = false;
|
manga = null; descExpanded = false;
|
||||||
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,12 +146,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const shouldAutoLink = settingsState.settings.autoLinkOnOpen;
|
|
||||||
const focal = seriesState.previewManga;
|
const focal = seriesState.previewManga;
|
||||||
if (focal) {
|
if (!focal) return;
|
||||||
originNavPage = app.navPage;
|
|
||||||
load(focal.id);
|
untrack(() => {
|
||||||
|
loadDetail(focal.id);
|
||||||
|
seriesState.loadChapters(focal.id);
|
||||||
loadCategories(focal.id);
|
loadCategories(focal.id);
|
||||||
|
|
||||||
|
const shouldAutoLink = settingsState.settings.autoLinkOnOpen;
|
||||||
if (shouldAutoLink) {
|
if (shouldAutoLink) {
|
||||||
if (allMangaForLink.length) {
|
if (allMangaForLink.length) {
|
||||||
autoLinkLibrary(focal, allMangaForLink)
|
autoLinkLibrary(focal, allMangaForLink)
|
||||||
@@ -166,71 +162,48 @@
|
|||||||
} else {
|
} else {
|
||||||
loadingLinkList = true;
|
loadingLinkList = true;
|
||||||
getAdapter().getMangaList({})
|
getAdapter().getMangaList({})
|
||||||
.then((d) => {
|
.then((d) => { allMangaForLink = d.items; return autoLinkLibrary(focal, d.items); })
|
||||||
allMangaForLink = d.items;
|
|
||||||
return autoLinkLibrary(focal, d.items);
|
|
||||||
})
|
|
||||||
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
|
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { loadingLinkList = false; });
|
.finally(() => { loadingLinkList = false; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(id: number) {
|
async function loadDetail(id: number) {
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
detailAbort?.abort();
|
||||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
detailAbort = ctrl;
|
||||||
manga = seriesState.previewManga as Manga;
|
manga = seriesState.previewManga as Manga;
|
||||||
chapters = []; descExpanded = false; fetchError = null;
|
descExpanded = false; fetchError = null;
|
||||||
loadingDetail = true; loadingChapters = true;
|
loadingDetail = true;
|
||||||
|
|
||||||
(async (): Promise<Manga> => {
|
const key = CACHE_KEYS.MANGA(id);
|
||||||
const key = CACHE_KEYS.MANGA(id);
|
try {
|
||||||
if (cache.has(key)) return cache.get(key, () => Promise.resolve(seriesState.previewManga as Manga)) as Promise<Manga>;
|
let fullManga: Manga;
|
||||||
try {
|
if (cache.has(key)) {
|
||||||
return await getAdapter().fetchManga(String(id), dCtrl.signal);
|
fullManga = await (cache.get(key, () => Promise.resolve(seriesState.previewManga as Manga)) as Promise<Manga>);
|
||||||
} catch (e: any) {
|
} else {
|
||||||
if (e?.name === "AbortError") throw e;
|
try {
|
||||||
const local = await getAdapter().getManga(String(id), dCtrl.signal);
|
fullManga = await getAdapter().fetchManga(String(id), ctrl.signal);
|
||||||
if (local) return local;
|
} catch (e: any) {
|
||||||
throw new Error("Could not load manga details");
|
if (e?.name === "AbortError") return;
|
||||||
|
const local = await getAdapter().getManga(String(id), ctrl.signal);
|
||||||
|
if (local) fullManga = local;
|
||||||
|
else throw new Error("Could not load manga details");
|
||||||
|
}
|
||||||
|
if (!cache.has(key)) cache.get(key, () => Promise.resolve(fullManga));
|
||||||
}
|
}
|
||||||
})()
|
if (ctrl.signal.aborted) return;
|
||||||
.then((fullManga) => {
|
manga = fullManga;
|
||||||
if (dCtrl.signal.aborted) return;
|
} catch (e: any) {
|
||||||
if (!cache.has(CACHE_KEYS.MANGA(id)))
|
if (e?.name === "AbortError") return;
|
||||||
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
manga = seriesState.previewManga as Manga;
|
||||||
manga = fullManga; loadingDetail = false;
|
fetchError = "Could not load full details — showing cached data";
|
||||||
})
|
} finally {
|
||||||
.catch((e) => {
|
if (!ctrl.signal.aborted) loadingDetail = false;
|
||||||
if (e?.name === "AbortError") return;
|
}
|
||||||
manga = seriesState.previewManga as Manga;
|
|
||||||
fetchError = "Could not load full details — showing cached data";
|
|
||||||
loadingDetail = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
getAdapter().getChapters(String(id), cCtrl.signal)
|
|
||||||
.then(async (nodes) => {
|
|
||||||
if (cCtrl.signal.aborted) return;
|
|
||||||
let sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
if (sorted.length === 0) {
|
|
||||||
try {
|
|
||||||
const fetched = await getAdapter().fetchChapters(String(id), cCtrl.signal);
|
|
||||||
if (!cCtrl.signal.aborted)
|
|
||||||
sorted = [...fetched].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!cCtrl.signal.aborted) {
|
|
||||||
chapters = sorted;
|
|
||||||
if (sorted.length > 0) checkAndMarkCompleted(id, sorted);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleLibrary() {
|
async function toggleLibrary() {
|
||||||
@@ -258,58 +231,68 @@
|
|||||||
|
|
||||||
function openSeriesDetail() {
|
function openSeriesDetail() {
|
||||||
if (!displayManga) return;
|
if (!displayManga) return;
|
||||||
setActiveManga(displayManga);
|
goto(`/series/${displayManga.id}`);
|
||||||
app.setNavPage(originNavPage);
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCategories(mangaId: number) {
|
function handleRead() {
|
||||||
|
if (!continueChapter || !displayManga) return;
|
||||||
|
const { ch, type, resumePage } = continueChapter;
|
||||||
|
if (type === "continue" && resumePage && resumePage > 1) {
|
||||||
|
const existing = seriesState.bookmarks.find((b) => b.chapterId === ch.id);
|
||||||
|
if (!existing || existing.pageNumber < resumePage) {
|
||||||
|
addBookmark({
|
||||||
|
mangaId: displayManga.id,
|
||||||
|
mangaTitle: displayManga.title,
|
||||||
|
thumbnailUrl: displayManga.thumbnailUrl,
|
||||||
|
chapterId: ch.id,
|
||||||
|
chapterName: ch.name,
|
||||||
|
pageNumber: resumePage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openReaderForChapter(ch, displayManga);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCategories(id: number) {
|
||||||
catsLoading = true;
|
catsLoading = true;
|
||||||
getAdapter().getCategories()
|
getAdapter().getCategories()
|
||||||
.then((cats) => {
|
.then((cats) => {
|
||||||
allCategories = cats.filter((c) => c.id !== 0);
|
allCategories = cats.filter((c) => c.id !== 0);
|
||||||
mangaCategories = allCategories.filter((c) => c.mangas?.nodes?.some((m) => m.id === mangaId));
|
mangaCategories = allCategories.filter((c) => c.mangas?.nodes?.some((m) => m.id === id));
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { catsLoading = false; });
|
.finally(() => { catsLoading = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
|
||||||
const mangaStatus = (manga ?? displayManga)?.status;
|
const isOngoing = (manga ?? displayManga)?.status === "ONGOING";
|
||||||
const isOngoing = mangaStatus === "ONGOING";
|
|
||||||
if (!chaps.length || isOngoing) return;
|
if (!chaps.length || isOngoing) return;
|
||||||
|
|
||||||
const allRead = chaps.every((c) => c.read);
|
const allRead = chaps.every((c) => c.read);
|
||||||
const completed = allCategories.find((c) => c.name === "Completed");
|
const completed = allCategories.find((c) => c.name === "Completed");
|
||||||
if (!completed) return;
|
if (!completed) return;
|
||||||
|
|
||||||
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
|
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
|
||||||
if (allRead && !inCompleted) {
|
if (allRead && !inCompleted) {
|
||||||
await getAdapter().updateMangaCategories(String(mangaId), [completed.id], []).catch(console.error);
|
await getAdapter().updateMangaCategories(String(id), [completed.id], []).catch(console.error);
|
||||||
mangaCategories = [...mangaCategories, completed];
|
mangaCategories = [...mangaCategories, completed];
|
||||||
} else if (!allRead && inCompleted) {
|
} else if (!allRead && inCompleted) {
|
||||||
await getAdapter().updateMangaCategories(String(mangaId), [], [completed.id]).catch(console.error);
|
await getAdapter().updateMangaCategories(String(id), [], [completed.id]).catch(console.error);
|
||||||
mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
|
mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleCategory(cat: Category) {
|
async function toggleCategory(cat: Category) {
|
||||||
if (!seriesState.previewManga) return;
|
if (!seriesState.previewManga) return;
|
||||||
const mangaId = seriesState.previewManga.id;
|
const id = seriesState.previewManga.id;
|
||||||
const inCat = mangaCategories.some((c) => c.id === cat.id);
|
const inCat = mangaCategories.some((c) => c.id === cat.id);
|
||||||
await getAdapter().updateMangaCategories(
|
await getAdapter().updateMangaCategories(String(id), inCat ? [] : [cat.id], inCat ? [cat.id] : []).catch(console.error);
|
||||||
String(mangaId),
|
|
||||||
inCat ? [] : [cat.id],
|
|
||||||
inCat ? [cat.id] : [],
|
|
||||||
).catch(console.error);
|
|
||||||
if (!inCat && !inLibrary) {
|
if (!inCat && !inLibrary) {
|
||||||
await getAdapter().addToLibrary(String(mangaId)).catch(console.error);
|
await getAdapter().addToLibrary(String(id)).catch(console.error);
|
||||||
if (manga) manga = { ...manga, inLibrary: true };
|
if (manga) manga = { ...manga, inLibrary: true };
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
}
|
}
|
||||||
mangaCategories = inCat
|
mangaCategories = inCat ? mangaCategories.filter((c) => c.id !== cat.id) : [...mangaCategories, cat];
|
||||||
? mangaCategories.filter((c) => c.id !== cat.id)
|
|
||||||
: [...mangaCategories, cat];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFolderCreate() {
|
async function handleFolderCreate() {
|
||||||
@@ -349,7 +332,6 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
detailAbort?.abort();
|
detailAbort?.abort();
|
||||||
chapterAbort?.abort();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -548,24 +530,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if continueChapter}
|
{#if continueChapter}
|
||||||
<button class="read-btn" onclick={() => {
|
<button class="read-btn" onclick={handleRead}>
|
||||||
const { ch, type, resumePage } = continueChapter!;
|
|
||||||
if (type === "continue" && resumePage && resumePage > 1) {
|
|
||||||
const existing = seriesState.bookmarks.find((b) => b.chapterId === ch.id);
|
|
||||||
if (!existing || existing.pageNumber < resumePage) {
|
|
||||||
addBookmark({
|
|
||||||
mangaId: displayManga!.id,
|
|
||||||
mangaTitle: displayManga!.title,
|
|
||||||
thumbnailUrl: displayManga!.thumbnailUrl,
|
|
||||||
chapterId: ch.id,
|
|
||||||
chapterName: ch.name,
|
|
||||||
pageNumber: resumePage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
openReader(ch, chapters, displayManga);
|
|
||||||
close();
|
|
||||||
}}>
|
|
||||||
<Play size={12} weight="fill" />{continueLabel}
|
<Play size={12} weight="fill" />{continueLabel}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -676,8 +641,6 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
|
|||||||
Vendored
+4
@@ -97,6 +97,10 @@ export function clearResolvedUrlCache(): void {
|
|||||||
aspectCache.clear();
|
aspectCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCachedAspect(url: string): number | undefined {
|
||||||
|
return aspectCache.get(url);
|
||||||
|
}
|
||||||
|
|
||||||
export function clearPageCache(chapterId?: number): void {
|
export function clearPageCache(chapterId?: number): void {
|
||||||
if (chapterId !== undefined) {
|
if (chapterId !== undefined) {
|
||||||
pageCache.delete(chapterId);
|
pageCache.delete(chapterId);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import { seriesState } from "$lib/state/series.svelte";
|
|
||||||
import { readerState } from "$lib/state/reader.svelte";
|
|
||||||
import type { Chapter } from "$lib/types";
|
import type { Chapter } from "$lib/types";
|
||||||
|
|
||||||
export async function getChapters(mangaId: number, signal?: AbortSignal): Promise<Chapter[]> {
|
export async function getChapters(mangaId: number, signal?: AbortSignal): Promise<Chapter[]> {
|
||||||
@@ -11,78 +9,25 @@ export async function fetchChapters(mangaId: number, signal?: AbortSignal): Prom
|
|||||||
return getAdapter().fetchChapters(String(mangaId), signal);
|
return getAdapter().fetchChapters(String(mangaId), signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadChapters(mangaId: string) {
|
export async function markChapterRead(id: number, read: boolean): Promise<void> {
|
||||||
seriesState.chaptersLoading = true;
|
|
||||||
seriesState.chaptersError = null;
|
|
||||||
try {
|
|
||||||
seriesState.chapters = await getAdapter().getChapters(mangaId);
|
|
||||||
} catch (e) {
|
|
||||||
seriesState.chaptersError = String(e);
|
|
||||||
} finally {
|
|
||||||
seriesState.chaptersLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadChapterPages(chapterId: string, signal?: AbortSignal) {
|
|
||||||
readerState.pagesLoading = true;
|
|
||||||
readerState.pagesError = null;
|
|
||||||
try {
|
|
||||||
readerState.pages = await getAdapter().getChapterPages(chapterId, signal);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof DOMException && e.name === "AbortError") return;
|
|
||||||
readerState.pagesError = String(e);
|
|
||||||
} finally {
|
|
||||||
readerState.pagesLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markChapterRead(id: number, read: boolean) {
|
|
||||||
await getAdapter().markChapterRead(String(id), read);
|
await getAdapter().markChapterRead(String(id), read);
|
||||||
const chapter = seriesState.chapters.find(c => c.id === id);
|
|
||||||
if (chapter) chapter.read = read;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markChaptersRead(ids: number[], read: boolean) {
|
export async function markChaptersRead(ids: number[], read: boolean): Promise<void> {
|
||||||
await getAdapter().markChaptersRead(ids.map(String), read);
|
await getAdapter().markChaptersRead(ids.map(String), read);
|
||||||
const idSet = new Set(ids);
|
|
||||||
for (const c of seriesState.chapters) {
|
|
||||||
if (idSet.has(c.id)) c.read = read;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markRead(id: string, read: boolean) {
|
export async function markManyRead(ids: string[], read: boolean): Promise<void> {
|
||||||
await getAdapter().markChapterRead(id, read);
|
|
||||||
const numId = Number(id);
|
|
||||||
const chapter = seriesState.chapters.find(c => c.id === numId);
|
|
||||||
if (chapter) chapter.read = read;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markManyRead(ids: string[], read: boolean) {
|
|
||||||
await getAdapter().markChaptersRead(ids, read);
|
await getAdapter().markChaptersRead(ids, read);
|
||||||
const numIds = new Set(ids.map(Number));
|
|
||||||
for (const c of seriesState.chapters) {
|
|
||||||
if (numIds.has(c.id)) c.read = read;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateChaptersProgress(
|
export async function updateChaptersProgress(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number },
|
patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number },
|
||||||
) {
|
): Promise<void> {
|
||||||
await getAdapter().updateChaptersProgress(ids, patch);
|
await getAdapter().updateChaptersProgress(ids, patch);
|
||||||
const numIds = new Set(ids.map(Number));
|
|
||||||
for (const c of seriesState.chapters) {
|
|
||||||
if (!numIds.has(c.id)) continue;
|
|
||||||
if (patch.isRead !== undefined) c.read = patch.isRead;
|
|
||||||
if (patch.isBookmarked !== undefined) c.bookmarked = patch.isBookmarked;
|
|
||||||
if (patch.lastPageRead !== undefined) c.lastPageRead = patch.lastPageRead;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDownloadedChapters(ids: number[]) {
|
export async function deleteDownloadedChapters(ids: number[]): Promise<void> {
|
||||||
await getAdapter().deleteDownloadedChapters(ids.map(String));
|
await getAdapter().deleteDownloadedChapters(ids.map(String));
|
||||||
const idSet = new Set(ids);
|
|
||||||
for (const c of seriesState.chapters) {
|
|
||||||
if (idSet.has(c.id)) c.downloaded = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { Manga, Chapter } from "$lib/types";
|
|||||||
import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||||
import type { MangaPrefs, ReaderSettings, ReaderPreset } from "$lib/types/settings";
|
import type { MangaPrefs, ReaderSettings, ReaderPreset } from "$lib/types/settings";
|
||||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
|
import { seriesState } from "$lib/state/series.svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
export const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
|
export const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
|
||||||
@@ -30,9 +31,12 @@ export interface StripChapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ReaderState {
|
class ReaderState {
|
||||||
activeManga = $state<Manga | null>(null);
|
get activeManga() { return seriesState.activeManga; }
|
||||||
activeChapter = $state<Chapter | null>(null);
|
set activeManga(v: Manga | null) { seriesState.activeManga = v; }
|
||||||
activeChapterList = $state<Chapter[]>([]);
|
|
||||||
|
get activeChapter() { return seriesState.activeChapter; }
|
||||||
|
set activeChapter(v: Chapter | null){ seriesState.activeChapter = v; }
|
||||||
|
|
||||||
pageUrls = $state<string[]>([]);
|
pageUrls = $state<string[]>([]);
|
||||||
pageNumber = $state(1);
|
pageNumber = $state(1);
|
||||||
bookmarks = $state<BookmarkEntry[]>([]);
|
bookmarks = $state<BookmarkEntry[]>([]);
|
||||||
@@ -77,19 +81,19 @@ class ReaderState {
|
|||||||
|
|
||||||
containerWidth = $state(0);
|
containerWidth = $state(0);
|
||||||
|
|
||||||
|
readonly activeChapterList = $derived(seriesState.readerChapterList);
|
||||||
|
|
||||||
get settings() { return settingsState.settings; }
|
get settings() { return settingsState.settings; }
|
||||||
|
|
||||||
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
openReader(chapter: Chapter, manga?: Manga | null) {
|
||||||
const isChapterNav = this.activeChapter !== null;
|
const isChapterNav = this.activeChapter !== null;
|
||||||
this.activeChapter = chapter;
|
this.activeChapter = chapter;
|
||||||
this.activeChapterList = chapterList;
|
|
||||||
if (manga !== undefined) this.activeManga = manga;
|
if (manga !== undefined) this.activeManga = manga;
|
||||||
goto(`/reader/${this.activeManga!.id}/${chapter.id}`, { replaceState: isChapterNav });
|
goto(`/reader/${this.activeManga!.id}/${chapter.id}`, { replaceState: isChapterNav });
|
||||||
}
|
}
|
||||||
|
|
||||||
closeReader() {
|
closeReader() {
|
||||||
this.activeChapter = null;
|
this.activeChapter = null;
|
||||||
this.activeChapterList = [];
|
|
||||||
history.back();
|
history.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,5 +228,5 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
|||||||
|
|
||||||
export const readerState = new ReaderState();
|
export const readerState = new ReaderState();
|
||||||
|
|
||||||
export function openReader(ch: Chapter, list: Chapter[], manga?: Manga | null) { readerState.openReader(ch, list, manga); }
|
export function openReader(ch: Chapter, manga?: Manga | null) { readerState.openReader(ch, manga); }
|
||||||
export function closeReader() { readerState.closeReader(); }
|
export function closeReader() { readerState.closeReader(); }
|
||||||
+220
-117
@@ -1,99 +1,18 @@
|
|||||||
import type { Manga, Chapter } from "$lib/types";
|
import type { Manga, Chapter } from '$lib/types'
|
||||||
import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
import type { BookmarkEntry, MarkerEntry, MarkerColor } from '$lib/types/history'
|
||||||
import type { MangaPrefs } from "$lib/types/settings";
|
import type { MangaPrefs } from '$lib/types/settings'
|
||||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { goto } from "$app/navigation";
|
import { getAdapter } from '$lib/request-manager'
|
||||||
|
import { buildChapterList } from '$lib/components/series/lib/chapterList'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
export type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
export type { BookmarkEntry, MarkerEntry, MarkerColor } from '$lib/types/history'
|
||||||
export type { MangaPrefs } from "$lib/types/settings";
|
export type { MangaPrefs } from '$lib/types/settings'
|
||||||
|
|
||||||
class SeriesStore {
|
|
||||||
current = $state<Manga | null>(null);
|
|
||||||
loading = $state(false);
|
|
||||||
error = $state<string | null>(null);
|
|
||||||
|
|
||||||
chapters = $state<Chapter[]>([]);
|
|
||||||
chaptersLoading = $state(false);
|
|
||||||
chaptersError = $state<string | null>(null);
|
|
||||||
|
|
||||||
activeMangaId = $state<number | null>(null);
|
|
||||||
activeManga = $state<Manga | null>(null);
|
|
||||||
previewManga = $state<Manga | null>(null);
|
|
||||||
activeChapter = $state<Chapter | null>(null);
|
|
||||||
activeChapterList = $state<Chapter[]>([]);
|
|
||||||
bookmarks = $state<BookmarkEntry[]>([]);
|
|
||||||
markers = $state<MarkerEntry[]>([]);
|
|
||||||
acknowledgedUpdates = $state<Set<number>>(new Set());
|
|
||||||
|
|
||||||
setActiveMangaId(next: number | null) { this.activeMangaId = next; }
|
|
||||||
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
|
||||||
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
|
||||||
|
|
||||||
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
|
||||||
this.activeChapter = chapter;
|
|
||||||
this.activeChapterList = chapterList;
|
|
||||||
if (manga !== undefined) this.activeManga = manga;
|
|
||||||
goto(`/reader/${this.activeManga!.id}/${chapter.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeReader() {
|
|
||||||
this.activeChapter = null;
|
|
||||||
this.activeChapterList = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
acknowledgeUpdate(mangaId: number) {
|
|
||||||
if (this.acknowledgedUpdates.has(mangaId)) return;
|
|
||||||
this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
|
||||||
this.bookmarks = [
|
|
||||||
{ ...entry, savedAt: Date.now(), label },
|
|
||||||
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
|
|
||||||
].slice(0, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId); }
|
|
||||||
clearBookmarks() { this.bookmarks = []; }
|
|
||||||
getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId); }
|
|
||||||
|
|
||||||
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
|
|
||||||
const id = Math.random().toString(36).slice(2);
|
|
||||||
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
|
|
||||||
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id); }
|
|
||||||
getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page); }
|
|
||||||
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); }
|
|
||||||
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); }
|
|
||||||
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); }
|
|
||||||
|
|
||||||
getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
|
|
||||||
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {};
|
|
||||||
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
|
||||||
}
|
|
||||||
|
|
||||||
setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
|
|
||||||
updateSettings({
|
|
||||||
mangaPrefs: {
|
|
||||||
...settingsState.settings.mangaPrefs,
|
|
||||||
[mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get settings() { return settingsState.settings; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||||
sortMode: "source",
|
sortMode: 'source',
|
||||||
sortDir: "asc",
|
sortDir: 'asc',
|
||||||
preferredScanlator: "",
|
preferredScanlator: '',
|
||||||
scanlatorFilter: [],
|
scanlatorFilter: [],
|
||||||
scanlatorBlacklist: [],
|
scanlatorBlacklist: [],
|
||||||
scanlatorForce: false,
|
scanlatorForce: false,
|
||||||
@@ -103,30 +22,214 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
|||||||
deleteOnRead: false,
|
deleteOnRead: false,
|
||||||
deleteDelayHours: 0,
|
deleteDelayHours: 0,
|
||||||
pauseUpdates: false,
|
pauseUpdates: false,
|
||||||
refreshInterval: "global",
|
refreshInterval: 'global',
|
||||||
coverUrl: "",
|
coverUrl: '',
|
||||||
};
|
}
|
||||||
|
|
||||||
export const seriesState = new SeriesStore();
|
const CHAPTER_TTL_MS = 2 * 60 * 1000
|
||||||
|
|
||||||
export const seriesStore = seriesState;
|
class SeriesStore {
|
||||||
|
activeManga = $state<Manga | null>(null)
|
||||||
|
previewManga = $state<Manga | null>(null)
|
||||||
|
activeChapter = $state<Chapter | null>(null)
|
||||||
|
bookmarks = $state<BookmarkEntry[]>([])
|
||||||
|
markers = $state<MarkerEntry[]>([])
|
||||||
|
acknowledgedUpdates = $state<Set<number>>(new Set())
|
||||||
|
|
||||||
export function setActiveMangaId(next: number | null) { seriesState.setActiveMangaId(next); }
|
#rawChapters = $state<Map<number, Chapter[]>>(new Map())
|
||||||
export function setActiveManga(next: Manga | null) { seriesState.setActiveManga(next); }
|
#fetchedAt = new Map<number, number>()
|
||||||
export function setPreviewManga(next: Manga | null) { seriesState.setPreviewManga(next); }
|
#abortCtrls = new Map<number, AbortController>()
|
||||||
export function openReader(ch: Chapter, list: Chapter[], manga?: Manga | null) { seriesState.openReader(ch, list, manga); }
|
#loading = $state<Set<number>>(new Set())
|
||||||
export function closeReader() { seriesState.closeReader(); }
|
#errors = $state<Map<number, string>>(new Map())
|
||||||
export function acknowledgeUpdate(mangaId: number) { seriesState.acknowledgeUpdate(mangaId); }
|
|
||||||
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { seriesState.addBookmark(entry, label); }
|
readonly activeChapterList = $derived.by(() => {
|
||||||
export function removeBookmark(chapterId: number) { seriesState.removeBookmark(chapterId); }
|
const id = this.activeManga?.id
|
||||||
export function clearBookmarks() { seriesState.clearBookmarks(); }
|
if (id == null) return []
|
||||||
export function getBookmark(chapterId: number) { return seriesState.getBookmark(chapterId); }
|
const raw = this.#rawChapters.get(id) ?? []
|
||||||
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return seriesState.addMarker(entry); }
|
const prefs = settingsState.settings.mangaPrefs?.[id] ?? {}
|
||||||
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { seriesState.updateMarker(id, patch); }
|
return buildChapterList(raw, {
|
||||||
export function removeMarker(id: string) { seriesState.removeMarker(id); }
|
sortMode: (prefs.sortMode ?? DEFAULT_MANGA_PREFS.sortMode) as MangaPrefs['sortMode'],
|
||||||
export function getMarkersForPage(chapterId: number, page: number) { return seriesState.getMarkersForPage(chapterId, page); }
|
sortDir: (prefs.sortDir ?? DEFAULT_MANGA_PREFS.sortDir) as MangaPrefs['sortDir'],
|
||||||
export function getMarkersForChapter(chapterId: number) { return seriesState.getMarkersForChapter(chapterId); }
|
preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator) as string,
|
||||||
export function getMarkersForManga(mangaId: number) { return seriesState.getMarkersForManga(mangaId); }
|
scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter) as string[],
|
||||||
export function clearMarkersForManga(mangaId: number) { seriesState.clearMarkersForManga(mangaId); }
|
scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist) as string[],
|
||||||
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] { return seriesState.getPref(mangaId, key); }
|
scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce) as boolean,
|
||||||
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) { seriesState.setPref(mangaId, key, value); }
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
readonly readerChapterList = $derived.by(() => {
|
||||||
|
const id = this.activeManga?.id
|
||||||
|
if (id == null) return []
|
||||||
|
const raw = this.#rawChapters.get(id) ?? []
|
||||||
|
const prefs = settingsState.settings.mangaPrefs?.[id] ?? {}
|
||||||
|
return buildChapterList(raw, {
|
||||||
|
sortMode: 'source',
|
||||||
|
sortDir: 'asc',
|
||||||
|
preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator) as string,
|
||||||
|
scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter) as string[],
|
||||||
|
scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist) as string[],
|
||||||
|
scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce) as boolean,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
chaptersFor(mangaId: number): Chapter[] { return this.#rawChapters.get(mangaId) ?? [] }
|
||||||
|
isLoadingChapters(mangaId: number) { return this.#loading.has(mangaId) }
|
||||||
|
chapterError(mangaId: number) { return this.#errors.get(mangaId) ?? null }
|
||||||
|
|
||||||
|
async loadChapters(mangaId: number, { force = false } = {}): Promise<void> {
|
||||||
|
const now = Date.now()
|
||||||
|
const stalest = this.#fetchedAt.get(mangaId) ?? 0
|
||||||
|
const fresh = !force && this.#rawChapters.has(mangaId) && now - stalest < CHAPTER_TTL_MS
|
||||||
|
|
||||||
|
if (fresh) return
|
||||||
|
|
||||||
|
this.#abortCtrls.get(mangaId)?.abort()
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
this.#abortCtrls.set(mangaId, ctrl)
|
||||||
|
|
||||||
|
this.#loading = new Set([...this.#loading, mangaId])
|
||||||
|
this.#errors = new Map(this.#errors)
|
||||||
|
this.#errors.delete(mangaId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adapter = getAdapter()
|
||||||
|
let nodes = await adapter.getChapters(String(mangaId), ctrl.signal)
|
||||||
|
|
||||||
|
if (!ctrl.signal.aborted && nodes.length === 0) {
|
||||||
|
const fetched = await adapter.fetchChapters(String(mangaId), ctrl.signal)
|
||||||
|
if (!ctrl.signal.aborted) nodes = fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctrl.signal.aborted) return
|
||||||
|
|
||||||
|
this.#rawChapters = new Map(this.#rawChapters).set(mangaId, nodes)
|
||||||
|
this.#fetchedAt.set(mangaId, Date.now())
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if ((e as { name?: string }).name === 'AbortError') return
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
this.#errors = new Map(this.#errors).set(mangaId, msg)
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) {
|
||||||
|
const next = new Set(this.#loading)
|
||||||
|
next.delete(mangaId)
|
||||||
|
this.#loading = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateChapters(mangaId: number) {
|
||||||
|
this.#fetchedAt.delete(mangaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
patchChapters(mangaId: number, updater: (chapters: Chapter[]) => Chapter[]) {
|
||||||
|
const current = this.#rawChapters.get(mangaId)
|
||||||
|
if (!current) return
|
||||||
|
this.#rawChapters = new Map(this.#rawChapters).set(mangaId, updater(current))
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveManga(manga: Manga | null) {
|
||||||
|
this.activeManga = manga
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewManga(manga: Manga | null) {
|
||||||
|
this.previewManga = manga
|
||||||
|
}
|
||||||
|
|
||||||
|
openReaderForChapter(chapter: Chapter, manga?: Manga | null) {
|
||||||
|
if (manga !== undefined) this.activeManga = manga
|
||||||
|
const mangaId = this.activeManga?.id
|
||||||
|
if (!mangaId) return
|
||||||
|
|
||||||
|
const list = this.readerChapterList
|
||||||
|
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {}
|
||||||
|
const ahead = (prefs.downloadAhead ?? DEFAULT_MANGA_PREFS.downloadAhead) as number
|
||||||
|
|
||||||
|
if (ahead > 0) {
|
||||||
|
const idx = list.findIndex(c => c.id === chapter.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
const toQueue = list
|
||||||
|
.slice(idx + 1, idx + 1 + ahead)
|
||||||
|
.filter(c => !c.downloaded && !c.read)
|
||||||
|
.map(c => String(c.id))
|
||||||
|
if (toQueue.length) getAdapter().enqueueDownloads(toQueue).catch(console.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeChapter = chapter
|
||||||
|
goto(`/reader/${mangaId}/${chapter.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReader() {
|
||||||
|
this.activeChapter = null
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledgeUpdate(mangaId: number) {
|
||||||
|
if (this.acknowledgedUpdates.has(mangaId)) return
|
||||||
|
this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId])
|
||||||
|
}
|
||||||
|
|
||||||
|
getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
|
||||||
|
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {}
|
||||||
|
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
|
||||||
|
updateSettings({
|
||||||
|
mangaPrefs: {
|
||||||
|
...settingsState.settings.mangaPrefs,
|
||||||
|
[mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) {
|
||||||
|
this.bookmarks = [
|
||||||
|
{ ...entry, savedAt: Date.now(), label },
|
||||||
|
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
|
||||||
|
].slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId) }
|
||||||
|
clearBookmarks() { this.bookmarks = [] }
|
||||||
|
getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId) }
|
||||||
|
|
||||||
|
addMarker(entry: Omit<MarkerEntry, 'id' | 'createdAt'>): string {
|
||||||
|
const id = Math.random().toString(36).slice(2)
|
||||||
|
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }]
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, 'note' | 'color'>>) {
|
||||||
|
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id) }
|
||||||
|
getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page) }
|
||||||
|
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId) }
|
||||||
|
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId) }
|
||||||
|
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId) }
|
||||||
|
|
||||||
|
get settings() { return settingsState.settings }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seriesState = new SeriesStore()
|
||||||
|
export const seriesStore = seriesState
|
||||||
|
|
||||||
|
export function setActiveManga(next: Manga | null) { seriesState.setActiveManga(next) }
|
||||||
|
export function setPreviewManga(next: Manga | null) { seriesState.setPreviewManga(next) }
|
||||||
|
export function openReaderForChapter(ch: Chapter, manga?: Manga | null) { seriesState.openReaderForChapter(ch, manga) }
|
||||||
|
export function closeReader() { seriesState.closeReader() }
|
||||||
|
export function acknowledgeUpdate(mangaId: number) { seriesState.acknowledgeUpdate(mangaId) }
|
||||||
|
export function addBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) { seriesState.addBookmark(entry, label) }
|
||||||
|
export function removeBookmark(chapterId: number) { seriesState.removeBookmark(chapterId) }
|
||||||
|
export function clearBookmarks() { seriesState.clearBookmarks() }
|
||||||
|
export function getBookmark(chapterId: number) { return seriesState.getBookmark(chapterId) }
|
||||||
|
export function addMarker(entry: Omit<MarkerEntry, 'id' | 'createdAt'>): string { return seriesState.addMarker(entry) }
|
||||||
|
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, 'note' | 'color'>>) { seriesState.updateMarker(id, patch) }
|
||||||
|
export function removeMarker(id: string) { seriesState.removeMarker(id) }
|
||||||
|
export function getMarkersForPage(chapterId: number, page: number) { return seriesState.getMarkersForPage(chapterId, page) }
|
||||||
|
export function getMarkersForChapter(chapterId: number) { return seriesState.getMarkersForChapter(chapterId) }
|
||||||
|
export function getMarkersForManga(mangaId: number) { return seriesState.getMarkersForManga(mangaId) }
|
||||||
|
export function clearMarkersForManga(mangaId: number) { seriesState.clearMarkersForManga(mangaId) }
|
||||||
|
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] { return seriesState.getPref(mangaId, key) }
|
||||||
|
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, v: MangaPrefs[K]) { seriesState.setPref(mangaId, key, v) }
|
||||||
@@ -19,7 +19,6 @@ class TrackingState {
|
|||||||
loadingFor: Set<number> = $state(new Set())
|
loadingFor: Set<number> = $state(new Set())
|
||||||
error: string | null = $state(null)
|
error: string | null = $state(null)
|
||||||
|
|
||||||
// Legacy flat fields kept for request-manager/tracking.ts compatibility
|
|
||||||
trackers: Tracker[] = $state([])
|
trackers: Tracker[] = $state([])
|
||||||
loading: boolean = $state(false)
|
loading: boolean = $state(false)
|
||||||
syncing: boolean = $state(false)
|
syncing: boolean = $state(false)
|
||||||
@@ -55,7 +54,6 @@ class TrackingState {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-manga load ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async loadForManga(mangaId: number) {
|
async loadForManga(mangaId: number) {
|
||||||
if (this.loadingFor.has(mangaId)) return
|
if (this.loadingFor.has(mangaId)) return
|
||||||
@@ -78,15 +76,13 @@ class TrackingState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global load (tracking page) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
async loadAll() {
|
async loadAll() {
|
||||||
this.loadingAll = true
|
this.loadingAll = true
|
||||||
this.error = null
|
this.error = null
|
||||||
try {
|
try {
|
||||||
const trackers = await getAdapter().getAllTrackerRecords() as TrackerWithRecords[]
|
const trackers = await getAdapter().getAllTrackerRecords() as TrackerWithRecords[]
|
||||||
this.allTrackers = trackers
|
this.allTrackers = trackers
|
||||||
this.trackers = trackers // keep flat field in sync
|
this.trackers = trackers
|
||||||
|
|
||||||
for (const tracker of trackers.filter((t) => t.isLoggedIn)) {
|
for (const tracker of trackers.filter((t) => t.isLoggedIn)) {
|
||||||
for (const record of tracker.trackRecords.nodes) {
|
for (const record of tracker.trackRecords.nodes) {
|
||||||
@@ -104,8 +100,6 @@ class TrackingState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Field updates ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async updateStatus(mangaId: number, record: TrackRecord, status: number): Promise<TrackRecord> {
|
async updateStatus(mangaId: number, record: TrackRecord, status: number): Promise<TrackRecord> {
|
||||||
const fresh = await getAdapter().updateTrackRecord(String(record.id), { status })
|
const fresh = await getAdapter().updateTrackRecord(String(record.id), { status })
|
||||||
this.patchFor(mangaId, fresh)
|
this.patchFor(mangaId, fresh)
|
||||||
@@ -134,7 +128,6 @@ class TrackingState {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Remote sync ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async syncFromRemote(
|
async syncFromRemote(
|
||||||
mangaId: number,
|
mangaId: number,
|
||||||
@@ -168,7 +161,6 @@ class TrackingState {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Read/unread sync ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async updateFromRead(
|
async updateFromRead(
|
||||||
mangaId: number,
|
mangaId: number,
|
||||||
@@ -232,7 +224,6 @@ class TrackingState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Boot sync ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async bootSync() {
|
async bootSync() {
|
||||||
if (!settingsState.settings.trackerSyncBack) return
|
if (!settingsState.settings.trackerSyncBack) return
|
||||||
@@ -297,7 +288,6 @@ class TrackingState {
|
|||||||
this.byManga = next
|
this.byManga = next
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private _statusesFor(trackerId: number): { value: number; name: string }[] {
|
private _statusesFor(trackerId: number): { value: number; name: string }[] {
|
||||||
return this.allTrackers.find((t) => t.id === trackerId)?.statuses ?? []
|
return this.allTrackers.find((t) => t.id === trackerId)?.statuses ?? []
|
||||||
|
|||||||
Reference in New Issue
Block a user