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">
import { tick } from "svelte";
import { readerState } from "$lib/state/reader.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
@@ -54,11 +55,51 @@
let resolvedSrc = $state<Record<number, string>>({});
let revokeQueue: string[] = [];
let observer: IntersectionObserver | null = null;
const elementIndex = new Map<Element, number>();
// Aspect ratios (w/h) keyed by flat index, written by the img onload handler.
// Retained as a fallback for scrollToFlatIndex when a slot is not yet in DOM.
const aspectMap = new Map<number, number>();
let 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) {
if (!src || !src.startsWith("blob:")) return;
revokeQueue.push(src);
@@ -68,6 +109,7 @@
});
}
// ── Load window management ────────────────────────────────────────────────
function loadPage(idx: number) {
if (loadedSet.has(idx)) return;
const page = flatPages[idx];
@@ -99,62 +141,162 @@
}
}
function recalcWindow() {
const center = viewportCenter;
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
function recalcWindow(center: number) {
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
const evictLo = center - UNLOAD_RADIUS;
const evictHi = center + UNLOAD_RADIUS;
for (let i = 0; i < flatPages.length; i++) {
if (i >= lo && i <= hi) loadPage(i);
else if (i < evictLo || i > evictHi) unloadPage(i);
if (i >= lo && i <= hi) loadPage(i);
else if (i < evictLo || i > evictHi) unloadPage(i);
}
}
$effect(() => { void viewportCenter; recalcWindow(); });
$effect(() => { void flatPages.length; recalcWindow(); });
$effect(() => { recalcWindow(centerIdx); });
$effect(() => { void flatPages.length; recalcWindow(centerIdx); });
function setupObserver(containerEl: HTMLElement) {
observer?.disconnect();
elementIndex.clear();
observer = new IntersectionObserver(
(entries) => {
let best = -1;
let bestRatio = -1;
for (const entry of entries) {
const idx = elementIndex.get(entry.target);
if (idx === undefined) continue;
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
bestRatio = entry.intersectionRatio;
best = idx;
}
}
if (best >= 0 && best !== viewportCenter) viewportCenter = best;
},
{ root: containerEl, rootMargin: "0px", threshold: [0, 0.1, 0.5, 1.0] },
);
}
// ── Scroll position preservation on image resize above viewport ───────────
// Ported from Suwayomi's usePreserveOnLeadingPageRender.
//
// Problem: when a placeholder above the current scroll position loads its
// real image and changes height, the browser shifts the scroll position
// relative to the viewport (layout shift). This corrects for that by:
// 1. Tracking the first visible image and its offsetTop at last scroll.
// 2. On every ResizeObserver entry for an image above scrollTop, computing
// the delta and applying it as a scroll correction.
//
// MutationObserver watches for images being added/removed so the
// ResizeObserver stays in sync with the actual DOM without needing
// querySelectorAll on every scroll tick.
$effect(() => {
if (style !== "longstrip" || !containerEl) return;
function observePage(el: HTMLDivElement, idx: number) {
elementIndex.set(el, idx);
observer?.observe(el);
return {
update(newIdx: number) { elementIndex.set(el, newIdx); },
destroy() { observer?.unobserve(el); elementIndex.delete(el); },
let visibleImg: HTMLElement | undefined;
let visibleImgTop = 0;
let lastScrollTop = 0;
const onScroll = () => {
lastScrollTop = containerEl.scrollTop;
if (visibleImg) {
visibleImgTop = visibleImg.offsetTop;
}
};
containerEl.addEventListener("scroll", onScroll, { passive: true });
const intersectionObs = new IntersectionObserver((entries) => {
const first = entries.find(e => e.isIntersecting);
if (first?.target instanceof HTMLElement) {
visibleImg = first.target;
visibleImgTop = first.target.offsetTop;
}
});
const resizeObs = new ResizeObserver((entries) => {
if (!visibleImg) return;
const hasEntryBeforeScroll = entries.some(e => {
if (!(e.target instanceof HTMLElement)) return false;
// Skip zero-size preload placeholders (they are outside the load window)
if (!e.target.clientWidth && !e.target.clientHeight) return false;
return e.target.offsetTop < lastScrollTop;
});
if (!hasEntryBeforeScroll) return;
const newTop = lastScrollTop - visibleImgTop + visibleImg.offsetTop;
containerEl.scrollTo({ top: newTop, behavior: "instant" } as ScrollToOptions);
});
const observe = (el: Element) => { intersectionObs.observe(el); resizeObs.observe(el); };
const unobserve = (el: Element) => { intersectionObs.unobserve(el); resizeObs.unobserve(el); };
const mutationObs = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach(n => {
if (!(n instanceof HTMLElement)) return;
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
imgs.forEach(observe);
});
m.removedNodes.forEach(n => {
if (!(n instanceof HTMLElement)) return;
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
imgs.forEach(unobserve);
});
}
});
mutationObs.observe(containerEl, { childList: true, subtree: true });
// Observe images already in the DOM at setup time
containerEl.querySelectorAll("img").forEach(observe);
return () => {
containerEl.removeEventListener("scroll", onScroll);
mutationObs.disconnect();
resizeObs.disconnect();
intersectionObs.disconnect();
};
});
// ── Cursor hide on inactivity (longstrip) ─────────────────────────────────
// Ported from Suwayomi's useReaderHideCursorOnInactivity.
// Hides the cursor after 5 s of mouse inactivity, restores on movement.
$effect(() => {
if (style !== "longstrip" || !containerEl) return;
const HIDE_AFTER_MS = 5_000;
let timer: ReturnType<typeof setTimeout> | null = null;
const show = () => {
containerEl.style.cursor = "";
if (timer) clearTimeout(timer);
timer = setTimeout(() => { containerEl.style.cursor = "none"; }, HIDE_AFTER_MS);
};
show(); // start the timer immediately
window.addEventListener("mousemove", show, { passive: true });
return () => {
containerEl.style.cursor = "";
window.removeEventListener("mousemove", show);
if (timer) clearTimeout(timer);
};
});
// ── Scroll to target flat index ───────────────────────────────────────────
export function notifyScrollCenter(idx: number) {
centerIdx = idx;
}
export async function scrollToFlatIndex(idx: number) {
if (!containerEl || !flatPages.length) return;
centerIdx = idx;
recalcWindow(idx);
// Wait for Svelte to render any newly-in-window slots.
await tick();
if (!containerEl) return;
// Use scrollIntoView — the browser knows the exact element position
// regardless of image load state or aspect ratio. This is the same approach
// used by Suwayomi's useReaderHandlePageSelection (imageRef.scrollIntoView).
const slots = containerEl.querySelectorAll<HTMLElement>(".strip-slot");
const 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;
$effect(() => {
const chapterId = readerState.activeChapter?.id ?? 0;
if (chapterId === lastChapterId) return;
lastChapterId = chapterId;
loadedSet = new Set<number>();
resolvedSrc = {};
const resume = readerState.resumePage;
viewportCenter = resume > 1 ? resume - 1 : 0;
loadedSet = new Set<number>();
resolvedSrc = {};
centerIdx = 0;
aspectMap.clear();
});
// ── Inspect / zoom helpers ────────────────────────────────────────────────
const INSPECT_ZOOM_STEP = 0.15;
const INSPECT_ZOOM_MAX = 8;
@@ -238,6 +380,11 @@
return () => cancelAnimationFrame(rafId);
});
$effect(() => {
if (style !== "longstrip") stopMidScroll();
});
// ── Pinch zoom ────────────────────────────────────────────────────────────
let pinch: PinchTracker | null = null;
$effect(() => {
@@ -255,6 +402,7 @@
}
});
// ── Pointer / mouse / wheel event handlers ────────────────────────────────
export function onInspectMouseDown(e: MouseEvent) {
if ((e.target as Element).closest(".bar")) return;
if (e.button === 1 && style === "longstrip") {
@@ -388,18 +536,7 @@
function setContainer(el: HTMLDivElement) {
containerEl = el;
bindContainer(el);
if (style === "longstrip") setupObserver(el);
}
$effect(() => {
if (style === "longstrip" && containerEl) {
setupObserver(containerEl);
} else if (style !== "longstrip") {
observer?.disconnect();
observer = null;
stopMidScroll();
}
});
</script>
<div
@@ -459,7 +596,7 @@
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
{@const src = resolvedSrc[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}
<img
{src}
@@ -475,7 +612,9 @@
const img = e.currentTarget as HTMLImageElement;
const slot = img.closest<HTMLElement>(".strip-slot");
if (slot && img.naturalWidth > 0) {
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
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}
<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>
{:then src}
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
{/await}
{/if}
</div>
{:else if style === "double" && pageReady}
@@ -502,11 +641,11 @@
{#if pageGroups.length}
<div class="double-wrap">
{#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>
{:then src}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
{/await}
{/if}
{/each}
</div>
{:else}
@@ -518,11 +657,11 @@
{:else if pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(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>
{:then src}
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
{/await}
{/if}
</div>
{/if}
</div>
+47 -24
View File
@@ -12,9 +12,10 @@
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
import { historyState } from "$lib/state/history.svelte";
import { setPreviewManga } from "$lib/state/series.svelte";
import { getAdapter } from "$lib/request-manager";
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 ReaderControls from "$lib/components/reader/ReaderControls.svelte";
import PageView from "$lib/components/reader/PageView.svelte";
@@ -211,6 +212,36 @@
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
? () => goBack(style, adjacent, startAtLast)
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
@@ -218,7 +249,6 @@
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
: () => goBack(style, adjacent, startAtLast));
// clear Discord presence and free page blob textures before closing
function handleCloseReader() {
clearReading().catch(() => {});
for (const url of readerState.pageUrls) revokeBlobUrl(url);
@@ -232,13 +262,13 @@
goNext: () => goNext(),
goPrev: () => goPrev(),
closeReader: () => handleCloseReader(),
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
goToPage: (p) => primedJump(p),
lastPage: () => lastPage,
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); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => { app.setSettingsOpen(true); },
openSettings: () => { app.setSettingsOpen(true); },
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(settingsState.settings.autoScroll ?? false) }); },
toggleMarker: () => {
@@ -325,7 +355,6 @@
}
});
// Separate from chapter load: also re-fires when idle splash dismisses so presence is restored.
$effect(() => {
const ch = readerState.activeChapter;
const manga = readerState.activeManga;
@@ -361,26 +390,18 @@
if (style === "longstrip" && readerState.pageUrls.length && readerState.activeChapter) {
const ch = readerState.activeChapter;
const urls = readerState.pageUrls;
const targetPg = untrack(() => readerState.resumePage);
const resumeTo = untrack(() => readerState.resumePage);
appending = false;
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
readerState.visibleChapterId = ch.id;
tick().then(() => {
if (!containerEl) return;
if (targetPg > 1) {
const chId = ch.id;
const scrollToResumePage = () => {
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();
if (resumeTo > 1) {
pageViewRef.scrollToFlatIndex(resumeTo - 1);
readerState.stripResumeReady = true;
return;
}
containerEl!.scrollTop = 0;
containerEl.scrollTop = 0;
});
}
});
@@ -430,10 +451,11 @@
untrack(() => {
cleanupScroll();
cleanupScroll = setupScrollTracking(containerEl!, {
onPageChange: (p) => { readerState.pageNumber = p; },
onChapterChange: (id) => { readerState.visibleChapterId = id; },
onMarkRead: (id) => markChapterRead(id, markedRead),
onAppend: () => {
onPageChange: (p) => { readerState.pageNumber = p; },
onChapterChange: (id) => { readerState.visibleChapterId = id; },
onCenterIdxChange: (idx) => { pageViewRef?.notifyScrollCenter(idx); },
onMarkRead: (id) => markChapterRead(id, markedRead),
onAppend: () => {
if (appending || !readerState.stripChapters.length) return;
appending = true;
appendNextChapter(
@@ -628,6 +650,7 @@
onClampZoom={clampZoom}
onApplySettings={applySettings}
onSettingsOpen={() => { app.setSettingsOpen(true); }}
onOpenPreview={() => { if (readerState.activeManga) setPreviewManga(readerState.activeManga); }}
{perMangaEnabled}
/>
@@ -688,7 +711,7 @@
{barPosition}
onGoPrev={goPrev}
onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
onJumpToPage={(p, commit) => primedJump(p, commit)}
/>
{/snippet}
@@ -702,7 +725,7 @@
{barPosition}
onGoPrev={goPrev}
onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
onJumpToPage={(p, commit) => primedJump(p, commit)}
/>
{/if}
</div>
@@ -36,6 +36,7 @@
onClampZoom: (z: number) => number;
onApplySettings: (patch: Partial<ReaderSettings>) => void;
onSettingsOpen: () => void;
onOpenPreview: () => void;
perMangaEnabled: boolean;
}
@@ -47,7 +48,7 @@
barPosition, progressBar,
onCaptureZoomAnchor, onRestoreZoomAnchor,
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
onClampZoom, onApplySettings, onSettingsOpen,
onClampZoom, onApplySettings, onSettingsOpen, onOpenPreview,
perMangaEnabled,
}: Props = $props();
@@ -155,12 +156,12 @@
<span class="ch-info">&#xE2CE;</span>
{:else}
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
<span class="ch-marquee-content">
<span class="ch-title">{readerState.activeManga?.title}</span>
<span class="ch-sep">/</span>
<span class="ch-name">{displayChapter?.name}</span>
<button class="ch-marquee-content ch-preview-btn" onclick={onOpenPreview}>
<span class="ch-title">{readerState.activeManga?.title}</span>
<span class="ch-sep">/</span>
<span class="ch-name">{displayChapter?.name}</span>
</button>
</span>
</span>
{/if}
</div>
{#if !isVertical}
@@ -494,6 +495,8 @@
.ch-marquee-track { overflow-x: auto; min-width: 0; flex: 1; scrollbar-width: 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-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-sep { color: var(--text-faint); flex-shrink: 0; }
.ch-name { color: var(--text-muted); }
+18 -2
View File
@@ -21,11 +21,27 @@
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));
function onBannerAnimationEnd() {
if (bannerFading) { bannerMounted = false; bannerFading = false; }
}
</script>
{#if showResumeBanner}
<button class="resume-banner" class:fading={resumeFading} onclick={onDismissResume}>
{#if bannerMounted}
<button class="resume-banner" class:fading={bannerFading} onclick={onDismissResume} onanimationend={onBannerAnimationEnd}>
Bookmark at page {resumePage}
</button>
{/if}
@@ -20,7 +20,7 @@
barPosition: "top" | "left" | "right";
onGoPrev: () => void;
onGoNext: () => void;
onJumpToPage: (page: number) => void;
onJumpToPage: (page: number, commit?: boolean) => void;
}
const {
@@ -32,12 +32,22 @@
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) {
const raw = Number((e.target as HTMLInputElement).value);
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), false);
}
function handleHCommit(e: Event) {
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), true);
}
function markerPct(pageNumber: number, forRtl = false): number {
@@ -46,9 +56,9 @@
return ((ord - 1) / (sliderMax - 1)) * 100;
}
// Custom vertical slider
let trackEl = $state<HTMLDivElement | null>(null);
let dragging = $state(false);
let pendingPage = 0;
function pctFromPointer(clientY: number): number {
if (!trackEl) return 0;
@@ -64,22 +74,24 @@
if (e.button !== 0) return;
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragging = true;
dragging = true;
readerState.sliderDragging = true;
const pct = pctFromPointer(e.clientY);
onJumpToPage(pageFromPct(pct));
pendingPage = pageFromPct(pctFromPointer(e.clientY));
onJumpToPage(pendingPage, false);
}
function handleTrackPointerMove(e: PointerEvent) {
if (!dragging) return;
const pct = pctFromPointer(e.clientY);
onJumpToPage(pageFromPct(pct));
pendingPage = pageFromPct(pctFromPointer(e.clientY));
onJumpToPage(pendingPage, false);
}
function handleTrackPointerUp(e: PointerEvent) {
if (!dragging) return;
dragging = false;
readerState.sliderDragging = false;
readerState.sliderHover = false;
onJumpToPage(pendingPage, true);
}
</script>
@@ -102,8 +114,9 @@
style={hPct}
min={1}
max={sliderMax}
value={hValue}
value={pageToSliderVal(sliderPage)}
oninput={handleH}
onchange={handleHCommit}
onmousedown={() => readerState.sliderDragging = true}
onmouseup={() => readerState.sliderDragging = false}
/>
+11 -6
View File
@@ -1,7 +1,7 @@
import { readerState } from "$lib/state/reader.svelte";
import { fetchPages } from "./pageLoader";
import { cancelQueuedFetches, revokeBlobUrl } from "$lib/core/cache/imageCache";
import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache";
import { readerState } from "$lib/state/reader.svelte";
import { fetchPages } from "./pageLoader";
import { cancelQueuedFetches, revokeBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache";
export function scheduleResumeDismiss() {
setTimeout(() => { readerState.resumeFading = true; }, 1500);
@@ -46,18 +46,23 @@ export async function loadChapter(
const resumeTo = bookmark ? bookmark.pageNumber : 0;
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
readerState.resumeDismissed = false;
readerState.resumeVisible = resumeTo > 1;
if (resumeTo > 1) scheduleResumeDismiss();
readerState.resumeVisible = false;
readerState.pageNumber = 1;
try {
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
if (ctrl.signal.aborted) return;
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;
else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
readerState.pageReady = true;
readerState.loading = false;
if (resumeTo > 1) readerState.resumeVisible = true;
if (adjacent.next) {
prefetchedChapterId = adjacent.next.id;
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); }
}
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") {
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" });
if (!scrollToFlatIndex || flatPageCount === 0) return;
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;
}
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];
} else {
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
+57 -16
View File
@@ -7,34 +7,65 @@ export interface StripChapter {
}
export interface ScrollHandlerCallbacks {
onPageChange: (page: number) => void;
onChapterChange: (chapterId: number) => void;
onMarkRead: (chapterId: number) => void;
onAppend: () => void;
getStripChapters: () => StripChapter[];
getPageUrls: () => string[];
shouldAutoMark: () => boolean;
onPageChange: (page: number) => void;
onChapterChange: (chapterId: number) => void;
onCenterIdxChange: (flatIdx: number) => void;
onMarkRead: (chapterId: number) => void;
onAppend: () => void;
getStripChapters: () => StripChapter[];
getPageUrls: () => string[];
shouldAutoMark: () => boolean;
}
/**
* Returns true if the element is considered "at" the read-line.
*
* Ported from Suwayomi's ReaderPager.utils `isPageInViewport`:
* - If the element's top is above the line AND its bottom is below it fully covers the line
* (handles a single page that is taller than the viewport).
* - If the element's top is at or below the line AND its bottom is also below it leading edge
* has crossed the line (normal scroll-past case).
*
* Using Math.trunc to avoid floating-point jitter from getBoundingClientRect.
*/
function isPageAtReadLine(el: HTMLElement, readLineY: number): boolean {
const rect = el.getBoundingClientRect();
const top = Math.trunc(rect.top);
const bottom = Math.trunc(rect.bottom);
const line = Math.trunc(readLineY);
// Element completely spans the read line (taller than viewport or very tall image)
if (top <= line && bottom >= line) return true;
// Element's top edge is at or above the line
if (top <= line) return true;
return false;
}
export function setupScrollTracking(
containerEl: HTMLElement,
callbacks: ScrollHandlerCallbacks,
): () => void {
const { onPageChange, onChapterChange, onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark } = callbacks;
const {
onPageChange, onChapterChange, onCenterIdxChange,
onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark,
} = callbacks;
let rafId: number | null = null;
function tick() {
rafId = null;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
if (!imgs.length) return;
const containerTop = containerEl.getBoundingClientRect().top;
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
const containerRect = containerEl.getBoundingClientRect();
const readLineY = containerRect.top + containerEl.clientHeight * READ_LINE_PCT;
// Find the last image whose top is at or above the read line.
// Binary search is still valid here since images are ordered top-to-bottom.
let lo = 0, hi = imgs.length - 1, best = 0;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
if (isPageAtReadLine(imgs[mid], readLineY)) { best = mid; lo = mid + 1; }
else hi = mid - 1;
}
@@ -45,10 +76,19 @@ export function setupScrollTracking(
onPageChange(activePage);
if (activeChId) onChapterChange(activeChId);
const chunks = getStripChapters();
let flatOffset = 0;
for (const chunk of chunks) {
if (chunk.chapterId === activeChId) {
onCenterIdxChange(flatOffset + activePage - 1);
break;
}
flatOffset += chunk.urls.length;
}
if (shouldAutoMark() && activeChId) {
const chunks = getStripChapters();
const chunk = chunks.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : getPageUrls().length;
const chunk = chunks.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : getPageUrls().length;
if (total > 0 && activePage >= total) onMarkRead(activeChId);
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
@@ -58,8 +98,9 @@ export function setupScrollTracking(
}
}
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.80) onAppend();
if ((containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight >= 0.80) {
onAppend();
}
}
function onScroll() {
+11 -4
View File
@@ -9,7 +9,8 @@
import { get } from 'svelte/store'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
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 { setPreviewManga } from '$lib/state/series.svelte'
@@ -59,6 +60,7 @@
let manageOpen: boolean = $state(false)
let genresExpanded: boolean = $state(false)
let descExpanded: boolean = $state(false)
let altOpen: boolean = $state(false)
const statusLabel = $derived(
@@ -104,7 +106,7 @@
</div>
{:else}
<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}
<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}
<div class="desc-wrap">
<p class="desc">{manga.description}</p>
<button class="expand-toggle" onclick={() => genresExpanded = !genresExpanded}>Read more</button>
<p class="desc" class:desc-open={descExpanded}>{manga.description}</p>
<button class="expand-toggle" onclick={() => descExpanded = !descExpanded}>{descExpanded ? 'Show less' : 'Read more'}</button>
</div>
{/if}
</div>
@@ -277,7 +279,11 @@
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-primary); line-height: var(--leading-snug);
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); }
.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);
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 {
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);
@@ -259,7 +259,7 @@
function openSeriesDetail() {
if (!displayManga) return;
setActiveManga(displayManga);
setNavPage(originNavPage);
app.setNavPage(originNavPage);
close();
}