From dddffb67bf980493fe821dc909da8768df2898cb Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 7 Sep 2026 17:19:34 +0800 Subject: [PATCH] fix(web-ui): support font size shortcuts and disable printing --- src/web-ui/src/app/App.tsx | 14 ++-- .../src/app/browserShortcutPolicy.test.ts | 71 ++++++++++++++++++- src/web-ui/src/app/browserShortcutPolicy.ts | 38 +++++++++- .../components/FontPreferencePanel.test.tsx | 21 ++++++ .../components/FontPreferencePanel.tsx | 6 +- .../core/FontPreferenceService.test.ts | 29 ++++++++ .../core/FontPreferenceService.ts | 8 +++ 7 files changed, 174 insertions(+), 13 deletions(-) diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index dc9ef69ba1..0b250bf543 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -32,7 +32,8 @@ import { ToolbarModeProvider } from '../flow_chat/components/toolbar-mode/Toolba import { RealtimeVoiceCallProvider } from '../flow_chat/components/voice/RealtimeVoiceCallContext'; import type { AgentCompanionPetCommand } from './services/agentCompanionPetCommands'; import AskUserAnnouncer from './components/NavPanel/AskUserAnnouncer'; -import { shouldBlockBrowserShortcut } from './browserShortcutPolicy'; +import { handleBrowserShortcut } from './browserShortcutPolicy'; +import { fontPreferenceService } from '@/infrastructure/font-preference/core/FontPreferenceService'; import { activateCreationRuntime } from '@/infrastructure/creation/creationRuntime'; import { attachCreationRuntime, recordCreationActivationError } from '@/infrastructure/creation/creationBridge'; import { createCreationUiApi } from './creation/creationUiApi'; @@ -852,17 +853,14 @@ function App() { }; }, []); - // Always block browser-native find. Page reload remains available while the + // Control typography and block browser-native find/print. Reload remains available while the // frontend runs in dev mode and is blocked in release builds. The desktop // host independently applies the matching Rust build-profile policy. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - const primary = e.ctrlKey || e.metaKey; - if (!primary) return; - if (shouldBlockBrowserShortcut(e.key, import.meta.env.DEV)) { - e.preventDefault(); - e.stopPropagation(); - } + handleBrowserShortcut(e, import.meta.env.DEV, delta => { + void fontPreferenceService.adjustUiSize(delta); + }); }; window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); diff --git a/src/web-ui/src/app/browserShortcutPolicy.test.ts b/src/web-ui/src/app/browserShortcutPolicy.test.ts index e5a2baa71d..e856c0294e 100644 --- a/src/web-ui/src/app/browserShortcutPolicy.test.ts +++ b/src/web-ui/src/app/browserShortcutPolicy.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest'; +// @vitest-environment jsdom -import { shouldBlockBrowserShortcut } from './browserShortcutPolicy'; +import { describe, expect, it, vi } from 'vitest'; + +import { handleBrowserShortcut, shouldBlockBrowserShortcut } from './browserShortcutPolicy'; describe('browser shortcut policy', () => { it('allows page reload shortcuts in development', () => { @@ -16,6 +18,69 @@ describe('browser shortcut policy', () => { it('continues to block browser find and ignores unrelated shortcuts', () => { expect(shouldBlockBrowserShortcut('f', true)).toBe(true); expect(shouldBlockBrowserShortcut('F', false)).toBe(true); - expect(shouldBlockBrowserShortcut('p', false)).toBe(false); + expect(shouldBlockBrowserShortcut('s', false)).toBe(false); + }); + + it('blocks printing in development and release builds', () => { + expect(shouldBlockBrowserShortcut('p', true)).toBe(true); + expect(shouldBlockBrowserShortcut('P', false)).toBe(true); + }); +}); + +describe.each([ + ['Win32', { ctrlKey: true }], + ['MacIntel', { metaKey: true }], +] as const)('browser shortcuts on %s', (platform, modifier) => { + it.each([ + [{ key: '=', code: 'Equal' }, 1], + [{ key: '+', code: 'Equal', shiftKey: true }, 1], + [{ key: '+', code: 'NumpadAdd' }, 1], + [{ key: '-', code: 'Minus' }, -1], + [{ key: '_', code: 'Minus', shiftKey: true }, -1], + [{ key: '-', code: 'NumpadSubtract' }, -1], + ] as const)('changes typography for %j before focused controls receive it', (keys, delta) => { + const adjust = vi.fn(); + const input = document.createElement('textarea'); + document.body.append(input); + input.focus(); + const editorHandler = vi.fn(); + input.addEventListener('keydown', editorHandler); + const capture = (event: KeyboardEvent) => handleBrowserShortcut(event, false, adjust, platform); + window.addEventListener('keydown', capture, true); + try { + const event = new KeyboardEvent('keydown', { ...keys, ...modifier, bubbles: true, cancelable: true }); + input.dispatchEvent(event); + expect(adjust).toHaveBeenCalledExactlyOnceWith(delta); + expect(event.defaultPrevented).toBe(true); + expect(editorHandler).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('keydown', capture, true); + input.remove(); + } + }); + + it.each(['p', 'P'])('prevents printing for %s', key => { + const adjust = vi.fn(); + const event = new KeyboardEvent('keydown', { key, ...modifier, cancelable: true }); + handleBrowserShortcut(event, true, adjust, platform); + expect(event.defaultPrevented).toBe(true); + expect(adjust).not.toHaveBeenCalled(); + }); + + it.each([ + { key: '+' }, + { key: 'p' }, + { key: '+', ...modifier, altKey: true }, + { key: '+', ctrlKey: true, metaKey: true }, + { key: '+', ...modifier, isComposing: true }, + { key: '+', ...modifier, keyCode: 229 }, + { key: 's', ...modifier }, + { key: '+', ...(platform === 'MacIntel' ? { ctrlKey: true } : { metaKey: true }) }, + ])('leaves unrelated keys and IME input untouched: %j', keys => { + const adjust = vi.fn(); + const event = new KeyboardEvent('keydown', { ...keys, cancelable: true }); + handleBrowserShortcut(event, false, adjust, platform); + expect(event.defaultPrevented).toBe(false); + expect(adjust).not.toHaveBeenCalled(); }); }); diff --git a/src/web-ui/src/app/browserShortcutPolicy.ts b/src/web-ui/src/app/browserShortcutPolicy.ts index 4296d88f6d..7c784f0aaa 100644 --- a/src/web-ui/src/app/browserShortcutPolicy.ts +++ b/src/web-ui/src/app/browserShortcutPolicy.ts @@ -1,4 +1,40 @@ +import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; + export function shouldBlockBrowserShortcut(key: string, allowPageReload: boolean): boolean { const normalizedKey = key.toLowerCase(); - return normalizedKey === 'f' || (normalizedKey === 'r' && !allowPageReload); + return normalizedKey === 'f' || normalizedKey === 'p' || (normalizedKey === 'r' && !allowPageReload); +} + +/** Capture browser shortcuts before focused editors and terminals consume them. */ +export function handleBrowserShortcut( + event: KeyboardEvent, + allowPageReload: boolean, + adjustFontSize: (delta: -1 | 1) => void, + platform = navigator.platform, +): void { + if (!(event.ctrlKey || event.metaKey)) return; + + if (shouldBlockBrowserShortcut(event.key, allowPageReload)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + const isMac = platform.toUpperCase().includes('MAC'); + const primary = isMac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; + if (!primary || event.altKey || isImeOwnedKeyboardEvent(event)) return; + + // '+' may require Shift; '=' is the conventional unshifted zoom-in binding. + const delta = event.key === '+' || event.key === '=' || event.code === 'NumpadAdd' + ? 1 + : event.key === '-' || event.key === '_' || event.code === 'NumpadSubtract' + ? -1 + : 0; + if (!delta) return; + + event.preventDefault(); + event.stopPropagation(); + adjustFontSize(delta); } diff --git a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.test.tsx b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.test.tsx index fad7cc0a14..f3f20b8b55 100644 --- a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.test.tsx +++ b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.test.tsx @@ -84,6 +84,27 @@ describe('FontPreferencePanel', () => { expect(numberInput?.value).toBe('18'); expect(previewInput?.style.fontSize).toBe('18px'); }); + it('updates the custom stepper and preview when a shortcut changes the preference', async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + fontPreferenceState.level = 'custom'; + fontPreferenceState.customPx = 16; + const container = document.createElement('div'); + const root = createRoot(container); + try { + await act(async () => root.render()); + fontPreferenceState.customPx = 17; + await act(async () => root.render()); + expect(container.querySelector( + '[data-testid="appearance-ui-font-custom-controls"] input', + )?.value).toBe('17'); + expect(container.querySelector( + '[data-testid="appearance-ui-font-preview-input"]', + )?.style.fontSize).toBe('17px'); + } finally { + await act(async () => root.unmount()); + } + }); + it('applies presets and initializes custom sizing from the current preset', async () => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const container = document.createElement('div'); diff --git a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx index 956d81bb50..053a03acf6 100644 --- a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx +++ b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx @@ -4,7 +4,7 @@ import { Select, type SelectOption, } from '@openbitfun/ui'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ConfigPageRow, ConfigPageSection } from '@/infrastructure/config/components/common'; import { useFontPreference } from '../hooks/useFontPreference'; @@ -21,6 +21,10 @@ export function FontPreferencePanel() { const [customInput, setCustomInput] = useState(String(customPx ?? 14)); const [previewText, setPreviewText] = useState(''); + useEffect(() => { + setCustomInput(String(customPx ?? PRESET_UI_BASE_PX.default)); + }, [customPx]); + /** Baseline px currently applied in the UI (preset level or custom). */ const getEffectiveUiBasePx = useCallback((): number => { if (level === 'custom') { diff --git a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts index c4f1821529..2452649de8 100644 --- a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts +++ b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts @@ -48,4 +48,33 @@ describe('FontPreferenceService', () => { expect(document.documentElement.style.getPropertyValue('--openbitfun-appearance-token-flowchat-font-size-base')).toBe(''); // typography-audit: negative-test-end }); + + it('adjusts a saved preset, applies typography, and persists the existing config shape', async () => { + configMocks.getConfig.mockResolvedValue({ uiSize: { level: 'large' } }); + const service = new FontPreferenceService(); + await service.initialize(); + await service.adjustUiSize(1); + expect(document.documentElement.style.getPropertyValue('--openbitfun-font-size-base')).toBe('17px'); + expect(configMocks.setConfig).toHaveBeenLastCalledWith('font', { + uiSize: { level: 'custom', customPx: 17 }, + }); + await service.adjustUiSize(-1); + expect(service.getPreference().uiSize.customPx).toBe(16); + }); + + it.each([[12, -1], [20, 1]] as const)('keeps the %ipx boundary without redundant writes', async (customPx, delta) => { + const service = new FontPreferenceService(); + await service.setUiSize('custom', customPx); + configMocks.setConfig.mockClear(); + await service.adjustUiSize(delta); + expect(service.getPreference().uiSize.customPx).toBe(customPx); + expect(configMocks.setConfig).not.toHaveBeenCalled(); + }); + + it('uses the latest size for repeated key presses before persistence completes', async () => { + configMocks.setConfig.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 0))); + const service = new FontPreferenceService(); + await Promise.all([service.adjustUiSize(1), service.adjustUiSize(1), service.adjustUiSize(-1)]); + expect(service.getPreference().uiSize.customPx).toBe(15); + }); }); diff --git a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts index c52c33a227..425b95eed6 100644 --- a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts +++ b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts @@ -79,6 +79,14 @@ export class FontPreferenceService { await this.setPreference(DEFAULT_FONT_PREFERENCE); } + async adjustUiSize(delta: -1 | 1): Promise { + const currentPx = parseFloat(resolveFontSizeTokens(this.preference.uiSize).base); + const nextPx = Math.max(12, Math.min(20, currentPx + delta)); + if (nextPx !== currentPx) { + await this.setUiSize('custom', nextPx); + } + } + // ---- CSS Application ---- applyPreference(pref: FontPreference): void {