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 0f4ef7789..283874139 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,13 @@ export class CompositionHelper { */ public compositionstart(): void { this._isComposing = true; - this._compositionPosition.start = this._textarea.value.length; + // 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); + 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'); @@ -81,7 +94,8 @@ export class CompositionHelper { this._compositionView.textContent = `\u200E${ev.data}\u200E`; 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); } @@ -148,6 +162,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 @@ -171,10 +186,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);