5 import { ITerminal } from './Interfaces';
13 * Encapsulates the logic for handling compositionstart, compositionupdate and compositionend
14 * events, displaying the in-progress composition to the UI and forwarding the final composition
17 export class CompositionHelper {
19 * Whether input composition is currently happening, eg. via a mobile keyboard, speech input or
20 * IME. This variable determines whether the compositionText should be displayed on the UI.
22 private isComposing: boolean;
25 * The position within the input textarea's value of the current composition.
27 private compositionPosition: IPosition;
30 * Whether a composition is in the process of being sent, setting this to false will cancel any
31 * in-progress composition.
33 private isSendingComposition: boolean;
36 * Creates a new CompositionHelper.
37 * @param textarea The textarea that xterm uses for input.
38 * @param compositionView The element to display the in-progress composition in.
39 * @param terminal The Terminal to forward the finished composition to.
42 private textarea: HTMLTextAreaElement,
43 private compositionView: HTMLElement,
44 private terminal: ITerminal
46 this.isComposing = false;
47 this.isSendingComposition = false;
48 this.compositionPosition = { start: null, end: null };
52 * Handles the compositionstart event, activating the composition view.
54 public compositionstart() {
55 this.isComposing = true;
56 this.compositionPosition.start = this.textarea.value.length;
57 this.compositionView.textContent = '';
58 this.compositionView.classList.add('active');
62 * Handles the compositionupdate event, updating the composition view.
63 * @param {CompositionEvent} ev The event.
65 public compositionupdate(ev: CompositionEvent) {
66 this.compositionView.textContent = ev.data;
67 this.updateCompositionElements();
69 this.compositionPosition.end = this.textarea.value.length;
74 * Handles the compositionend event, hiding the composition view and sending the composition to
77 public compositionend() {
78 this.finalizeComposition(true);
82 * Handles the keydown event, routing any necessary events to the CompositionHelper functions.
83 * @param ev The keydown event.
84 * @return Whether the Terminal should continue processing the keydown event.
86 public keydown(ev: KeyboardEvent) {
87 if (this.isComposing || this.isSendingComposition) {
88 if (ev.keyCode === 229) {
89 // Continue composing if the keyCode is the "composition character"
91 } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
92 // Continue composing if the keyCode is a modifier key
95 // Finish composition immediately. This is mainly here for the case where enter is
96 // pressed and the handler needs to be triggered before the command is executed.
97 this.finalizeComposition(false);
101 if (ev.keyCode === 229) {
102 // If the "composition character" is used but gets to this point it means a non-composition
103 // character (eg. numbers and punctuation) was pressed when the IME was active.
104 this.handleAnyTextareaChanges();
112 * Finalizes the composition, resuming regular input actions. This is called when a composition
114 * @param waitForPropogation Whether to wait for events to propogate before sending
115 * the input. This should be false if a non-composition keystroke is entered before the
116 * compositionend event is triggered, such as enter, so that the composition is send before
117 * the command is executed.
119 private finalizeComposition(waitForPropogation: boolean) {
120 this.compositionView.classList.remove('active');
121 this.isComposing = false;
122 this.clearTextareaPosition();
124 if (!waitForPropogation) {
125 // Cancel any delayed composition send requests and send the input immediately.
126 this.isSendingComposition = false;
127 const input = this.textarea.value.substring(this.compositionPosition.start, this.compositionPosition.end);
128 this.terminal.handler(input);
130 // Make a deep copy of the composition position here as a new compositionstart event may
131 // fire before the setTimeout executes.
132 const currentCompositionPosition = {
133 start: this.compositionPosition.start,
134 end: this.compositionPosition.end,
137 // Since composition* events happen before the changes take place in the textarea on most
138 // browsers, use a setTimeout with 0ms time to allow the native compositionend event to
139 // complete. This ensures the correct character is retrieved, this solution was used
141 // - The compositionend event's data property is unreliable, at least on Chromium
142 // - The last compositionupdate event's data property does not always accurately describe
143 // the character, a counter example being Korean where an ending consonsant can move to
144 // the following character if the following input is a vowel.
145 this.isSendingComposition = true;
147 // Ensure that the input has not already been sent
148 if (this.isSendingComposition) {
149 this.isSendingComposition = false;
151 if (this.isComposing) {
152 // Use the end position to get the string if a new composition has started.
153 input = this.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end);
155 // Don't use the end position here in order to pick up any characters after the
156 // composition has finished, for example when typing a non-composition character
157 // (eg. 2) after a composition character.
158 input = this.textarea.value.substring(currentCompositionPosition.start);
160 this.terminal.handler(input);
167 * Apply any changes made to the textarea after the current event chain is allowed to complete.
168 * This should be called when not currently composing but a keydown event with the "composition
169 * character" (229) is triggered, in order to allow non-composition text to be entered when an
172 private handleAnyTextareaChanges() {
173 const oldValue = this.textarea.value;
175 // Ignore if a composition has started since the timeout
176 if (!this.isComposing) {
177 const newValue = this.textarea.value;
178 const diff = newValue.replace(oldValue, '');
179 if (diff.length > 0) {
180 this.terminal.handler(diff);
187 * Positions the composition view on top of the cursor and the textarea just below it (so the
188 * IME helper dialog is positioned correctly).
189 * @param dontRecurse Whether to use setTimeout to recursively trigger another update, this is
190 * necessary as the IME events across browsers are not consistently triggered.
192 public updateCompositionElements(dontRecurse?: boolean) {
193 if (!this.isComposing) {
196 const cursor = <HTMLElement>this.terminal.element.querySelector('.terminal-cursor');
198 // Take .xterm-rows offsetTop into account as well in case it's positioned absolutely within
199 // the .xterm element.
200 const xtermRows = <HTMLElement>this.terminal.element.querySelector('.xterm-rows');
201 const cursorTop = xtermRows.offsetTop + cursor.offsetTop;
203 this.compositionView.style.left = cursor.offsetLeft + 'px';
204 this.compositionView.style.top = cursorTop + 'px';
205 this.compositionView.style.height = cursor.offsetHeight + 'px';
206 this.compositionView.style.lineHeight = cursor.offsetHeight + 'px';
207 // Sync the textarea to the exact position of the composition view so the IME knows where the
209 const compositionViewBounds = this.compositionView.getBoundingClientRect();
210 this.textarea.style.left = cursor.offsetLeft + 'px';
211 this.textarea.style.top = cursorTop + 'px';
212 this.textarea.style.width = compositionViewBounds.width + 'px';
213 this.textarea.style.height = compositionViewBounds.height + 'px';
214 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() {
226 this.textarea.style.left = '';
227 this.textarea.style.top = '';