xterm
[VSoRC/.git] / node_modules / xterm / src / browser / input / CompositionHelper.ts
1 /**
2  * Copyright (c) 2016 The xterm.js authors. All rights reserved.
3  * @license MIT
4  */
5
6 import { ICharSizeService } from 'browser/services/Services';
7 import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services';
8
9 interface IPosition {
10   start: number;
11   end: number;
12 }
13
14 /**
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
17  * to the handler.
18  */
19 export class CompositionHelper {
20   /**
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.
23    */
24   private _isComposing: boolean;
25
26   /**
27    * The position within the input textarea's value of the current composition.
28    */
29   private _compositionPosition: IPosition;
30
31   /**
32    * Whether a composition is in the process of being sent, setting this to false will cancel any
33    * in-progress composition.
34    */
35   private _isSendingComposition: boolean;
36
37   constructor(
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
44   ) {
45     this._isComposing = false;
46     this._isSendingComposition = false;
47     this._compositionPosition = { start: 0, end: 0 };
48   }
49
50   /**
51    * Handles the compositionstart event, activating the composition view.
52    */
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');
58   }
59
60   /**
61    * Handles the compositionupdate event, updating the composition view.
62    * @param ev The event.
63    */
64   public compositionupdate(ev: CompositionEvent): void {
65     this._compositionView.textContent = ev.data;
66     this.updateCompositionElements();
67     setTimeout(() => {
68       this._compositionPosition.end = this._textarea.value.length;
69     }, 0);
70   }
71
72   /**
73    * Handles the compositionend event, hiding the composition view and sending the composition to
74    * the handler.
75    */
76   public compositionend(): void {
77     this._finalizeComposition(true);
78   }
79
80   /**
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.
84    */
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"
89         return false;
90       } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
91         // Continue composing if the keyCode is a modifier key
92         return false;
93       }
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);
97     }
98
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();
103       return false;
104     }
105
106     return true;
107   }
108
109   /**
110    * Finalizes the composition, resuming regular input actions. This is called when a composition
111    * is ending.
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.
116    */
117   private _finalizeComposition(waitForPropagation: boolean): void {
118     this._compositionView.classList.remove('active');
119     this._isComposing = false;
120     this._clearTextareaPosition();
121
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);
127     } else {
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
133       };
134
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;
144       setTimeout(() => {
145         // Ensure that the input has not already been sent
146         if (this._isSendingComposition) {
147           this._isSendingComposition = false;
148           let input;
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);
152           } else {
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);
157           }
158           this._coreService.triggerDataEvent(input, true);
159         }
160       }, 0);
161     }
162   }
163
164   /**
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
168    * IME is active.
169    */
170   private _handleAnyTextareaChanges(): void {
171     const oldValue = this._textarea.value;
172     setTimeout(() => {
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);
179         }
180       }
181     }, 0);
182   }
183
184   /**
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.
189    */
190   public updateCompositionElements(dontRecurse?: boolean): void {
191     if (!this._isComposing) {
192       return;
193     }
194
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;
199
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
207       // text is.
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';
214     }
215
216     if (!dontRecurse) {
217       setTimeout(() => this.updateCompositionElements(true), 0);
218     }
219   }
220
221   /**
222    * Clears the textarea's position so that the cursor does not blink on IE.
223    * @private
224    */
225   private _clearTextareaPosition(): void {
226     this._textarea.style.left = '';
227     this._textarea.style.top = '';
228   }
229 }