mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Unify desktop zoom controls
PR #428 added a General Settings zoom slider after the desktop shortcut work already introduced a native-first app zoom controller. Keeping both paths would create double scaling and stale UI state, so the slider now routes through the existing controller and store state while the app shell no longer applies a second CSS zoom. Constraint: UI zoom is device-local display state and should not be written into shared user settings. Rejected: Keep cc-haha-ui-zoom plus AppShell style.zoom | it conflicts with shortcut zoom and multiplies visual scale. Rejected: Persist zoom through /api/settings/user | it would sync display-specific state across machines. Confidence: high Scope-risk: moderate Directive: Keep app zoom behind desktop/src/lib/appZoom.ts; do not add another storage key or DOM zoom application point without migration and shortcut sync tests. Tested: cd desktop && bun run test -- --run src/lib/appZoom.test.ts src/hooks/useKeyboardShortcuts.test.tsx src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx src/lib/persistenceMigrations.test.ts src/lib/doctorRepair.test.ts Tested: bun run check:desktop Not-tested: Real Windows/Linux desktop runtime smoke.
This commit is contained in:
parent
1503fd0007
commit
4b62d354d6
@ -144,6 +144,7 @@ describe('Settings > General tab', () => {
|
||||
skipWebFetchPreflight: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
responseLanguage: '',
|
||||
uiZoom: 1,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
h5Access: {
|
||||
enabled: false,
|
||||
@ -167,6 +168,9 @@ describe('Settings > General tab', () => {
|
||||
setResponseLanguage: vi.fn().mockImplementation(async (language: string) => {
|
||||
useSettingsStore.setState({ responseLanguage: language })
|
||||
}),
|
||||
setUiZoom: vi.fn().mockImplementation((uiZoom: number) => {
|
||||
useSettingsStore.setState({ uiZoom })
|
||||
}),
|
||||
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
|
||||
useSettingsStore.setState({ webSearch })
|
||||
}),
|
||||
@ -251,6 +255,26 @@ describe('Settings > General tab', () => {
|
||||
expect(screen.getByRole('button', { name: 'Warm Classic' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('updates the shared UI zoom setting from the General display control', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText('UI Zoom'), {
|
||||
target: { value: '1.25', valueAsNumber: 1.25 },
|
||||
})
|
||||
})
|
||||
|
||||
expect(useSettingsStore.getState().setUiZoom).toHaveBeenCalledWith(1.25)
|
||||
expect(screen.getByText('125%')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset UI zoom to 100%' }))
|
||||
})
|
||||
|
||||
expect(useSettingsStore.getState().setUiZoom).toHaveBeenLastCalledWith(1)
|
||||
})
|
||||
|
||||
it('opens the Token usage tab from Settings navigation above Diagnostics', () => {
|
||||
render(<Settings />)
|
||||
|
||||
|
||||
@ -28,7 +28,6 @@ function isChatTab(tab: Tab | undefined) {
|
||||
|
||||
export function AppShell() {
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
const uiZoom = useSettingsStore((s) => s.uiZoom)
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
|
||||
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen)
|
||||
@ -195,7 +194,7 @@ export function AppShell() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`} style={{ zoom: uiZoom }}>
|
||||
<div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`}>
|
||||
{isMobileShell && effectiveSidebarOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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 { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useKeyboardShortcuts } from './useKeyboardShortcuts'
|
||||
|
||||
function ShortcutHost() {
|
||||
@ -22,6 +23,7 @@ describe('useKeyboardShortcuts app zoom', () => {
|
||||
document.documentElement.removeAttribute('data-app-zoom-percent')
|
||||
document.documentElement.style.removeProperty('--app-zoom')
|
||||
document.body.style.removeProperty('zoom')
|
||||
useSettingsStore.setState({ uiZoom: 1 })
|
||||
setNavigatorPlatform('Win32')
|
||||
})
|
||||
|
||||
@ -41,6 +43,7 @@ describe('useKeyboardShortcuts app zoom', () => {
|
||||
await waitFor(() => {
|
||||
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.1')
|
||||
})
|
||||
expect(useSettingsStore.getState().uiZoom).toBe(1.1)
|
||||
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('110')
|
||||
|
||||
fireEvent.keyDown(document, {
|
||||
@ -52,6 +55,7 @@ describe('useKeyboardShortcuts app zoom', () => {
|
||||
await waitFor(() => {
|
||||
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1')
|
||||
})
|
||||
expect(useSettingsStore.getState().uiZoom).toBe(1)
|
||||
|
||||
fireEvent.keyDown(document, {
|
||||
code: 'NumpadAdd',
|
||||
|
||||
@ -4,12 +4,10 @@ import { useChatStore } from '../stores/chatStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import {
|
||||
applyAppZoomLevel,
|
||||
getAppZoomKeyboardAction,
|
||||
initializeAppZoom,
|
||||
nextAppZoomLevel,
|
||||
readStoredAppZoomLevel,
|
||||
} from '../lib/appZoom'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const setActiveSession = useSessionStore((s) => s.setActiveSession)
|
||||
@ -20,6 +18,8 @@ export function useKeyboardShortcuts() {
|
||||
const stopGeneration = useChatStore((s) => s.stopGeneration)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle')
|
||||
const uiZoom = useSettingsStore((s) => s.uiZoom)
|
||||
const setUiZoom = useSettingsStore((s) => s.setUiZoom)
|
||||
|
||||
const activeModalRef = useRef(activeModal)
|
||||
activeModalRef.current = activeModal
|
||||
@ -27,18 +27,17 @@ export function useKeyboardShortcuts() {
|
||||
chatStateRef.current = chatState
|
||||
const activeTabIdRef = useRef(activeTabId)
|
||||
activeTabIdRef.current = activeTabId
|
||||
const appZoomLevelRef = useRef(readStoredAppZoomLevel())
|
||||
const appZoomLevelRef = useRef(uiZoom)
|
||||
appZoomLevelRef.current = uiZoom
|
||||
|
||||
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)
|
||||
setUiZoom(nextZoom)
|
||||
return
|
||||
}
|
||||
|
||||
@ -80,5 +79,5 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [closeModal, setActiveSession, setActiveView, setSidebarOpen, stopGeneration])
|
||||
}, [closeModal, setActiveSession, setActiveView, setSidebarOpen, setUiZoom, stopGeneration])
|
||||
}
|
||||
|
||||
@ -768,7 +768,8 @@ export const en = {
|
||||
'settings.general.webSearchHint': 'Auto uses native Claude web search for Claude model names, then falls back to Tavily and Brave keys.',
|
||||
'settings.general.webSearchSave': 'Save',
|
||||
'settings.general.uiZoom': 'UI Zoom',
|
||||
'settings.general.uiZoomDescription': 'Scale the entire interface for better visibility on high-DPI displays.',
|
||||
'settings.general.uiZoomDescription': 'Adjust the size of the entire interface.',
|
||||
'settings.general.uiZoomReset': 'Reset UI zoom to 100%',
|
||||
|
||||
// ─── Empty Session ──────────────────────────────────────
|
||||
'empty.title': 'New session',
|
||||
|
||||
@ -770,7 +770,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.webSearchHint': '自动模式会对 Claude 模型名优先使用原生 WebSearch,失败或非 Claude 模型时再使用 Tavily/Brave。',
|
||||
'settings.general.webSearchSave': '保存',
|
||||
'settings.general.uiZoom': '界面缩放',
|
||||
'settings.general.uiZoomDescription': '缩放整个界面,以便在高 DPI 屏幕上获得更好的可见性。',
|
||||
'settings.general.uiZoomDescription': '调整整个界面的显示大小。',
|
||||
'settings.general.uiZoomReset': '重置界面缩放到 100%',
|
||||
|
||||
// ─── Empty Session ──────────────────────────────────────
|
||||
'empty.title': '新建会话',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
APP_ZOOM_STORAGE_KEY,
|
||||
LEGACY_UI_ZOOM_STORAGE_KEY,
|
||||
applyAppZoomLevel,
|
||||
getAppZoomKeyboardAction,
|
||||
initializeAppZoom,
|
||||
@ -39,6 +40,15 @@ describe('appZoom', () => {
|
||||
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.2')
|
||||
})
|
||||
|
||||
it('reads the legacy UI zoom key when the app zoom key is absent', async () => {
|
||||
window.localStorage.setItem(LEGACY_UI_ZOOM_STORAGE_KEY, '1.25')
|
||||
|
||||
await initializeAppZoom()
|
||||
|
||||
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('125')
|
||||
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('persists app zoom changes', async () => {
|
||||
await applyAppZoomLevel(1.3)
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
export const APP_ZOOM_STORAGE_KEY = 'cc-haha-app-zoom'
|
||||
export const LEGACY_UI_ZOOM_STORAGE_KEY = 'cc-haha-ui-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 const APP_ZOOM_CONTROL_STEP = 0.05
|
||||
|
||||
export type AppZoomAction = 'in' | 'out' | 'reset'
|
||||
|
||||
@ -45,7 +47,9 @@ export function isValidStoredAppZoomLevel(value: string | null): boolean {
|
||||
export function readStoredAppZoomLevel(storage: StorageLike | null = getDefaultStorage()): number {
|
||||
if (!storage) return DEFAULT_APP_ZOOM
|
||||
try {
|
||||
return normalizeAppZoomLevel(storage.getItem(APP_ZOOM_STORAGE_KEY))
|
||||
const stored = storage.getItem(APP_ZOOM_STORAGE_KEY)
|
||||
if (stored !== null) return normalizeAppZoomLevel(stored)
|
||||
return normalizeAppZoomLevel(storage.getItem(LEGACY_UI_ZOOM_STORAGE_KEY))
|
||||
} catch {
|
||||
return DEFAULT_APP_ZOOM
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor'
|
||||
import { APP_ZOOM_STORAGE_KEY } from './appZoom'
|
||||
import { APP_ZOOM_STORAGE_KEY, LEGACY_UI_ZOOM_STORAGE_KEY } from './appZoom'
|
||||
import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations'
|
||||
|
||||
export const SAFE_DOCTOR_STORAGE_KEYS = [
|
||||
@ -8,6 +8,7 @@ export const SAFE_DOCTOR_STORAGE_KEYS = [
|
||||
'cc-haha-theme',
|
||||
'cc-haha-locale',
|
||||
APP_ZOOM_STORAGE_KEY,
|
||||
LEGACY_UI_ZOOM_STORAGE_KEY,
|
||||
DESKTOP_PERSISTENCE_VERSION_KEY,
|
||||
] as const
|
||||
|
||||
|
||||
@ -81,6 +81,19 @@ describe('desktop persistence migrations', () => {
|
||||
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBeNull()
|
||||
})
|
||||
|
||||
test('migrates the legacy UI zoom key into app zoom storage', () => {
|
||||
window.localStorage.setItem('cc-haha-ui-zoom', '1.25')
|
||||
|
||||
const report = runDesktopPersistenceMigrations()
|
||||
|
||||
expect(report.migratedKeys).toEqual(expect.arrayContaining([
|
||||
'cc-haha-app-zoom',
|
||||
'cc-haha-ui-zoom',
|
||||
]))
|
||||
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.25')
|
||||
expect(window.localStorage.getItem('cc-haha-ui-zoom')).toBeNull()
|
||||
})
|
||||
|
||||
test('does not throw if schema version persistence is blocked', () => {
|
||||
const storage = {
|
||||
getItem: window.localStorage.getItem.bind(window.localStorage),
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { THEME_MODES } from '../types/settings'
|
||||
import { APP_ZOOM_STORAGE_KEY, isValidStoredAppZoomLevel } from './appZoom'
|
||||
import {
|
||||
APP_ZOOM_STORAGE_KEY,
|
||||
LEGACY_UI_ZOOM_STORAGE_KEY,
|
||||
isValidStoredAppZoomLevel,
|
||||
normalizeAppZoomLevel,
|
||||
} from './appZoom'
|
||||
|
||||
export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1
|
||||
export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion'
|
||||
@ -121,6 +126,17 @@ function normalizeAppZoomKey(storage: StorageLike, report: DesktopMigrationRepor
|
||||
storage.removeItem(APP_ZOOM_STORAGE_KEY)
|
||||
report.migratedKeys.push(APP_ZOOM_STORAGE_KEY)
|
||||
}
|
||||
|
||||
const currentValue = storage.getItem(APP_ZOOM_STORAGE_KEY)
|
||||
const legacyValue = storage.getItem(LEGACY_UI_ZOOM_STORAGE_KEY)
|
||||
if (currentValue === null && legacyValue !== null && isValidStoredAppZoomLevel(legacyValue)) {
|
||||
storage.setItem(APP_ZOOM_STORAGE_KEY, String(normalizeAppZoomLevel(legacyValue)))
|
||||
report.migratedKeys.push(APP_ZOOM_STORAGE_KEY)
|
||||
}
|
||||
if (legacyValue !== null) {
|
||||
storage.removeItem(LEGACY_UI_ZOOM_STORAGE_KEY)
|
||||
report.migratedKeys.push(LEGACY_UI_ZOOM_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrationStep(
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './theme/globals.css'
|
||||
import { initializeAppZoom } from './lib/appZoom'
|
||||
import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
|
||||
|
||||
runDesktopPersistenceMigrations()
|
||||
void initializeAppZoom()
|
||||
const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await Promise.all([
|
||||
import('./App'),
|
||||
import('./components/ErrorBoundary'),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
|
||||
import { useSettingsStore, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
|
||||
import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
|
||||
import { useProviderStore } from '../stores/providerStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { Modal } from '../components/shared/Modal'
|
||||
@ -1532,6 +1532,45 @@ function GeneralSettings() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* UI Zoom */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-3 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.uiZoom')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">{t('settings.general.uiZoomDescription')}</p>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<span className="min-w-[48px] rounded-md bg-[var(--color-surface-container-low)] px-2 py-1 text-center text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
{Math.round(uiZoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('settings.general.uiZoomReset')}
|
||||
title={t('settings.general.uiZoomReset')}
|
||||
onClick={() => setUiZoom(UI_ZOOM_DEFAULT)}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
100%
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-9 text-right text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MIN * 100)}%</span>
|
||||
<input
|
||||
type="range"
|
||||
aria-label={t('settings.general.uiZoom')}
|
||||
min={UI_ZOOM_MIN}
|
||||
max={UI_ZOOM_MAX}
|
||||
step={UI_ZOOM_STEP}
|
||||
value={uiZoom}
|
||||
onChange={(e) => setUiZoom(e.currentTarget.valueAsNumber)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-9 text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MAX * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language selector */}
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p>
|
||||
@ -1777,33 +1816,6 @@ function GeneralSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* UI Zoom */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.uiZoom')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.uiZoomDescription')}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-sm text-[var(--color-text-secondary)]">
|
||||
{Math.round(uiZoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MIN * 100)}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min={UI_ZOOM_MIN}
|
||||
max={UI_ZOOM_MAX}
|
||||
step={UI_ZOOM_STEP}
|
||||
value={uiZoom}
|
||||
onChange={(e) => setUiZoom(parseFloat(e.target.value))}
|
||||
onMouseUp={(e) => e.currentTarget.blur()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MAX * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -22,6 +22,47 @@ describe('settingsStore locale defaults', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('settingsStore UI zoom', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
window.localStorage.clear()
|
||||
document.documentElement.removeAttribute('data-app-zoom-percent')
|
||||
document.documentElement.style.removeProperty('--app-zoom')
|
||||
document.body.style.removeProperty('zoom')
|
||||
})
|
||||
|
||||
it('hydrates from the app zoom storage key', async () => {
|
||||
window.localStorage.setItem('cc-haha-app-zoom', '1.25')
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
expect(useSettingsStore.getState().uiZoom).toBe(1.25)
|
||||
})
|
||||
|
||||
it('applies and persists UI zoom changes through the shared app zoom controller', async () => {
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
useSettingsStore.getState().setUiZoom(1.25)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.25')
|
||||
})
|
||||
expect(useSettingsStore.getState().uiZoom).toBe(1.25)
|
||||
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('125')
|
||||
})
|
||||
|
||||
it('clamps UI zoom changes to the supported range', async () => {
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
useSettingsStore.getState().setUiZoom(9)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('2')
|
||||
})
|
||||
expect(useSettingsStore.getState().uiZoom).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settingsStore desktop notification persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
@ -5,13 +5,22 @@ import { modelsApi } from '../api/models'
|
||||
import { h5AccessApi } from '../api/h5Access'
|
||||
import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings } from '../types/settings'
|
||||
import type { Locale } from '../i18n'
|
||||
import {
|
||||
APP_ZOOM_CONTROL_STEP,
|
||||
DEFAULT_APP_ZOOM,
|
||||
MAX_APP_ZOOM,
|
||||
MIN_APP_ZOOM,
|
||||
applyAppZoomLevel,
|
||||
normalizeAppZoomLevel,
|
||||
readStoredAppZoomLevel,
|
||||
} from '../lib/appZoom'
|
||||
import { useUIStore } from './uiStore'
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
|
||||
const UI_ZOOM_STORAGE_KEY = 'cc-haha-ui-zoom'
|
||||
export const UI_ZOOM_MIN = 0.5
|
||||
export const UI_ZOOM_MAX = 2.0
|
||||
export const UI_ZOOM_STEP = 0.05
|
||||
export const UI_ZOOM_MIN = MIN_APP_ZOOM
|
||||
export const UI_ZOOM_MAX = MAX_APP_ZOOM
|
||||
export const UI_ZOOM_STEP = APP_ZOOM_CONTROL_STEP
|
||||
export const UI_ZOOM_DEFAULT = DEFAULT_APP_ZOOM
|
||||
let desktopNotificationsSaveQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function getStoredLocale(): Locale {
|
||||
@ -22,17 +31,6 @@ function getStoredLocale(): Locale {
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
function getStoredUiZoom(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(UI_ZOOM_STORAGE_KEY)
|
||||
if (stored === null) return 1.0
|
||||
const parsed = parseFloat(stored)
|
||||
if (isNaN(parsed)) return 1.0
|
||||
return Math.min(UI_ZOOM_MAX, Math.max(UI_ZOOM_MIN, parsed))
|
||||
} catch { /* localStorage unavailable */ }
|
||||
return 1.0
|
||||
}
|
||||
|
||||
type SettingsStore = {
|
||||
permissionMode: PermissionMode
|
||||
currentModel: ModelInfo | null
|
||||
@ -96,14 +94,14 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
|
||||
h5AccessError: null,
|
||||
responseLanguage: '',
|
||||
uiZoom: getStoredUiZoom(),
|
||||
uiZoom: readStoredAppZoomLevel(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setUiZoom: (zoom: number) => {
|
||||
const clamped = Math.min(UI_ZOOM_MAX, Math.max(UI_ZOOM_MIN, zoom))
|
||||
set({ uiZoom: clamped })
|
||||
try { localStorage.setItem(UI_ZOOM_STORAGE_KEY, String(clamped)) } catch { /* noop */ }
|
||||
const level = normalizeAppZoomLevel(zoom)
|
||||
set({ uiZoom: level })
|
||||
void applyAppZoomLevel(level)
|
||||
},
|
||||
|
||||
fetchAll: async () => {
|
||||
|
||||
@ -43,6 +43,5 @@ export type UserSettings = {
|
||||
desktopNotificationsEnabled?: boolean
|
||||
webSearch?: WebSearchSettings
|
||||
language?: string
|
||||
uiZoom?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user