]>
Commit | Line | Data |
---|---|---|
28c3a202 DI |
1 | /** |
2 | * Encapsulates the logic for handling compositionstart, compositionupdate and compositionend | |
3 | * events, displaying the in-progress composition to the UI and forwarding the final composition | |
4 | * to the handler. | |
5 | * @param {HTMLTextAreaElement} textarea The textarea that xterm uses for input. | |
6 | * @param {HTMLElement} compositionView The element to display the in-progress composition in. | |
7 | * @param {Terminal} terminal The Terminal to forward the finished composition to. | |
8 | */ | |
9 | function CompositionHelper(textarea, compositionView, terminal) { | |
10 | this.textarea = textarea; | |
11 | this.compositionView = compositionView; | |
12 | this.terminal = terminal; | |
13 | ||
14 | // Whether input composition is currently happening, eg. via a mobile keyboard, speech input | |
15 | // or IME. This variable determines whether the compositionText should be displayed on the UI. | |
16 | this.isComposing = false; | |
17 | ||
18 | // The input currently being composed, eg. via a mobile keyboard, speech input or IME. | |
19 | this.compositionText = null; | |
20 | ||
21 | // The position within the input textarea's value of the current composition. | |
22 | this.compositionPosition = { start: null, end: null }; | |
23 | ||
24 | // Whether a composition is in the process of being sent, setting this to false will cancel | |
25 | // any in-progress composition. | |
26 | this.isSendingComposition = false; | |
27 | } | |
28 | ||
29 | /** | |
30 | * Handles the compositionstart event, activating the composition view. | |
31 | */ | |
32 | CompositionHelper.prototype.compositionstart = function() { | |
33 | this.isComposing = true; | |
34 | this.compositionPosition.start = this.textarea.value.length; | |
35 | this.compositionView.textContent = ''; | |
36 | this.compositionView.classList.add('active'); | |
37 | }; | |
38 | ||
39 | /** | |
40 | * Handles the compositionupdate event, updating the composition view. | |
41 | * @param {CompositionEvent} ev The event. | |
42 | */ | |
43 | CompositionHelper.prototype.compositionupdate = function(ev) { | |
44 | this.compositionView.textContent = ev.data; | |
45 | this.updateCompositionElements(); | |
46 | var self = this; | |
47 | setTimeout(function() { | |
48 | self.compositionPosition.end = self.textarea.value.length; | |
49 | }, 0); | |
50 | }; | |
51 | ||
52 | /** | |
53 | * Handles the compositionend event, hiding the composition view and sending the composition to | |
54 | * the handler. | |
55 | */ | |
56 | CompositionHelper.prototype.compositionend = function() { | |
57 | this.finalizeComposition(true); | |
58 | }; | |
59 | ||
60 | /** | |
61 | * Handles the keydown event, routing any necessary events to the CompositionHelper functions. | |
62 | * @return Whether the Terminal should continue processing the keydown event. | |
63 | */ | |
64 | CompositionHelper.prototype.keydown = function(ev) { | |
65 | if (this.isComposing || this.isSendingComposition) { | |
66 | if (ev.keyCode === 229) { | |
67 | // Continue composing if the keyCode is the "composition character" | |
68 | return false; | |
69 | } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) { | |
70 | // Continue composing if the keyCode is a modifier key | |
71 | return false; | |
72 | } else { | |
73 | // Finish composition immediately. This is mainly here for the case where enter is | |
74 | // pressed and the handler needs to be triggered before the command is executed. | |
75 | this.finalizeComposition(false); | |
76 | } | |
77 | } | |
78 | ||
79 | if (ev.keyCode === 229) { | |
80 | // If the "composition character" is used but gets to this point it means a non-composition | |
81 | // character (eg. numbers and punctuation) was pressed when the IME was active. | |
82 | this.handleAnyTextareaChanges(); | |
83 | return false; | |
84 | } | |
85 | ||
86 | return true; | |
87 | }; | |
88 | ||
89 | /** | |
90 | * Finalizes the composition, resuming regular input actions. This is called when a composition | |
91 | * is ending. | |
92 | * @param {boolean} waitForPropogation Whether to wait for events to propogate before sending | |
93 | * the input. This should be false if a non-composition keystroke is entered before the | |
94 | * compositionend event is triggered, such as enter, so that the composition is send before | |
95 | * the command is executed. | |
96 | */ | |
97 | CompositionHelper.prototype.finalizeComposition = function(waitForPropogation) { | |
98 | this.compositionView.classList.remove('active'); | |
99 | this.isComposing = false; | |
100 | this.clearTextareaPosition(); | |
101 | ||
102 | if (!waitForPropogation) { | |
103 | // Cancel any delayed composition send requests and send the input immediately. | |
104 | this.isSendingComposition = false; | |
105 | var input = this.textarea.value.substring(this.compositionPosition.start, this.compositionPosition.end); | |
106 | this.terminal.handler(input); | |
107 | } else { | |
108 | // Make a deep copy of the composition position here as a new compositionstart event may | |
109 | // fire before the setTimeout executes. | |
110 | var currentCompositionPosition = { | |
111 | start: this.compositionPosition.start, | |
112 | end: this.compositionPosition.end, | |
113 | } | |
114 | ||
115 | // Since composition* events happen before the changes take place in the textarea on most | |
116 | // browsers, use a setTimeout with 0ms time to allow the native compositionend event to | |
117 | // complete. This ensures the correct character is retrieved, this solution was used | |
118 | // because: | |
119 | // - The compositionend event's data property is unreliable, at least on Chromium | |
120 | // - The last compositionupdate event's data property does not always accurately describe | |
121 | // the character, a counter example being Korean where an ending consonsant can move to | |
122 | // the following character if the following input is a vowel. | |
123 | var self = this; | |
124 | this.isSendingComposition = true; | |
125 | setTimeout(function () { | |
126 | // Ensure that the input has not already been sent | |
127 | if (self.isSendingComposition) { | |
128 | self.isSendingComposition = false; | |
129 | var input; | |
130 | if (self.isComposing) { | |
131 | // Use the end position to get the string if a new composition has started. | |
132 | input = self.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end); | |
133 | } else { | |
134 | // Don't use the end position here in order to pick up any characters after the | |
135 | // composition has finished, for example when typing a non-composition character | |
136 | // (eg. 2) after a composition character. | |
137 | input = self.textarea.value.substring(currentCompositionPosition.start); | |
138 | } | |
139 | self.terminal.handler(input); | |
140 | } | |
141 | }, 0); | |
142 | } | |
143 | }; | |
144 | ||
145 | /** | |
146 | * Apply any changes made to the textarea after the current event chain is allowed to complete. | |
147 | * This should be called when not currently composing but a keydown event with the "composition | |
148 | * character" (229) is triggered, in order to allow non-composition text to be entered when an | |
149 | * IME is active. | |
150 | */ | |
151 | CompositionHelper.prototype.handleAnyTextareaChanges = function() { | |
152 | var oldValue = this.textarea.value; | |
153 | var self = this; | |
154 | setTimeout(function() { | |
155 | // Ignore if a composition has started since the timeout | |
156 | if (!self.isComposing) { | |
157 | var newValue = self.textarea.value; | |
158 | var diff = newValue.replace(oldValue, ''); | |
159 | if (diff.length > 0) { | |
160 | self.terminal.handler(diff); | |
161 | } | |
162 | } | |
163 | }, 0); | |
164 | }; | |
165 | ||
166 | /** | |
167 | * Positions the composition view on top of the cursor and the textarea just below it (so the | |
168 | * IME helper dialog is positioned correctly). | |
169 | */ | |
170 | CompositionHelper.prototype.updateCompositionElements = function(dontRecurse) { | |
171 | if (!this.isComposing) { | |
172 | return; | |
173 | } | |
174 | var cursor = this.terminal.element.querySelector('.terminal-cursor'); | |
175 | if (cursor) { | |
176 | this.compositionView.style.left = cursor.offsetLeft + 'px'; | |
177 | this.compositionView.style.top = cursor.offsetTop + 'px'; | |
178 | var compositionViewBounds = this.compositionView.getBoundingClientRect(); | |
179 | this.textarea.style.left = cursor.offsetLeft + compositionViewBounds.width + 'px'; | |
180 | this.textarea.style.top = (cursor.offsetTop + cursor.offsetHeight) + 'px'; | |
181 | } | |
182 | if (!dontRecurse) { | |
183 | setTimeout(this.updateCompositionElements.bind(this, true), 0); | |
184 | } | |
185 | }; | |
186 | ||
187 | /** | |
188 | * Clears the textarea's position so that the cursor does not blink on IE. | |
189 | * @private | |
190 | */ | |
191 | CompositionHelper.prototype.clearTextareaPosition = function() { | |
192 | this.textarea.style.left = ''; | |
193 | this.textarea.style.top = ''; | |
194 | }; | |
195 | ||
196 | export { CompositionHelper }; |