Merge pull request #5698 from ntomoya/fix/ime-middle-composition-suffix

Fix IME input when composing in the middle of the textarea
This commit is contained in:
Daniel Imms
2026-03-11 13:20:16 +09:00
committed by GitHub
2 changed files with 51 additions and 6 deletions
@@ -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);
});
});
});
+25 -6
View File
@@ -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);