From 5162a1b2398edfab760a68c632a58415b1bb6e83 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 23 May 2026 10:48:08 -0300 Subject: [PATCH] fix(input): route IME composition events to the hidden textarea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 #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 Inspired-by: https://github.com/coder/ghostty-web/pull/120 --- lib/input-handler.ts | 57 ++++++++++++++++++++++++++++++----- lib/terminal.ts | 72 +++++++++++++++++++++++++++++--------------- 2 files changed, 97 insertions(+), 32 deletions(-) diff --git a/lib/input-handler.ts b/lib/input-handler.ts index dc902496..ef795b9d 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -195,6 +195,8 @@ export class InputHandler { private mousemoveListener: ((e: MouseEvent) => void) | null = null; private wheelListener: ((e: WheelEvent) => void) | null = null; private isComposing = false; + private compositionJustEnded = false; // Block keydown briefly after composition ends + private pendingKeyAfterComposition: string | null = null; // Key to output after composition private isDisposed = false; private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting private lastKeyDownData: string | null = null; @@ -288,14 +290,19 @@ export class InputHandler { this.inputElement.addEventListener('beforeinput', this.beforeInputListener); } + // Attach composition events to inputElement (textarea) if available. + // IME composition events fire on the focused element, and when using a hidden + // textarea for input (as ghostty-web does), the textarea receives focus, + // not the container. This fixes Korean/Chinese/Japanese IME input. + const compositionTarget = this.inputElement || this.container; this.compositionStartListener = this.handleCompositionStart.bind(this); - this.container.addEventListener('compositionstart', this.compositionStartListener); + compositionTarget.addEventListener('compositionstart', this.compositionStartListener); this.compositionUpdateListener = this.handleCompositionUpdate.bind(this); - this.container.addEventListener('compositionupdate', this.compositionUpdateListener); + compositionTarget.addEventListener('compositionupdate', this.compositionUpdateListener); this.compositionEndListener = this.handleCompositionEnd.bind(this); - this.container.addEventListener('compositionend', this.compositionEndListener); + compositionTarget.addEventListener('compositionend', this.compositionEndListener); // Mouse event listeners (for terminal mouse tracking) this.mousedownListener = this.handleMouseDown.bind(this); @@ -365,7 +372,23 @@ export class InputHandler { // Ignore keydown events during composition // Note: Some browsers send keyCode 229 for all keys during composition - if (this.isComposing || event.isComposing || event.keyCode === 229) { + if (event.isComposing || event.keyCode === 229) { + return; + } + + // If we're still in composition (our flag) but browser says composition ended, + // this is the key that ended the composition (space, period, etc.). + // Queue it to be processed after compositionend to maintain correct order. + if (this.isComposing) { + // Store the key to be processed after composition ends + this.pendingKeyAfterComposition = event.key; + event.preventDefault(); + return; + } + + // Block the key that triggered composition end if we just processed a pending key + if (this.compositionJustEnded) { + this.compositionJustEnded = false; return; } @@ -689,6 +712,8 @@ export class InputHandler { if (data && data.length > 0) { if (this.shouldIgnoreCompositionEnd(data)) { this.cleanupCompositionTextNodes(); + // Still process pending key even if composition data is ignored + this.processPendingKeyAfterComposition(); return; } this.onDataCallback(data); @@ -696,6 +721,22 @@ export class InputHandler { } this.cleanupCompositionTextNodes(); + + // Process the key that ended composition (space, period, etc.) + // This ensures correct order: composed text first, then the terminating key + this.processPendingKeyAfterComposition(); + } + + /** + * Process the pending key that was queued during composition + */ + private processPendingKeyAfterComposition(): void { + if (this.pendingKeyAfterComposition) { + const key = this.pendingKeyAfterComposition; + this.pendingKeyAfterComposition = null; + // Output the key that ended composition + this.onDataCallback(key); + } } /** @@ -1079,18 +1120,20 @@ export class InputHandler { this.beforeInputListener = null; } + // Remove composition listeners from the same element they were attached to + const compositionTarget = this.inputElement || this.container; if (this.compositionStartListener) { - this.container.removeEventListener('compositionstart', this.compositionStartListener); + compositionTarget.removeEventListener('compositionstart', this.compositionStartListener); this.compositionStartListener = null; } if (this.compositionUpdateListener) { - this.container.removeEventListener('compositionupdate', this.compositionUpdateListener); + compositionTarget.removeEventListener('compositionupdate', this.compositionUpdateListener); this.compositionUpdateListener = null; } if (this.compositionEndListener) { - this.container.removeEventListener('compositionend', this.compositionEndListener); + compositionTarget.removeEventListener('compositionend', this.compositionEndListener); this.compositionEndListener = null; } diff --git a/lib/terminal.ts b/lib/terminal.ts index 902ea94c..6a73bdcf 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -351,20 +351,20 @@ export class Terminal implements ITerminalCore { this.isOpen = true; try { - // Make parent focusable if it isn't already - if (!parent.hasAttribute('tabindex')) { - parent.setAttribute('tabindex', '0'); - } - - // Mark as contenteditable so browser extensions (Vimium, etc.) recognize - // this as an input element and don't intercept keyboard events. - parent.setAttribute('contenteditable', 'true'); - // Prevent actual content editing - we handle input ourselves - parent.addEventListener('beforeinput', (e) => { - if (e.target === parent) { - e.preventDefault(); - } - }); + // Set tabindex="-1" on parent so it is not focusable via click/tab. + // We route ALL focus to the hidden textarea so IME composition events + // (Korean, Chinese, Japanese) fire on the element our listeners are + // attached to. Composition events fire on the focused element only. + // + // We intentionally do NOT set contenteditable on the parent container. + // Setting it caused IME (CJK) input to be inserted directly into the + // container as text nodes, bypassing our textarea. + // + // NOTE: removing contenteditable may bring back the browser-extension + // key-interception regression that #78 fixed with that attribute. + // The textarea is itself a real input element so most extensions + // (Vimium, etc.) should leave it alone — to be verified in browser. + parent.setAttribute('tabindex', '-1'); // Add accessibility attributes for screen readers and extensions parent.setAttribute('role', 'textbox'); @@ -417,6 +417,20 @@ export class Terminal implements ITerminalCore { ev.preventDefault(); textarea.focus(); }); + // Redirect focus from the parent container to the textarea so that + // IME composition events always fire on the textarea (where our + // listeners live). Without this, clicking on the container border + // (outside the canvas) would put focus on parent — the textarea + // would not receive composition events. + parent.addEventListener('mousedown', (ev) => { + if (ev.target === parent) { + ev.preventDefault(); + textarea.focus(); + } + }); + parent.addEventListener('focus', () => { + textarea.focus(); + }); // Create renderer this.renderer = new CanvasRenderer(this.canvas, { @@ -763,15 +777,22 @@ export class Terminal implements ITerminalCore { * Focus terminal input */ focus(): void { - if (this.isOpen && this.element) { - // Focus immediately for immediate keyboard/wheel event handling - this.element.focus(); - - // Also schedule a delayed focus as backup to ensure it sticks - // (some browsers may need this if DOM isn't fully settled) - setTimeout(() => { - this.element?.focus(); - }, 0); + if (this.isOpen) { + // Focus the textarea (not the container) for keyboard / IME input. + // The textarea is the actual input element that receives keyboard + // events and IME composition events. Focusing the container does + // not work for IME because composition events fire on the focused + // element only. + const target = this.textarea || this.element; + if (target) { + target.focus(); + + // Also schedule a delayed focus as backup to ensure it sticks + // (some browsers may need this if DOM isn't fully settled) + setTimeout(() => { + target?.focus(); + }, 0); + } } } @@ -1268,8 +1289,9 @@ export class Terminal implements ITerminalCore { this.element.removeEventListener('mouseleave', this.handleMouseLeave); this.element.removeEventListener('click', this.handleClick); - // Remove contenteditable and accessibility attributes added in open() - this.element.removeAttribute('contenteditable'); + // Remove accessibility attributes added in open(). + // (contenteditable is no longer set on the parent — we focus the + // textarea directly for IME support; see open() comments.) this.element.removeAttribute('role'); this.element.removeAttribute('aria-label'); this.element.removeAttribute('aria-multiline');