From e665814798ab98c12abe7cac777cc265f0dec888 Mon Sep 17 00:00:00 2001 From: wj-xiao Date: Mon, 19 Jan 2026 16:42:42 +0800 Subject: [PATCH] fix(desktop): fix keyboard issues compatible with IME composition compatible with Windows AltGr key --- .../src/components/keyboard/index.tsx | 105 +++++++++++++++--- 1 file changed, 90 insertions(+), 15 deletions(-) diff --git a/desktop/src/renderer/src/components/keyboard/index.tsx b/desktop/src/renderer/src/components/keyboard/index.tsx index 6fa39b7..739e4aa 100644 --- a/desktop/src/renderer/src/components/keyboard/index.tsx +++ b/desktop/src/renderer/src/components/keyboard/index.tsx @@ -4,26 +4,26 @@ import { useAtomValue } from 'jotai' import { IpcEvents } from '@common/ipc-events' import { isKeyboardEnableAtom } from '@renderer/jotai/keyboard' import { KeyboardReport } from '@renderer/libs/keyboard/keyboard' -import { isModifier } from "@renderer/libs/keyboard/keymap" +import { isModifier } from '@renderer/libs/keyboard/keymap' + +interface AltGrState { + active: boolean + ctrlLeftTimestamp: number +} + +const ALTGR_THRESHOLD_MS = 10 export const Keyboard = (): ReactElement => { const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom) const keyboardRef = useRef(new KeyboardReport()) const pressedKeys = useRef(new Set()) - - // Keyboard handler - async function handleKeyEvent(event: { type: 'keydown' | 'keyup'; code: string }): Promise { - const kb = keyboardRef.current - const report = event.type === 'keydown' ? kb.keyDown(event.code) : kb.keyUp(event.code) - await sendReport(report) - } - - async function sendReport(report: number[]): Promise { - await window.electron.ipcRenderer.invoke(IpcEvents.SEND_KEYBOARD, report) - } + const altGrState = useRef(null) + const isComposing = useRef(false) useEffect(() => { + initAltGr() + if (!isKeyboardEnabled) { releaseKeys() return @@ -31,6 +31,8 @@ export const Keyboard = (): ReactElement => { document.addEventListener('keydown', handleKeyDown) document.addEventListener('keyup', handleKeyUp) + document.addEventListener('compositionstart', handleCompositionStart) + document.addEventListener('compositionend', handleCompositionEnd) window.addEventListener('blur', handleBlur) document.addEventListener('visibilitychange', handleVisibilityChange) @@ -38,6 +40,9 @@ export const Keyboard = (): ReactElement => { async function handleKeyDown(event: KeyboardEvent): Promise { if (!isKeyboardEnabled) return + // Skip during IME composition + if (isComposing.current || event.isComposing) return + event.preventDefault() event.stopPropagation() @@ -46,6 +51,20 @@ export const Keyboard = (): ReactElement => { return } + // When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight + if (altGrState.current) { + if (code === 'ControlLeft') { + altGrState.current.ctrlLeftTimestamp = event.timeStamp + } else if (code === 'AltRight') { + const timeDiff = event.timeStamp - altGrState.current.ctrlLeftTimestamp + if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) { + pressedKeys.current.delete('ControlLeft') + handleKeyEvent({ type: 'keyup', code: 'ControlLeft' }) + altGrState.current.active = true + } + } + } + pressedKeys.current.add(code) await handleKeyEvent({ type: 'keydown', code }) } @@ -54,24 +73,51 @@ export const Keyboard = (): ReactElement => { async function handleKeyUp(event: KeyboardEvent): Promise { if (!isKeyboardEnabled) return + if (isComposing.current || event.isComposing) return + event.preventDefault() event.stopPropagation() const code = event.code + // Handle AltGr state for Windows + if (altGrState.current?.active) { + if (code === 'ControlLeft') return + + if (code === 'AltRight') { + altGrState.current.active = false + } + } + + // Compatible with macOS's command key combinations if (code === 'MetaLeft' || code === 'MetaRight') { + const keysToRelease: string[] = [] pressedKeys.current.forEach((pressedCode) => { if (!isModifier(pressedCode)) { - handleKeyEvent({ type: 'keyup', code: pressedCode }); - pressedKeys.current.delete(pressedCode); + keysToRelease.push(pressedCode) } - }); + }) + + for (const key of keysToRelease) { + await handleKeyEvent({ type: 'keyup', code: key }) + pressedKeys.current.delete(key) + } } pressedKeys.current.delete(code) await handleKeyEvent({ type: 'keyup', code }) } + // Composition start event + function handleCompositionStart(): void { + isComposing.current = true + } + + // Composition end event + function handleCompositionEnd(): void { + isComposing.current = false + } + // Release all keys when window loses focus async function handleBlur(): Promise { await releaseKeys() @@ -92,6 +138,12 @@ export const Keyboard = (): ReactElement => { pressedKeys.current.clear() + // Reset AltGr state + if (altGrState.current) { + altGrState.current.active = false + altGrState.current.ctrlLeftTimestamp = 0 + } + const report = keyboardRef.current.reset() await sendReport(report) } @@ -99,10 +151,33 @@ export const Keyboard = (): ReactElement => { return () => { document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keyup', handleKeyUp) + document.removeEventListener('compositionstart', handleCompositionStart) + document.removeEventListener('compositionend', handleCompositionEnd) window.removeEventListener('blur', handleBlur) document.removeEventListener('visibilitychange', handleVisibilityChange) + + releaseKeys() } }, [isKeyboardEnabled]) + async function initAltGr(): Promise { + const platform = await window.electron.ipcRenderer.invoke(IpcEvents.GET_PLATFORM) + const isWin = platform.toLowerCase().startsWith('win') + if (isWin && !altGrState.current) { + altGrState.current = { active: false, ctrlLeftTimestamp: 0 } + } + } + + // Keyboard handler + async function handleKeyEvent(event: { type: 'keydown' | 'keyup'; code: string }): Promise { + const kb = keyboardRef.current + const report = event.type === 'keydown' ? kb.keyDown(event.code) : kb.keyUp(event.code) + await sendReport(report) + } + + async function sendReport(report: number[]): Promise { + await window.electron.ipcRenderer.invoke(IpcEvents.SEND_KEYBOARD, report) + } + return <> }