From 95407422c07e06b0a629531679247125b67511b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 13 May 2026 12:43:07 +0800 Subject: [PATCH] Support IDE-style desktop zoom shortcuts The desktop shell needed predictable zoom controls across macOS, Windows, and Linux without depending on browser defaults. This adds a small app zoom controller that persists a bounded zoom factor, maps the IDE-style primary-modifier shortcuts, and uses native Tauri webview zoom when available with a browser fallback for H5/dev runs. Constraint: Issue #407 requested IDE-style zoom shortcuts across macOS, Windows, and Linux Rejected: Enable Tauri built-in zoom hotkeys only | it would not cover browser/H5 fallback or app-owned persistence consistently Confidence: high Scope-risk: narrow Directive: Keep app zoom bounded and validated before applying persisted localStorage values Tested: bun run verify; browser smoke on http://127.0.0.1:45679/ with Meta+= and Meta+0 Not-tested: Manual Windows/Linux desktop runtime smoke --- desktop/src-tauri/src/lib.rs | 11 +- .../src/hooks/useKeyboardShortcuts.test.tsx | 99 +++++++++++++ desktop/src/hooks/useKeyboardShortcuts.ts | 19 +++ desktop/src/lib/appZoom.test.ts | 79 +++++++++++ desktop/src/lib/appZoom.ts | 130 ++++++++++++++++++ desktop/src/lib/doctorRepair.ts | 2 + desktop/src/lib/persistenceMigrations.test.ts | 17 +++ desktop/src/lib/persistenceMigrations.ts | 10 ++ 8 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 desktop/src/hooks/useKeyboardShortcuts.test.tsx create mode 100644 desktop/src/lib/appZoom.test.ts create mode 100644 desktop/src/lib/appZoom.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e6b16def..6d52f5c2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -837,6 +837,14 @@ fn open_windows_notification_settings() -> Result { open_windows_notification_settings_impl() } +#[tauri::command] +fn set_app_zoom(window: tauri::WebviewWindow, zoom_factor: f64) -> Result<(), String> { + let clamped = zoom_factor.clamp(0.5, 2.0); + window + .set_zoom(clamped) + .map_err(|err| format!("set app zoom: {err}")) +} + #[cfg(target_os = "windows")] fn open_windows_notification_settings_impl() -> Result { StdCommand::new("explorer.exe") @@ -1617,7 +1625,8 @@ pub fn run() { macos_notification_permission_state, macos_request_notification_permission, macos_send_notification, - open_windows_notification_settings + open_windows_notification_settings, + set_app_zoom ]); // macOS: native menu bar (traffic-light overlay style) diff --git a/desktop/src/hooks/useKeyboardShortcuts.test.tsx b/desktop/src/hooks/useKeyboardShortcuts.test.tsx new file mode 100644 index 00000000..807d2b9c --- /dev/null +++ b/desktop/src/hooks/useKeyboardShortcuts.test.tsx @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { APP_ZOOM_STORAGE_KEY } from '../lib/appZoom' +import { useKeyboardShortcuts } from './useKeyboardShortcuts' + +function ShortcutHost() { + useKeyboardShortcuts() + return null +} + +function setNavigatorPlatform(platform: string) { + Object.defineProperty(window.navigator, 'platform', { + configurable: true, + value: platform, + }) +} + +describe('useKeyboardShortcuts app zoom', () => { + beforeEach(() => { + window.localStorage.clear() + document.documentElement.removeAttribute('data-app-zoom-mode') + document.documentElement.removeAttribute('data-app-zoom-percent') + document.documentElement.style.removeProperty('--app-zoom') + document.body.style.removeProperty('zoom') + setNavigatorPlatform('Win32') + }) + + afterEach(() => { + cleanup() + }) + + it('handles Ctrl zoom shortcuts on Windows and Linux style platforms', async () => { + render() + + fireEvent.keyDown(document, { + code: 'Equal', + ctrlKey: true, + key: '=', + }) + + await waitFor(() => { + expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.1') + }) + expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('110') + + fireEvent.keyDown(document, { + code: 'Minus', + ctrlKey: true, + key: '-', + }) + + await waitFor(() => { + expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1') + }) + + fireEvent.keyDown(document, { + code: 'NumpadAdd', + ctrlKey: true, + key: '+', + }) + await waitFor(() => { + expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.1') + }) + + fireEvent.keyDown(document, { + code: 'Digit0', + ctrlKey: true, + key: '0', + }) + + await waitFor(() => { + expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1') + }) + }) + + it('uses Cmd zoom shortcuts on macOS', async () => { + setNavigatorPlatform('MacIntel') + render() + + fireEvent.keyDown(document, { + code: 'Minus', + key: '-', + metaKey: true, + }) + + await waitFor(() => { + expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('0.9') + }) + + fireEvent.keyDown(document, { + code: 'Equal', + ctrlKey: true, + key: '=', + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('0.9') + }) +}) diff --git a/desktop/src/hooks/useKeyboardShortcuts.ts b/desktop/src/hooks/useKeyboardShortcuts.ts index d2178760..c58d2705 100644 --- a/desktop/src/hooks/useKeyboardShortcuts.ts +++ b/desktop/src/hooks/useKeyboardShortcuts.ts @@ -3,6 +3,13 @@ import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { useTabStore } from '../stores/tabStore' import { useUIStore } from '../stores/uiStore' +import { + applyAppZoomLevel, + getAppZoomKeyboardAction, + initializeAppZoom, + nextAppZoomLevel, + readStoredAppZoomLevel, +} from '../lib/appZoom' export function useKeyboardShortcuts() { const setActiveSession = useSessionStore((s) => s.setActiveSession) @@ -20,9 +27,21 @@ export function useKeyboardShortcuts() { chatStateRef.current = chatState const activeTabIdRef = useRef(activeTabId) activeTabIdRef.current = activeTabId + const appZoomLevelRef = useRef(readStoredAppZoomLevel()) useEffect(() => { + void initializeAppZoom() + const handler = (e: KeyboardEvent) => { + const zoomAction = getAppZoomKeyboardAction(e) + if (zoomAction) { + e.preventDefault() + const nextZoom = nextAppZoomLevel(appZoomLevelRef.current, zoomAction) + appZoomLevelRef.current = nextZoom + void applyAppZoomLevel(nextZoom) + return + } + const meta = e.metaKey || e.ctrlKey // Cmd+N — New session diff --git a/desktop/src/lib/appZoom.test.ts b/desktop/src/lib/appZoom.test.ts new file mode 100644 index 00000000..b641183d --- /dev/null +++ b/desktop/src/lib/appZoom.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + APP_ZOOM_STORAGE_KEY, + applyAppZoomLevel, + getAppZoomKeyboardAction, + initializeAppZoom, + nextAppZoomLevel, + normalizeAppZoomLevel, +} from './appZoom' + +describe('appZoom', () => { + beforeEach(() => { + window.localStorage.clear() + document.documentElement.removeAttribute('data-app-zoom-mode') + document.documentElement.removeAttribute('data-app-zoom-percent') + document.documentElement.style.removeProperty('--app-zoom') + document.body.style.removeProperty('zoom') + }) + + it('normalizes, clamps, and steps app zoom levels', () => { + expect(normalizeAppZoomLevel('1.25')).toBe(1.25) + expect(normalizeAppZoomLevel('bad')).toBe(1) + expect(normalizeAppZoomLevel(4)).toBe(2) + expect(normalizeAppZoomLevel(0.1)).toBe(0.5) + + expect(nextAppZoomLevel(1, 'in')).toBe(1.1) + expect(nextAppZoomLevel(1, 'out')).toBe(0.9) + expect(nextAppZoomLevel(1.7, 'reset')).toBe(1) + }) + + it('applies browser fallback zoom and preserves valid persisted zoom', async () => { + window.localStorage.setItem(APP_ZOOM_STORAGE_KEY, '1.2') + + await initializeAppZoom() + + expect(document.documentElement.getAttribute('data-app-zoom-mode')).toBe('css') + expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('120') + expect(document.documentElement.style.getPropertyValue('--app-zoom')).toBe('1.2') + expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.2') + }) + + it('persists app zoom changes', async () => { + await applyAppZoomLevel(1.3) + + expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.3') + expect(document.documentElement.style.getPropertyValue('--app-zoom')).toBe('1.3') + }) + + it('maps IDE-style zoom shortcuts by platform', () => { + expect(getAppZoomKeyboardAction({ + altKey: false, + code: 'Equal', + ctrlKey: false, + key: '=', + metaKey: true, + } as KeyboardEvent, 'MacIntel')).toBe('in') + expect(getAppZoomKeyboardAction({ + altKey: false, + code: 'Minus', + ctrlKey: true, + key: '-', + metaKey: false, + } as KeyboardEvent, 'Win32')).toBe('out') + expect(getAppZoomKeyboardAction({ + altKey: false, + code: 'Numpad0', + ctrlKey: true, + key: '0', + metaKey: false, + } as KeyboardEvent, 'Linux x86_64')).toBe('reset') + expect(getAppZoomKeyboardAction({ + altKey: true, + code: 'Equal', + ctrlKey: true, + key: '=', + metaKey: false, + } as KeyboardEvent, 'Win32')).toBeNull() + }) +}) diff --git a/desktop/src/lib/appZoom.ts b/desktop/src/lib/appZoom.ts new file mode 100644 index 00000000..b8ac9d02 --- /dev/null +++ b/desktop/src/lib/appZoom.ts @@ -0,0 +1,130 @@ +export const APP_ZOOM_STORAGE_KEY = 'cc-haha-app-zoom' +export const DEFAULT_APP_ZOOM = 1 +export const MIN_APP_ZOOM = 0.5 +export const MAX_APP_ZOOM = 2 +export const APP_ZOOM_STEP = 0.1 + +export type AppZoomAction = 'in' | 'out' | 'reset' + +type StorageLike = Pick +type KeyboardShortcutInput = Pick + +function isTauriRuntime() { + return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) +} + +function getDefaultStorage(): StorageLike | null { + try { + return globalThis.localStorage ?? null + } catch { + return null + } +} + +function roundZoom(value: number): number { + return Math.round(value * 100) / 100 +} + +export function normalizeAppZoomLevel(value: unknown): number { + const numeric = typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : DEFAULT_APP_ZOOM + if (!Number.isFinite(numeric)) return DEFAULT_APP_ZOOM + return roundZoom(Math.min(Math.max(numeric, MIN_APP_ZOOM), MAX_APP_ZOOM)) +} + +export function isValidStoredAppZoomLevel(value: string | null): boolean { + if (value === null) return true + if (value.trim() === '') return false + const numeric = Number(value) + return Number.isFinite(numeric) && numeric >= MIN_APP_ZOOM && numeric <= MAX_APP_ZOOM +} + +export function readStoredAppZoomLevel(storage: StorageLike | null = getDefaultStorage()): number { + if (!storage) return DEFAULT_APP_ZOOM + try { + return normalizeAppZoomLevel(storage.getItem(APP_ZOOM_STORAGE_KEY)) + } catch { + return DEFAULT_APP_ZOOM + } +} + +function persistAppZoomLevel(level: number, storage: StorageLike | null = getDefaultStorage()) { + if (!storage) return + try { + storage.setItem(APP_ZOOM_STORAGE_KEY, String(level)) + } catch { + // localStorage can be unavailable in hardened browser contexts. + } +} + +function setCssAppZoomMode(level: number, mode: 'css' | 'native') { + if (typeof document === 'undefined') return + document.documentElement.style.setProperty('--app-zoom', String(level)) + document.documentElement.setAttribute('data-app-zoom-mode', mode) + document.documentElement.setAttribute('data-app-zoom-percent', String(Math.round(level * 100))) + document.body?.style.setProperty('zoom', mode === 'css' ? String(level) : '') +} + +async function trySetNativeAppZoom(level: number): Promise { + if (!isTauriRuntime()) return false + try { + const { invoke } = await import('@tauri-apps/api/core') + await invoke('set_app_zoom', { zoomFactor: level }) + setCssAppZoomMode(level, 'native') + return true + } catch { + return false + } +} + +export async function applyAppZoomLevel( + input: number, + options: { persist?: boolean } = {}, +): Promise { + const level = normalizeAppZoomLevel(input) + if (options.persist !== false) { + persistAppZoomLevel(level) + } + + const nativeApplied = await trySetNativeAppZoom(level) + if (!nativeApplied) { + setCssAppZoomMode(level, 'css') + } + + return level +} + +export function nextAppZoomLevel(current: number, action: AppZoomAction): number { + if (action === 'reset') return DEFAULT_APP_ZOOM + const delta = action === 'in' ? APP_ZOOM_STEP : -APP_ZOOM_STEP + return normalizeAppZoomLevel(roundZoom(current + delta)) +} + +export function getAppZoomKeyboardAction( + event: KeyboardShortcutInput, + platform: string = typeof navigator === 'undefined' ? '' : navigator.platform, +): AppZoomAction | null { + if (event.altKey) return null + const isMac = /mac/i.test(platform) + const hasPrimaryModifier = isMac ? event.metaKey : event.ctrlKey + if (!hasPrimaryModifier) return null + + const key = event.key.toLowerCase() + if (key === '+' || key === '=' || event.code === 'Equal' || event.code === 'NumpadAdd') { + return 'in' + } + if (key === '-' || event.code === 'Minus' || event.code === 'NumpadSubtract') { + return 'out' + } + if (key === '0' || event.code === 'Digit0' || event.code === 'Numpad0') { + return 'reset' + } + return null +} + +export function initializeAppZoom(): Promise { + return applyAppZoomLevel(readStoredAppZoomLevel(), { persist: false }) +} diff --git a/desktop/src/lib/doctorRepair.ts b/desktop/src/lib/doctorRepair.ts index 4d9bc6db..55b47907 100644 --- a/desktop/src/lib/doctorRepair.ts +++ b/desktop/src/lib/doctorRepair.ts @@ -1,4 +1,5 @@ import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor' +import { APP_ZOOM_STORAGE_KEY } from './appZoom' import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations' export const SAFE_DOCTOR_STORAGE_KEYS = [ @@ -6,6 +7,7 @@ export const SAFE_DOCTOR_STORAGE_KEYS = [ 'cc-haha-session-runtime', 'cc-haha-theme', 'cc-haha-locale', + APP_ZOOM_STORAGE_KEY, DESKTOP_PERSISTENCE_VERSION_KEY, ] as const diff --git a/desktop/src/lib/persistenceMigrations.test.ts b/desktop/src/lib/persistenceMigrations.test.ts index a461ed91..397f8bc6 100644 --- a/desktop/src/lib/persistenceMigrations.test.ts +++ b/desktop/src/lib/persistenceMigrations.test.ts @@ -65,6 +65,22 @@ describe('desktop persistence migrations', () => { expect(window.localStorage.getItem('cc-haha-theme')).toBe('white') }) + test('preserves valid app zoom and removes invalid app zoom values', () => { + window.localStorage.setItem('cc-haha-app-zoom', '1.2') + + const validReport = runDesktopPersistenceMigrations() + + expect(validReport.migratedKeys).not.toContain('cc-haha-app-zoom') + expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.2') + + window.localStorage.setItem('cc-haha-app-zoom', '4') + + const invalidReport = runDesktopPersistenceMigrations() + + expect(invalidReport.migratedKeys).toContain('cc-haha-app-zoom') + expect(window.localStorage.getItem('cc-haha-app-zoom')).toBeNull() + }) + test('does not throw if schema version persistence is blocked', () => { const storage = { getItem: window.localStorage.getItem.bind(window.localStorage), @@ -101,6 +117,7 @@ describe('desktop persistence migrations', () => { 'cc-haha-session-runtime', 'cc-haha-theme', 'cc-haha-locale', + 'cc-haha-app-zoom', DESKTOP_PERSISTENCE_VERSION_KEY, ])) }) diff --git a/desktop/src/lib/persistenceMigrations.ts b/desktop/src/lib/persistenceMigrations.ts index 5c889ebd..f10a77b9 100644 --- a/desktop/src/lib/persistenceMigrations.ts +++ b/desktop/src/lib/persistenceMigrations.ts @@ -1,4 +1,5 @@ import { THEME_MODES } from '../types/settings' +import { APP_ZOOM_STORAGE_KEY, isValidStoredAppZoomLevel } from './appZoom' export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1 export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion' @@ -114,6 +115,14 @@ function normalizeEnumKey( } } +function normalizeAppZoomKey(storage: StorageLike, report: DesktopMigrationReport): void { + const value = storage.getItem(APP_ZOOM_STORAGE_KEY) + if (!isValidStoredAppZoomLevel(value)) { + storage.removeItem(APP_ZOOM_STORAGE_KEY) + report.migratedKeys.push(APP_ZOOM_STORAGE_KEY) + } +} + function runMigrationStep( report: DesktopMigrationReport, fallbackKey: string, @@ -142,6 +151,7 @@ export function runDesktopPersistenceMigrations(storage: StorageLike | null = ge runMigrationStep(report, SESSION_RUNTIME_STORAGE_KEY, () => migrateSessionRuntime(storage, report)) runMigrationStep(report, THEME_STORAGE_KEY, () => normalizeEnumKey(storage, THEME_STORAGE_KEY, [...THEME_MODES], report)) runMigrationStep(report, LOCALE_STORAGE_KEY, () => normalizeEnumKey(storage, LOCALE_STORAGE_KEY, ['zh', 'en'], report)) + runMigrationStep(report, APP_ZOOM_STORAGE_KEY, () => normalizeAppZoomKey(storage, report)) try { storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION)) } catch {