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