Feat: Longstrip Viewer(s) & Lag Improvements

This commit is contained in:
Youwes09
2026-06-11 23:27:01 -05:00
parent 1e159bbd73
commit 437b52fd8b
26 changed files with 1298 additions and 1325 deletions
@@ -0,0 +1,89 @@
<script lang="ts">
import { readerState } from "$lib/state/reader.svelte";
interface Props {
imgCls: string;
currentGroup: number[];
srcs: (string | null)[];
pageGroups: number[][];
}
const { imgCls, currentGroup, srcs, pageGroups }: Props = $props();
</script>
<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 (pg)}
{#if srcs[i]}
<img
src={srcs[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>
{/if}
{/each}
</div>
{:else}
<div class="center-overlay">
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
</div>
{/if}
</div>
{#snippet skeleton()}
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
</svg>
{/snippet}
<style>
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
.page-half { flex: 1; min-width: 0; object-fit: contain; }
.gap-left { margin-right: 2px; }
.gap-right { margin-left: 2px; }
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
.page-loader-single {
width: min(100%, var(--effective-width, 100%));
max-width: var(--effective-width, 100%);
max-height: calc(var(--visual-vh, 100vh) - 80px);
aspect-ratio: 2 / 3;
}
.panel-skeleton { width: 100%; height: 100%; }
.panel-skeleton :global(.ps-r) {
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
stroke-dasharray: 400; stroke-dashoffset: 400;
animation: ps-shimmer 2s ease-in-out infinite;
}
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
@keyframes ps-shimmer {
0% { stroke-dashoffset: 400; opacity: 0.25; }
40% { stroke-dashoffset: 0; opacity: 0.55; }
70% { stroke-dashoffset: 0; opacity: 0.55; }
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
</style>
@@ -0,0 +1,409 @@
<script lang="ts">
import { tick } from "svelte";
import { readerState } from "$lib/state/reader.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { getCachedAspect } from "$lib/components/reader/lib/pageLoader";
export interface StripPage {
chapterId: number;
chapterName: string;
localIndex: number;
url: string;
total: number;
}
interface Props {
containerEl: HTMLDivElement | undefined;
flatPages: StripPage[];
imgCls: string;
effectiveWidth: number | undefined;
resolveUrl: (url: string, priority?: number) => Promise<string>;
barPosition: "top" | "left" | "right";
}
const { containerEl, flatPages, imgCls, effectiveWidth, resolveUrl, barPosition }: Props = $props();
const LOAD_RADIUS = 5;
const UNLOAD_RADIUS = 10;
let _loadedSet: Set<number> = new Set();
let _resolvedSrc: Record<number, string> = {};
let _version = $state(0);
const loadedSet = { has: (i: number) => _loadedSet.has(i) };
const resolvedSrc = { get: (i: number) => _resolvedSrc[i] as string | undefined };
let revokeQueue: string[] = [];
let centerIdx = $state(0);
const aspectMap = new Map<number, number>();
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;
_loadedSet.add(idx);
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[idx] = src;
_version++;
} else {
scheduleRevoke(src);
}
});
}
function unloadPage(idx: number) {
if (!_loadedSet.has(idx)) return;
_loadedSet.delete(idx);
const aspect = aspectMap.get(idx);
if (aspect !== undefined && containerEl) {
const slot = containerEl.querySelectorAll<HTMLElement>(".strip-slot")[idx];
slot?.style.setProperty("--aspect", String(aspect));
}
const oldSrc = _resolvedSrc[idx];
if (oldSrc) {
delete _resolvedSrc[idx];
scheduleRevoke(oldSrc);
}
_version++;
}
let recalcTimer: ReturnType<typeof setTimeout> | null = null;
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);
}
}
function scheduleRecalc(center: number) {
if (recalcTimer) return;
recalcTimer = setTimeout(() => { recalcTimer = null; recalcWindow(center); }, 50);
}
$effect(() => { void _version; });
$effect(() => { recalcWindow(centerIdx); });
$effect(() => { void flatPages.length; tick().then(() => recalcWindow(centerIdx)); });
let lastChapterId = 0;
$effect(() => {
let chapterId: number;
try { chapterId = readerState.activeChapter?.id ?? 0; } catch { return; }
if (chapterId === lastChapterId) return;
lastChapterId = chapterId;
_loadedSet = new Set<number>();
_resolvedSrc = {};
centerIdx = 0;
_version++;
aspectMap.clear();
});
export function notifyScrollCenter(idx: number) {
centerIdx = idx;
scheduleRecalc(idx);
}
export async function scrollToFlatIndex(idx: number) {
if (!containerEl || !flatPages.length) return;
centerIdx = idx;
recalcWindow(idx);
await tick();
if (!containerEl) return;
const slot = containerEl.querySelectorAll<HTMLElement>(".strip-slot")[idx];
if (slot) slot.scrollIntoView({ block: "start", behavior: "instant" });
}
let anchorEl: HTMLElement | null = null;
let anchorOffset = 0;
export function captureAnchor() {
if (!containerEl) return;
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
let best: HTMLElement | null = null;
let bestTop = -Infinity;
for (const img of imgs) {
const top = img.getBoundingClientRect().top;
if (top <= readY && top > bestTop) { bestTop = top; best = img; }
}
anchorEl = best;
anchorOffset = best ? readY - best.getBoundingClientRect().top : 0;
}
export function restoreAnchor() {
if (!containerEl || !anchorEl) return;
requestAnimationFrame(() => {
if (!anchorEl || !containerEl) return;
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
const delta = (readY - anchorEl.getBoundingClientRect().top) - anchorOffset;
containerEl.scrollTop -= delta;
anchorEl = null;
});
}
let autoScrollPaused = false;
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
export function pauseAutoScroll() {
autoScrollPaused = true;
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
}
$effect(() => {
if (!settingsState.settings.autoScroll || !containerEl) return;
let rafId: number;
const tick = () => {
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (settingsState.settings.autoScrollSpeed ?? 5) * 0.5;
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
});
const HIDE_AFTER_MS = 5_000;
$effect(() => {
if (!containerEl) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const show = () => {
containerEl.style.cursor = "";
if (timer) clearTimeout(timer);
timer = setTimeout(() => { if (containerEl) containerEl.style.cursor = "none"; }, HIDE_AFTER_MS);
};
show();
window.addEventListener("mousemove", show, { passive: true });
return () => {
if (containerEl) containerEl.style.cursor = "";
window.removeEventListener("mousemove", show);
if (timer) clearTimeout(timer);
};
});
let midScrollActive = $state(false);
let midScrollOriginY = $state(0);
let midScrollCurrentY = 0;
let midScrollDisplayLevel = $state(0);
let midScrollRaf: number | null = null;
function startMidScroll(originY: number) {
midScrollActive = true;
midScrollOriginY = originY;
midScrollDisplayLevel = 0;
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
const frame = () => {
if (!midScrollActive || !containerEl) return;
const dy = midScrollCurrentY - midScrollOriginY;
const excess = Math.max(0, Math.abs(dy) - 24);
containerEl.scrollTop += Math.sign(dy) * excess * 0.12;
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
midScrollRaf = requestAnimationFrame(frame);
};
midScrollRaf = requestAnimationFrame(frame);
}
export function stopMidScroll() {
midScrollActive = false;
midScrollDisplayLevel = 0;
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
}
let stripDragging = false;
let stripDragMoved = false;
let stripDragStartY = 0;
let stripScrollStart = 0;
function setDragCursor(dragging: boolean) {
if (containerEl) containerEl.style.cursor = dragging ? "grabbing" : "";
}
export function onMouseDown(e: MouseEvent) {
if ((e.target as Element).closest(".bar")) return;
if (e.button === 1) {
e.preventDefault();
if (midScrollActive) stopMidScroll();
else { settingsState.settings.autoScroll = false; startMidScroll(e.clientY); }
return;
}
stripDragging = true;
stripDragMoved = false;
stripDragStartY = e.clientY;
stripScrollStart = containerEl?.scrollTop ?? 0;
setDragCursor(true);
pauseAutoScroll();
e.preventDefault();
}
export function onMouseMove(e: MouseEvent) {
midScrollCurrentY = e.clientY;
if (!stripDragging) return;
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
}
export function onMouseUp() {
stripDragging = false;
setDragCursor(false);
}
export function onPointerDown(e: PointerEvent) {
if ((e.target as Element).closest(".bar")) return;
stripDragging = true;
stripDragMoved = false;
stripDragStartY = e.clientY;
stripScrollStart = containerEl?.scrollTop ?? 0;
setDragCursor(true);
pauseAutoScroll();
}
export function onPointerMove(e: PointerEvent) {
if (!stripDragging) return;
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
}
export function onPointerUp() {
stripDragging = false;
setDragCursor(false);
}
export function consumeTap(): boolean {
if (stripDragMoved) { stripDragMoved = false; return true; }
return false;
}
export function onWheel(e: WheelEvent) {
if (!e.ctrlKey) pauseAutoScroll();
}
</script>
{#if midScrollActive}
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
<div class="midscroll-segments">
{#each [5,4,3,2,1] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
{/each}
<div class="midscroll-origin-dot"></div>
{#each [1,2,3,4,5] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
{/each}
</div>
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
</button>
</div>
{/if}
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
{@const src = (_version, resolvedSrc.get(gi))}
{@const isLoaded = (_version, loadedSet.has(gi))}
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId} style={getCachedAspect(page.url) != null ? `--aspect:${getCachedAspect(page.url)}` : undefined}>
{#if isLoaded && src}
<img
{src}
alt="{page.chapterName} Page {page.localIndex + 1}"
data-local-page={page.localIndex + 1}
data-chapter={page.chapterId}
data-total={page.total}
class="{imgCls}{settingsState.settings.pageGap ? ' strip-gap' : ''}"
loading="lazy"
decoding="async"
draggable="false"
onload={(e) => {
const img = e.currentTarget as HTMLImageElement;
const slot = img.closest<HTMLElement>(".strip-slot");
if (slot && img.naturalWidth > 0) {
const aspect = img.naturalWidth / img.naturalHeight;
slot.style.setProperty("--aspect", String(aspect));
aspectMap.set(gi, aspect);
}
}}
/>
{:else}
<div class="strip-placeholder" aria-hidden="true">{@render skeleton()}</div>
{/if}
</div>
{/each}
<div style="height:1px;flex-shrink:0"></div>
{#snippet skeleton()}
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
</svg>
{/snippet}
<style>
.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);
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
}
.panel-skeleton { width: 100%; height: 100%; }
.panel-skeleton :global(.ps-r) {
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
stroke-dasharray: 400; stroke-dashoffset: 400;
animation: ps-shimmer 2s ease-in-out infinite;
}
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
@keyframes ps-shimmer {
0% { stroke-dashoffset: 400; opacity: 0.25; }
40% { stroke-dashoffset: 0; opacity: 0.55; }
70% { stroke-dashoffset: 0; opacity: 0.55; }
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
.midscroll-bar {
position: fixed; top: 50%; transform: translateY(-50%);
z-index: 200; display: flex; flex-direction: column; align-items: center;
gap: 8px; padding: 10px 6px;
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
border: 1px solid var(--border-base); border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
pointer-events: auto; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
.midscroll-bar-right { right: 8px; }
.midscroll-bar-left { left: 8px; }
.midscroll-segments { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.midscroll-origin-dot { width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--accent-fg); opacity: 0.6; flex-shrink: 0; margin: 2px 0; }
.midscroll-seg { width: 4px; height: 14px; border-radius: 2px; background: var(--border-strong); transition: background 0.06s ease; flex-shrink: 0; }
.midscroll-seg-lit { background: var(--accent-fg); }
.midscroll-stop { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); flex-shrink: 0; }
.midscroll-stop:hover { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
</style>
@@ -0,0 +1,71 @@
<script lang="ts">
import { readerState } from "$lib/state/reader.svelte";
interface Props {
imgCls: string;
src: string | null;
fadingOut: boolean;
isFade: boolean;
}
const { imgCls, src, fadingOut, isFade }: Props = $props();
</script>
<div
class="inspect-wrap"
style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"
>
{#if src}
<img
{src}
alt="Page {readerState.pageNumber}"
class={imgCls}
decoding="async"
draggable="false"
style={isFade ? `opacity:${fadingOut ? 0 : 1};transition:opacity 0.1s ease` : undefined}
/>
{:else}
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
{/if}
</div>
{#snippet skeleton()}
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
</svg>
{/snippet}
<style>
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
.page-loader-single {
width: min(100%, var(--effective-width, 100%));
max-width: var(--effective-width, 100%);
max-height: calc(var(--visual-vh, 100vh) - 80px);
aspect-ratio: 2 / 3;
}
.panel-skeleton { width: 100%; height: 100%; }
.panel-skeleton :global(.ps-r) {
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
stroke-dasharray: 400; stroke-dashoffset: 400;
animation: ps-shimmer 2s ease-in-out infinite;
}
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
@keyframes ps-shimmer {
0% { stroke-dashoffset: 400; opacity: 0.25; }
40% { stroke-dashoffset: 0; opacity: 0.55; }
70% { stroke-dashoffset: 0; opacity: 0.55; }
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
</style>