Web Inspector: separate SuggestBox from TextPrompt
[WebKit-https.git] / Source / WebCore / inspector / front-end / SuggestBox.js
1 /*
2  * Copyright (C) 2013 Google 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 are
6  * met:
7  *
8  *     * Redistributions of source code must retain the above copyright
9  * notice, this list of conditions and the following disclaimer.
10  *     * Redistributions in binary form must reproduce the above
11  * copyright notice, this list of conditions and the following disclaimer
12  * in the documentation and/or other materials provided with the
13  * distribution.
14  *     * Neither the name of Google Inc. nor the names of its
15  * contributors may be used to endorse or promote products derived from
16  * this software without specific prior written permission.
17  *
18  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19  * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20  * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21  * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22  * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23  * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29  */
30
31 /**
32  * @interface
33  */
34 WebInspector.SuggestBoxDelegate = function()
35 {
36 }
37
38 WebInspector.SuggestBoxDelegate.prototype = {
39     /**
40      * @param {string} suggestion
41      * @param {boolean=} isIntermediateSuggestion
42      */
43     applySuggestion: function(suggestion, isIntermediateSuggestion) { },
44
45     /**
46      * acceptSuggestion will be always called after call to applySuggestion with isIntermediateSuggestion being equal to false.
47      */
48     acceptSuggestion: function() { },
49
50     /**
51      * @return {string}
52      */
53     userEnteredText: function() { }
54 }
55
56 /**
57  * @constructor
58  * @param {WebInspector.SuggestBoxDelegate} suggestBoxDelegate
59  * @param {Element} inputElement
60  * @param {string} className
61  */
62 WebInspector.SuggestBox = function(suggestBoxDelegate, inputElement, className)
63 {
64     this._suggestBoxDelegate = suggestBoxDelegate;
65     this._inputElement = inputElement;
66     this._length = 0;
67     this._selectedIndex = -1;
68     this._selectedElement = null;
69     this._boundOnScroll = this._onscrollresize.bind(this, true);
70     this._boundOnResize = this._onscrollresize.bind(this, false);
71     window.addEventListener("scroll", this._boundOnScroll, true);
72     window.addEventListener("resize", this._boundOnResize, true);
73
74     this._bodyElement = inputElement.ownerDocument.body;
75     this._element = inputElement.ownerDocument.createElement("div");
76     this._element.className = "suggest-box " + (className || "");
77     this._element.addEventListener("mousedown", this._onboxmousedown.bind(this), true);
78     this.containerElement = this._element.createChild("div", "container");
79     this.contentElement = this.containerElement.createChild("div", "content");
80 }
81
82 WebInspector.SuggestBox.prototype = {
83     get visible()
84     {
85         return !!this._element.parentElement;
86     },
87
88     get hasSelection()
89     {
90         return !!this._selectedElement;
91     },
92
93     _onscrollresize: function(isScroll, event)
94     {
95         if (isScroll && this._element.isAncestor(event.target) || !this.visible)
96             return;
97         this._updateBoxPositionWithExistingAnchor();
98     },
99
100     _updateBoxPositionWithExistingAnchor: function()
101     {
102         this._updateBoxPosition(this._anchorBox);
103     },
104
105     /**
106      * @param {AnchorBox} anchorBox
107      */
108     _updateBoxPosition: function(anchorBox)
109     {
110         // Measure the content element box.
111         this.contentElement.style.display = "inline-block";
112         document.body.appendChild(this.contentElement);
113         this.contentElement.positionAt(0, 0);
114         var contentWidth = this.contentElement.offsetWidth;
115         var contentHeight = this.contentElement.offsetHeight;
116         this.contentElement.style.display = "block";
117         this.containerElement.appendChild(this.contentElement);
118
119         // Lay out the suggest-box relative to the anchorBox.
120         this._anchorBox = anchorBox;
121         const spacer = 6;
122
123         const suggestBoxPaddingX = 21;
124         var maxWidth = document.body.offsetWidth - anchorBox.x - spacer;
125         var width = Math.min(contentWidth, maxWidth - suggestBoxPaddingX) + suggestBoxPaddingX;
126         var paddedWidth = contentWidth + suggestBoxPaddingX;
127         var boxX = anchorBox.x;
128         if (width < paddedWidth) {
129             // Shift the suggest box to the left to accommodate the content without trimming to the BODY edge.
130             maxWidth = document.body.offsetWidth - spacer;
131             width = Math.min(contentWidth, maxWidth - suggestBoxPaddingX) + suggestBoxPaddingX;
132             boxX = document.body.offsetWidth - width;
133         }
134
135         const suggestBoxPaddingY = 2;
136         var boxY;
137         var aboveHeight = anchorBox.y;
138         var underHeight = document.body.offsetHeight - anchorBox.y - anchorBox.height;
139         var maxHeight = Math.max(underHeight, aboveHeight) - spacer;
140         var height = Math.min(contentHeight, maxHeight - suggestBoxPaddingY) + suggestBoxPaddingY;
141         if (underHeight >= aboveHeight) {
142             // Locate the suggest box under the anchorBox.
143             boxY = anchorBox.y + anchorBox.height;
144             this._element.removeStyleClass("above-anchor");
145             this._element.addStyleClass("under-anchor");
146         } else {
147             // Locate the suggest box above the anchorBox.
148             boxY = anchorBox.y - height;
149             this._element.removeStyleClass("under-anchor");
150             this._element.addStyleClass("above-anchor");
151         }
152
153         this._element.positionAt(boxX, boxY);
154         this._element.style.width = width + "px";
155         this._element.style.height = height + "px";
156     },
157
158     _onboxmousedown: function(event)
159     {
160         event.preventDefault();
161     },
162
163     hide: function()
164     {
165         if (!this.visible)
166             return;
167
168         this._element.parentElement.removeChild(this._element);
169         delete this._selectedElement;
170     },
171
172     removeFromElement: function()
173     {
174         window.removeEventListener("scroll", this._boundOnScroll, true);
175         window.removeEventListener("resize", this._boundOnResize, true);
176         this.hide();
177     },
178
179     /**
180      * @param {string=} text
181      * @param {boolean=} isIntermediateSuggestion
182      */
183     _applySuggestion: function(text, isIntermediateSuggestion)
184     {
185         if (!this.visible || !(text || this._selectedElement))
186             return false;
187
188         var suggestion = text || this._selectedElement.textContent;
189         if (!suggestion)
190             return false;
191
192         this._suggestBoxDelegate.applySuggestion(suggestion, isIntermediateSuggestion);
193         return true;
194     },
195
196     /**
197      * @param {string=} text
198      */
199     acceptSuggestion: function(text)
200     {
201         var result = this._applySuggestion(text, false);
202         this.hide();
203         if (!result)
204             return false;
205
206         this._suggestBoxDelegate.acceptSuggestion();
207
208         return true;
209     },
210
211     /**
212      * @param {number} shift
213      * @param {boolean=} isCircular
214      * @return {boolean} is changed
215      */
216     _selectClosest: function(shift, isCircular)
217     {
218         if (!this._length)
219             return false;
220
221         var index = this._selectedIndex + shift;
222
223         if (isCircular)
224             index = (this._length + index) % this._length;
225         else
226             index = Number.constrain(index, 0, this._length - 1);
227
228         this._selectItem(index);
229         this._applySuggestion(undefined, true);
230         return true;
231     },
232
233     /**
234      * @param {AnchorBox} anchorBox
235      * @param {Array.<string>=} completions
236      * @param {number=} selectedIndex
237      * @param {boolean=} canShowForSingleItem
238      */
239     updateSuggestions: function(anchorBox, completions, selectedIndex, canShowForSingleItem)
240     {
241         if (this._suggestTimeout) {
242             clearTimeout(this._suggestTimeout);
243             delete this._suggestTimeout;
244         }
245         this._completionsReady(anchorBox, completions, selectedIndex, canShowForSingleItem);
246     },
247
248     _onItemMouseDown: function(text, event)
249     {
250         this.acceptSuggestion(text);
251         event.consume(true);
252     },
253
254     _createItemElement: function(prefix, text)
255     {
256         var element = document.createElement("div");
257         element.className = "suggest-box-content-item source-code";
258         element.tabIndex = -1;
259         if (prefix && prefix.length && !text.indexOf(prefix)) {
260             var prefixElement = element.createChild("span", "prefix");
261             prefixElement.textContent = prefix;
262             var suffixElement = element.createChild("span", "suffix");
263             suffixElement.textContent = text.substring(prefix.length);
264         } else {
265             var suffixElement = element.createChild("span", "suffix");
266             suffixElement.textContent = text;
267         }
268         element.addEventListener("mousedown", this._onItemMouseDown.bind(this, text), false);
269         return element;
270     },
271
272     /**
273      * @param {Array.<string>=} items
274      * @param {number=} selectedIndex
275      */
276     _updateItems: function(items, selectedIndex)
277     {
278         this._length = items.length;
279         this.contentElement.removeChildren();
280
281         var userEnteredText = this._suggestBoxDelegate.userEnteredText();
282         for (var i = 0; i < items.length; ++i) {
283             var item = items[i];
284             var currentItemElement = this._createItemElement(userEnteredText, item);
285             this.contentElement.appendChild(currentItemElement);
286         }
287
288         this._selectedElement = null;
289         if (typeof selectedIndex === "number")
290             this._selectItem(selectedIndex);
291     },
292
293     /**
294      * @param {number} index
295      */
296     _selectItem: function(index)
297     {
298         if (this._selectedElement)
299             this._selectedElement.classList.remove("selected");
300
301         this._selectedIndex = index;
302         this._selectedElement = this.contentElement.children[index];
303         this._selectedElement.classList.add("selected");
304
305         this._selectedElement.scrollIntoViewIfNeeded(false);
306     },
307
308     /**
309      * @param {Array.<string>=} completions
310      * @param {boolean=} canShowForSingleItem
311      */
312     _canShowBox: function(completions, canShowForSingleItem)
313     {
314         if (!completions || !completions.length)
315             return false;
316
317         if (completions.length > 1)
318             return true;
319
320         // Do not show a single suggestion if it is the same as user-entered prefix, even if allowed to show single-item suggest boxes.
321         return canShowForSingleItem && completions[0] !== this._suggestBoxDelegate.userEnteredText();
322     },
323
324     _rememberRowCountPerViewport: function()
325     {
326         if (!this.contentElement.firstChild)
327             return;
328
329         this._rowCountPerViewport = Math.floor(this.containerElement.offsetHeight / this.contentElement.firstChild.offsetHeight);
330     },
331
332     /**
333      * @param {AnchorBox} anchorBox
334      * @param {Array.<string>=} completions
335      * @param {number=} selectedIndex
336      * @param {boolean=} canShowForSingleItem
337      */
338     _completionsReady: function(anchorBox, completions, selectedIndex, canShowForSingleItem)
339     {
340         if (this._canShowBox(completions, canShowForSingleItem)) {
341             this._updateItems(completions, selectedIndex);
342             this._updateBoxPosition(anchorBox);
343             if (!this.visible)
344                 this._bodyElement.appendChild(this._element);
345             this._rememberRowCountPerViewport();
346         } else
347             this.hide();
348     },
349
350     /**
351      * @return {boolean}
352      */
353     upKeyPressed: function()
354     {
355         return this._selectClosest(-1, true);
356     },
357
358     /**
359      * @return {boolean}
360      */
361     downKeyPressed: function()
362     {
363         return this._selectClosest(1, true);
364     },
365
366     /**
367      * @return {boolean}
368      */
369     pageUpKeyPressed: function()
370     {
371         return this._selectClosest(-this._rowCountPerViewport, false);
372     },
373
374     /**
375      * @return {boolean}
376      */
377     pageDownKeyPressed: function()
378     {
379         return this._selectClosest(this._rowCountPerViewport, false);
380     },
381
382     /**
383      * @return {boolean}
384      */
385     enterKeyPressed: function()
386     {
387         var hasSelectedItem = !!this._selectedElement;
388         this.acceptSuggestion();
389
390         // Report the event as non-handled if there is no selected item,
391         // to commit the input or handle it otherwise.
392         return hasSelectedItem;
393     },
394
395     /**
396      * @return {boolean}
397      */
398     tabKeyPressed: function()
399     {
400         return this.enterKeyPressed();
401     }
402 }