]> git.proxmox.com Git - mirror_xterm.js.git/blob - src/CompositionHelper.ts
Merge pull request #926 from ficristo/search-fix
[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 setTimeout(() => {
69 this.compositionPosition.end = this.textarea.value.length;
70 }, 0);
71 }
72
73 /**
74 * Handles the compositionend event, hiding the composition view and sending the composition to
75 * the handler.
76 */
77 public compositionend() {
78 this.finalizeComposition(true);
79 }
80
81 /**
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.
85 */
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"
90 return false;
91 } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
92 // Continue composing if the keyCode is a modifier key
93 return false;
94 } else {
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);
98 }
99 }
100
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();
105 return false;
106 }
107
108 return true;
109 }
110
111 /**
112 * Finalizes the composition, resuming regular input actions. This is called when a composition
113 * is ending.
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.
118 */
119 private finalizeComposition(waitForPropogation: boolean) {
120 this.compositionView.classList.remove('active');
121 this.isComposing = false;
122 this.clearTextareaPosition();
123
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);
129 } else {
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,
135 };
136
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
140 // because:
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;
146 setTimeout(() => {
147 // Ensure that the input has not already been sent
148 if (this.isSendingComposition) {
149 this.isSendingComposition = false;
150 let input;
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);
154 } else {
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);
159 }
160 this.terminal.handler(input);
161 }
162 }, 0);
163 }
164 }
165
166 /**
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
170 * IME is active.
171 */
172 private handleAnyTextareaChanges() {
173 const oldValue = this.textarea.value;
174 setTimeout(() => {
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);
181 }
182 }
183 }, 0);
184 }
185
186 /**
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.
191 */
192 public updateCompositionElements(dontRecurse?: boolean) {
193 if (!this.isComposing) {
194 return;
195 }
196 const cursor = <HTMLElement>this.terminal.element.querySelector('.terminal-cursor');
197 if (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;
202
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
208 // text is.
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';
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() {
226 this.textarea.style.left = '';
227 this.textarea.style.top = '';
228 };
229 }