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:
@@ -1,10 +1,26 @@
|
||||
<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";
|
||||
import type { PinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
||||
import type { StripChapter } from "$lib/components/reader/lib/scrollHandler";
|
||||
import { READ_LINE_PCT } from "$lib/components/reader/lib/scrollHandler";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import LongstripViewer from "$lib/components/reader/viewer/LongstripViewer.svelte";
|
||||
import SingleViewer from "$lib/components/reader/viewer/SingleViewer.svelte";
|
||||
import DoubleViewer from "$lib/components/reader/viewer/DoubleViewer.svelte";
|
||||
|
||||
export interface StripChapter {
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
type FlatPage = {
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
localIndex: number;
|
||||
url: string;
|
||||
total: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
style: string;
|
||||
@@ -15,10 +31,10 @@
|
||||
pageReady: boolean;
|
||||
pageGroups: number[][];
|
||||
currentGroup: number[];
|
||||
stripToRender: StripChapter[];
|
||||
fadingOut: boolean;
|
||||
tapToToggleBar: boolean;
|
||||
pinchZoomEnabled: boolean;
|
||||
useBlob: boolean;
|
||||
barPosition: "top" | "left" | "right";
|
||||
onGetZoom: () => number;
|
||||
onSetZoom: (z: number) => void;
|
||||
@@ -27,44 +43,58 @@
|
||||
onWheel: (e: WheelEvent) => void;
|
||||
onToggleUi: () => void;
|
||||
bindContainer: (el: HTMLDivElement) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onChapterChange: (chapterId: number) => void;
|
||||
onCenterIdxChange:(flatIdx: number) => void;
|
||||
onMarkRead: (chapterId: number) => void;
|
||||
onAppend: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||
tapToToggleBar, pinchZoomEnabled, barPosition,
|
||||
pageGroups, currentGroup, fadingOut,
|
||||
tapToToggleBar, pinchZoomEnabled, useBlob, barPosition,
|
||||
onGetZoom, onSetZoom, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||
onPageChange, onChapterChange, onCenterIdxChange, onMarkRead, onAppend,
|
||||
}: Props = $props();
|
||||
|
||||
const LOAD_RADIUS = 5;
|
||||
const UNLOAD_RADIUS = 10;
|
||||
let stripChunks = $state<StripChapter[]>([]);
|
||||
|
||||
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
|
||||
export function loadStrip(chapterId: number, chapterName: string, urls: string[], resumeTo = 0) {
|
||||
stripChunks = [{ chapterId, chapterName, urls }];
|
||||
if (resumeTo > 1) {
|
||||
setTimeout(() => scrollToFlatIndex(resumeTo - 1), 0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendStripChunk(chapterId: number, chapterName: string, urls: string[]) {
|
||||
if (stripChunks.some(c => c.chapterId === chapterId)) return;
|
||||
stripChunks = [...stripChunks, { chapterId, chapterName, urls }];
|
||||
}
|
||||
|
||||
export function getStripChunks(): StripChapter[] {
|
||||
return stripChunks;
|
||||
}
|
||||
|
||||
const flatPages = $derived.by<FlatPage[]>(() => {
|
||||
const out: FlatPage[] = [];
|
||||
for (const chunk of stripToRender) {
|
||||
for (const chunk of stripChunks) {
|
||||
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 });
|
||||
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 currentSrc = $state<string | null>(null);
|
||||
let currentGroupSrcs = $state<(string | null)[]>([]);
|
||||
|
||||
// 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 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;
|
||||
@@ -89,218 +119,75 @@
|
||||
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);
|
||||
}
|
||||
if (style !== "longstrip" && containerEl) containerEl.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
// ── Blob URL revocation ───────────────────────────────────────────────────
|
||||
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 {} }
|
||||
});
|
||||
}
|
||||
let lastTrackedPage = 0;
|
||||
let lastTrackedChapter = 0;
|
||||
|
||||
// ── Load window management ────────────────────────────────────────────────
|
||||
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 handleScroll() {
|
||||
if (style !== "longstrip" || !containerEl || !flatPages.length) return;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
const containerRect = containerEl.getBoundingClientRect();
|
||||
const readY = containerRect.top + containerEl.clientHeight * READ_LINE_PCT;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { recalcWindow(centerIdx); });
|
||||
$effect(() => { void flatPages.length; recalcWindow(centerIdx); });
|
||||
|
||||
// ── 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;
|
||||
|
||||
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;
|
||||
let centerFlatIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
|
||||
slots.forEach((slot, idx) => {
|
||||
const rect = slot.getBoundingClientRect();
|
||||
const mid = (rect.top + rect.bottom) / 2;
|
||||
const dist = Math.abs(mid - readY);
|
||||
if (dist < bestDist) { bestDist = dist; centerFlatIdx = idx; }
|
||||
});
|
||||
|
||||
onCenterIdxChange(centerFlatIdx);
|
||||
|
||||
const page = flatPages[centerFlatIdx];
|
||||
if (!page) return;
|
||||
|
||||
const localPage = page.localIndex + 1;
|
||||
if (localPage !== lastTrackedPage || page.chapterId !== lastTrackedChapter) {
|
||||
lastTrackedPage = localPage;
|
||||
lastTrackedChapter = page.chapterId;
|
||||
onPageChange(localPage);
|
||||
onChapterChange(page.chapterId);
|
||||
}
|
||||
|
||||
for (const chunk of stripChunks) {
|
||||
const lastLocalIdx = chunk.urls.length - 1;
|
||||
let flatLastIdx = -1;
|
||||
for (let i = 0; i < flatPages.length; i++) {
|
||||
if (flatPages[i].chapterId === chunk.chapterId && flatPages[i].localIndex === lastLocalIdx) {
|
||||
flatLastIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (flatLastIdx < 0) continue;
|
||||
const lastSlot = slots[flatLastIdx];
|
||||
if (!lastSlot) continue;
|
||||
const lastRect = lastSlot.getBoundingClientRect();
|
||||
if (lastRect.bottom < readY) onMarkRead(chunk.chapterId);
|
||||
}
|
||||
|
||||
const scrollBottom = containerEl.scrollTop + containerEl.clientHeight;
|
||||
const scrollTotal = containerEl.scrollHeight;
|
||||
if (scrollTotal - scrollBottom < containerEl.clientHeight * 1.5) onAppend();
|
||||
}
|
||||
|
||||
// ── Reset on chapter change ───────────────────────────────────────────────
|
||||
let lastChapterId = 0;
|
||||
$effect(() => {
|
||||
const chapterId = readerState.activeChapter?.id ?? 0;
|
||||
if (chapterId === lastChapterId) return;
|
||||
lastChapterId = chapterId;
|
||||
loadedSet = new Set<number>();
|
||||
resolvedSrc = {};
|
||||
centerIdx = 0;
|
||||
aspectMap.clear();
|
||||
});
|
||||
|
||||
// ── Inspect / zoom helpers ────────────────────────────────────────────────
|
||||
const INSPECT_ZOOM_STEP = 0.15;
|
||||
const INSPECT_ZOOM_MAX = 8;
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let containerEl = $state<HTMLDivElement | undefined>();
|
||||
let stripRef: LongstripViewer | undefined = $state();
|
||||
|
||||
export function captureAnchor() { stripRef?.captureAnchor(); }
|
||||
export function restoreAnchor() { stripRef?.restoreAnchor(); }
|
||||
export function notifyScrollCenter(idx: number) { stripRef?.notifyScrollCenter(idx); }
|
||||
export async function scrollToFlatIndex(idx: number) { await stripRef?.scrollToFlatIndex(idx); }
|
||||
|
||||
function getInspectImageEl(): HTMLElement | null {
|
||||
if (!containerEl) return null;
|
||||
@@ -325,66 +212,6 @@
|
||||
let inspectPanStartX = 0;
|
||||
let inspectPanStartY = 0;
|
||||
|
||||
let stripDragging = $state(false);
|
||||
let stripDragMoved = false;
|
||||
let stripDragStartY = 0;
|
||||
let stripScrollStart = 0;
|
||||
|
||||
let autoScrollPaused = false;
|
||||
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
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 tick = () => {
|
||||
if (!midScrollActive || !containerEl) return;
|
||||
const dy = midScrollCurrentY - midScrollOriginY;
|
||||
const deadZone = 24;
|
||||
const excess = Math.max(0, Math.abs(dy) - deadZone);
|
||||
const speed = Math.sign(dy) * excess * 0.12;
|
||||
containerEl.scrollTop += speed;
|
||||
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
|
||||
midScrollRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
midScrollRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stopMidScroll() {
|
||||
midScrollActive = false;
|
||||
midScrollDisplayLevel = 0;
|
||||
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
|
||||
}
|
||||
|
||||
function pauseAutoScroll() {
|
||||
autoScrollPaused = true;
|
||||
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
|
||||
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (style !== "longstrip" || !settingsState.settings.autoScroll) 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);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (style !== "longstrip") stopMidScroll();
|
||||
});
|
||||
|
||||
// ── Pinch zoom ────────────────────────────────────────────────────────────
|
||||
let pinch: PinchTracker | null = null;
|
||||
|
||||
$effect(() => {
|
||||
@@ -402,28 +229,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Pointer / mouse / wheel event handlers ────────────────────────────────
|
||||
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
||||
|
||||
export function onInspectMouseDown(e: MouseEvent) {
|
||||
if ((e.target as Element).closest(".bar")) return;
|
||||
if (e.button === 1 && style === "longstrip") {
|
||||
e.preventDefault();
|
||||
if (midScrollActive) {
|
||||
stopMidScroll();
|
||||
} else {
|
||||
settingsState.settings.autoScroll = false;
|
||||
startMidScroll(e.clientY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (style === "longstrip") {
|
||||
stripDragging = true;
|
||||
stripDragMoved = false;
|
||||
stripDragStartY = e.clientY;
|
||||
stripScrollStart = containerEl?.scrollTop ?? 0;
|
||||
pauseAutoScroll();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (style === "longstrip") { stripRef?.onMouseDown(e); return; }
|
||||
if (readerState.inspectScale <= 1) return;
|
||||
inspectDragging = true;
|
||||
inspectDragMoved = false;
|
||||
@@ -435,13 +245,7 @@
|
||||
}
|
||||
|
||||
export function onInspectMouseMove(e: MouseEvent) {
|
||||
midScrollCurrentY = e.clientY;
|
||||
if (stripDragging) {
|
||||
const dy = e.clientY - stripDragStartY;
|
||||
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
||||
return;
|
||||
}
|
||||
if (style === "longstrip") { stripRef?.onMouseMove(e); return; }
|
||||
if (!inspectDragging) return;
|
||||
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||
@@ -452,22 +256,19 @@
|
||||
}
|
||||
|
||||
export function onInspectMouseUp() {
|
||||
stripDragging = false;
|
||||
if (style === "longstrip") { stripRef?.onMouseUp(); return; }
|
||||
inspectDragging = false;
|
||||
}
|
||||
|
||||
export function onPointerDown(e: PointerEvent) {
|
||||
if ((e.target as Element).closest(".bar")) return;
|
||||
pinch?.onPointerDown(e);
|
||||
if (style === "longstrip") stripRef?.onPointerDown(e);
|
||||
}
|
||||
|
||||
export function onPointerMove(e: PointerEvent) {
|
||||
if (pinch?.isPinching()) { pinch.onPointerMove(e); return; }
|
||||
if (stripDragging) {
|
||||
const dy = e.clientY - stripDragStartY;
|
||||
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
||||
}
|
||||
if (style === "longstrip") { stripRef?.onPointerMove(e); return; }
|
||||
if (inspectDragging) {
|
||||
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||
@@ -480,13 +281,16 @@
|
||||
|
||||
export function onPointerUp(e: PointerEvent) {
|
||||
pinch?.onPointerUp(e);
|
||||
if (!pinch?.isPinching()) { stripDragging = false; inspectDragging = false; }
|
||||
if (!pinch?.isPinching()) {
|
||||
if (style === "longstrip") stripRef?.onPointerUp();
|
||||
else inspectDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWheel(e: WheelEvent) {
|
||||
if (style === "longstrip") {
|
||||
if (e.ctrlKey) { onWheel(e); }
|
||||
else pauseAutoScroll();
|
||||
if (e.ctrlKey) onWheel(e);
|
||||
else stripRef?.onWheel(e);
|
||||
return;
|
||||
}
|
||||
if (!e.ctrlKey) { onWheel(e); return; }
|
||||
@@ -496,14 +300,12 @@
|
||||
if (next === readerState.inspectScale) return;
|
||||
if (next === 1) { readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0; return; }
|
||||
const img = getInspectImageEl();
|
||||
const anchor = img ?? containerEl;
|
||||
const anchor = img ?? containerEl ?? null;
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
|
||||
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
|
||||
const ratio = next / readerState.inspectScale;
|
||||
const rawPanX = cx + (readerState.inspectPanX - cx) * ratio;
|
||||
const rawPanY = cy + (readerState.inspectPanY - cy) * ratio;
|
||||
const [clampedX, clampedY] = clampInspectPan(next, rawPanX, rawPanY);
|
||||
const [clampedX, clampedY] = clampInspectPan(next, cx + (readerState.inspectPanX - cx) * ratio, cy + (readerState.inspectPanY - cy) * ratio);
|
||||
readerState.inspectScale = next;
|
||||
readerState.inspectPanX = clampedX;
|
||||
readerState.inspectPanY = clampedY;
|
||||
@@ -513,11 +315,10 @@
|
||||
|
||||
function handleTap(e: MouseEvent) {
|
||||
if (style === "longstrip") {
|
||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||
if (stripRef?.consumeTap()) return;
|
||||
return;
|
||||
}
|
||||
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||
if (tapToToggleBar) {
|
||||
if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; return; }
|
||||
tapTimer = setTimeout(() => { tapTimer = null; onTap(e); }, 220);
|
||||
@@ -550,10 +351,10 @@
|
||||
onclick={handleTap}
|
||||
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
|
||||
ondblclick={handleDblClick}
|
||||
onscroll={style === "longstrip" ? handleScroll : undefined}
|
||||
onmousedown={onInspectMouseDown}
|
||||
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
||||
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
||||
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === " " && style === "longstrip") {
|
||||
e.preventDefault();
|
||||
@@ -563,28 +364,9 @@
|
||||
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{#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}
|
||||
|
||||
{#if loading}
|
||||
<div class="center-overlay">
|
||||
<div class="page-loader page-loader-single" aria-hidden="true">
|
||||
{@render skeleton()}
|
||||
</div>
|
||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -593,76 +375,21 @@
|
||||
{/if}
|
||||
|
||||
{#if style === "longstrip"}
|
||||
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
||||
{@const src = resolvedSrc[gi]}
|
||||
{@const isLoaded = loadedSet.has(gi)}
|
||||
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId}>
|
||||
{#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="eager"
|
||||
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>
|
||||
|
||||
{: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)">
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
<LongstripViewer
|
||||
bind:this={stripRef}
|
||||
{containerEl}
|
||||
{flatPages}
|
||||
{imgCls}
|
||||
{effectiveWidth}
|
||||
{resolveUrl}
|
||||
{barPosition}
|
||||
/>
|
||||
|
||||
{: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 (pg)}
|
||||
{#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>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="center-overlay">
|
||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<DoubleViewer {imgCls} {currentGroup} srcs={currentGroupSrcs} {pageGroups} />
|
||||
|
||||
{:else if pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
<SingleViewer {imgCls} src={currentSrc} {fadingOut} isFade={style === "fade"} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -685,19 +412,6 @@
|
||||
|
||||
:global(.pinch-active) .viewer { touch-action: none; }
|
||||
|
||||
.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);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
||||
.page-loader-single {
|
||||
width: min(100%, var(--effective-width, 100%));
|
||||
@@ -728,47 +442,14 @@
|
||||
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||
}
|
||||
|
||||
.img { display: block; user-select: none; image-rendering: auto; }
|
||||
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||
:global(.img) { display: block; user-select: none; image-rendering: auto; }
|
||||
:global(.img.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||||
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
|
||||
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
||||
:global(.strip-gap) { margin-bottom: 8px; }
|
||||
|
||||
.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; }
|
||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||
|
||||
.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>
|
||||
@@ -5,7 +5,6 @@
|
||||
import { app, appState } from "$lib/state/app.svelte";
|
||||
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
|
||||
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
|
||||
import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler";
|
||||
import { createReaderKeyHandler } from "$lib/components/reader/lib/readerKeybinds";
|
||||
import { markChapterRead, getMangaPrefs, toggleBookmark } from "$lib/components/reader/lib/chapterActions";
|
||||
import { goForward, goBack, jumpToPage } from "$lib/components/reader/lib/navigation";
|
||||
@@ -46,9 +45,11 @@
|
||||
const pinchZoomEnabled = $derived(settingsState.settings.pinchZoom ?? false);
|
||||
const containerized = $derived(settingsState.settings.readerContainerized ?? false);
|
||||
|
||||
let visibleChapterId = $state<number | null>(null);
|
||||
|
||||
const displayChapter = $derived(
|
||||
style === "longstrip" && readerState.visibleChapterId
|
||||
? (readerState.activeChapterList.find(c => c.id === readerState.visibleChapterId) ?? readerState.activeChapter)
|
||||
style === "longstrip" && visibleChapterId
|
||||
? (readerState.activeChapterList.find(c => c.id === visibleChapterId) ?? readerState.activeChapter)
|
||||
: readerState.activeChapter
|
||||
);
|
||||
|
||||
@@ -71,7 +72,7 @@
|
||||
|
||||
const showResumeBanner = $derived(
|
||||
readerState.resumeVisible && readerState.resumePage > 1 &&
|
||||
(style === "longstrip" ? readerState.stripResumeReady : readerState.pageNumber === readerState.resumePage)
|
||||
readerState.pageNumber === readerState.resumePage
|
||||
);
|
||||
|
||||
const adjacent = $derived.by(() => {
|
||||
@@ -87,9 +88,9 @@
|
||||
|
||||
const visibleChunkLastPage = $derived.by(() => {
|
||||
if (style !== "longstrip") return lastPage;
|
||||
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
||||
const chunk = readerState.stripChapters.find(c => c.chapterId === chId);
|
||||
return chunk?.urls.length ?? lastPage;
|
||||
const chunks = pageViewRef?.getStripChunks() ?? [];
|
||||
const chId = visibleChapterId ?? readerState.activeChapter?.id;
|
||||
return chunks.find(c => c.chapterId === chId)?.urls.length ?? lastPage;
|
||||
});
|
||||
|
||||
const imgCls = $derived([
|
||||
@@ -101,14 +102,6 @@
|
||||
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
||||
].filter(Boolean).join(" "));
|
||||
|
||||
const stripToRender = $derived(
|
||||
style === "longstrip"
|
||||
? (readerState.stripChapters.length > 0
|
||||
? readerState.stripChapters
|
||||
: [{ chapterId: readerState.activeChapter?.id ?? 0, chapterName: readerState.activeChapter?.name ?? "", urls: readerState.pageUrls }])
|
||||
: []
|
||||
);
|
||||
|
||||
const currentGroup = $derived.by(() => {
|
||||
const group = style === "double" && readerState.pageGroups.length
|
||||
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
|
||||
@@ -145,13 +138,9 @@
|
||||
let abortCtrl = { current: null as AbortController | null };
|
||||
let hasNavigated = false;
|
||||
let startAtLastPageRef = { current: false };
|
||||
let cleanupScroll: () => void = () => {};
|
||||
let stripChaptersRef = readerState.stripChapters;
|
||||
let tickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
$effect(() => { stripChaptersRef = readerState.stripChapters; });
|
||||
|
||||
function maybeMarkCurrentRead() {
|
||||
const ch = displayChapter ?? readerState.activeChapter;
|
||||
if (ch && markOnNext) markChapterRead(ch.id, markedRead);
|
||||
@@ -212,17 +201,6 @@
|
||||
|
||||
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();
|
||||
@@ -236,9 +214,8 @@
|
||||
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,
|
||||
visibleChapterId ?? readerState.activeChapter?.id ?? 0,
|
||||
pageViewRef?.getStripChunks() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,9 +229,6 @@
|
||||
function handleCloseReader() {
|
||||
clearReading().catch(() => {});
|
||||
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
||||
for (const strip of readerState.stripChapters) {
|
||||
for (const url of strip.urls) revokeBlobUrl(url);
|
||||
}
|
||||
readerState.closeReader();
|
||||
}
|
||||
|
||||
@@ -342,10 +316,11 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const ch = readerState.activeChapter;
|
||||
const manga = readerState.activeManga;
|
||||
if (ch && manga) {
|
||||
const ch = readerState.activeChapter;
|
||||
if (ch) {
|
||||
untrack(() => {
|
||||
const manga = readerState.activeManga;
|
||||
if (!manga) return;
|
||||
historyState.openSession(
|
||||
manga.id, manga.title, manga.thumbnailUrl,
|
||||
ch.id, ch.name, readerState.pageNumber,
|
||||
@@ -367,7 +342,7 @@
|
||||
$effect(() => {
|
||||
const page = readerState.pageNumber;
|
||||
const chId = style === "longstrip"
|
||||
? (readerState.visibleChapterId ?? readerState.activeChapter?.id)
|
||||
? (visibleChapterId ?? readerState.activeChapter?.id)
|
||||
: readerState.activeChapter?.id;
|
||||
const chName = style === "longstrip"
|
||||
? (readerState.activeChapterList.find(c => c.id === chId)?.name ?? readerState.activeChapter?.name ?? "")
|
||||
@@ -391,99 +366,33 @@
|
||||
const ch = readerState.activeChapter;
|
||||
const urls = readerState.pageUrls;
|
||||
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 (resumeTo > 1) {
|
||||
pageViewRef.scrollToFlatIndex(resumeTo - 1);
|
||||
readerState.stripResumeReady = true;
|
||||
return;
|
||||
}
|
||||
containerEl.scrollTop = 0;
|
||||
});
|
||||
visibleChapterId = ch.id;
|
||||
appending = false;
|
||||
pageViewRef.loadStrip(ch.id, ch.name, urls, resumeTo);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
||||
|
||||
$effect(() => {
|
||||
const chId = readerState.visibleChapterId;
|
||||
const chId = visibleChapterId;
|
||||
if (!chId || style !== "longstrip") return;
|
||||
if (chId === readerState.activeChapter?.id) return;
|
||||
const wasAppended = untrack(() => readerState.stripChapters.findIndex(c => c.chapterId === chId)) > 0;
|
||||
if (wasAppended) {
|
||||
untrack(() => {
|
||||
readerState.resumePage = 0;
|
||||
readerState.resumeVisible = false;
|
||||
const prefs = getMangaPrefs(chId);
|
||||
if (prefs.downloadAhead > 0) {
|
||||
const list = readerState.activeChapterList;
|
||||
const idx = list.findIndex(c => c.id === chId);
|
||||
if (idx >= 0) {
|
||||
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||||
.filter(c => !c.downloaded && !c.read)
|
||||
.map(c => c.id);
|
||||
if (toQueue.length) getAdapter().enqueueDownloads(toQueue.map(String)).catch(console.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const bookmark = readerState.bookmarks.find(b => b.chapterId === chId);
|
||||
if (bookmark && bookmark.pageNumber > 1) {
|
||||
untrack(() => {
|
||||
readerState.resumePage = bookmark.pageNumber;
|
||||
readerState.resumeDismissed = false;
|
||||
readerState.resumeVisible = true;
|
||||
readerState.stripResumeReady = true;
|
||||
scheduleResumeDismiss();
|
||||
});
|
||||
} else {
|
||||
untrack(() => readerState.resetResume());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void style;
|
||||
if (!containerEl) return;
|
||||
untrack(() => {
|
||||
cleanupScroll();
|
||||
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;
|
||||
appending = true;
|
||||
appendNextChapter(
|
||||
stripChaptersRef,
|
||||
readerState.activeChapterList,
|
||||
(id) => fetchPages(id, useBlob),
|
||||
(url) => preloadImage(url, useBlob),
|
||||
(next) => { readerState.stripChapters = [...readerState.stripChapters, next]; appending = false; },
|
||||
() => { appending = false; },
|
||||
);
|
||||
},
|
||||
getStripChapters: () => stripChaptersRef,
|
||||
getPageUrls: () => readerState.pageUrls,
|
||||
shouldAutoMark: () => settingsState.settings.autoMarkRead ?? true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (readerState.activeChapter && readerState.activeChapterList.length) {
|
||||
const idx = readerState.activeChapterList.findIndex(c => c.id === readerState.activeChapter!.id);
|
||||
if (idx >= 0) {
|
||||
const next = readerState.activeChapterList[idx + 1];
|
||||
const prev = readerState.activeChapterList[idx - 1];
|
||||
if (next) fetchPages(next.id, useBlob).then(urls => urls.slice(0, 8).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
||||
if (prev) fetchPages(prev.id, useBlob).then(urls => urls.slice(0, 2).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
||||
readerState.resumePage = 0;
|
||||
readerState.resumeVisible = false;
|
||||
const prefs = getMangaPrefs(chId);
|
||||
if (prefs.downloadAhead > 0) {
|
||||
const list = readerState.activeChapterList;
|
||||
const idx = list.findIndex(c => c.id === chId);
|
||||
if (idx >= 0) {
|
||||
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||||
.filter(c => !c.downloaded && !c.read)
|
||||
.map(c => c.id);
|
||||
if (toQueue.length) getAdapter().enqueueDownloads(toQueue.map(String)).catch(console.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -552,7 +461,7 @@
|
||||
if (pageNum > 1) hasNavigated = true;
|
||||
untrack(() => {
|
||||
if (!hasNavigated) return;
|
||||
if (style === "longstrip" && readerState.visibleChapterId && chapterId !== readerState.visibleChapterId) return;
|
||||
if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return;
|
||||
if (settingsState.settings.autoBookmark ?? true) {
|
||||
const existing = readerState.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
|
||||
if (existing) readerState.removeBookmark(existing.chapterId);
|
||||
@@ -606,7 +515,6 @@
|
||||
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
|
||||
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
|
||||
cleanupScroll();
|
||||
ro.disconnect();
|
||||
};
|
||||
});
|
||||
@@ -687,10 +595,11 @@
|
||||
error={readerState.error}
|
||||
pageReady={readerState.pageReady}
|
||||
pageGroups={readerState.pageGroups}
|
||||
{currentGroup} {stripToRender}
|
||||
{currentGroup}
|
||||
fadingOut={readerState.fadingOut}
|
||||
{tapToToggleBar}
|
||||
{pinchZoomEnabled}
|
||||
{useBlob}
|
||||
{barPosition}
|
||||
onGetZoom={() => zoom}
|
||||
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
||||
@@ -699,6 +608,28 @@
|
||||
onWheel={handleWheel}
|
||||
onToggleUi={toggleUiVisibility}
|
||||
{bindContainer}
|
||||
onPageChange={(p) => { readerState.pageNumber = p; }}
|
||||
onChapterChange={(id) => { visibleChapterId = id; }}
|
||||
onCenterIdxChange={(idx) => { pageViewRef?.notifyScrollCenter(idx); }}
|
||||
onMarkRead={(id) => markChapterRead(id, markedRead)}
|
||||
onAppend={() => {
|
||||
if (appending) return;
|
||||
const chunks = pageViewRef?.getStripChunks() ?? [];
|
||||
if (!chunks.length) return;
|
||||
const lastChunk = chunks[chunks.length - 1];
|
||||
const list = readerState.activeChapterList;
|
||||
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
|
||||
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
||||
const next = list[lastIdx + 1];
|
||||
if (!next || chunks.some(c => c.chapterId === next.id)) return;
|
||||
appending = true;
|
||||
fetchPages(next.id, useBlob)
|
||||
.then(urls => {
|
||||
urls.slice(0, 6).forEach(url => preloadImage(url, useBlob));
|
||||
return pageViewRef.appendStripChunk(next.id, next.name, urls);
|
||||
})
|
||||
.finally(() => { appending = false; });
|
||||
}}
|
||||
/>
|
||||
|
||||
{#snippet progressBarSnippet()}
|
||||
@@ -742,4 +673,4 @@
|
||||
.root.bar-right :global(.viewer) { margin-right: 40px; }
|
||||
|
||||
.root.pinch-active :global(.viewer) { touch-action: none; }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
<div class="bar-divider"></div>
|
||||
|
||||
<button class="icon-btn"
|
||||
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev, readerState.activeChapterList); } }}
|
||||
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev); } }}
|
||||
disabled={!adjacent.prev}
|
||||
title="Previous chapter">
|
||||
{#if isVertical}<CaretUp size={13} weight="regular" />{:else}<CaretLeft size={13} weight="regular" />{/if}
|
||||
@@ -179,7 +179,7 @@
|
||||
</div>
|
||||
|
||||
<button class="icon-btn"
|
||||
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next, readerState.activeChapterList); } }}
|
||||
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next); } }}
|
||||
disabled={!adjacent.next}
|
||||
title="Next chapter">
|
||||
{#if isVertical}<CaretDown size={13} weight="regular" />{:else}<CaretRight size={13} weight="regular" />{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readerState, DEFAULT_MANGA_PREFS } from "$lib/state/reader.svelte";
|
||||
import { seriesState } from "$lib/state/series.svelte";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import type { MangaPrefs } from "$lib/types/settings";
|
||||
@@ -35,8 +36,8 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
|
||||
const mangaId = readerState.activeManga?.id;
|
||||
if (!mangaId) return;
|
||||
|
||||
readerState.activeChapterList = readerState.activeChapterList.map(c =>
|
||||
c.id === id ? { ...c, read: true } : c
|
||||
seriesState.patchChapters(mangaId, chapters =>
|
||||
chapters.map(c => c.id === id ? { ...c, read: true } : c),
|
||||
);
|
||||
|
||||
const prefs = getMangaPrefs(mangaId);
|
||||
@@ -79,15 +80,15 @@ export function toggleBookmark(chapter: typeof readerState.activeChapter, pageNu
|
||||
const manga = readerState.activeManga;
|
||||
if (!chapter || !manga) return;
|
||||
|
||||
const existing = readerState.bookmarks.find(
|
||||
const existing = seriesState.bookmarks.find(
|
||||
b => b.mangaId === manga.id && b.chapterId === chapter.id && b.pageNumber === pageNumber,
|
||||
);
|
||||
if (existing) {
|
||||
readerState.removeBookmark(chapter.id);
|
||||
seriesState.removeBookmark(chapter.id);
|
||||
} else {
|
||||
const other = readerState.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== chapter.id);
|
||||
if (other) readerState.removeBookmark(other.chapterId);
|
||||
readerState.addBookmark({
|
||||
const other = seriesState.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== chapter.id);
|
||||
if (other) seriesState.removeBookmark(other.chapterId);
|
||||
seriesState.addBookmark({
|
||||
mangaId: manga.id,
|
||||
mangaTitle: manga.title,
|
||||
thumbnailUrl: manga.thumbnailUrl,
|
||||
@@ -103,9 +104,9 @@ export function commitMarker(color: MarkerColor, note: string, editId: string) {
|
||||
const manga = readerState.activeManga;
|
||||
if (!chapter || !manga) return;
|
||||
if (editId) {
|
||||
readerState.updateMarker(editId, { note: note.trim(), color });
|
||||
seriesState.updateMarker(editId, { note: note.trim(), color });
|
||||
} else {
|
||||
readerState.addMarker({
|
||||
seriesState.addMarker({
|
||||
mangaId: manga.id,
|
||||
mangaTitle: manga.title,
|
||||
thumbnailUrl: manga.thumbnailUrl,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readerState } from "$lib/state/reader.svelte";
|
||||
import { seriesState } from "$lib/state/series.svelte";
|
||||
import { fetchPages } from "./pageLoader";
|
||||
import { cancelQueuedFetches, revokeBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||
import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache";
|
||||
@@ -9,6 +10,7 @@ export function scheduleResumeDismiss() {
|
||||
}
|
||||
|
||||
let prefetchedChapterId: number | null = null;
|
||||
let prefetchedUrls: string[] = [];
|
||||
|
||||
export async function loadChapter(
|
||||
id: number,
|
||||
@@ -26,15 +28,12 @@ export async function loadChapter(
|
||||
if (useBlob) {
|
||||
clearResolvedUrlCache();
|
||||
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
||||
for (const strip of readerState.stripChapters) {
|
||||
for (const url of strip.urls) revokeBlobUrl(url);
|
||||
}
|
||||
if (prefetchedChapterId !== null && prefetchedChapterId !== id) {
|
||||
const prefetchedUrls = await fetchPages(prefetchedChapterId, false).catch(() => [] as string[]);
|
||||
for (const url of prefetchedUrls) revokeBlobUrl(url);
|
||||
clearPageCache(prefetchedChapterId);
|
||||
}
|
||||
prefetchedChapterId = null;
|
||||
prefetchedUrls = [];
|
||||
}
|
||||
|
||||
startAtLastPage.current = false;
|
||||
@@ -42,7 +41,7 @@ export async function loadChapter(
|
||||
readerState.resetForChapter();
|
||||
readerState.pageUrls = [];
|
||||
|
||||
const bookmark = readerState.bookmarks.find(b => b.chapterId === id);
|
||||
const bookmark = seriesState.bookmarks.find(b => b.chapterId === id);
|
||||
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
||||
readerState.resumeDismissed = false;
|
||||
@@ -63,9 +62,16 @@ export async function loadChapter(
|
||||
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(() => {});
|
||||
fetchPages(adjacent.next.id, useBlob, ctrl.signal)
|
||||
.then(fetched => {
|
||||
if (!ctrl.signal.aborted && prefetchedChapterId === adjacent.next!.id) {
|
||||
prefetchedUrls = fetched;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
@@ -11,11 +11,11 @@ function advanceGroup(forward: boolean, adjacent: Adjacent, startAtLastPage: ()
|
||||
const gi = readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber));
|
||||
if (forward) {
|
||||
if (gi < readerState.pageGroups.length - 1) readerState.pageNumber = readerState.pageGroups[gi + 1][0];
|
||||
else if (adjacent.next) { readerState.pageNumber = 1; openReader(adjacent.next, readerState.activeChapterList); }
|
||||
else if (adjacent.next) { readerState.pageNumber = 1; openReader(adjacent.next); }
|
||||
else closeReader();
|
||||
} else {
|
||||
if (gi > 0) readerState.pageNumber = readerState.pageGroups[gi - 1][0];
|
||||
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
||||
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function goForward(
|
||||
) {
|
||||
if (readerState.loading) return;
|
||||
if (style === "longstrip") {
|
||||
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, readerState.activeChapterList); }
|
||||
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next); }
|
||||
return;
|
||||
}
|
||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
|
||||
@@ -46,14 +46,14 @@ export function goForward(
|
||||
} else if (adjacent.next) {
|
||||
onMaybeMarkRead();
|
||||
readerState.pageNumber = 1;
|
||||
openReader(adjacent.next, readerState.activeChapterList);
|
||||
openReader(adjacent.next);
|
||||
} else closeReader();
|
||||
}
|
||||
|
||||
export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () => void) {
|
||||
if (readerState.loading) return;
|
||||
if (style === "longstrip") {
|
||||
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
||||
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
|
||||
return;
|
||||
}
|
||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
|
||||
@@ -61,7 +61,7 @@ export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () =>
|
||||
if (readerState.pageNumber > 1) {
|
||||
if (style === "fade") animateFade(() => { readerState.pageNumber--; });
|
||||
else readerState.pageNumber--;
|
||||
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
||||
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
|
||||
}
|
||||
|
||||
export function jumpToPage(
|
||||
@@ -69,14 +69,13 @@ export function jumpToPage(
|
||||
style: string,
|
||||
lastPage: number,
|
||||
scrollToFlatIndex: ((idx: number) => void) | null,
|
||||
flatPageCount: number,
|
||||
activeChapterId: number,
|
||||
stripChapters: { chapterId: number; urls: string[] }[],
|
||||
chunks: { chapterId: number; urls: string[] }[],
|
||||
) {
|
||||
if (style === "longstrip") {
|
||||
if (!scrollToFlatIndex || flatPageCount === 0) return;
|
||||
if (!scrollToFlatIndex || !chunks.length) return;
|
||||
let offset = 0;
|
||||
for (const chunk of stripChapters) {
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.chapterId === activeChapterId) {
|
||||
scrollToFlatIndex(offset + Math.max(0, page - 1));
|
||||
return;
|
||||
@@ -92,4 +91,4 @@ export function jumpToPage(
|
||||
} else {
|
||||
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache, clearResolvedUrlCache } from "$lib/core/cache/pageCache";
|
||||
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache, clearResolvedUrlCache, getCachedAspect } from "$lib/core/cache/pageCache";
|
||||
|
||||
export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads: boolean): number[][] {
|
||||
const groups: number[][] = [[1]];
|
||||
@@ -10,4 +10,4 @@ export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads
|
||||
else { groups.push([i, i + 1]); i += 2; }
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
@@ -1,141 +1 @@
|
||||
export const READ_LINE_PCT = 0.50;
|
||||
|
||||
export interface StripChapter {
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export interface ScrollHandlerCallbacks {
|
||||
onPageChange: (page: number) => void;
|
||||
onChapterChange: (chapterId: number) => void;
|
||||
onCenterIdxChange: (flatIdx: number) => void;
|
||||
onMarkRead: (chapterId: number) => void;
|
||||
onAppend: () => void;
|
||||
getStripChapters: () => StripChapter[];
|
||||
getPageUrls: () => string[];
|
||||
shouldAutoMark: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the element is considered "at" the read-line.
|
||||
*
|
||||
* Ported from Suwayomi's ReaderPager.utils `isPageInViewport`:
|
||||
* - If the element's top is above the line AND its bottom is below it → fully covers the line
|
||||
* (handles a single page that is taller than the viewport).
|
||||
* - If the element's top is at or below the line AND its bottom is also below it → leading edge
|
||||
* has crossed the line (normal scroll-past case).
|
||||
*
|
||||
* Using Math.trunc to avoid floating-point jitter from getBoundingClientRect.
|
||||
*/
|
||||
function isPageAtReadLine(el: HTMLElement, readLineY: number): boolean {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const top = Math.trunc(rect.top);
|
||||
const bottom = Math.trunc(rect.bottom);
|
||||
const line = Math.trunc(readLineY);
|
||||
// Element completely spans the read line (taller than viewport or very tall image)
|
||||
if (top <= line && bottom >= line) return true;
|
||||
// Element's top edge is at or above the line
|
||||
if (top <= line) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function setupScrollTracking(
|
||||
containerEl: HTMLElement,
|
||||
callbacks: ScrollHandlerCallbacks,
|
||||
): () => void {
|
||||
const {
|
||||
onPageChange, onChapterChange, 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 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 (isPageAtReadLine(imgs[mid], readLineY)) { best = mid; lo = mid + 1; }
|
||||
else hi = mid - 1;
|
||||
}
|
||||
|
||||
const active = imgs[best];
|
||||
const activePage = Number(active.dataset.localPage);
|
||||
const activeChId = Number(active.dataset.chapter);
|
||||
|
||||
onPageChange(activePage);
|
||||
if (activeChId) onChapterChange(activeChId);
|
||||
|
||||
const chunks = getStripChapters();
|
||||
let flatOffset = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.chapterId === activeChId) {
|
||||
onCenterIdxChange(flatOffset + activePage - 1);
|
||||
break;
|
||||
}
|
||||
flatOffset += chunk.urls.length;
|
||||
}
|
||||
|
||||
if (shouldAutoMark() && activeChId) {
|
||||
const chunk = chunks.find(c => c.chapterId === activeChId);
|
||||
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
||||
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
||||
|
||||
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
|
||||
if (atBottom) {
|
||||
const last = chunks[chunks.length - 1];
|
||||
if (last) onMarkRead(last.chapterId);
|
||||
}
|
||||
}
|
||||
|
||||
if ((containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight >= 0.80) {
|
||||
onAppend();
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
containerEl.removeEventListener("scroll", onScroll);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}
|
||||
|
||||
export function appendNextChapter(
|
||||
stripChapters: StripChapter[],
|
||||
chapterList: { id: number; name: string }[],
|
||||
fetchPages: (chapterId: number) => Promise<string[]>,
|
||||
preloadImage: (url: string) => void,
|
||||
onAppended: (next: StripChapter) => void,
|
||||
onDone: () => void,
|
||||
): void {
|
||||
if (!stripChapters.length) return;
|
||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||
const lastIdx = chapterList.findIndex(c => c.id === lastChunk.chapterId);
|
||||
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) return;
|
||||
const next = chapterList[lastIdx + 1];
|
||||
if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
|
||||
|
||||
fetchPages(next.id)
|
||||
.then(urls => { urls.slice(0, 6).forEach(preloadImage); return urls; })
|
||||
.then(urls => {
|
||||
if (stripChapters.some(c => c.chapterId === next.id)) { onDone(); return; }
|
||||
onAppended({ chapterId: next.id, chapterName: next.name, urls });
|
||||
onDone();
|
||||
})
|
||||
.catch(() => onDone());
|
||||
}
|
||||
@@ -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