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 */
34WebInspector.SuggestBoxDelegate = function()
35{
36}
37
38WebInspector.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/**
52 * @constructor
53 * @param {!WebInspector.SuggestBoxDelegate} suggestBoxDelegate
54 * @param {!Element} anchorElement
55 * @param {string=} className
56 * @param {number=} maxItemsHeight
57 */
58WebInspector.SuggestBox = function(suggestBoxDelegate, anchorElement, className, maxItemsHeight)
59{
60    this._suggestBoxDelegate = suggestBoxDelegate;
61    this._anchorElement = anchorElement;
62    this._length = 0;
63    this._selectedIndex = -1;
64    this._selectedElement = null;
65    this._maxItemsHeight = maxItemsHeight;
66    this._boundOnScroll = this._onScrollOrResize.bind(this, true);
67    this._boundOnResize = this._onScrollOrResize.bind(this, false);
68    window.addEventListener("scroll", this._boundOnScroll, true);
69    window.addEventListener("resize", this._boundOnResize, true);
70
71    this._bodyElement = anchorElement.ownerDocument.body;
72    this._element = anchorElement.ownerDocument.createElement("div");
73    this._element.className = "suggest-box " + (className || "");
74    this._element.addEventListener("mousedown", this._onBoxMouseDown.bind(this), true);
75    this.containerElement = this._element.createChild("div", "container");
76    this.contentElement = this.containerElement.createChild("div", "content");
77}
78
79WebInspector.SuggestBox.prototype = {
80    /**
81     * @return {boolean}
82     */
83    visible: function()
84    {
85        return !!this._element.parentElement;
86    },
87
88    /**
89     * @param {boolean} isScroll
90     * @param {?Event} event
91     */
92    _onScrollOrResize: function(isScroll, event)
93    {
94        if (isScroll && this._element.isAncestor(event.target) || !this.visible())
95            return;
96        this._updateBoxPosition(this._anchorBox);
97    },
98
99    /**
100     * @param {!AnchorBox} anchorBox
101     */
102    setPosition: function(anchorBox)
103    {
104        this._updateBoxPosition(anchorBox);
105    },
106
107    /**
108     * @param {!AnchorBox=} anchorBox
109     */
110    _updateBoxPosition: function(anchorBox)
111    {
112        this._anchorBox = anchorBox;
113        anchorBox = anchorBox || this._anchorElement.boxInWindow(window);
114
115        // Measure the content element box.
116        this.contentElement.style.display = "inline-block";
117        document.body.appendChild(this.contentElement);
118        this.contentElement.positionAt(0, 0);
119        var contentWidth = this.contentElement.offsetWidth;
120        var contentHeight = this.contentElement.offsetHeight;
121        this.contentElement.style.display = "block";
122        this.containerElement.appendChild(this.contentElement);
123
124        const spacer = 6;
125        const suggestBoxPaddingX = 21;
126        const suggestBoxPaddingY = 2;
127
128        var maxWidth = document.body.offsetWidth - anchorBox.x - spacer;
129        var width = Math.min(contentWidth, maxWidth - suggestBoxPaddingX) + suggestBoxPaddingX;
130        var paddedWidth = contentWidth + suggestBoxPaddingX;
131        var boxX = anchorBox.x;
132        if (width < paddedWidth) {
133            // Shift the suggest box to the left to accommodate the content without trimming to the BODY edge.
134            maxWidth = document.body.offsetWidth - spacer;
135            width = Math.min(contentWidth, maxWidth - suggestBoxPaddingX) + suggestBoxPaddingX;
136            boxX = document.body.offsetWidth - width;
137        }
138
139        var boxY;
140        var aboveHeight = anchorBox.y;
141        var underHeight = document.body.offsetHeight - anchorBox.y - anchorBox.height;
142
143        var maxHeight = this._maxItemsHeight ? contentHeight * this._maxItemsHeight / this._length : Math.max(underHeight, aboveHeight) - spacer;
144        var height = Math.min(contentHeight, maxHeight - suggestBoxPaddingY) + suggestBoxPaddingY;
145        if (underHeight >= aboveHeight) {
146            // Locate the suggest box under the anchorBox.
147            boxY = anchorBox.y + anchorBox.height;
148            this._element.classList.remove("above-anchor");
149            this._element.classList.add("under-anchor");
150        } else {
151            // Locate the suggest box above the anchorBox.
152            boxY = anchorBox.y - height;
153            this._element.classList.remove("under-anchor");
154            this._element.classList.add("above-anchor");
155        }
156
157        this._element.positionAt(boxX, boxY);
158        this._element.style.width = width + "px";
159        this._element.style.height = height + "px";
160    },
161
162    /**
163     * @param {?Event} event
164     */
165    _onBoxMouseDown: function(event)
166    {
167        event.preventDefault();
168    },
169
170    hide: function()
171    {
172        if (!this.visible())
173            return;
174
175        this._element.remove();
176        delete this._selectedElement;
177        this._selectedIndex = -1;
178    },
179
180    removeFromElement: function()
181    {
182        window.removeEventListener("scroll", this._boundOnScroll, true);
183        window.removeEventListener("resize", this._boundOnResize, true);
184        this.hide();
185    },
186
187    /**
188     * @param {string=} text
189     * @param {boolean=} isIntermediateSuggestion
190     */
191    _applySuggestion: function(text, isIntermediateSuggestion)
192    {
193        if (!this.visible() || !(text || this._selectedElement))
194            return false;
195
196        var suggestion = text || this._selectedElement.textContent;
197        if (!suggestion)
198            return false;
199
200        this._suggestBoxDelegate.applySuggestion(suggestion, isIntermediateSuggestion);
201        return true;
202    },
203
204    /**
205     * @param {string=} text
206     * @return {boolean}
207     */
208    acceptSuggestion: function(text)
209    {
210        var result = this._applySuggestion(text, false);
211        this.hide();
212        if (!result)
213            return false;
214
215        this._suggestBoxDelegate.acceptSuggestion();
216
217        return true;
218    },
219
220    /**
221     * @param {number} shift
222     * @param {boolean=} isCircular
223     * @return {boolean} is changed
224     */
225    _selectClosest: function(shift, isCircular)
226    {
227        if (!this._length)
228            return false;
229
230        if (this._selectedIndex === -1 && shift < 0)
231            shift += 1;
232
233        var index = this._selectedIndex + shift;
234
235        if (isCircular)
236            index = (this._length + index) % this._length;
237        else
238            index = Number.constrain(index, 0, this._length - 1);
239
240        this._selectItem(index);
241        this._applySuggestion(undefined, true);
242        return true;
243    },
244
245    /**
246     * @param {string} text
247     * @param {?Event} event
248     */
249    _onItemMouseDown: function(text, event)
250    {
251        this.acceptSuggestion(text);
252        event.consume(true);
253    },
254
255    /**
256     * @param {string} prefix
257     * @param {string} text
258     */
259    _createItemElement: function(prefix, text)
260    {
261        var element = document.createElement("div");
262        element.className = "suggest-box-content-item source-code";
263        element.tabIndex = -1;
264        if (prefix && prefix.length && !text.indexOf(prefix)) {
265            var prefixElement = element.createChild("span", "prefix");
266            prefixElement.textContent = prefix;
267            var suffixElement = element.createChild("span", "suffix");
268            suffixElement.textContent = text.substring(prefix.length);
269        } else {
270            var suffixElement = element.createChild("span", "suffix");
271            suffixElement.textContent = text;
272        }
273        element.addEventListener("mousedown", this._onItemMouseDown.bind(this, text), false);
274        return element;
275    },
276
277    /**
278     * @param {!Array.<string>} items
279     * @param {number} selectedIndex
280     * @param {string} userEnteredText
281     */
282    _updateItems: function(items, selectedIndex, userEnteredText)
283    {
284        this._length = items.length;
285        this.contentElement.removeChildren();
286
287        for (var i = 0; i < items.length; ++i) {
288            var item = items[i];
289            var currentItemElement = this._createItemElement(userEnteredText, item);
290            this.contentElement.appendChild(currentItemElement);
291        }
292
293        this._selectedElement = null;
294        if (typeof selectedIndex === "number")
295            this._selectItem(selectedIndex);
296    },
297
298    /**
299     * @param {number} index
300     */
301    _selectItem: function(index)
302    {
303        if (this._selectedElement)
304            this._selectedElement.classList.remove("selected");
305
306        this._selectedIndex = index;
307        if (index < 0)
308            return;
309
310        this._selectedElement = this.contentElement.children[index];
311        this._selectedElement.classList.add("selected");
312
313        this._selectedElement.scrollIntoViewIfNeeded(false);
314    },
315
316    /**
317     * @param {!Array.<string>} completions
318     * @param {boolean} canShowForSingleItem
319     * @param {string} userEnteredText
320     */
321    _canShowBox: function(completions, canShowForSingleItem, userEnteredText)
322    {
323        if (!completions || !completions.length)
324            return false;
325
326        if (completions.length > 1)
327            return true;
328
329        // Do not show a single suggestion if it is the same as user-entered prefix, even if allowed to show single-item suggest boxes.
330        return canShowForSingleItem && completions[0] !== userEnteredText;
331    },
332
333    _rememberRowCountPerViewport: function()
334    {
335        if (!this.contentElement.firstChild)
336            return;
337
338        this._rowCountPerViewport = Math.floor(this.containerElement.offsetHeight / this.contentElement.firstChild.offsetHeight);
339    },
340
341    /**
342     * @param {!AnchorBox} anchorBox
343     * @param {!Array.<string>} completions
344     * @param {number} selectedIndex
345     * @param {boolean} canShowForSingleItem
346     * @param {string} userEnteredText
347     */
348    updateSuggestions: function(anchorBox, completions, selectedIndex, canShowForSingleItem, userEnteredText)
349    {
350        if (this._canShowBox(completions, canShowForSingleItem, userEnteredText)) {
351            this._updateItems(completions, selectedIndex, userEnteredText);
352            this._updateBoxPosition(anchorBox);
353            if (!this.visible())
354                this._bodyElement.appendChild(this._element);
355            this._rememberRowCountPerViewport();
356        } else
357            this.hide();
358    },
359
360    /**
361     * @param {!KeyboardEvent} event
362     * @return {boolean}
363     */
364    keyPressed: function(event)
365    {
366        switch (event.keyIdentifier) {
367        case "Up":
368            return this.upKeyPressed();
369        case "Down":
370            return this.downKeyPressed();
371        case "PageUp":
372            return this.pageUpKeyPressed();
373        case "PageDown":
374            return this.pageDownKeyPressed();
375        case "Enter":
376            return this.enterKeyPressed();
377        }
378        return false;
379    },
380
381    /**
382     * @return {boolean}
383     */
384    upKeyPressed: function()
385    {
386        return this._selectClosest(-1, true);
387    },
388
389    /**
390     * @return {boolean}
391     */
392    downKeyPressed: function()
393    {
394        return this._selectClosest(1, true);
395    },
396
397    /**
398     * @return {boolean}
399     */
400    pageUpKeyPressed: function()
401    {
402        return this._selectClosest(-this._rowCountPerViewport, false);
403    },
404
405    /**
406     * @return {boolean}
407     */
408    pageDownKeyPressed: function()
409    {
410        return this._selectClosest(this._rowCountPerViewport, false);
411    },
412
413    /**
414     * @return {boolean}
415     */
416    enterKeyPressed: function()
417    {
418        var hasSelectedItem = !!this._selectedElement;
419        this.acceptSuggestion();
420
421        // Report the event as non-handled if there is no selected item,
422        // to commit the input or handle it otherwise.
423        return hasSelectedItem;
424    }
425}
426