mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Vendored
+10
@@ -112,7 +112,17 @@ export function deprioritizeQueue(): void {
|
|||||||
queue.sort((a, b) => b.priority - a.priority);
|
queue.sort((a, b) => b.priority - a.priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelQueuedFetches(): void {
|
||||||
|
const dropped = queue.splice(0);
|
||||||
|
for (const entry of dropped) {
|
||||||
|
inflight.delete(entry.url);
|
||||||
|
entry.reject(new DOMException("Cancelled", "AbortError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function clearBlobCache(): void {
|
export function clearBlobCache(): void {
|
||||||
|
cancelQueuedFetches();
|
||||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
inflight.clear();
|
||||||
}
|
}
|
||||||
Vendored
+12
-7
@@ -1,12 +1,11 @@
|
|||||||
import { gql, getServerUrl } from "@api/client";
|
import { gql, getServerUrl } from "@api/client";
|
||||||
import { getBlobUrl } from "@core/cache/imageCache";
|
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||||
import { dedupeRequest } from "@core/async/batchRequests";
|
import { dedupeRequest } from "@core/async/batchRequests";
|
||||||
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
const resolvedUrlCache = new Map<string, Promise<string>>();
|
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||||
const preloadedUrls = new Set<string>();
|
|
||||||
const aspectCache = new Map<string, number>();
|
const aspectCache = new Map<string, number>();
|
||||||
|
|
||||||
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||||
@@ -63,11 +62,18 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function preloadImage(url: string, useBlob: boolean): void {
|
export function preloadImage(url: string, useBlob: boolean): void {
|
||||||
if (preloadedUrls.has(url)) return;
|
if (useBlob) {
|
||||||
preloadedUrls.add(url);
|
preloadBlobUrls([url], 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearResolvedUrlCache(): void {
|
||||||
|
resolvedUrlCache.clear();
|
||||||
|
aspectCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
export function clearPageCache(chapterId?: number): void {
|
export function clearPageCache(chapterId?: number): void {
|
||||||
if (chapterId !== undefined) {
|
if (chapterId !== undefined) {
|
||||||
pageCache.delete(chapterId);
|
pageCache.delete(chapterId);
|
||||||
@@ -76,7 +82,6 @@ export function clearPageCache(chapterId?: number): void {
|
|||||||
pageCache.clear();
|
pageCache.clear();
|
||||||
inflight.clear();
|
inflight.clear();
|
||||||
resolvedUrlCache.clear();
|
resolvedUrlCache.clear();
|
||||||
preloadedUrls.clear();
|
|
||||||
aspectCache.clear();
|
aspectCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
fadingOut: boolean;
|
fadingOut: boolean;
|
||||||
tapToToggleBar: boolean;
|
tapToToggleBar: boolean;
|
||||||
pinchZoomEnabled: boolean;
|
pinchZoomEnabled: boolean;
|
||||||
|
chapterEpoch: number;
|
||||||
onGetZoom: () => number;
|
onGetZoom: () => number;
|
||||||
onSetZoom: (z: number) => void;
|
onSetZoom: (z: number) => void;
|
||||||
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||||
@@ -31,10 +32,152 @@
|
|||||||
const {
|
const {
|
||||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||||
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom,
|
tapToToggleBar, pinchZoomEnabled, chapterEpoch, onGetZoom, onSetZoom,
|
||||||
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const LOAD_RADIUS = 5;
|
||||||
|
const UNLOAD_RADIUS = 10;
|
||||||
|
|
||||||
|
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
|
||||||
|
|
||||||
|
const flatPages = $derived.by<FlatPage[]>(() => {
|
||||||
|
const out: FlatPage[] = [];
|
||||||
|
for (const chunk of stripToRender) {
|
||||||
|
for (let i = 0; i < chunk.urls.length; i++) {
|
||||||
|
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let loadedSet = $state(new Set<number>());
|
||||||
|
let resolvedSrc = $state<Record<number, string>>({});
|
||||||
|
let revokeQueue: string[] = [];
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
const elementIndex = new Map<Element, number>();
|
||||||
|
|
||||||
|
let viewportCenter = $state(0);
|
||||||
|
|
||||||
|
function scheduleRevoke(src: string) {
|
||||||
|
if (!src || !src.startsWith("blob:")) return;
|
||||||
|
revokeQueue.push(src);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const url = revokeQueue.shift();
|
||||||
|
if (url) {
|
||||||
|
try { URL.revokeObjectURL(url); } catch { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPage(idx: number) {
|
||||||
|
if (loadedSet.has(idx)) return;
|
||||||
|
const page = flatPages[idx];
|
||||||
|
if (!page) return;
|
||||||
|
const newSet = new Set(loadedSet);
|
||||||
|
newSet.add(idx);
|
||||||
|
loadedSet = newSet;
|
||||||
|
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
|
||||||
|
resolveUrl(page.url, priority).then(src => {
|
||||||
|
if (loadedSet.has(idx)) {
|
||||||
|
resolvedSrc = { ...resolvedSrc, [idx]: src };
|
||||||
|
} else {
|
||||||
|
scheduleRevoke(src);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadPage(idx: number) {
|
||||||
|
if (!loadedSet.has(idx)) return;
|
||||||
|
const newSet = new Set(loadedSet);
|
||||||
|
newSet.delete(idx);
|
||||||
|
loadedSet = newSet;
|
||||||
|
const oldSrc = resolvedSrc[idx];
|
||||||
|
if (oldSrc) {
|
||||||
|
const next = { ...resolvedSrc };
|
||||||
|
delete next[idx];
|
||||||
|
resolvedSrc = next;
|
||||||
|
scheduleRevoke(oldSrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcWindow() {
|
||||||
|
const center = viewportCenter;
|
||||||
|
const lo = center - LOAD_RADIUS;
|
||||||
|
const hi = center + LOAD_RADIUS;
|
||||||
|
const evictLo = center - UNLOAD_RADIUS;
|
||||||
|
const evictHi = center + UNLOAD_RADIUS;
|
||||||
|
|
||||||
|
for (let i = 0; i < flatPages.length; i++) {
|
||||||
|
if (i >= lo && i <= hi) {
|
||||||
|
loadPage(i);
|
||||||
|
} else if (i < evictLo || i > evictHi) {
|
||||||
|
unloadPage(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void viewportCenter;
|
||||||
|
recalcWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void flatPages.length;
|
||||||
|
recalcWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void chapterEpoch;
|
||||||
|
loadedSet = new Set<number>();
|
||||||
|
resolvedSrc = {};
|
||||||
|
const resume = readerState.resumePage;
|
||||||
|
viewportCenter = resume > 1 ? resume - 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
const INSPECT_ZOOM_STEP = 0.15;
|
const INSPECT_ZOOM_STEP = 0.15;
|
||||||
const INSPECT_ZOOM_MAX = 8;
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
@@ -194,7 +337,17 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -221,61 +374,77 @@
|
|||||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if style === "longstrip"}
|
{#key chapterEpoch}
|
||||||
{#each stripToRender as chunk}
|
{#if style === "longstrip"}
|
||||||
{#each chunk.urls as url, i}
|
{#each flatPages as page, gi}
|
||||||
{#if i < 8}
|
{@const src = resolvedSrc[gi]}
|
||||||
{#await resolveUrl(url, 8 - i)}
|
{@const isLoaded = loadedSet.has(gi)}
|
||||||
<img src="" alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading="eager" decoding="async" />
|
<div
|
||||||
{:then src}
|
class="strip-slot"
|
||||||
<img {src} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading="eager" decoding="async" />
|
use:observePage={gi}
|
||||||
{/await}
|
data-gi={gi}
|
||||||
{:else}
|
>
|
||||||
{#await resolveUrl(url, 0)}
|
{#if isLoaded}
|
||||||
<img src="" alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading="lazy" decoding="async" />
|
<img
|
||||||
{:then src}
|
src={src ?? ""}
|
||||||
<img {src} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading="lazy" decoding="async" />
|
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
||||||
{/await}
|
data-local-page={page.localIndex + 1}
|
||||||
{/if}
|
data-chapter={page.chapterId}
|
||||||
{/each}
|
data-total={page.total}
|
||||||
{/each}
|
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
|
||||||
<div style="height:1px;flex-shrink:0"></div>
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
{:else if style === "fade" && pageReady}
|
onload={(e) => {
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
const slot = img.closest<HTMLElement>(".strip-slot");
|
||||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
if (slot && img.naturalWidth > 0) {
|
||||||
{:then src}
|
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
}
|
||||||
{/await}
|
}}
|
||||||
</div>
|
/>
|
||||||
|
{:else}
|
||||||
{:else if style === "double" && pageReady}
|
<div class="strip-placeholder"></div>
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
{/if}
|
||||||
{#if pageGroups.length}
|
|
||||||
<div class="double-wrap">
|
|
||||||
{#each currentGroup as pg, i}
|
|
||||||
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
|
||||||
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
|
||||||
{:then src}
|
|
||||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
|
||||||
{/await}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/each}
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div style="height:1px;flex-shrink:0"></div>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if 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(store.pageUrls[store.pageNumber - 1], 999)}
|
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
||||||
{:then src}
|
{:then src}
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
{:else if style === "double" && pageReady}
|
||||||
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
|
{#if pageGroups.length}
|
||||||
|
<div class="double-wrap">
|
||||||
|
{#each currentGroup as pg, i}
|
||||||
|
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
||||||
|
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||||
|
{:then src}
|
||||||
|
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||||
|
{/await}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{: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(store.pageUrls[store.pageNumber - 1], 999)}
|
||||||
|
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||||||
|
{:then src}
|
||||||
|
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -290,6 +459,20 @@
|
|||||||
|
|
||||||
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||||
|
|
||||||
|
.strip-slot {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-placeholder {
|
||||||
|
width: var(--effective-width, 100%);
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
aspect-ratio: var(--aspect, 0.667);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
.img { display: block; user-select: none; image-rendering: auto; }
|
||||||
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||||
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||||
|
|||||||
@@ -420,23 +420,28 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ahead = store.settings.preloadPages ?? 3;
|
const ahead = store.settings.preloadPages ?? 3;
|
||||||
const current = store.pageUrls[store.pageNumber - 1];
|
const current = store.pageUrls[store.pageNumber - 1];
|
||||||
|
const pageNum = store.pageNumber;
|
||||||
|
const urls = store.pageUrls;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
if (useBlob) {
|
const t = setTimeout(() => {
|
||||||
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
if (useBlob) {
|
||||||
getBlobUrl(current, 999);
|
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
||||||
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
|
getBlobUrl(current, 999);
|
||||||
const behind = store.pageUrls[store.pageNumber - 2];
|
const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
|
||||||
preloadBlobUrls(upcoming, ahead);
|
const behind = urls[pageNum - 2];
|
||||||
if (behind) preloadBlobUrls([behind], 0);
|
preloadBlobUrls(upcoming, ahead);
|
||||||
});
|
if (behind) preloadBlobUrls([behind], 0);
|
||||||
} else {
|
});
|
||||||
for (let i = 1; i <= ahead; i++) {
|
} else {
|
||||||
const url = store.pageUrls[store.pageNumber - 1 + i];
|
for (let i = 1; i <= ahead; i++) {
|
||||||
if (url) preloadImage(url, useBlob);
|
const url = urls[pageNum - 1 + i];
|
||||||
|
if (url) preloadImage(url, useBlob);
|
||||||
|
}
|
||||||
|
const behind = urls[pageNum - 2];
|
||||||
|
if (behind) preloadImage(behind, useBlob);
|
||||||
}
|
}
|
||||||
const behind = store.pageUrls[store.pageNumber - 2];
|
}, 150);
|
||||||
if (behind) preloadImage(behind, useBlob);
|
return () => clearTimeout(t);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { store, openReader } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { readerState } from "../store/readerState.svelte";
|
import { readerState } from "../store/readerState.svelte";
|
||||||
import { fetchPages } from "./pageLoader";
|
import { fetchPages } from "./pageLoader";
|
||||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
import { cancelQueuedFetches } from "@core/cache/imageCache";
|
||||||
|
import { clearResolvedUrlCache } from "@core/cache/pageCache";
|
||||||
|
|
||||||
export function scheduleResumeDismiss() {
|
export function scheduleResumeDismiss() {
|
||||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||||
@@ -19,6 +21,10 @@ export async function loadChapter(
|
|||||||
abortCtrl.current?.abort();
|
abortCtrl.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortCtrl.current = ctrl;
|
abortCtrl.current = ctrl;
|
||||||
|
|
||||||
|
cancelQueuedFetches();
|
||||||
|
if (useBlob) clearResolvedUrlCache();
|
||||||
|
|
||||||
startAtLastPage.current = false;
|
startAtLastPage.current = false;
|
||||||
markedRead.clear();
|
markedRead.clear();
|
||||||
readerState.resetForChapter();
|
readerState.resetForChapter();
|
||||||
@@ -43,7 +49,7 @@ export async function loadChapter(
|
|||||||
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||||
readerState.pageReady = true;
|
readerState.pageReady = true;
|
||||||
readerState.loading = false;
|
readerState.loading = false;
|
||||||
if (adjacent.next) fetchPages(adjacent.next.id, useBlob).catch(() => {});
|
if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
readerState.error = e instanceof Error ? e.message : String(e);
|
readerState.error = e instanceof Error ? e.message : String(e);
|
||||||
|
|||||||
@@ -25,57 +25,58 @@ export function setupScrollTracking(
|
|||||||
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
||||||
} = callbacks;
|
} = callbacks;
|
||||||
|
|
||||||
function onScroll() {
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
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 containerTop = containerEl.getBoundingClientRect().top;
|
||||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
||||||
|
|
||||||
let activePage: number | null = null;
|
let lo = 0, hi = imgs.length - 1, best = 0;
|
||||||
let activeChId: number | null = null;
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
for (const img of imgs) {
|
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
|
||||||
if (img.getBoundingClientRect().top <= readLineY) {
|
else hi = mid - 1;
|
||||||
activePage = Number(img.dataset.localPage);
|
|
||||||
activeChId = Number(img.dataset.chapter);
|
|
||||||
} else break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePage === null) {
|
const active = imgs[best];
|
||||||
activePage = Number(imgs[0].dataset.localPage);
|
const activePage = Number(active.dataset.localPage);
|
||||||
activeChId = Number(imgs[0].dataset.chapter);
|
const activeChId = Number(active.dataset.chapter);
|
||||||
}
|
|
||||||
|
|
||||||
if (activePage !== null) onPageChange(activePage);
|
onPageChange(activePage);
|
||||||
if (activeChId) onChapterChange(activeChId);
|
if (activeChId) onChapterChange(activeChId);
|
||||||
|
|
||||||
if (shouldAutoMark() && activePage !== null && activeChId) {
|
if (shouldAutoMark() && activeChId) {
|
||||||
const chunks = getStripChapters();
|
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;
|
||||||
|
if (atBottom) {
|
||||||
|
const last = chunks[chunks.length - 1];
|
||||||
|
if (last) onMarkRead(last.chapterId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
|
|
||||||
if (atBottom && shouldAutoMark()) {
|
|
||||||
const chunks = getStripChapters();
|
|
||||||
const last = chunks[chunks.length - 1];
|
|
||||||
if (last) onMarkRead(last.chapterId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScrollAppend() {
|
|
||||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||||
if (pct >= 0.80) onAppend();
|
if (pct >= 0.80) onAppend();
|
||||||
}
|
}
|
||||||
|
|
||||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
function onScroll() {
|
||||||
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
|
if (rafId !== null) return;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
containerEl.removeEventListener("scroll", onScroll);
|
containerEl.removeEventListener("scroll", onScroll);
|
||||||
containerEl.removeEventListener("scroll", onScrollAppend);
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user