Fix: Reworked Bookmark System & Added Double Page (WIP)

This commit is contained in:
Youwes09
2026-03-31 19:46:11 -05:00
parent a69d5eacc5
commit 4decce9a7f
4 changed files with 463 additions and 252 deletions
+90 -41
View File
@@ -17,8 +17,8 @@
});
const icons: Record<Toast["kind"], string> = {
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
success: "M20 6L9 17l-5-5",
error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
};
@@ -27,10 +27,15 @@
{#if store.toasts.length}
<div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)}
<div class="toast toast-{t.kind}" role="alert">
<div
class="toast toast-{t.kind}"
role="alert"
onclick={() => dismissToast(t.id)}
>
<div class="accent-bar"></div>
<span class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={icons[t.kind]} />
</svg>
</span>
@@ -38,12 +43,6 @@
<p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if}
</div>
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
@@ -51,41 +50,91 @@
<style>
.toaster {
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
z-index: 9999; display: flex; flex-direction: column;
gap: var(--sp-2); pointer-events: none; max-width: 320px;
position: fixed;
bottom: var(--sp-5);
right: var(--sp-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
max-width: 300px;
}
.toast {
display: flex; align-items: flex-start; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-base);
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 10px var(--sp-3) 10px 0;
border-radius: var(--radius-md);
background: var(--bg-raised);
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
pointer-events: all; min-width: 220px;
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
border: 1px solid var(--border-dim);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
pointer-events: all;
min-width: 200px;
overflow: hidden;
cursor: pointer;
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 0.15s ease, transform 0.15s ease;
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(24px) scale(0.96); }
to { opacity: 1; transform: translateX(0) scale(1); }
.toast:hover { opacity: 0.85; transform: translateX(-2px); }
.toast:active { transform: translateX(0) scale(0.98); }
@keyframes slideIn {
from { opacity: 0; transform: translateX(16px) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
.toast-success { border-color: var(--accent-dim); }
.toast-success .icon { color: var(--accent-fg); }
.toast-error { border-color: var(--color-error); }
.toast-error .icon { color: var(--color-error); }
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
.accent-bar {
width: 3px;
align-self: stretch;
flex-shrink: 0;
border-radius: 0 2px 2px 0;
margin-right: 2px;
}
.toast-success .accent-bar { background: var(--accent-fg); }
.toast-error .accent-bar { background: var(--color-error); }
.toast-info .accent-bar { background: var(--text-faint); }
.toast-download .accent-bar { background: var(--accent-fg); }
.icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-success .icon { color: var(--accent-fg); }
.toast-error .icon { color: var(--color-error); }
.toast-info .icon { color: var(--text-muted); }
.toast-download .icon { color: var(--accent-fg); }
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.title {
font-size: var(--text-xs);
font-family: var(--font-ui);
color: var(--text-secondary);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
line-height: 1.3;
}
.sub {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.close {
display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: var(--radius-sm);
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
transition: color var(--t-base), background var(--t-base);
}
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
</style>
+367 -202
View File
@@ -1,10 +1,14 @@
<!-- Reader.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 } from "phosphor-svelte";
import {
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus,
Bookmark, BookOpen, MonitorPlay,
} from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addToast } from "../../store/state.svelte";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark } from "../../store/state.svelte";
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
import { setReading } from "../../lib/discord";
import type { FitMode } from "../../store/state.svelte";
@@ -15,8 +19,11 @@
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 1.0;
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
type PageStyle = typeof PAGE_STYLES[number];
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
const cached = pageCache.get(chapterId);
@@ -101,17 +108,32 @@
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 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);
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");
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);
@@ -119,18 +141,19 @@
const lastPage = $derived(store.pageUrls.length);
const effectiveWidth = $derived(containerWidth > 0 ? Math.round(containerWidth * zoom) : undefined);
const zoomPct = $derived(Math.round(zoom * 100));
const showResumeBanner = $derived(resumePage > 1 && !resumeDismissed && (style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage));
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);
$effect(() => {
const chapter = displayChapter;
const manga = store.activeManga;
if (store.settings.discordRpc && chapter && manga) {
setReading(manga, chapter);
}
});
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 showResumeBanner = $derived(
resumeVisible && resumePage > 1 &&
(style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage)
);
const adjacent = $derived.by(() => {
const ref = displayChapter ?? store.activeChapter;
@@ -160,8 +183,41 @@
].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 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;
@@ -172,7 +228,7 @@
abortCtrl?.abort();
const ctrl = new AbortController();
abortCtrl = ctrl;
hasNavigated = false;
hasNavigated = false;
appending = false;
markedRead = new Set();
loading = true;
@@ -182,11 +238,14 @@
stripChapters = [];
visibleChapterId = null;
store.pageUrls = [];
fadingOut = false;
const bookmark = store.bookmarks.find(b => b.chapterId === id);
const resumeTo = bookmark ? bookmark.pageNumber : 0;
resumePage = resumeTo > 1 ? resumeTo : 0;
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;
@@ -195,8 +254,8 @@
if (ctrl.signal.aborted) return;
store.pageUrls = urls;
if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
pageReady = true;
loading = false;
pageReady = true;
loading = false;
} catch (e: any) {
if (ctrl.signal.aborted) return;
error = e instanceof Error ? e.message : String(e);
@@ -206,10 +265,10 @@
$effect(() => {
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
const ch = store.activeChapter;
const urls = store.pageUrls;
const ch = store.activeChapter;
const urls = store.pageUrls;
const targetPg = untrack(() => resumePage);
appending = false;
appending = false;
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
visibleChapterId = ch.id;
tick().then(() => {
@@ -220,10 +279,7 @@
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;
};
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 }); }
};
@@ -244,12 +300,11 @@
const bookmark = store.bookmarks.find(b => b.chapterId === chId);
if (bookmark && bookmark.pageNumber > 1) {
untrack(() => {
resumePage = bookmark.pageNumber;
resumeDismissed = false;
stripResumeReady = true;
resumePage = bookmark.pageNumber; resumeDismissed = false; resumeVisible = true; stripResumeReady = true;
scheduleResumeDismiss();
});
} else {
untrack(() => { resumePage = 0; resumeDismissed = false; stripResumeReady = false; });
untrack(() => { resumePage = 0; resumeDismissed = false; resumeVisible = false; stripResumeReady = false; });
}
});
@@ -262,13 +317,8 @@
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 => { 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 }];
@@ -286,35 +336,27 @@
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 (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);
@@ -328,7 +370,6 @@
containerEl.addEventListener("scroll", onScroll, { passive: true });
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
return () => {
containerEl.removeEventListener("scroll", onScroll);
containerEl.removeEventListener("scroll", onScrollAppend);
@@ -340,10 +381,7 @@
$effect(() => {
void style;
if (!containerEl) return;
untrack(() => {
cleanupScroll();
cleanupScroll = setupScrollTracking();
});
untrack(() => { cleanupScroll(); cleanupScroll = setupScrollTracking(); });
});
$effect(() => {
@@ -357,9 +395,7 @@
.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(() => {});
}
if (idx > 0) fetchPages(store.activeChapterList[idx - 1].id).catch(() => {});
}
}
});
@@ -375,8 +411,8 @@
if (offset) groups.push([2]);
let i = offset ? 3 : 2;
while (i <= snap.length) {
const a = aspects[i - 1], nextA = aspects[i] ?? 0;
if (a > 1.2 || i === snap.length || nextA > 1.2) { groups.push([i++]); }
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;
@@ -402,14 +438,12 @@
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 (store.settings.bookmarksEnabled ?? true) {
if (store.settings.autoBookmark ?? true) {
addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
}
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
@@ -457,30 +491,63 @@
}
}
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 === "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) {
store.pageNumber = store.pageNumber + 1;
} else if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
else closeReader();
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 === "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) {
store.pageNumber = store.pageNumber - 1;
} else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
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;
}
@@ -503,24 +570,14 @@
if (!ch || !manga) return;
if (isBookmarked) {
removeBookmark(ch.id);
addToast({ kind: "info", title: "Bookmark removed", duration: 2000 });
} else {
addBookmark({
mangaId: manga.id,
mangaTitle: manga.title,
thumbnailUrl: manga.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: store.pageNumber,
});
addToast({ kind: "success", title: "Bookmarked", body: `Page ${store.pageNumber}${ch.name}`, duration: 2500 });
addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber: store.pageNumber });
}
}
function cycleStyle() {
const opts = ["single", "longstrip"] as const;
const cur = style === "double" ? "single" : style;
updateSettings({ pageStyle: opts[(opts.indexOf(cur as typeof opts[number]) + 1) % opts.length] });
const idx = PAGE_STYLES.indexOf(style);
updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] });
}
function cycleFit() {
@@ -540,6 +597,10 @@
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") return;
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
@@ -561,14 +622,14 @@
else if (matchesKeybind(e, kb.chapterRight)) {
e.preventDefault();
const list = store.activeChapterList;
const idx = list.findIndex(c => c.id === store.activeChapter?.id);
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 idx = list.findIndex(c => c.id === store.activeChapter?.id);
const prev = idx > 0 ? list[idx - 1] : null;
if (prev) openReader(prev, list);
}
@@ -598,9 +659,7 @@
window.addEventListener("wheel", onWheel, { passive: false });
containerEl?.focus({ preventScroll: true });
const ro = new ResizeObserver(entries => {
containerWidth = entries[0].contentRect.width;
});
const ro = new ResizeObserver(entries => { containerWidth = entries[0].contentRect.width; });
ro.observe(containerEl);
return () => {
@@ -617,83 +676,119 @@
<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}>
<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>
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
<button class="icon-btn" onclick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } }} disabled={!adjacent.next}>
<CaretRight size={14} weight="light" />
</button>
<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 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>
<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}>
{#if style === "single"}<Square size={14} weight="light" />{:else}<Rows size={14} weight="light" />{/if}
<span class="mode-label">{style}</span>
</button>
{#if style !== "single"}
<button class="mode-btn" class:active={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
<span class="mode-label">Gap</span>
<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>
{/if}
{#if style === "longstrip"}
<button class="mode-btn" class:active={autoNext} onclick={() => updateSettings({ autoNextChapter: !autoNext })}>
<span class="mode-label">Auto</span>
<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>
{/if}
{#if !autoNext}
<button class="mode-btn" class:active={markOnNext} onclick={() => updateSettings({ markReadOnNext: !markOnNext })}>
<span class="mode-label">Mk.Read</span>
<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>
{/if}
<button class="mode-btn" onclick={() => dlOpen = true}>
<Download size={14} weight="light" />
</button>
{#if store.settings.bookmarksEnabled ?? true}
<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>
<button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
</button>
{/if}
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
</button>
</div>
</div>
{#if showResumeBanner}
<div class="resume-banner" class:fading={resumeFading} role="status" onclick={() => { resumeVisible = false; resumeFading = false; }}>
<span>Bookmark at page {resumePage}</span>
{#if style === "longstrip" && visibleChapterId && visibleChapterId !== store.activeChapter?.id}
<button class="resume-jump" onclick={() => {
const chId = visibleChapterId!;
const targetPg = resumePage;
const scrollToPage = () => {
const target = containerEl.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
if (!target) { requestAnimationFrame(scrollToPage); return; }
target.scrollIntoView({ block: "start", behavior: "smooth" });
};
scrollToPage();
resumeVisible = false;
}}>Jump</button>
{/if}
</div>
{/if}
<div
bind:this={containerEl}
class="viewer"
@@ -705,25 +800,7 @@
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 showResumeBanner}
<div class="resume-banner" role="status">
<span>Bookmark at page {resumePage}</span>
{#if style === "longstrip" && visibleChapterId && visibleChapterId !== store.activeChapter?.id}
<button class="resume-jump" onclick={() => {
const chId = visibleChapterId!;
const targetPg = resumePage;
const scrollToPage = () => {
const target = containerEl.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
if (!target) { requestAnimationFrame(scrollToPage); return; }
target.scrollIntoView({ block: "start", behavior: "smooth" });
};
scrollToPage();
resumeDismissed = true;
}}>Jump</button>
{/if}
<button class="resume-dismiss" onclick={() => resumeDismissed = true}>✕</button>
</div>
{/if}
{#if loading}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if}
@@ -747,25 +824,86 @@
{/each}
{/each}
<div style="height:1px;flex-shrink:0"></div>
{:else if pageReady}
{#if style === "double" && pageGroups.length}
{:else if style === "fade" && pageReady}
<img
src={store.pageUrls[store.pageNumber - 1]}
alt="Page {store.pageNumber}"
class={imgCls}
decoding="async"
style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;"
/>
{:else if style === "double" && pageReady}
{#if pageGroups.length}
<div class="double-wrap">
{#each currentGroup as pg}
<img src={store.pageUrls[pg - 1]} alt="Page {pg}" class="{imgCls} page-half {pg === currentGroup[0] ? 'gap-left' : 'gap-right'}" decoding="async" />
{#each currentGroup as pg, i}
<img
src={store.pageUrls[pg - 1]}
alt="Page {pg}"
class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}"
decoding="async"
/>
{/each}
</div>
{:else}
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="transition:opacity 0.1s ease" />
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if}
{:else if pageReady}
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{/if}
</div>
<div class="bottombar" class:hidden={!uiVisible}>
<button class="nav-btn" onclick={goBack} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
{#if rtl}<ArrowRight size={13} weight="light" />{:else}<ArrowLeft size={13} weight="light" />{/if}
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
<ArrowLeft size={13} weight="light" />
</button>
<button class="nav-btn" onclick={goForward} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
{#if rtl}<ArrowLeft size={13} weight="light" />{:else}<ArrowRight size={13} weight="light" />{/if}
{#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}
<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>
@@ -807,21 +945,30 @@
.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; 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 { 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); }
.ch-label { flex: 1; 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; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-sep { color: var(--text-faint); }
.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); }
@@ -836,26 +983,48 @@
.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; }
.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: none; width: auto; 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; justify-content: center; gap: var(--sp-4); padding: 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; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); transition: background var(--t-base), color var(--t-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; }
.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; }
.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); } }
.resume-jump { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 2px 8px; cursor: pointer; transition: filter var(--t-fast); }
.resume-jump:hover { filter: brightness(1.15); }
.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); }
@@ -869,10 +1038,6 @@
.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); }
.resume-banner { position: absolute; top: var(--sp-3); left: 50%; translate: -50% 0; 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: scaleIn 0.15s ease both; white-space: nowrap; }
.resume-dismiss { display: flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; font-size: 9px; color: var(--text-faint); transition: color var(--t-fast), background var(--t-fast); }
.resume-dismiss:hover { color: var(--text-primary); background: var(--bg-overlay); }
.resume-jump { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 2px 8px; cursor: pointer; transition: filter var(--t-fast); }
.resume-jump:hover { filter: brightness(1.15); }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
</style>
+4 -6
View File
@@ -9,7 +9,7 @@
import { GET_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER, GET_SOURCES } from "../../lib/queries";
import { GET_DOWNLOADS_PATH, SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
import type { Category, Source } from "../../lib/types";
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories, clearBookmarks } from "../../store/state.svelte";
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte";
import { cache } from "../../lib/cache";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
@@ -1111,11 +1111,9 @@
</label>
{/if}
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Bookmarks</span><span class="toggle-desc">One per manga acts like a physical bookmark</span></div>
<button role="switch" aria-checked={store.settings.bookmarksEnabled ?? true} aria-label="Enable bookmarks" class="toggle" class:on={store.settings.bookmarksEnabled ?? true} onclick={() => {
const next = !(store.settings.bookmarksEnabled ?? true);
updateSettings({ bookmarksEnabled: next });
if (!next) clearBookmarks();
<div class="toggle-info"><span class="toggle-label">Auto-bookmark</span><span class="toggle-desc">Automatically saves your page position as you read</span></div>
<button role="switch" aria-checked={store.settings.autoBookmark ?? true} aria-label="Enable auto-bookmark" class="toggle" class:on={store.settings.autoBookmark ?? true} onclick={() => {
updateSettings({ autoBookmark: !(store.settings.autoBookmark ?? true) });
}}><span class="toggle-thumb"></span></button>
</label>
<div class="step-row">
+2 -3
View File
@@ -213,7 +213,7 @@ export interface Settings {
storageLimitGb: number | null;
markReadOnNext: boolean;
readerDebounceMs: number;
bookmarksEnabled: boolean;
autoBookmark: boolean;
theme: Theme;
libraryBranches: boolean;
renderLimit: number;
@@ -297,7 +297,7 @@ export const DEFAULT_SETTINGS: Settings = {
storageLimitGb: null,
markReadOnNext: true,
readerDebounceMs: 120,
bookmarksEnabled: true,
autoBookmark: true,
theme: "dark",
libraryBranches: true,
renderLimit: 48,
@@ -569,7 +569,6 @@ class Store {
* per chapter is kept adding a second one replaces the first.
*/
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
if (!(this.settings.bookmarksEnabled ?? true)) return;
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
this.bookmarks = [
bookmark,