REGRESSION (r226994): Web Inspector: Styles: Suggestions popover floats in top-left...
[WebKit-https.git] / Source / WebInspectorUI / UserInterface / Views / SpreadsheetTextField.js
1 /*
2  * Copyright (C) 2017 Apple Inc. All rights reserved.
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions
6  * are met:
7  * 1. Redistributions of source code must retain the above copyright
8  *    notice, this list of conditions and the following disclaimer.
9  * 2. Redistributions in binary form must reproduce the above copyright
10  *    notice, this list of conditions and the following disclaimer in the
11  *    documentation and/or other materials provided with the distribution.
12  *
13  * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15  * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23  * THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 WI.SpreadsheetTextField = class SpreadsheetTextField
27 {
28     constructor(delegate, element, completionProvider)
29     {
30         this._delegate = delegate;
31         this._element = element;
32
33         this._completionProvider = completionProvider || null;
34         if (this._completionProvider) {
35             this._suggestionHintElement = document.createElement("span");
36             this._suggestionHintElement.contentEditable = false;
37             this._suggestionHintElement.classList.add("completion-hint");
38             this._suggestionsView = new WI.CompletionSuggestionsView(this, {preventBlur: true});
39         }
40
41         this._element.classList.add("spreadsheet-text-field");
42
43         this._element.addEventListener("focus", this._handleFocus.bind(this));
44         this._element.addEventListener("blur", this._handleBlur.bind(this));
45         this._element.addEventListener("keydown", this._handleKeyDown.bind(this));
46         this._element.addEventListener("input", this._handleInput.bind(this));
47
48         this._editing = false;
49         this._valueBeforeEditing = "";
50     }
51
52     // Public
53
54     get element() { return this._element; }
55
56     get editing() { return this._editing; }
57
58     get value() { return this._element.textContent; }
59     set value(value) { this._element.textContent = value; }
60
61     valueWithoutSuggestion()
62     {
63         let value = this._element.textContent;
64         return value.slice(0, value.length - this.suggestionHint.length);
65     }
66
67     get suggestionHint()
68     {
69         return this._suggestionHintElement.textContent;
70     }
71
72     set suggestionHint(value)
73     {
74         this._suggestionHintElement.textContent = value;
75
76         if (value) {
77             if (this._suggestionHintElement.parentElement !== this._element)
78                 this._element.append(this._suggestionHintElement);
79         } else
80             this._suggestionHintElement.remove();
81     }
82
83     startEditing()
84     {
85         if (this._editing)
86             return;
87
88         if (this._delegate && typeof this._delegate.spreadsheetTextFieldWillStartEditing === "function")
89             this._delegate.spreadsheetTextFieldWillStartEditing(this);
90
91         this._editing = true;
92         this._valueBeforeEditing = this.value;
93
94         this._element.classList.add("editing");
95         this._element.contentEditable = "plaintext-only";
96         this._element.spellcheck = false;
97         this._element.scrollIntoViewIfNeeded(false);
98
99         this._element.focus();
100         this._selectText();
101
102         this._updateCompletions();
103     }
104
105     stopEditing()
106     {
107         if (!this._editing)
108             return;
109
110         this._editing = false;
111         this._valueBeforeEditing = "";
112         this._element.classList.remove("editing");
113         this._element.contentEditable = false;
114
115         this.discardCompletion();
116     }
117
118     discardCompletion()
119     {
120         if (!this._completionProvider)
121             return;
122
123         this._suggestionsView.hide();
124
125         let hadSuggestionHint = !!this.suggestionHint;
126         this.suggestionHint = "";
127         if (hadSuggestionHint && this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
128             this._delegate.spreadsheetTextFieldDidChange(this);
129     }
130
131     detached()
132     {
133         this.discardCompletion();
134         this._element.remove();
135     }
136
137     // CompletionSuggestionsView delegate
138
139     completionSuggestionsSelectedCompletion(suggestionsView, selectedText = "")
140     {
141         let prefix = this.valueWithoutSuggestion();
142         let completionPrefix = this._getCompletionPrefix(prefix);
143
144         this.suggestionHint = selectedText.slice(completionPrefix.length);
145
146         if (this._suggestionHintElement.parentElement !== this._element)
147             this._element.append(this._suggestionHintElement);
148
149         if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
150             this._delegate.spreadsheetTextFieldDidChange(this);
151     }
152
153     completionSuggestionsClickedCompletion(suggestionsView, selectedText)
154     {
155         // Consider the following example:
156         //
157         //   border: 1px solid ro|
158         //                     rosybrown
159         //                     royalblue
160         //
161         // Clicking on "rosybrown" should replace "ro" with "rosybrown".
162         //
163         //           prefix:  1px solid ro
164         // completionPrefix:            ro
165         //        newPrefix:  1px solid
166         //     selectedText:            rosybrown
167         let prefix = this.valueWithoutSuggestion();
168         let completionPrefix = this._getCompletionPrefix(prefix);
169         let newPrefix = prefix.slice(0, -completionPrefix.length);
170
171         this._element.textContent = newPrefix + selectedText;
172
173         // Place text caret at the end.
174         window.getSelection().setBaseAndExtent(this._element, selectedText.length, this._element, selectedText.length);
175
176         this.discardCompletion();
177
178         if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
179             this._delegate.spreadsheetTextFieldDidChange(this);
180     }
181
182     // Private
183
184     _selectText()
185     {
186         window.getSelection().selectAllChildren(this._element);
187     }
188
189     _discardChange()
190     {
191         if (this._valueBeforeEditing !== this.value) {
192             this.value = this._valueBeforeEditing;
193             this._selectText();
194
195             if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
196                 this._delegate.spreadsheetTextFieldDidChange(this);
197         }
198     }
199
200     _handleFocus(event)
201     {
202         this.startEditing();
203     }
204
205     _handleBlur(event)
206     {
207         if (!this._editing)
208             return;
209
210         this._applyCompletionHint();
211         this.discardCompletion();
212
213         this._delegate.spreadsheetTextFieldDidBlur(this, event);
214         this.stopEditing();
215     }
216
217     _handleKeyDown(event)
218     {
219         if (!this._editing)
220             return;
221
222         if (this._suggestionsView) {
223             let consumed = this._handleKeyDownForSuggestionView(event);
224             if (consumed)
225                 return;
226         }
227
228         if (event.key === "Enter" || event.key === "Tab") {
229             event.stop();
230             this._applyCompletionHint();
231
232             let direction = (event.shiftKey && event.key === "Tab") ? "backward" : "forward";
233
234             if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidCommit === "function")
235                 this._delegate.spreadsheetTextFieldDidCommit(this, {direction});
236
237             this.stopEditing();
238             return;
239         }
240
241         if (event.key === "ArrowUp" || event.key === "ArrowDown") {
242             let delta = 1;
243             if (event.metaKey)
244                 delta = 100;
245             else if (event.shiftKey)
246                 delta = 10;
247             else if (event.altKey)
248                 delta = 0.1;
249
250             if (event.key === "ArrowDown")
251                 delta = -delta;
252
253             let didModify = WI.incrementElementValue(this._element, delta);
254             if (!didModify)
255                 return;
256
257             event.stop();
258
259             if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
260                 this._delegate.spreadsheetTextFieldDidChange(this);
261         }
262
263         if (event.key === "Backspace") {
264             if (!this.value) {
265                 event.stop();
266                 this.stopEditing();
267
268                 if (this._delegate && this._delegate.spreadsheetTextFieldDidBackspace)
269                     this._delegate.spreadsheetTextFieldDidBackspace(this);
270
271                 return;
272             }
273         }
274
275         if (event.key === "Escape") {
276             event.stop();
277             this._discardChange();
278         }
279     }
280
281     _handleKeyDownForSuggestionView(event)
282     {
283         if ((event.key === "ArrowDown" || event.key === "ArrowUp") && this._suggestionsView.visible) {
284             event.stop();
285
286             if (event.key === "ArrowDown")
287                 this._suggestionsView.selectNext();
288             else
289                 this._suggestionsView.selectPrevious();
290
291             if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
292                 this._delegate.spreadsheetTextFieldDidChange(this);
293
294             return true;
295         }
296
297         if (event.key === "ArrowRight" && this.suggestionHint) {
298             let selection = window.getSelection();
299
300             if (selection.isCollapsed && (selection.focusOffset === this.valueWithoutSuggestion().length || selection.focusNode === this._suggestionHintElement)) {
301                 event.stop();
302                 document.execCommand("insertText", false, this.suggestionHint);
303
304                 // When completing "background", don't hide the completion popover.
305                 // Continue showing the popover with properties such as "background-color" and "background-image".
306                 this._updateCompletions();
307
308                 if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
309                     this._delegate.spreadsheetTextFieldDidChange(this);
310
311                 return true;
312             }
313         }
314
315         if (event.key === "Escape" && this._suggestionsView.visible) {
316             event.stop();
317
318             let willChange = !!this.suggestionHint;
319             this.discardCompletion();
320
321             if (willChange && this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
322                 this._delegate.spreadsheetTextFieldDidChange(this);
323
324             return true;
325         }
326
327         if (event.key === "ArrowLeft" && (this.suggestionHint || this._suggestionsView.visible)) {
328             this.discardCompletion();
329
330             if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
331                 this._delegate.spreadsheetTextFieldDidChange(this);
332         }
333
334         return false;
335     }
336
337     _handleInput(event)
338     {
339         if (!this._editing)
340             return;
341
342         this._updateCompletions();
343
344         if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
345             this._delegate.spreadsheetTextFieldDidChange(this);
346     }
347
348     _updateCompletions()
349     {
350         if (!this._completionProvider)
351             return;
352
353         let prefix = this.valueWithoutSuggestion();
354         let completionPrefix = this._getCompletionPrefix(prefix);
355         let completions = this._completionProvider(completionPrefix);
356
357         if (!completions.length) {
358             this.discardCompletion();
359             return;
360         }
361
362         // No need to show the completion popover with only one item that matches the entered value.
363         if (completions.length === 1 && completions[0] === prefix) {
364             this.discardCompletion();
365             return;
366         }
367
368         console.assert(this._element.isConnected, "SpreadsheetTextField already removed from the DOM.");
369         if (!this._element.isConnected) {
370             this._suggestionsView.hide();
371             return;
372         }
373
374         this._suggestionsView.update(completions);
375
376         if (completions.length === 1) {
377             // No need to show the completion popover that matches the suggestion hint.
378             this._suggestionsView.hide();
379         } else {
380             let caretRect = this._getCaretRect(prefix, completionPrefix);
381             this._suggestionsView.show(caretRect);
382         }
383
384         this._suggestionsView.selectedIndex = NaN;
385         if (completionPrefix) {
386             // Select first item and call completionSuggestionsSelectedCompletion.
387             this._suggestionsView.selectNext();
388         } else
389             this.suggestionHint = "";
390     }
391
392     _getCaretRect(prefix, completionPrefix)
393     {
394         let startOffset = prefix.length - completionPrefix.length;
395         let selection = window.getSelection();
396
397         if (startOffset > 0 && selection.rangeCount) {
398             let range = selection.getRangeAt(0).cloneRange();
399             range.setStart(range.startContainer, startOffset);
400             let clientRect = range.getBoundingClientRect();
401             return WI.Rect.rectFromClientRect(clientRect);
402         }
403
404         let clientRect = this._element.getBoundingClientRect();
405         const leftPadding = parseInt(getComputedStyle(this._element).paddingLeft) || 0;
406         return new WI.Rect(clientRect.left + leftPadding, clientRect.top, clientRect.width, clientRect.height);
407     }
408
409     _getCompletionPrefix(prefix)
410     {
411         // For "border: 1px so|", we want to suggest "solid" based on "so" prefix.
412         let match = prefix.match(/[a-z0-9()-]+$/i);
413         if (match)
414             return match[0];
415
416         return prefix;
417     }
418
419     _applyCompletionHint()
420     {
421         if (!this._completionProvider || !this.suggestionHint)
422             return;
423
424         this._element.textContent = this._element.textContent;
425     }
426 };