Skip to content

Commit fe081ad

Browse files
fix(input): route IME composition events to the hidden textarea
IME composition events (compositionstart / compositionupdate / compositionend) fire on the focused element. ghostty-web focuses a hidden textarea for keyboard input, but composition listeners were attached to the container element — so every Korean / Chinese / Japanese input event was missed. This commit: - Moves composition listeners from `container` to `inputElement` (textarea) when the input element is available. Detach is also retargeted to the same element so disposal is symmetric. - Adds a state machine to handle the "terminating key" of an IME composition (space, period, etc.). The key is queued during composition and replayed after compositionend so the composed text appears before the terminator. - Removes `contenteditable="true"` from the parent container. Having contenteditable on the container caused IME text to be inserted as text nodes in the container, bypassing the textarea entirely. The textarea is itself a real input element, so most browser extensions (Vimium, etc.) leave it alone — this should not regress the motivation behind coder#78, but needs verification in real browsers. - Sets `tabindex="-1"` on the parent so it is no longer click/tab focusable. Redirects parent mousedown and focus events to the textarea so any focus eventually lands on the input element. - Updates `Terminal.focus()` to target the textarea instead of the container, with the same delayed-focus backup behaviour. Differences from upstream PR coder#120 (deliberate): - The composition-preview overlay (a div with hardcoded Korean text "조합중:" and `#ffcc00` on dark background) is intentionally NOT ported. Native browsers already render IME composition feedback, and the upstream overlay was both untranslated and theme-hostile. - The selection-manager wide-char fix from that PR was already shipped separately as #120a. Co-authored-by: Seungwoo Hong <ai.baryon.ai@gmail.com> Inspired-by: coder#120
1 parent c3115f2 commit fe081ad

2 files changed

Lines changed: 97 additions & 32 deletions

File tree

