mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-15 02:09:57 -05:00
Feat: Longstrip Viewer(s) & Lag Improvements
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user