2 * Copyright (c) 2016 The xterm.js authors. All rights reserved.
6 import { ICharSizeService } from 'browser/services/Services';
7 import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services';
15 * Encapsulates the logic for handling compositionstart, compositionupdate and compositionend
16 * events, displaying the in-progress composition to the UI and forwarding the final composition
19 export class CompositionHelper {
21 * Whether input composition is currently happening, eg. via a mobile keyboard, speech input or
22 * IME. This variable determines whether the compositionText should be displayed on the UI.
24 private _isComposing: boolean;
27 * The position within the input textarea's value of the current composition.
29 private _compositionPosition: IPosition;
32 * Whether a composition is in the process of being sent, setting this to false will cancel any
33 * in-progress composition.
35 private _isSendingComposition: boolean;
38 private readonly _textarea: HTMLTextAreaElement,
39 private readonly _compositionView: HTMLElement,
40 @IBufferService private readonly _bufferService: IBufferService,
41 @IOptionsService private readonly _optionsService: IOptionsService,
42 @ICharSizeService private readonly _charSizeService: ICharSizeService,
43 @ICoreService private readonly _coreService: ICoreService
45 this._isComposing = false;
46 this._isSendingComposition = false;
47 this._compositionPosition = { start: 0, end: 0 };
51 * Handles the compositionstart event, activating the composition view.
53 public compositionstart(): void {
54 this._isComposing = true;
55 this._compositionPosition.start = this._textarea.value.length;
56 this._compositionView.textContent = '';
57 this._compositionView.classList.add('active');
61 * Handles the compositionupdate event, updating the composition view.
62 * @param ev The event.
64 public compositionupdate(ev: CompositionEvent): void {
65 this._compositionView.textContent = ev.data;
66 this.updateCompositionElements();
68 this._compositionPosition.end = this._textarea.value.length;
73 * Handles the compositionend event, hiding the composition view and sending the composition to
76 public compositionend(): void {
77 this._finalizeComposition(true);
81 * Handles the keydown event, routing any necessary events to the CompositionHelper functions.
82 * @param ev The keydown event.
83 * @return Whether the Terminal should continue processing the keydown event.
85 public keydown(ev: KeyboardEvent): boolean {
86 if (this._isComposing || this._isSendingComposition) {
87 if (ev.keyCode === 229) {
88 // Continue composing if the keyCode is the "composition character"
90 } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
91 // Continue composing if the keyCode is a modifier key
94 // Finish composition immediately. This is mainly here for the case where enter is
95 // pressed and the handler needs to be triggered before the command is executed.
96 this._finalizeComposition(false);
99 if (ev.keyCode === 229) {
100 // If the "composition character" is used but gets to this point it means a non-composition
101 // character (eg. numbers and punctuation) was pressed when the IME was active.
102 this._handleAnyTextareaChanges();
110 * Finalizes the composition, resuming regular input actions. This is called when a composition
112 * @param waitForPropagation Whether to wait for events to propagate before sending
113 * the input. This should be false if a non-composition keystroke is entered before the
114 * compositionend event is triggered, such as enter, so that the composition is sent before
115 * the command is executed.
117 private _finalizeComposition(waitForPropagation: boolean): void {
118 this._compositionView.classList.remove('active');
119 this._isComposing = false;
120 this._clearTextareaPosition();
122 if (!waitForPropagation) {
123 // Cancel any delayed composition send requests and send the input immediately.
124 this._isSendingComposition = false;
125 const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
126 this._coreService.triggerDataEvent(input, true);
128 // Make a deep copy of the composition position here as a new compositionstart event may
129 // fire before the setTimeout executes.
130 const currentCompositionPosition = {
131 start: this._compositionPosition.start,
132 end: this._compositionPosition.end
135 // Since composition* events happen before the changes take place in the textarea on most
136 // browsers, use a setTimeout with 0ms time to allow the native compositionend event to
137 // complete. This ensures the correct character is retrieved.
138 // This solution was used because:
139 // - The compositionend event's data property is unreliable, at least on Chromium
140 // - The last compositionupdate event's data property does not always accurately describe
141 // the character, a counter example being Korean where an ending consonsant can move to
142 // the following character if the following input is a vowel.
143 this._isSendingComposition = true;
145 // Ensure that the input has not already been sent
146 if (this._isSendingComposition) {
147 this._isSendingComposition = false;
149 if (this._isComposing) {
150 // Use the end position to get the string if a new composition has started.
151 input = this._textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end);
153 // Don't use the end position here in order to pick up any characters after the
154 // composition has finished, for example when typing a non-composition character
155 // (eg. 2) after a composition character.
156 input = this._textarea.value.substring(currentCompositionPosition.start);
158 this._coreService.triggerDataEvent(input, true);
165 * Apply any changes made to the textarea after the current event chain is allowed to complete.
166 * This should be called when not currently composing but a keydown event with the "composition
167 * character" (229) is triggered, in order to allow non-composition text to be entered when an
170 private _handleAnyTextareaChanges(): void {
171 const oldValue = this._textarea.value;
173 // Ignore if a composition has started since the timeout
174 if (!this._isComposing) {
175 const newValue = this._textarea.value;
176 const diff = newValue.replace(oldValue, '');
177 if (diff.length > 0) {
178 this._coreService.triggerDataEvent(diff, true);
185 * Positions the composition view on top of the cursor and the textarea just below it (so the
186 * IME helper dialog is positioned correctly).
187 * @param dontRecurse Whether to use setTimeout to recursively trigger another update, this is
188 * necessary as the IME events across browsers are not consistently triggered.
190 public updateCompositionElements(dontRecurse?: boolean): void {
191 if (!this._isComposing) {
195 if (this._bufferService.buffer.isCursorInViewport) {
196 const cellHeight = Math.ceil(this._charSizeService.height * this._optionsService.options.lineHeight);
197 const cursorTop = this._bufferService.buffer.y * cellHeight;
198 const cursorLeft = this._bufferService.buffer.x * this._charSizeService.width;
200 this._compositionView.style.left = cursorLeft + 'px';
201 this._compositionView.style.top = cursorTop + 'px';
202 this._compositionView.style.height = cellHeight + 'px';
203 this._compositionView.style.lineHeight = cellHeight + 'px';
204 this._compositionView.style.fontFamily = this._optionsService.options.fontFamily;
205 this._compositionView.style.fontSize = this._optionsService.options.fontSize + 'px';
206 // Sync the textarea to the exact position of the composition view so the IME knows where the
208 const compositionViewBounds = this._compositionView.getBoundingClientRect();
209 this._textarea.style.left = cursorLeft + 'px';
210 this._textarea.style.top = cursorTop + 'px';
211 this._textarea.style.width = compositionViewBounds.width + 'px';
212 this._textarea.style.height = compositionViewBounds.height + 'px';
213 this._textarea.style.lineHeight = compositionViewBounds.height + 'px';
217 setTimeout(() => this.updateCompositionElements(true), 0);
222 * Clears the textarea's position so that the cursor does not blink on IE.
225 private _clearTextareaPosition(): void {
226 this._textarea.style.left = '';
227 this._textarea.style.top = '';