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:
Menghuan1918
2026-08-12 01:16:20 +08:00
parent 956fcc005b
commit 31b78f7941
5 changed files with 579 additions and 2 deletions
+1
View File
@@ -134,6 +134,7 @@
"@types/ws": "^8.5.10",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7",
"jsdom": "^29.1.1",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
"react-dom": "18.2.0",
+324 -2
View File
@@ -168,6 +168,9 @@ importers:
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-loader@1.0.0-rc.5)
jsdom:
specifier: ^29.1.1
version: 29.1.1
lightningcss:
specifier: ^1.32.0
version: 1.33.0
@@ -185,7 +188,7 @@ importers:
version: 5.6.2
vitest:
specifier: ^4.1.8
version: 4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3))
version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.1(@types/node@24.13.3))
packages:
@@ -197,10 +200,29 @@ packages:
pdfjs-dist:
optional: true
'@asamuzakjp/css-color@5.1.11':
resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/dom-selector@7.1.1':
resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/generational-cache@1.0.1':
resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
@@ -276,6 +298,42 @@ packages:
node-addon-require-builtin:
optional: true
'@csstools/color-helpers@6.1.0':
resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.3.0':
resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.1.10':
resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.7':
resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@deepseek-ai/cordis-plugin-include@1.0.5-rc.1':
resolution: {integrity: sha512-fwDm2DvCUawaP7o2xx9kPQ46FU8krYkB51iQxOIXwZFNRCamfGkdYwIEe4ycEnXoEpjm4P/gA9pZovbJJNqnAw==}
peerDependencies:
@@ -779,6 +837,15 @@ packages:
'@deepseek-ai/schemastery@3.18.1-rc.1':
resolution: {integrity: sha512-gilia4mY6S/j6oFYsn4x2Ve9K6QfvzTgqJntkty0wFsKjTYHQ6e2WDvXLMw48t75WRBhpLt+ZpFm3GHmTWkNMw==}
'@exodus/bytes@1.15.1':
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@flatten-js/interval-tree@1.1.3':
resolution: {integrity: sha512-xhFWUBoHJFF77cJO1D6REjdgJEMRf2Y2Z+eKEPav8evGKcLSnj1ud5pLXQSbGuxF3VSvT1rWhMfVpXEKJLTL+A==}
@@ -2152,6 +2219,9 @@ packages:
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
cac@7.0.0:
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
engines: {node: '>=20.19.0'}
@@ -2241,9 +2311,17 @@ packages:
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -2301,6 +2379,10 @@ packages:
resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==}
engines: {node: '>=14'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
es-module-lexer@2.3.1:
resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
@@ -2367,6 +2449,10 @@ packages:
hookable@6.1.1:
resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -2387,6 +2473,9 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -2397,6 +2486,15 @@ packages:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsdom@29.1.1:
resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
@@ -2506,6 +2604,10 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -2551,6 +2653,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@@ -2691,6 +2796,9 @@ packages:
pako@1.0.2:
resolution: {integrity: sha512-qCaDHl8pk5JSbz5k1Bi5zTZf430DUuV13pmcFEHHan4fW++WIwm2HXUirpzfciBRyFUN+eeZW11bfIM/Y4Zeog==}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -2718,6 +2826,10 @@ packages:
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
engines: {node: '>=12.0.0'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
@@ -2795,6 +2907,10 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -2828,6 +2944,10 @@ packages:
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.23.0:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
@@ -2884,6 +3004,9 @@ packages:
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
@@ -2902,6 +3025,21 @@ packages:
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
engines: {node: '>=14.0.0'}
tldts-core@7.4.10:
resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
tldts@7.4.10:
resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}
hasBin: true
tough-cookie@6.0.2:
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
engines: {node: '>=16'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -2963,6 +3101,10 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
unicode-regex@4.2.0:
resolution: {integrity: sha512-fEYz7CCnvHDAdrb8OYAP7qlQCWzXBO5cHXQ3XI+HoZaBpiAwyC6b2nixMGl91yrDYEIRm7NDskgTvnLZ7mqrKQ==}
engines: {node: '>=16'}
@@ -3117,6 +3259,22 @@ packages:
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -3151,6 +3309,13 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xterm@5.3.0:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
@@ -3207,8 +3372,32 @@ snapshots:
echarts: 6.1.0
jszip: 3.10.1
'@asamuzakjp/css-color@5.1.11':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@asamuzakjp/dom-selector@7.1.1':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
'@asamuzakjp/generational-cache@1.0.1': {}
'@asamuzakjp/nwsapi@2.3.9': {}
'@babel/runtime@7.29.7': {}
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
@@ -3376,6 +3565,30 @@ snapshots:
cordis: 4.0.0-rc.7(@cordisjs/plugin-loader@1.0.0-rc.5)
cosmokit: 1.8.1
'@csstools/color-helpers@6.1.0': {}
'@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.1.0
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
'@csstools/css-tokenizer@4.0.0': {}
'@deepseek-ai/cordis-plugin-include@1.0.5-rc.1(@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1)(@deepseek-ai/cordis@4.0.1-rc.1)':
dependencies:
'@deepseek-ai/cordis': 4.0.1-rc.1(@deepseek-ai/cordis-plugin-include@1.0.5-rc.1)(@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1)
@@ -3961,6 +4174,8 @@ snapshots:
'@deepseek-ai/cosmokit': 1.8.2-rc.1
'@standard-schema/spec': 1.1.0
'@exodus/bytes@1.15.1': {}
'@flatten-js/interval-tree@1.1.3': {}
'@floating-ui/core@1.8.0':
@@ -6121,6 +6336,10 @@ snapshots:
async-lock@1.4.1: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
cac@7.0.0: {}
ccount@2.0.1: {}
@@ -6186,8 +6405,20 @@ snapshots:
crelt@1.0.7: {}
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
csstype@3.2.3: {}
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -6230,6 +6461,8 @@ snapshots:
empathic@2.0.1: {}
entities@8.0.0: {}
es-module-lexer@2.3.1: {}
escalade@3.2.0: {}
@@ -6287,6 +6520,12 @@ snapshots:
hookable@6.1.1: {}
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.1
transitivePeerDependencies:
- '@noble/hashes'
html-void-elements@3.0.0: {}
immediate@3.0.6: {}
@@ -6299,6 +6538,8 @@ snapshots:
is-fullwidth-code-point@3.0.0: {}
is-potential-custom-element-name@1.0.1: {}
isarray@1.0.0: {}
js-tokens@3.0.0: {}
@@ -6307,6 +6548,32 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@29.1.1:
dependencies:
'@asamuzakjp/css-color': 5.1.11
'@asamuzakjp/dom-selector': 7.1.1
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1)
'@exodus/bytes': 1.15.1
css-tree: 3.2.1
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
parse5: 8.0.1
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.2
undici: 7.29.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
jszip@3.10.1:
dependencies:
lie: 3.3.0
@@ -6393,6 +6660,8 @@ snapshots:
dependencies:
js-tokens: 3.0.0
lru-cache@11.5.2: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -6525,6 +6794,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.27.1: {}
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.3.0
@@ -6766,6 +7037,10 @@ snapshots:
pako@1.0.2: {}
parse5@8.0.1:
dependencies:
entities: 8.0.0
pathe@2.0.3: {}
picocolors@1.1.1: {}
@@ -6802,6 +7077,8 @@ snapshots:
'@types/node': 24.13.3
long: 5.3.2
punycode@2.3.1: {}
quansync@1.0.0: {}
quickselect@3.0.0: {}
@@ -6882,6 +7159,8 @@ snapshots:
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {}
rolldown-plugin-dts@0.27.14(rolldown@1.2.3)(typescript@5.6.2):
@@ -6924,6 +7203,10 @@ snapshots:
safe-buffer@5.1.2: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.23.0:
dependencies:
loose-envify: 1.4.0
@@ -6986,6 +7269,8 @@ snapshots:
style-mod@4.1.3: {}
symbol-tree@3.2.4: {}
tailwind-merge@2.6.0: {}
tinybench@2.9.0: {}
@@ -6999,6 +7284,20 @@ snapshots:
tinyrainbow@3.1.1: {}
tldts-core@7.4.10: {}
tldts@7.4.10:
dependencies:
tldts-core: 7.4.10
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.10
tr46@6.0.0:
dependencies:
punycode: 2.3.1
tree-kill@1.2.2: {}
trigram-utils@2.0.1:
@@ -7046,6 +7345,8 @@ snapshots:
undici-types@7.18.2: {}
undici@7.29.0: {}
unicode-regex@4.2.0:
dependencies:
regexp-util: 2.0.3
@@ -7126,7 +7427,7 @@ snapshots:
'@types/node': 24.13.3
fsevents: 2.3.3
vitest@4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)):
vitest@4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.1(@types/node@24.13.3)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3))
@@ -7150,11 +7451,28 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
jsdom: 29.1.1
transitivePeerDependencies:
- msw
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -7182,6 +7500,10 @@ snapshots:
wmf: 1.0.2
word: 0.3.0
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
xterm@5.3.0: {}
y18n@5.0.8: {}
+47
View File
@@ -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)
}
}
+20
View File
@@ -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
+187
View File
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
/**
* IME-composition guard tests.
*
* The guard registers document CAPTURE-phase keydown/keyup listeners that
* stopPropagation() while an input method is composing (isComposing or the
* legacy keyCode 229). These tests pin:
*
* 1. the pure decision (`isImeComposition`);
* 2. the native-listener path — a bubble listener on `document` and a
* target-phase listener on the input must NOT see composition keys, but
* must see every other key;
* 3. the React-synthetic path — a React `onKeyDown` handler (delegated at
* the root container) must not fire for composition keys, proving the
* document-capture listener wins the ordering race against React's
* delegation (the mechanism that inlined third-party UI, e.g. Univer's
* InputNumber, uses to hijack ArrowUp/ArrowDown);
* 4. the disposer restores normal flow (HMR-safe).
*
* Events are dispatched on a deep element (an `<input>` in `document.body`)
* exactly like the browser does, so capture-phase blocking behaves like in
* production — dispatching on `document` itself would run all listeners in
* the target phase where stopPropagation does not stop same-node listeners.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { isImeComposition, registerImeGuard } from '../src/client/ime-guard.ts'
/** Build a KeyboardEvent the way jsdom allows: keyCode/isComposing via defineProperty. */
function keyEvent(
type: 'keydown' | 'keyup',
init: { key: string; isComposing?: boolean; keyCode?: number; bubbles?: boolean },
): KeyboardEvent {
const event = new KeyboardEvent(type, { key: init.key, bubbles: init.bubbles ?? true, cancelable: true })
if (init.isComposing !== undefined) {
Object.defineProperty(event, 'isComposing', { value: init.isComposing })
}
if (init.keyCode !== undefined) {
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
}
return event
}
describe('isImeComposition', () => {
it('treats isComposing as composition', () => {
expect(isImeComposition({ isComposing: true, keyCode: 0 })).toBe(true)
})
it('treats keyCode 229 as composition (legacy engines without isComposing)', () => {
expect(isImeComposition({ isComposing: false, keyCode: 229 })).toBe(true)
})
it('treats both signals together as composition', () => {
expect(isImeComposition({ isComposing: true, keyCode: 229 })).toBe(true)
})
it('lets ordinary keys through', () => {
expect(isImeComposition({ isComposing: false, keyCode: 40 })).toBe(false)
expect(isImeComposition({ isComposing: false, keyCode: 0 })).toBe(false)
})
})
describe('registerImeGuard — native listeners', () => {
let input: HTMLInputElement
let dispose: (() => void) | undefined
const seen: string[] = []
const onDocumentBubble = (event: Event): void => {
seen.push(`document:${event.type}`)
}
const onInputTarget = (event: Event): void => {
seen.push(`input:${event.type}`)
}
beforeEach(() => {
input = document.createElement('input')
document.body.appendChild(input)
seen.length = 0
document.addEventListener('keydown', onDocumentBubble)
document.addEventListener('keyup', onDocumentBubble)
input.addEventListener('keydown', onInputTarget)
input.addEventListener('keyup', onInputTarget)
})
afterEach(() => {
dispose?.()
dispose = undefined
document.removeEventListener('keydown', onDocumentBubble)
document.removeEventListener('keyup', onDocumentBubble)
input.removeEventListener('keydown', onInputTarget)
input.removeEventListener('keyup', onInputTarget)
input.remove()
})
it('lets ordinary keys reach document bubble and input target listeners', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown' }))
input.dispatchEvent(keyEvent('keyup', { key: 'ArrowDown' }))
expect(seen).toEqual(['input:keydown', 'document:keydown', 'input:keyup', 'document:keyup'])
})
it('blocks composition keys (isComposing) from every downstream listener', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
input.dispatchEvent(keyEvent('keyup', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual([])
})
it('blocks composition keys signalled by keyCode 229 only', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', keyCode: 229 }))
expect(seen).toEqual([])
})
it('still blocks after the composition signal is gone (guard is per-event)', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: false }))
expect(seen).toEqual(['input:keydown', 'document:keydown'])
})
it('disposer restores normal flow', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual([])
dispose()
dispose = undefined
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual(['input:keydown', 'document:keydown'])
})
})
describe('registerImeGuard — React synthetic path', () => {
let container: HTMLDivElement
let root: Root
let input: HTMLInputElement
let dispose: (() => void) | undefined
const seen: string[] = []
const onReactKeyDown = (): void => {
seen.push('react:keydown')
}
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root.render(createElement('input', { 'data-testid': 'ime-target', onKeyDown: onReactKeyDown }))
})
input = container.querySelector('input') as HTMLInputElement
seen.length = 0
})
afterEach(() => {
dispose?.()
dispose = undefined
act(() => { root.unmount() })
container.remove()
})
it('baseline: React onKeyDown receives composition keys when the guard is absent', () => {
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual(['react:keydown'])
})
it('guard blocks React synthetic onKeyDown during composition (capture beats delegation)', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual([])
// Ordinary keys still flow through React after the guard is in place.
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: false }))
expect(seen).toEqual(['react:keydown'])
})
it('disposer restores the React synthetic path', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual([])
dispose()
dispose = undefined
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual(['react:keydown'])
})
})