diff --git a/app/core/hooks/event.ts b/app/core/hooks/event.ts index 13296fbe9..c8c3ab380 100644 --- a/app/core/hooks/event.ts +++ b/app/core/hooks/event.ts @@ -2,6 +2,7 @@ import {onDeactivated, onMounted, onUnmounted, watch, type WatchSource} from 'vu import {emitter, EventKey} from '../utils/eventBus' import {useSettingStore} from '../stores' import {isMobile} from '../utils' +import {shouldIgnoreDeadKeyCompositionSpace} from '../utils/dead-key-composition' import {Toast} from '@/base' const CODE_TO_CHAR: Record = { @@ -79,6 +80,9 @@ export function useWindowClick(cb: (e: PointerEvent) => void) { onDeactivated(remove) } +// 组合状态(IME/死键)共享给全局 keydown 处理:组合进行中时空格是终结符而非分词符 +let isComposing = false + export function useEventListener(type: string, listener: EventListenerOrEventListenerObject) { const invokeListener = (event: KeyboardEvent) => { if (typeof listener === 'function') { @@ -145,7 +149,6 @@ export function useEventListener(type: string, listener: EventListenerOrEventLis } const hiddenInput = ensureMobileInput() - let isComposing = false const createSyntheticEvent = (payload: { key: string; code?: string; keyCode: number }) => { const base = { @@ -174,11 +177,15 @@ export function useEventListener(type: string, listener: EventListenerOrEventLis const handleCompositionStart = () => { // console.log('handleCompositionStart',Date.now()) isComposing = true - Toast.warning('请切换到英文输入') } const handleCompositionEnd = (event: CompositionEvent) => { isComposing = false + // 死键(´、`、^…)组合也走 composition 事件,但它们不是 IME;只有产出 CJK/全角 + // 文本才是真正的输入法,此时才提示切换到英文,避免死键每次都弹警告 + if (event.data && /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af\uff00-\uffef]/.test(event.data)) { + Toast.warning('请切换到英文输入') + } if (!event.data) { hiddenInput.value = ' ' return @@ -197,6 +204,9 @@ export function useEventListener(type: string, listener: EventListenerOrEventLis const handleInput = (event: InputEvent) => { // console.log('handleInput',event,Date.now()) if (isComposing) return + // Firefox 的 input 事件晚于 compositionend 到达,此时输入框已被重置为哨兵 ' ', + // slice(-1) 会把哨兵空格派发成幽灵空格;组合字符已由 handleCompositionEnd 派发过 + if (event.inputType === 'insertCompositionText' || event.inputType === 'insertFromComposition') return const target = event.target as HTMLInputElement | null if (!target) return let char = '' @@ -317,6 +327,21 @@ export function useStartKeyboardEventListener() { const settingStore = useSettingStore() useEventListener('keydown', (e: KeyboardEvent) => { + // 组合进行中(死键/IME)的空格是"终结/取消"组合的键,不是分词空格,必须吞掉 + if (isComposing && e.code === 'Space' && e.key === ' ') { + e.preventDefault() + return + } + // 死键布局(西班牙语/US-Intl)中 ' 的 keydown 是组合起点,组合空格的终结在此吞掉 + if (shouldIgnoreDeadKeyCompositionSpace(e)) { + e.preventDefault() + return + } + // 死键本身不产生字符,字符由系统组合后经隐藏输入框 input 事件送入 + if (e.key === 'Dead') { + e.preventDefault() + return + } // console.log('keydown', e) // debugger //解决无法复制、全选的问题 diff --git a/app/core/utils/dead-key-composition.ts b/app/core/utils/dead-key-composition.ts new file mode 100644 index 000000000..a7ce791d5 --- /dev/null +++ b/app/core/utils/dead-key-composition.ts @@ -0,0 +1,29 @@ +/** + * 死键(dead key)处理:Chromium/Gecko 在死键布局(西班牙语、国际美式等)下把 + * 死键报告为 key='Dead'。死键本身不产生字符,字符由系统组合后经隐藏输入框的 + * input 事件送进输入逻辑;紧跟死键的空格是组合终结符,会被系统消费,不是分词空格。 + */ +let lastKeyDownWasDeadKey = false + +/** 返回 true 表示这次空格是死键组合终结符,应吞掉;对其他键跟踪死键状态。 */ +export function shouldIgnoreDeadKeyCompositionSpace(event: { + key: string + code: string + ctrlKey?: boolean + metaKey?: boolean + altKey?: boolean +}): boolean { + if (event.ctrlKey || event.metaKey || event.altKey) return false + if (event.code === 'Space' && event.key === ' ') { + const isTerminator = lastKeyDownWasDeadKey + lastKeyDownWasDeadKey = false + return isTerminator + } + lastKeyDownWasDeadKey = event.key === 'Dead' + return false +} + +/** 复位内部状态,便于测试隔离。 */ +export function resetDeadKeyCompositionState(): void { + lastKeyDownWasDeadKey = false +} diff --git a/tests/unit/dead-key-composition.test.ts b/tests/unit/dead-key-composition.test.ts new file mode 100644 index 000000000..a6efcbbde --- /dev/null +++ b/tests/unit/dead-key-composition.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + resetDeadKeyCompositionState, + shouldIgnoreDeadKeyCompositionSpace, +} from '../../app/core/utils/dead-key-composition.ts' + +describe('dead key composition space swallowing', () => { + beforeEach(() => { + resetDeadKeyCompositionState() + }) + + it('swallows the space that terminates a dead-key composition (es / US-Intl, key=Dead)', () => { + expect(shouldIgnoreDeadKeyCompositionSpace({ key: 'Dead', code: 'Quote' })).toBe(false) + expect(shouldIgnoreDeadKeyCompositionSpace({ key: ' ', code: 'Space' })).toBe(true) + expect(shouldIgnoreDeadKeyCompositionSpace({ key: ' ', code: 'Space' })).toBe(false) + }) + + it('does not swallow a normal space after a regular letter (plain English layout)', () => { + expect(shouldIgnoreDeadKeyCompositionSpace({ key: 's', code: 'KeyS' })).toBe(false) + expect(shouldIgnoreDeadKeyCompositionSpace({ key: ' ', code: 'Space' })).toBe(false) + }) + + it('resets the dead key flag after any intermediate non-space key', () => { + expect(shouldIgnoreDeadKeyCompositionSpace({ key: 'Dead', code: 'Quote' })).toBe(false) + expect(shouldIgnoreDeadKeyCompositionSpace({ key: 'x', code: 'KeyX' })).toBe(false) + expect(shouldIgnoreDeadKeyCompositionSpace({ key: ' ', code: 'Space' })).toBe(false) + }) + + it('never swallows spaces pressed with modifiers', () => { + expect(shouldIgnoreDeadKeyCompositionSpace({ key: 'Dead', code: 'Quote' })).toBe(false) + expect(shouldIgnoreDeadKeyCompositionSpace({ key: ' ', code: 'Space', metaKey: true })).toBe(false) + }) +})