lib/input-handler.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ export class InputHandler {
195195
private mousemoveListener: ((e: MouseEvent) => void) | null = null;
196196
private wheelListener: ((e: WheelEvent) => void) | null = null;
197197
private isComposing = false;
198+
private compositionJustEnded = false; // Block keydown briefly after composition ends
199+
private pendingKeyAfterComposition: string | null = null; // Key to output after composition
198200
private isDisposed = false;
199201
private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting
200202
private lastKeyDownData: string | null = null;
@@ -288,14 +290,19 @@ export class InputHandler {
288290
this.inputElement.addEventListener('beforeinput', this.beforeInputListener);
289291
}
290292

293+
// Attach composition events to inputElement (textarea) if available.
294+
// IME composition events fire on the focused element, and when using a hidden
295+
// textarea for input (as ghostty-web does), the textarea receives focus,
296+
// not the container. This fixes Korean/Chinese/Japanese IME input.
297+
const compositionTarget = this.inputElement || this.container;
291298
this.compositionStartListener = this.handleCompositionStart.bind(this);
292-
this.container.addEventListener('compositionstart', this.compositionStartListener);
299+
compositionTarget.addEventListener('compositionstart', this.compositionStartListener);
293300

294301
this.compositionUpdateListener = this.handleCompositionUpdate.bind(this);
295-
this.container.addEventListener('compositionupdate', this.compositionUpdateListener);
302+
compositionTarget.addEventListener('compositionupdate', this.compositionUpdateListener);
296303

297304
this.compositionEndListener = this.handleCompositionEnd.bind(this);
298-
this.container.addEventListener('compositionend', this.compositionEndListener);
305+
compositionTarget.addEventListener('compositionend', this.compositionEndListener);
299306

300307
// Mouse event listeners (for terminal mouse tracking)
301308
this.mousedownListener = this.handleMouseDown.bind(this);
@@ -365,7 +372,23 @@ export class InputHandler {
365372

366373
// Ignore keydown events during composition
367374
// Note: Some browsers send keyCode 229 for all keys during composition
368-
if (this.isComposing || event.isComposing || event.keyCode === 229) {
375+
if (event.isComposing || event.keyCode === 229) {
376+
return;
377+
}
378+
379+
// If we're still in composition (our flag) but browser says composition ended,
380+
// this is the key that ended the composition (space, period, etc.).
381+
// Queue it to be processed after compositionend to maintain correct order.
382+
if (this.isComposing) {
383+
// Store the key to be processed after composition ends
384+
this.pendingKeyAfterComposition = event.key;
385+
event.preventDefault();
386+
return;
387+
}
388+
389+
// Block the key that triggered composition end if we just processed a pending key
390+
if (this.compositionJustEnded) {
391+
this.compositionJustEnded = false;
369392
return;
370393
}
371394

@@ -689,13 +712,31 @@ export class InputHandler {
689712
if (data && data.length > 0) {
690713
if (this.shouldIgnoreCompositionEnd(data)) {
691714
this.cleanupCompositionTextNodes();
715+
// Still process pending key even if composition data is ignored
716+
this.processPendingKeyAfterComposition();
692717
return;
693718
}
694719
this.onDataCallback(data);
695720
this.recordCompositionData(data);
696721
}
697722

698723
this.cleanupCompositionTextNodes();
724+
725+
// Process the key that ended composition (space, period, etc.)
726+
// This ensures correct order: composed text first, then the terminating key
727+
this.processPendingKeyAfterComposition();
728+
}
729+
730+
/**
731+
* Process the pending key that was queued during composition
732+
*/
733+
private processPendingKeyAfterComposition(): void {
734+
if (this.pendingKeyAfterComposition) {
735+
const key = this.pendingKeyAfterComposition;
736+
this.pendingKeyAfterComposition = null;
737+
// Output the key that ended composition
738+
this.onDataCallback(key);
739+
}
699740
}
700741

701742
/**
@@ -1059,18 +1100,20 @@ export class InputHandler {
10591100
this.beforeInputListener = null;
10601101
}
10611102

1103+
// Remove composition listeners from the same element they were attached to
1104+
const compositionTarget = this.inputElement || this.container;
10621105
if (this.compositionStartListener) {
1063-
this.container.removeEventListener('compositionstart', this.compositionStartListener);
1106+
compositionTarget.removeEventListener('compositionstart', this.compositionStartListener);
10641107
this.compositionStartListener = null;
10651108
}
10661109

10671110
if (this.compositionUpdateListener) {
1068-
this.container.removeEventListener('compositionupdate', this.compositionUpdateListener);
1111+
compositionTarget.removeEventListener('compositionupdate', this.compositionUpdateListener);
10691112
this.compositionUpdateListener = null;
10701113
}
10711114

10721115
if (this.compositionEndListener) {
1073-
this.container.removeEventListener('compositionend', this.compositionEndListener);
1116+
compositionTarget.removeEventListener('compositionend', this.compositionEndListener);
10741117
this.compositionEndListener = null;
10751118
}
10761119

lib/terminal.ts

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -349,20 +349,20 @@ export class Terminal implements ITerminalCore {
349349
this.isOpen = true;
350350

351351
try {
352-
// Make parent focusable if it isn't already
353-
if (!parent.hasAttribute('tabindex')) {
354-
parent.setAttribute('tabindex', '0');
355-
}
356-
357-
// Mark as contenteditable so browser extensions (Vimium, etc.) recognize
358-
// this as an input element and don't intercept keyboard events.
359-
parent.setAttribute('contenteditable', 'true');
360-
// Prevent actual content editing - we handle input ourselves
361-
parent.addEventListener('beforeinput', (e) => {
362-
if (e.target === parent) {
363-
e.preventDefault();
364-
}
365-
});
352+
// Set tabindex="-1" on parent so it is not focusable via click/tab.
353+
// We route ALL focus to the hidden textarea so IME composition events
354+
// (Korean, Chinese, Japanese) fire on the element our listeners are
355+
// attached to. Composition events fire on the focused element only.
356+
//
357+
// We intentionally do NOT set contenteditable on the parent container.
358+
// Setting it caused IME (CJK) input to be inserted directly into the
359+
// container as text nodes, bypassing our textarea.
360+
//
361+
// NOTE: removing contenteditable may bring back the browser-extension
362+
// key-interception regression that #78 fixed with that attribute.
363+
// The textarea is itself a real input element so most extensions
364+
// (Vimium, etc.) should leave it alone — to be verified in browser.
365+
parent.setAttribute('tabindex', '-1');
366366

367367
// Add accessibility attributes for screen readers and extensions
368368
parent.setAttribute('role', 'textbox');
@@ -415,6 +415,20 @@ export class Terminal implements ITerminalCore {
415415
ev.preventDefault();
416416
textarea.focus();
417417
});
418+
// Redirect focus from the parent container to the textarea so that
419+
// IME composition events always fire on the textarea (where our
420+
// listeners live). Without this, clicking on the container border
421+
// (outside the canvas) would put focus on parent — the textarea
422+
// would not receive composition events.
423+
parent.addEventListener('mousedown', (ev) => {
424+
if (ev.target === parent) {
425+
ev.preventDefault();
426+
textarea.focus();
427+
}
428+
});
429+
parent.addEventListener('focus', () => {
430+
textarea.focus();
431+
});
418432

419433
// Create renderer
420434
this.renderer = new CanvasRenderer(this.canvas, {
@@ -736,15 +750,22 @@ export class Terminal implements ITerminalCore {
736750
* Focus terminal input
737751
*/
738752
focus(): void {
739-
if (this.isOpen && this.element) {
740-
// Focus immediately for immediate keyboard/wheel event handling
741-
this.element.focus();
742-
743-
// Also schedule a delayed focus as backup to ensure it sticks
744-
// (some browsers may need this if DOM isn't fully settled)
745-
setTimeout(() => {
746-
this.element?.focus();
747-
}, 0);
753+
if (this.isOpen) {
754+
// Focus the textarea (not the container) for keyboard / IME input.
755+
// The textarea is the actual input element that receives keyboard
756+
// events and IME composition events. Focusing the container does
757+
// not work for IME because composition events fire on the focused
758+
// element only.
759+
const target = this.textarea || this.element;
760+
if (target) {
761+
target.focus();
762+
763+
// Also schedule a delayed focus as backup to ensure it sticks
764+
// (some browsers may need this if DOM isn't fully settled)
765+
setTimeout(() => {
766+
target?.focus();
767+
}, 0);
768+
}
748769
}
749770
}
750771

@@ -1241,8 +1262,9 @@ export class Terminal implements ITerminalCore {
12411262
this.element.removeEventListener('mouseleave', this.handleMouseLeave);
12421263
this.element.removeEventListener('click', this.handleClick);
12431264

1244-
// Remove contenteditable and accessibility attributes added in open()
1245-
this.element.removeAttribute('contenteditable');
1265+
// Remove accessibility attributes added in open().
1266+
// (contenteditable is no longer set on the parent — we focus the
1267+
// textarea directly for IME support; see open() comments.)
12461268
this.element.removeAttribute('role');
12471269
this.element.removeAttribute('aria-label');
12481270
this.element.removeAttribute('aria-multiline');

0 commit comments

Comments
 (0)