]>
Commit | Line | Data |
---|---|---|
81fccee9 | 1 | /** |
1d300911 | 2 | * @license MIT |
81fccee9 DI |
3 | */ |
4 | ||
30fcdd6c | 5 | import { ITerminal } from './Interfaces'; |
28c3a202 | 6 | |
30fcdd6c DI |
7 | interface IPosition { |
8 | start: number; | |
9 | end: number; | |
10 | } | |
28c3a202 | 11 | |
f75b7db9 DI |
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 | */ | |
30fcdd6c DI |
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 | */ | |
3b166966 | 27 | private compositionPosition: IPosition; |
30fcdd6c DI |
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 | /** | |
f75b7db9 | 36 | * Creates a new CompositionHelper. |
30fcdd6c DI |
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 | } | |
28c3a202 | 50 | |
30fcdd6c DI |
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 | } | |
28c3a202 | 60 | |
30fcdd6c DI |
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(); | |
3b166966 DI |
68 | setTimeout(() => { |
69 | this.compositionPosition.end = this.textarea.value.length; | |
30fcdd6c DI |
70 | }, 0); |
71 | } | |
28c3a202 | 72 | |
30fcdd6c DI |
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 | } | |
28c3a202 | 80 | |
30fcdd6c DI |
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 | } | |
28c3a202 | 100 | |
28c3a202 | 101 | if (ev.keyCode === 229) { |
30fcdd6c DI |
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(); | |
28c3a202 | 105 | return false; |
28c3a202 | 106 | } |
28c3a202 | 107 | |
30fcdd6c | 108 | return true; |
28c3a202 DI |
109 | } |
110 | ||
30fcdd6c DI |
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; | |
3b166966 | 127 | const input = this.textarea.value.substring(this.compositionPosition.start, this.compositionPosition.end); |
30fcdd6c DI |
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. | |
3b166966 | 132 | const currentCompositionPosition = { |
30fcdd6c DI |
133 | start: this.compositionPosition.start, |
134 | end: this.compositionPosition.end, | |
3b166966 | 135 | }; |
28c3a202 | 136 | |
30fcdd6c DI |
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. | |
30fcdd6c | 145 | this.isSendingComposition = true; |
3b166966 | 146 | setTimeout(() => { |
30fcdd6c | 147 | // Ensure that the input has not already been sent |
3b166966 DI |
148 | if (this.isSendingComposition) { |
149 | this.isSendingComposition = false; | |
150 | let input; | |
151 | if (this.isComposing) { | |
30fcdd6c | 152 | // Use the end position to get the string if a new composition has started. |
3b166966 | 153 | input = this.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end); |
30fcdd6c DI |
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. | |
3b166966 | 158 | input = this.textarea.value.substring(currentCompositionPosition.start); |
30fcdd6c | 159 | } |
3b166966 | 160 | this.terminal.handler(input); |
30fcdd6c DI |
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() { | |
3b166966 DI |
173 | const oldValue = this.textarea.value; |
174 | setTimeout(() => { | |
30fcdd6c | 175 | // Ignore if a composition has started since the timeout |
3b166966 DI |
176 | if (!this.isComposing) { |
177 | const newValue = this.textarea.value; | |
178 | const diff = newValue.replace(oldValue, ''); | |
30fcdd6c | 179 | if (diff.length > 0) { |
3b166966 | 180 | this.terminal.handler(diff); |
28c3a202 | 181 | } |
28c3a202 DI |
182 | } |
183 | }, 0); | |
184 | } | |
28c3a202 | 185 | |
30fcdd6c DI |
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; | |
28c3a202 | 195 | } |
3b166966 | 196 | const cursor = <HTMLElement>this.terminal.element.querySelector('.terminal-cursor'); |
30fcdd6c DI |
197 | if (cursor) { |
198 | // Take .xterm-rows offsetTop into account as well in case it's positioned absolutely within | |
199 | // the .xterm element. | |
3b166966 DI |
200 | const xtermRows = <HTMLElement>this.terminal.element.querySelector('.xterm-rows'); |
201 | const cursorTop = xtermRows.offsetTop + cursor.offsetTop; | |
30fcdd6c DI |
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. | |
3b166966 | 209 | const compositionViewBounds = this.compositionView.getBoundingClientRect(); |
30fcdd6c DI |
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) { | |
3b166966 | 217 | setTimeout(() => this.updateCompositionElements(true), 0); |
30fcdd6c DI |
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 | } |