Fix: Reader Longstrip Bookmark + ProgressBar

This commit is contained in:
Youwes09
2026-06-09 22:52:11 -05:00
parent f99fa60e8e
commit a8ad9034fc
10 changed files with 407 additions and 144 deletions
+205 -66
View File
@@ -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,62 +141,162 @@
} }
} }
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;
const evictHi = center + UNLOAD_RADIUS; const evictHi = center + UNLOAD_RADIUS;
for (let i = 0; i < flatPages.length; i++) { for (let i = 0; i < flatPages.length; i++) {
if (i >= lo && i <= hi) loadPage(i); if (i >= lo && i <= hi) loadPage(i);
else if (i < evictLo || i > evictHi) unloadPage(i); else if (i < evictLo || i > evictHi) unloadPage(i);
} }
} }
$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;
if (chapterId === lastChapterId) return; if (chapterId === lastChapterId) return;
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>
+47 -24
View File
@@ -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,13 +262,13 @@
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); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); }, cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }), toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => { app.setSettingsOpen(true); }, openSettings: () => { app.setSettingsOpen(true); },
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber), toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(settingsState.settings.autoScroll ?? false) }); }, toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(settingsState.settings.autoScroll ?? false) }); },
toggleMarker: () => { toggleMarker: () => {
@@ -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;
}); });
} }
}); });
@@ -430,10 +451,11 @@
untrack(() => { untrack(() => {
cleanupScroll(); cleanupScroll();
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; },
onMarkRead: (id) => markChapterRead(id, markedRead), onCenterIdxChange: (idx) => { pageViewRef?.notifyScrollCenter(idx); },
onAppend: () => { onMarkRead: (id) => markChapterRead(id, markedRead),
onAppend: () => {
if (appending || !readerState.stripChapters.length) return; if (appending || !readerState.stripChapters.length) return;
appending = true; appending = true;
appendNextChapter( appendNextChapter(
@@ -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,12 +156,12 @@
<span class="ch-info">&#xE2CE;</span> <span class="ch-info">&#xE2CE;</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>
</button>
</span> </span>
</span>
{/if} {/if}
</div> </div>
{#if !isVertical} {#if !isVertical}
@@ -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); }
+18 -2
View File
@@ -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;
@@ -64,22 +74,24 @@
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
(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}
/> />
+11 -6
View File
@@ -1,7 +1,7 @@
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() {
setTimeout(() => { readerState.resumeFading = true; }, 1500); setTimeout(() => { readerState.resumeFading = true; }, 1500);
@@ -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(() => {});
+20 -4
View File
@@ -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));
+57 -16
View File
@@ -7,34 +7,65 @@ 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;
onMarkRead: (chapterId: number) => void; onCenterIdxChange: (flatIdx: number) => void;
onAppend: () => void; onMarkRead: (chapterId: number) => void;
getStripChapters: () => StripChapter[]; onAppend: () => void;
getPageUrls: () => string[]; getStripChapters: () => StripChapter[];
shouldAutoMark: () => boolean; 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( 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,10 +76,19 @@ export function setupScrollTracking(
onPageChange(activePage); onPageChange(activePage);
if (activeChId) onChapterChange(activeChId); 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) { if (shouldAutoMark() && activeChId) {
const chunks = getStripChapters(); const chunk = chunks.find(c => c.chapterId === activeChId);
const chunk = chunks.find(c => c.chapterId === activeChId); const total = chunk ? chunk.urls.length : getPageUrls().length;
const total = chunk ? chunk.urls.length : getPageUrls().length;
if (total > 0 && activePage >= total) onMarkRead(activeChId); if (total > 0 && activePage >= total) onMarkRead(activeChId);
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40; const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
@@ -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() {
+11 -4
View File
@@ -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();
} }