Skip to content

Commit de5216b

Browse files
authored
Merge PR #130: mobile CJK input reliability + iPad keyboard accessory bar
fix(mobile): CJK input reliability + iPad keyboard accessory bar
2 parents d5809d1 + 8dc70a5 commit de5216b

7 files changed

Lines changed: 379 additions & 405 deletions

File tree

src/web/public/app.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2206,7 +2206,6 @@ class CodemanApp {
22062206
document.body.classList.toggle('cjk-input-visible', !!showCjk);
22072207
cjkEl.style.display = showCjk ? 'block' : 'none';
22082208
cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true');
2209-
if (showCjk && cjkEl.value === '\u200B') cjkEl.value = '';
22102209
if (!showCjk) window.cjkActive = false;
22112210
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard();
22122211
}

src/web/public/input-cjk.js

Lines changed: 115 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,21 @@
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
*
@@ -41,16 +49,30 @@
4149
// eslint-disable-next-line no-unused-vars
4250
const 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

Comments
 (0)