From c66b0ed16829f9150ac143b1646cd4115680a426 Mon Sep 17 00:00:00 2001 From: Tomoya Nakanishi Date: Fri, 13 Feb 2026 02:43:06 +0900 Subject: [PATCH 1/2] Fix IME input when composing in the middle of the textarea Track the composition range using the textarea selection and avoid re-sending trailing text that existed before composition started (notably in screenReaderMode). Fixes #5456 --- src/browser/input/CompositionHelper.test.ts | 26 ++++++++++++++++++ src/browser/input/CompositionHelper.ts | 29 ++++++++++++++++----- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/browser/input/CompositionHelper.test.ts b/src/browser/input/CompositionHelper.test.ts index e29111e18..94b5841af 100644 --- a/src/browser/input/CompositionHelper.test.ts +++ b/src/browser/input/CompositionHelper.test.ts @@ -233,5 +233,31 @@ describe('CompositionHelper', () => { }, 0); }, 0); }); + + it('Should insert middle composition and subsequent input without appending existing trailing text', (done) => { + textarea.value = '一二'; + // screenReaderMode keeps textarea content/selection for assistive technologies (eg. screen + // readers), so the caret can be moved within the textarea (eg. via arrow keys) before + // starting composition. + textarea.selectionStart = 1; + textarea.selectionEnd = 1; + + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: '一' }); + textarea.value = '一一二'; + // After the composed text is inserted, the caret typically moves to after it. + textarea.selectionStart = 2; + textarea.selectionEnd = 2; + + setTimeout(() => { // wait for any textarea updates + compositionHelper.compositionend(); + // Second character '1' (a non-composition character) + textarea.value = '一一1二'; + setTimeout(() => { // wait for any textarea updates + assert.equal(handledText, '一1'); + done(); + }, 0); + }, 0); + }); }); }); diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts index b4b03a842..62c32cb96 100644 --- a/src/browser/input/CompositionHelper.ts +++ b/src/browser/input/CompositionHelper.ts @@ -30,6 +30,12 @@ export class CompositionHelper { */ private _compositionPosition: IPosition; + /** + * Text that existed after the composing range when composition started. + * This is used to avoid treating existing trailing text as new input. + */ + private _compositionSuffix: string; + /** * Whether a composition is in the process of being sent, setting this to false will cancel any * in-progress composition. @@ -57,6 +63,7 @@ export class CompositionHelper { this._isComposing = false; this._isSendingComposition = false; this._compositionPosition = { start: 0, end: 0 }; + this._compositionSuffix = ''; this._dataAlreadySent = ''; } @@ -65,7 +72,11 @@ export class CompositionHelper { */ public compositionstart(): void { this._isComposing = true; - this._compositionPosition.start = this._textarea.value.length; + const start = this._textarea.selectionStart ?? this._textarea.value.length; + const end = this._textarea.selectionEnd ?? start; + this._compositionPosition.start = Math.min(start, end); + this._compositionPosition.end = Math.max(start, end); + this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end); this._compositionView.textContent = ''; this._dataAlreadySent = ''; this._compositionView.classList.add('active'); @@ -79,7 +90,8 @@ export class CompositionHelper { this._compositionView.textContent = ev.data; this.updateCompositionElements(); setTimeout(() => { - this._compositionPosition.end = this._textarea.value.length; + const end = this._textarea.selectionEnd ?? this._textarea.value.length; + this._compositionPosition.end = Math.max( this._compositionPosition.start, end); }, 0); } @@ -146,6 +158,7 @@ export class CompositionHelper { start: this._compositionPosition.start, end: this._compositionPosition.end }; + const currentCompositionSuffix = this._compositionSuffix; // Since composition* events happen before the changes take place in the textarea on most // browsers, use a setTimeout with 0ms time to allow the native compositionend event to @@ -169,10 +182,14 @@ export class CompositionHelper { // if a new composition has started. input = this._textarea.value.substring(currentCompositionPosition.start, this._compositionPosition.start); } else { - // Don't use the end position here in order to pick up any characters after the - // composition has finished, for example when typing a non-composition character - // (eg. 2) after a composition character. - input = this._textarea.value.substring(currentCompositionPosition.start); + // Keep support for non-composition characters typed immediately after composition end + // while avoiding re-sending the trailing text that was already present + // before composition started. + const value = this._textarea.value; + const valueEnd = currentCompositionSuffix.length > 0 && value.endsWith(currentCompositionSuffix) + ? value.length - currentCompositionSuffix.length + : value.length; + input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd)); } if (input.length > 0) { this._coreService.triggerDataEvent(input, true); From 1c1aac0da8feaa21a31c6aef74b002f9f4a88869 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:12:36 +0900 Subject: [PATCH 2/2] Update src/browser/input/CompositionHelper.ts --- src/browser/input/CompositionHelper.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts index 2830199bc..283874139 100644 --- a/src/browser/input/CompositionHelper.ts +++ b/src/browser/input/CompositionHelper.ts @@ -72,6 +72,8 @@ export class CompositionHelper { */ public compositionstart(): void { this._isComposing = true; + // It's important to use the selection here instead of textarea length to avoid conflicts with + // screen reader mode const start = this._textarea.selectionStart ?? this._textarea.value.length; const end = this._textarea.selectionEnd ?? start; this._compositionPosition.start = Math.min(start, end);