mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
The recorded composition extent tracks the preedit, so for any conversion engine it overshoots the commit it produced. Used as a floor it made the strict variant of the pending-input read exclude nothing, and the character typed straight after a commit went to the pty twice. Co-authored-by: Orca <help@stably.ai>
882 lines
34 KiB
Diff
882 lines
34 KiB
Diff
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
|
|
index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1e931791d 100644
|
|
--- a/src/browser/CoreBrowserTerminal.ts
|
|
+++ b/src/browser/CoreBrowserTerminal.ts
|
|
@@ -325,6 +325,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|
private _handleTextAreaBlur(): void {
|
|
// Text can safely be removed on blur. Doing it earlier could interfere with
|
|
// screen readers reading it out.
|
|
+ if (this._compositionHelper instanceof CompositionHelper) {
|
|
+ this._compositionHelper.blur();
|
|
+ }
|
|
this.textarea!.value = '';
|
|
this.refresh(this.buffer.y, this.buffer.y);
|
|
if (this.coreService.decPrivateModes.sendFocus) {
|
|
@@ -425,7 +428,18 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|
this._compositionHelper!.updateCompositionElements();
|
|
}));
|
|
this._register(addDisposableListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e)));
|
|
- this._register(addDisposableListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend()));
|
|
+ this._register(addDisposableListener(this.textarea!, 'compositionend', (e: CompositionEvent) => {
|
|
+ if (this._compositionHelper instanceof CompositionHelper) {
|
|
+ if (this._compositionHelper.compositionend(e)) {
|
|
+ this.textarea!.dispatchEvent(new CustomEvent(
|
|
+ 'xterm-composition-transaction-accepted',
|
|
+ { bubbles: true }
|
|
+ ));
|
|
+ }
|
|
+ } else {
|
|
+ this._compositionHelper!.compositionend();
|
|
+ }
|
|
+ }));
|
|
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
|
|
this._register(this.onRender(() => this._compositionHelper!.updateCompositionElements()));
|
|
}
|
|
@@ -551,6 +565,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|
this._compositionView = this._document.createElement('div');
|
|
this._compositionView.classList.add('composition-view');
|
|
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
|
|
+ this._register(toDisposable(() => {
|
|
+ if (this._compositionHelper instanceof CompositionHelper) {
|
|
+ this._compositionHelper.dispose();
|
|
+ }
|
|
+ }));
|
|
this._helperContainer.appendChild(this._compositionView);
|
|
|
|
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
|
|
@@ -1008,7 +1027,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|
|
|
this._onKey.fire({ key, domEvent: ev });
|
|
this._showCursor();
|
|
- this.coreService.triggerDataEvent(key, true);
|
|
+ if (!this._compositionHelper!.keypress?.(key)) {
|
|
+ this.coreService.triggerDataEvent(key, true);
|
|
+ }
|
|
|
|
this._keyPressHandled = true;
|
|
|
|
@@ -1026,6 +1047,15 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|
* @param ev The input event to be handled.
|
|
*/
|
|
protected _inputEvent(ev: InputEvent): boolean {
|
|
+ if (
|
|
+ ev.data &&
|
|
+ ev.inputType === 'insertText' &&
|
|
+ !this.optionsService.rawOptions.screenReaderMode &&
|
|
+ this._compositionHelper instanceof CompositionHelper &&
|
|
+ this._compositionHelper.input(ev.data)
|
|
+ ) {
|
|
+ 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
|
|
diff --git a/src/browser/Types.ts b/src/browser/Types.ts
|
|
index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc07dc6b07f 100644
|
|
--- a/src/browser/Types.ts
|
|
+++ b/src/browser/Types.ts
|
|
@@ -41,9 +41,10 @@ export interface ICompositionHelper {
|
|
readonly isComposing: boolean;
|
|
compositionstart(): void;
|
|
compositionupdate(ev: CompositionEvent): void;
|
|
- compositionend(): void;
|
|
+ compositionend(): boolean | void;
|
|
updateCompositionElements(dontRecurse?: boolean): void;
|
|
keydown(ev: KeyboardEvent): boolean;
|
|
+ keypress?(text: string): boolean;
|
|
}
|
|
|
|
export interface IBrowser {
|
|
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
|
|
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..665f5f81c56fc59d3bcbf19239ff93cbaea01f88 100644
|
|
--- a/src/browser/input/CompositionHelper.ts
|
|
+++ b/src/browser/input/CompositionHelper.ts
|
|
@@ -12,6 +12,28 @@ interface IPosition {
|
|
end: number;
|
|
}
|
|
|
|
+interface IPendingComposition {
|
|
+ transactionId: number;
|
|
+ finalizerTimer?: ReturnType<typeof setTimeout>;
|
|
+ lifecycleSettled: boolean;
|
|
+ sessionEnded: boolean;
|
|
+ position: IPosition;
|
|
+ suffix: string;
|
|
+ dataAlreadySent: string;
|
|
+ compositionData: string;
|
|
+ endData: string;
|
|
+ inputData: string;
|
|
+ keypressData: string;
|
|
+ keypressMayOverlapComposition: boolean;
|
|
+ expectsPostCompositionInput: boolean;
|
|
+ nextCompositionStart?: number;
|
|
+}
|
|
+
|
|
+const XTERM_COMPOSITION_SESSION_START_EVENT = 'xterm-composition-session-start';
|
|
+const XTERM_COMPOSITION_SESSION_END_EVENT = 'xterm-composition-session-end';
|
|
+const XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT =
|
|
+ 'xterm-composition-transaction-accepted';
|
|
+
|
|
/**
|
|
* Encapsulates the logic for handling compositionstart, compositionupdate and compositionend
|
|
* events, displaying the in-progress composition to the UI and forwarding the final composition
|
|
@@ -24,6 +46,15 @@ export class CompositionHelper {
|
|
*/
|
|
private _isComposing: boolean;
|
|
public get isComposing(): boolean { return this._isComposing; }
|
|
+ public get hasPendingCompositionFinalization(): boolean {
|
|
+ return this._pendingComposition !== undefined;
|
|
+ }
|
|
+ public get _isSendingComposition(): boolean {
|
|
+ return this.hasPendingCompositionFinalization;
|
|
+ }
|
|
+ public get _pendingKeypressData(): string {
|
|
+ return this._pendingComposition?.keypressData ?? '';
|
|
+ }
|
|
|
|
/**
|
|
* The position within the input textarea's value of the current composition.
|
|
@@ -36,22 +67,48 @@ export class CompositionHelper {
|
|
*/
|
|
private _compositionSuffix: string;
|
|
|
|
- /**
|
|
- * Whether a composition is in the process of being sent, setting this to false will cancel any
|
|
- * in-progress composition.
|
|
- */
|
|
- private _isSendingComposition: boolean;
|
|
-
|
|
/**
|
|
* Data already sent due to keydown event.
|
|
*/
|
|
private _dataAlreadySent: string;
|
|
|
|
+ private _pendingComposition?: IPendingComposition;
|
|
+
|
|
+ private _isAwaitingCompositionEnd: boolean;
|
|
+
|
|
+ private _compositionInputData: string;
|
|
+
|
|
+ private _lastCompositionData: string;
|
|
+
|
|
+ private _compositionStartValue: string;
|
|
+
|
|
+ private _compositionStartSelection: IPosition;
|
|
+
|
|
+ private _compositionHasObservedProgress: boolean;
|
|
+
|
|
+ private _canceledKey?: Pick<KeyboardEvent, 'code' | 'timeStamp'>;
|
|
+
|
|
/**
|
|
* The pending textarea change timer, if any.
|
|
*/
|
|
private _textareaChangeTimer?: number;
|
|
|
|
+ /**
|
|
+ * Identifies the composition transaction that owns deferred work.
|
|
+ */
|
|
+ private _compositionTransactionId: number;
|
|
+
|
|
+ /**
|
|
+ * Timers that still own deferred composition state.
|
|
+ */
|
|
+ private _compositionTimers: Set<ReturnType<typeof setTimeout>>;
|
|
+
|
|
+ private _compositionPositionTimer?: ReturnType<typeof setTimeout>;
|
|
+
|
|
+ private _compositionViewTimer?: ReturnType<typeof setTimeout>;
|
|
+
|
|
+ private _compositionEndTimer?: ReturnType<typeof setTimeout>;
|
|
+
|
|
constructor(
|
|
private readonly _textarea: HTMLTextAreaElement,
|
|
private readonly _compositionView: HTMLElement,
|
|
@@ -61,27 +118,63 @@ export class CompositionHelper {
|
|
@IRenderService private readonly _renderService: IRenderService
|
|
) {
|
|
this._isComposing = false;
|
|
- this._isSendingComposition = false;
|
|
+ this._isAwaitingCompositionEnd = false;
|
|
this._compositionPosition = { start: 0, end: 0 };
|
|
this._compositionSuffix = '';
|
|
this._dataAlreadySent = '';
|
|
+ this._compositionInputData = '';
|
|
+ this._lastCompositionData = '';
|
|
+ this._compositionStartValue = '';
|
|
+ this._compositionStartSelection = { start: 0, end: 0 };
|
|
+ this._compositionHasObservedProgress = false;
|
|
+ this._compositionTransactionId = 0;
|
|
+ this._compositionTimers = new Set();
|
|
}
|
|
|
|
/**
|
|
* Handles the compositionstart event, activating the composition view.
|
|
*/
|
|
public compositionstart(): void {
|
|
- this._isComposing = true;
|
|
+ this._cancelDeferredTimer(this._compositionPositionTimer);
|
|
+ this._compositionPositionTimer = undefined;
|
|
+ this._cancelDeferredTimer(this._compositionViewTimer);
|
|
+ this._compositionViewTimer = undefined;
|
|
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
|
+ this._compositionEndTimer = undefined;
|
|
+ if (this._pendingComposition) {
|
|
+ this._cancelPendingFinalizer(this._pendingComposition);
|
|
+ this._endPendingCompositionSession(this._pendingComposition);
|
|
+ this._settlePendingComposition(this._pendingComposition);
|
|
+ }
|
|
+ if (this._textareaChangeTimer !== undefined) {
|
|
+ clearTimeout(this._textareaChangeTimer);
|
|
+ this._textareaChangeTimer = undefined;
|
|
+ }
|
|
// 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._compositionStartValue = this._textarea.value;
|
|
+ this._compositionStartSelection = { start, end };
|
|
+ this._compositionHasObservedProgress = false;
|
|
+ if (this._pendingComposition) {
|
|
+ this._pendingComposition.nextCompositionStart = this._compositionPosition.start;
|
|
+ }
|
|
+ this._compositionTransactionId++;
|
|
+ this._isComposing = true;
|
|
+ this._isAwaitingCompositionEnd = true;
|
|
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
|
|
this._compositionView.textContent = '';
|
|
this._dataAlreadySent = '';
|
|
+ this._compositionInputData = '';
|
|
+ this._lastCompositionData = '';
|
|
this._compositionView.classList.add('active');
|
|
+ this._dispatchCompositionSessionEvent(new CustomEvent(XTERM_COMPOSITION_SESSION_START_EVENT, {
|
|
+ bubbles: true,
|
|
+ detail: { id: this._compositionTransactionId }
|
|
+ }));
|
|
}
|
|
|
|
/**
|
|
@@ -89,22 +182,87 @@ export class CompositionHelper {
|
|
* @param ev The event.
|
|
*/
|
|
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
|
|
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
|
+ this._compositionEndTimer = undefined;
|
|
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
|
+ if (ev.data?.length > 0) {
|
|
+ this._lastCompositionData = ev.data;
|
|
+ }
|
|
// Mark text as LTR, direction=rtl is used in CSS so the end of the text is followed for long
|
|
// compositions
|
|
- this._compositionView.textContent = `\u200E${ev.data}\u200E`;
|
|
+ this._compositionView.textContent = `\u200E${ev.data ?? ''}\u200E`;
|
|
this.updateCompositionElements();
|
|
- setTimeout(() => {
|
|
- const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
|
- this._compositionPosition.end = Math.max( this._compositionPosition.start, end);
|
|
- }, 0);
|
|
+ const transactionId = this._compositionTransactionId;
|
|
+ this._cancelDeferredTimer(this._compositionPositionTimer);
|
|
+ this._compositionPositionTimer = this._defer(() => {
|
|
+ if (this._isComposing && this._compositionTransactionId === transactionId) {
|
|
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
|
+ const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
|
+ this._compositionPosition.end = Math.max(this._compositionPosition.start, end);
|
|
+ }
|
|
+ });
|
|
}
|
|
|
|
/**
|
|
* Handles the compositionend event, hiding the composition view and sending the composition to
|
|
* the handler.
|
|
*/
|
|
- public compositionend(): void {
|
|
- this._finalizeComposition(true);
|
|
+ public compositionend(ev?: Pick<CompositionEvent, 'data'>): boolean {
|
|
+ if (!this._isAwaitingCompositionEnd) {
|
|
+ return false;
|
|
+ }
|
|
+ if (!this._isComposing) {
|
|
+ const pending = this._pendingComposition;
|
|
+ if (pending?.transactionId === this._compositionTransactionId) {
|
|
+ pending.endData = ev?.data ?? '';
|
|
+ this._updatePostCompositionInputExpectation(pending);
|
|
+ }
|
|
+ return false;
|
|
+ }
|
|
+ const endData = ev?.data ?? '';
|
|
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
|
+ if (!this._compositionEndBelongsToCurrentTransaction(endData)) {
|
|
+ const pending = this._pendingComposition;
|
|
+ if (pending && pending.transactionId !== this._compositionTransactionId) {
|
|
+ this._sendPendingComposition(pending);
|
|
+ }
|
|
+ this._deferCompositionEnd(endData);
|
|
+ return false;
|
|
+ }
|
|
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
|
+ this._compositionEndTimer = undefined;
|
|
+ this._finalizeComposition(true, endData);
|
|
+ return true;
|
|
+ }
|
|
+
|
|
+ public blur(): void {
|
|
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
|
+ this._compositionEndTimer = undefined;
|
|
+ if (this._isComposing) {
|
|
+ const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
|
+ this._compositionPosition.end = Math.max(this._compositionPosition.start, end);
|
|
+ }
|
|
+ if (this._isComposing || this.hasPendingCompositionFinalization) {
|
|
+ this._finalizeComposition(false);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ public dispose(): void {
|
|
+ if (this._textareaChangeTimer !== undefined) {
|
|
+ clearTimeout(this._textareaChangeTimer);
|
|
+ this._textareaChangeTimer = undefined;
|
|
+ }
|
|
+ for (const timer of this._compositionTimers) {
|
|
+ clearTimeout(timer);
|
|
+ }
|
|
+ this._compositionTimers.clear();
|
|
+ this._compositionPositionTimer = undefined;
|
|
+ this._compositionViewTimer = undefined;
|
|
+ this._compositionEndTimer = undefined;
|
|
+ this._pendingComposition = undefined;
|
|
+ this._isAwaitingCompositionEnd = false;
|
|
+ this._isComposing = false;
|
|
+ this._compositionTransactionId++;
|
|
}
|
|
|
|
/**
|
|
@@ -113,7 +271,16 @@ export class CompositionHelper {
|
|
* @returns Whether the Terminal should continue processing the keydown event.
|
|
*/
|
|
public keydown(ev: KeyboardEvent): boolean {
|
|
- if (this._isComposing || this._isSendingComposition) {
|
|
+ if (this._canceledKey?.code === ev.code && this._canceledKey.timeStamp === ev.timeStamp) {
|
|
+ this._canceledKey = undefined;
|
|
+ return false;
|
|
+ }
|
|
+ if (ev.key === 'Escape' && (this._isComposing || this.hasPendingCompositionFinalization)) {
|
|
+ this._canceledKey = { code: ev.code, timeStamp: ev.timeStamp };
|
|
+ this._cancelComposition();
|
|
+ return false;
|
|
+ }
|
|
+ if (this._isComposing || this.hasPendingCompositionFinalization) {
|
|
if (ev.keyCode === 20 || ev.keyCode === 229) {
|
|
// 20 is CapsLock, 229 is Enter
|
|
// Continue composing if the keyCode is the "composition character"
|
|
@@ -138,6 +305,54 @@ export class CompositionHelper {
|
|
return true;
|
|
}
|
|
|
|
+ /**
|
|
+ * Defers keypress text while a composition finalizer is pending so all input is emitted once
|
|
+ * after reconciliation with the final textarea candidate.
|
|
+ */
|
|
+ public keypress(text: string): boolean {
|
|
+ const pending = this._pendingComposition;
|
|
+ if (!pending) {
|
|
+ return false;
|
|
+ }
|
|
+ if (pending.keypressMayOverlapComposition) {
|
|
+ pending.keypressData += text;
|
|
+ return true;
|
|
+ }
|
|
+ if (pending.expectsPostCompositionInput && pending.keypressData.length === 0) {
|
|
+ pending.keypressData = text;
|
|
+ return true;
|
|
+ }
|
|
+ this._sendPendingComposition(pending);
|
|
+ return false;
|
|
+ }
|
|
+
|
|
+ public input(text: string): boolean {
|
|
+ if (this._isComposing) {
|
|
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
|
+ this._compositionInputData += text;
|
|
+ return true;
|
|
+ }
|
|
+ const pending = this._pendingComposition;
|
|
+ if (!pending) {
|
|
+ return false;
|
|
+ }
|
|
+ if (pending.expectsPostCompositionInput) {
|
|
+ pending.inputData += text;
|
|
+ pending.expectsPostCompositionInput = false;
|
|
+ this._sendPendingComposition(pending);
|
|
+ return true;
|
|
+ }
|
|
+ const repeatsPendingTextareaInput =
|
|
+ text.length > 0 &&
|
|
+ this._getPendingTextareaInput(pending) === text &&
|
|
+ this._getPendingTextareaInput(pending, true) === text;
|
|
+ this._sendPendingComposition(pending);
|
|
+ if (!repeatsPendingTextareaInput) {
|
|
+ this._coreService.triggerDataEvent(text, true);
|
|
+ }
|
|
+ return true;
|
|
+ }
|
|
+
|
|
/**
|
|
* Finalizes the composition, resuming regular input actions. This is called when a composition
|
|
* is ending.
|
|
@@ -146,23 +361,49 @@ export class CompositionHelper {
|
|
* compositionend event is triggered, such as enter, so that the composition is sent before
|
|
* the command is executed.
|
|
*/
|
|
- private _finalizeComposition(waitForPropagation: boolean): void {
|
|
+ private _finalizeComposition(waitForPropagation: boolean, endData: string = ''): void {
|
|
+ const wasComposing = this._isComposing;
|
|
this._compositionView.classList.remove('active');
|
|
this._isComposing = false;
|
|
+ if (waitForPropagation && !wasComposing) {
|
|
+ return;
|
|
+ }
|
|
|
|
if (!waitForPropagation) {
|
|
- // Cancel any delayed composition send requests and send the input immediately.
|
|
- this._isSendingComposition = false;
|
|
- const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
|
|
- this._coreService.triggerDataEvent(input, true);
|
|
+ if (this._pendingComposition) {
|
|
+ this._sendPendingComposition(this._pendingComposition, true);
|
|
+ }
|
|
+ if (wasComposing) {
|
|
+ const input = this._getCompositionInput(
|
|
+ this._compositionPosition.start + this._dataAlreadySent.length,
|
|
+ this._compositionSuffix
|
|
+ );
|
|
+ this._sendCompositionInput(this._compositionTransactionId, input);
|
|
+ }
|
|
} else {
|
|
- // Make a deep copy of the composition position here as a new compositionstart event may
|
|
- // fire before the setTimeout executes.
|
|
- const currentCompositionPosition = {
|
|
- start: this._compositionPosition.start,
|
|
- end: this._compositionPosition.end
|
|
+ if (this._pendingComposition) {
|
|
+ this._sendPendingComposition(this._pendingComposition);
|
|
+ }
|
|
+ const pending: IPendingComposition = {
|
|
+ transactionId: this._compositionTransactionId,
|
|
+ lifecycleSettled: false,
|
|
+ sessionEnded: false,
|
|
+ position: {
|
|
+ start: this._compositionPosition.start,
|
|
+ end: this._compositionPosition.end
|
|
+ },
|
|
+ suffix: this._compositionSuffix,
|
|
+ dataAlreadySent: this._dataAlreadySent,
|
|
+ compositionData: this._lastCompositionData,
|
|
+ endData,
|
|
+ inputData: this._compositionInputData,
|
|
+ keypressData: '',
|
|
+ keypressMayOverlapComposition:
|
|
+ this._lastCompositionData.length === 0 && endData.length === 0,
|
|
+ expectsPostCompositionInput: false
|
|
};
|
|
- const currentCompositionSuffix = this._compositionSuffix;
|
|
+ this._updatePostCompositionInputExpectation(pending);
|
|
+ this._pendingComposition = pending;
|
|
|
|
// 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
|
|
@@ -172,35 +413,282 @@ export class CompositionHelper {
|
|
// - The last compositionupdate event's data property does not always accurately describe
|
|
// the character, a counter example being Korean where an ending consonsant can move to
|
|
// the following character if the following input is a vowel.
|
|
- this._isSendingComposition = true;
|
|
- setTimeout(() => {
|
|
- // Ensure that the input has not already been sent
|
|
- if (this._isSendingComposition) {
|
|
- this._isSendingComposition = false;
|
|
- let input;
|
|
- // Add length of data already sent due to keydown event,
|
|
- // otherwise input characters can be duplicated. (Issue #3191)
|
|
- currentCompositionPosition.start += this._dataAlreadySent.length;
|
|
- if (this._isComposing) {
|
|
- // Use the start position of the new composition to get the string
|
|
- // if a new composition has started.
|
|
- input = this._textarea.value.substring(currentCompositionPosition.start, this._compositionPosition.start);
|
|
- } else {
|
|
- // 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);
|
|
- }
|
|
+ pending.finalizerTimer = this._defer(() => {
|
|
+ pending.finalizerTimer = undefined;
|
|
+ if (this._compositionTransactionId === pending.transactionId) {
|
|
+ this._isAwaitingCompositionEnd = false;
|
|
+ }
|
|
+ if (this._pendingComposition === pending) {
|
|
+ this._sendPendingComposition(pending, true);
|
|
}
|
|
- }, 0);
|
|
+ });
|
|
+ }
|
|
+ }
|
|
+
|
|
+ private _sendPendingComposition(
|
|
+ pending: IPendingComposition,
|
|
+ includeFollowingInput: boolean = false
|
|
+ ): void {
|
|
+ this._cancelPendingFinalizer(pending);
|
|
+ if (this._pendingComposition === pending) {
|
|
+ this._pendingComposition = undefined;
|
|
+ }
|
|
+ const textareaInput = this._getPendingTextareaInput(pending, includeFollowingInput);
|
|
+ const observedInput = this._removeAlreadySentData(
|
|
+ pending.inputData || pending.keypressData,
|
|
+ pending.dataAlreadySent
|
|
+ );
|
|
+ const input = this._mergeTextObservations(
|
|
+ textareaInput || pending.endData || pending.compositionData,
|
|
+ observedInput,
|
|
+ pending.keypressMayOverlapComposition
|
|
+ );
|
|
+ this._sendCompositionInput(pending.transactionId, input, !pending.sessionEnded);
|
|
+ this._settlePendingComposition(pending);
|
|
+ }
|
|
+
|
|
+ private _cancelPendingFinalizer(pending: IPendingComposition): void {
|
|
+ if (pending.finalizerTimer === undefined) {
|
|
+ return;
|
|
+ }
|
|
+ clearTimeout(pending.finalizerTimer);
|
|
+ this._compositionTimers.delete(pending.finalizerTimer);
|
|
+ pending.finalizerTimer = undefined;
|
|
+ }
|
|
+
|
|
+ private _settlePendingComposition(pending: IPendingComposition): void {
|
|
+ if (pending.lifecycleSettled) {
|
|
+ return;
|
|
+ }
|
|
+ pending.lifecycleSettled = true;
|
|
+ this._dispatchCompositionTransactionSettled();
|
|
+ }
|
|
+
|
|
+ private _mergeTextObservations(
|
|
+ candidate: string,
|
|
+ observed: string,
|
|
+ findShortestOrder: boolean
|
|
+ ): string {
|
|
+ if (!observed || candidate.includes(observed)) {
|
|
+ return candidate;
|
|
+ }
|
|
+ if (!candidate || observed.includes(candidate)) {
|
|
+ return observed;
|
|
+ }
|
|
+ if (findShortestOrder) {
|
|
+ let candidateFirstOverlap = Math.min(candidate.length, observed.length);
|
|
+ while (
|
|
+ candidateFirstOverlap > 0 &&
|
|
+ !candidate.endsWith(observed.substring(0, candidateFirstOverlap))
|
|
+ ) {
|
|
+ candidateFirstOverlap--;
|
|
+ }
|
|
+ let observedFirstOverlap = Math.min(candidate.length, observed.length);
|
|
+ while (
|
|
+ observedFirstOverlap > 0 &&
|
|
+ !observed.endsWith(candidate.substring(0, observedFirstOverlap))
|
|
+ ) {
|
|
+ observedFirstOverlap--;
|
|
+ }
|
|
+ return candidateFirstOverlap > observedFirstOverlap
|
|
+ ? candidate + observed.substring(candidateFirstOverlap)
|
|
+ : observed + candidate.substring(observedFirstOverlap);
|
|
+ }
|
|
+ let overlap = Math.min(candidate.length, observed.length);
|
|
+ while (overlap > 0 && !candidate.endsWith(observed.substring(0, overlap))) {
|
|
+ overlap--;
|
|
+ }
|
|
+ return candidate + observed.substring(overlap);
|
|
+ }
|
|
+
|
|
+ private _updatePostCompositionInputExpectation(pending: IPendingComposition): void {
|
|
+ pending.expectsPostCompositionInput =
|
|
+ (pending.endData.length > 0 || pending.compositionData.length > 0) &&
|
|
+ pending.inputData.length === 0 &&
|
|
+ this._getPendingTextareaInput(pending).length === 0;
|
|
+ }
|
|
+
|
|
+ private _getPendingTextareaInput(
|
|
+ pending: IPendingComposition,
|
|
+ includeFollowingInput: boolean = false
|
|
+ ): string {
|
|
+ const value = this._textarea.value;
|
|
+ const start = pending.position.start + pending.dataAlreadySent.length;
|
|
+ if (pending.nextCompositionStart !== undefined) {
|
|
+ return value.substring(start, Math.max(start, pending.nextCompositionStart));
|
|
+ }
|
|
+ const suffixEnd =
|
|
+ pending.suffix.length > 0 && value.endsWith(pending.suffix)
|
|
+ ? value.length - pending.suffix.length
|
|
+ : value.length;
|
|
+ const compositionLength = (pending.endData || pending.compositionData).length;
|
|
+ // position.end recorded the *preedit* extent, which overshoots for every conversion
|
|
+ // engine: "nihao" commits "你好". Once the commit length is known it is the extent, not
|
|
+ // a floor, or this excludes nothing and the caller re-sends the text that followed.
|
|
+ const observedEnd = includeFollowingInput
|
|
+ ? suffixEnd
|
|
+ : compositionLength > 0
|
|
+ ? start + compositionLength
|
|
+ : pending.position.end;
|
|
+ return value.substring(start, Math.max(start, Math.min(suffixEnd, observedEnd)));
|
|
+ }
|
|
+
|
|
+ private _getCompositionInput(start: number, suffix: string): string {
|
|
+ const value = this._textarea.value;
|
|
+ const valueEnd =
|
|
+ suffix.length > 0 && value.endsWith(suffix) ? value.length - suffix.length : value.length;
|
|
+ return value.substring(start, Math.max(start, valueEnd));
|
|
+ }
|
|
+
|
|
+ private _removeAlreadySentData(input: string, dataAlreadySent: string): string {
|
|
+ if (dataAlreadySent.length === 0) {
|
|
+ return input;
|
|
+ }
|
|
+ if (input.startsWith(dataAlreadySent)) {
|
|
+ return input.substring(dataAlreadySent.length);
|
|
}
|
|
+ return dataAlreadySent.includes(input) ? '' : input;
|
|
+ }
|
|
+
|
|
+ private _cancelComposition(): void {
|
|
+ const pending = this._pendingComposition;
|
|
+ if (
|
|
+ pending &&
|
|
+ this._isComposing &&
|
|
+ pending.transactionId !== this._compositionTransactionId
|
|
+ ) {
|
|
+ this._sendPendingComposition(pending);
|
|
+ }
|
|
+ const transactionId = this._isComposing
|
|
+ ? this._compositionTransactionId
|
|
+ : this._pendingComposition?.transactionId ?? 0;
|
|
+ const settlesPending = pending !== undefined && this._pendingComposition === pending;
|
|
+ this._pendingComposition = undefined;
|
|
+ this._isAwaitingCompositionEnd = false;
|
|
+ this._isComposing = false;
|
|
+ this._compositionView.classList.remove('active');
|
|
+ this._textarea.value =
|
|
+ this._textarea.value.substring(0, this._compositionPosition.start) + this._compositionSuffix;
|
|
+ this._sendCompositionInput(transactionId, '');
|
|
+ if (settlesPending && pending) {
|
|
+ this._settlePendingComposition(pending);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ private _sendCompositionInput(
|
|
+ transactionId: number,
|
|
+ input: string,
|
|
+ dispatchSessionEnd: boolean = true
|
|
+ ): void {
|
|
+ let prevented = false;
|
|
+ if (dispatchSessionEnd) {
|
|
+ const event = new CustomEvent(XTERM_COMPOSITION_SESSION_END_EVENT, {
|
|
+ bubbles: true,
|
|
+ cancelable: true,
|
|
+ detail: { id: transactionId, data: input }
|
|
+ });
|
|
+ this._dispatchCompositionSessionEvent(event);
|
|
+ prevented = event.defaultPrevented;
|
|
+ }
|
|
+ if (input.length > 0 && !prevented) {
|
|
+ this._coreService.triggerDataEvent(input, true);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ private _endPendingCompositionSession(pending: IPendingComposition): void {
|
|
+ if (pending.sessionEnded) {
|
|
+ return;
|
|
+ }
|
|
+ pending.sessionEnded = true;
|
|
+ const input =
|
|
+ this._getPendingTextareaInput(pending) ||
|
|
+ pending.endData ||
|
|
+ pending.compositionData;
|
|
+ this._dispatchCompositionSessionEvent(new CustomEvent(
|
|
+ XTERM_COMPOSITION_SESSION_END_EVENT,
|
|
+ {
|
|
+ bubbles: true,
|
|
+ cancelable: true,
|
|
+ detail: {
|
|
+ id: pending.transactionId,
|
|
+ data: input,
|
|
+ dataPendingReconciliation: true
|
|
+ }
|
|
+ }
|
|
+ ));
|
|
+ }
|
|
+
|
|
+ private _dispatchCompositionSessionEvent(event: CustomEvent): void {
|
|
+ if (typeof this._textarea.dispatchEvent === 'function') {
|
|
+ this._textarea.dispatchEvent(event);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ private _dispatchCompositionTransactionSettled(): void {
|
|
+ this._dispatchCompositionSessionEvent(new CustomEvent(
|
|
+ 'xterm-composition-transaction-settled',
|
|
+ { bubbles: true }
|
|
+ ));
|
|
+ }
|
|
+
|
|
+ private _deferCompositionEnd(endData: string): void {
|
|
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
|
+ const transactionId = this._compositionTransactionId;
|
|
+ const timer = this._defer(() => {
|
|
+ if (
|
|
+ this._compositionEndTimer !== timer ||
|
|
+ !this._isComposing ||
|
|
+ this._compositionTransactionId !== transactionId ||
|
|
+ !this._compositionEndBelongsToCurrentTransaction(endData)
|
|
+ ) {
|
|
+ return;
|
|
+ }
|
|
+ this._compositionEndTimer = undefined;
|
|
+ this._finalizeComposition(true, endData);
|
|
+ this._dispatchCompositionSessionEvent(new CustomEvent(
|
|
+ XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
|
|
+ { bubbles: true }
|
|
+ ));
|
|
+ const pending = this._pendingComposition;
|
|
+ if (pending?.transactionId === transactionId) {
|
|
+ this._sendPendingComposition(pending, true);
|
|
+ }
|
|
+ });
|
|
+ this._compositionEndTimer = timer;
|
|
+ }
|
|
+
|
|
+ private _hasCompositionProgress(): boolean {
|
|
+ const start = this._textarea.selectionStart ?? this._textarea.value.length;
|
|
+ const end = this._textarea.selectionEnd ?? start;
|
|
+ return this._compositionHasObservedProgress || (
|
|
+ this._textarea.value !== this._compositionStartValue ||
|
|
+ start !== this._compositionStartSelection.start ||
|
|
+ end !== this._compositionStartSelection.end
|
|
+ );
|
|
+ }
|
|
+
|
|
+ private _compositionEndBelongsToCurrentTransaction(endData: string): boolean {
|
|
+ return (
|
|
+ this._hasCompositionProgress() ||
|
|
+ (endData.length > 0 && endData === this._lastCompositionData)
|
|
+ );
|
|
+ }
|
|
+
|
|
+ private _defer(callback: () => void): ReturnType<typeof setTimeout> {
|
|
+ const timer = setTimeout(() => {
|
|
+ this._compositionTimers.delete(timer);
|
|
+ callback();
|
|
+ }, 0);
|
|
+ this._compositionTimers.add(timer);
|
|
+ return timer;
|
|
+ }
|
|
+
|
|
+ private _cancelDeferredTimer(timer?: ReturnType<typeof setTimeout>): void {
|
|
+ if (timer === undefined) {
|
|
+ return;
|
|
+ }
|
|
+ clearTimeout(timer);
|
|
+ this._compositionTimers.delete(timer);
|
|
}
|
|
|
|
/**
|
|
@@ -214,21 +702,39 @@ export class CompositionHelper {
|
|
return;
|
|
}
|
|
const oldValue = this._textarea.value;
|
|
+ // Record the caret too, so an insertion is read as the slice between two known
|
|
+ // offsets instead of a diff of two buffer observations. `newValue.replace(oldValue, '')`
|
|
+ // dropped the first occurrence of the old value *anywhere* in the new one, so any
|
|
+ // insertion that was not a pure append produced mangled text: old "ab" with new "xaby"
|
|
+ // yielded "xy". Orca routes bare keydown-229 here on macOS and Linux, which is the
|
|
+ // non-composition punctuation and digit path for an active IME.
|
|
+ const oldSelectionStart = this._textarea.selectionStart ?? oldValue.length;
|
|
this._textareaChangeTimer = window.setTimeout(() => {
|
|
this._textareaChangeTimer = undefined;
|
|
// Ignore if a composition has started since the timeout
|
|
if (!this._isComposing) {
|
|
const newValue = this._textarea.value;
|
|
|
|
- const diff = newValue.replace(oldValue, '');
|
|
-
|
|
- this._dataAlreadySent = diff;
|
|
+ // Keep _dataAlreadySent equal to what was actually sent — _finalizeComposition
|
|
+ // advances the composition start by its length to avoid re-sending (Issue #3191),
|
|
+ // so any other value shifts that offset by text the pty never received.
|
|
+ this._dataAlreadySent = '';
|
|
|
|
if (newValue.length > oldValue.length) {
|
|
- this._coreService.triggerDataEvent(diff, true);
|
|
+ const newSelectionEnd = this._textarea.selectionEnd ?? newValue.length;
|
|
+ const grew = newValue.length - oldValue.length;
|
|
+ if (newSelectionEnd < oldSelectionStart || grew !== newSelectionEnd - oldSelectionStart) {
|
|
+ // The caret does not account for the growth, so which characters are new is
|
|
+ // ambiguous. Emit nothing rather than guess text straight into the pty.
|
|
+ return;
|
|
+ }
|
|
+ const inserted = newValue.substring(oldSelectionStart, newSelectionEnd);
|
|
+ this._dataAlreadySent = inserted;
|
|
+ this._coreService.triggerDataEvent(inserted, true);
|
|
} else if (newValue.length < oldValue.length) {
|
|
this._coreService.triggerDataEvent(`${C0.DEL}`, true);
|
|
} else if ((newValue.length === oldValue.length) && (newValue !== oldValue)) {
|
|
+ this._dataAlreadySent = newValue;
|
|
this._coreService.triggerDataEvent(newValue, true);
|
|
}
|
|
|
|
@@ -278,7 +784,8 @@ export class CompositionHelper {
|
|
}
|
|
|
|
if (!dontRecurse) {
|
|
- setTimeout(() => this.updateCompositionElements(true), 0);
|
|
+ this._cancelDeferredTimer(this._compositionViewTimer);
|
|
+ this._compositionViewTimer = this._defer(() => this.updateCompositionElements(true));
|
|
}
|
|
}
|
|
}
|
|
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
|
|
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
|
|
--- a/src/common/SortedList.ts
|
|
+++ b/src/common/SortedList.ts
|
|
@@ -87,6 +87,24 @@ export class SortedList<T> {
|
|
if (key === undefined) {
|
|
return false;
|
|
}
|
|
+ if (this._deleteAtKey(value, key)) {
|
|
+ return true;
|
|
+ }
|
|
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
|
|
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
|
|
+ // order, so the binary search above can miss a value that is present.
|
|
+ // Compacting those entries out restores the order; retry before reporting
|
|
+ // the value absent, else its `onDecorationRemoved` never fires and the
|
|
+ // decoration paints forever. Miss path only, so the common bulk delete
|
|
+ // keeps its O(log n) search and deferred-compaction batching.
|
|
+ if (this._deletedIndices.length === 0) {
|
|
+ return false;
|
|
+ }
|
|
+ this._flushCleanupDeleted();
|
|
+ return this._deleteAtKey(value, key);
|
|
+ }
|
|
+
|
|
+ private _deleteAtKey(value: T, key: number): boolean {
|
|
i = this._search(key);
|
|
if (i === -1) {
|
|
return false;
|