diff --git a/lib/input-handler.ts b/lib/input-handler.ts index dc90249..ef795b9 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 902ea94..6a73bdc 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');