From d70c52da926584f6865ab22e1a98a7d83a4c38bc Mon Sep 17 00:00:00 2001 From: Hyen Su Oh Date: Mon, 16 Feb 2026 23:17:16 +0900 Subject: [PATCH] fix: handle insertReplacementText for Korean IME on WKWebView/Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WKWebView (used by Tauri, Capacitor, and Safari-based apps) does not fire compositionstart/compositionupdate/compositionend events for Korean IME input. Instead, it fires: - insertText for initial jamo (e.g. 'ㅎ') - insertReplacementText for composition updates (e.g. 'ㅎ' → '하' → '한') Since _inputEvent only handles insertText, composed Korean syllables were silently dropped, causing only raw jamo to reach the terminal. This patch: 1. Buffers insertReplacementText data and shows composition preview 2. Intercepts Hangul insertText to buffer instead of sending immediately 3. Flushes the composed syllable on next non-IME keydown or new character 4. Adds wkImeComposing flag to CompositionHelper to suppress _handleAnyTextareaChanges during synthetic WK composition Tested with Korean (Hangul) IME in Tauri v2 (macOS WKWebView). --- src/browser/CoreBrowserTerminal.ts | 93 ++++++++++++++++++++++++++ src/browser/TestUtils.test.ts | 1 + src/browser/Types.ts | 1 + src/browser/input/CompositionHelper.ts | 11 ++- 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 38ad467a51..fe3574bebc 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -119,6 +119,64 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { */ private _unprocessedDeadKey: boolean = false; + /** + * WKWebView IME workaround: holds the latest composed text from + * insertReplacementText events, flushed on next insertText or non-IME keydown. + */ + private _wkImeComposing: boolean = false; + private _wkImePending: string = ''; + + private static _isHangul(text: string): boolean { + const cp = text.codePointAt(0)!; + return (cp >= 0x1100 && cp <= 0x11FF) || + (cp >= 0x3130 && cp <= 0x318F) || + (cp >= 0xAC00 && cp <= 0xD7AF) || + (cp >= 0xA960 && cp <= 0xA97F) || + (cp >= 0xD7B0 && cp <= 0xD7FF); + } + + private _wkShowComposition(text: string): void { + if (!this._compositionView || !this._renderService) return; + this._compositionView.textContent = text; + this._compositionView.classList.add('active'); + + const cursorX = Math.min(this.buffer.x, this.cols - 1); + const cellHeight = this._renderService.dimensions.css.cell.height; + const cursorTop = this.buffer.y * cellHeight; + const cursorLeft = cursorX * this._renderService.dimensions.css.cell.width; + + this._compositionView.style.left = cursorLeft + 'px'; + this._compositionView.style.top = cursorTop + 'px'; + this._compositionView.style.height = cellHeight + 'px'; + this._compositionView.style.lineHeight = cellHeight + 'px'; + this._compositionView.style.fontFamily = this.optionsService.rawOptions.fontFamily ?? ''; + this._compositionView.style.fontSize = this.optionsService.rawOptions.fontSize + 'px'; + } + + private _wkHideComposition(): void { + if (!this._compositionView) return; + this._compositionView.textContent = ''; + this._compositionView.classList.remove('active'); + } + + private _wkSetComposing(value: boolean): void { + this._wkImeComposing = value; + if (this._compositionHelper) { + this._compositionHelper.wkImeComposing = value; + } + } + + private _wkFlush(): void { + if (!this._wkImeComposing) return; + const text = this._wkImePending; + this._wkSetComposing(false); + this._wkImePending = ''; + this._wkHideComposition(); + if (text) { + this.coreService.triggerDataEvent(text, true); + } + } + private _compositionHelper: ICompositionHelper | undefined; private _accessibilityManager: MutableDisposable = this._register(new MutableDisposable()); @@ -1022,6 +1080,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._keyDownHandled = false; this._keyDownSeen = true; + // Flush pending WKWebView IME composition on non-IME keydown (keyCode 229 = IME processing) + if (this._wkImeComposing && event.keyCode !== 229) { + this._wkFlush(); + } + if (this._customKeyEventHandler && this._customKeyEventHandler(event) === false) { return false; } @@ -1190,6 +1253,33 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { * @param ev The input event to be handled. */ protected _inputEvent(ev: InputEvent): boolean { + // WKWebView/Safari fires insertReplacementText instead of composition events + // for Korean (and other CJK) IME input. Buffer the latest value and show preview. + if (ev.data && ev.inputType === 'insertReplacementText' && !this.optionsService.rawOptions.screenReaderMode) { + this._wkSetComposing(true); + this._wkImePending = ev.data; + this._wkShowComposition(ev.data); + this.cancel(ev); + return true; + } + + // WKWebView IME: Hangul insertText starts a new composition. + // This check must be before the composed/keyDownSeen guard because WKWebView + // may fire insertText for composed Hangul syllables with composed=true. + if (ev.data && ev.inputType === 'insertText' && CoreBrowserTerminal._isHangul(ev.data) && !this.optionsService.rawOptions.screenReaderMode) { + const hadPending = this._wkImeComposing; + this._wkFlush(); + this._wkSetComposing(true); + this._wkImePending = ev.data; + // Show preview immediately only if there was no prior flush + // (avoids stale cursor position after flush + PTY echo delay) + if (!hadPending) { + this._wkShowComposition(ev.data); + } + this.cancel(ev); + return true; + } + // Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to // support reading out character input which can doubling up input characters // Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679 @@ -1198,6 +1288,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { return false; } + // Non-Hangul: flush any pending WKWebView composition first + this._wkFlush(); + // The key was handled so clear the dead key state, otherwise certain keystrokes like arrow // keys could be ignored this._unprocessedDeadKey = false; diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index cf878cbc9a..d260a941a6 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -335,6 +335,7 @@ export class MockViewport implements IViewport { } export class MockCompositionHelper implements ICompositionHelper { + public wkImeComposing: boolean = false; public get isComposing(): boolean { return false; } diff --git a/src/browser/Types.ts b/src/browser/Types.ts index 10e604114a..a986a9aecf 100644 --- a/src/browser/Types.ts +++ b/src/browser/Types.ts @@ -37,6 +37,7 @@ export type LineData = CharData[]; export interface ICompositionHelper { readonly isComposing: boolean; + wkImeComposing: boolean; compositionstart(): void; compositionupdate(ev: CompositionEvent): void; compositionend(): void; diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts index b87ebbea07..8e77ca4057 100644 --- a/src/browser/input/CompositionHelper.ts +++ b/src/browser/input/CompositionHelper.ts @@ -25,6 +25,12 @@ export class CompositionHelper { private _isComposing: boolean; public get isComposing(): boolean { return this._isComposing; } + /** + * WKWebView IME workaround: set by CoreBrowserTerminal to suppress + * _handleAnyTextareaChanges during synthetic WK composition. + */ + public wkImeComposing: boolean = false; + /** * The position within the input textarea's value of the current composition. */ @@ -110,7 +116,10 @@ export class CompositionHelper { if (ev.keyCode === 229) { // If the "composition character" is used but gets to this point it means a non-composition // character (eg. numbers and punctuation) was pressed when the IME was active. - this._handleAnyTextareaChanges(); + // Skip if WKWebView synthetic composition is active (handled by CoreBrowserTerminal). + if (!this.wkImeComposing) { + this._handleAnyTextareaChanges(); + } return false; }