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