Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions app/core/hooks/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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 = ''
Expand Down Expand Up @@ -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
//解决无法复制、全选的问题
Expand Down
29 changes: 29 additions & 0 deletions app/core/utils/dead-key-composition.ts
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 33 additions & 0 deletions tests/unit/dead-key-composition.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})