Fix: Zoom Issue (Bug #14)

This commit is contained in:
Youwes09
2026-03-29 12:40:28 -05:00
parent e850cbac1e
commit 32d2fffdc5
7 changed files with 304 additions and 170 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ modules:
path: . path: .
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: a6b7b0f57210ea15b1a7ef580be9f89a667d647373abca4f34fe017a5ac8c850 sha256: 3f18e4cc9153e28fd9020f7de22aac6dad1891034833b683c4bc0f5d0e04fc2b
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+15 -15
View File
@@ -4201,14 +4201,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustc-hash/rustc-hash-2.1.1.crate", "url": "https://static.crates.io/crates/rustc-hash/rustc-hash-2.1.2.crate",
"sha256": "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d", "sha256": "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe",
"dest": "cargo/vendor/rustc-hash-2.1.1" "dest": "cargo/vendor/rustc-hash-2.1.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d\", \"files\": {}}", "contents": "{\"package\": \"94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe\", \"files\": {}}",
"dest": "cargo/vendor/rustc-hash-2.1.1", "dest": "cargo/vendor/rustc-hash-2.1.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -7503,27 +7503,27 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.47.crate", "url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.48.crate",
"sha256": "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87", "sha256": "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9",
"dest": "cargo/vendor/zerocopy-0.8.47" "dest": "cargo/vendor/zerocopy-0.8.48"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87\", \"files\": {}}", "contents": "{\"package\": \"eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9\", \"files\": {}}",
"dest": "cargo/vendor/zerocopy-0.8.47", "dest": "cargo/vendor/zerocopy-0.8.48",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.47.crate", "url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.48.crate",
"sha256": "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89", "sha256": "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4",
"dest": "cargo/vendor/zerocopy-derive-0.8.47" "dest": "cargo/vendor/zerocopy-derive-0.8.48"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89\", \"files\": {}}", "contents": "{\"package\": \"70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4\", \"files\": {}}",
"dest": "cargo/vendor/zerocopy-derive-0.8.47", "dest": "cargo/vendor/zerocopy-derive-0.8.48",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
+39 -85
View File
@@ -104,14 +104,14 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}) })
} }
/// Returns the OS/monitor DPI scale factor for the window's current monitor.
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
/// 1.251.5 on Windows displays with OS-level scaling applied.
/// The frontend multiplies this by the user's uiZoom preference to get the
/// final effective zoom applied to document.documentElement.
#[tauri::command] #[tauri::command]
fn get_platform_ui_scale() -> f64 { fn get_platform_ui_scale(window: tauri::Window) -> f64 {
#[cfg(target_os = "windows")] window.scale_factor().unwrap_or(1.0)
return 1.0;
#[cfg(target_os = "macos")]
return 1.0;
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
return 1.5;
} }
fn kill_tachidesk(app: &tauri::AppHandle) { fn kill_tachidesk(app: &tauri::AppHandle) {
@@ -248,22 +248,7 @@ struct ServerInvocation {
working_dir: Option<PathBuf>, working_dir: Option<PathBuf>,
} }
#[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
let java = bundle_dir.join("jre").join("bin").join("java.exe");
#[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java");
do_log(log, &format!("[find_java] checking path: {:?}", java));
do_log(log, &format!("[find_java] exists: {}", java.exists()));
if java.exists() { Some(java) } else { None }
}
fn do_log(log: &mut Option<std::fs::File>, msg: &str) { fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
eprintln!("{}", msg);
if let Some(f) = log { if let Some(f) = log {
let _ = writeln!(f, "{}", msg); let _ = writeln!(f, "{}", msg);
} }
@@ -276,81 +261,50 @@ fn resolve_server_binary(
) -> Result<ServerInvocation, SpawnError> { ) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary arg = {:?}", binary)); do_log(log, &format!("[resolve] binary arg = {:?}", binary));
// 1. User-specified binary path
if !binary.trim().is_empty() { if !binary.trim().is_empty() {
do_log(log, "[resolve] using user-supplied binary path"); let path = strip_unc(PathBuf::from(binary.trim()));
return Ok(ServerInvocation { do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
bin: binary.to_string(), if path.exists() {
args: vec![], return Ok(ServerInvocation {
working_dir: None, bin: path.to_string_lossy().into_owned(),
}); args: vec![],
working_dir: path.parent().map(|p| p.to_path_buf()),
});
}
return Err(SpawnError::NotConfigured(
format!("Configured binary not found: {}", path.display()),
));
} }
let resource_dir = match app.path().resource_dir() { // 2. Bundled sidecar (Windows / Linux AppImage)
Ok(p) => {
let stripped = strip_unc(p);
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
stripped
}
Err(e) => {
let msg = format!("resource_dir error: {e}");
do_log(log, &format!("[resolve] ERROR: {}", msg));
return Err(SpawnError::SpawnFailed(msg));
}
};
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle"); let resource_dir = app.path().resource_dir().unwrap_or_default();
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar"); let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
for name in &candidates {
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir)); let p = resource_dir.join(name);
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists())); do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
do_log(log, &format!("[resolve] jar = {:?}", jar)); if p.exists() {
do_log(log, &format!("[resolve] jar exists: {}", jar.exists())); do_log(log, &format!("[resolve] using sidecar: {:?}", p));
return Ok(ServerInvocation {
match find_java_in_bundle(&bundle_dir, log) { bin: p.to_string_lossy().into_owned(),
Some(java) => { args: vec![],
do_log(log, &format!("[resolve] java found: {:?}", java)); working_dir: Some(resource_dir),
if jar.exists() { });
do_log(log, "[resolve] both java and jar found — using bundled JRE");
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec![
"-jar".to_string(),
jar.to_string_lossy().into_owned(),
],
working_dir: Some(bundle_dir),
});
} else {
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
}
}
None => {
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path");
} }
} }
} }
// 3. macOS app bundle — look in MacOS/ and Resources/
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// Tauri places externalBin sidecars next to the main binary in let resource_dir = app.path().resource_dir().unwrap_or_default();
// Contents/MacOS/, not in Contents/Resources/. Derive that path let macos_dir = resource_dir.parent()
// from resource_dir (Contents/Resources → Contents/MacOS). .map(|p| p.join("MacOS"))
let macos_dir = resource_dir.join("../MacOS") .unwrap_or_default();
.canonicalize()
.unwrap_or_else(|_| resource_dir.join("../MacOS"));
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir)); let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
// Tauri strips the target triple when installing externalBin sidecars
// into Contents/MacOS/, so the binary is always just "suwayomi-server"
// at runtime. The triple-suffixed names are only needed on disk at
// build time for Tauri to pick the right arch during bundling.
let candidates = [
"suwayomi-server",
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
];
// Search MacOS/ first (correct location), then Resources/ as fallback // Search MacOS/ first (correct location), then Resources/ as fallback
// for flat dev layouts where the script sits next to resources. // for flat dev layouts where the script sits next to resources.
+30 -7
View File
@@ -74,13 +74,23 @@
let notConfigured = $state(false); let notConfigured = $state(false);
let idle = $state(false); let idle = $state(false);
let devSplash = $state(false); let devSplash = $state(false);
let platformScale = $state(1);
// The OS/monitor DPI scale factor for the current display.
// Queried from Rust (window.scale_factor()) on mount and updated live
// whenever the window moves to a different monitor via the scaleChanged event.
// 1.0 = standard display, 2.0 = HiDPI/4K, 1.251.5 = Windows scaled display.
let platformScale = $state(1.0);
// effectiveZoom = platformScale × uiZoom (user preference, float, default 1.0)
// Applied to document.documentElement so the entire UI scales correctly.
function applyZoom() { function applyZoom() {
const normalized = store.settings.uiScale * platformScale; const uiZoom = store.settings.uiZoom ?? 1.5;
document.documentElement.style.zoom = `${normalized}%`; const effective = platformScale * uiZoom;
document.documentElement.style.setProperty("--ui-scale", String(normalized)); const pct = effective * 100;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`); document.documentElement.style.zoom = `${pct}%`;
document.documentElement.style.setProperty("--ui-scale", String(effective));
// visual-vh compensates for the zoom so 100vh-based calculations stay correct.
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`);
} }
let prevQueue: DownloadQueueItem[] = []; let prevQueue: DownloadQueueItem[] = [];
@@ -125,8 +135,9 @@
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle)); return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
}); });
// Re-apply zoom whenever uiZoom setting or platformScale changes.
$effect(() => { $effect(() => {
store.settings.uiScale; platformScale; store.settings.uiZoom; platformScale;
applyZoom(); applyZoom();
}); });
@@ -214,14 +225,25 @@
document.addEventListener("contextmenu", e => e.preventDefault()); document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true; (window as any).__mokuShowSplash = () => devSplash = true;
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1); // Fetch the real monitor scale factor from Rust (window.scale_factor()).
// This reflects actual DPI — 2.0 on HiDPI, 1.25 on Windows scaled displays, etc.
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
applyZoom(); applyZoom();
store.isFullscreen = await win.isFullscreen(); store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => { const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen(); store.isFullscreen = await win.isFullscreen();
}); });
// Re-query the scale factor when the window moves to a different monitor.
// Tauri emits this event whenever the DPI changes (e.g. dragging window
// from a 1080p display to a 4K display).
const unlistenScale = await win.onScaleChanged(async (event) => {
platformScale = event.payload.scaleFactor;
applyZoom();
});
if (store.settings.autoStartServer) { if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => { invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") { if (err?.kind === "NotConfigured") {
@@ -240,6 +262,7 @@
return () => { return () => {
cancelProbe = true; cancelProbe = true;
unlistenResize(); unlistenResize();
unlistenScale();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {}); if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
if (idleTimer) clearTimeout(idleTimer); if (idleTimer) clearTimeout(idleTimer);
if (pollInterval) clearInterval(pollInterval); if (pollInterval) clearInterval(pollInterval);
+155 -44
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte"; import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte"; import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte";
@@ -12,6 +12,10 @@
const AVG_MIN_PER_PAGE = 0.33; const AVG_MIN_PER_PAGE = 0.33;
const MAX_CACHED = 10; const MAX_CACHED = 10;
const READ_LINE_PCT = 0.20; const READ_LINE_PCT = 0.20;
// Zoom step per Ctrl+Wheel tick or keyboard shortcut (5% of viewer width)
const ZOOM_STEP = 0.05;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 4.0;
// ─── Page cache ─────────────────────────────────────────────────────────────── // ─── Page cache ───────────────────────────────────────────────────────────────
@@ -93,6 +97,47 @@
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
// ─── Container width (for resolution-based zoom) ──────────────────────────────
// Tracked via ResizeObserver so 100% zoom always means "fills the viewer",
// regardless of screen resolution or window size.
let containerWidth = $state(0);
// ─── Zoom anchor (longstrip) ──────────────────────────────────────────────────
// Before zoom changes the layout we snapshot which image is at the top of the
// viewport and how far it is from the top edge. After the DOM re-renders at
// the new zoom we scroll back so that same image is at the same visual offset,
// preventing the "random page teleport" that occurs when scrollHeight changes.
let zoomAnchorEl: HTMLElement | null = null;
let zoomAnchorOffset: number = 0;
function captureZoomAnchor() {
if (!containerEl || style !== "longstrip") return;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
const containerTop = containerEl.getBoundingClientRect().top;
for (const img of imgs) {
const rect = img.getBoundingClientRect();
if (rect.bottom > containerTop) {
zoomAnchorEl = img;
zoomAnchorOffset = rect.top - containerTop;
return;
}
}
}
function restoreZoomAnchor() {
if (!zoomAnchorEl || !containerEl) return;
const el = zoomAnchorEl;
zoomAnchorEl = null;
// Use rAF to wait for the DOM to finish re-laying out after the zoom change.
requestAnimationFrame(() => {
const containerTop = containerEl.getBoundingClientRect().top;
const newRect = el.getBoundingClientRect();
containerEl.scrollTop += (newRect.top - containerTop) - zoomAnchorOffset;
});
}
// ─── UI state ───────────────────────────────────────────────────────────────── // ─── UI state ─────────────────────────────────────────────────────────────────
let loading = $state(true); let loading = $state(true);
@@ -121,14 +166,23 @@
// ─── Derived ────────────────────────────────────────────────────────────────── // ─── Derived ──────────────────────────────────────────────────────────────────
const rtl = $derived(store.settings.readingDirection === "rtl"); const rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode); const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
const style = $derived(store.settings.pageStyle ?? "single"); const style = $derived(store.settings.pageStyle ?? "single");
const maxW = $derived(store.settings.maxPageWidth ?? 900); const zoom = $derived(store.settings.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 lastPage = $derived(store.pageUrls.length); const lastPage = $derived(store.pageUrls.length);
// effectiveWidth: how wide the image should be, in pixels.
// = container width × zoom multiplier. Applied as max-width on the viewer
// so fit modes (height, screen) can still further constrain the image.
const effectiveWidth = $derived(
containerWidth > 0 ? Math.round(containerWidth * zoom) : undefined
);
const zoomPct = $derived(Math.round(zoom * 100));
const displayChapter = $derived( const displayChapter = $derived(
style === "longstrip" && autoNext && visibleChapterId style === "longstrip" && autoNext && visibleChapterId
@@ -216,9 +270,6 @@
} }
// ─── Strip initialisation ───────────────────────────────────────────────────── // ─── Strip initialisation ─────────────────────────────────────────────────────
// Runs when a chapter finishes loading in longstrip mode.
// Starts the strip with just the current chapter; appendNextChapter adds more
// as the user scrolls. Nothing is ever removed from the DOM mid-read.
$effect(() => { $effect(() => {
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) { if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
@@ -239,8 +290,6 @@
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; }); $effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
// ─── Forward append only ────────────────────────────────────────────────────── // ─── Forward append only ──────────────────────────────────────────────────────
// Appends the next chapter to the bottom when the user scrolls past 80%.
// No eviction, no prepend, no sliding window — chapters accumulate forward.
function appendNextChapter() { function appendNextChapter() {
if (appending || !stripChapters.length) return; if (appending || !stripChapters.length) return;
@@ -398,17 +447,8 @@
}); });
// ─── Progress / history tracking ───────────────────────────────────────────── // ─── Progress / history tracking ─────────────────────────────────────────────
// Only records history after the user has genuinely navigated (pageNumber > 1,
// or scrolled past page 1 in longstrip). This prevents the chapter-open event
// from writing "page 1" as the last-read position, which caused the history to
// always show the chapter you started on rather than where you left off.
$effect(() => { $effect(() => {
// Use displayChapter, not store.activeChapter — in longstrip with autoNext,
// store.activeChapter stays as the chapter you *opened* (e.g. ch61) while
// displayChapter tracks visibleChapterId (the chapter actually on screen).
// Using store.activeChapter here caused every history write to stamp ch61
// even when the user had scrolled all the way to ch72.
const ch = displayChapter ?? store.activeChapter; const ch = displayChapter ?? store.activeChapter;
if (ch && lastPage && store.activeManga) { if (ch && lastPage && store.activeManga) {
const chapterId = ch.id; const chapterId = ch.id;
@@ -419,11 +459,9 @@
const pageNum = store.pageNumber; const pageNum = store.pageNumber;
const atLast = store.pageNumber === lastPage; const atLast = store.pageNumber === lastPage;
// Mark that the user has moved past the initial load.
if (pageNum > 1) hasNavigated = true; if (pageNum > 1) hasNavigated = true;
untrack(() => { untrack(() => {
// Skip the very first page-1 write that fires on chapter load.
if (!hasNavigated) return; if (!hasNavigated) return;
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() }); addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId); if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
@@ -507,6 +545,24 @@
const goNext = $derived(rtl ? goBack : goForward); const goNext = $derived(rtl ? goBack : goForward);
const goPrev = $derived(rtl ? goForward : goBack); const goPrev = $derived(rtl ? goForward : goBack);
// ─── Zoom helpers ─────────────────────────────────────────────────────────────
function clampZoom(z: number): number {
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
}
function adjustZoom(delta: number) {
captureZoomAnchor();
updateSettings({ readerZoom: clampZoom(zoom + delta) });
restoreZoomAnchor();
}
function resetZoom() {
captureZoomAnchor();
updateSettings({ readerZoom: 1.0 });
restoreZoomAnchor();
}
// ─── Settings toggles ───────────────────────────────────────────────────────── // ─── Settings toggles ─────────────────────────────────────────────────────────
function cycleStyle() { function cycleStyle() {
@@ -531,13 +587,13 @@
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
if (!e.ctrlKey) return; if (!e.ctrlKey) return;
e.preventDefault(); e.preventDefault();
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) }); // Each wheel tick adjusts by ZOOM_STEP (5%). Larger deltaY = bigger scroll = same step.
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if ((e.target as HTMLElement).tagName === "INPUT") return; if ((e.target as HTMLElement).tagName === "INPUT") return;
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS; const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
const mW = store.settings.maxPageWidth ?? 900;
const r = store.settings.readingDirection === "rtl"; const r = store.settings.readingDirection === "rtl";
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -545,9 +601,9 @@
if (dlOpen) { dlOpen = false; return; } if (dlOpen) { dlOpen = false; return; }
closeReader(); return; closeReader(); return;
} }
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, mW + 100) }); return; } if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); adjustZoom(ZOOM_STEP * 2); return; }
if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, mW - 100) }); return; } if (e.ctrlKey && e.key === "-") { e.preventDefault(); adjustZoom(-ZOOM_STEP * 2); return; }
if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; } if (e.ctrlKey && e.key === "0") { e.preventDefault(); resetZoom(); return; }
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); } if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); } else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); } else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
@@ -591,12 +647,20 @@
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("wheel", onWheel, { passive: false }); window.addEventListener("wheel", onWheel, { passive: false });
containerEl?.focus({ preventScroll: true }); containerEl?.focus({ preventScroll: true });
// Track the viewer's actual paint width so zoom is always relative to it.
const ro = new ResizeObserver(entries => {
containerWidth = entries[0].contentRect.width;
});
ro.observe(containerEl);
return () => { return () => {
abortCtrl?.abort(); abortCtrl?.abort();
if (hideTimer) clearTimeout(hideTimer); if (hideTimer) clearTimeout(hideTimer);
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
window.removeEventListener("wheel", onWheel); window.removeEventListener("wheel", onWheel);
cleanupScroll(); cleanupScroll();
ro.disconnect();
}; };
}); });
</script> </script>
@@ -625,16 +689,37 @@
{:else}<ArrowsOut size={14} weight="light" />{/if} {:else}<ArrowsOut size={14} weight="light" />{/if}
<span class="mode-label">{fitLabel}</span> <span class="mode-label">{fitLabel}</span>
</button> </button>
<!-- ── Zoom controls ────────────────────────────────────────────────────── -->
<div class="zoom-wrap"> <div class="zoom-wrap">
<button class="zoom-btn" onclick={() => zoomOpen = !zoomOpen}>{Math.round((maxW / 900) * 100)}%</button> <div class="zoom-inline">
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
<MagnifyingGlassMinus size={13} weight="light" />
</button>
<button class="zoom-pct-btn" onclick={() => zoomOpen = !zoomOpen} title="Click to adjust zoom">
{zoomPct}%
</button>
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
<MagnifyingGlassPlus size={13} weight="light" />
</button>
</div>
{#if zoomOpen} {#if zoomOpen}
<div class="zoom-popover"> <div class="zoom-popover">
<input type="range" class="zoom-slider" min={200} max={2400} step={50} value={maxW} <div class="zoom-slider-row">
oninput={(e) => updateSettings({ maxPageWidth: Number(e.currentTarget.value) })} /> <input type="range" class="zoom-slider" min={10} max={400} step={5} value={zoomPct}
<button class="zoom-reset" onclick={() => updateSettings({ maxPageWidth: 900 })}>{Math.round((maxW / 900) * 100)}%</button> oninput={(e) => { captureZoomAnchor(); updateSettings({ readerZoom: clampZoom(Number(e.currentTarget.value) / 100) }); restoreZoomAnchor(); }} />
</div>
<div class="zoom-presets">
{#each [50, 75, 100, 125, 150, 200] as pct}
<button class="zoom-preset" class:active={zoomPct === pct}
onclick={() => { captureZoomAnchor(); updateSettings({ readerZoom: pct / 100 }); restoreZoomAnchor(); }}>{pct}%</button>
{/each}
</div>
<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" })}> <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> <ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
</button> </button>
@@ -666,7 +751,7 @@
bind:this={containerEl} bind:this={containerEl}
class="viewer" class="viewer"
class:strip={style === "longstrip"} class:strip={style === "longstrip"}
style="--max-page-width:{maxW}px" style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
role="presentation" role="presentation"
tabindex="-1" tabindex="-1"
onclick={handleTap} onclick={handleTap}
@@ -770,25 +855,51 @@
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); } .mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); } .mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
.mode-label { text-transform: capitalize; } .mode-label { text-transform: capitalize; }
/* ── Zoom controls ───────────────────────────────────────────────────────── */
.zoom-wrap { position: relative; flex-shrink: 0; } .zoom-wrap { position: relative; flex-shrink: 0; }
.zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); } .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-btn:hover { color: var(--text-secondary); background: var(--bg-raised); } .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-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) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; } .zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-slider { width: 140px; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; } .zoom-step-btn:disabled { opacity: 0.25; cursor: default; }
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; } .zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); } .zoom-presets { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; }
.zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); } .zoom-preset { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); padding: 3px 6px; border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
.zoom-preset:hover { color: var(--text-primary); background: var(--bg-overlay); }
.zoom-preset.active { color: var(--accent-fg); background: var(--accent-muted); }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
/* ── Viewer ──────────────────────────────────────────────────────────────── */
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; } .viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; } .viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; } .viewer:focus { outline: none; }
.img { display: block; user-select: none; image-rendering: auto; } .img { display: block; user-select: none; image-rendering: auto; }
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; } .img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
.fit-width { max-width: var(--max-page-width); width: 100%; height: auto; }
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; } /*
.fit-screen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; } * Fit modes — all constrain within --effective-width (the zoom-adjusted
* container width). effectiveWidth is set as a CSS variable on .viewer
* so every fit class automatically respects the current zoom level.
*
* fit-width : fills up to effectiveWidth, never wider
* fit-height : constrained to viewport height; never taller, never wider than effectiveWidth
* fit-screen : fits within both axes (contain); never wider than effectiveWidth
* fit-original : natural image size, no constraint
*/
.fit-width { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
.fit-screen { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
.fit-original { max-width: none; width: auto; height: auto; } .fit-original { max-width: none; width: auto; height: auto; }
.strip-gap { margin-bottom: 8px; } .strip-gap { margin-bottom: 8px; }
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--max-page-width) * 2); width: 100%; } .double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
.page-half { flex: 1; min-width: 0; object-fit: contain; } .page-half { flex: 1; min-width: 0; object-fit: contain; }
.gap-left { margin-right: 2px; } .gap-left { margin-right: 2px; }
.gap-right { margin-left: 2px; } .gap-right { margin-left: 2px; }
+42 -13
View File
@@ -717,28 +717,30 @@
<div class="section"> <div class="section">
<p class="section-title">Interface Scale</p> <p class="section-title">Interface Scale</p>
<div class="scale-row"> <div class="scale-row">
<input type="range" min={50} max={200} step={5} value={store.settings.uiScale} <input type="range" min={50} max={200} step={5}
oninput={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" /> value={Math.round((store.settings.uiZoom ?? 1.5) * 100)}
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
class="scale-slider" />
<input <input
type="number" min={50} max={200} step={1} type="number" min={50} max={200} step={1}
class="scale-val-input" class="scale-val-input"
value={store.settings.uiScale} value={Math.round((store.settings.uiZoom ?? 1.5) * 100)}
oninput={(e) => { oninput={(e) => {
const n = parseInt(e.currentTarget.value, 10); const n = parseInt(e.currentTarget.value, 10);
if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiScale: n }); if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 });
}} }}
onblur={(e) => { onblur={(e) => {
const n = parseInt(e.currentTarget.value, 10); const n = parseInt(e.currentTarget.value, 10);
if (isNaN(n) || n < 50) { updateSettings({ uiScale: 50 }); e.currentTarget.value = "50"; } if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = "50"; }
else if (n > 200) { updateSettings({ uiScale: 200 }); e.currentTarget.value = "200"; } else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = "200"; }
}} }}
/> />
<span class="scale-pct">%</span> <span class="scale-pct">%</span>
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset"></button> <button class="step-btn" onclick={() => updateSettings({ uiZoom: 1.5 })} disabled={(store.settings.uiZoom ?? 1.5) === 1.5} title="Reset"></button>
</div> </div>
<p class="scale-hint"> <p class="scale-hint">
{#each [50,60,70,80,90,100,110,125,150,175,200] as v} {#each [50,60,70,80,90,100,110,125,150,175,200] as v}
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button> <button class="scale-preset" class:active={Math.round((store.settings.uiZoom ?? 1.5) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
{/each} {/each}
</p> </p>
</div> </div>
@@ -931,13 +933,40 @@
</div> </div>
</div> </div>
<div class="step-row"> <div class="step-row">
<div class="toggle-info"><span class="toggle-label">Max page width</span><span class="toggle-desc">Pixel cap for fit-width mode.</span></div> <div class="toggle-info">
<div class="step-controls"> <span class="toggle-label">Default zoom</span>
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.max(200, (store.settings.maxPageWidth ?? 900) - 100) })}></button> <span class="toggle-desc">Starting zoom when opening a chapter. 100% = fills the reader.</span>
<span class="step-val">{store.settings.maxPageWidth ?? 900}px</span> </div>
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.min(2400, (store.settings.maxPageWidth ?? 900) + 100) })}>+</button> <div class="scale-row">
<input type="range" min={10} max={400} step={5}
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
oninput={(e) => updateSettings({ readerZoom: Number(e.currentTarget.value) / 100 })}
class="scale-slider" />
<input
type="number" min={10} max={400} step={5}
class="scale-val-input"
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
oninput={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (!isNaN(n) && n >= 10 && n <= 400) updateSettings({ readerZoom: n / 100 });
}}
onblur={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (isNaN(n) || n < 10) { updateSettings({ readerZoom: 0.1 }); e.currentTarget.value = "10"; }
else if (n > 400) { updateSettings({ readerZoom: 4.0 }); e.currentTarget.value = "400"; }
}}
/>
<span class="scale-pct">%</span>
<button class="step-btn" onclick={() => updateSettings({ readerZoom: 0.5 })} disabled={(store.settings.readerZoom ?? 0.5) === 0.5} title="Reset to 100%"></button>
</div> </div>
</div> </div>
<p class="scale-hint">
{#each [50, 75, 100, 125, 150, 200] as v}
<button class="scale-preset"
class:active={Math.round((store.settings.readerZoom ?? 0.5) * 100) === v}
onclick={() => updateSettings({ readerZoom: v / 100 })}>{v}%</button>
{/each}
</p>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div> <div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
+22 -5
View File
@@ -146,7 +146,12 @@ export interface Settings {
pageStyle: PageStyle; pageStyle: PageStyle;
readingDirection: ReadingDirection; readingDirection: ReadingDirection;
fitMode: FitMode; fitMode: FitMode;
maxPageWidth: number; /**
* Reader zoom level unitless float multiplier relative to the viewer
* container width. 1.0 = image fills the viewer, 1.5 = 150%, 0.8 = 80%.
* Replaces the old `maxPageWidth` pixel value.
*/
readerZoom: number;
pageGap: boolean; pageGap: boolean;
optimizeContrast: boolean; optimizeContrast: boolean;
offsetDoubleSpreads: boolean; offsetDoubleSpreads: boolean;
@@ -159,7 +164,12 @@ export interface Settings {
chapterSortDir: ChapterSortDir; chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode; chapterSortMode: ChapterSortMode;
chapterPageSize: number; chapterPageSize: number;
uiScale: number; /**
* UI zoom level unitless float multiplier applied on top of the
* platform scale factor from the OS/monitor. 1.0 = no user adjustment.
* Replaces the old `uiScale` percentage integer.
*/
uiZoom: number;
compactSidebar: boolean; compactSidebar: boolean;
gpuAcceleration: boolean; gpuAcceleration: boolean;
serverUrl: string; serverUrl: string;
@@ -198,6 +208,11 @@ export interface Settings {
hiddenCategoryIds: number[]; hiddenCategoryIds: number[];
/** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */ /** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */
defaultLibraryCategoryId: number | null; defaultLibraryCategoryId: number | null;
// Legacy fields kept for migration reads only — never written after v3.
/** @deprecated use readerZoom */
maxPageWidth?: number;
/** @deprecated use uiZoom */
uiScale?: number;
} }
@@ -205,7 +220,7 @@ export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip", pageStyle: "longstrip",
readingDirection: "ltr", readingDirection: "ltr",
fitMode: "width", fitMode: "width",
maxPageWidth: 900, readerZoom: 1.0,
pageGap: true, pageGap: true,
optimizeContrast: false, optimizeContrast: false,
offsetDoubleSpreads: false, offsetDoubleSpreads: false,
@@ -218,7 +233,7 @@ export const DEFAULT_SETTINGS: Settings = {
chapterSortDir: "desc", chapterSortDir: "desc",
chapterSortMode: "source", chapterSortMode: "source",
chapterPageSize: 25, chapterPageSize: 25,
uiScale: 100, uiZoom: 1.0,
compactSidebar: false, compactSidebar: false,
gpuAcceleration: true, gpuAcceleration: true,
serverUrl: "http://localhost:4567", serverUrl: "http://localhost:4567",
@@ -260,12 +275,14 @@ export const DEFAULT_SETTINGS: Settings = {
// ── Persistence ─────────────────────────────────────────────────────────────── // ── Persistence ───────────────────────────────────────────────────────────────
const STORE_VERSION = 2; const STORE_VERSION = 3;
// Fields reset to their DEFAULT_SETTINGS value on each version bump. // Fields reset to their DEFAULT_SETTINGS value on each version bump.
// Add a key here whenever its default changes meaning between releases. // Add a key here whenever its default changes meaning between releases.
const RESET_ON_UPGRADE: (keyof Settings)[] = [ const RESET_ON_UPGRADE: (keyof Settings)[] = [
"serverBinary", "serverBinary",
"readerZoom",
"uiZoom",
]; ];
function loadPersisted(): any { function loadPersisted(): any {