From 4d3dfdbec6874b7d17f6cfe6a1d3194b821167cc Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Wed, 29 Apr 2026 21:07:53 -0500 Subject: [PATCH] Feat: Settings Reset, Data Clear, Date Fixes (#56) --- src-tauri/src/commands/backup.rs | 38 ++- src-tauri/src/commands/system.rs | 48 ++++ src-tauri/src/lib.rs | 6 +- src/core/backup.ts | 264 ++++++++++++++++-- src/core/persistence/persist.ts | 21 ++ src/core/ui/touchscreen.ts | 56 ++-- .../home/components/ActivityHeatmap.svelte | 8 +- src/features/reader/lib/pinchZoom.ts | 77 ++--- .../settings/sections/StorageSettings.svelte | 222 ++++++++++++--- src/store/state.svelte.ts | 10 +- 10 files changed, 586 insertions(+), 164 deletions(-) diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index 2f9932d..9808db7 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -16,46 +16,45 @@ fn unix_now() -> u64 { } #[tauri::command] -pub async fn export_app_data(app: tauri::AppHandle, json: String) -> Result { +pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec) -> Result<(), String> { use tauri_plugin_dialog::DialogExt; - let filename = format!("moku-backup-{}.json", unix_now()); + let filename = format!("moku-backup-{}.zip", unix_now()); let path = app .dialog() .file() .set_title("Save Moku app data backup") .set_file_name(&filename) + .add_filter("Moku Backup", &["zip"]) .blocking_save_file() .ok_or("Cancelled")?; - let dest = PathBuf::from(path.to_string()); - std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?; - - Ok(dest.to_string_lossy().into_owned()) + std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string()) } #[tauri::command] -pub async fn import_app_data(app: tauri::AppHandle) -> Result { +pub async fn import_app_data(app: tauri::AppHandle) -> Result, String> { use tauri_plugin_dialog::DialogExt; let path = app .dialog() .file() .set_title("Open Moku app data backup") + .add_filter("Moku Backup", &["zip"]) .blocking_pick_file() .ok_or("Cancelled")?; - std::fs::read_to_string(PathBuf::from(path.to_string())).map_err(|e| e.to_string()) + std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string()) } #[tauri::command] -pub fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> { +pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec) -> Result<(), String> { let dir = backup_dir(&app); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let dest = dir.join(format!("auto-moku-backup-{}.json", unix_now())); - std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?; + let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now())); + std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?; let mut entries: Vec<_> = std::fs::read_dir(&dir) .map_err(|e| e.to_string())? @@ -80,3 +79,20 @@ pub fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), S pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String { backup_dir(&app).to_string_lossy().into_owned() } + +#[tauri::command] +pub fn read_store_files(app: tauri::AppHandle, names: Vec) -> Vec<(String, String)> { + let base = app + .path() + .app_local_data_dir() + .unwrap_or_else(|_| PathBuf::from(".")); + + names + .into_iter() + .map(|name| { + let content = std::fs::read_to_string(base.join(&name)) + .unwrap_or_else(|_| "{}".to_string()); + (name, content) + }) + .collect() +} \ No newline at end of file diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 73041c3..e0d7659 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -50,3 +50,51 @@ pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option { .blocking_pick_folder() .map(|p| p.to_string()) } + +#[tauri::command] +pub fn exit_app(app: tauri::AppHandle) { + app.exit(0); +} + +#[tauri::command] +pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> { + use tauri::Manager; + let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?; + if cache_dir.exists() { + std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?; + std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[tauri::command] +pub fn clear_suwayomi_cache() -> Result<(), String> { + use crate::server::resolve::suwayomi_data_dir; + let data_dir = suwayomi_data_dir(); + for dir in &["cache", "bin/kcef", "cache/kcef"] { + let p = data_dir.join(dir); + if p.exists() { + std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +#[tauri::command] +pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> { + use crate::server::resolve::suwayomi_data_dir; + + crate::server::kill_tachidesk(&app); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let data_dir = suwayomi_data_dir(); + for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] { + let p = data_dir.join(entry_name); + if p.is_dir() { + std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?; + } else if p.exists() { + std::fs::remove_file(&p).map_err(|e| format!("{entry_name}: {e}"))?; + } + } + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db0ae22..78dad53 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,6 +29,10 @@ pub fn run() { commands::server::kill_server, commands::system::get_platform_ui_scale, commands::system::restart_app, + commands::system::exit_app, + commands::system::clear_moku_cache, + commands::system::clear_suwayomi_cache, + commands::system::reset_suwayomi_data, commands::system::open_path, commands::system::pick_downloads_folder, commands::backup::export_app_data, @@ -46,4 +50,4 @@ pub fn run() { }) .run(tauri::generate_context!()) .expect("error while running moku"); -} +} \ No newline at end of file diff --git a/src/core/backup.ts b/src/core/backup.ts index fa2e9e5..81a6233 100644 --- a/src/core/backup.ts +++ b/src/core/backup.ts @@ -1,38 +1,256 @@ import { invoke } from "@tauri-apps/api/core"; +import { + persistSettings, + persistLibrary, + persistUpdates, +} from "@core/persistence/persist"; -function collectAppData(): Record { - const data: Record = {}; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key !== null) data[key] = localStorage.getItem(key) ?? ""; - } - return data; -} - -function applyAppData(data: Record): void { - localStorage.clear(); - for (const [key, value] of Object.entries(data)) { - localStorage.setItem(key, value); - } -} +const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const; export async function exportAppData(): Promise { - const json = JSON.stringify(collectAppData(), null, 2); - await invoke("export_app_data", { json }); + const entries: [string, string][] = await invoke("read_store_files", { + names: [...STORE_FILES], + }); + + const zip = buildZip( + entries.map(([name, content]) => ({ + name, + bytes: new TextEncoder().encode(content), + })) + ); + + await invoke("export_app_data", { bytes: Array.from(zip) }); } export async function importAppData(): Promise { - const json = await invoke("import_app_data"); - const data: Record = JSON.parse(json); - applyAppData(data); - location.reload(); + const raw: number[] = await invoke("import_app_data"); + const files = parseZip(new Uint8Array(raw)); + + const decode = (name: string) => { + const bytes = files.get(name); + if (!bytes) throw new Error(`Backup is missing ${name}`); + return JSON.parse(new TextDecoder().decode(bytes)); + }; + + const s = decode("settings.json"); + const l = decode("library.json"); + const u = decode("updates.json"); + + await Promise.all([ + persistSettings({ + settings: s.settings ?? null, + storeVersion: s.storeVersion ?? 1, + }), + persistLibrary({ + history: l.history ?? [], + bookmarks: l.bookmarks ?? [], + markers: l.markers ?? [], + readLog: l.readLog ?? [], + readingStats: l.readingStats ?? null, + dailyReadCounts: l.dailyReadCounts ?? {}, + }), + persistUpdates({ + libraryUpdates: u.libraryUpdates ?? [], + lastLibraryRefresh: u.lastLibraryRefresh ?? 0, + acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [], + }), + ]); + + await showExitModal(); + invoke("exit_app"); +} + +function showExitModal(): Promise { + return new Promise(resolve => { + const backdrop = document.createElement("div"); + backdrop.className = "s-backdrop"; + backdrop.style.cssText = "z-index:99999"; + + const modal = document.createElement("div"); + modal.style.cssText = [ + "background:var(--bg-surface)", + "border:1px solid var(--border-base)", + "border-radius:var(--radius-2xl)", + "box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)", + "width:min(400px,calc(100vw - 40px))", + "display:flex", + "flex-direction:column", + "overflow:hidden", + "animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both", + ].join(";"); + + const header = document.createElement("div"); + header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)"; + + const title = document.createElement("p"); + title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em"; + title.textContent = "Import complete"; + header.appendChild(title); + + const body = document.createElement("div"); + body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)"; + + const sub = document.createElement("p"); + sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)"; + sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data."; + + const counter = document.createElement("p"); + counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)"; + counter.textContent = "Closing in 3…"; + + body.append(sub, counter); + + const footer = document.createElement("div"); + footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end"; + + const btn = document.createElement("button"); + btn.className = "s-btn s-btn-danger"; + btn.textContent = "Close now"; + + footer.appendChild(btn); + modal.append(header, body, footer); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + let secs = 3; + const tick = setInterval(() => { + secs--; + counter.textContent = secs > 0 ? `Closing in ${secs}…` : "Closing…"; + if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); } + }, 1000); + + btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); }); + }); } export async function autoBackupAppData(): Promise { try { - const json = JSON.stringify(collectAppData()); - await invoke("auto_backup_app_data", { json }); + const entries: [string, string][] = await invoke("read_store_files", { + names: [...STORE_FILES], + }); + const zip = buildZip( + entries.map(([name, content]) => ({ + name, + bytes: new TextEncoder().encode(content), + })) + ); + await invoke("auto_backup_app_data", { bytes: Array.from(zip) }); } catch (e) { console.warn("[moku] auto-backup failed:", e); } +} + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array { + const buf = new ArrayBuffer(30 + name.byteLength); + const v = new DataView(buf); + v.setUint32(0, 0x04034b50, true); + v.setUint16(4, 20, true); + v.setUint16(6, 0, true); + v.setUint16(8, 0, true); + v.setUint16(10, 0, true); + v.setUint16(12, 0, true); + v.setUint32(14, crc32(data), true); + v.setUint32(18, data.byteLength, true); + v.setUint32(22, data.byteLength, true); + v.setUint16(26, name.byteLength, true); + v.setUint16(28, 0, true); + new Uint8Array(buf).set(name, 30); + return new Uint8Array(buf); +} + +function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array { + const buf = new ArrayBuffer(46 + name.byteLength); + const v = new DataView(buf); + v.setUint32(0, 0x02014b50, true); + v.setUint16(4, 20, true); + v.setUint16(6, 20, true); + v.setUint16(8, 0, true); + v.setUint16(10, 0, true); + v.setUint16(12, 0, true); + v.setUint16(14, 0, true); + v.setUint32(16, crc32(data), true); + v.setUint32(20, data.byteLength, true); + v.setUint32(24, data.byteLength, true); + v.setUint16(28, name.byteLength, true); + v.setUint16(30, 0, true); + v.setUint16(32, 0, true); + v.setUint16(34, 0, true); + v.setUint16(36, 0, true); + v.setUint32(38, 0, true); + v.setUint32(42, offset, true); + new Uint8Array(buf).set(name, 46); + return new Uint8Array(buf); +} + +function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array { + const buf = new ArrayBuffer(22); + const v = new DataView(buf); + v.setUint32(0, 0x06054b50, true); + v.setUint16(4, 0, true); + v.setUint16(6, 0, true); + v.setUint16(8, count, true); + v.setUint16(10, count, true); + v.setUint32(12, cdSize, true); + v.setUint32(16, cdOffset, true); + v.setUint16(20, 0, true); + return new Uint8Array(buf); +} + +function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array { + const enc = new TextEncoder(); + const parts: Uint8Array[] = []; + const offsets: number[] = []; + let pos = 0; + + for (const { name, bytes } of files) { + const nameBytes = enc.encode(name); + const lh = localHeader(nameBytes, bytes); + offsets.push(pos); + parts.push(lh, bytes); + pos += lh.byteLength + bytes.byteLength; + } + + const cdParts = files.map(({ name, bytes }, i) => + centralHeader(enc.encode(name), bytes, offsets[i]) + ); + const cd = concat(cdParts); + + return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]); +} + +function parseZip(data: Uint8Array): Map { + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const files = new Map(); + let pos = 0; + + while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) { + const fnLen = view.getUint16(pos + 26, true); + const exLen = view.getUint16(pos + 28, true); + const cSize = view.getUint32(pos + 18, true); + const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen)); + const start = pos + 30 + fnLen + exLen; + files.set(name, data.subarray(start, start + cSize)); + pos = start + cSize; + } + + return files; +} + +function concat(arrays: Uint8Array[]): Uint8Array { + const total = arrays.reduce((n, a) => n + a.byteLength, 0); + const out = new Uint8Array(total); + let pos = 0; + for (const a of arrays) { out.set(a, pos); pos += a.byteLength; } + return out; } \ No newline at end of file diff --git a/src/core/persistence/persist.ts b/src/core/persistence/persist.ts index 6923c31..30feca9 100644 --- a/src/core/persistence/persist.ts +++ b/src/core/persistence/persist.ts @@ -3,6 +3,7 @@ import { LazyStore } from "@tauri-apps/plugin-store"; const settingsStore = new LazyStore("settings.json", { autoSave: false }); const libraryStore = new LazyStore("library.json", { autoSave: false }); const updatesStore = new LazyStore("updates.json", { autoSave: false }); +const backupsStore = new LazyStore("backups.json", { autoSave: false }); export interface PersistedData { settings: any; @@ -133,3 +134,23 @@ export async function persistUpdates(data: { ]); await updatesStore.save(); } + +export interface BackupEntry { url: string; name: string; } + +export async function loadBackups(): Promise { + const fromStore = await backupsStore.get("backupList"); + if (fromStore) return fromStore; + try { + const raw = localStorage.getItem("moku_backups"); + if (!raw) return []; + const migrated: BackupEntry[] = JSON.parse(raw); + await persistBackups(migrated); + localStorage.removeItem("moku_backups"); + return migrated; + } catch { return []; } +} + +export async function persistBackups(list: BackupEntry[]): Promise { + await backupsStore.set("backupList", list); + await backupsStore.save(); +} \ No newline at end of file diff --git a/src/core/ui/touchscreen.ts b/src/core/ui/touchscreen.ts index e1203a6..72731fd 100644 --- a/src/core/ui/touchscreen.ts +++ b/src/core/ui/touchscreen.ts @@ -28,9 +28,6 @@ export function longPress(node: HTMLElement, opts: LongPressOptions) { node.addEventListener("pointerleave", cancel); node.addEventListener("pointercancel",cancel); - function suppressClick(e: MouseEvent) { if (fired) { fired = false; e.preventDefault(); e.stopPropagation(); } } - node.addEventListener("click", suppressClick, true); - return { get fired() { return fired; }, destroy() { @@ -40,7 +37,6 @@ export function longPress(node: HTMLElement, opts: LongPressOptions) { node.removeEventListener("pointerup", cancel); node.removeEventListener("pointerleave", cancel); node.removeEventListener("pointercancel",cancel); - node.removeEventListener("click", suppressClick, true); }, }; } @@ -134,53 +130,69 @@ export interface PinchOptions { onPinchEnd?: (scale: number) => void; } -export function pinch(node: HTMLElement, opts: PinchOptions) { +export interface PinchGestureOptions { + onPinch: (scale: number, origin: { x: number; y: number }) => void; + onPinchEnd?: (scale: number) => void; +} + +export interface PinchGesture { + onPointerDown: (e: PointerEvent) => void; + onPointerMove: (e: PointerEvent) => void; + onPointerUp: (e: PointerEvent) => void; + isPinching: () => boolean; +} + +export function createPinchGesture(opts: PinchGestureOptions): PinchGesture { const { onPinch, onPinchEnd } = opts; const pointers = new Map(); - let initDist = 0, initMid = { x: 0, y: 0 }; + let initDist = 0; - function dist(a: PointerEvent, b: PointerEvent) { + function pdist(a: PointerEvent, b: PointerEvent) { const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; return Math.sqrt(dx * dx + dy * dy); } - function mid(a: PointerEvent, b: PointerEvent) { + function pmid(a: PointerEvent, b: PointerEvent) { return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; } - function down(e: PointerEvent) { + function onPointerDown(e: PointerEvent) { pointers.set(e.pointerId, e); - node.setPointerCapture(e.pointerId); if (pointers.size === 2) { const [a, b] = [...pointers.values()]; - initDist = dist(a, b); - initMid = mid(a, b); + initDist = pdist(a, b); } } - function move(e: PointerEvent) { + function onPointerMove(e: PointerEvent) { if (!pointers.has(e.pointerId)) return; pointers.set(e.pointerId, e); if (pointers.size !== 2 || initDist === 0) return; const [a, b] = [...pointers.values()]; - onPinch(dist(a, b) / initDist, mid(a, b)); + onPinch(pdist(a, b) / initDist, pmid(a, b)); } - function up(e: PointerEvent) { + function onPointerUp(e: PointerEvent) { if (pointers.size === 2 && onPinchEnd) { const [a, b] = [...pointers.values()]; - onPinchEnd(dist(a, b) / initDist); + onPinchEnd(pdist(a, b) / initDist); } pointers.delete(e.pointerId); initDist = 0; } + return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 }; +} + +export function pinch(node: HTMLElement, opts: PinchOptions) { + const gesture = createPinchGesture(opts); + function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); } node.addEventListener("pointerdown", down); - node.addEventListener("pointermove", move); - node.addEventListener("pointerup", up); - node.addEventListener("pointercancel", up); + node.addEventListener("pointermove", gesture.onPointerMove); + node.addEventListener("pointerup", gesture.onPointerUp); + node.addEventListener("pointercancel", gesture.onPointerUp); return { destroy() { node.removeEventListener("pointerdown", down); - node.removeEventListener("pointermove", move); - node.removeEventListener("pointerup", up); - node.removeEventListener("pointercancel", up); + node.removeEventListener("pointermove", gesture.onPointerMove); + node.removeEventListener("pointerup", gesture.onPointerUp); + node.removeEventListener("pointercancel", gesture.onPointerUp); }}; } diff --git a/src/features/home/components/ActivityHeatmap.svelte b/src/features/home/components/ActivityHeatmap.svelte index 34d82cd..4779c1f 100644 --- a/src/features/home/components/ActivityHeatmap.svelte +++ b/src/features/home/components/ActivityHeatmap.svelte @@ -29,6 +29,10 @@ return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }); } + function localDateStr(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + } + let wrapEl: HTMLElement; let cellSize = $state(12); let numWeeks = $state(26); @@ -55,7 +59,7 @@ const visibleWeeks = $derived((() => { const today = new Date(); today.setHours(0, 0, 0, 0); - const todayStr = today.toISOString().slice(0, 10); + const todayStr = localDateStr(today); const endDow = today.getDay(); // 0=Sun ... 6=Sat const weekEnd = new Date(today); weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday @@ -66,7 +70,7 @@ for (let di = 0; di < 7; di++) { const d = new Date(weekEnd); d.setDate(d.getDate() - wi * 7 - (6 - di)); - const dateStr = d.toISOString().slice(0, 10); + const dateStr = localDateStr(d); week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today }); } weeks.push(week); diff --git a/src/features/reader/lib/pinchZoom.ts b/src/features/reader/lib/pinchZoom.ts index 3afe078..0130805 100644 --- a/src/features/reader/lib/pinchZoom.ts +++ b/src/features/reader/lib/pinchZoom.ts @@ -1,3 +1,4 @@ +import { createPinchGesture } from "@core/ui/touchscreen"; import { clampZoom } from "./zoomHelpers"; import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte"; @@ -10,69 +11,33 @@ export interface PinchTrackerOptions { isLongstrip: () => boolean; } -export interface PinchTracker { - onPointerDown: (e: PointerEvent) => void; - onPointerMove: (e: PointerEvent) => void; - onPointerUp: (e: PointerEvent) => void; - isPinching: () => boolean; -} +export type { PinchGesture as PinchTracker } from "@core/ui/touchscreen"; const INSPECT_ZOOM_MAX = 8; -export function createPinchTracker(opts: PinchTrackerOptions): PinchTracker { - const pointers = new Map(); - let startDist = 0; +export function createPinchTracker(opts: PinchTrackerOptions) { let startZoom = 0; let startInspect = 0; - let pinching = false; - function dist(a: { x: number; y: number }, b: { x: number; y: number }): number { - return Math.hypot(b.x - a.x, b.y - a.y); - } - - function onPointerDown(e: PointerEvent) { - pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (pointers.size === 2) { - const [a, b] = [...pointers.values()]; - startDist = dist(a, b); - startZoom = opts.getZoom(); - startInspect = opts.getInspectScale(); - pinching = true; - } - } - - function onPointerMove(e: PointerEvent) { - if (!pinching || !pointers.has(e.pointerId)) return; - pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (pointers.size < 2) return; - - const [a, b] = [...pointers.values()]; - const current = dist(a, b); - if (startDist === 0) return; - const ratio = current / startDist; - - if (opts.isLongstrip()) { - opts.setZoom(clampZoom(startZoom * ratio)); - } else { - const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * ratio)); - if (next !== opts.getInspectScale()) { - if (next === 1) opts.resetInspectPan(); - opts.setInspectScale(next); + return createPinchGesture({ + onPinch(scale) { + if (startZoom === 0) { + startZoom = opts.getZoom(); + startInspect = opts.getInspectScale(); } - } - } - - function onPointerUp(e: PointerEvent) { - pointers.delete(e.pointerId); - if (pointers.size < 2) { - pinching = false; - startDist = 0; + if (opts.isLongstrip()) { + opts.setZoom(clampZoom(startZoom * scale)); + } else { + const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * scale)); + if (next !== opts.getInspectScale()) { + if (next === 1) opts.resetInspectPan(); + opts.setInspectScale(next); + } + } + }, + onPinchEnd() { startZoom = 0; startInspect = 0; - } - } - - function isPinching() { return pinching; } - - return { onPointerDown, onPointerMove, onPointerUp, isPinching }; + }, + }); } \ No newline at end of file diff --git a/src/features/settings/sections/StorageSettings.svelte b/src/features/settings/sections/StorageSettings.svelte index 07678a3..8e1eb67 100644 --- a/src/features/settings/sections/StorageSettings.svelte +++ b/src/features/settings/sections/StorageSettings.svelte @@ -1,14 +1,111 @@