mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Fix: Reader Longstrip Bookmark + ProgressBar
This commit is contained in:
@@ -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,8 +141,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function recalcWindow() {
|
||||
const center = viewportCenter;
|
||||
function recalcWindow(center: number) {
|
||||
const lo = center - LOAD_RADIUS;
|
||||
const hi = center + LOAD_RADIUS;
|
||||
const evictLo = center - UNLOAD_RADIUS;
|
||||
@@ -111,39 +152,139 @@
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
@@ -151,10 +292,11 @@
|
||||
lastChapterId = chapterId;
|
||||
loadedSet = new Set<number>();
|
||||
resolvedSrc = {};
|
||||
const resume = readerState.resumePage;
|
||||
viewportCenter = resume > 1 ? resume - 1 : 0;
|
||||
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>
|
||||
|
||||
@@ -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,7 +262,7 @@
|
||||
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); },
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -432,6 +453,7 @@
|
||||
cleanupScroll = setupScrollTracking(containerEl!, {
|
||||
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;
|
||||
@@ -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,11 +156,11 @@
|
||||
<span class="ch-info"></span>
|
||||
{:else}
|
||||
<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-sep">/</span>
|
||||
<span class="ch-name">{displayChapter?.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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); }
|
||||
|
||||
@@ -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}%`);
|
||||
|
||||
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;
|
||||
@@ -66,20 +76,22 @@
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readerState } from "$lib/state/reader.svelte";
|
||||
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";
|
||||
|
||||
export function scheduleResumeDismiss() {
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface StripChapter {
|
||||
export interface ScrollHandlerCallbacks {
|
||||
onPageChange: (page: number) => void;
|
||||
onChapterChange: (chapterId: number) => void;
|
||||
onCenterIdxChange: (flatIdx: number) => void;
|
||||
onMarkRead: (chapterId: number) => void;
|
||||
onAppend: () => void;
|
||||
getStripChapters: () => StripChapter[];
|
||||
@@ -16,25 +17,55 @@ export interface ScrollHandlerCallbacks {
|
||||
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,8 +76,17 @@ export function setupScrollTracking(
|
||||
onPageChange(activePage);
|
||||
if (activeChId) onChapterChange(activeChId);
|
||||
|
||||
if (shouldAutoMark() && activeChId) {
|
||||
const chunks = getStripChapters();
|
||||
let flatOffset = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.chapterId === activeChId) {
|
||||
onCenterIdxChange(flatOffset + activePage - 1);
|
||||
break;
|
||||
}
|
||||
flatOffset += chunk.urls.length;
|
||||
}
|
||||
|
||||
if (shouldAutoMark() && activeChId) {
|
||||
const chunk = chunks.find(c => c.chapterId === activeChId);
|
||||
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
||||
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user