]>
Commit | Line | Data |
---|---|---|
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 | } |