mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
1286 lines
62 KiB
Svelte
1286 lines
62 KiB
Svelte
<script lang="ts">
|
||
import { onMount, untrack, tick } from "svelte";
|
||
import {
|
||
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
||
Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
|
||
CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus,
|
||
Bookmark, BookOpen, MonitorPlay, MapPin, Check,
|
||
} from "phosphor-svelte";
|
||
import { gql, thumbUrl, plainThumbUrl } from "../../lib/client";
|
||
import { getBlobUrl, preloadBlobUrls } from "../../lib/imageCache";
|
||
import { store as appStore } from "../../store/state.svelte";
|
||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
||
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } from "../../store/state.svelte";
|
||
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
||
import { setReading } from "../../lib/discord";
|
||
import type { FitMode, MarkerColor } from "../../store/state.svelte";
|
||
|
||
const AVG_MIN_PER_PAGE = 0.33;
|
||
const READ_LINE_PCT = 0.20;
|
||
const ZOOM_STEP = 0.05;
|
||
const ZOOM_MIN = 0.1;
|
||
const ZOOM_MAX = 1.0;
|
||
|
||
const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
|
||
type PageStyle = typeof PAGE_STYLES[number];
|
||
|
||
const MARKER_COLORS: MarkerColor[] = ["yellow", "red", "blue", "green", "purple"];
|
||
const MARKER_COLOR_HEX: Record<MarkerColor, string> = {
|
||
yellow: "#c4a94a",
|
||
red: "#c47a7a",
|
||
blue: "#7a9ec4",
|
||
green: "#7aab7a",
|
||
purple: "#a07ac4",
|
||
};
|
||
|
||
const pageCache = new Map<number, string[]>();
|
||
const inflight = new Map<number, Promise<string[]>>();
|
||
|
||
const isAuth = () => (appStore.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH";
|
||
|
||
function resolveUrl(url: string, priority = 0): Promise<string> {
|
||
return isAuth() ? getBlobUrl(url, priority) : Promise.resolve(url);
|
||
}
|
||
|
||
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
|
||
const cached = pageCache.get(chapterId);
|
||
if (cached) return Promise.resolve(cached);
|
||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||
|
||
if (!inflight.has(chapterId)) {
|
||
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||
.then(d => {
|
||
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
|
||
if (isAuth()) preloadBlobUrls(urls, urls.length);
|
||
pageCache.set(chapterId, urls);
|
||
return urls;
|
||
})
|
||
.finally(() => inflight.delete(chapterId));
|
||
inflight.set(chapterId, p);
|
||
}
|
||
|
||
const base = inflight.get(chapterId)!;
|
||
if (!signal) return base;
|
||
return new Promise((resolve, reject) => {
|
||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||
base.then(resolve, reject);
|
||
});
|
||
}
|
||
|
||
const aspectCache = new Map<string, number>();
|
||
|
||
function measureAspect(url: string): Promise<number> {
|
||
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
||
return resolveUrl(url).then(src => new Promise(res => {
|
||
const img = new Image();
|
||
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
|
||
img.onerror = () => res(0.67);
|
||
img.src = src;
|
||
}));
|
||
}
|
||
|
||
function preloadImage(url: string) {
|
||
resolveUrl(url).then(src => { new Image().src = src; }).catch(() => {});
|
||
}
|
||
|
||
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
|
||
|
||
let containerEl: HTMLDivElement;
|
||
let containerWidth = $state(0);
|
||
let zoomAnchorEl: HTMLElement | null = null;
|
||
let zoomAnchorOffset: number = 0;
|
||
|
||
function captureZoomAnchor() {
|
||
if (!containerEl || style !== "longstrip") return;
|
||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||
const containerTop = containerEl.getBoundingClientRect().top;
|
||
for (const img of imgs) {
|
||
const rect = img.getBoundingClientRect();
|
||
if (rect.bottom > containerTop) {
|
||
zoomAnchorEl = img;
|
||
zoomAnchorOffset = rect.top - containerTop;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
function restoreZoomAnchor() {
|
||
if (!zoomAnchorEl || !containerEl) return;
|
||
const el = zoomAnchorEl;
|
||
zoomAnchorEl = null;
|
||
requestAnimationFrame(() => {
|
||
const containerTop = containerEl.getBoundingClientRect().top;
|
||
const newRect = el.getBoundingClientRect();
|
||
containerEl.scrollTop += (newRect.top - containerTop) - zoomAnchorOffset;
|
||
});
|
||
}
|
||
|
||
let loading = $state(true);
|
||
let error: string | null = $state(null);
|
||
let dlOpen = $state(false);
|
||
let zoomOpen = $state(false);
|
||
let uiVisible = $state(true);
|
||
let pageReady = $state(false);
|
||
let pageGroups: number[][] = $state([]);
|
||
let stripChapters: StripChapter[] = $state([]);
|
||
let visibleChapterId: number | null = $state(null);
|
||
let nextN = $state(5);
|
||
let dlBusy = $state(false);
|
||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let markedRead = new Set<number>();
|
||
let appending = false;
|
||
let abortCtrl: AbortController | null = null;
|
||
let hasNavigated = false;
|
||
let resumePage = $state(0);
|
||
let resumeDismissed = $state(false);
|
||
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let resumeFadeTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let resumeFading = $state(false);
|
||
let resumeVisible = $state(false);
|
||
|
||
let markerOpen = $state(false);
|
||
let markerNote = $state("");
|
||
let markerColor: MarkerColor = $state("yellow");
|
||
let markerEditId = $state("");
|
||
|
||
function scheduleResumeDismiss() {
|
||
if (resumeTimer) clearTimeout(resumeTimer);
|
||
if (resumeFadeTimer) clearTimeout(resumeFadeTimer);
|
||
resumeFading = false;
|
||
resumeFadeTimer = setTimeout(() => { resumeFading = true; }, 1500);
|
||
resumeTimer = setTimeout(() => { resumeVisible = false; resumeFading = false; }, 2500);
|
||
}
|
||
let stripResumeReady = $state(false);
|
||
let fadingOut = $state(false);
|
||
let sliderDragging = $state(false);
|
||
let sliderHover = $state(false);
|
||
|
||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
||
const style = $derived((store.settings.pageStyle ?? "single") as PageStyle);
|
||
const zoom = $derived(store.settings.readerZoom ?? 1.0);
|
||
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
||
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
||
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
||
const lastPage = $derived(store.pageUrls.length);
|
||
const effectiveWidth = $derived(containerWidth > 0 ? Math.round(containerWidth * zoom) : undefined);
|
||
const zoomPct = $derived(Math.round(zoom * 100));
|
||
|
||
const displayChapter = $derived(
|
||
style === "longstrip" && visibleChapterId
|
||
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
||
: store.activeChapter
|
||
);
|
||
const currentBookmark = $derived(displayChapter ? store.bookmarks.find(b => b.chapterId === displayChapter!.id) : undefined);
|
||
const isBookmarked = $derived(!!currentBookmark && currentBookmark.pageNumber === store.pageNumber);
|
||
|
||
const currentPageMarkers = $derived(
|
||
displayChapter ? store.getMarkersForPage(displayChapter.id, store.pageNumber) : []
|
||
);
|
||
|
||
const activeChapterMarkers = $derived(
|
||
displayChapter ? store.getMarkersForChapter(displayChapter.id) : []
|
||
);
|
||
|
||
const hasMarkerOnPage = $derived(currentPageMarkers.length > 0);
|
||
|
||
const showResumeBanner = $derived(
|
||
resumeVisible && resumePage > 1 &&
|
||
(style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage)
|
||
);
|
||
|
||
const adjacent = $derived.by(() => {
|
||
const ref = displayChapter ?? store.activeChapter;
|
||
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
||
const idx = store.activeChapterList.findIndex(c => c.id === ref.id);
|
||
return {
|
||
prev: idx > 0 ? store.activeChapterList[idx - 1] : null,
|
||
next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null,
|
||
remaining: store.activeChapterList.slice(idx + 1),
|
||
};
|
||
});
|
||
|
||
const visibleChunkLastPage = $derived.by(() => {
|
||
if (style !== "longstrip") return lastPage;
|
||
const chId = visibleChapterId ?? store.activeChapter?.id;
|
||
const chunk = stripChapters.find(c => c.chapterId === chId);
|
||
return chunk?.urls.length ?? lastPage;
|
||
});
|
||
|
||
const imgCls = $derived([
|
||
"img",
|
||
fit === "width" && "fit-width",
|
||
fit === "height" && "fit-height",
|
||
fit === "screen" && "fit-screen",
|
||
fit === "original" && "fit-original",
|
||
store.settings.optimizeContrast && "optimize-contrast",
|
||
].filter(Boolean).join(" "));
|
||
|
||
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
||
|
||
const stripToRender = $derived(
|
||
style === "longstrip"
|
||
? (stripChapters.length > 0
|
||
? stripChapters
|
||
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
|
||
: []
|
||
);
|
||
|
||
const currentGroup = $derived(
|
||
style === "double" && pageGroups.length
|
||
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
|
||
: [store.pageNumber]
|
||
);
|
||
|
||
const sliderPage = $derived.by(() => {
|
||
if (style === "double" && pageGroups.length) {
|
||
return pageGroups.findIndex(g => g.includes(store.pageNumber)) + 1;
|
||
}
|
||
return store.pageNumber;
|
||
});
|
||
|
||
const sliderMax = $derived.by(() => {
|
||
if (style === "double" && pageGroups.length) return pageGroups.length;
|
||
if (style === "longstrip") return visibleChunkLastPage || 1;
|
||
return lastPage || 1;
|
||
});
|
||
|
||
const sliderPct = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
|
||
|
||
$effect(() => {
|
||
const chapter = displayChapter;
|
||
const manga = store.activeManga;
|
||
if (store.settings.discordRpc && chapter && manga) setReading(manga, chapter);
|
||
});
|
||
|
||
$effect(() => {
|
||
const ch = store.activeChapter;
|
||
if (ch) untrack(() => loadChapter(ch.id));
|
||
});
|
||
|
||
async function loadChapter(id: number) {
|
||
abortCtrl?.abort();
|
||
const ctrl = new AbortController();
|
||
abortCtrl = ctrl;
|
||
hasNavigated = false;
|
||
appending = false;
|
||
markedRead = new Set();
|
||
loading = true;
|
||
error = null;
|
||
pageGroups = [];
|
||
pageReady = false;
|
||
stripChapters = [];
|
||
visibleChapterId = null;
|
||
store.pageUrls = [];
|
||
fadingOut = false;
|
||
markerOpen = false;
|
||
|
||
const bookmark = store.bookmarks.find(b => b.chapterId === id);
|
||
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||
resumePage = resumeTo > 1 ? resumeTo : 0;
|
||
resumeDismissed = false;
|
||
resumeVisible = resumeTo > 1;
|
||
if (resumeTo > 1) scheduleResumeDismiss();
|
||
stripResumeReady = false;
|
||
|
||
store.pageNumber = 1;
|
||
try {
|
||
const urls = await fetchPages(id, ctrl.signal);
|
||
if (ctrl.signal.aborted) return;
|
||
store.pageUrls = urls;
|
||
if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||
pageReady = true;
|
||
loading = false;
|
||
} catch (e: any) {
|
||
if (ctrl.signal.aborted) return;
|
||
error = e instanceof Error ? e.message : String(e);
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
||
const ch = store.activeChapter;
|
||
const urls = store.pageUrls;
|
||
const targetPg = untrack(() => resumePage);
|
||
appending = false;
|
||
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
||
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" }); stripResumeReady = true; };
|
||
if (target.complete && target.naturalHeight > 0) { doScroll(); }
|
||
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
|
||
};
|
||
scrollToResumePage();
|
||
return;
|
||
}
|
||
containerEl.scrollTop = 0;
|
||
});
|
||
}
|
||
});
|
||
|
||
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
||
|
||
$effect(() => {
|
||
const chId = visibleChapterId;
|
||
if (!chId || style !== "longstrip") return;
|
||
if (chId === store.activeChapter?.id) return;
|
||
const wasAppended = untrack(() => stripChapters.findIndex(c => c.chapterId === chId)) > 0;
|
||
if (wasAppended) {
|
||
untrack(() => {
|
||
resumePage = 0; resumeVisible = false;
|
||
const prefs = getMangaPrefs();
|
||
if (prefs.downloadAhead > 0) {
|
||
const list = store.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.isDownloaded && !c.isRead)
|
||
.map(c => c.id);
|
||
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
|
||
}
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
const bookmark = store.bookmarks.find(b => b.chapterId === chId);
|
||
if (bookmark && bookmark.pageNumber > 1) {
|
||
untrack(() => {
|
||
resumePage = bookmark.pageNumber; resumeDismissed = false; resumeVisible = true; stripResumeReady = true;
|
||
scheduleResumeDismiss();
|
||
});
|
||
} else {
|
||
untrack(() => { resumePage = 0; resumeDismissed = false; resumeVisible = false; stripResumeReady = false; });
|
||
}
|
||
});
|
||
|
||
function appendNextChapter() {
|
||
if (appending || !stripChapters.length) return;
|
||
const lastChunk = stripChapters[stripChapters.length - 1];
|
||
const list = store.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 || stripChapters.some(c => c.chapterId === next.id)) return;
|
||
appending = true;
|
||
fetchPages(next.id)
|
||
.then(urls => { urls.forEach(url => measureAspect(url).catch(() => {})); urls.slice(0, 6).forEach(preloadImage); return urls; })
|
||
.then(urls => {
|
||
if (stripChapters.some(c => c.chapterId === next.id)) { appending = false; return; }
|
||
stripChapters = [...stripChapters, { chapterId: next.id, chapterName: next.name, urls }];
|
||
appending = false;
|
||
})
|
||
.catch(() => { appending = false; });
|
||
}
|
||
|
||
let stripChaptersRef: StripChapter[] = [];
|
||
$effect(() => { stripChaptersRef = stripChapters; });
|
||
|
||
function setupScrollTracking(): () => void {
|
||
if (!containerEl || style !== "longstrip") return () => {};
|
||
|
||
function onScroll() {
|
||
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;
|
||
let activePage: number | null = null;
|
||
let activeChId: number | null = null;
|
||
for (const img of imgs) {
|
||
if (img.getBoundingClientRect().top <= readLineY) {
|
||
activePage = Number(img.dataset.localPage);
|
||
activeChId = Number(img.dataset.chapter);
|
||
} else break;
|
||
}
|
||
if (activePage === null) {
|
||
activePage = Number(imgs[0].dataset.localPage);
|
||
activeChId = Number(imgs[0].dataset.chapter);
|
||
}
|
||
if (activePage !== null) store.pageNumber = activePage;
|
||
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
||
if (store.settings.autoMarkRead && activePage !== null && activeChId) {
|
||
const chunk = stripChaptersRef.find(c => c.chapterId === activeChId);
|
||
const total = chunk ? chunk.urls.length : store.pageUrls.length;
|
||
if (total > 0 && activePage >= total) markChapterRead(activeChId);
|
||
}
|
||
if (containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40) {
|
||
const last = stripChaptersRef[stripChaptersRef.length - 1];
|
||
if (last && store.settings.autoMarkRead) markChapterRead(last.chapterId);
|
||
}
|
||
}
|
||
|
||
function onScrollAppend() {
|
||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||
if (pct >= 0.80) appendNextChapter();
|
||
}
|
||
|
||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
|
||
return () => {
|
||
containerEl.removeEventListener("scroll", onScroll);
|
||
containerEl.removeEventListener("scroll", onScrollAppend);
|
||
};
|
||
}
|
||
|
||
let cleanupScroll: () => void = () => {};
|
||
|
||
$effect(() => {
|
||
void style;
|
||
if (!containerEl) return;
|
||
untrack(() => { cleanupScroll(); cleanupScroll = setupScrollTracking(); });
|
||
});
|
||
|
||
$effect(() => {
|
||
if (store.activeChapter && store.activeChapterList.length) {
|
||
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
|
||
if (idx >= 0) {
|
||
for (let i = 1; i <= 3; i++) {
|
||
const entry = store.activeChapterList[idx + i];
|
||
if (!entry) break;
|
||
fetchPages(entry.id)
|
||
.then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); })
|
||
.catch(() => {});
|
||
}
|
||
if (idx > 0) fetchPages(store.activeChapterList[idx - 1].id).catch(() => {});
|
||
}
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
if (style === "double" && store.pageUrls.length) {
|
||
let cancelled = false;
|
||
const snap = store.pageUrls;
|
||
Promise.all(snap.map(measureAspect)).then(aspects => {
|
||
if (cancelled || snap !== store.pageUrls) return;
|
||
const offset = store.settings.offsetDoubleSpreads;
|
||
const groups: number[][] = [[1]];
|
||
if (offset) groups.push([2]);
|
||
let i = offset ? 3 : 2;
|
||
while (i <= snap.length) {
|
||
const a = aspects[i - 1];
|
||
if (a > 1.2 || i === snap.length) { groups.push([i++]); }
|
||
else { groups.push(rtl ? [i + 1, i] : [i, i + 1]); i += 2; }
|
||
}
|
||
pageGroups = groups;
|
||
});
|
||
return () => { cancelled = true; };
|
||
} else { pageGroups = []; }
|
||
});
|
||
|
||
$effect(() => {
|
||
const ahead = store.settings.preloadPages ?? 3;
|
||
const current = store.pageUrls[store.pageNumber - 1];
|
||
if (!current) return;
|
||
if (isAuth()) {
|
||
getBlobUrl(current, 999);
|
||
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
|
||
const behind = store.pageUrls[store.pageNumber - 2];
|
||
preloadBlobUrls(upcoming, ahead);
|
||
if (behind) preloadBlobUrls([behind], 0);
|
||
} else {
|
||
for (let i = 1; i <= ahead; i++) {
|
||
const url = store.pageUrls[store.pageNumber - 1 + i];
|
||
if (url) preloadImage(url);
|
||
}
|
||
const behind = store.pageUrls[store.pageNumber - 2];
|
||
if (behind) preloadImage(behind);
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
const ch = displayChapter ?? store.activeChapter;
|
||
const autoBookmark = store.settings.autoBookmark ?? true;
|
||
if (ch && lastPage && store.activeManga) {
|
||
const chapterId = ch.id;
|
||
const chapterName = ch.name;
|
||
const mangaId = store.activeManga.id;
|
||
const mangaTitle = store.activeManga.title;
|
||
const thumb = store.activeManga.thumbnailUrl;
|
||
const pageNum = store.pageNumber;
|
||
const atLast = store.pageNumber === lastPage;
|
||
if (pageNum > 1) hasNavigated = true;
|
||
untrack(() => {
|
||
if (!hasNavigated) return;
|
||
if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return;
|
||
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, readAt: Date.now() });
|
||
if (autoBookmark) {
|
||
addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
||
}
|
||
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
|
||
});
|
||
}
|
||
});
|
||
|
||
function getMangaPrefs() {
|
||
const mangaId = store.activeManga?.id;
|
||
if (!mangaId) return DEFAULT_MANGA_PREFS;
|
||
return { ...DEFAULT_MANGA_PREFS, ...(appStore.settings.mangaPrefs?.[mangaId] ?? {}) };
|
||
}
|
||
|
||
function markChapterRead(id: number) {
|
||
if (markedRead.has(id)) return;
|
||
markedRead.add(id);
|
||
const chapter = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
|
||
const pages = chapter?.pageCount ?? store.pageUrls.length ?? 15;
|
||
const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE));
|
||
if (store.activeManga && chapter) {
|
||
addHistory(
|
||
{ mangaId: store.activeManga.id, mangaTitle: store.activeManga.title, thumbnailUrl: store.activeManga.thumbnailUrl, chapterId: id, chapterName: chapter.name, readAt: Date.now() },
|
||
true, minutes,
|
||
);
|
||
}
|
||
gql(MARK_CHAPTER_READ, { id, isRead: true })
|
||
.then(() => {
|
||
const mangaId = store.activeManga?.id;
|
||
if (mangaId) {
|
||
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
||
checkAndMarkCompleted(mangaId, updated);
|
||
|
||
const prefs = getMangaPrefs();
|
||
|
||
if (prefs.deleteOnRead) {
|
||
const ch = store.activeChapterList.find(c => c.id === id);
|
||
if (ch?.isDownloaded) {
|
||
const delayMs = (prefs.deleteDelayHours ?? 0) * 60 * 60 * 1000;
|
||
const doDelete = () => gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [id] }).catch(console.error);
|
||
if (delayMs === 0) doDelete();
|
||
else setTimeout(doDelete, delayMs);
|
||
}
|
||
}
|
||
|
||
if (prefs.downloadAhead > 0) {
|
||
const list = store.activeChapterList;
|
||
const idx = list.findIndex(c => c.id === id);
|
||
if (idx >= 0) {
|
||
const toQueue = list
|
||
.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||
.filter(c => !c.isDownloaded && !c.isRead)
|
||
.map(c => c.id);
|
||
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
|
||
}
|
||
}
|
||
|
||
if (prefs.maxKeepChapters > 0) {
|
||
const downloaded = store.activeChapterList
|
||
.filter(c => c.isDownloaded)
|
||
.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||
const excess = downloaded.slice(0, Math.max(0, downloaded.length - prefs.maxKeepChapters));
|
||
if (excess.length) gql(DELETE_DOWNLOADED_CHAPTERS, { ids: excess.map(c => c.id) }).catch(console.error);
|
||
}
|
||
}
|
||
})
|
||
.catch(e => { markedRead.delete(id); console.error(e); });
|
||
}
|
||
|
||
function maybeMarkCurrentRead() {
|
||
const ch = displayChapter ?? store.activeChapter;
|
||
if (ch && markOnNext) markChapterRead(ch.id);
|
||
}
|
||
|
||
function advanceGroup(forward: boolean) {
|
||
if (!pageGroups.length) return;
|
||
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
|
||
if (forward) {
|
||
if (gi < pageGroups.length - 1) store.pageNumber = pageGroups[gi + 1][0];
|
||
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
||
else closeReader();
|
||
} else {
|
||
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0];
|
||
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
||
}
|
||
}
|
||
|
||
async function animateFade(fn: () => void) {
|
||
fadingOut = true;
|
||
await new Promise(r => setTimeout(r, 100));
|
||
fn();
|
||
fadingOut = false;
|
||
}
|
||
|
||
function goForward() {
|
||
if (loading) return;
|
||
if (style === "longstrip") {
|
||
if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); }
|
||
return;
|
||
}
|
||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||
if (!store.pageUrls.length) return;
|
||
if (store.pageNumber < lastPage) {
|
||
if (style === "fade") { animateFade(() => { store.pageNumber++; }); }
|
||
else { store.pageNumber++; }
|
||
} else if (adjacent.next) {
|
||
maybeMarkCurrentRead();
|
||
store.pageNumber = 1;
|
||
openReader(adjacent.next, store.activeChapterList);
|
||
} else { closeReader(); }
|
||
}
|
||
|
||
function goBack() {
|
||
if (loading) return;
|
||
if (style === "longstrip") {
|
||
if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
||
return;
|
||
}
|
||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||
if (!store.pageUrls.length) return;
|
||
if (store.pageNumber > 1) {
|
||
if (style === "fade") { animateFade(() => { store.pageNumber--; }); }
|
||
else { store.pageNumber--; }
|
||
} else if (adjacent.prev) { openReader(adjacent.prev, store.activeChapterList); }
|
||
}
|
||
|
||
const goNext = $derived(rtl ? goBack : goForward);
|
||
const goPrev = $derived(rtl ? goForward : goBack);
|
||
|
||
function jumpToPage(page: number) {
|
||
if (style === "longstrip") {
|
||
const chId = visibleChapterId ?? store.activeChapter?.id;
|
||
const target = containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`);
|
||
target?.scrollIntoView({ block: "start" });
|
||
return;
|
||
}
|
||
if (style === "double" && pageGroups.length) {
|
||
const group = pageGroups[page - 1];
|
||
if (group) store.pageNumber = group[0];
|
||
} else {
|
||
store.pageNumber = Math.max(1, Math.min(lastPage, page));
|
||
}
|
||
}
|
||
|
||
function clampZoom(z: number): number {
|
||
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
|
||
}
|
||
|
||
function adjustZoom(delta: number) {
|
||
captureZoomAnchor();
|
||
updateSettings({ readerZoom: clampZoom(zoom + delta) });
|
||
restoreZoomAnchor();
|
||
}
|
||
|
||
function resetZoom() {
|
||
captureZoomAnchor();
|
||
updateSettings({ readerZoom: 1.0 });
|
||
restoreZoomAnchor();
|
||
}
|
||
|
||
function toggleBookmark() {
|
||
const ch = displayChapter;
|
||
const manga = store.activeManga;
|
||
if (!ch || !manga) return;
|
||
if (isBookmarked) {
|
||
removeBookmark(ch.id);
|
||
resumeVisible = false;
|
||
} else {
|
||
addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber: store.pageNumber });
|
||
}
|
||
}
|
||
|
||
function openMarkerPopover() {
|
||
const ch = displayChapter;
|
||
const manga = store.activeManga;
|
||
if (!ch || !manga) return;
|
||
if (currentPageMarkers.length > 0) {
|
||
const first = currentPageMarkers[0];
|
||
markerEditId = first.id;
|
||
markerNote = first.note;
|
||
markerColor = first.color;
|
||
} else {
|
||
markerEditId = "";
|
||
markerNote = "";
|
||
markerColor = "yellow";
|
||
}
|
||
markerOpen = !markerOpen;
|
||
zoomOpen = false;
|
||
dlOpen = false;
|
||
}
|
||
|
||
function commitMarker() {
|
||
const ch = displayChapter;
|
||
const manga = store.activeManga;
|
||
if (!ch || !manga) return;
|
||
if (markerEditId) {
|
||
updateMarker(markerEditId, { note: markerNote.trim(), color: markerColor });
|
||
} else {
|
||
addMarker({
|
||
mangaId: manga.id,
|
||
mangaTitle: manga.title,
|
||
thumbnailUrl: manga.thumbnailUrl,
|
||
chapterId: ch.id,
|
||
chapterName: ch.name,
|
||
pageNumber: store.pageNumber,
|
||
note: markerNote.trim(),
|
||
color: markerColor,
|
||
});
|
||
}
|
||
markerOpen = false;
|
||
markerNote = "";
|
||
markerEditId = "";
|
||
}
|
||
|
||
function deleteCurrentMarker() {
|
||
if (markerEditId) removeMarker(markerEditId);
|
||
markerOpen = false;
|
||
markerNote = "";
|
||
markerEditId = "";
|
||
}
|
||
|
||
function cycleStyle() {
|
||
const idx = PAGE_STYLES.indexOf(style);
|
||
updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] });
|
||
}
|
||
|
||
function cycleFit() {
|
||
const opts: FitMode[] = ["width", "height", "screen", "original"];
|
||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
||
}
|
||
|
||
function showUi() {
|
||
uiVisible = true;
|
||
if (hideTimer) clearTimeout(hideTimer);
|
||
hideTimer = setTimeout(() => { if (!markerOpen) uiVisible = false; }, 3000);
|
||
}
|
||
|
||
$effect(() => {
|
||
if (markerOpen) {
|
||
uiVisible = true;
|
||
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||
}
|
||
});
|
||
|
||
function onWheel(e: WheelEvent) {
|
||
if (!e.ctrlKey) return;
|
||
e.preventDefault();
|
||
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
|
||
}
|
||
|
||
function onSliderInput(e: Event) {
|
||
jumpToPage(Number((e.currentTarget as HTMLInputElement).value));
|
||
}
|
||
|
||
function onKey(e: KeyboardEvent) {
|
||
if ((e.target as HTMLElement).tagName === "INPUT" || (e.target as HTMLElement).tagName === "TEXTAREA") return;
|
||
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
|
||
const r = store.settings.readingDirection === "rtl";
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
if (markerOpen) { markerOpen = false; return; }
|
||
if (zoomOpen) { zoomOpen = false; return; }
|
||
if (dlOpen) { dlOpen = false; return; }
|
||
closeReader(); return;
|
||
}
|
||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); adjustZoom(ZOOM_STEP * 2); return; }
|
||
if (e.ctrlKey && e.key === "-") { e.preventDefault(); adjustZoom(-ZOOM_STEP * 2); return; }
|
||
if (e.ctrlKey && e.key === "0") { e.preventDefault(); resetZoom(); return; }
|
||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); store.pageNumber = 1; }
|
||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); store.pageNumber = lastPage; }
|
||
else if (matchesKeybind(e, kb.chapterRight)) {
|
||
e.preventDefault();
|
||
const list = store.activeChapterList;
|
||
const idx = list.findIndex(c => c.id === store.activeChapter?.id);
|
||
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
||
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
||
}
|
||
else if (matchesKeybind(e, kb.chapterLeft)) {
|
||
e.preventDefault();
|
||
const list = store.activeChapterList;
|
||
const idx = list.findIndex(c => c.id === store.activeChapter?.id);
|
||
const prev = idx > 0 ? list[idx - 1] : null;
|
||
if (prev) openReader(prev, list);
|
||
}
|
||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
||
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); toggleBookmark(); }
|
||
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); openMarkerPopover(); }
|
||
}
|
||
|
||
function handleTap(e: MouseEvent) {
|
||
if (style === "longstrip") return;
|
||
const x = e.clientX / window.innerWidth;
|
||
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
|
||
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
|
||
}
|
||
|
||
async function runDl(fn: () => Promise<unknown>) {
|
||
dlBusy = true;
|
||
try { await fn(); } catch (e: any) { console.error(e); }
|
||
dlBusy = false; dlOpen = false;
|
||
}
|
||
|
||
onMount(() => {
|
||
showUi();
|
||
window.addEventListener("keydown", onKey);
|
||
window.addEventListener("wheel", onWheel, { passive: false });
|
||
containerEl?.focus({ preventScroll: true });
|
||
|
||
const ro = new ResizeObserver(entries => { containerWidth = entries[0].contentRect.width; });
|
||
ro.observe(containerEl);
|
||
|
||
return () => {
|
||
abortCtrl?.abort();
|
||
if (hideTimer) clearTimeout(hideTimer);
|
||
window.removeEventListener("keydown", onKey);
|
||
window.removeEventListener("wheel", onWheel);
|
||
cleanupScroll();
|
||
ro.disconnect();
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<div class="root" class:overlay-bars={overlayBars} role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
|
||
|
||
<div class="topbar" class:hidden={!uiVisible}>
|
||
|
||
<div class="topbar-left">
|
||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||
<button class="icon-btn" onclick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, store.activeChapterList); } }} disabled={!adjacent.prev}>
|
||
<CaretLeft size={14} weight="light" />
|
||
</button>
|
||
<span class="ch-label">
|
||
<span class="ch-title">{store.activeManga?.title}</span>
|
||
<span class="ch-sep">/</span>
|
||
<span>{displayChapter?.name}</span>
|
||
</span>
|
||
<button class="icon-btn" onclick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } }} disabled={!adjacent.next}>
|
||
<CaretRight size={14} weight="light" />
|
||
</button>
|
||
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||
</div>
|
||
|
||
<div class="topbar-right">
|
||
<div class="top-sep"></div>
|
||
|
||
<button class="mode-btn" onclick={cycleFit}>
|
||
{#if fit === "width"}<ArrowsLeftRight size={14} weight="light" />
|
||
{:else if fit === "height"}<ArrowsVertical size={14} weight="light" />
|
||
{:else if fit === "screen"}<ArrowsIn size={14} weight="light" />
|
||
{:else}<ArrowsOut size={14} weight="light" />{/if}
|
||
<span class="mode-label">{fitLabel}</span>
|
||
</button>
|
||
|
||
<div class="zoom-wrap">
|
||
<div class="zoom-inline">
|
||
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
||
<MagnifyingGlassMinus size={13} weight="light" />
|
||
</button>
|
||
<button class="zoom-pct-btn" onclick={() => zoomOpen = !zoomOpen} title="Click to adjust zoom">
|
||
{zoomPct}%
|
||
</button>
|
||
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
|
||
<MagnifyingGlassPlus size={13} weight="light" />
|
||
</button>
|
||
</div>
|
||
{#if zoomOpen}
|
||
<div class="zoom-popover">
|
||
<div class="zoom-slider-row">
|
||
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
|
||
oninput={(e) => { captureZoomAnchor(); updateSettings({ readerZoom: clampZoom(Number(e.currentTarget.value) / 100) }); restoreZoomAnchor(); }} />
|
||
</div>
|
||
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
|
||
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
|
||
</button>
|
||
|
||
<button class="mode-btn" onclick={cycleStyle} title="Cycle view mode">
|
||
{#if style === "single"}<Square size={14} weight="light" />
|
||
{:else if style === "fade"}<MonitorPlay size={14} weight="light" />
|
||
{:else if style === "double"}<BookOpen size={14} weight="light" />
|
||
{:else}<Rows size={14} weight="light" />{/if}
|
||
<span class="mode-label">{style}</span>
|
||
</button>
|
||
|
||
<div class="mode-extras">
|
||
{#if style === "double"}
|
||
<button class="mode-btn" class:active={store.settings.offsetDoubleSpreads} onclick={() => updateSettings({ offsetDoubleSpreads: !store.settings.offsetDoubleSpreads })}>
|
||
<span class="mode-label">Offset</span>
|
||
</button>
|
||
{/if}
|
||
{#if style === "longstrip"}
|
||
<button class="mode-btn" class:active={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
||
<span class="mode-label">Gap</span>
|
||
</button>
|
||
<button class="mode-btn" class:active={autoNext} onclick={() => updateSettings({ autoNextChapter: !autoNext })}>
|
||
<span class="mode-label">Auto</span>
|
||
</button>
|
||
{/if}
|
||
{#if !autoNext}
|
||
<button class="mode-btn" class:active={markOnNext} onclick={() => updateSettings({ markReadOnNext: !markOnNext })}>
|
||
<span class="mode-label">Mk.Read</span>
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
|
||
<button class="mode-btn" onclick={() => dlOpen = true}>
|
||
<Download size={14} weight="light" />
|
||
</button>
|
||
|
||
<div class="marker-wrap">
|
||
<button
|
||
class="icon-btn"
|
||
class:active={hasMarkerOnPage}
|
||
class:marker-btn-has={hasMarkerOnPage}
|
||
onclick={openMarkerPopover}
|
||
title={hasMarkerOnPage ? "Edit marker" : "Add marker"}
|
||
style={hasMarkerOnPage ? `--marker-color:${MARKER_COLOR_HEX[currentPageMarkers[0].color]}` : ""}
|
||
>
|
||
<MapPin size={14} weight={hasMarkerOnPage ? "fill" : "regular"} />
|
||
</button>
|
||
|
||
{#if markerOpen}
|
||
<div class="marker-popover" role="presentation" onclick={(e) => e.stopPropagation()}
|
||
onmouseenter={() => { uiVisible = true; if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }}
|
||
>
|
||
<div class="marker-pop-header">
|
||
<span class="marker-pop-title">
|
||
{markerEditId ? "Edit marker" : "New marker"} · p.{store.pageNumber}
|
||
</span>
|
||
{#if markerEditId}
|
||
<button class="marker-delete-btn" onclick={deleteCurrentMarker} title="Delete marker">
|
||
<X size={12} weight="light" />
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
<div class="marker-color-row">
|
||
{#each MARKER_COLORS as c}
|
||
<button
|
||
class="marker-swatch"
|
||
class:marker-swatch-active={markerColor === c}
|
||
style="--swatch:{MARKER_COLOR_HEX[c]}"
|
||
onclick={() => markerColor = c}
|
||
title={c}
|
||
>
|
||
<span class="swatch-dot"></span>
|
||
<span class="swatch-label">{c}</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
<textarea
|
||
class="marker-textarea"
|
||
style="--accent-marker:{MARKER_COLOR_HEX[markerColor]}"
|
||
rows={3}
|
||
placeholder="Note (optional)…"
|
||
bind:value={markerNote}
|
||
onmouseenter={() => { uiVisible = true; if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }}
|
||
onkeydown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitMarker(); }
|
||
if (e.key === "Escape") { markerOpen = false; }
|
||
}}
|
||
></textarea>
|
||
<div class="marker-pop-actions">
|
||
<button class="marker-save-btn" style="--accent-marker:{MARKER_COLOR_HEX[markerColor]}" onclick={commitMarker}>
|
||
<Check size={12} weight="bold" />
|
||
{markerEditId ? "Update" : "Save"}
|
||
</button>
|
||
<button class="marker-cancel-btn" onclick={() => markerOpen = false}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
|
||
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{#if showResumeBanner}
|
||
<button class="resume-banner" class:fading={resumeFading} onclick={() => { resumeVisible = false; resumeFading = false; }}>
|
||
<span>Bookmark at page {resumePage}</span>
|
||
</button>
|
||
{/if}
|
||
|
||
<div
|
||
bind:this={containerEl}
|
||
class="viewer"
|
||
class:strip={style === "longstrip"}
|
||
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
|
||
role="presentation"
|
||
tabindex="-1"
|
||
onclick={handleTap}
|
||
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
||
>
|
||
|
||
{#if loading}
|
||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||
{/if}
|
||
{#if error}
|
||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||
{/if}
|
||
|
||
{#if style === "longstrip"}
|
||
{#each stripToRender as chunk}
|
||
{#each chunk.urls as url, i}
|
||
{#await resolveUrl(url, chunk.urls.length - i)}
|
||
<img src="" alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
||
{:then src}
|
||
<img {src} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
||
{/await}
|
||
{/each}
|
||
{/each}
|
||
<div style="height:1px;flex-shrink:0"></div>
|
||
|
||
{:else if style === "fade" && pageReady}
|
||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
||
{:then src}
|
||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
||
{/await}
|
||
|
||
{:else if style === "double" && pageReady}
|
||
{#if pageGroups.length}
|
||
<div class="double-wrap">
|
||
{#each currentGroup as pg, i}
|
||
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
||
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||
{:then src}
|
||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||
{/await}
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||
{/if}
|
||
|
||
{:else if pageReady}
|
||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||
{:then src}
|
||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||
{/await}
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="bottombar" class:hidden={!uiVisible}>
|
||
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
|
||
<ArrowLeft size={13} weight="light" />
|
||
</button>
|
||
|
||
{#if sliderMax > 1}
|
||
<div
|
||
class="slider-wrap"
|
||
class:dragging={sliderDragging}
|
||
onmouseenter={() => sliderHover = true}
|
||
onmouseleave={() => { sliderHover = false; }}
|
||
role="presentation"
|
||
>
|
||
<div class="slider-track-bg">
|
||
<div class="slider-fill" style="width: {rtl ? 100 - sliderPct : sliderPct}%"></div>
|
||
</div>
|
||
|
||
{#if isBookmarked && currentBookmark}
|
||
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||
<div
|
||
class="slider-checkpoint bookmark-checkpoint"
|
||
style="left: {rtl ? 100 - bPct : bPct}%"
|
||
title="Bookmark: Page {currentBookmark.pageNumber}"
|
||
></div>
|
||
{/if}
|
||
|
||
{#each activeChapterMarkers as m (m.id)}
|
||
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||
<div
|
||
class="slider-checkpoint marker-checkpoint"
|
||
style="left: {rtl ? 100 - mPct : mPct}%; background:{MARKER_COLOR_HEX[m.color]}"
|
||
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"
|
||
></div>
|
||
{/each}
|
||
|
||
<input
|
||
type="range"
|
||
class="slider-input"
|
||
min={1}
|
||
max={sliderMax}
|
||
step={1}
|
||
value={sliderPage}
|
||
dir={rtl ? "rtl" : "ltr"}
|
||
oninput={onSliderInput}
|
||
onmousedown={() => sliderDragging = true}
|
||
onmouseup={() => sliderDragging = false}
|
||
ontouchstart={() => sliderDragging = true}
|
||
ontouchend={() => sliderDragging = false}
|
||
aria-label="Page {sliderPage} of {sliderMax}"
|
||
/>
|
||
{#if sliderHover || sliderDragging}
|
||
<div class="slider-tooltip" style="left: {rtl ? 100 - sliderPct : sliderPct}%">
|
||
{sliderPage} / {sliderMax}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
|
||
<ArrowRight size={13} weight="light" />
|
||
</button>
|
||
</div>
|
||
|
||
{#if dlOpen && store.activeChapter}
|
||
{@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)}
|
||
<div class="dl-backdrop" role="presentation" onclick={() => dlOpen = false}>
|
||
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||
<p class="dl-title">Download</p>
|
||
<button class="dl-option" disabled={dlBusy || !!store.activeChapter.isDownloaded}
|
||
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: store.activeChapter!.id }))}>
|
||
This chapter
|
||
<span class="dl-sub">{store.activeChapter.isDownloaded ? "Already downloaded" : store.activeChapter.name}</span>
|
||
</button>
|
||
<div class="dl-row">
|
||
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.slice(0, nextN).map(c => c.id) }))}>
|
||
Next chapters
|
||
<span class="dl-sub">{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
||
</button>
|
||
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||
<button class="dl-step-btn" onclick={() => nextN = Math.max(1, nextN - 1)} disabled={nextN <= 1}>−</button>
|
||
<span class="dl-step-val">{nextN}</span>
|
||
<button class="dl-step-btn" onclick={() => nextN = Math.min(queueable.length || 1, nextN + 1)} disabled={nextN >= queueable.length}>+</button>
|
||
</div>
|
||
</div>
|
||
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map(c => c.id) }))}>
|
||
All remaining
|
||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
|
||
.overlay-bars { position: fixed; }
|
||
.overlay-bars .topbar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
||
.overlay-bars .bottombar { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
|
||
.overlay-bars .viewer { height: 100%; }
|
||
|
||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-1); padding: 0 var(--sp-3); height: 40px; background: var(--bg-void); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; position: relative; z-index: 2; transition: opacity 0.25s ease; }
|
||
.topbar.hidden, .bottombar.hidden { opacity: 0; pointer-events: none; }
|
||
|
||
.topbar-left { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; flex: 1; overflow: hidden; }
|
||
.topbar-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||
.mode-extras { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
|
||
|
||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
||
.icon-btn.active { color: var(--accent-fg); }
|
||
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
||
|
||
.ch-label { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||
.page-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||
.top-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
|
||
|
||
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: color var(--t-base), background var(--t-base); }
|
||
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||
.mode-label { text-transform: capitalize; }
|
||
|
||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
|
||
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||
.zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||
.zoom-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
|
||
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
||
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
||
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
||
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
||
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
||
|
||
.marker-wrap { position: relative; flex-shrink: 0; }
|
||
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
|
||
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||
.marker-swatch:hover .swatch-dot { transform: scale(1.15); }
|
||
.marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
|
||
.swatch-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); color: var(--text-faint); text-transform: capitalize; line-height: 1; }
|
||
.marker-swatch-active .swatch-label { color: var(--text-muted); }
|
||
|
||
.marker-textarea { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 7px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base), box-shadow var(--t-base); }
|
||
.marker-textarea:focus { border-color: var(--accent-marker, var(--border-focus)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-marker, var(--accent)) 18%, transparent); }
|
||
.marker-pop-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||
.marker-save-btn { display: flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: var(--radius-sm); border: 1px solid color-mix(in srgb, var(--accent-marker, var(--accent)) 50%, transparent); background: color-mix(in srgb, var(--accent-marker, var(--accent)) 15%, transparent); color: var(--accent-marker, var(--accent-fg)); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
|
||
.marker-save-btn:hover { filter: brightness(1.2); }
|
||
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
|
||
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||
|
||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||
.viewer:focus { outline: none; }
|
||
|
||
.img { display: block; user-select: none; image-rendering: auto; }
|
||
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
|
||
.fit-width { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||
.fit-screen { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
||
.fit-original { max-width: 100%; width: auto; height: auto; }
|
||
.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); }
|
||
|
||
.bottombar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
|
||
.nav-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); transition: background var(--t-base), color var(--t-base); }
|
||
.nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
||
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||
|
||
.slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; cursor: pointer; }
|
||
.slider-track-bg { position: absolute; left: 0; right: 0; height: 3px; background: var(--border-strong); border-radius: 2px; overflow: hidden; pointer-events: none; }
|
||
.slider-fill { height: 100%; background: var(--accent-fg); border-radius: 2px; transition: width 0.05s linear; }
|
||
.slider-input { position: absolute; left: 0; right: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; margin: 0; z-index: 2; }
|
||
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; }
|
||
.bookmark-checkpoint { background: var(--accent-fg); opacity: 0.7; }
|
||
.marker-checkpoint { opacity: 0.85; }
|
||
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
|
||
|
||
.resume-banner { position: fixed; top: 48px; left: 50%; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: 6px var(--sp-3); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); z-index: 20; box-shadow: 0 4px 16px rgba(0,0,0,0.4); animation: bannerIn 0.2s cubic-bezier(0.16,1,0.3,1) both; white-space: nowrap; cursor: pointer; text-align: left; }
|
||
.resume-banner.fading { animation: bannerOut 1s ease forwards; }
|
||
@keyframes bannerIn { from { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(0.97); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } }
|
||
@keyframes bannerOut { from { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } to { opacity: 0; transform: translateX(-50%) translateY(-4px) scale(0.97); } }
|
||
|
||
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
|
||
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
|
||
.dl-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); }
|
||
.dl-option { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||
.dl-option:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||
.dl-option:disabled { opacity: 0.3; cursor: default; }
|
||
.dl-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
||
.dl-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||
.dl-stepper { display: flex; align-items: center; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; }
|
||
.dl-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 28px; font-size: var(--text-base); color: var(--text-muted); background: none; border: none; cursor: pointer; line-height: 1; transition: color var(--t-fast), background var(--t-fast); }
|
||
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
|
||
|
||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||
</style>
|