mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Fix: Reader Longstrip Bookmark + ProgressBar
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
<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 { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
||||||
@@ -54,11 +55,51 @@
|
|||||||
let resolvedSrc = $state<Record<number, string>>({});
|
let resolvedSrc = $state<Record<number, string>>({});
|
||||||
let revokeQueue: string[] = [];
|
let revokeQueue: string[] = [];
|
||||||
|
|
||||||
let observer: IntersectionObserver | null = null;
|
// Aspect ratios (w/h) keyed by flat index, written by the img onload handler.
|
||||||
const elementIndex = new Map<Element, number>();
|
// Retained as a fallback for scrollToFlatIndex when a slot is not yet in DOM.
|
||||||
|
const aspectMap = new Map<number, number>();
|
||||||
|
|
||||||
let viewportCenter = $state(0);
|
let currentSrc = $state<string | null>(null);
|
||||||
|
let currentGroupSrcs = $state<(string | null)[]>([]);
|
||||||
|
|
||||||
|
let centerIdx = $state(0);
|
||||||
|
|
||||||
|
// ── Non-longstrip page src resolution ────────────────────────────────────
|
||||||
|
$effect(() => {
|
||||||
|
if (style === "longstrip" || !pageReady) return;
|
||||||
|
const pageNum = readerState.pageNumber;
|
||||||
|
const urls = readerState.pageUrls;
|
||||||
|
const group = currentGroup;
|
||||||
|
currentSrc = null;
|
||||||
|
currentGroupSrcs = group.map(() => null);
|
||||||
|
let cancelled = false;
|
||||||
|
if (style === "double") {
|
||||||
|
group.forEach((pg, i) => {
|
||||||
|
const url = urls[pg - 1];
|
||||||
|
if (!url) return;
|
||||||
|
resolveUrl(url, 999).then(src => {
|
||||||
|
if (cancelled) return;
|
||||||
|
currentGroupSrcs = currentGroupSrcs.map((s, j) => j === i ? src : s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const url = urls[pageNum - 1];
|
||||||
|
if (url) resolveUrl(url, 999).then(src => { if (!cancelled) currentSrc = src; });
|
||||||
|
}
|
||||||
|
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(() => {
|
||||||
|
void readerState.pageNumber;
|
||||||
|
if (style !== "longstrip" && containerEl) {
|
||||||
|
containerEl.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Blob URL revocation ───────────────────────────────────────────────────
|
||||||
function scheduleRevoke(src: string) {
|
function scheduleRevoke(src: string) {
|
||||||
if (!src || !src.startsWith("blob:")) return;
|
if (!src || !src.startsWith("blob:")) return;
|
||||||
revokeQueue.push(src);
|
revokeQueue.push(src);
|
||||||
@@ -68,6 +109,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Load window management ────────────────────────────────────────────────
|
||||||
function loadPage(idx: number) {
|
function loadPage(idx: number) {
|
||||||
if (loadedSet.has(idx)) return;
|
if (loadedSet.has(idx)) return;
|
||||||
const page = flatPages[idx];
|
const page = flatPages[idx];
|
||||||
@@ -99,8 +141,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recalcWindow() {
|
function recalcWindow(center: number) {
|
||||||
const center = viewportCenter;
|
|
||||||
const lo = center - LOAD_RADIUS;
|
const lo = center - LOAD_RADIUS;
|
||||||
const hi = center + LOAD_RADIUS;
|
const hi = center + LOAD_RADIUS;
|
||||||
const evictLo = center - UNLOAD_RADIUS;
|
const evictLo = center - UNLOAD_RADIUS;
|
||||||
@@ -111,39 +152,139 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { void viewportCenter; recalcWindow(); });
|
$effect(() => { recalcWindow(centerIdx); });
|
||||||
$effect(() => { void flatPages.length; recalcWindow(); });
|
$effect(() => { void flatPages.length; recalcWindow(centerIdx); });
|
||||||
|
|
||||||
function setupObserver(containerEl: HTMLElement) {
|
// ── Scroll position preservation on image resize above viewport ───────────
|
||||||
observer?.disconnect();
|
// Ported from Suwayomi's usePreserveOnLeadingPageRender.
|
||||||
elementIndex.clear();
|
//
|
||||||
observer = new IntersectionObserver(
|
// Problem: when a placeholder above the current scroll position loads its
|
||||||
(entries) => {
|
// real image and changes height, the browser shifts the scroll position
|
||||||
let best = -1;
|
// relative to the viewport (layout shift). This corrects for that by:
|
||||||
let bestRatio = -1;
|
// 1. Tracking the first visible image and its offsetTop at last scroll.
|
||||||
for (const entry of entries) {
|
// 2. On every ResizeObserver entry for an image above scrollTop, computing
|
||||||
const idx = elementIndex.get(entry.target);
|
// the delta and applying it as a scroll correction.
|
||||||
if (idx === undefined) continue;
|
//
|
||||||
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
|
// MutationObserver watches for images being added/removed so the
|
||||||
bestRatio = entry.intersectionRatio;
|
// ResizeObserver stays in sync with the actual DOM without needing
|
||||||
best = idx;
|
// querySelectorAll on every scroll tick.
|
||||||
}
|
$effect(() => {
|
||||||
}
|
if (style !== "longstrip" || !containerEl) return;
|
||||||
if (best >= 0 && best !== viewportCenter) viewportCenter = best;
|
|
||||||
},
|
|
||||||
{ root: containerEl, rootMargin: "0px", threshold: [0, 0.1, 0.5, 1.0] },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function observePage(el: HTMLDivElement, idx: number) {
|
let visibleImg: HTMLElement | undefined;
|
||||||
elementIndex.set(el, idx);
|
let visibleImgTop = 0;
|
||||||
observer?.observe(el);
|
let lastScrollTop = 0;
|
||||||
return {
|
|
||||||
update(newIdx: number) { elementIndex.set(el, newIdx); },
|
const onScroll = () => {
|
||||||
destroy() { observer?.unobserve(el); elementIndex.delete(el); },
|
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 slot = slots[idx];
|
||||||
|
if (slot) {
|
||||||
|
slot.scrollIntoView({ block: "start", behavior: "instant" });
|
||||||
|
} else {
|
||||||
|
// Slot not in DOM — proportional fallback (very unlikely after tick).
|
||||||
|
containerEl.scrollTop = (idx / flatPages.length) * containerEl.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reset on chapter change ───────────────────────────────────────────────
|
||||||
let lastChapterId = 0;
|
let lastChapterId = 0;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const chapterId = readerState.activeChapter?.id ?? 0;
|
const chapterId = readerState.activeChapter?.id ?? 0;
|
||||||
@@ -151,10 +292,11 @@
|
|||||||
lastChapterId = chapterId;
|
lastChapterId = chapterId;
|
||||||
loadedSet = new Set<number>();
|
loadedSet = new Set<number>();
|
||||||
resolvedSrc = {};
|
resolvedSrc = {};
|
||||||
const resume = readerState.resumePage;
|
centerIdx = 0;
|
||||||
viewportCenter = resume > 1 ? resume - 1 : 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;
|
||||||
|
|
||||||
@@ -238,6 +380,11 @@
|
|||||||
return () => cancelAnimationFrame(rafId);
|
return () => cancelAnimationFrame(rafId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (style !== "longstrip") stopMidScroll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Pinch zoom ────────────────────────────────────────────────────────────
|
||||||
let pinch: PinchTracker | null = null;
|
let pinch: PinchTracker | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -255,6 +402,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Pointer / mouse / wheel event handlers ────────────────────────────────
|
||||||
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 (e.button === 1 && style === "longstrip") {
|
||||||
@@ -388,18 +536,7 @@
|
|||||||
function setContainer(el: HTMLDivElement) {
|
function setContainer(el: HTMLDivElement) {
|
||||||
containerEl = el;
|
containerEl = el;
|
||||||
bindContainer(el);
|
bindContainer(el);
|
||||||
if (style === "longstrip") setupObserver(el);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (style === "longstrip" && containerEl) {
|
|
||||||
setupObserver(containerEl);
|
|
||||||
} else if (style !== "longstrip") {
|
|
||||||
observer?.disconnect();
|
|
||||||
observer = null;
|
|
||||||
stopMidScroll();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -459,7 +596,7 @@
|
|||||||
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
||||||
{@const src = resolvedSrc[gi]}
|
{@const src = resolvedSrc[gi]}
|
||||||
{@const isLoaded = loadedSet.has(gi)}
|
{@const isLoaded = loadedSet.has(gi)}
|
||||||
<div class="strip-slot" use:observePage={gi}>
|
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId}>
|
||||||
{#if isLoaded && src}
|
{#if isLoaded && src}
|
||||||
<img
|
<img
|
||||||
{src}
|
{src}
|
||||||
@@ -475,7 +612,9 @@
|
|||||||
const img = e.currentTarget as HTMLImageElement;
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
const slot = img.closest<HTMLElement>(".strip-slot");
|
const slot = img.closest<HTMLElement>(".strip-slot");
|
||||||
if (slot && img.naturalWidth > 0) {
|
if (slot && img.naturalWidth > 0) {
|
||||||
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
|
const aspect = img.naturalWidth / img.naturalHeight;
|
||||||
|
slot.style.setProperty("--aspect", String(aspect));
|
||||||
|
aspectMap.set(gi, aspect);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -490,11 +629,11 @@
|
|||||||
|
|
||||||
{:else if style === "fade" && pageReady}
|
{:else if style === "fade" && pageReady}
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
|
{#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>
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
{:then src}
|
{/if}
|
||||||
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
|
|
||||||
{/await}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if style === "double" && pageReady}
|
{:else if style === "double" && pageReady}
|
||||||
@@ -502,11 +641,11 @@
|
|||||||
{#if pageGroups.length}
|
{#if pageGroups.length}
|
||||||
<div class="double-wrap">
|
<div class="double-wrap">
|
||||||
{#each currentGroup as pg, i (pg)}
|
{#each currentGroup as pg, i (pg)}
|
||||||
{#await resolveUrl(readerState.pageUrls[pg - 1], 999)}
|
{#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>
|
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">{@render skeleton()}</div>
|
||||||
{:then src}
|
{/if}
|
||||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
|
||||||
{/await}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -518,11 +657,11 @@
|
|||||||
|
|
||||||
{: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)">
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
|
{#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>
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
{:then src}
|
{/if}
|
||||||
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
|
||||||
{/await}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
|
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
|
||||||
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
|
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
|
||||||
import { historyState } from "$lib/state/history.svelte";
|
import { historyState } from "$lib/state/history.svelte";
|
||||||
|
import { setPreviewManga } from "$lib/state/series.svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import { setReading, clearReading } from "$lib/core/discord";
|
import { setReading, clearReading } from "$lib/core/discord";
|
||||||
import { revokeBlobUrl } from "$lib/core/cache/imageCache";
|
import { revokeBlobUrl, cancelQueuedFetches, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||||
import type { ReaderSettings } from "$lib/state/reader.svelte";
|
import type { ReaderSettings } from "$lib/state/reader.svelte";
|
||||||
import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
|
import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
|
||||||
import PageView from "$lib/components/reader/PageView.svelte";
|
import PageView from "$lib/components/reader/PageView.svelte";
|
||||||
@@ -211,6 +212,36 @@
|
|||||||
|
|
||||||
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) {
|
||||||
|
if (useBlob && commit && style !== "longstrip") {
|
||||||
|
cancelQueuedFetches();
|
||||||
|
const urls = readerState.pageUrls;
|
||||||
|
const lo = Math.max(0, page - 2);
|
||||||
|
const hi = Math.min(urls.length, page + 4);
|
||||||
|
preloadBlobUrls(urls.slice(lo, hi), 999);
|
||||||
|
}
|
||||||
|
jumpToPage(
|
||||||
|
page,
|
||||||
|
style,
|
||||||
|
lastPage,
|
||||||
|
style === "longstrip" ? (idx) => pageViewRef.scrollToFlatIndex(idx) : null,
|
||||||
|
stripToRender.reduce((s, c) => s + c.urls.length, 0),
|
||||||
|
readerState.visibleChapterId ?? readerState.activeChapter?.id ?? 0,
|
||||||
|
readerState.stripChapters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const goNext = $derived(rtl
|
const goNext = $derived(rtl
|
||||||
? () => goBack(style, adjacent, startAtLast)
|
? () => goBack(style, adjacent, startAtLast)
|
||||||
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
|
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
|
||||||
@@ -218,7 +249,6 @@
|
|||||||
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
|
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
|
||||||
: () => goBack(style, adjacent, startAtLast));
|
: () => goBack(style, adjacent, startAtLast));
|
||||||
|
|
||||||
// clear Discord presence and free page blob textures before closing
|
|
||||||
function handleCloseReader() {
|
function handleCloseReader() {
|
||||||
clearReading().catch(() => {});
|
clearReading().catch(() => {});
|
||||||
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
||||||
@@ -232,7 +262,7 @@
|
|||||||
goNext: () => goNext(),
|
goNext: () => goNext(),
|
||||||
goPrev: () => goPrev(),
|
goPrev: () => goPrev(),
|
||||||
closeReader: () => handleCloseReader(),
|
closeReader: () => handleCloseReader(),
|
||||||
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
|
goToPage: (p) => primedJump(p),
|
||||||
lastPage: () => lastPage,
|
lastPage: () => lastPage,
|
||||||
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||||
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||||
@@ -325,7 +355,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Separate from chapter load: also re-fires when idle splash dismisses so presence is restored.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ch = readerState.activeChapter;
|
const ch = readerState.activeChapter;
|
||||||
const manga = readerState.activeManga;
|
const manga = readerState.activeManga;
|
||||||
@@ -361,26 +390,18 @@
|
|||||||
if (style === "longstrip" && readerState.pageUrls.length && readerState.activeChapter) {
|
if (style === "longstrip" && readerState.pageUrls.length && readerState.activeChapter) {
|
||||||
const ch = readerState.activeChapter;
|
const ch = readerState.activeChapter;
|
||||||
const urls = readerState.pageUrls;
|
const urls = readerState.pageUrls;
|
||||||
const targetPg = untrack(() => readerState.resumePage);
|
const resumeTo = untrack(() => readerState.resumePage);
|
||||||
appending = false;
|
appending = false;
|
||||||
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
||||||
readerState.visibleChapterId = ch.id;
|
readerState.visibleChapterId = ch.id;
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (!containerEl) return;
|
if (!containerEl) return;
|
||||||
if (targetPg > 1) {
|
if (resumeTo > 1) {
|
||||||
const chId = ch.id;
|
pageViewRef.scrollToFlatIndex(resumeTo - 1);
|
||||||
const scrollToResumePage = () => {
|
readerState.stripResumeReady = true;
|
||||||
const target = containerEl!.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
|
|
||||||
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
|
|
||||||
containerEl!.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`).forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
|
|
||||||
const doScroll = () => { target.scrollIntoView({ block: "start" }); readerState.stripResumeReady = true; };
|
|
||||||
if (target.complete && target.naturalHeight > 0) doScroll();
|
|
||||||
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
|
|
||||||
};
|
|
||||||
scrollToResumePage();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
containerEl!.scrollTop = 0;
|
containerEl.scrollTop = 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -432,6 +453,7 @@
|
|||||||
cleanupScroll = setupScrollTracking(containerEl!, {
|
cleanupScroll = setupScrollTracking(containerEl!, {
|
||||||
onPageChange: (p) => { readerState.pageNumber = p; },
|
onPageChange: (p) => { readerState.pageNumber = p; },
|
||||||
onChapterChange: (id) => { readerState.visibleChapterId = id; },
|
onChapterChange: (id) => { readerState.visibleChapterId = id; },
|
||||||
|
onCenterIdxChange: (idx) => { pageViewRef?.notifyScrollCenter(idx); },
|
||||||
onMarkRead: (id) => markChapterRead(id, markedRead),
|
onMarkRead: (id) => markChapterRead(id, markedRead),
|
||||||
onAppend: () => {
|
onAppend: () => {
|
||||||
if (appending || !readerState.stripChapters.length) return;
|
if (appending || !readerState.stripChapters.length) return;
|
||||||
@@ -628,6 +650,7 @@
|
|||||||
onClampZoom={clampZoom}
|
onClampZoom={clampZoom}
|
||||||
onApplySettings={applySettings}
|
onApplySettings={applySettings}
|
||||||
onSettingsOpen={() => { app.setSettingsOpen(true); }}
|
onSettingsOpen={() => { app.setSettingsOpen(true); }}
|
||||||
|
onOpenPreview={() => { if (readerState.activeManga) setPreviewManga(readerState.activeManga); }}
|
||||||
{perMangaEnabled}
|
{perMangaEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -688,7 +711,7 @@
|
|||||||
{barPosition}
|
{barPosition}
|
||||||
onGoPrev={goPrev}
|
onGoPrev={goPrev}
|
||||||
onGoNext={goNext}
|
onGoNext={goNext}
|
||||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
onJumpToPage={(p, commit) => primedJump(p, commit)}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -702,7 +725,7 @@
|
|||||||
{barPosition}
|
{barPosition}
|
||||||
onGoPrev={goPrev}
|
onGoPrev={goPrev}
|
||||||
onGoNext={goNext}
|
onGoNext={goNext}
|
||||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
onJumpToPage={(p, commit) => primedJump(p, commit)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
onClampZoom: (z: number) => number;
|
onClampZoom: (z: number) => number;
|
||||||
onApplySettings: (patch: Partial<ReaderSettings>) => void;
|
onApplySettings: (patch: Partial<ReaderSettings>) => void;
|
||||||
onSettingsOpen: () => void;
|
onSettingsOpen: () => void;
|
||||||
|
onOpenPreview: () => void;
|
||||||
perMangaEnabled: boolean;
|
perMangaEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
barPosition, progressBar,
|
barPosition, progressBar,
|
||||||
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
||||||
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
||||||
onClampZoom, onApplySettings, onSettingsOpen,
|
onClampZoom, onApplySettings, onSettingsOpen, onOpenPreview,
|
||||||
perMangaEnabled,
|
perMangaEnabled,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -155,11 +156,11 @@
|
|||||||
<span class="ch-info"></span>
|
<span class="ch-info"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
|
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
|
||||||
<span class="ch-marquee-content">
|
<button class="ch-marquee-content ch-preview-btn" onclick={onOpenPreview}>
|
||||||
<span class="ch-title">{readerState.activeManga?.title}</span>
|
<span class="ch-title">{readerState.activeManga?.title}</span>
|
||||||
<span class="ch-sep">/</span>
|
<span class="ch-sep">/</span>
|
||||||
<span class="ch-name">{displayChapter?.name}</span>
|
<span class="ch-name">{displayChapter?.name}</span>
|
||||||
</span>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -494,6 +495,8 @@
|
|||||||
.ch-marquee-track { overflow-x: auto; min-width: 0; flex: 1; scrollbar-width: none; }
|
.ch-marquee-track { overflow-x: auto; min-width: 0; flex: 1; scrollbar-width: none; }
|
||||||
.ch-marquee-track::-webkit-scrollbar { display: none; }
|
.ch-marquee-track::-webkit-scrollbar { display: none; }
|
||||||
.ch-marquee-content { display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
|
.ch-marquee-content { display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
|
||||||
|
.ch-preview-btn { background: none; border: none; cursor: pointer; padding: 0; font-size: inherit; font-family: inherit; border-radius: var(--radius-sm); transition: opacity var(--t-fast); }
|
||||||
|
.ch-preview-btn:hover { opacity: 0.7; }
|
||||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||||||
.ch-name { color: var(--text-muted); }
|
.ch-name { color: var(--text-muted); }
|
||||||
|
|||||||
@@ -21,11 +21,27 @@
|
|||||||
readerState.dlOpen = false;
|
readerState.dlOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bannerMounted = $state(false);
|
||||||
|
let bannerFading = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showResumeBanner) {
|
||||||
|
bannerMounted = true;
|
||||||
|
bannerFading = false;
|
||||||
|
} else if (bannerMounted) {
|
||||||
|
bannerFading = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
|
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
|
||||||
|
|
||||||
|
function onBannerAnimationEnd() {
|
||||||
|
if (bannerFading) { bannerMounted = false; bannerFading = false; }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showResumeBanner}
|
{#if bannerMounted}
|
||||||
<button class="resume-banner" class:fading={resumeFading} onclick={onDismissResume}>
|
<button class="resume-banner" class:fading={bannerFading} onclick={onDismissResume} onanimationend={onBannerAnimationEnd}>
|
||||||
Bookmark at page {resumePage}
|
Bookmark at page {resumePage}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
barPosition: "top" | "left" | "right";
|
barPosition: "top" | "left" | "right";
|
||||||
onGoPrev: () => void;
|
onGoPrev: () => void;
|
||||||
onGoNext: () => void;
|
onGoNext: () => void;
|
||||||
onJumpToPage: (page: number) => void;
|
onJumpToPage: (page: number, commit?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -32,12 +32,22 @@
|
|||||||
|
|
||||||
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||||
|
|
||||||
const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage);
|
|
||||||
const hPct = $derived(`--pct:${sliderPct}%`);
|
const hPct = $derived(`--pct:${sliderPct}%`);
|
||||||
|
|
||||||
|
function sliderValToPage(raw: number): number {
|
||||||
|
return rtl ? sliderMax - raw + 1 : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageToSliderVal(page: number): number {
|
||||||
|
return rtl ? sliderMax - page + 1 : page;
|
||||||
|
}
|
||||||
|
|
||||||
function handleH(e: Event) {
|
function handleH(e: Event) {
|
||||||
const raw = Number((e.target as HTMLInputElement).value);
|
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), false);
|
||||||
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
|
}
|
||||||
|
|
||||||
|
function handleHCommit(e: Event) {
|
||||||
|
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerPct(pageNumber: number, forRtl = false): number {
|
function markerPct(pageNumber: number, forRtl = false): number {
|
||||||
@@ -46,9 +56,9 @@
|
|||||||
return ((ord - 1) / (sliderMax - 1)) * 100;
|
return ((ord - 1) / (sliderMax - 1)) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom vertical slider
|
|
||||||
let trackEl = $state<HTMLDivElement | null>(null);
|
let trackEl = $state<HTMLDivElement | null>(null);
|
||||||
let dragging = $state(false);
|
let dragging = $state(false);
|
||||||
|
let pendingPage = 0;
|
||||||
|
|
||||||
function pctFromPointer(clientY: number): number {
|
function pctFromPointer(clientY: number): number {
|
||||||
if (!trackEl) return 0;
|
if (!trackEl) return 0;
|
||||||
@@ -66,20 +76,22 @@
|
|||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
dragging = true;
|
dragging = true;
|
||||||
readerState.sliderDragging = true;
|
readerState.sliderDragging = true;
|
||||||
const pct = pctFromPointer(e.clientY);
|
pendingPage = pageFromPct(pctFromPointer(e.clientY));
|
||||||
onJumpToPage(pageFromPct(pct));
|
onJumpToPage(pendingPage, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrackPointerMove(e: PointerEvent) {
|
function handleTrackPointerMove(e: PointerEvent) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
const pct = pctFromPointer(e.clientY);
|
pendingPage = pageFromPct(pctFromPointer(e.clientY));
|
||||||
onJumpToPage(pageFromPct(pct));
|
onJumpToPage(pendingPage, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrackPointerUp(e: PointerEvent) {
|
function handleTrackPointerUp(e: PointerEvent) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
dragging = false;
|
dragging = false;
|
||||||
readerState.sliderDragging = false;
|
readerState.sliderDragging = false;
|
||||||
|
readerState.sliderHover = false;
|
||||||
|
onJumpToPage(pendingPage, true);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -102,8 +114,9 @@
|
|||||||
style={hPct}
|
style={hPct}
|
||||||
min={1}
|
min={1}
|
||||||
max={sliderMax}
|
max={sliderMax}
|
||||||
value={hValue}
|
value={pageToSliderVal(sliderPage)}
|
||||||
oninput={handleH}
|
oninput={handleH}
|
||||||
|
onchange={handleHCommit}
|
||||||
onmousedown={() => readerState.sliderDragging = true}
|
onmousedown={() => readerState.sliderDragging = true}
|
||||||
onmouseup={() => readerState.sliderDragging = false}
|
onmouseup={() => readerState.sliderDragging = false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readerState } from "$lib/state/reader.svelte";
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
import { fetchPages } from "./pageLoader";
|
import { fetchPages } from "./pageLoader";
|
||||||
import { cancelQueuedFetches, revokeBlobUrl } 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";
|
||||||
|
|
||||||
export function scheduleResumeDismiss() {
|
export function scheduleResumeDismiss() {
|
||||||
@@ -46,18 +46,23 @@ export async function loadChapter(
|
|||||||
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;
|
||||||
readerState.resumeVisible = resumeTo > 1;
|
readerState.resumeVisible = false;
|
||||||
if (resumeTo > 1) scheduleResumeDismiss();
|
|
||||||
|
|
||||||
readerState.pageNumber = 1;
|
readerState.pageNumber = 1;
|
||||||
try {
|
try {
|
||||||
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
readerState.pageUrls = urls;
|
readerState.pageUrls = urls;
|
||||||
|
if (useBlob && resumeTo > 1) {
|
||||||
|
const lo = Math.max(0, resumeTo - 2);
|
||||||
|
const hi = Math.min(urls.length, resumeTo + 4);
|
||||||
|
preloadBlobUrls(urls.slice(lo, hi), 900);
|
||||||
|
}
|
||||||
if (startAtLastPage.current) readerState.pageNumber = urls.length;
|
if (startAtLastPage.current) readerState.pageNumber = urls.length;
|
||||||
else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||||
readerState.pageReady = true;
|
readerState.pageReady = true;
|
||||||
readerState.loading = false;
|
readerState.loading = false;
|
||||||
|
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).catch(() => {});
|
||||||
|
|||||||
@@ -64,14 +64,30 @@ export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () =>
|
|||||||
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jumpToPage(page: number, style: string, lastPage: number, containerEl: HTMLElement | null) {
|
export function jumpToPage(
|
||||||
|
page: number,
|
||||||
|
style: string,
|
||||||
|
lastPage: number,
|
||||||
|
scrollToFlatIndex: ((idx: number) => void) | null,
|
||||||
|
flatPageCount: number,
|
||||||
|
activeChapterId: number,
|
||||||
|
stripChapters: { chapterId: number; urls: string[] }[],
|
||||||
|
) {
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
if (!scrollToFlatIndex || flatPageCount === 0) return;
|
||||||
containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" });
|
let offset = 0;
|
||||||
|
for (const chunk of stripChapters) {
|
||||||
|
if (chunk.chapterId === activeChapterId) {
|
||||||
|
scrollToFlatIndex(offset + Math.max(0, page - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
offset += chunk.urls.length;
|
||||||
|
}
|
||||||
|
scrollToFlatIndex(Math.max(0, page - 1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (style === "double" && readerState.pageGroups.length) {
|
if (style === "double" && readerState.pageGroups.length) {
|
||||||
const group = readerState.pageGroups[page - 1];
|
const group = readerState.pageGroups.find(g => g.includes(page)) ?? readerState.pageGroups.findLast(g => g[0] <= page);
|
||||||
if (group) readerState.pageNumber = group[0];
|
if (group) readerState.pageNumber = group[0];
|
||||||
} else {
|
} else {
|
||||||
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
|
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface StripChapter {
|
|||||||
export interface ScrollHandlerCallbacks {
|
export interface ScrollHandlerCallbacks {
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
onChapterChange: (chapterId: number) => void;
|
onChapterChange: (chapterId: number) => void;
|
||||||
|
onCenterIdxChange: (flatIdx: number) => void;
|
||||||
onMarkRead: (chapterId: number) => void;
|
onMarkRead: (chapterId: number) => void;
|
||||||
onAppend: () => void;
|
onAppend: () => void;
|
||||||
getStripChapters: () => StripChapter[];
|
getStripChapters: () => StripChapter[];
|
||||||
@@ -16,25 +17,55 @@ export interface ScrollHandlerCallbacks {
|
|||||||
shouldAutoMark: () => boolean;
|
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(
|
export function setupScrollTracking(
|
||||||
containerEl: HTMLElement,
|
containerEl: HTMLElement,
|
||||||
callbacks: ScrollHandlerCallbacks,
|
callbacks: ScrollHandlerCallbacks,
|
||||||
): () => void {
|
): () => void {
|
||||||
const { onPageChange, onChapterChange, onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark } = callbacks;
|
const {
|
||||||
|
onPageChange, onChapterChange, onCenterIdxChange,
|
||||||
|
onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
||||||
|
} = callbacks;
|
||||||
|
|
||||||
let rafId: number | null = null;
|
let rafId: number | null = null;
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
rafId = null;
|
rafId = null;
|
||||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
|
|
||||||
if (!imgs.length) return;
|
if (!imgs.length) return;
|
||||||
|
|
||||||
const containerTop = containerEl.getBoundingClientRect().top;
|
const containerRect = containerEl.getBoundingClientRect();
|
||||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
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;
|
let lo = 0, hi = imgs.length - 1, best = 0;
|
||||||
while (lo <= hi) {
|
while (lo <= hi) {
|
||||||
const mid = (lo + hi) >>> 1;
|
const mid = (lo + hi) >>> 1;
|
||||||
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
|
if (isPageAtReadLine(imgs[mid], readLineY)) { best = mid; lo = mid + 1; }
|
||||||
else hi = mid - 1;
|
else hi = mid - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +76,17 @@ export function setupScrollTracking(
|
|||||||
onPageChange(activePage);
|
onPageChange(activePage);
|
||||||
if (activeChId) onChapterChange(activeChId);
|
if (activeChId) onChapterChange(activeChId);
|
||||||
|
|
||||||
if (shouldAutoMark() && activeChId) {
|
|
||||||
const chunks = getStripChapters();
|
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 chunk = chunks.find(c => c.chapterId === activeChId);
|
||||||
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
||||||
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
||||||
@@ -58,8 +98,9 @@ export function setupScrollTracking(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
if ((containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight >= 0.80) {
|
||||||
if (pct >= 0.80) onAppend();
|
onAppend();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
import { get } from 'svelte/store'
|
import { get } from 'svelte/store'
|
||||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
import { resolvedCover } from '$lib/core/cover/coverResolver'
|
import { resolvedCover } from '$lib/core/cover/coverResolver'
|
||||||
import type { MangaPrefs } from '$lib/types/settings'
|
import type { Manga, Chapter, Category } from '$lib/types'
|
||||||
|
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
import { setPreviewManga } from '$lib/state/series.svelte'
|
import { setPreviewManga } from '$lib/state/series.svelte'
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
|
|
||||||
let manageOpen: boolean = $state(false)
|
let manageOpen: boolean = $state(false)
|
||||||
let genresExpanded: boolean = $state(false)
|
let genresExpanded: boolean = $state(false)
|
||||||
|
let descExpanded: boolean = $state(false)
|
||||||
let altOpen: boolean = $state(false)
|
let altOpen: boolean = $state(false)
|
||||||
|
|
||||||
const statusLabel = $derived(
|
const statusLabel = $derived(
|
||||||
@@ -104,7 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<p class="title">{manga?.title}</p>
|
<button class="title" onclick={() => manga && setPreviewManga(manga)} disabled={!manga}>{manga?.title}</button>
|
||||||
|
|
||||||
{#if manga?.author || manga?.artist}
|
{#if manga?.author || manga?.artist}
|
||||||
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' · ')}</p>
|
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' · ')}</p>
|
||||||
@@ -148,8 +150,8 @@
|
|||||||
|
|
||||||
{#if manga?.description}
|
{#if manga?.description}
|
||||||
<div class="desc-wrap">
|
<div class="desc-wrap">
|
||||||
<p class="desc">{manga.description}</p>
|
<p class="desc" class:desc-open={descExpanded}>{manga.description}</p>
|
||||||
<button class="expand-toggle" onclick={() => genresExpanded = !genresExpanded}>Read more</button>
|
<button class="expand-toggle" onclick={() => descExpanded = !descExpanded}>{descExpanded ? 'Show less' : 'Read more'}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +279,11 @@
|
|||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
color: var(--text-primary); line-height: var(--leading-snug);
|
color: var(--text-primary); line-height: var(--leading-snug);
|
||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
|
background: none; border: none; padding: 0; text-align: left; cursor: pointer;
|
||||||
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
|
.title:hover:not(:disabled) { color: var(--accent-fg); }
|
||||||
|
.title:disabled { cursor: default; }
|
||||||
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
||||||
|
|
||||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
@@ -328,6 +334,7 @@
|
|||||||
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
|
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
|
||||||
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
|
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
||||||
.expand-toggle {
|
.expand-toggle {
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wide); align-self: flex-start; transition: color var(--t-base);
|
letter-spacing: var(--tracking-wide); align-self: flex-start; transition: color var(--t-base);
|
||||||
|
|||||||
@@ -259,7 +259,7 @@
|
|||||||
function openSeriesDetail() {
|
function openSeriesDetail() {
|
||||||
if (!displayManga) return;
|
if (!displayManga) return;
|
||||||
setActiveManga(displayManga);
|
setActiveManga(displayManga);
|
||||||
setNavPage(originNavPage);
|
app.setNavPage(originNavPage);
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user