Feat: Per-Manga Reader Settings + Settings Access (#42 & #46)

This commit is contained in:
Youwes09
2026-04-24 21:09:05 -05:00
parent 581aea5694
commit 2e9939c4a9
7 changed files with 1474 additions and 265 deletions
+2 -6
View File
@@ -33,11 +33,7 @@ In-Progress:
- Fix Tracking Login - Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives - Pasting OAuth URL is not User-Friendly, Look for Alternatives
- MacOS Fixes
- Revamp Server-State Check (MacOS does not pick up) (TESTING)
- Check Moku Sidebar Icon (Looks ugly on MacOS)
- Icon appears as a Square
- Icon appears to have Green Underglow?
Testing Bugs: Notes from last time:
- Currently working on #42, just need to mount panel and fix button in reader
+139 -25
View File
@@ -5,7 +5,8 @@
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads"; import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
import { store, updateSettings, openReader, closeReader, addHistory, import { store, updateSettings, openReader, closeReader, addHistory,
addBookmark, removeBookmark, addMarker, updateMarker, removeMarker, addBookmark, removeBookmark, addMarker, updateMarker, removeMarker,
setSettingsOpen } from "@store/state.svelte"; setSettingsOpen, setMangaReaderSettings, clearMangaReaderSettings,
saveReaderPreset, updateReaderPreset, deleteReaderPreset } from "@store/state.svelte";
import { setReading } from "@store/discord"; import { setReading } from "@store/discord";
import { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds"; import { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds";
import { readerState, PAGE_STYLES } from "../store/readerState.svelte"; import { readerState, PAGE_STYLES } from "../store/readerState.svelte";
@@ -21,17 +22,27 @@
import PageView from "./PageView.svelte"; import PageView from "./PageView.svelte";
import ReaderProgressBar from "./ReaderProgressBar.svelte"; import ReaderProgressBar from "./ReaderProgressBar.svelte";
import ReaderOverlay from "./ReaderOverlay.svelte"; import ReaderOverlay from "./ReaderOverlay.svelte";
import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
const win = getCurrentWindow(); const win = getCurrentWindow();
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH"); const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
const rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode); const effectiveReaderSettings = $derived.by(() => {
const style = $derived((store.settings.pageStyle ?? "single") as typeof PAGE_STYLES[number]); const mangaId = store.activeManga?.id;
const zoom = $derived(store.settings.readerZoom ?? 1.0); const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
return override ? { ...store.settings, ...override } : store.settings;
});
const rtl = $derived(effectiveReaderSettings.readingDirection === "rtl");
const fit = $derived((effectiveReaderSettings.fitMode ?? "width") as FitMode);
const style = $derived((effectiveReaderSettings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
const zoom = $derived(effectiveReaderSettings.readerZoom ?? 1.0);
const autoNext = $derived(store.settings.autoNextChapter ?? false); const autoNext = $derived(store.settings.autoNextChapter ?? false);
const markOnNext = $derived(store.settings.markReadOnNext ?? true); const markOnNext = $derived(store.settings.markReadOnNext ?? true);
const overlayBars = $derived(store.settings.overlayBars ?? false); const overlayBars = $derived(store.settings.overlayBars ?? false);
const tapToToggleBar = $derived(store.settings.tapToToggleBar ?? false); const tapToToggleBar = $derived(store.settings.tapToToggleBar ?? false);
const barPosition = $derived((store.settings.barPosition ?? "top") as "top" | "left" | "right");
const isVerticalBar = $derived(barPosition === "left" || barPosition === "right");
const lastPage = $derived(store.pageUrls.length); const lastPage = $derived(store.pageUrls.length);
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined); const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
const zoomPct = $derived(Math.round(zoom * 100)); const zoomPct = $derived(Math.round(zoom * 100));
@@ -84,7 +95,7 @@
fit === "height" && "fit-height", fit === "height" && "fit-height",
fit === "screen" && "fit-screen", fit === "screen" && "fit-screen",
fit === "original" && "fit-original", fit === "original" && "fit-original",
store.settings.optimizeContrast && "optimize-contrast", effectiveReaderSettings.optimizeContrast && "optimize-contrast",
].filter(Boolean).join(" ")); ].filter(Boolean).join(" "));
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]); const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
@@ -119,6 +130,11 @@
const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0); const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw); const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw);
const perMangaEnabled = $derived(
store.activeManga?.id != null &&
!!(store.settings.mangaReaderSettings ?? {})[store.activeManga.id]
);
let containerEl: HTMLDivElement | null = null; let containerEl: HTMLDivElement | null = null;
let pageViewRef: PageView; let pageViewRef: PageView;
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 }; let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
@@ -184,7 +200,7 @@
e.preventDefault(); e.preventDefault();
captureZoomAnchor(containerEl, style, zoomAnchor); captureZoomAnchor(containerEl, style, zoomAnchor);
const ZOOM_STEP = 0.05; const ZOOM_STEP = 0.05;
updateSettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) }); applySettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) });
restoreZoomAnchor(containerEl, zoomAnchor); restoreZoomAnchor(containerEl, zoomAnchor);
} }
@@ -202,10 +218,10 @@
closeReader, closeReader,
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl), goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
lastPage: () => lastPage, lastPage: () => lastPage,
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); }, adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); }, resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); }, cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
toggleDirection: () => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }), toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => setSettingsOpen(true), openSettings: () => setSettingsOpen(true),
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber), toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
toggleMarker: () => { toggleMarker: () => {
@@ -230,6 +246,54 @@
function bindContainer(el: HTMLDivElement) { containerEl = el; } function bindContainer(el: HTMLDivElement) { containerEl = el; }
function captureCurrentReaderSettings() {
return {
pageStyle: style,
fitMode: fit,
readingDirection: (store.settings.readingDirection ?? "ltr") as import("@store/state.svelte").ReadingDirection,
readerZoom: zoom,
pageGap: effectiveReaderSettings.pageGap ?? true,
optimizeContrast: effectiveReaderSettings.optimizeContrast ?? false,
offsetDoubleSpreads: effectiveReaderSettings.offsetDoubleSpreads ?? false,
} satisfies import("@store/state.svelte").ReaderSettings;
}
function applySettings(patch: Parameters<typeof updateSettings>[0]) {
const mangaId = store.activeManga?.id;
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
setMangaReaderSettings(mangaId, { ...(store.settings.mangaReaderSettings ?? {})[mangaId]!, ...patch });
} else {
updateSettings(patch);
}
}
function handleTogglePerManga() {
const mangaId = store.activeManga?.id;
if (mangaId == null) return;
if ((store.settings.mangaReaderSettings ?? {})[mangaId]) {
clearMangaReaderSettings(mangaId);
} else {
setMangaReaderSettings(mangaId, captureCurrentReaderSettings());
}
}
function handleSavePreset(name: string) {
saveReaderPreset(name, captureCurrentReaderSettings());
}
function handleApplyPreset(settings: import("@store/state.svelte").ReaderSettings) {
const mangaId = store.activeManga?.id;
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
setMangaReaderSettings(mangaId, settings);
} else {
updateSettings(settings);
}
}
function handleBarPositionChange(pos: "top" | "left" | "right") {
updateSettings({ barPosition: pos });
}
$effect(() => { $effect(() => {
const chapter = displayChapter; const chapter = displayChapter;
const manga = store.activeManga; const manga = store.activeManga;
@@ -346,7 +410,7 @@
const snap = store.pageUrls; const snap = store.pageUrls;
Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => { Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => {
if (cancelled || snap !== store.pageUrls) return; if (cancelled || snap !== store.pageUrls) return;
readerState.pageGroups = buildPageGroups(snap, aspects, store.settings.offsetDoubleSpreads ?? false); readerState.pageGroups = buildPageGroups(snap, aspects, effectiveReaderSettings.offsetDoubleSpreads ?? false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
} else { readerState.pageGroups = []; } } else { readerState.pageGroups = []; }
@@ -446,17 +510,26 @@
<div <div
class="root" class="root"
class:overlay-bars={overlayBars} class:overlay-bars={overlayBars}
class:bar-left={barPosition === "left"}
class:bar-right={barPosition === "right"}
role="presentation" role="presentation"
onmousemove={(e) => { if (!tapToToggleBar && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi(); }} onmousemove={(e) => {
if (!tapToToggleBar) {
if (barPosition === "top" && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi();
if (barPosition === "left" && e.clientX < 60) showUi();
if (barPosition === "right" && window.innerWidth - e.clientX < 60) showUi();
}
}}
> >
<ReaderControls <ReaderControls
{displayChapter} {adjacent} {visibleChunkLastPage} {displayChapter} {adjacent} {visibleChunkLastPage}
{fit} {fitLabel} {style} {rtl} {zoom} {zoomPct} {zoom} {zoomPct}
isFullscreen={readerState.isFullscreen} isFullscreen={readerState.isFullscreen}
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers} {isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
{autoNext} {markOnNext}
uiVisible={readerState.uiVisible} uiVisible={readerState.uiVisible}
{hideTimer} {hideTimer}
{barPosition}
progressBar={isVerticalBar ? progressBarSnippet : undefined}
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)} onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)} onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
onMaybeMarkRead={maybeMarkCurrentRead} onMaybeMarkRead={maybeMarkCurrentRead}
@@ -464,11 +537,31 @@
onCommitMarker={commitMarker} onCommitMarker={commitMarker}
onDeleteMarker={deleteCurrentMarker} onDeleteMarker={deleteCurrentMarker}
onClampZoom={clampZoom} onClampZoom={clampZoom}
onApplySettings={applySettings}
onDlOpen={() => readerState.dlOpen = true} onDlOpen={() => readerState.dlOpen = true}
onSettingsOpen={() => setSettingsOpen(true)} onSettingsOpen={() => setSettingsOpen(true)}
{perMangaEnabled}
{win} {win}
/> />
{#if readerState.presetOpen}
<ReaderPresetPanel
{fit} {style} {rtl} {zoom} {zoomPct}
{perMangaEnabled}
{barPosition}
onBarPositionChange={handleBarPositionChange}
onTogglePerManga={handleTogglePerManga}
onApplySettings={applySettings}
onSavePreset={handleSavePreset}
onApplyPreset={handleApplyPreset}
onUpdatePreset={updateReaderPreset}
onDeletePreset={deleteReaderPreset}
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
onClampZoom={clampZoom}
/>
{/if}
<ReaderOverlay <ReaderOverlay
{showResumeBanner} {showResumeBanner}
resumePage={readerState.resumePage} resumePage={readerState.resumePage}
@@ -494,21 +587,42 @@
{bindContainer} {bindContainer}
/> />
<ReaderProgressBar {#snippet progressBarSnippet()}
{style} <ReaderProgressBar
loading={readerState.loading} {style}
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage} loading={readerState.loading}
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent} {rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
uiVisible={readerState.uiVisible} {displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
onGoPrev={goPrev} uiVisible={readerState.uiVisible}
onGoNext={goNext} {barPosition}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)} onGoPrev={goPrev}
/> onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
/>
{/snippet}
{#if !isVerticalBar}
<ReaderProgressBar
{style}
loading={readerState.loading}
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
uiVisible={readerState.uiVisible}
{barPosition}
onGoPrev={goPrev}
onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
/>
{/if}
</div> </div>
<style> <style>
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; } .root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
.root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; } .root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
.root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; } .root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
.root.overlay-bars :global(.viewer) { height: 100%; } .root.overlay-bars :global(.viewer) { height: 100%; }
.root.bar-left :global(.viewer) { margin-left: 40px; }
.root.bar-right :global(.viewer) { margin-right: 40px; }
</style> </style>
@@ -1,81 +1,77 @@
<script lang="ts"> <script lang="ts">
import { import {
X, CaretLeft, CaretRight, X, CaretLeft, CaretRight, CaretUp, CaretDown,
Square, Rows, BookOpen, MonitorPlay,
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
MagnifyingGlassMinus, MagnifyingGlassPlus, MagnifyingGlassMinus, MagnifyingGlassPlus,
Bookmark, MapPin, Download, Check, GearSix, Bookmark, MapPin, Download, Check, GearSix, Sliders,
} from "phosphor-svelte"; } from "phosphor-svelte";
import { store, updateSettings } from "@store/state.svelte"; import { store, updateSettings } from "@store/state.svelte";
import { openReader, closeReader } from "@store/state.svelte"; import { openReader, closeReader } from "@store/state.svelte";
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX, PAGE_STYLES } from "../store/readerState.svelte"; import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { cubicOut, cubicIn } from "svelte/easing"; import { cubicOut, cubicIn } from "svelte/easing";
import type { FitMode } from "@store/state.svelte"; import type { Chapter } from "@types";
import type { Chapter } from "@types";
import type { Snippet } from "svelte";
interface Props { interface Props {
displayChapter: Chapter | null; displayChapter: Chapter | null;
adjacent: { prev: Chapter | null; next: Chapter | null }; adjacent: { prev: Chapter | null; next: Chapter | null };
visibleChunkLastPage: number; visibleChunkLastPage: number;
fit: FitMode; zoom: number;
fitLabel: string; zoomPct: number;
style: string; isFullscreen: boolean;
rtl: boolean; isBookmarked: boolean;
zoom: number; hasMarkerOnPage: boolean;
zoomPct: number; currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[];
isFullscreen: boolean; uiVisible: boolean;
isBookmarked: boolean; hideTimer: ReturnType<typeof setTimeout> | null;
hasMarkerOnPage: boolean; barPosition: "top" | "left" | "right";
currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[]; progressBar?: Snippet;
autoNext: boolean;
markOnNext: boolean;
uiVisible: boolean;
hideTimer: ReturnType<typeof setTimeout> | null;
onCaptureZoomAnchor: () => void; onCaptureZoomAnchor: () => void;
onRestoreZoomAnchor: () => void; onRestoreZoomAnchor: () => void;
onMaybeMarkRead: () => void; onMaybeMarkRead: () => void;
onToggleBookmark: () => void; onToggleBookmark: () => void;
onCommitMarker: () => void; onCommitMarker: () => void;
onDeleteMarker: () => void; onDeleteMarker: () => void;
onClampZoom: (z: number) => number; onClampZoom: (z: number) => number;
onDlOpen: () => void; onApplySettings: (patch: Parameters<typeof updateSettings>[0]) => void;
onSettingsOpen: () => void; onDlOpen: () => void;
win: import("@tauri-apps/api/window").Window; onSettingsOpen: () => void;
hasMangaOverride: boolean;
win: import("@tauri-apps/api/window").Window;
} }
const { const {
displayChapter, adjacent, visibleChunkLastPage, displayChapter, adjacent, visibleChunkLastPage,
fit, fitLabel, style, rtl, zoom, zoomPct, zoom, zoomPct, isFullscreen,
isFullscreen, isBookmarked, hasMarkerOnPage, currentPageMarkers, isBookmarked, hasMarkerOnPage, currentPageMarkers,
autoNext, markOnNext, uiVisible, hideTimer, uiVisible, hideTimer,
barPosition, progressBar,
onCaptureZoomAnchor, onRestoreZoomAnchor, onCaptureZoomAnchor, onRestoreZoomAnchor,
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker, onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
onClampZoom, onDlOpen, onSettingsOpen, win, onClampZoom, onApplySettings, onDlOpen, onSettingsOpen,
hasMangaOverride, win,
}: Props = $props(); }: Props = $props();
const isVertical = $derived(barPosition === "left" || barPosition === "right");
const popoverSide = $derived(
barPosition === "left" ? "right" :
barPosition === "right" ? "left" :
"bottom"
);
function adjustZoom(delta: number) { function adjustZoom(delta: number) {
onCaptureZoomAnchor(); onCaptureZoomAnchor();
updateSettings({ readerZoom: onClampZoom(zoom + delta) }); onApplySettings({ readerZoom: onClampZoom(zoom + delta) });
onRestoreZoomAnchor(); onRestoreZoomAnchor();
} }
function resetZoom() { function resetZoom() {
onCaptureZoomAnchor(); onCaptureZoomAnchor();
updateSettings({ readerZoom: 1.0 }); onApplySettings({ readerZoom: 1.0 });
onRestoreZoomAnchor(); onRestoreZoomAnchor();
} }
function cycleStyle() {
const idx = PAGE_STYLES.indexOf(style as typeof PAGE_STYLES[number]);
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 keepUiAlive() { function keepUiAlive() {
readerState.uiVisible = true; readerState.uiVisible = true;
if (hideTimer) clearTimeout(hideTimer); if (hideTimer) clearTimeout(hideTimer);
@@ -102,105 +98,115 @@
readerState.openMarker("", "", "yellow"); readerState.openMarker("", "", "yellow");
} }
} }
let chapterHover = $state(false);
let chapterHoverTimer: ReturnType<typeof setTimeout> | null = null;
function showChapterPopover() {
if (chapterHoverTimer) clearTimeout(chapterHoverTimer);
chapterHover = true;
}
function hideChapterPopover() {
chapterHoverTimer = setTimeout(() => { chapterHover = false; }, 120);
}
</script> </script>
<div class="topbar" class:hidden={!uiVisible}> <div
class="bar"
<div class="topbar-left"> class:bar-top={barPosition === "top"}
class:bar-left={barPosition === "left"}
class:bar-right={barPosition === "right"}
class:hidden={!uiVisible}
>
<div class="bar-start">
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button> <button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
<button class="icon-btn" <button class="icon-btn"
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); openReader(adjacent.prev, store.activeChapterList); } }} onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); openReader(adjacent.prev, store.activeChapterList); } }}
disabled={!adjacent.prev}> disabled={!adjacent.prev}>
<CaretLeft size={14} weight="light" /> {#if isVertical}
<CaretUp size={14} weight="light" />
{:else}
<CaretLeft size={14} weight="light" />
{/if}
</button> </button>
<span class="ch-label">
<span class="ch-title">{store.activeManga?.title}</span> <div
<span class="ch-sep">/</span> class="ch-hover-wrap"
<span>{displayChapter?.name}</span> onmouseenter={showChapterPopover}
</span> onmouseleave={hideChapterPopover}
role="presentation"
>
<button class="ch-pill" title="{store.activeManga?.title} / {displayChapter?.name}">
{#if isVertical}
<span class="ch-info">&#xE2CE;</span>
{:else}
<span class="ch-title">{store.activeManga?.title}</span>
<span class="ch-sep">/</span>
<span class="ch-name">{displayChapter?.name}</span>
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
{/if}
</button>
{#if chapterHover && isVertical}
<div class="ch-popover ch-popover-{popoverSide}">
<span class="ch-pop-title">{store.activeManga?.title}</span>
<span class="ch-pop-sep">/</span>
<span class="ch-pop-name">{displayChapter?.name}</span>
<span class="ch-pop-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
</div>
{/if}
</div>
<button class="icon-btn" <button class="icon-btn"
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }} onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }}
disabled={!adjacent.next}> disabled={!adjacent.next}>
<CaretRight size={14} weight="light" /> {#if isVertical}
<CaretDown size={14} weight="light" />
{:else}
<CaretRight size={14} weight="light" />
{/if}
</button> </button>
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
{#if !isVertical}
<span class="bar-sep"></span>
{/if}
</div> </div>
<div class="topbar-right"> {#if isVertical && progressBar}
<div class="top-sep"></div> <div class="bar-middle">
{@render progressBar()}
<button class="mode-btn" onclick={cycleFit}> </div>
{#if fit === "width"}<ArrowsLeftRight size={14} weight="light" /> {/if}
{: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="bar-end">
<div class="zoom-wrap"> <div class="zoom-wrap">
<div class="zoom-inline"> <div class="zoom-inline">
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}> <button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
<MagnifyingGlassMinus size={13} weight="light" /> <MagnifyingGlassMinus size={13} weight="light" />
</button> </button>
<div class="zoom-divider"></div>
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom"> <button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
{zoomPct}% {zoomPct}%
</button> </button>
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}> <div class="zoom-divider"></div>
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
<MagnifyingGlassPlus size={13} weight="light" /> <MagnifyingGlassPlus size={13} weight="light" />
</button> </button>
</div> </div>
{#if readerState.zoomOpen} {#if readerState.zoomOpen}
<div class="zoom-popover"> <div class="popover zoom-popover popover-{popoverSide}">
<div class="zoom-slider-row"> <div class="zoom-slider-row">
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct} <input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
oninput={(e) => { onCaptureZoomAnchor(); updateSettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} /> oninput={(e) => { onCaptureZoomAnchor(); onApplySettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
</div> </div>
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button> <button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
</div> </div>
{/if} {/if}
</div> </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={onDlOpen}>
<Download size={14} weight="light" />
</button>
<div class="marker-wrap"> <div class="marker-wrap">
<button <button
class="icon-btn" class="icon-btn"
@@ -214,7 +220,7 @@
</button> </button>
{#if readerState.markerOpen} {#if readerState.markerOpen}
<div class="marker-popover" role="presentation" <div class="popover marker-popover popover-{popoverSide}" role="presentation"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onmouseenter={keepUiAlive} onmouseenter={keepUiAlive}
> >
@@ -270,6 +276,20 @@
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} /> <Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
</button> </button>
<button class="icon-btn" onclick={onDlOpen}>
<Download size={14} weight="light" />
</button>
<button class="icon-btn" class:active={hasMangaOverride}
onclick={() => { readerState.presetOpen = true; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
title="Reader settings">
<Sliders size={13} weight="regular" />
</button>
<button class="icon-btn" onclick={onSettingsOpen} title="Settings">
<GearSix size={13} weight="regular" />
</button>
<div class="wc-wrap"> <div class="wc-wrap">
<button <button
class="icon-btn" class="icon-btn"
@@ -284,54 +304,98 @@
</svg> </svg>
</button> </button>
{#if readerState.winOpen} {#if readerState.winOpen}
<div class="wc-clip" onmouseenter={wcResetTimer} onmousemove={wcResetTimer}> <div
class="wc-clip wc-clip-{popoverSide}"
onmouseenter={wcResetTimer}
onmousemove={wcResetTimer}
>
<div <div
class="wc-bar" class="wc-bar"
role="presentation" role="presentation"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
in:fly={{ y: '-100%', duration: 200, easing: cubicOut }} in:fly={isVertical
out:fly={{ y: '-100%', duration: 150, easing: cubicIn }} ? (barPosition === "left" ? { x: '-100%', duration: 200, easing: cubicOut } : { x: '100%', duration: 200, easing: cubicOut })
: { y: '-100%', duration: 200, easing: cubicOut }}
out:fly={isVertical
? (barPosition === "left" ? { x: '-100%', duration: 150, easing: cubicIn } : { x: '100%', duration: 150, easing: cubicIn })
: { y: '-100%', duration: 150, easing: cubicIn }}
> >
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; onSettingsOpen(); }} title="Settings"> <button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }} title="Minimize">
<GearSix size={13} weight="regular" /> <svg width="10" height="2" viewBox="0 0 10 2"><line x1="0" y1="1" x2="10" y2="1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button> </button>
<div class="wc-bar-sep"></div> <button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }} title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}>
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }} title="Minimize"> {#if isFullscreen}
<svg width="10" height="2" viewBox="0 0 10 2"><line x1="0" y1="1" x2="10" y2="1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> <svg width="11" height="11" viewBox="0 0 11 11">
</button> <polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }} title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}> <polyline points="7,1 10,1 10,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
{#if isFullscreen} <polyline points="10,7 10,10 7,10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="11" height="11" viewBox="0 0 11 11"> <polyline points="4,10 1,10 1,7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
<polyline points="7,1 10,1 10,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> {:else}
<polyline points="10,7 10,10 7,10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.75" y="0.75" width="8.5" height="8.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<polyline points="4,10 1,10 1,7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> {/if}
</button>
<button class="wc-icon-btn wc-icon-close" onclick={() => { readerState.winOpen = false; win.close(); }} title="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg> </svg>
{:else} </button>
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.75" y="0.75" width="8.5" height="8.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg> </div>
{/if}
</button>
<button class="wc-icon-btn wc-icon-close" onclick={() => { readerState.winOpen = false; win.close(); }} title="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.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; overflow: visible; } .bar {
.topbar.hidden { opacity: 0; pointer-events: none; } display: flex;
align-items: center;
gap: var(--sp-1);
background: var(--bg-void);
flex-shrink: 0;
position: relative;
z-index: 2;
transition: opacity 0.25s ease;
overflow: visible;
}
.bar.hidden { opacity: 0; pointer-events: none; }
.topbar-left { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; flex: 1; overflow: hidden; } .bar-top {
.topbar-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; } flex-direction: row;
.mode-extras { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; } justify-content: space-between;
padding: 0 var(--sp-3);
height: 40px;
border-bottom: 1px solid var(--border-dim);
}
.bar-left, .bar-right {
flex-direction: column;
justify-content: space-between;
padding: var(--sp-3) 0;
width: 40px;
position: fixed;
top: 0;
bottom: 0;
z-index: 2;
border-bottom: none;
}
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
.bar-start, .bar-end {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.bar-top .bar-start { flex: 1; overflow: hidden; }
.bar-left .bar-start,
.bar-left .bar-end,
.bar-right .bar-start,
.bar-right .bar-end {
flex-direction: column;
}
.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 { 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:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
@@ -339,25 +403,104 @@
.icon-btn.active { color: var(--accent-fg); } .icon-btn.active { color: var(--accent-fg); }
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; } .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-hover-wrap { position: relative; min-width: 0; }
.ch-pill {
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;
padding: 2px 4px;
border-radius: var(--radius-sm);
background: none;
cursor: default;
transition: background var(--t-fast);
}
.bar-left .ch-pill, .bar-right .ch-pill {
width: 28px;
height: 28px;
justify-content: center;
padding: 0;
}
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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; } .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; } .ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); } .ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.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); } .ch-popover {
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); } position: absolute;
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); } background: var(--bg-raised);
.mode-label { text-transform: capitalize; } border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-2) var(--sp-3);
display: flex;
align-items: center;
gap: var(--sp-2);
white-space: nowrap;
z-index: 100;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
font-size: var(--text-sm);
pointer-events: none;
animation: scaleIn 0.1s ease both;
}
.ch-popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
.ch-popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
.ch-pop-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-pop-sep { color: var(--text-faint); }
.ch-pop-name { color: var(--text-muted); }
.ch-pop-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.zoom-wrap { position: relative; flex-shrink: 0; } .bar-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
.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-wrap { position: relative; flex-shrink: 0; }
.zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } .zoom-inline { display: flex; align-items: center; }
.zoom-step-btn:disabled { opacity: 0.25; cursor: default; } .bar-left .zoom-inline, .bar-right .zoom-inline { flex-direction: column; }
.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-icon-btn { width: 28px; height: 28px; }
.zoom-divider {
background: var(--border-dim);
flex-shrink: 0;
}
.bar-top .zoom-divider { width: 1px; height: 16px; }
.bar-left .zoom-divider,
.bar-right .zoom-divider { height: 1px; width: 16px; }
.zoom-pct-btn {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
color: var(--text-secondary);
height: 28px;
min-width: 38px;
text-align: center;
transition: color var(--t-base), background var(--t-base);
padding: 0 var(--sp-1);
border-radius: 0;
}
.bar-left .zoom-pct-btn,
.bar-right .zoom-pct-btn { height: 24px; min-width: unset; width: 28px; writing-mode: vertical-rl; font-size: 9px; padding: var(--sp-1) 0; }
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); } .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; }
.popover {
position: absolute;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 100;
animation: scaleIn 0.1s ease both;
}
.popover-bottom { top: calc(100% + 6px); left: 50%; translate: -50% 0; transform-origin: top center; }
.popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
.popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
.zoom-popover { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); min-width: 180px; }
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); } .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 { 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-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
@@ -366,7 +509,7 @@
.zoom-reset:disabled { opacity: 0.3; cursor: default; } .zoom-reset:disabled { opacity: 0.3; cursor: default; }
.marker-wrap { position: relative; flex-shrink: 0; } .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-popover { width: 240px; padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); }
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; } .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; } .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; }
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); } .marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
@@ -390,11 +533,25 @@
.wc-wrap { position: static; flex-shrink: 0; } .wc-wrap { position: static; flex-shrink: 0; }
.wc-clip { .wc-clip {
position: absolute; position: absolute;
z-index: 100;
}
.wc-clip-bottom {
top: 100%; top: 100%;
right: var(--sp-3); right: var(--sp-3);
z-index: 100;
clip-path: inset(0 -20px -20px -20px); clip-path: inset(0 -20px -20px -20px);
} }
.wc-clip-right {
left: calc(100% + 1px);
top: auto;
bottom: var(--sp-3);
clip-path: inset(-20px -20px -20px 0);
}
.wc-clip-left {
right: calc(100% + 1px);
top: auto;
bottom: var(--sp-3);
clip-path: inset(-20px 0 -20px -20px);
}
.wc-bar { .wc-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -402,10 +559,12 @@
padding: 3px 10px 4px; padding: 3px 10px 4px;
background: var(--bg-raised); background: var(--bg-raised);
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
border-top: none;
box-shadow: 0 6px 16px rgba(0,0,0,0.45); box-shadow: 0 6px 16px rgba(0,0,0,0.45);
border-radius: 0 0 8px 8px;
} }
.wc-clip-bottom .wc-bar { border-top: none; border-radius: 0 0 8px 8px; flex-direction: row; }
.wc-clip-right .wc-bar { border-left: none; border-radius: 0 8px 8px 0; flex-direction: column; padding: 10px 4px; }
.wc-clip-left .wc-bar { border-right: none; border-radius: 8px 0 0 8px; flex-direction: column; padding: 10px 4px; }
.wc-icon-btn { .wc-icon-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -422,7 +581,17 @@
} }
.wc-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); } .wc-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.wc-icon-close:hover { color: #fff; background: #c0392b; } .wc-icon-close:hover { color: #fff; background: #c0392b; }
.wc-bar-sep { width: 1px; height: 12px; background: var(--border-dim); margin: 0 2px; flex-shrink: 0; }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
.bar-middle {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-height: 0;
padding: var(--sp-1) 0;
overflow: hidden;
}
</style> </style>
@@ -0,0 +1,731 @@
<script lang="ts">
import {
X, Check, Trash, FloppyDisk,
Square, Rows, BookOpen, MonitorPlay,
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
ArrowsHorizontal,
SidebarSimple,
} from "phosphor-svelte";
import type { ReaderSettings, ReaderPreset, FitMode } from "@store/state.svelte";
import { store, updateSettings } from "@store/state.svelte";
import { readerState, PAGE_STYLES, ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
interface Props {
fit: FitMode;
style: string;
rtl: boolean;
zoom: number;
zoomPct: number;
perMangaEnabled: boolean;
onTogglePerManga: () => void;
onSavePreset: (name: string) => void;
onApplyPreset: (settings: ReaderSettings) => void;
onUpdatePreset: (id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) => void;
onDeletePreset: (id: string) => void;
onApplySettings: (patch: Partial<ReaderSettings>) => void;
onCaptureZoomAnchor: () => void;
onRestoreZoomAnchor: () => void;
onClampZoom: (z: number) => number;
barPosition: "top" | "left" | "right";
onBarPositionChange: (pos: "top" | "left" | "right") => void;
}
const {
fit, style, rtl, zoom, zoomPct,
perMangaEnabled, onTogglePerManga,
onSavePreset, onApplyPreset, onUpdatePreset, onDeletePreset,
onApplySettings,
onCaptureZoomAnchor, onRestoreZoomAnchor, onClampZoom,
barPosition, onBarPositionChange,
}: Props = $props();
const presets = $derived(store.settings.readerPresets ?? []);
const effectiveSettings = $derived.by(() => {
const mangaId = store.activeManga?.id;
const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
return override ? { ...store.settings, ...override } : store.settings;
});
let presetSaving = $state(false);
let presetNameInput = $state("");
let presetEditId = $state<string | null>(null);
let presetEditName = $state("");
function close() {
readerState.presetOpen = false;
presetSaving = false;
presetNameInput = "";
presetEditId = null;
}
function commitSavePreset() {
if (!presetNameInput.trim()) return;
onSavePreset(presetNameInput.trim());
presetSaving = false;
presetNameInput = "";
}
function commitRenamePreset() {
if (!presetEditId || !presetEditName.trim()) return;
onUpdatePreset(presetEditId, { name: presetEditName.trim() });
presetEditId = null;
presetEditName = "";
}
function describeSettings(s: ReaderSettings): string {
const parts = [s.pageStyle ?? "single", s.fitMode ?? "width", (s.readingDirection ?? "ltr") === "rtl" ? "RTL" : "LTR"];
if ((s.readerZoom ?? 1) !== 1.0) parts.push(`${Math.round((s.readerZoom ?? 1) * 100)}%`);
if (!s.pageGap) parts.push("no gap");
return parts.join(" · ");
}
function setZoom(v: number) {
onCaptureZoomAnchor();
onApplySettings({ readerZoom: onClampZoom(v) });
onRestoreZoomAnchor();
}
const fitOptions: { value: FitMode; label: string; icon: any }[] = [
{ value: "width", label: "Fit Width", icon: ArrowsLeftRight },
{ value: "height", label: "Fit Height", icon: ArrowsVertical },
{ value: "screen", label: "Fit Screen", icon: ArrowsIn },
{ value: "original", label: "Original", icon: ArrowsOut },
];
const styleOptions: { value: string; label: string; icon: any }[] = [
{ value: "single", label: "Single", icon: Square },
{ value: "double", label: "Double", icon: BookOpen },
{ value: "fade", label: "Fade", icon: MonitorPlay },
{ value: "longstrip", label: "Long Strip", icon: Rows },
];
const barOptions: { value: "top" | "left" | "right"; label: string }[] = [
{ value: "left", label: "Left" },
{ value: "top", label: "Top" },
{ value: "right", label: "Right" },
];
</script>
<div class="backdrop" role="presentation" onclick={close} transition:fade={{ duration: 150 }}></div>
<div
class="panel"
role="dialog"
aria-label="Reader settings & presets"
transition:fly={{ x: 320, duration: 220, easing: cubicOut }}
>
<div class="panel-header">
<span class="panel-title">Reader Settings</span>
{#if store.activeManga}
<span class="panel-manga">{store.activeManga.title}</span>
{/if}
<button class="close-btn" onclick={close}><X size={14} weight="light" /></button>
</div>
<div class="panel-body">
<section class="section">
<p class="section-label">Page Style</p>
<div class="option-grid">
{#each styleOptions as o}
<button
class="option-tile"
class:active={style === o.value}
onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })}
>
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={style === o.value ? "fill" : "light"} /></div>
<span class="tile-label">{o.label}</span>
</button>
{/each}
</div>
{#if style === "double"}
<label class="toggle-row">
<span class="toggle-label">Offset double spreads</span>
<button
class="toggle"
class:on={effectiveSettings.offsetDoubleSpreads}
onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })}
role="switch"
aria-checked={effectiveSettings.offsetDoubleSpreads}
><span class="toggle-knob"></span></button>
</label>
{/if}
{#if style === "longstrip"}
<label class="toggle-row">
<span class="toggle-label">Gap between pages</span>
<button
class="toggle"
class:on={effectiveSettings.pageGap ?? true}
onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })}
role="switch"
aria-checked={effectiveSettings.pageGap ?? true}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Auto next chapter</span>
<button
class="toggle"
class:on={store.settings.autoNextChapter ?? false}
onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}
role="switch"
aria-checked={store.settings.autoNextChapter ?? false}
><span class="toggle-knob"></span></button>
</label>
{/if}
</section>
<section class="section">
<p class="section-label">Fit Mode</p>
<div class="option-grid">
{#each fitOptions as o}
<button
class="option-tile"
class:active={fit === o.value}
onclick={() => onApplySettings({ fitMode: o.value })}
>
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={fit === o.value ? "fill" : "light"} /></div>
<span class="tile-label">{o.label}</span>
</button>
{/each}
</div>
</section>
<section class="section">
<p class="section-label">Reading Direction</p>
<div class="dir-row">
<button
class="dir-btn"
class:active={!rtl}
onclick={() => onApplySettings({ readingDirection: "ltr" })}
>
<ArrowsHorizontal size={14} weight="light" />
<span>Left to Right</span>
</button>
<button
class="dir-btn"
class:active={rtl}
onclick={() => onApplySettings({ readingDirection: "rtl" })}
>
<ArrowsHorizontal size={14} weight="light" style="transform:scaleX(-1)" />
<span>Right to Left</span>
</button>
</div>
</section>
<section class="section">
<p class="section-label">Bar Position</p>
<div class="bar-grid">
{#each barOptions as o}
<button
class="bar-tile"
class:active={barPosition === o.value}
onclick={() => onBarPositionChange(o.value)}
>
<div class="bar-tile-preview bar-preview-{o.value}">
<div class="bar-preview-strip"></div>
<div class="bar-preview-content"></div>
</div>
<span class="tile-label">{o.label}</span>
</button>
{/each}
</div>
</section>
<section class="section">
<div class="section-header-row">
<p class="section-label" style="margin:0">Zoom</p>
<span class="zoom-readout">{zoomPct}%</span>
</div>
<div class="zoom-row">
<button class="zoom-step" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}></button>
<input
type="range"
class="zoom-slider"
min={Math.round(ZOOM_MIN * 100)}
max={Math.round(ZOOM_MAX * 100)}
step={5}
value={zoomPct}
oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)}
/>
<button class="zoom-step" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button>
</div>
</section>
<section class="section">
<p class="section-label">Image</p>
<label class="toggle-row">
<span class="toggle-label">Optimize contrast</span>
<button
class="toggle"
class:on={effectiveSettings.optimizeContrast}
onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })}
role="switch"
aria-checked={effectiveSettings.optimizeContrast}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Mark read on chapter advance</span>
<button
class="toggle"
class:on={store.settings.markReadOnNext ?? true}
onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}
role="switch"
aria-checked={store.settings.markReadOnNext ?? true}
><span class="toggle-knob"></span></button>
</label>
</section>
{#if store.activeManga}
<section class="section">
<label class="toggle-row">
<span class="toggle-label">Per-manga settings</span>
<button
class="toggle"
class:on={perMangaEnabled}
onclick={onTogglePerManga}
role="switch"
aria-checked={perMangaEnabled}
><span class="toggle-knob"></span></button>
</label>
</section>
{/if}
<section class="section">
<div class="section-header-row">
<p class="section-label" style="margin:0">Saved Presets</p>
{#if !presetSaving}
<button class="new-preset-btn" onclick={() => { presetSaving = true; presetNameInput = ""; }}>+ New</button>
{/if}
</div>
{#if presetSaving}
<div class="preset-name-row">
<input
class="preset-name-input"
placeholder="Preset name…"
bind:value={presetNameInput}
onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }}
/>
<button class="small-btn" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button>
<button class="small-btn" onclick={() => presetSaving = false}><X size={12} weight="light" /></button>
</div>
{/if}
{#if presets.length === 0 && !presetSaving}
<p class="empty-hint">No presets saved yet. Save the current settings to create one.</p>
{:else}
<div class="preset-list">
{#each presets as p (p.id)}
{#if presetEditId === p.id}
<div class="preset-name-row">
<input
class="preset-name-input"
bind:value={presetEditName}
onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }}
/>
<button class="small-btn" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button>
<button class="small-btn" onclick={() => presetEditId = null}><X size={12} weight="light" /></button>
</div>
{:else}
<div class="preset-row">
<button class="preset-apply" onclick={() => { onApplyPreset(p.settings); close(); }}>
<span class="preset-name">{p.name}</span>
<span class="preset-desc">{describeSettings(p.settings)}</span>
</button>
<button class="small-btn" title="Rename" onclick={() => { presetEditId = p.id; presetEditName = p.name; }}>
<FloppyDisk size={12} weight="regular" />
</button>
<button class="small-btn danger" title="Delete" onclick={() => onDeletePreset(p.id)}>
<Trash size={12} weight="regular" />
</button>
</div>
{/if}
{/each}
</div>
{/if}
</section>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: calc(var(--z-reader) + 20);
background: rgba(0, 0, 0, 0.35);
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 320px;
z-index: calc(var(--z-reader) + 21);
background: var(--bg-surface);
border-left: 1px solid var(--border-base);
display: flex;
flex-direction: column;
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.5);
}
.panel-header {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-4);
height: 48px;
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.panel-title {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
}
.panel-manga {
flex: 1;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.close-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);
}
.close-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.panel-body {
flex: 1;
overflow-y: auto;
padding: var(--sp-3) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-4);
scrollbar-width: thin;
scrollbar-color: var(--border-dim) transparent;
}
.section { display: flex; flex-direction: column; gap: var(--sp-2); }
.section-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
margin: 0 0 var(--sp-1);
}
.section-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-1);
}
.option-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-1);
}
.option-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--sp-2) var(--sp-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.option-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.option-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.tile-icon { display: flex; align-items: center; justify-content: center; }
.tile-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: capitalize; line-height: 1; }
.bar-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-1);
}
.bar-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--sp-2) var(--sp-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.bar-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.bar-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.bar-tile-preview {
width: 32px;
height: 22px;
border-radius: 3px;
border: 1px solid currentColor;
position: relative;
overflow: hidden;
opacity: 0.7;
display: flex;
}
.bar-tile.active .bar-tile-preview { opacity: 1; }
.bar-preview-strip {
background: currentColor;
opacity: 0.5;
flex-shrink: 0;
}
.bar-preview-content {
flex: 1;
background: color-mix(in srgb, currentColor 8%, transparent);
}
.bar-preview-top { flex-direction: column; }
.bar-preview-left { flex-direction: row; }
.bar-preview-right { flex-direction: row-reverse; }
.bar-preview-top .bar-preview-strip { height: 5px; width: 100%; }
.bar-preview-left .bar-preview-strip { width: 5px; height: 100%; }
.bar-preview-right .bar-preview-strip { width: 5px; height: 100%; }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-1) 0;
}
.toggle-label {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.toggle {
position: relative;
width: 32px;
height: 18px;
border-radius: 9px;
background: var(--border-strong);
border: none;
cursor: pointer;
flex-shrink: 0;
transition: background var(--t-base);
}
.toggle.on { background: var(--accent-fg); }
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
transition: left var(--t-base);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.toggle.on .toggle-knob { left: 16px; }
.dir-row { display: flex; gap: var(--sp-2); }
.dir-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
font-size: var(--text-xs);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.dir-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.dir-btn.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.zoom-readout {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-secondary);
letter-spacing: var(--tracking-wide);
}
.zoom-row {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.zoom-step {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
font-size: var(--text-base);
line-height: 1;
flex-shrink: 0;
transition: color var(--t-fast), background var(--t-fast);
}
.zoom-step:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-step:disabled { opacity: 0.25; cursor: default; }
.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: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-fg);
cursor: pointer;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3);
}
.new-preset-btn {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--accent-fg);
letter-spacing: var(--tracking-wide);
background: none;
border: none;
cursor: pointer;
padding: 2px var(--sp-1);
border-radius: var(--radius-sm);
transition: background var(--t-fast);
}
.new-preset-btn:hover { background: var(--accent-muted); }
.preset-name-row { display: flex; align-items: center; gap: var(--sp-1); }
.preset-name-input {
flex: 1;
background: var(--bg-raised);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 5px 8px;
font-size: var(--text-xs);
color: var(--text-primary);
outline: none;
font-family: inherit;
transition: border-color var(--t-base);
}
.preset-name-input:focus { border-color: var(--accent-dim); }
.preset-list { display: flex; flex-direction: column; gap: 2px; }
.preset-row { display: flex; align-items: center; gap: var(--sp-1); }
.preset-apply {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 7px var(--sp-2);
border-radius: var(--radius-md);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background var(--t-fast);
min-width: 0;
}
.preset-apply:hover { background: var(--bg-overlay); }
.preset-name {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: var(--weight-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.preset-desc {
font-family: var(--font-ui);
font-size: 10px;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.small-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-fast), background var(--t-fast);
}
.small-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); }
.small-btn:disabled { opacity: 0.25; cursor: default; }
.small-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.empty-hint {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
margin: 0;
padding: var(--sp-2) 0;
text-align: center;
}
</style>
@@ -17,6 +17,7 @@
activeChapterMarkers: MarkerEntry[]; activeChapterMarkers: MarkerEntry[];
adjacent: { prev: Chapter | null; next: Chapter | null }; adjacent: { prev: Chapter | null; next: Chapter | null };
uiVisible: boolean; uiVisible: boolean;
barPosition: "top" | "left" | "right";
onGoPrev: () => void; onGoPrev: () => void;
onGoNext: () => void; onGoNext: () => void;
onJumpToPage: (page: number) => void; onJumpToPage: (page: number) => void;
@@ -25,71 +26,126 @@
const { const {
style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage, style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage,
displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible, displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible,
barPosition,
onGoPrev, onGoNext, onJumpToPage, onGoPrev, onGoNext, onJumpToPage,
}: Props = $props(); }: Props = $props();
const isVertical = $derived(barPosition === "left" || barPosition === "right");
</script> </script>
<div class="bottombar" class:hidden={!uiVisible}> {#if !isVertical}
<button class="nav-btn" onclick={onGoPrev} <div class="bottombar" class:hidden={!uiVisible}>
disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}> <button class="nav-btn" onclick={onGoPrev}
<ArrowLeft size={13} weight="light" /> disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}>
</button> <ArrowLeft size={13} weight="light" />
</button>
{#if sliderMax > 1} {#if sliderMax > 1}
<div <div
class="slider-wrap" class="slider-wrap"
class:dragging={readerState.sliderDragging} class:dragging={readerState.sliderDragging}
role="slider" role="slider"
aria-valuenow={sliderPage} aria-valuenow={sliderPage}
aria-valuemin={1} aria-valuemin={1}
aria-valuemax={sliderMax} aria-valuemax={sliderMax}
tabindex="-1" tabindex="-1"
onmouseenter={() => readerState.sliderHover = true} onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }} onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
onmousedown={(e) => { onmousedown={(e) => {
readerState.sliderDragging = true; readerState.sliderDragging = true;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
}} }}
onmousemove={(e) => { onmousemove={(e) => {
if (!readerState.sliderDragging) return; if (!readerState.sliderDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
}} }}
onmouseup={() => readerState.sliderDragging = false} onmouseup={() => readerState.sliderDragging = false}
> >
<div class="slider-track-bg"> <div class="slider-track-bg">
<div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div> <div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div>
</div>
<div class="slider-thumb" style="left:{sliderPct}%"></div>
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
{@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0}
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
{/if}
{#each activeChapterMarkers as m (m.id)}
{@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber}
{@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0}
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
{/each}
{#if readerState.sliderHover || readerState.sliderDragging}
<div class="slider-tooltip" style="left:{sliderPct}%">
{sliderPage} / {sliderMax}
</div> </div>
{/if} <div class="slider-thumb" style="left:{sliderPct}%"></div>
</div>
{/if}
<button class="nav-btn" onclick={onGoNext} {#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}> {@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
<ArrowRight size={13} weight="light" /> {@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0}
</button> <div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
</div> {/if}
{#each activeChapterMarkers as m (m.id)}
{@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber}
{@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0}
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
{/each}
{#if readerState.sliderHover || readerState.sliderDragging}
<div class="slider-tooltip" style="left:{sliderPct}%">
{sliderPage} / {sliderMax}
</div>
{/if}
</div>
{/if}
<button class="nav-btn" onclick={onGoNext}
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}>
<ArrowRight size={13} weight="light" />
</button>
</div>
{:else}
<div class="vbar-progress" class:hidden={!uiVisible} class:vbar-right={barPosition === "right"}>
{#if sliderMax > 1}
<div
class="vslider-wrap"
class:dragging={readerState.sliderDragging}
role="slider"
aria-valuenow={sliderPage}
aria-valuemin={1}
aria-valuemax={sliderMax}
tabindex="-1"
onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
onmousedown={(e) => {
readerState.sliderDragging = true;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
}}
onmousemove={(e) => {
if (!readerState.sliderDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
}}
onmouseup={() => readerState.sliderDragging = false}
>
<div class="vslider-track-bg">
<div class="vslider-fill" style="height:{sliderPct}%"></div>
</div>
<div class="vslider-thumb" style="top:{sliderPct}%"></div>
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div class="vslider-checkpoint bookmark-checkpoint" style="top:{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="vslider-checkpoint marker-checkpoint" style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
{/each}
{#if readerState.sliderHover || readerState.sliderDragging}
<div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}>
{sliderPage} / {sliderMax}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<style> <style>
.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; } .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; }
@@ -109,4 +165,91 @@
.marker-checkpoint { opacity: 0.85; } .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-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; } .slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
.vbar-progress {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
width: 100%;
padding: var(--sp-2) 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.vbar-progress.hidden { opacity: 0; }
.vslider-wrap {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 36px;
cursor: pointer;
pointer-events: all;
margin: var(--sp-1) 0;
}
.vslider-track-bg {
position: absolute;
top: 0;
bottom: 0;
width: 5px;
background: var(--border-strong);
border-radius: 3px;
pointer-events: none;
left: 50%;
translate: -50% 0;
}
.vslider-fill {
width: 100%;
background: var(--accent-fg);
border-radius: 3px;
transition: height 0.05s linear;
}
.vslider-thumb {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-fg);
pointer-events: none;
z-index: 2;
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
transition: transform var(--t-fast);
}
.vslider-wrap:hover .vslider-thumb, .vslider-wrap.dragging .vslider-thumb { transform: translate(-50%, -50%) scale(1.3); }
.vslider-wrap:hover .vslider-track-bg, .vslider-wrap.dragging .vslider-track-bg { width: 7px; }
.vslider-checkpoint {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 5px;
border-radius: 2px;
pointer-events: none;
z-index: 1;
}
.vslider-tooltip {
position: absolute;
left: calc(100% + 6px);
transform: translateY(-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);
}
.vslider-tooltip.tooltip-right {
left: auto;
right: calc(100% + 6px);
}
</style> </style>
@@ -31,6 +31,8 @@ class ReaderState {
dlOpen = $state(false); dlOpen = $state(false);
zoomOpen = $state(false); zoomOpen = $state(false);
winOpen = $state(false); winOpen = $state(false);
presetOpen = $state(false);
presetNameInput = $state("");
nextN = $state(5); nextN = $state(5);
dlBusy = $state(false); dlBusy = $state(false);
@@ -80,10 +82,11 @@ class ReaderState {
} }
closeAllPopovers(): boolean { closeAllPopovers(): boolean {
if (this.markerOpen) { this.markerOpen = false; return true; } if (this.markerOpen) { this.markerOpen = false; return true; }
if (this.zoomOpen) { this.zoomOpen = false; return true; } if (this.zoomOpen) { this.zoomOpen = false; return true; }
if (this.dlOpen) { this.dlOpen = false; return true; } if (this.dlOpen) { this.dlOpen = false; return true; }
if (this.winOpen) { this.winOpen = false; return true; } if (this.winOpen) { this.winOpen = false; return true; }
if (this.presetOpen) { this.presetOpen = false; return true; }
return false; return false;
} }
@@ -104,4 +107,4 @@ class ReaderState {
} }
} }
export const readerState = new ReaderState(); export const readerState = new ReaderState();
+55 -2
View File
@@ -95,6 +95,23 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownloadScanlators: [], autoDownloadScanlators: [],
}; };
export interface ReaderSettings {
pageStyle: PageStyle;
fitMode: FitMode;
readingDirection: ReadingDirection;
readerZoom: number;
pageGap: boolean;
optimizeContrast: boolean;
offsetDoubleSpreads: boolean;
barPosition?: "top" | "left" | "right";
}
export interface ReaderPreset {
id: string;
name: string;
settings: ReaderSettings;
}
export interface Settings { export interface Settings {
pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode; pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode;
readerZoom: number; pageGap: boolean; optimizeContrast: boolean; readerZoom: number; pageGap: boolean; optimizeContrast: boolean;
@@ -128,6 +145,9 @@ export interface Settings {
extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string; extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string;
qolAnimations: boolean; qolAnimations: boolean;
pinnedSourceIds: string[]; pinnedSourceIds: string[];
readerPresets: ReaderPreset[];
mangaReaderSettings: Record<number, ReaderSettings>;
barPosition?: "top" | "left" | "right";
} }
export const DEFAULT_READING_STATS: ReadingStats = { export const DEFAULT_READING_STATS: ReadingStats = {
@@ -163,6 +183,8 @@ export const DEFAULT_SETTINGS: Settings = {
extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "", extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "",
qolAnimations: true, qolAnimations: true,
pinnedSourceIds: [], pinnedSourceIds: [],
readerPresets: [],
mangaReaderSettings: {},
}; };
const STORE_VERSION = 3; const STORE_VERSION = 3;
@@ -207,8 +229,10 @@ function mergeSettings(saved: any): Settings {
libraryTabSort: saved?.settings?.libraryTabSort ?? {}, libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {}, libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
extraScanDirs: saved?.settings?.extraScanDirs ?? [], extraScanDirs: saved?.settings?.extraScanDirs ?? [],
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [], pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
readerPresets: saved?.settings?.readerPresets ?? [],
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
}; };
} }
@@ -419,6 +443,30 @@ class Store {
this.settings = { ...this.settings, pinnedSourceIds: pins.includes(sourceId) ? pins.filter(id => id !== sourceId) : [...pins, sourceId] }; this.settings = { ...this.settings, pinnedSourceIds: pins.includes(sourceId) ? pins.filter(id => id !== sourceId) : [...pins, sourceId] };
} }
saveReaderPreset(name: string, settings: ReaderSettings): string {
const id = Math.random().toString(36).slice(2);
this.settings = { ...this.settings, readerPresets: [...(this.settings.readerPresets ?? []), { id, name: name.trim() || "Preset", settings }] };
return id;
}
updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) {
this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).map(p => p.id === id ? { ...p, ...patch } : p) };
}
deleteReaderPreset(id: string) {
this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).filter(p => p.id !== id) };
}
setMangaReaderSettings(mangaId: number, settings: ReaderSettings) {
this.settings = { ...this.settings, mangaReaderSettings: { ...(this.settings.mangaReaderSettings ?? {}), [mangaId]: settings } };
}
clearMangaReaderSettings(mangaId: number) {
const next = { ...(this.settings.mangaReaderSettings ?? {}) };
delete next[mangaId];
this.settings = { ...this.settings, mangaReaderSettings: next };
}
setCategories(cats: Category[]) { this.categories = cats; } setCategories(cats: Category[]) { this.categories = cats; }
setActiveManga(next: Manga | null) { this.activeManga = next; } setActiveManga(next: Manga | null) { this.activeManga = next; }
setPreviewManga(next: Manga | null) { this.previewManga = next; } setPreviewManga(next: Manga | null) { this.previewManga = next; }
@@ -452,6 +500,11 @@ export function setPageNumber(next: number)
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); } export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); } export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
export function togglePinnedSource(sourceId: string) { store.togglePinnedSource(sourceId); } export function togglePinnedSource(sourceId: string) { store.togglePinnedSource(sourceId); }
export function saveReaderPreset(name: string, settings: ReaderSettings): string { return store.saveReaderPreset(name, settings); }
export function updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) { store.updateReaderPreset(id, patch); }
export function deleteReaderPreset(id: string) { store.deleteReaderPreset(id); }
export function setMangaReaderSettings(mangaId: number, settings: ReaderSettings) { store.setMangaReaderSettings(mangaId, settings); }
export function clearMangaReaderSettings(mangaId: number) { store.clearMangaReaderSettings(mangaId); }
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); } export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
export function resetKeybinds() { store.resetKeybinds(); } export function resetKeybinds() { store.resetKeybinds(); }
export function clearSearchCache() { store.clearSearchCache(); } export function clearSearchCache() { store.clearSearchCache(); }