Skip to content

Commit 67c3107

Browse files
authored
Fix IME composition Enter key handling on Windows (#20)
Stop swallowing the IME confirm Enter so Windows Zhuyin no longer dumps the raw phonetic buffer; scope the guard to a short post-compositionend window with a -Infinity no-composition sentinel.
1 parent 0204526 commit 67c3107

3 files changed

Lines changed: 85 additions & 17 deletions

File tree

frontend/src/components/Editor/Editor.jsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { mention } from './mention'
1616
import { math, mathBlockSchema } from './math'
1717
import { tableTooltip } from './tableTooltip'
1818
import { tablePasteExpand } from './tablePasteExpand'
19+
import { isStrayPostCompositionEnter, NO_COMPOSITION } from './imeGuard'
1920

2021
const SLASH_ITEM_DEFS = [
2122
{ id: 'h1', icon: 'H1' },
@@ -801,32 +802,24 @@ const Editor = forwardRef(function Editor({ defaultValue = '', onChange, onDrawi
801802
})
802803
})
803804

804-
// On Windows, compositionend fires before the keydown for the Enter that
805-
// confirmed the IME selection. By the time handleKeyDown runs, both
806-
// event.isComposing and view.composing are false, so ProseMirror's default
807-
// Enter handler creates a spurious newline (causing the "line shifts down"
808-
// symptom). Track composition state via DOM events and clear the flag one
809-
// microtask later so that post-composition Enter keydown is suppressed.
805+
// On Windows, the Enter that confirms an IME selection can surface as a
806+
// stray keydown fired right after compositionend (isComposing already
807+
// false), which ProseMirror's default Enter handler would turn into a
808+
// spurious block split. Suppress only that stray Enter — scoped to a
809+
// short window after compositionend — and never an Enter that is still
810+
// part of an active composition (see imeGuard.js for the rationale).
810811
const compositionGuardPlugin = $prose(() => {
811-
let _imeComposing = false
812+
let lastCompositionEndAt = NO_COMPOSITION
812813
return new Plugin({
813814
props: {
814815
handleDOMEvents: {
815-
compositionstart(_view, _event) {
816-
_imeComposing = true
817-
return false
818-
},
819816
compositionend(_view, _event) {
820-
// setTimeout(0) rather than Promise microtask — on Windows the
821-
// post-composition Enter keydown may arrive in the same task after
822-
// compositionend, and microtasks run within that same task before
823-
// the next macrotask, making the delay insufficient.
824-
setTimeout(() => { _imeComposing = false }, 0)
817+
lastCompositionEndAt = performance.now()
825818
return false
826819
},
827820
},
828821
handleKeyDown(_view, event) {
829-
if (_imeComposing && event.key === 'Enter') {
822+
if (isStrayPostCompositionEnter(event, lastCompositionEndAt, performance.now())) {
830823
event.preventDefault()
831824
return true
832825
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Window (ms) after `compositionend` during which a *stray* Enter keydown
2+
// is suppressed. On Windows, the Enter that confirms an IME selection can
3+
// surface as an extra keydown fired right after `compositionend` (with
4+
// `isComposing` already false); ProseMirror's default Enter handler would
5+
// turn that into a spurious block split ("the line shifts down"). That
6+
// stray Enter is part of the same physical keypress as the IME confirm, so
7+
// it lands within a few ms — a deliberate newline is a separate, much
8+
// later keypress, so a short window cleanly separates the two.
9+
export const POST_COMPOSITION_ENTER_MS = 50
10+
11+
// Sentinel for "no compositionend has happened yet". Must be -Infinity, not
12+
// 0: timestamps are `performance.now()` (ms since page load), so a 0
13+
// sentinel would falsely fall inside the window during the first
14+
// POST_COMPOSITION_ENTER_MS after load and swallow a plain Enter.
15+
export const NO_COMPOSITION = Number.NEGATIVE_INFINITY
16+
17+
// Pure decision: should this keydown be swallowed as a post-composition
18+
// stray Enter? `lastCompositionEndAt` is the timestamp of the most recent
19+
// `compositionend` (NO_COMPOSITION if none yet); `now` is the current
20+
// timestamp (same clock as `lastCompositionEndAt`).
21+
export function isStrayPostCompositionEnter(event, lastCompositionEndAt, now) {
22+
// An Enter that is still part of an active composition (e.g. Zhuyin
23+
// pressing Enter to commit bopomofo into Han characters) MUST reach the
24+
// IME. Never preventDefault it, or the IME can't finalize the conversion
25+
// and commits the raw phonetic buffer instead of the converted text.
26+
if (event.isComposing || event.keyCode === 229) return false
27+
if (event.key !== 'Enter') return false
28+
return now - lastCompositionEndAt <= POST_COMPOSITION_ENTER_MS
29+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { isStrayPostCompositionEnter, POST_COMPOSITION_ENTER_MS, NO_COMPOSITION } from './imeGuard'
3+
4+
const enter = (overrides = {}) => ({ key: 'Enter', keyCode: 13, isComposing: false, ...overrides })
5+
6+
describe('isStrayPostCompositionEnter', () => {
7+
it('suppresses a stray Enter fired right after compositionend', () => {
8+
expect(isStrayPostCompositionEnter(enter(), 1000, 1001)).toBe(true)
9+
})
10+
11+
it('suppresses at the exact end of the window (inclusive)', () => {
12+
expect(
13+
isStrayPostCompositionEnter(enter(), 1000, 1000 + POST_COMPOSITION_ENTER_MS),
14+
).toBe(true)
15+
})
16+
17+
it('does NOT suppress an Enter that is still part of composition (isComposing)', () => {
18+
// Zhuyin pressing Enter to commit bopomofo->Han: must reach the IME or
19+
// the raw phonetic buffer gets dumped instead of the converted text.
20+
expect(isStrayPostCompositionEnter(enter({ isComposing: true }), 1000, 1001)).toBe(false)
21+
})
22+
23+
it('does NOT suppress an Enter delivered to the IME (keyCode 229)', () => {
24+
expect(isStrayPostCompositionEnter(enter({ keyCode: 229 }), 1000, 1001)).toBe(false)
25+
})
26+
27+
it('does NOT suppress a deliberate Enter long after compositionend', () => {
28+
expect(
29+
isStrayPostCompositionEnter(enter(), 1000, 1000 + POST_COMPOSITION_ENTER_MS + 1),
30+
).toBe(false)
31+
})
32+
33+
it('does NOT suppress Enter when no composition has happened yet', () => {
34+
expect(isStrayPostCompositionEnter(enter(), NO_COMPOSITION, 999999)).toBe(false)
35+
})
36+
37+
it('does NOT suppress a plain Enter pressed within 50ms of page load', () => {
38+
// Regression: a 0 sentinel + performance.now()-based `now` would make
39+
// `now - 0 <= 50` true during the first 50ms after the time origin.
40+
expect(isStrayPostCompositionEnter(enter(), NO_COMPOSITION, 12)).toBe(false)
41+
})
42+
43+
it('ignores non-Enter keys even right after compositionend', () => {
44+
expect(isStrayPostCompositionEnter({ key: 'a', keyCode: 65, isComposing: false }, 1000, 1001)).toBe(false)
45+
})
46+
})

0 commit comments

Comments
 (0)