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