]> git.proxmox.com Git - mirror_xterm.js.git/blob - src/CompositionHelper.ts
Move CompositionHelper header comment to class
[mirror_xterm.js.git] / src / CompositionHelper.ts
1 /**
2 * xterm.js: xterm, in the browser
3 * Copyright (c) 2014-2016, SourceLair Private Company (www.sourcelair.com (MIT License)
4 */
5
6 import { ITerminal } from './Interfaces';
7
8 interface IPosition {
9 start: number;
10 end: number;
11 }
12
13 /**
14 * Encapsulates the logic for handling compositionstart, compositionupdate and compositionend
15 * events, displaying the in-progress composition to the UI and forwarding the final composition
16 * to the handler.
17 */
18 export class CompositionHelper {
19 /**
20 * Whether input composition is currently happening, eg. via a mobile keyboard, speech input or
21 * IME. This variable determines whether the compositionText should be displayed on the UI.
22 */
23 private isComposing: boolean;
24
25 /**
26 * The position within the input textarea's value of the current composition.
27 */
28 private compositionPosition: IPosition;;
29
30 /**
31 * Whether a composition is in the process of being sent, setting this to false will cancel any
32 * in-progress composition.
33 */
34 private isSendingComposition: boolean;
35
36 /**
37 * Creates a new CompositionHelper.
38 * @param textarea The textarea that xterm uses for input.
39 * @param compositionView The element to display the in-progress composition in.
40 * @param terminal The Terminal to forward the finished composition to.
41 */
42 constructor(
43 private textarea: HTMLTextAreaElement,
44 private compositionView: HTMLElement,
45 private terminal: ITerminal
46 ) {
47 this.isComposing = false;
48 this.isSendingComposition = false;
49 this.compositionPosition = { start: null, end: null };
50 }
51
52 /**
53 * Handles the compositionstart event, activating the composition view.
54 */
55 public compositionstart() {
56 this.isComposing = true;
57 this.compositionPosition.start = this.textarea.value.length;
58 this.compositionView.textContent = '';
59 this.compositionView.classList.add('active');
60 }
61
62 /**
63 * Handles the compositionupdate event, updating the composition view.
64 * @param {CompositionEvent} ev The event.
65 */
66 public compositionupdate(ev: CompositionEvent) {
67 this.compositionView.textContent = ev.data;
68 this.updateCompositionElements();
69 var self = this;
70 setTimeout(function() {
71 self.compositionPosition.end = self.textarea.value.length;
72 }, 0);
73 }
74
75 /**
76 * Handles the compositionend event, hiding the composition view and sending the composition to
77 * the handler.
78 */
79 public compositionend() {
80 this.finalizeComposition(true);
81 }
82
83 /**
84 * Handles the keydown event, routing any necessary events to the CompositionHelper functions.
85 * @param ev The keydown event.
86 * @return Whether the Terminal should continue processing the keydown event.
87 */
88 public keydown(ev: KeyboardEvent) {
89 if (this.isComposing || this.isSendingComposition) {
90 if (ev.keyCode === 229) {
91 // Continue composing if the keyCode is the "composition character"
92 return false;
93 } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
94 // Continue composing if the keyCode is a modifier key
95 return false;
96 } else {
97 // Finish composition immediately. This is mainly here for the case where enter is
98 // pressed and the handler needs to be triggered before the command is executed.
99 this.finalizeComposition(false);
100 }
101 }
102
103 if (ev.keyCode === 229) {
104 // If the "composition character" is used but gets to this point it means a non-composition
105 // character (eg. numbers and punctuation) was pressed when the IME was active.
106 this.handleAnyTextareaChanges();
107 return false;
108 }
109
110 return true;
111 }
112
113 /**
114 * Finalizes the composition, resuming regular input actions. This is called when a composition
115 * is ending.
116 * @param waitForPropogation Whether to wait for events to propogate before sending
117 * the input. This should be false if a non-composition keystroke is entered before the
118 * compositionend event is triggered, such as enter, so that the composition is send before
119 * the command is executed.
120 */
121 private finalizeComposition(waitForPropogation: boolean) {
122 this.compositionView.classList.remove('active');
123 this.isComposing = false;
124 this.clearTextareaPosition();
125
126 if (!waitForPropogation) {
127 // Cancel any delayed composition send requests and send the input immediately.
128 this.isSendingComposition = false;
129 var input = this.textarea.value.substring(this.compositionPosition.start, this.compositionPosition.end);
130 this.terminal.handler(input);
131 } else {
132 // Make a deep copy of the composition position here as a new compositionstart event may
133 // fire before the setTimeout executes.
134 var currentCompositionPosition = {
135 start: this.compositionPosition.start,
136 end: this.compositionPosition.end,
137 }
138
139 // Since composition* events happen before the changes take place in the textarea on most
140 // browsers, use a setTimeout with 0ms time to allow the native compositionend event to
141 // complete. This ensures the correct character is retrieved, this solution was used
142 // because:
143 // - The compositionend event's data property is unreliable, at least on Chromium
144 // - The last compositionupdate event's data property does not always accurately describe
145 // the character, a counter example being Korean where an ending consonsant can move to
146 // the following character if the following input is a vowel.
147 var self = this;
148 this.isSendingComposition = true;
149 setTimeout(function () {
150 // Ensure that the input has not already been sent
151 if (self.isSendingComposition) {
152 self.isSendingComposition = false;
153 var input;
154 if (self.isComposing) {
155 // Use the end position to get the string if a new composition has started.
156 input = self.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end);
157 } else {
158 // Don't use the end position here in order to pick up any characters after the
159 // composition has finished, for example when typing a non-composition character
160 // (eg. 2) after a composition character.
161 input = self.textarea.value.substring(currentCompositionPosition.start);
162 }
163 self.terminal.handler(input);
164 }
165 }, 0);
166 }
167 }
168
169 /**
170 * Apply any changes made to the textarea after the current event chain is allowed to complete.
171 * This should be called when not currently composing but a keydown event with the "composition
172 * character" (229) is triggered, in order to allow non-composition text to be entered when an
173 * IME is active.
174 */
175 private handleAnyTextareaChanges() {
176 var oldValue = this.textarea.value;
177 var self = this;
178 setTimeout(function() {
179 // Ignore if a composition has started since the timeout
180 if (!self.isComposing) {
181 var newValue = self.textarea.value;
182 var diff = newValue.replace(oldValue, '');
183 if (diff.length > 0) {
184 self.terminal.handler(diff);
185 }
186 }
187 }, 0);
188 }
189
190 /**
191 * Positions the composition view on top of the cursor and the textarea just below it (so the
192 * IME helper dialog is positioned correctly).
193 * @param dontRecurse Whether to use setTimeout to recursively trigger another update, this is
194 * necessary as the IME events across browsers are not consistently triggered.
195 */
196 public updateCompositionElements(dontRecurse?: boolean) {
197 if (!this.isComposing) {
198 return;
199 }
200 var cursor = <HTMLElement>this.terminal.element.querySelector('.terminal-cursor');
201 if (cursor) {
202 // Take .xterm-rows offsetTop into account as well in case it's positioned absolutely within
203 // the .xterm element.
204 var xtermRows = <HTMLElement>this.terminal.element.querySelector('.xterm-rows');
205 var cursorTop = xtermRows.offsetTop + cursor.offsetTop;
206
207 this.compositionView.style.left = cursor.offsetLeft + 'px';
208 this.compositionView.style.top = cursorTop + 'px';
209 this.compositionView.style.height = cursor.offsetHeight + 'px';
210 this.compositionView.style.lineHeight = cursor.offsetHeight + 'px';
211 // Sync the textarea to the exact position of the composition view so the IME knows where the
212 // text is.
213 var compositionViewBounds = this.compositionView.getBoundingClientRect();
214 this.textarea.style.left = cursor.offsetLeft + 'px';
215 this.textarea.style.top = cursorTop + 'px';
216 this.textarea.style.width = compositionViewBounds.width + 'px';
217 this.textarea.style.height = compositionViewBounds.height + 'px';
218 this.textarea.style.lineHeight = compositionViewBounds.height + 'px';
219 }
220 if (!dontRecurse) {
221 setTimeout(this.updateCompositionElements.bind(this, true), 0);
222 }
223 };
224
225 /**
226 * Clears the textarea's position so that the cursor does not blink on IE.
227 * @private
228 */
229 private clearTextareaPosition() {
230 this.textarea.style.left = '';
231 this.textarea.style.top = '';
232 };
233 }