From 2e9939c4a9b9b199248aa56ca2a1879ebbfb37d2 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Fri, 24 Apr 2026 21:09:05 -0500 Subject: [PATCH] Feat: Per-Manga Reader Settings + Settings Access (#42 & #46) --- Todo | 8 +- src/features/reader/components/Reader.svelte | 164 +++- .../reader/components/ReaderControls.svelte | 507 ++++++++---- .../components/ReaderPresetPanel.svelte | 731 ++++++++++++++++++ .../components/ReaderProgressBar.svelte | 259 +++++-- .../reader/store/readerState.svelte.ts | 13 +- src/store/state.svelte.ts | 57 +- 7 files changed, 1474 insertions(+), 265 deletions(-) create mode 100644 src/features/reader/components/ReaderPresetPanel.svelte diff --git a/Todo b/Todo index bb39f58..7f24f54 100644 --- a/Todo +++ b/Todo @@ -33,11 +33,7 @@ In-Progress: - Fix Tracking Login - 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: \ No newline at end of file +Notes from last time: + - Currently working on #42, just need to mount panel and fix button in reader \ No newline at end of file diff --git a/src/features/reader/components/Reader.svelte b/src/features/reader/components/Reader.svelte index 5d371b2..786cf88 100644 --- a/src/features/reader/components/Reader.svelte +++ b/src/features/reader/components/Reader.svelte @@ -5,7 +5,8 @@ import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads"; import { store, updateSettings, openReader, closeReader, addHistory, 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 { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds"; import { readerState, PAGE_STYLES } from "../store/readerState.svelte"; @@ -21,17 +22,27 @@ import PageView from "./PageView.svelte"; import ReaderProgressBar from "./ReaderProgressBar.svelte"; import ReaderOverlay from "./ReaderOverlay.svelte"; + import ReaderPresetPanel from "./ReaderPresetPanel.svelte"; const win = getCurrentWindow(); 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 style = $derived((store.settings.pageStyle ?? "single") as typeof PAGE_STYLES[number]); - const zoom = $derived(store.settings.readerZoom ?? 1.0); + + const effectiveReaderSettings = $derived.by(() => { + const mangaId = store.activeManga?.id; + 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 markOnNext = $derived(store.settings.markReadOnNext ?? true); const overlayBars = $derived(store.settings.overlayBars ?? 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 effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined); const zoomPct = $derived(Math.round(zoom * 100)); @@ -84,7 +95,7 @@ fit === "height" && "fit-height", fit === "screen" && "fit-screen", fit === "original" && "fit-original", - store.settings.optimizeContrast && "optimize-contrast", + effectiveReaderSettings.optimizeContrast && "optimize-contrast", ].filter(Boolean).join(" ")); 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 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 pageViewRef: PageView; let zoomAnchor = { el: null as HTMLElement | null, offset: 0 }; @@ -184,7 +200,7 @@ e.preventDefault(); captureZoomAnchor(containerEl, style, zoomAnchor); 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); } @@ -202,10 +218,10 @@ closeReader, goToPage: (p) => jumpToPage(p, style, lastPage, containerEl), lastPage: () => lastPage, - adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); }, - resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); }, - cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); }, - toggleDirection: () => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }), + adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); }, + resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); }, + cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); }, + toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }), openSettings: () => setSettingsOpen(true), toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber), toggleMarker: () => { @@ -230,6 +246,54 @@ 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[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(() => { const chapter = displayChapter; const manga = store.activeManga; @@ -346,7 +410,7 @@ const snap = store.pageUrls; Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => { 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; }; } else { readerState.pageGroups = []; } @@ -446,17 +510,26 @@ + \ No newline at end of file diff --git a/src/features/reader/components/ReaderControls.svelte b/src/features/reader/components/ReaderControls.svelte index a3cb512..ea54c30 100644 --- a/src/features/reader/components/ReaderControls.svelte +++ b/src/features/reader/components/ReaderControls.svelte @@ -1,81 +1,77 @@ -
- -
+
+
+ - - {store.activeManga?.title} - / - {displayChapter?.name} - + + + - {store.pageNumber} / {visibleChunkLastPage || "…"} + + {#if !isVertical} + + {/if}
-
-
- - + {#if isVertical && progressBar} +
+ {@render progressBar()} +
+ {/if} +
- +
-
+ {#if readerState.zoomOpen} -
+
{ onCaptureZoomAnchor(); updateSettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} /> + oninput={(e) => { onCaptureZoomAnchor(); onApplySettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
{/if}
- - - - -
- {#if style === "double"} - - {/if} - {#if style === "longstrip"} - - - {/if} - {#if !autoNext} - - {/if} -
- - -
+ + + + + +
{#if readerState.winOpen} -
+
+ +
{/if}
-
\ No newline at end of file diff --git a/src/features/reader/components/ReaderPresetPanel.svelte b/src/features/reader/components/ReaderPresetPanel.svelte new file mode 100644 index 0000000..6e4f252 --- /dev/null +++ b/src/features/reader/components/ReaderPresetPanel.svelte @@ -0,0 +1,731 @@ + + + + + + + \ No newline at end of file diff --git a/src/features/reader/components/ReaderProgressBar.svelte b/src/features/reader/components/ReaderProgressBar.svelte index 4fdb959..dcdc0e0 100644 --- a/src/features/reader/components/ReaderProgressBar.svelte +++ b/src/features/reader/components/ReaderProgressBar.svelte @@ -17,6 +17,7 @@ activeChapterMarkers: MarkerEntry[]; adjacent: { prev: Chapter | null; next: Chapter | null }; uiVisible: boolean; + barPosition: "top" | "left" | "right"; onGoPrev: () => void; onGoNext: () => void; onJumpToPage: (page: number) => void; @@ -25,71 +26,126 @@ const { style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage, displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible, + barPosition, onGoPrev, onGoNext, onJumpToPage, }: Props = $props(); + + const isVertical = $derived(barPosition === "left" || barPosition === "right"); -
- +{#if !isVertical} +
+ - {#if sliderMax > 1} -
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.clientX - rect.left) / rect.width)); - onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : 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.clientX - rect.left) / rect.width)); - onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); - }} - onmouseup={() => readerState.sliderDragging = false} - > -
-
-
-
- - {#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} -
- {/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} -
- {/each} - - {#if readerState.sliderHover || readerState.sliderDragging} -
- {sliderPage} / {sliderMax} + {#if sliderMax > 1} +
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.clientX - rect.left) / rect.width)); + onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : 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.clientX - rect.left) / rect.width)); + onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); + }} + onmouseup={() => readerState.sliderDragging = false} + > +
+
- {/if} -
- {/if} +
- -
+ {#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} +
+ {/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} +
+ {/each} + + {#if readerState.sliderHover || readerState.sliderDragging} +
+ {sliderPage} / {sliderMax} +
+ {/if} +
+ {/if} + + +
+{:else} +
+ {#if sliderMax > 1} +
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} + > +
+
+
+
+ + {#if currentBookmark && currentBookmark.chapterId === displayChapter?.id} + {@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0} +
+ {/if} + + {#each activeChapterMarkers as m (m.id)} + {@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0} +
+ {/each} + + {#if readerState.sliderHover || readerState.sliderDragging} +
+ {sliderPage} / {sliderMax} +
+ {/if} +
+ {/if} +
+{/if} \ No newline at end of file diff --git a/src/features/reader/store/readerState.svelte.ts b/src/features/reader/store/readerState.svelte.ts index c9bfdce..5737aac 100644 --- a/src/features/reader/store/readerState.svelte.ts +++ b/src/features/reader/store/readerState.svelte.ts @@ -31,6 +31,8 @@ class ReaderState { dlOpen = $state(false); zoomOpen = $state(false); winOpen = $state(false); + presetOpen = $state(false); + presetNameInput = $state(""); nextN = $state(5); dlBusy = $state(false); @@ -80,10 +82,11 @@ class ReaderState { } closeAllPopovers(): boolean { - if (this.markerOpen) { this.markerOpen = false; return true; } - if (this.zoomOpen) { this.zoomOpen = false; return true; } - if (this.dlOpen) { this.dlOpen = false; return true; } - if (this.winOpen) { this.winOpen = false; return true; } + if (this.markerOpen) { this.markerOpen = false; return true; } + if (this.zoomOpen) { this.zoomOpen = false; return true; } + if (this.dlOpen) { this.dlOpen = false; return true; } + if (this.winOpen) { this.winOpen = false; return true; } + if (this.presetOpen) { this.presetOpen = false; return true; } return false; } @@ -104,4 +107,4 @@ class ReaderState { } } -export const readerState = new ReaderState(); +export const readerState = new ReaderState(); \ No newline at end of file diff --git a/src/store/state.svelte.ts b/src/store/state.svelte.ts index 154f709..d42b01d 100644 --- a/src/store/state.svelte.ts +++ b/src/store/state.svelte.ts @@ -95,6 +95,23 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = { 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 { pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode; readerZoom: number; pageGap: boolean; optimizeContrast: boolean; @@ -128,6 +145,9 @@ export interface Settings { extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string; qolAnimations: boolean; pinnedSourceIds: string[]; + readerPresets: ReaderPreset[]; + mangaReaderSettings: Record; + barPosition?: "top" | "left" | "right"; } export const DEFAULT_READING_STATS: ReadingStats = { @@ -163,6 +183,8 @@ export const DEFAULT_SETTINGS: Settings = { extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "", qolAnimations: true, pinnedSourceIds: [], + readerPresets: [], + mangaReaderSettings: {}, }; const STORE_VERSION = 3; @@ -207,8 +229,10 @@ function mergeSettings(saved: any): Settings { libraryTabSort: saved?.settings?.libraryTabSort ?? {}, libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, libraryTabFilters: saved?.settings?.libraryTabFilters ?? {}, - extraScanDirs: saved?.settings?.extraScanDirs ?? [], - pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [], + extraScanDirs: saved?.settings?.extraScanDirs ?? [], + 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] }; } + 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>) { + 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; } setActiveManga(next: Manga | null) { this.activeManga = 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 setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); } 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>) { 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) { store.updateSettings(patch); } export function resetKeybinds() { store.resetKeybinds(); } export function clearSearchCache() { store.clearSearchCache(); }