fix: IME 组合期按键守卫 — document capture 拦截,防内联第三方组件劫持方向键(#562)
Univer InputNumber 的 keydown 对 ArrowUp/Down 直接 preventDefault 且无 isComposing 守卫,中文输入法组合期候选词方向键被吞(#562 回归,同类 #535)。 新增 src/client/ime-guard.ts:document capture 阶段对组合期按键(isComposing 或 keyCode 229)stopPropagation,运行于 React 委托与任何原生监听之前,覆盖 内联依赖的全部劫持路径;apply 时经 ctx.effect 注册(HMR-safe)。 - tests/ime-guard.spec.ts:12 个用例,覆盖纯决策、原生 document/input 监听、 React 合成事件路径与 disposer 恢复(新增 jsdom devDependency) - 全量 280 测试通过,typecheck 干净
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* IME-composition key guard.
|
||||
*
|
||||
* While a Chinese/Japanese/Korean input method is composing (the user is
|
||||
* picking a candidate from the IME window), every pressed key BELONGS to the
|
||||
* input method: arrows move the candidate highlight, Enter/Space confirm the
|
||||
* composition, Escape cancels it. Page code must not process those keys —
|
||||
* a component that does (a number stepper calling preventDefault() on
|
||||
* ArrowUp/ArrowDown, a submit handler reacting to Enter, ...) silently
|
||||
* breaks the IME: candidates stop responding, the composition gets torn
|
||||
* apart, and only bare letters come out.
|
||||
*
|
||||
* This guard enforces that rule at the document boundary: a capture-phase
|
||||
* keydown/keyup listener that stops the event from propagating further
|
||||
* whenever a composition is in progress. Because it runs in the capture
|
||||
* phase on `document` — the outermost node — it fires BEFORE React's
|
||||
* delegated handlers (attached at the root container) and before any native
|
||||
* target/bubble listener, so an inlined third-party component (e.g. the
|
||||
* Univer office UI bundled into this plugin) can never intercept
|
||||
* composition keys. The browser's native IME processing is untouched:
|
||||
* stopPropagation only silences page JS, not the default action.
|
||||
*
|
||||
* The composition signal follows the DSH core convention (InputBar's IME
|
||||
* guard, issue #535): `isComposing` for modern engines, keyCode 229 as the
|
||||
* legacy signal engines emit without isComposing.
|
||||
*/
|
||||
|
||||
/** The pure decision: is this keyboard event part of an IME composition? */
|
||||
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
|
||||
return event.isComposing || event.keyCode === 229
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the document-level capture guard. Returns the disposer
|
||||
* (HMR-safe; call through `ctx.effect`).
|
||||
*/
|
||||
export function registerImeGuard(): () => void {
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (isImeComposition(event)) event.stopPropagation()
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
document.addEventListener('keyup', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
document.removeEventListener('keyup', onKey, true)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
@@ -183,6 +184,25 @@ export function apply(ctx: Context): void {
|
||||
'dsh-better-sidebar: link interception',
|
||||
)
|
||||
|
||||
// The IME guard: composition keys (candidate arrows, confirm, cancel)
|
||||
// belong to the input method, never to page JS. Inlined third-party UI
|
||||
// (Univer's office controls) has shipped unguarded keydown handlers that
|
||||
// hijack ArrowUp/ArrowDown and break Chinese input (#562 regression); the
|
||||
// document-capture guard neutralizes the whole class before React or any
|
||||
// native listener sees the event. Registered as early as possible so no
|
||||
// other capture-phase listener can win the ordering race.
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerImeGuard()
|
||||
} catch (error) {
|
||||
fail('ime guard', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: IME composition guard',
|
||||
)
|
||||
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
|
||||
Reference in New Issue
Block a user