mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Compare commits
21 Commits
8c2917b698
...
f10b343108
| Author | SHA1 | Date | |
|---|---|---|---|
| f10b343108 | |||
| a8ad9034fc | |||
| f99fa60e8e | |||
| 915ff66b2f | |||
| abd60f261f | |||
| fc20835dde | |||
| 04631d93ef | |||
| 5c09cd15ad | |||
| 7af69fd77c | |||
| 0b6372bd17 | |||
| 32bdeb92ff | |||
| 22c4a222d8 | |||
| 26cb16ec0f | |||
| 6d33fb7ae1 | |||
| 0e7ff1a27c | |||
| 685bd9b9da | |||
| 3926b5d064 | |||
| 9f6996dcdb | |||
| 294865fe9d | |||
| 13e760594d | |||
| b44b12ba86 |
@@ -1,9 +1,46 @@
|
||||
<script module lang="ts">
|
||||
function getLiveSet(): Set<HTMLCanvasElement> {
|
||||
const g = window as any
|
||||
if (!g.__splashCanvasSet) g.__splashCanvasSet = new Set<HTMLCanvasElement>()
|
||||
return g.__splashCanvasSet as Set<HTMLCanvasElement>
|
||||
}
|
||||
|
||||
function pruneSet(set: Set<HTMLCanvasElement>) {
|
||||
for (const el of set) if (!el.isConnected) set.delete(el)
|
||||
}
|
||||
|
||||
export function splashDevRegister(el: HTMLCanvasElement) {
|
||||
const set = getLiveSet()
|
||||
pruneSet(set)
|
||||
set.add(el)
|
||||
}
|
||||
|
||||
export function splashDevUnregister(el: HTMLCanvasElement) {
|
||||
const set = getLiveSet()
|
||||
set.delete(el)
|
||||
pruneSet(set)
|
||||
}
|
||||
|
||||
export function splashDevLiveCount(): number {
|
||||
const set = getLiveSet()
|
||||
pruneSet(set)
|
||||
return set.size
|
||||
}
|
||||
|
||||
export function splashDevNextMount(): number {
|
||||
const g = window as any
|
||||
g.__splashTotalMounts = (g.__splashTotalMounts ?? 0) + 1
|
||||
return g.__splashTotalMounts as number
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
|
||||
const isTauri = platformService.platform === 'tauri'
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
interface Props {
|
||||
mode?: 'loading' | 'idle' | 'locked'
|
||||
@@ -12,6 +49,7 @@
|
||||
notConfigured?: boolean
|
||||
showCards?: boolean
|
||||
showFps?: boolean
|
||||
showDevOverlay?: boolean
|
||||
pinLen?: number
|
||||
pinCorrect?: string
|
||||
onReady?: () => void
|
||||
@@ -23,7 +61,7 @@
|
||||
|
||||
let {
|
||||
mode = 'loading', ringFull = false, failed = false,
|
||||
notConfigured = false, showCards = true, showFps = false,
|
||||
notConfigured = false, showCards = true, showFps = false, showDevOverlay = false,
|
||||
pinLen = 4, pinCorrect = '',
|
||||
onReady, onUnlock, onRetry, onBypass, onDismiss,
|
||||
}: Props = $props()
|
||||
@@ -80,7 +118,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (mode === 'loading' && !failed && !notConfigured && !ringFull) {
|
||||
if (!isTauri) return // no ring animation on web; probe outcome drives exit
|
||||
if (!isTauri) return
|
||||
animStart = null
|
||||
animPhase = 1
|
||||
animFrame = requestAnimationFrame(animateRing)
|
||||
@@ -280,22 +318,87 @@
|
||||
}
|
||||
}
|
||||
|
||||
interface DevMetrics {
|
||||
totalMounts: number
|
||||
resizeCount: number
|
||||
stampCount: number
|
||||
mountedAt: number
|
||||
lastResizeAt: number | null
|
||||
}
|
||||
|
||||
let devMetrics = $state<DevMetrics | null>(null)
|
||||
let uptimeSecs = $state(0)
|
||||
let devLiveCount = $state(0)
|
||||
|
||||
$effect(() => {
|
||||
if (!isDev || mode !== 'idle' || !devMetrics) return
|
||||
const start = Date.now()
|
||||
const iv = setInterval(() => { uptimeSecs = Math.floor((Date.now() - start) / 1000) }, 1000)
|
||||
return () => clearInterval(iv)
|
||||
})
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
}
|
||||
|
||||
function fmtAgo(ts: number | null): string {
|
||||
if (ts === null) return '—'
|
||||
const s = Math.floor((Date.now() - ts) / 1000)
|
||||
if (s < 60) return `${s}s ago`
|
||||
return `${Math.floor(s / 60)}m ago`
|
||||
}
|
||||
|
||||
function mountCanvas(el: HTMLCanvasElement) {
|
||||
const ctx = el.getContext('2d')!
|
||||
let live: RenderState | null = null
|
||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0
|
||||
|
||||
if (isDev && mode === 'idle') {
|
||||
splashDevRegister(el)
|
||||
devLiveCount = splashDevLiveCount()
|
||||
uptimeSecs = 0
|
||||
devMetrics = {
|
||||
totalMounts: splashDevNextMount(),
|
||||
resizeCount: 0,
|
||||
stampCount: 0,
|
||||
mountedAt: Date.now(),
|
||||
lastResizeAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (live) {
|
||||
live.stamps.forEach(c => { c.width = 0; c.height = 0 })
|
||||
live.vignette.width = 0
|
||||
live.vignette.height = 0
|
||||
live = null
|
||||
}
|
||||
ctx.clearRect(0, 0, el.width, el.height)
|
||||
}
|
||||
|
||||
function applySize(logW: number, logH: number, scale: number) {
|
||||
const gen = ++buildGen
|
||||
if (logW <= 0 || logH <= 0) return
|
||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return
|
||||
lastLogW = logW; lastLogH = logH; lastScale = scale
|
||||
if (live) cleanup()
|
||||
const built = buildCards(logW, logH)
|
||||
const stamps = built.cards.map(c => buildStamp(c, scale))
|
||||
const vig = buildVignette(logW, logH, scale)
|
||||
el.width = Math.round(logW * scale)
|
||||
el.height = Math.round(logH * scale)
|
||||
if (gen === buildGen) live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: el.width, CH: el.height, scale }
|
||||
if (gen === buildGen) {
|
||||
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: el.width, CH: el.height, scale }
|
||||
if (isDev && mode === 'idle' && devMetrics) {
|
||||
devMetrics = {
|
||||
...devMetrics,
|
||||
resizeCount: devMetrics.resizeCount + 1,
|
||||
stampCount: stamps.length,
|
||||
lastResizeAt: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let extraCleanup: (() => void) | undefined
|
||||
@@ -343,8 +446,13 @@
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
cleanup()
|
||||
extraCleanup?.()
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
if (isDev && mode === 'idle') {
|
||||
splashDevUnregister(el)
|
||||
devLiveCount = splashDevLiveCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -357,6 +465,20 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isDev && mode === 'idle' && devMetrics && showDevOverlay}
|
||||
<div class="dev-overlay">
|
||||
<span class="dev-title">canvas · idle splash</span>
|
||||
<div class="dev-grid">
|
||||
<span class="dev-k">live</span> <span class="dev-v" class:dev-warn={devLiveCount > 1}>{devLiveCount}</span>
|
||||
<span class="dev-k">total mounts</span> <span class="dev-v">{devMetrics.totalMounts}</span>
|
||||
<span class="dev-k">stamps</span> <span class="dev-v">{devMetrics.stampCount}</span>
|
||||
<span class="dev-k">resizes</span> <span class="dev-v">{devMetrics.resizeCount}</span>
|
||||
<span class="dev-k">uptime</span> <span class="dev-v">{fmtUptime(uptimeSecs)}</span>
|
||||
<span class="dev-k">last resize</span> <span class="dev-v">{fmtAgo(devMetrics.lastResizeAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'idle'}
|
||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
||||
@@ -449,4 +571,11 @@
|
||||
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); }
|
||||
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
|
||||
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
|
||||
|
||||
.dev-overlay { position:absolute; top:12px; left:12px; z-index:10; background:rgba(0,0,0,0.72); border:1px solid rgba(255,255,255,0.10); border-radius:6px; padding:8px 10px; pointer-events:none; backdrop-filter:blur(6px); }
|
||||
.dev-title { display:block; font-family:var(--font-ui); font-size:9px; letter-spacing:0.14em; text-transform:uppercase; color:var(--accent); margin-bottom:6px; }
|
||||
.dev-grid { display:grid; grid-template-columns:auto auto; column-gap:12px; row-gap:2px; }
|
||||
.dev-k { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); white-space:nowrap; }
|
||||
.dev-v { font-family:var(--font-ui); font-size:10px; color:var(--text-secondary); text-align:right; white-space:nowrap; }
|
||||
.dev-warn { color:#f87171; }
|
||||
</style>
|
||||
@@ -393,7 +393,7 @@
|
||||
{:else}
|
||||
<LibraryToolbar
|
||||
tab={libraryState.tab}
|
||||
tabSortMode={libraryState.tabSort[libraryState.tab]?.mode ?? 'alphabetical'}
|
||||
tabSortMode={libraryState.tabSort[libraryState.tab]?.mode ?? 'az'}
|
||||
tabSortDir={libraryState.tabSort[libraryState.tab]?.dir ?? 'asc'}
|
||||
tabStatus={libraryState.tabStatus[libraryState.tab] ?? 'ALL'}
|
||||
tabFilters={libraryState.tabFilters[libraryState.tab] ?? {}}
|
||||
@@ -423,7 +423,6 @@
|
||||
onFilterPanelToggle={() => filterPanelOpen = !filterPanelOpen}
|
||||
onRefresh={startRefresh}
|
||||
onCancelRefresh={cancelRefresh}
|
||||
onRefreshCategory={refreshCategory}
|
||||
onOpenDownloadsFolder={openDownloadsFolder}
|
||||
onTabDragStart={onTabDragStart}
|
||||
onTabDragOver={onTabDragOver}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { CheckSquare, Trash, Folder } from 'phosphor-svelte'
|
||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import type { Manga, Category } from '$lib/types'
|
||||
|
||||
interface Props {
|
||||
@@ -27,6 +28,9 @@
|
||||
|
||||
let movePanelOpen = $state(false)
|
||||
|
||||
const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? false)
|
||||
const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true)
|
||||
|
||||
function onDocDown(e: MouseEvent) {
|
||||
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
|
||||
}
|
||||
@@ -114,10 +118,11 @@
|
||||
class="card"
|
||||
class:card-selected={isSelected}
|
||||
class:select-mode={selectMode}
|
||||
class:stats-always={statsAlways}
|
||||
onclick={(e) => onCardClick(e, m)}
|
||||
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
||||
>
|
||||
<div class="cover-wrap" class:completed={isCompleted}>
|
||||
<div class="cover-wrap" class:completed={isCompleted} class:cover-contain={!cropCovers}>
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" id={m.id} />
|
||||
<div class="overlay">
|
||||
<div class="badges">
|
||||
@@ -236,6 +241,7 @@
|
||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||
|
||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.cover-contain :global(.cover) { object-fit: contain; }
|
||||
|
||||
.overlay {
|
||||
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
||||
@@ -245,6 +251,7 @@
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.card:not(.select-mode):hover .overlay { opacity: 1; }
|
||||
.stats-always .overlay { opacity: 1; }
|
||||
|
||||
.badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
||||
.badge {
|
||||
|
||||
@@ -4,21 +4,19 @@
|
||||
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
||||
} from "phosphor-svelte";
|
||||
import LibraryFilters from "./LibraryFilters.svelte";
|
||||
import type { Category } from "@types";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
|
||||
import type { Category } from "$lib/types";
|
||||
import type { LibrarySortOption, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "$lib/state/library.svelte";
|
||||
|
||||
interface Props {
|
||||
tab: string;
|
||||
tabSortMode: LibrarySortMode;
|
||||
tabSortMode: LibrarySortOption;
|
||||
tabSortDir: LibrarySortDir;
|
||||
tabStatus: LibraryStatusFilter;
|
||||
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
||||
hasActiveFilters: boolean;
|
||||
anims: boolean;
|
||||
anims?: boolean;
|
||||
visibleCategories: Category[];
|
||||
visibleTabIds: string[];
|
||||
virtualTabIds: string[];
|
||||
folderTabIds: string[];
|
||||
completedCatId: number | null;
|
||||
counts: Record<string, number>;
|
||||
search: string;
|
||||
@@ -35,7 +33,7 @@
|
||||
tabsEl: HTMLDivElement;
|
||||
onSearchChange: (v: string) => void;
|
||||
onTabChange: (f: string) => void;
|
||||
onSortChange: (mode: LibrarySortMode) => void;
|
||||
onSortChange: (mode: LibrarySortOption) => void;
|
||||
onSortDirToggle: () => void;
|
||||
onStatusChange: (s: LibraryStatusFilter) => void;
|
||||
onFilterToggle: (f: LibraryContentFilter) => void;
|
||||
@@ -44,7 +42,6 @@
|
||||
onFilterPanelToggle: () => void;
|
||||
onRefresh: () => void;
|
||||
onCancelRefresh: () => void;
|
||||
onRefreshCategory: (catId: number) => void;
|
||||
onOpenDownloadsFolder: () => void;
|
||||
onTabDragStart: (e: DragEvent, id: string) => void;
|
||||
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
|
||||
@@ -55,13 +52,13 @@
|
||||
|
||||
let {
|
||||
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
||||
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
|
||||
anims = false, visibleCategories, visibleTabIds, completedCatId,
|
||||
counts, search, refreshing, refreshProgress, refreshDone, refreshingCatId,
|
||||
activeDragKind, dragInsertIdx, dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
||||
tabsEl = $bindable(),
|
||||
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
||||
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
|
||||
onRefresh, onCancelRefresh, onRefreshCategory, onOpenDownloadsFolder,
|
||||
onRefresh, onCancelRefresh, onOpenDownloadsFolder,
|
||||
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -85,18 +82,18 @@
|
||||
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
|
||||
});
|
||||
|
||||
const SORT_LABELS: Record<LibrarySortMode, string> = {
|
||||
const SORT_LABELS: Record<LibrarySortOption, string> = {
|
||||
az: "A–Z",
|
||||
unreadCount: "Unread chapters",
|
||||
totalChapters: "Total chapters",
|
||||
recentlyAdded: "Recently added",
|
||||
recentlyRead: "Recently read",
|
||||
dateAdded: "Recently added",
|
||||
lastRead: "Recently read",
|
||||
latestFetched: "Latest fetched chapter",
|
||||
latestUploaded: "Latest uploaded chapter",
|
||||
};
|
||||
|
||||
const ALL_SORT_MODES: LibrarySortMode[] = [
|
||||
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
||||
const ALL_SORT_MODES: LibrarySortOption[] = [
|
||||
"az", "unreadCount", "totalChapters", "dateAdded", "lastRead", "latestFetched", "latestUploaded",
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { readerState } from "$lib/state/reader.svelte";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
||||
@@ -54,11 +55,51 @@
|
||||
let resolvedSrc = $state<Record<number, string>>({});
|
||||
let revokeQueue: string[] = [];
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
const elementIndex = new Map<Element, number>();
|
||||
// Aspect ratios (w/h) keyed by flat index, written by the img onload handler.
|
||||
// Retained as a fallback for scrollToFlatIndex when a slot is not yet in DOM.
|
||||
const aspectMap = new Map<number, number>();
|
||||
|
||||
let viewportCenter = $state(0);
|
||||
let currentSrc = $state<string | null>(null);
|
||||
let currentGroupSrcs = $state<(string | null)[]>([]);
|
||||
|
||||
let centerIdx = $state(0);
|
||||
|
||||
// ── Non-longstrip page src resolution ────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (style === "longstrip" || !pageReady) return;
|
||||
const pageNum = readerState.pageNumber;
|
||||
const urls = readerState.pageUrls;
|
||||
const group = currentGroup;
|
||||
currentSrc = null;
|
||||
currentGroupSrcs = group.map(() => null);
|
||||
let cancelled = false;
|
||||
if (style === "double") {
|
||||
group.forEach((pg, i) => {
|
||||
const url = urls[pg - 1];
|
||||
if (!url) return;
|
||||
resolveUrl(url, 999).then(src => {
|
||||
if (cancelled) return;
|
||||
currentGroupSrcs = currentGroupSrcs.map((s, j) => j === i ? src : s);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const url = urls[pageNum - 1];
|
||||
if (url) resolveUrl(url, 999).then(src => { if (!cancelled) currentSrc = src; });
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
});
|
||||
|
||||
// ── Non-longstrip: scroll to top on every page change ────────────────────
|
||||
// Ported from Suwayomi's useReaderScrollToStartOnPageChange.
|
||||
// Prevents stale pan position carrying over when flipping pages.
|
||||
$effect(() => {
|
||||
void readerState.pageNumber;
|
||||
if (style !== "longstrip" && containerEl) {
|
||||
containerEl.scrollTo(0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Blob URL revocation ───────────────────────────────────────────────────
|
||||
function scheduleRevoke(src: string) {
|
||||
if (!src || !src.startsWith("blob:")) return;
|
||||
revokeQueue.push(src);
|
||||
@@ -68,6 +109,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load window management ────────────────────────────────────────────────
|
||||
function loadPage(idx: number) {
|
||||
if (loadedSet.has(idx)) return;
|
||||
const page = flatPages[idx];
|
||||
@@ -99,62 +141,162 @@
|
||||
}
|
||||
}
|
||||
|
||||
function recalcWindow() {
|
||||
const center = viewportCenter;
|
||||
const lo = center - LOAD_RADIUS;
|
||||
const hi = center + LOAD_RADIUS;
|
||||
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);
|
||||
if (i >= lo && i <= hi) loadPage(i);
|
||||
else if (i < evictLo || i > evictHi) unloadPage(i);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { void viewportCenter; recalcWindow(); });
|
||||
$effect(() => { void flatPages.length; recalcWindow(); });
|
||||
$effect(() => { recalcWindow(centerIdx); });
|
||||
$effect(() => { void flatPages.length; recalcWindow(centerIdx); });
|
||||
|
||||
function setupObserver(containerEl: HTMLElement) {
|
||||
observer?.disconnect();
|
||||
elementIndex.clear();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let best = -1;
|
||||
let bestRatio = -1;
|
||||
for (const entry of entries) {
|
||||
const idx = elementIndex.get(entry.target);
|
||||
if (idx === undefined) continue;
|
||||
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
|
||||
bestRatio = entry.intersectionRatio;
|
||||
best = idx;
|
||||
}
|
||||
}
|
||||
if (best >= 0 && best !== viewportCenter) viewportCenter = best;
|
||||
},
|
||||
{ root: containerEl, rootMargin: "0px", threshold: [0, 0.1, 0.5, 1.0] },
|
||||
);
|
||||
}
|
||||
// ── Scroll position preservation on image resize above viewport ───────────
|
||||
// Ported from Suwayomi's usePreserveOnLeadingPageRender.
|
||||
//
|
||||
// Problem: when a placeholder above the current scroll position loads its
|
||||
// real image and changes height, the browser shifts the scroll position
|
||||
// relative to the viewport (layout shift). This corrects for that by:
|
||||
// 1. Tracking the first visible image and its offsetTop at last scroll.
|
||||
// 2. On every ResizeObserver entry for an image above scrollTop, computing
|
||||
// the delta and applying it as a scroll correction.
|
||||
//
|
||||
// MutationObserver watches for images being added/removed so the
|
||||
// ResizeObserver stays in sync with the actual DOM without needing
|
||||
// querySelectorAll on every scroll tick.
|
||||
$effect(() => {
|
||||
if (style !== "longstrip" || !containerEl) return;
|
||||
|
||||
function observePage(el: HTMLDivElement, idx: number) {
|
||||
elementIndex.set(el, idx);
|
||||
observer?.observe(el);
|
||||
return {
|
||||
update(newIdx: number) { elementIndex.set(el, newIdx); },
|
||||
destroy() { observer?.unobserve(el); elementIndex.delete(el); },
|
||||
let visibleImg: HTMLElement | undefined;
|
||||
let visibleImgTop = 0;
|
||||
let lastScrollTop = 0;
|
||||
|
||||
const onScroll = () => {
|
||||
lastScrollTop = containerEl.scrollTop;
|
||||
if (visibleImg) {
|
||||
visibleImgTop = visibleImg.offsetTop;
|
||||
}
|
||||
};
|
||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
const intersectionObs = new IntersectionObserver((entries) => {
|
||||
const first = entries.find(e => e.isIntersecting);
|
||||
if (first?.target instanceof HTMLElement) {
|
||||
visibleImg = first.target;
|
||||
visibleImgTop = first.target.offsetTop;
|
||||
}
|
||||
});
|
||||
|
||||
const resizeObs = new ResizeObserver((entries) => {
|
||||
if (!visibleImg) return;
|
||||
const hasEntryBeforeScroll = entries.some(e => {
|
||||
if (!(e.target instanceof HTMLElement)) return false;
|
||||
// Skip zero-size preload placeholders (they are outside the load window)
|
||||
if (!e.target.clientWidth && !e.target.clientHeight) return false;
|
||||
return e.target.offsetTop < lastScrollTop;
|
||||
});
|
||||
if (!hasEntryBeforeScroll) return;
|
||||
const newTop = lastScrollTop - visibleImgTop + visibleImg.offsetTop;
|
||||
containerEl.scrollTo({ top: newTop, behavior: "instant" } as ScrollToOptions);
|
||||
});
|
||||
|
||||
const observe = (el: Element) => { intersectionObs.observe(el); resizeObs.observe(el); };
|
||||
const unobserve = (el: Element) => { intersectionObs.unobserve(el); resizeObs.unobserve(el); };
|
||||
|
||||
const mutationObs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
m.addedNodes.forEach(n => {
|
||||
if (!(n instanceof HTMLElement)) return;
|
||||
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
|
||||
imgs.forEach(observe);
|
||||
});
|
||||
m.removedNodes.forEach(n => {
|
||||
if (!(n instanceof HTMLElement)) return;
|
||||
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
|
||||
imgs.forEach(unobserve);
|
||||
});
|
||||
}
|
||||
});
|
||||
mutationObs.observe(containerEl, { childList: true, subtree: true });
|
||||
|
||||
// Observe images already in the DOM at setup time
|
||||
containerEl.querySelectorAll("img").forEach(observe);
|
||||
|
||||
return () => {
|
||||
containerEl.removeEventListener("scroll", onScroll);
|
||||
mutationObs.disconnect();
|
||||
resizeObs.disconnect();
|
||||
intersectionObs.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
// ── Cursor hide on inactivity (longstrip) ─────────────────────────────────
|
||||
// Ported from Suwayomi's useReaderHideCursorOnInactivity.
|
||||
// Hides the cursor after 5 s of mouse inactivity, restores on movement.
|
||||
$effect(() => {
|
||||
if (style !== "longstrip" || !containerEl) return;
|
||||
|
||||
const HIDE_AFTER_MS = 5_000;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const show = () => {
|
||||
containerEl.style.cursor = "";
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => { containerEl.style.cursor = "none"; }, HIDE_AFTER_MS);
|
||||
};
|
||||
|
||||
show(); // start the timer immediately
|
||||
window.addEventListener("mousemove", show, { passive: true });
|
||||
|
||||
return () => {
|
||||
containerEl.style.cursor = "";
|
||||
window.removeEventListener("mousemove", show);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
});
|
||||
|
||||
// ── Scroll to target flat index ───────────────────────────────────────────
|
||||
export function notifyScrollCenter(idx: number) {
|
||||
centerIdx = idx;
|
||||
}
|
||||
|
||||
export async function scrollToFlatIndex(idx: number) {
|
||||
if (!containerEl || !flatPages.length) return;
|
||||
centerIdx = idx;
|
||||
recalcWindow(idx);
|
||||
// Wait for Svelte to render any newly-in-window slots.
|
||||
await tick();
|
||||
if (!containerEl) return;
|
||||
// Use scrollIntoView — the browser knows the exact element position
|
||||
// regardless of image load state or aspect ratio. This is the same approach
|
||||
// used by Suwayomi's useReaderHandlePageSelection (imageRef.scrollIntoView).
|
||||
const slots = containerEl.querySelectorAll<HTMLElement>(".strip-slot");
|
||||
const slot = slots[idx];
|
||||
if (slot) {
|
||||
slot.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
} else {
|
||||
// Slot not in DOM — proportional fallback (very unlikely after tick).
|
||||
containerEl.scrollTop = (idx / flatPages.length) * containerEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset on chapter change ───────────────────────────────────────────────
|
||||
let lastChapterId = 0;
|
||||
$effect(() => {
|
||||
const chapterId = readerState.activeChapter?.id ?? 0;
|
||||
if (chapterId === lastChapterId) return;
|
||||
lastChapterId = chapterId;
|
||||
loadedSet = new Set<number>();
|
||||
resolvedSrc = {};
|
||||
const resume = readerState.resumePage;
|
||||
viewportCenter = resume > 1 ? resume - 1 : 0;
|
||||
loadedSet = new Set<number>();
|
||||
resolvedSrc = {};
|
||||
centerIdx = 0;
|
||||
aspectMap.clear();
|
||||
});
|
||||
|
||||
// ── Inspect / zoom helpers ────────────────────────────────────────────────
|
||||
const INSPECT_ZOOM_STEP = 0.15;
|
||||
const INSPECT_ZOOM_MAX = 8;
|
||||
|
||||
@@ -238,6 +380,11 @@
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (style !== "longstrip") stopMidScroll();
|
||||
});
|
||||
|
||||
// ── Pinch zoom ────────────────────────────────────────────────────────────
|
||||
let pinch: PinchTracker | null = null;
|
||||
|
||||
$effect(() => {
|
||||
@@ -255,6 +402,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Pointer / mouse / wheel event handlers ────────────────────────────────
|
||||
export function onInspectMouseDown(e: MouseEvent) {
|
||||
if ((e.target as Element).closest(".bar")) return;
|
||||
if (e.button === 1 && style === "longstrip") {
|
||||
@@ -388,18 +536,7 @@
|
||||
function setContainer(el: HTMLDivElement) {
|
||||
containerEl = el;
|
||||
bindContainer(el);
|
||||
if (style === "longstrip") setupObserver(el);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (style === "longstrip" && containerEl) {
|
||||
setupObserver(containerEl);
|
||||
} else if (style !== "longstrip") {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
stopMidScroll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -459,7 +596,7 @@
|
||||
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
||||
{@const src = resolvedSrc[gi]}
|
||||
{@const isLoaded = loadedSet.has(gi)}
|
||||
<div class="strip-slot" use:observePage={gi}>
|
||||
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId}>
|
||||
{#if isLoaded && src}
|
||||
<img
|
||||
{src}
|
||||
@@ -475,7 +612,9 @@
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const slot = img.closest<HTMLElement>(".strip-slot");
|
||||
if (slot && img.naturalWidth > 0) {
|
||||
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
|
||||
const aspect = img.naturalWidth / img.naturalHeight;
|
||||
slot.style.setProperty("--aspect", String(aspect));
|
||||
aspectMap.set(gi, aspect);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -490,11 +629,11 @@
|
||||
|
||||
{:else if style === "fade" && pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
|
||||
{#if currentSrc}
|
||||
<img src={currentSrc} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
|
||||
{:else}
|
||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||
{:then src}
|
||||
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if style === "double" && pageReady}
|
||||
@@ -502,11 +641,11 @@
|
||||
{#if pageGroups.length}
|
||||
<div class="double-wrap">
|
||||
{#each currentGroup as pg, i (pg)}
|
||||
{#await resolveUrl(readerState.pageUrls[pg - 1], 999)}
|
||||
{#if currentGroupSrcs[i]}
|
||||
<img src={currentGroupSrcs[i]} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
||||
{:else}
|
||||
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">{@render skeleton()}</div>
|
||||
{:then src}
|
||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
||||
{/await}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -518,11 +657,11 @@
|
||||
|
||||
{:else if pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
|
||||
{#if currentSrc}
|
||||
<img src={currentSrc} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
||||
{:else}
|
||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||
{:then src}
|
||||
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount, untrack, tick } from "svelte";
|
||||
import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte";
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import { app } from "$lib/state/app.svelte";
|
||||
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";
|
||||
@@ -12,7 +12,10 @@
|
||||
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
|
||||
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
|
||||
import { historyState } from "$lib/state/history.svelte";
|
||||
import { setPreviewManga } from "$lib/state/series.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import { setReading, clearReading } from "$lib/core/discord";
|
||||
import { revokeBlobUrl, cancelQueuedFetches, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||
import type { ReaderSettings } from "$lib/state/reader.svelte";
|
||||
import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
|
||||
import PageView from "$lib/components/reader/PageView.svelte";
|
||||
@@ -209,6 +212,36 @@
|
||||
|
||||
const startAtLast = () => { startAtLastPageRef.current = true; };
|
||||
|
||||
function flatIndexForPage(page: number): number {
|
||||
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
||||
const chunks = readerState.stripChapters;
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.chapterId === chId) return offset + Math.max(0, page - 1);
|
||||
offset += chunk.urls.length;
|
||||
}
|
||||
return Math.max(0, page - 1);
|
||||
}
|
||||
|
||||
function primedJump(page: number, commit = true) {
|
||||
if (useBlob && commit && style !== "longstrip") {
|
||||
cancelQueuedFetches();
|
||||
const urls = readerState.pageUrls;
|
||||
const lo = Math.max(0, page - 2);
|
||||
const hi = Math.min(urls.length, page + 4);
|
||||
preloadBlobUrls(urls.slice(lo, hi), 999);
|
||||
}
|
||||
jumpToPage(
|
||||
page,
|
||||
style,
|
||||
lastPage,
|
||||
style === "longstrip" ? (idx) => pageViewRef.scrollToFlatIndex(idx) : null,
|
||||
stripToRender.reduce((s, c) => s + c.urls.length, 0),
|
||||
readerState.visibleChapterId ?? readerState.activeChapter?.id ?? 0,
|
||||
readerState.stripChapters,
|
||||
);
|
||||
}
|
||||
|
||||
const goNext = $derived(rtl
|
||||
? () => goBack(style, adjacent, startAtLast)
|
||||
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
|
||||
@@ -216,17 +249,26 @@
|
||||
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
|
||||
: () => goBack(style, adjacent, startAtLast));
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const onKey = createReaderKeyHandler({
|
||||
goNext: () => goNext(),
|
||||
goPrev: () => goPrev(),
|
||||
closeReader: () => readerState.closeReader(),
|
||||
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
|
||||
closeReader: () => handleCloseReader(),
|
||||
goToPage: (p) => primedJump(p),
|
||||
lastPage: () => lastPage,
|
||||
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
||||
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||
openSettings: () => { app.setSettingsOpen(true); },
|
||||
openSettings: () => { app.setSettingsOpen(true); },
|
||||
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
|
||||
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(settingsState.settings.autoScroll ?? false) }); },
|
||||
toggleMarker: () => {
|
||||
@@ -313,6 +355,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const ch = readerState.activeChapter;
|
||||
const manga = readerState.activeManga;
|
||||
const idle = appState.idleSplash;
|
||||
if (ch && manga && !idle) {
|
||||
untrack(() => setReading(manga, ch).catch(() => {}));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const page = readerState.pageNumber;
|
||||
const chId = style === "longstrip"
|
||||
@@ -339,26 +390,18 @@
|
||||
if (style === "longstrip" && readerState.pageUrls.length && readerState.activeChapter) {
|
||||
const ch = readerState.activeChapter;
|
||||
const urls = readerState.pageUrls;
|
||||
const targetPg = untrack(() => readerState.resumePage);
|
||||
const resumeTo = untrack(() => readerState.resumePage);
|
||||
appending = false;
|
||||
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
||||
readerState.visibleChapterId = ch.id;
|
||||
tick().then(() => {
|
||||
if (!containerEl) return;
|
||||
if (targetPg > 1) {
|
||||
const chId = ch.id;
|
||||
const scrollToResumePage = () => {
|
||||
const target = containerEl!.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
|
||||
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
|
||||
containerEl!.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`).forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
|
||||
const doScroll = () => { target.scrollIntoView({ block: "start" }); readerState.stripResumeReady = true; };
|
||||
if (target.complete && target.naturalHeight > 0) doScroll();
|
||||
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
|
||||
};
|
||||
scrollToResumePage();
|
||||
if (resumeTo > 1) {
|
||||
pageViewRef.scrollToFlatIndex(resumeTo - 1);
|
||||
readerState.stripResumeReady = true;
|
||||
return;
|
||||
}
|
||||
containerEl!.scrollTop = 0;
|
||||
containerEl.scrollTop = 0;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -408,10 +451,11 @@
|
||||
untrack(() => {
|
||||
cleanupScroll();
|
||||
cleanupScroll = setupScrollTracking(containerEl!, {
|
||||
onPageChange: (p) => { readerState.pageNumber = p; },
|
||||
onChapterChange: (id) => { readerState.visibleChapterId = id; },
|
||||
onMarkRead: (id) => markChapterRead(id, markedRead),
|
||||
onAppend: () => {
|
||||
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(
|
||||
@@ -606,6 +650,7 @@
|
||||
onClampZoom={clampZoom}
|
||||
onApplySettings={applySettings}
|
||||
onSettingsOpen={() => { app.setSettingsOpen(true); }}
|
||||
onOpenPreview={() => { if (readerState.activeManga) setPreviewManga(readerState.activeManga); }}
|
||||
{perMangaEnabled}
|
||||
/>
|
||||
|
||||
@@ -666,7 +711,7 @@
|
||||
{barPosition}
|
||||
onGoPrev={goPrev}
|
||||
onGoNext={goNext}
|
||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||
onJumpToPage={(p, commit) => primedJump(p, commit)}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -680,7 +725,7 @@
|
||||
{barPosition}
|
||||
onGoPrev={goPrev}
|
||||
onGoNext={goNext}
|
||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||
onJumpToPage={(p, commit) => primedJump(p, commit)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
onClampZoom: (z: number) => number;
|
||||
onApplySettings: (patch: Partial<ReaderSettings>) => void;
|
||||
onSettingsOpen: () => void;
|
||||
onOpenPreview: () => void;
|
||||
perMangaEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@
|
||||
barPosition, progressBar,
|
||||
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
||||
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
||||
onClampZoom, onApplySettings, onSettingsOpen,
|
||||
onClampZoom, onApplySettings, onSettingsOpen, onOpenPreview,
|
||||
perMangaEnabled,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -155,12 +156,12 @@
|
||||
<span class="ch-info"></span>
|
||||
{:else}
|
||||
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
|
||||
<span class="ch-marquee-content">
|
||||
<span class="ch-title">{readerState.activeManga?.title}</span>
|
||||
<span class="ch-sep">/</span>
|
||||
<span class="ch-name">{displayChapter?.name}</span>
|
||||
<button class="ch-marquee-content ch-preview-btn" onclick={onOpenPreview}>
|
||||
<span class="ch-title">{readerState.activeManga?.title}</span>
|
||||
<span class="ch-sep">/</span>
|
||||
<span class="ch-name">{displayChapter?.name}</span>
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !isVertical}
|
||||
@@ -494,6 +495,8 @@
|
||||
.ch-marquee-track { overflow-x: auto; min-width: 0; flex: 1; scrollbar-width: none; }
|
||||
.ch-marquee-track::-webkit-scrollbar { display: none; }
|
||||
.ch-marquee-content { display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
|
||||
.ch-preview-btn { background: none; border: none; cursor: pointer; padding: 0; font-size: inherit; font-family: inherit; border-radius: var(--radius-sm); transition: opacity var(--t-fast); }
|
||||
.ch-preview-btn:hover { opacity: 0.7; }
|
||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||||
.ch-name { color: var(--text-muted); }
|
||||
|
||||
@@ -21,11 +21,27 @@
|
||||
readerState.dlOpen = false;
|
||||
}
|
||||
|
||||
let bannerMounted = $state(false);
|
||||
let bannerFading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (showResumeBanner) {
|
||||
bannerMounted = true;
|
||||
bannerFading = false;
|
||||
} else if (bannerMounted) {
|
||||
bannerFading = true;
|
||||
}
|
||||
});
|
||||
|
||||
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
|
||||
|
||||
function onBannerAnimationEnd() {
|
||||
if (bannerFading) { bannerMounted = false; bannerFading = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showResumeBanner}
|
||||
<button class="resume-banner" class:fading={resumeFading} onclick={onDismissResume}>
|
||||
{#if bannerMounted}
|
||||
<button class="resume-banner" class:fading={bannerFading} onclick={onDismissResume} onanimationend={onBannerAnimationEnd}>
|
||||
Bookmark at page {resumePage}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
barPosition: "top" | "left" | "right";
|
||||
onGoPrev: () => void;
|
||||
onGoNext: () => void;
|
||||
onJumpToPage: (page: number) => void;
|
||||
onJumpToPage: (page: number, commit?: boolean) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -32,12 +32,22 @@
|
||||
|
||||
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||
|
||||
const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage);
|
||||
const hPct = $derived(`--pct:${sliderPct}%`);
|
||||
const hPct = $derived(`--pct:${sliderPct}%`);
|
||||
|
||||
function sliderValToPage(raw: number): number {
|
||||
return rtl ? sliderMax - raw + 1 : raw;
|
||||
}
|
||||
|
||||
function pageToSliderVal(page: number): number {
|
||||
return rtl ? sliderMax - page + 1 : page;
|
||||
}
|
||||
|
||||
function handleH(e: Event) {
|
||||
const raw = Number((e.target as HTMLInputElement).value);
|
||||
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
|
||||
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), false);
|
||||
}
|
||||
|
||||
function handleHCommit(e: Event) {
|
||||
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), true);
|
||||
}
|
||||
|
||||
function markerPct(pageNumber: number, forRtl = false): number {
|
||||
@@ -46,9 +56,9 @@
|
||||
return ((ord - 1) / (sliderMax - 1)) * 100;
|
||||
}
|
||||
|
||||
// Custom vertical slider
|
||||
let trackEl = $state<HTMLDivElement | null>(null);
|
||||
let dragging = $state(false);
|
||||
let pendingPage = 0;
|
||||
|
||||
function pctFromPointer(clientY: number): number {
|
||||
if (!trackEl) return 0;
|
||||
@@ -64,22 +74,24 @@
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragging = true;
|
||||
dragging = true;
|
||||
readerState.sliderDragging = true;
|
||||
const pct = pctFromPointer(e.clientY);
|
||||
onJumpToPage(pageFromPct(pct));
|
||||
pendingPage = pageFromPct(pctFromPointer(e.clientY));
|
||||
onJumpToPage(pendingPage, false);
|
||||
}
|
||||
|
||||
function handleTrackPointerMove(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
const pct = pctFromPointer(e.clientY);
|
||||
onJumpToPage(pageFromPct(pct));
|
||||
pendingPage = pageFromPct(pctFromPointer(e.clientY));
|
||||
onJumpToPage(pendingPage, false);
|
||||
}
|
||||
|
||||
function handleTrackPointerUp(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
readerState.sliderDragging = false;
|
||||
readerState.sliderHover = false;
|
||||
onJumpToPage(pendingPage, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -102,8 +114,9 @@
|
||||
style={hPct}
|
||||
min={1}
|
||||
max={sliderMax}
|
||||
value={hValue}
|
||||
value={pageToSliderVal(sliderPage)}
|
||||
oninput={handleH}
|
||||
onchange={handleHCommit}
|
||||
onmousedown={() => readerState.sliderDragging = true}
|
||||
onmouseup={() => readerState.sliderDragging = false}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { readerState } from "$lib/state/reader.svelte";
|
||||
import { fetchPages } from "./pageLoader";
|
||||
import { cancelQueuedFetches } from "$lib/core/cache/imageCache";
|
||||
import { clearResolvedUrlCache } from "$lib/core/cache/pageCache";
|
||||
import { readerState } from "$lib/state/reader.svelte";
|
||||
import { fetchPages } from "./pageLoader";
|
||||
import { cancelQueuedFetches, revokeBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||
import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache";
|
||||
|
||||
export function scheduleResumeDismiss() {
|
||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||
setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500);
|
||||
}
|
||||
|
||||
let prefetchedChapterId: number | null = null;
|
||||
|
||||
export async function loadChapter(
|
||||
id: number,
|
||||
useBlob: boolean,
|
||||
@@ -21,7 +23,19 @@ export async function loadChapter(
|
||||
abortCtrl.current = ctrl;
|
||||
|
||||
cancelQueuedFetches();
|
||||
if (useBlob) clearResolvedUrlCache();
|
||||
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;
|
||||
}
|
||||
|
||||
startAtLastPage.current = false;
|
||||
markedRead.clear();
|
||||
@@ -32,19 +46,27 @@ export async function loadChapter(
|
||||
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
||||
readerState.resumeDismissed = false;
|
||||
readerState.resumeVisible = resumeTo > 1;
|
||||
if (resumeTo > 1) scheduleResumeDismiss();
|
||||
readerState.resumeVisible = false;
|
||||
|
||||
readerState.pageNumber = 1;
|
||||
try {
|
||||
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
||||
if (ctrl.signal.aborted) return;
|
||||
readerState.pageUrls = urls;
|
||||
if (useBlob && resumeTo > 1) {
|
||||
const lo = Math.max(0, resumeTo - 2);
|
||||
const hi = Math.min(urls.length, resumeTo + 4);
|
||||
preloadBlobUrls(urls.slice(lo, hi), 900);
|
||||
}
|
||||
if (startAtLastPage.current) readerState.pageNumber = urls.length;
|
||||
else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||
readerState.pageReady = true;
|
||||
readerState.loading = false;
|
||||
if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
|
||||
if (resumeTo > 1) readerState.resumeVisible = true;
|
||||
if (adjacent.next) {
|
||||
prefetchedChapterId = adjacent.next.id;
|
||||
fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
readerState.error = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -64,14 +64,30 @@ export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () =>
|
||||
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
||||
}
|
||||
|
||||
export function jumpToPage(page: number, style: string, lastPage: number, containerEl: HTMLElement | null) {
|
||||
export function jumpToPage(
|
||||
page: number,
|
||||
style: string,
|
||||
lastPage: number,
|
||||
scrollToFlatIndex: ((idx: number) => void) | null,
|
||||
flatPageCount: number,
|
||||
activeChapterId: number,
|
||||
stripChapters: { chapterId: number; urls: string[] }[],
|
||||
) {
|
||||
if (style === "longstrip") {
|
||||
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
||||
containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" });
|
||||
if (!scrollToFlatIndex || flatPageCount === 0) return;
|
||||
let offset = 0;
|
||||
for (const chunk of stripChapters) {
|
||||
if (chunk.chapterId === activeChapterId) {
|
||||
scrollToFlatIndex(offset + Math.max(0, page - 1));
|
||||
return;
|
||||
}
|
||||
offset += chunk.urls.length;
|
||||
}
|
||||
scrollToFlatIndex(Math.max(0, page - 1));
|
||||
return;
|
||||
}
|
||||
if (style === "double" && readerState.pageGroups.length) {
|
||||
const group = readerState.pageGroups[page - 1];
|
||||
const group = readerState.pageGroups.find(g => g.includes(page)) ?? readerState.pageGroups.findLast(g => g[0] <= page);
|
||||
if (group) readerState.pageNumber = group[0];
|
||||
} else {
|
||||
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
|
||||
|
||||
@@ -7,34 +7,65 @@ export interface StripChapter {
|
||||
}
|
||||
|
||||
export interface ScrollHandlerCallbacks {
|
||||
onPageChange: (page: number) => void;
|
||||
onChapterChange: (chapterId: number) => void;
|
||||
onMarkRead: (chapterId: number) => void;
|
||||
onAppend: () => void;
|
||||
getStripChapters: () => StripChapter[];
|
||||
getPageUrls: () => string[];
|
||||
shouldAutoMark: () => boolean;
|
||||
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, onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark } = callbacks;
|
||||
const {
|
||||
onPageChange, onChapterChange, onCenterIdxChange,
|
||||
onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
||||
} = callbacks;
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
function tick() {
|
||||
rafId = null;
|
||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||
|
||||
if (!imgs.length) return;
|
||||
|
||||
const containerTop = containerEl.getBoundingClientRect().top;
|
||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
||||
const containerRect = containerEl.getBoundingClientRect();
|
||||
const readLineY = containerRect.top + containerEl.clientHeight * READ_LINE_PCT;
|
||||
|
||||
// Find the last image whose top is at or above the read line.
|
||||
// Binary search is still valid here since images are ordered top-to-bottom.
|
||||
let lo = 0, hi = imgs.length - 1, best = 0;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
|
||||
if (isPageAtReadLine(imgs[mid], readLineY)) { best = mid; lo = mid + 1; }
|
||||
else hi = mid - 1;
|
||||
}
|
||||
|
||||
@@ -45,10 +76,19 @@ export function setupScrollTracking(
|
||||
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 chunks = getStripChapters();
|
||||
const chunk = chunks.find(c => c.chapterId === activeChId);
|
||||
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
||||
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;
|
||||
@@ -58,8 +98,9 @@ export function setupScrollTracking(
|
||||
}
|
||||
}
|
||||
|
||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||
if (pct >= 0.80) onAppend();
|
||||
if ((containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight >= 0.80) {
|
||||
onAppend();
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { get } from 'svelte/store'
|
||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||
import { resolvedCover } from '$lib/core/cover/coverResolver'
|
||||
import type { MangaPrefs } from '$lib/types/settings'
|
||||
import type { Manga, Chapter, Category } from '$lib/types'
|
||||
|
||||
import { seriesState } from '$lib/state/series.svelte'
|
||||
import { setPreviewManga } from '$lib/state/series.svelte'
|
||||
|
||||
@@ -59,6 +60,7 @@
|
||||
|
||||
let manageOpen: boolean = $state(false)
|
||||
let genresExpanded: boolean = $state(false)
|
||||
let descExpanded: boolean = $state(false)
|
||||
let altOpen: boolean = $state(false)
|
||||
|
||||
const statusLabel = $derived(
|
||||
@@ -93,7 +95,7 @@
|
||||
|
||||
<div class="cover-wrap">
|
||||
<button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}>
|
||||
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" id={seriesState.activeManga?.id ?? manga?.id} />
|
||||
<Thumbnail src={resolvedCover(manga?.id ?? seriesState.activeManga?.id ?? 0, manga?.thumbnailUrl ?? seriesState.activeManga?.thumbnailUrl ?? "")} alt={manga?.title ?? seriesState.activeManga?.title ?? ""} class="cover" id={manga?.id ?? seriesState.activeManga?.id} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +106,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="meta">
|
||||
<p class="title">{manga?.title}</p>
|
||||
<button class="title" onclick={() => manga && setPreviewManga(manga)} disabled={!manga}>{manga?.title}</button>
|
||||
|
||||
{#if manga?.author || manga?.artist}
|
||||
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' · ')}</p>
|
||||
@@ -148,8 +150,8 @@
|
||||
|
||||
{#if manga?.description}
|
||||
<div class="desc-wrap">
|
||||
<p class="desc">{manga.description}</p>
|
||||
<button class="expand-toggle" onclick={() => genresExpanded = !genresExpanded}>Read more</button>
|
||||
<p class="desc" class:desc-open={descExpanded}>{manga.description}</p>
|
||||
<button class="expand-toggle" onclick={() => descExpanded = !descExpanded}>{descExpanded ? 'Show less' : 'Read more'}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -277,7 +279,11 @@
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
color: var(--text-primary); line-height: var(--leading-snug);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
background: none; border: none; padding: 0; text-align: left; cursor: pointer;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.title:hover:not(:disabled) { color: var(--accent-fg); }
|
||||
.title:disabled { cursor: default; }
|
||||
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
||||
|
||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
@@ -328,6 +334,7 @@
|
||||
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
|
||||
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
||||
.expand-toggle {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide); align-self: flex-start; transition: color var(--t-base);
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
/* ── Backdrop & Modal Shell ───────────────────────────────────────── */
|
||||
.s-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
|
||||
z-index: var(--z-settings);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: s-fade-in 0.14s ease both;
|
||||
@@ -29,10 +27,7 @@
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
animation: s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255,255,255,0.04) inset,
|
||||
0 24px 80px rgba(0,0,0,0.7),
|
||||
0 8px 24px rgba(0,0,0,0.4);
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +41,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
overflow-y: auto;
|
||||
overflow-y: hidden;
|
||||
border-radius: var(--radius-2xl) 0 0 var(--radius-2xl);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import ContentSettings from './sections/ContentSettings.svelte'
|
||||
import AboutSettings from './sections/AboutSettings.svelte'
|
||||
import DevtoolsSettings from './sections/DevToolsSettings.svelte'
|
||||
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
|
||||
|
||||
interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void }
|
||||
let { onclose, onOpenThemeEditor }: Props = $props()
|
||||
@@ -111,6 +112,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<ModalBlur />
|
||||
<div class="s-backdrop" role="presentation" tabindex="-1"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) close() }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
|
||||
|
||||
@@ -102,9 +102,10 @@
|
||||
}
|
||||
|
||||
function triggerSplash() {
|
||||
if (appState.devSplash) return
|
||||
splashTriggered = true
|
||||
setTimeout(() => splashTriggered = false, 200)
|
||||
appState.idleSplash = true
|
||||
appState.devSplash = true
|
||||
}
|
||||
|
||||
async function testWindowsHello() {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
} from "$lib/state/series.svelte";
|
||||
import { app } from "$lib/state/app.svelte";
|
||||
import type { Manga, Chapter, Category } from "$lib/types";
|
||||
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
|
||||
|
||||
|
||||
let manga: Manga | null = $state(null);
|
||||
@@ -258,7 +259,7 @@
|
||||
function openSeriesDetail() {
|
||||
if (!displayManga) return;
|
||||
setActiveManga(displayManga);
|
||||
setNavPage(originNavPage);
|
||||
app.setNavPage(originNavPage);
|
||||
close();
|
||||
}
|
||||
|
||||
@@ -353,6 +354,7 @@
|
||||
</script>
|
||||
|
||||
{#if seriesState.previewManga}
|
||||
<ModalBlur blur={4} dim={0.72} />
|
||||
<div
|
||||
class="backdrop"
|
||||
role="button"
|
||||
@@ -679,10 +681,8 @@
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.72);
|
||||
z-index: var(--z-settings);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.12s ease both;
|
||||
}
|
||||
.modal {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
blur = 8,
|
||||
dim = 0.6,
|
||||
zIndex = 'var(--z-settings)',
|
||||
animate = true,
|
||||
}: {
|
||||
blur?: number
|
||||
dim?: number
|
||||
zIndex?: string | number
|
||||
animate?: boolean
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="modal-blur"
|
||||
class:animate
|
||||
style="--blur:{blur}px; --dim:{dim}; --z:{zIndex}"
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.modal-blur {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(var(--blur));
|
||||
-webkit-backdrop-filter: blur(var(--blur));
|
||||
background: rgba(0, 0, 0, var(--dim));
|
||||
pointer-events: none;
|
||||
z-index: var(--z);
|
||||
}
|
||||
|
||||
.modal-blur.animate {
|
||||
animation: blur-in 0.14s ease both;
|
||||
}
|
||||
|
||||
@keyframes blur-in {
|
||||
from { opacity: 0 }
|
||||
to { opacity: 1 }
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@
|
||||
import { markManyRead } from "$lib/request-manager/chapters";
|
||||
import type { Tracker, TrackRecord, TrackSearch } from "$lib/types";
|
||||
import type { Chapter } from "$lib/types";
|
||||
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
|
||||
|
||||
let { mangaId, mangaTitle, onClose }: {
|
||||
mangaId: number;
|
||||
@@ -250,6 +251,7 @@
|
||||
}
|
||||
}} />
|
||||
|
||||
<ModalBlur blur={4} dim={0.68} />
|
||||
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="modal" role="dialog" aria-label="Tracking">
|
||||
|
||||
@@ -497,6 +499,7 @@
|
||||
{#if confirmUnbindId !== null}
|
||||
{@const rec = records.find(r => r.id === confirmUnbindId)}
|
||||
{@const trk = rec ? trackerFor(rec.trackerId) : null}
|
||||
<ModalBlur blur={2} dim={0.45} zIndex="calc(var(--z-settings) + 1)" />
|
||||
<div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel"
|
||||
onclick={() => confirmUnbindId = null}
|
||||
onkeydown={(e) => { if (e.key === "Escape") confirmUnbindId = null; }}>
|
||||
@@ -515,10 +518,9 @@
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.68);
|
||||
position: fixed; inset: 0;
|
||||
z-index: var(--z-settings);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.12s ease both;
|
||||
}
|
||||
.modal {
|
||||
@@ -646,7 +648,7 @@
|
||||
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
||||
|
||||
.confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); background: rgba(0,0,0,0.45); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; }
|
||||
.confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; }
|
||||
.confirm-modal { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-xl); padding: var(--sp-5); width: 260px; display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 16px 48px rgba(0,0,0,0.5); animation: scaleIn 0.15s ease both; }
|
||||
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); margin: 0; }
|
||||
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; margin: 0; letter-spacing: var(--tracking-wide); }
|
||||
|
||||
Vendored
+11
-9
@@ -5,9 +5,9 @@ import { getUIAccessToken } from "$lib/core/auth";
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
const MAX_CONCURRENT = 6;
|
||||
let active = 0;
|
||||
let active = 0;
|
||||
let drainScheduled = false;
|
||||
let clearing = false;
|
||||
let generation = 0;
|
||||
|
||||
interface QueueEntry {
|
||||
url: string;
|
||||
@@ -32,10 +32,11 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async function doFetch(url: string): Promise<string> {
|
||||
async function doFetch(url: string, gen: number): Promise<string> {
|
||||
const headers = await getAuthHeaders();
|
||||
const blob = await platformService.fetchImage(url, headers);
|
||||
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
||||
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
||||
const blob = await platformService.fetchImage(url, headers);
|
||||
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
cache.set(url, blobUrl);
|
||||
return blobUrl;
|
||||
@@ -55,8 +56,9 @@ function drain() {
|
||||
drainScheduled = false;
|
||||
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||
const entry = queue.shift()!;
|
||||
const gen = generation;
|
||||
active++;
|
||||
doFetch(entry.url)
|
||||
doFetch(entry.url, gen)
|
||||
.then(entry.resolve, entry.reject)
|
||||
.finally(() => { active--; drain(); });
|
||||
}
|
||||
@@ -107,6 +109,7 @@ export function preloadBlobUrls(urls: string[], basePriority = 0): void {
|
||||
export function revokeBlobUrl(url: string): void {
|
||||
const blob = cache.get(url);
|
||||
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
|
||||
inflight.delete(url);
|
||||
}
|
||||
|
||||
export function deprioritizeQueue(): void {
|
||||
@@ -123,10 +126,9 @@ export function cancelQueuedFetches(): void {
|
||||
}
|
||||
|
||||
export function clearBlobCache(): void {
|
||||
clearing = true;
|
||||
generation++;
|
||||
cancelQueuedFetches();
|
||||
inflight.clear();
|
||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
clearing = false;
|
||||
}
|
||||
Vendored
+5
-2
@@ -1,5 +1,5 @@
|
||||
import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import { getBlobUrl, preloadBlobUrls, revokeBlobUrl } from "$lib/core/cache/imageCache";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
|
||||
const pageCache = new Map<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
@@ -90,6 +90,9 @@ export function preloadImage(url: string, useBlob: boolean): void {
|
||||
}
|
||||
|
||||
export function clearResolvedUrlCache(): void {
|
||||
for (const promise of resolvedUrlCache.values()) {
|
||||
promise.then(blobUrl => { if (blobUrl) revokeBlobUrl(blobUrl); }).catch(() => {});
|
||||
}
|
||||
resolvedUrlCache.clear();
|
||||
aspectCache.clear();
|
||||
}
|
||||
|
||||
+31
-17
@@ -1,4 +1,5 @@
|
||||
import { platformService } from '$lib/platform-service'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import type { Manga } from '$lib/types/manga'
|
||||
import type { Chapter } from '$lib/types/chapter'
|
||||
|
||||
@@ -9,11 +10,8 @@ const APP_BUTTONS = [
|
||||
|
||||
const FALLBACK_IMAGE = 'moku_logo'
|
||||
|
||||
let sessionStart: number | null = null
|
||||
|
||||
function isPublicUrl(url: string | null | undefined): boolean {
|
||||
return typeof url === 'string' && url.startsWith('https://')
|
||||
}
|
||||
let sessionStart: number | null = null
|
||||
let activeMangaId: number | null = null
|
||||
|
||||
function trunc(s: string, max = 128): string {
|
||||
return s.length <= max ? s : `${s.slice(0, max - 1)}…`
|
||||
@@ -24,6 +22,31 @@ function formatChapter(chapter: Chapter): string {
|
||||
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
|
||||
}
|
||||
|
||||
// Suwayomi always returns the proxy path (/api/v1/manga/{id}/thumbnail), never the raw CDN URL.
|
||||
// The proxy URL is only useful to Discord when the server is publicly reachable over HTTPS.
|
||||
// For localhost setups cover art falls back to the app logo until Suwayomi exposes rawThumbnailUrl.
|
||||
function resolveCoverUrl(manga: Manga): string {
|
||||
const serverBase = (settingsState.settings.serverUrl ?? '').replace(/\/$/, '')
|
||||
if (!serverBase.startsWith('https://')) return FALLBACK_IMAGE
|
||||
const path = manga.thumbnailUrl?.startsWith('/') ? manga.thumbnailUrl : `/api/v1/manga/${manga.id}/thumbnail`
|
||||
return `${serverBase}${path}`
|
||||
}
|
||||
|
||||
function buildPresence(manga: Manga, chapter: Chapter, coverUrl: string) {
|
||||
return {
|
||||
details: trunc(manga.title),
|
||||
state: `${formatChapter(chapter)} · Reading`,
|
||||
timestamps: { start: sessionStart ?? Date.now() },
|
||||
assets: {
|
||||
largeImage: coverUrl,
|
||||
largeText: trunc(manga.title),
|
||||
smallImage: FALLBACK_IMAGE,
|
||||
smallText: 'Moku',
|
||||
},
|
||||
buttons: APP_BUTTONS,
|
||||
}
|
||||
}
|
||||
|
||||
export async function initRpc(): Promise<void> {
|
||||
if (!platformService.isSupported('discord-rpc')) return
|
||||
sessionStart = Date.now()
|
||||
@@ -36,18 +59,9 @@ export async function destroyRpc(): Promise<void> {
|
||||
|
||||
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
||||
if (!platformService.isSupported('discord-rpc')) return
|
||||
await platformService.setDiscordPresence({
|
||||
details: trunc(manga.title),
|
||||
state: `${formatChapter(chapter)} · Reading`,
|
||||
timestamps: { start: sessionStart ?? Date.now() },
|
||||
assets: {
|
||||
largeImage: isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE,
|
||||
largeText: trunc(manga.title),
|
||||
smallImage: FALLBACK_IMAGE,
|
||||
smallText: 'Moku',
|
||||
},
|
||||
buttons: APP_BUTTONS,
|
||||
})
|
||||
|
||||
activeMangaId = manga.id
|
||||
await platformService.setDiscordPresence(buildPresence(manga, chapter, resolveCoverUrl(manga)))
|
||||
}
|
||||
|
||||
export async function setIdle(): Promise<void> {
|
||||
|
||||
@@ -36,6 +36,7 @@ export const appState = $state({
|
||||
toasts: [] as unknown[],
|
||||
appDir: '',
|
||||
idleSplash: false,
|
||||
devSplash: false,
|
||||
})
|
||||
|
||||
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { Category } from "$lib/types";
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
|
||||
export type LibrarySortOption =
|
||||
| "alphabetical"
|
||||
| "unread"
|
||||
| "az"
|
||||
| "unreadCount"
|
||||
| "lastRead"
|
||||
| "dateAdded"
|
||||
| "totalChapters"
|
||||
@@ -153,11 +153,11 @@ class LibraryState {
|
||||
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
|
||||
|
||||
const { mode, dir } = this.tabSort[tab] ?? { mode: "alphabetical" as LibrarySortOption, dir: "asc" as LibrarySortDir };
|
||||
const { mode, dir } = this.tabSort[tab] ?? { mode: "az" as LibrarySortOption, dir: "asc" as LibrarySortDir };
|
||||
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
switch (mode) {
|
||||
case "unread": return (b.unreadCount ?? 0) - (a.unreadCount ?? 0);
|
||||
case "unreadCount": return (b.unreadCount ?? 0) - (a.unreadCount ?? 0);
|
||||
case "lastRead": return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0);
|
||||
case "dateAdded": return (b.addedAt ?? 0) - (a.addedAt ?? 0);
|
||||
case "totalChapters": return (b.chapters?.totalCount ?? 0) - (a.chapters?.totalCount ?? 0);
|
||||
@@ -185,7 +185,7 @@ class LibraryState {
|
||||
|
||||
toggleTabSortDir(tab: string) {
|
||||
const prev = this.tabSort[tab];
|
||||
const mode = prev?.mode ?? "alphabetical";
|
||||
const mode = prev?.mode ?? "az";
|
||||
const dir = prev?.dir === "asc" ? "desc" : "asc";
|
||||
this.setTabSort(tab, mode, dir);
|
||||
}
|
||||
@@ -204,10 +204,18 @@ class LibraryState {
|
||||
this.tabFilters = { ...this.tabFilters, [tab]: {} };
|
||||
}
|
||||
|
||||
syncFromSettings(s: { hiddenLibraryTabs?: string[]; libraryPinnedTabOrder?: string[]; defaultLibraryCategoryId?: number | null }) {
|
||||
if (s.hiddenLibraryTabs) this.hiddenTabs = new Set(s.hiddenLibraryTabs);
|
||||
if (s.libraryPinnedTabOrder) this.pinnedTabOrder = s.libraryPinnedTabOrder;
|
||||
if (s.defaultLibraryCategoryId !== undefined) this.defaultCategoryId = s.defaultLibraryCategoryId ?? null;
|
||||
syncFromSettings(s: {
|
||||
hiddenLibraryTabs?: string[];
|
||||
libraryPinnedTabOrder?: string[];
|
||||
defaultLibraryCategoryId?: number | null;
|
||||
libraryShowAllInSaved?: boolean;
|
||||
libraryHideCompletedInSaved?: boolean;
|
||||
}) {
|
||||
if (s.hiddenLibraryTabs) this.hiddenTabs = new Set(s.hiddenLibraryTabs);
|
||||
if (s.libraryPinnedTabOrder) this.pinnedTabOrder = s.libraryPinnedTabOrder;
|
||||
if (s.defaultLibraryCategoryId !== undefined) this.defaultCategoryId = s.defaultLibraryCategoryId ?? null;
|
||||
if (s.libraryShowAllInSaved !== undefined) this.showAllInSaved = s.libraryShowAllInSaved;
|
||||
if (s.libraryHideCompletedInSaved !== undefined) this.hideCompletedInSaved = s.libraryHideCompletedInSaved;
|
||||
}
|
||||
|
||||
setCategories(cats: Category[]) {
|
||||
|
||||
@@ -80,10 +80,11 @@ class ReaderState {
|
||||
get settings() { return settingsState.settings; }
|
||||
|
||||
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||
const isChapterNav = this.activeChapter !== null;
|
||||
this.activeChapter = chapter;
|
||||
this.activeChapterList = chapterList;
|
||||
if (manga !== undefined) this.activeManga = manga;
|
||||
goto(`/reader/${this.activeManga!.id}/${chapter.id}`);
|
||||
goto(`/reader/${this.activeManga!.id}/${chapter.id}`, { replaceState: isChapterNav });
|
||||
}
|
||||
|
||||
closeReader() {
|
||||
|
||||
+50
-18
@@ -34,13 +34,12 @@
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
let _splashDismissed = $state(false)
|
||||
let bypassed = $state(false)
|
||||
let themeEditorOpen = $state(false)
|
||||
let themeEditorId = $state<string | null>(null)
|
||||
let splashDismissed = $state(false)
|
||||
let themeEditorOpen = $state(false)
|
||||
let themeEditorId = $state<string | null>(null)
|
||||
|
||||
const splashVisible = $derived(
|
||||
!_splashDismissed ||
|
||||
!splashDismissed ||
|
||||
appState.status === 'booting' ||
|
||||
appState.status === 'locked' ||
|
||||
appState.status === 'error' ||
|
||||
@@ -48,17 +47,16 @@
|
||||
)
|
||||
|
||||
const ringFull = $derived(appState.status === 'ready')
|
||||
const showApp = $derived(!splashVisible)
|
||||
|
||||
const showApp = $derived(
|
||||
!splashVisible && (
|
||||
appState.status === 'ready' ||
|
||||
bypassed
|
||||
)
|
||||
)
|
||||
|
||||
function onSplashReady() { _splashDismissed = true }
|
||||
function onSplashUnlock() { appState.status = 'ready'; _splashDismissed = true }
|
||||
function onSplashBypass() { bypassed = true; _splashDismissed = true }
|
||||
function onSplashReady() { splashDismissed = true }
|
||||
function onSplashUnlock() { appState.status = 'ready'; splashDismissed = true }
|
||||
function onSplashBypass() {
|
||||
import('$lib/state/boot.svelte').then(({ bypassBoot }) => {
|
||||
bypassBoot(appState.authMode ?? 'NONE', appState.authUser ?? '', appState.authPass ?? '')
|
||||
})
|
||||
splashDismissed = true
|
||||
}
|
||||
|
||||
const isReaderRoute = $derived($page.url.pathname.startsWith('/reader'))
|
||||
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
|
||||
@@ -141,10 +139,40 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (appState.status === 'booting') _splashDismissed = false
|
||||
if (appState.status === 'booting') splashDismissed = false
|
||||
})
|
||||
|
||||
function onIdleDismiss() { appState.idleSplash = false }
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let idleDismissLock = false
|
||||
|
||||
function onIdleDismiss() {
|
||||
if (idleDismissLock) return
|
||||
idleDismissLock = true
|
||||
appState.idleSplash = false
|
||||
setTimeout(() => { idleDismissLock = false }, 400)
|
||||
}
|
||||
|
||||
function armIdleTimer() {
|
||||
if (idleTimer !== null) clearTimeout(idleTimer)
|
||||
const mins = settingsState.settings.idleTimeoutMin ?? 5
|
||||
if (mins <= 0) return
|
||||
idleTimer = setTimeout(() => {
|
||||
if (appState.status === 'ready' && !appState.idleSplash) appState.idleSplash = true
|
||||
}, mins * 60_000)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (appState.status !== 'ready') return
|
||||
|
||||
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'touchmove', 'wheel', 'click'] as const
|
||||
for (const e of events) document.addEventListener(e, armIdleTimer, { capture: true, passive: true })
|
||||
armIdleTimer()
|
||||
|
||||
return () => {
|
||||
if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null }
|
||||
for (const e of events) document.removeEventListener(e, armIdleTimer, { capture: true })
|
||||
}
|
||||
})
|
||||
|
||||
function onSplashRetry() {
|
||||
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
|
||||
@@ -174,7 +202,11 @@
|
||||
{/if}
|
||||
|
||||
{#if appState.idleSplash}
|
||||
<SplashScreen mode="idle" onDismiss={onIdleDismiss} />
|
||||
<SplashScreen mode="idle" showCards={settingsState.settings.splashCards ?? true} onDismiss={onIdleDismiss} />
|
||||
{/if}
|
||||
|
||||
{#if appState.devSplash}
|
||||
<SplashScreen mode="idle" showDevOverlay onDismiss={() => appState.devSplash = false} />
|
||||
{/if}
|
||||
|
||||
{#if showApp}
|
||||
|
||||
Reference in New Issue
Block a user