1414 * This means compositionstart fires even for English text, and compositionend
1515 * may not fire until the user explicitly confirms (space, candidate tap).
1616 *
17- * We use InputEvent.inputType to distinguish:
18- * - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin)
19- * - `insertText`: final committed text (confirmed word, punctuation, space)
17+ * During composition, all input events are ignored — only compositionend
18+ * triggers a flush (CJK candidate selection).
2019 *
21- * During composition, `insertText` events are flushed immediately (punctuation,
22- * English words confirmed by IME). `insertCompositionText` waits for
23- * compositionend (CJK candidate selection).
20+ * ## iOS dictation challenge (WebKit Bug 261764)
21+ *
22+ * iOS/iPadOS voice dictation does NOT fire composition events. Text arrives
23+ * as bare input events with isComposing === false. Dictation refinement is
24+ * a delete→reinsert cycle (deleteContentBackward + insertReplacementText),
25+ * all within a few ms. Flushing on every input event would send irrevocable
26+ * provisional text to the PTY, causing duplication when the IME replaces it.
27+ *
28+ * Solution: outside composition, flush is DEBOUNCED (200ms). The entire
29+ * delete→reinsert cycle collapses into one flush of the final textarea value.
30+ * Keyboard typing of single printable characters still goes through the
31+ * keydown handler (immediate, no debounce).
2432 *
2533 * ## Phantom character for Android backspace
2634 *
4149// eslint-disable-next-line no-unused-vars
4250const CjkInput = ( ( ) => {
4351 let _textarea = null ;
44- let _terminalContainer = null ;
45- let _xtermTextarea = null ;
4652 let _send = null ;
4753 let _initialized = false ;
4854 let _composing = false ;
55+ let _flushTimer = null ;
56+ let _dictationActive = false ;
57+ let _dictationDecayTimer = null ;
58+ let _keydownSentAt = 0 ;
4959 const _listeners = { } ;
5060
51- // Zero-width space: always present in textarea so Android backspace has
52- // something to delete, triggering the `input` event we need to detect it.
53- const PHANTOM = '\u200B' ;
61+ const PHANTOM = '' ;
62+
63+ // Two-tier debounce for non-composition input:
64+ // - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
65+ // composition events even for keyboard CJK typing)
66+ // - DICTATION: long debounce (iOS voice dictation sends delete→reinsert
67+ // refinement cycles without composition events — WebKit Bug 261764)
68+ //
69+ // Dictation is detected by deleteContentBackward on non-empty text or
70+ // insertReplacementText — signals that the IME is rewriting provisional
71+ // text. Once detected, dictation mode persists for 3s (covers multi-word
72+ // dictation with natural pauses between words).
73+ const DEBOUNCE_KEYBOARD_MS = 150 ;
74+ const DEBOUNCE_DICTATION_MS = 1500 ;
75+ const DICTATION_DECAY_MS = 3000 ;
5476
5577 const PASSTHROUGH_KEYS = {
5678 ArrowUp : '\x1b[A' ,
@@ -66,35 +88,15 @@ const CjkInput = (() => {
6688 c : '\x03' , d : '\x04' , l : '\x0c' , z : '\x1a' , a : '\x01' , e : '\x05' ,
6789 } ;
6890
69- /** Strip phantom characters from a string */
7091 function _strip ( str ) {
71- return str . replace ( / \u200B / g, '' ) ;
92+ return str . replace ( / / g, '' ) ;
7293 }
7394
74- /** Reset textarea to phantom-only state with cursor at end */
7595 function _resetToPhantom ( ) {
7696 _textarea . value = PHANTOM ;
7797 _textarea . setSelectionRange ( 1 , 1 ) ;
7898 }
7999
80- function _isMobileComposer ( ) {
81- return ! ! (
82- _textarea &&
83- typeof MobileDetection !== 'undefined' &&
84- MobileDetection . isTouchDevice ( ) &&
85- _textarea . classList . contains ( 'cjk-input-visible' )
86- ) ;
87- }
88-
89- function _resetInput ( ) {
90- if ( _isMobileComposer ( ) ) {
91- _textarea . value = '' ;
92- } else {
93- _resetToPhantom ( ) ;
94- }
95- }
96-
97- /** Check if textarea contains only phantom(s) or is empty — no real user text */
98100 function _isEffectivelyEmpty ( ) {
99101 return ! _strip ( _textarea . value ) ;
100102 }
@@ -108,61 +110,73 @@ const CjkInput = (() => {
108110 _resetToPhantom ( ) ;
109111 }
110112
113+ /** Cancel any pending debounced flush */
114+ function _cancelDebouncedFlush ( ) {
115+ if ( _flushTimer ) {
116+ clearTimeout ( _flushTimer ) ;
117+ _flushTimer = null ;
118+ }
119+ }
120+
121+ /** Mark that dictation rewriting is in progress */
122+ function _enterDictationMode ( ) {
123+ _dictationActive = true ;
124+ clearTimeout ( _dictationDecayTimer ) ;
125+ _dictationDecayTimer = setTimeout ( ( ) => {
126+ _dictationActive = false ;
127+ _dictationDecayTimer = null ;
128+ } , DICTATION_DECAY_MS ) ;
129+ }
130+
131+ /** Schedule a flush after input settles */
132+ function _debouncedFlush ( ) {
133+ _cancelDebouncedFlush ( ) ;
134+ const delay = _dictationActive ? DEBOUNCE_DICTATION_MS : DEBOUNCE_KEYBOARD_MS ;
135+ _flushTimer = setTimeout ( ( ) => {
136+ _flushTimer = null ;
137+ _flush ( ) ;
138+ } , delay ) ;
139+ }
140+
111141 return {
112142 init ( { send } ) {
113143 if ( _initialized ) this . destroy ( ) ;
114144
115145 _send = send ;
116146 _composing = false ;
147+ _flushTimer = null ;
117148 _textarea = document . getElementById ( 'cjkInput' ) ;
118149 if ( ! _textarea ) return this ;
119- _terminalContainer = document . getElementById ( 'terminalContainer' ) ;
120150
121- // Seed the phantom character for the hidden/immediate CJK path.
122- _resetInput ( ) ;
151+ _resetToPhantom ( ) ;
123152
124153 _listeners . mousedown = ( e ) => { e . stopPropagation ( ) ; } ;
125154 _listeners . focus = ( ) => {
126155 window . cjkActive = true ;
127- if ( _isMobileComposer ( ) && _textarea . value === PHANTOM ) {
128- _textarea . value = '' ;
129- return ;
156+ if ( ! _textarea . value ) _resetToPhantom ( ) ;
157+ } ;
158+ _listeners . blur = ( ) => {
159+ // Keep cjkActive while CJK input is visible — iOS dictation and system
160+ // UI may steal focus temporarily, and clearing the flag during that
161+ // window lets xterm's onData process duplicated input.
162+ if ( ! _textarea . classList . contains ( 'cjk-input-visible' ) ) {
163+ window . cjkActive = false ;
130164 }
131- // Restore phantom if textarea was emptied while blurred
132- if ( ! _textarea . value && ! _isMobileComposer ( ) ) _resetToPhantom ( ) ;
133165 } ;
134- _listeners . blur = ( ) => { window . cjkActive = false ; } ;
135166 _textarea . addEventListener ( 'mousedown' , _listeners . mousedown ) ;
136167 _textarea . addEventListener ( 'focus' , _listeners . focus ) ;
137168 _textarea . addEventListener ( 'blur' , _listeners . blur ) ;
138169
139- _listeners . xtermFocusRedirect = ( ) => {
140- if ( ! _isMobileComposer ( ) ) return ;
141- _textarea . focus ( ) ;
142- } ;
143- if ( _terminalContainer ) {
144- _xtermTextarea = _terminalContainer . querySelector ( '.xterm-helper-textarea' ) ;
145- if ( _xtermTextarea ) {
146- _xtermTextarea . addEventListener ( 'focus' , _listeners . xtermFocusRedirect , { capture : true } ) ;
147- }
148- }
149-
150- // ── Composition tracking ──
170+ // ── Composition tracking (keyboard IME — works for CJK typing) ──
151171 _listeners . compositionstart = ( ) => {
152172 _composing = true ;
153- if ( _isMobileComposer ( ) ) {
154- if ( _textarea . value === PHANTOM ) _textarea . value = '' ;
155- return ;
156- }
157- // Clear phantom so IME sees a clean textarea — some IMEs include
158- // existing text in the composition region which would corrupt input.
159- if ( _textarea . value === PHANTOM ) {
160- _textarea . value = '' ;
161- }
173+ _cancelDebouncedFlush ( ) ;
174+ // Leave textarea.value untouched — programmatic changes during
175+ // compositionstart cancel the IME composition on iOS Safari.
162176 } ;
163177 _listeners . compositionend = ( ) => {
164178 _composing = false ;
165- if ( _isMobileComposer ( ) ) return ;
179+ _cancelDebouncedFlush ( ) ;
166180 // Defer flush: some Android IMEs haven't committed text to textarea
167181 // when compositionend fires. setTimeout(0) ensures we read the final value.
168182 setTimeout ( _flush , 0 ) ;
@@ -172,32 +186,28 @@ const CjkInput = (() => {
172186
173187 // ── Keydown: special keys work REGARDLESS of composition state ──
174188 _listeners . keydown = ( e ) => {
175- // Enter: flush accumulated text (or bare Enter if empty).
176- // No isComposing guard — Android IMEs set isComposing=true for English
177- // prediction, but Enter should ALWAYS send. We preventDefault to stop
178- // the IME from also handling Enter (which could double-send or do nothing).
179189 if ( e . key === 'Enter' ) {
180190 e . preventDefault ( ) ;
181191 _composing = false ;
192+ _cancelDebouncedFlush ( ) ;
182193 const val = _strip ( _textarea . value ) ;
183194 if ( val ) {
184195 _send ( val + '\r' ) ;
185196 } else {
186197 _send ( '\r' ) ;
187198 }
188- _resetInput ( ) ;
199+ _resetToPhantom ( ) ;
189200 return ;
190201 }
191202
192- // Escape: clear textarea (always works)
193203 if ( e . key === 'Escape' ) {
194204 e . preventDefault ( ) ;
195205 _composing = false ;
196- _resetInput ( ) ;
206+ _cancelDebouncedFlush ( ) ;
207+ _resetToPhantom ( ) ;
197208 return ;
198209 }
199210
200- // Ctrl combos: forward to PTY (always works)
201211 if ( e . ctrlKey && CTRL_KEYS [ e . key ] ) {
202212 e . preventDefault ( ) ;
203213 _send ( CTRL_KEYS [ e . key ] ) ;
@@ -207,21 +217,7 @@ const CjkInput = (() => {
207217 // Below: only when NOT composing (composing keystrokes belong to IME)
208218 if ( _composing ) return ;
209219
210- if ( _isMobileComposer ( ) ) {
211- if ( e . key === 'Backspace' && _isEffectivelyEmpty ( ) ) {
212- e . preventDefault ( ) ;
213- _send ( '\x7f' ) ;
214- return ;
215- }
216- if ( PASSTHROUGH_KEYS [ e . key ] && _isEffectivelyEmpty ( ) ) {
217- e . preventDefault ( ) ;
218- _send ( PASSTHROUGH_KEYS [ e . key ] ) ;
219- }
220- return ;
221- }
222-
223220 // Backspace: forward to PTY when no real text in textarea
224- // (Desktop path — Android uses the input event + phantom approach)
225221 if ( e . key === 'Backspace' && _isEffectivelyEmpty ( ) ) {
226222 e . preventDefault ( ) ;
227223 _send ( '\x7f' ) ;
@@ -236,62 +232,62 @@ const CjkInput = (() => {
236232 return ;
237233 }
238234
239- // Single printable character: send immediately to PTY
240- // (Desktop keyboards with physical keys — Android sends 'Unidentified')
235+ // Single printable character: send immediately to PTY.
236+ // Third-party IMEs on iOS may ignore preventDefault, so the char
237+ // still enters the textarea and fires an input event — _keydownSentAt
238+ // tells the input handler to skip that echo.
241239 if ( e . key . length === 1 && ! e . ctrlKey && ! e . altKey && ! e . metaKey && _isEffectivelyEmpty ( ) ) {
242240 e . preventDefault ( ) ;
243241 _send ( e . key ) ;
242+ _keydownSentAt = performance . now ( ) ;
243+ _resetToPhantom ( ) ;
244244 return ;
245245 }
246246 } ;
247247 _textarea . addEventListener ( 'keydown' , _listeners . keydown ) ;
248248
249- // ── Input event: the primary path for Android virtual keyboards ──
250- // Android sends keyCode 229 + key "Unidentified" for virtual key presses,
251- // making keydown unreliable. input fires AFTER character insertion and
252- // carries inputType which tells us whether the text is final or tentative.
249+ // ── Input event: primary path for virtual keyboards + dictation ──
253250 _listeners . input = ( e ) => {
254- if ( _isMobileComposer ( ) ) {
255- if ( _textarea . value . includes ( PHANTOM ) ) {
256- _textarea . value = _strip ( _textarea . value ) ;
257- }
258- return ;
259- }
260-
261251 // ── Backspace / delete detection ──
262- // Android long-press backspace generates rapid deleteContentBackward events.
263- // The phantom character ensures the textarea is never truly empty, so each
264- // press/repeat fires an input event that we can catch here.
265252 if ( e . inputType === 'deleteContentBackward' || e . inputType === 'deleteWordBackward' ) {
253+ if ( _composing ) return ;
266254 if ( _isEffectivelyEmpty ( ) ) {
267- // No real text left — forward backspace to PTY
255+ _cancelDebouncedFlush ( ) ;
268256 _send ( '\x7f' ) ;
269257 _resetToPhantom ( ) ;
270258 return ;
271259 }
272- // User is editing their own text in the textarea — let it be.
273- // Ensure phantom is still present for the NEXT backspace.
260+ // Delete on non-empty text outside composition = dictation rewrite.
261+ // The IME is revising provisional text — switch to long debounce.
262+ _enterDictationMode ( ) ;
274263 if ( ! _textarea . value . startsWith ( PHANTOM ) ) {
275264 _textarea . value = PHANTOM + _textarea . value ;
276265 _textarea . setSelectionRange ( 1 , 1 ) ;
277266 }
267+ _debouncedFlush ( ) ;
278268 return ;
279269 }
280270
281- if ( _composing ) {
282- // insertText during composition = IME committed final text
283- // (e.g., punctuation key inserts 。directly, or IME confirms a word).
284- // Flush immediately — this text won't change.
285- if ( e . inputType === 'insertText' ) {
286- _flush ( ) ;
287- return ;
288- }
289- // insertCompositionText = IME is still working (pinyin, candidates,
290- // English prediction). Wait for compositionend to flush.
271+ // insertReplacementText = dictation/autocorrect refinement
272+ if ( e . inputType === 'insertReplacementText' ) {
273+ _enterDictationMode ( ) ;
274+ _debouncedFlush ( ) ;
275+ return ;
276+ }
277+
278+ if ( _composing ) return ;
279+
280+ // Keydown handler already sent this character — just clear the
281+ // textarea echo that the IME inserted despite preventDefault.
282+ if ( performance . now ( ) - _keydownSentAt < 100 ) {
283+ _resetToPhantom ( ) ;
291284 return ;
292285 }
293- // Outside composition: send immediately
294- _flush ( ) ;
286+
287+ // Outside composition: keyboard typing or voice dictation.
288+ // If dictation mode was detected (delete/replacement events seen
289+ // recently), use long debounce. Otherwise short debounce for keyboard.
290+ _debouncedFlush ( ) ;
295291 } ;
296292 _textarea . addEventListener ( 'input' , _listeners . input ) ;
297293
@@ -300,18 +296,16 @@ const CjkInput = (() => {
300296 } ,
301297
302298 destroy ( ) {
299+ _cancelDebouncedFlush ( ) ;
300+ clearTimeout ( _dictationDecayTimer ) ;
301+ _dictationActive = false ;
303302 if ( _textarea ) {
304303 for ( const [ event , handler ] of Object . entries ( _listeners ) ) {
305304 if ( handler ) _textarea . removeEventListener ( event , handler ) ;
306305 }
307306 }
308- if ( _xtermTextarea && _listeners . xtermFocusRedirect ) {
309- _xtermTextarea . removeEventListener ( 'focus' , _listeners . xtermFocusRedirect , { capture : true } ) ;
310- }
311307 window . cjkActive = false ;
312308 _composing = false ;
313- _terminalContainer = null ;
314- _xtermTextarea = null ;
315309 for ( const key of Object . keys ( _listeners ) ) delete _listeners [ key ] ;
316310 _initialized = false ;
317311 } ,
0 commit comments