Home | History | Annotate | Download | only in front_end
      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 /**
     52  * @constructor
     53  * @param {WebInspector.SuggestBoxDelegate} suggestBoxDelegate
     54  * @param {Element} anchorElement
     55  * @param {string=} className
     56  * @param {number=} maxItemsHeight
     57  */
     58 WebInspector.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 
     79 WebInspector.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 
    114         // Measure the content element box.
    115         this.contentElement.style.display = "inline-block";
    116         document.body.appendChild(this.contentElement);
    117         this.contentElement.positionAt(0, 0);
    118         var contentWidth = this.contentElement.offsetWidth;
    119         var contentHeight = this.contentElement.offsetHeight;
    120         this.contentElement.style.display = "block";
    121         this.containerElement.appendChild(this.contentElement);
    122 
    123         const spacer = 6;
    124         const suggestBoxPaddingX = 21;
    125         const suggestBoxPaddingY = 2;
    126 
    127         var maxWidth = document.body.offsetWidth - anchorBox.x - spacer;
    128         var width = Math.min(contentWidth, maxWidth - suggestBoxPaddingX) + suggestBoxPaddingX;
    129         var paddedWidth = contentWidth + suggestBoxPaddingX;
    130         var boxX = anchorBox.x;
    131         if (width < paddedWidth) {
    132             // Shift the suggest box to the left to accommodate the content without trimming to the BODY edge.
    133             maxWidth = document.body.offsetWidth - spacer;
    134             width = Math.min(contentWidth, maxWidth - suggestBoxPaddingX) + suggestBoxPaddingX;
    135             boxX = document.body.offsetWidth - width;
    136         }
    137 
    138         var boxY;
    139         var aboveHeight = anchorBox.y;
    140         var underHeight = document.body.offsetHeight - anchorBox.y - anchorBox.height;
    141 
    142         var maxHeight = this._maxItemsHeight ? contentHeight * this._maxItemsHeight / this._length : Math.max(underHeight, aboveHeight) - spacer;
    143         var height = Math.min(contentHeight, maxHeight - suggestBoxPaddingY) + suggestBoxPaddingY;
    144         if (underHeight >= aboveHeight) {
    145             // Locate the suggest box under the anchorBox.
    146             boxY = anchorBox.y + anchorBox.height;
    147             this._element.removeStyleClass("above-anchor");
    148             this._element.addStyleClass("under-anchor");
    149         } else {
    150             // Locate the suggest box above the anchorBox.
    151             boxY = anchorBox.y - height;
    152             this._element.removeStyleClass("under-anchor");
    153             this._element.addStyleClass("above-anchor");
    154         }
    155 
    156         this._element.positionAt(boxX, boxY);
    157         this._element.style.width = width + "px";
    158         this._element.style.height = height + "px";
    159     },
    160 
    161     /**
    162      * @param {Event} event
    163      */
    164     _onBoxMouseDown: function(event)
    165     {
    166         event.preventDefault();
    167     },
    168 
    169     hide: function()
    170     {
    171         if (!this.visible())
    172             return;
    173 
    174         this._element.remove();
    175         delete this._selectedElement;
    176     },
    177 
    178     removeFromElement: function()
    179     {
    180         window.removeEventListener("scroll", this._boundOnScroll, true);
    181         window.removeEventListener("resize", this._boundOnResize, true);
    182         this.hide();
    183     },
    184 
    185     /**
    186      * @param {string=} text
    187      * @param {boolean=} isIntermediateSuggestion
    188      */
    189     _applySuggestion: function(text, isIntermediateSuggestion)
    190     {
    191         if (!this.visible() || !(text || this._selectedElement))
    192             return false;
    193 
    194         var suggestion = text || this._selectedElement.textContent;
    195         if (!suggestion)
    196             return false;
    197 
    198         this._suggestBoxDelegate.applySuggestion(suggestion, isIntermediateSuggestion);
    199         return true;
    200     },
    201 
    202     /**
    203      * @param {string=} text
    204      */
    205     acceptSuggestion: function(text)
    206     {
    207         var result = this._applySuggestion(text, false);
    208         this.hide();
    209         if (!result)
    210             return false;
    211 
    212         this._suggestBoxDelegate.acceptSuggestion();
    213 
    214         return true;
    215     },
    216 
    217     /**
    218      * @param {number} shift
    219      * @param {boolean=} isCircular
    220      * @return {boolean} is changed
    221      */
    222     _selectClosest: function(shift, isCircular)
    223     {
    224         if (!this._length)
    225             return false;
    226 
    227         var index = this._selectedIndex + shift;
    228 
    229         if (isCircular)
    230             index = (this._length + index) % this._length;
    231         else
    232             index = Number.constrain(index, 0, this._length - 1);
    233 
    234         this._selectItem(index);
    235         this._applySuggestion(undefined, true);
    236         return true;
    237     },
    238 
    239     /**
    240      * @param {string} text
    241      * @param {Event} event
    242      */
    243     _onItemMouseDown: function(text, event)
    244     {
    245         this.acceptSuggestion(text);
    246         event.consume(true);
    247     },
    248 
    249     /**
    250      * @param {string} prefix
    251      * @param {string} text
    252      */
    253     _createItemElement: function(prefix, text)
    254     {
    255         var element = document.createElement("div");
    256         element.className = "suggest-box-content-item source-code";
    257         element.tabIndex = -1;
    258         if (prefix && prefix.length && !text.indexOf(prefix)) {
    259             var prefixElement = element.createChild("span", "prefix");
    260             prefixElement.textContent = prefix;
    261             var suffixElement = element.createChild("span", "suffix");
    262             suffixElement.textContent = text.substring(prefix.length);
    263         } else {
    264             var suffixElement = element.createChild("span", "suffix");
    265             suffixElement.textContent = text;
    266         }
    267         element.addEventListener("mousedown", this._onItemMouseDown.bind(this, text), false);
    268         return element;
    269     },
    270 
    271     /**
    272      * @param {!Array.<string>} items
    273      * @param {number} selectedIndex
    274      * @param {string} userEnteredText
    275      */
    276     _updateItems: function(items, selectedIndex, userEnteredText)
    277     {
    278         this._length = items.length;
    279         this.contentElement.removeChildren();
    280 
    281         for (var i = 0; i < items.length; ++i) {
    282             var item = items[i];
    283             var currentItemElement = this._createItemElement(userEnteredText, item);
    284             this.contentElement.appendChild(currentItemElement);
    285         }
    286 
    287         this._selectedElement = null;
    288         if (typeof selectedIndex === "number")
    289             this._selectItem(selectedIndex);
    290     },
    291 
    292     /**
    293      * @param {number} index
    294      */
    295     _selectItem: function(index)
    296     {
    297         if (this._selectedElement)
    298             this._selectedElement.classList.remove("selected");
    299 
    300         this._selectedIndex = index;
    301         this._selectedElement = this.contentElement.children[index];
    302         this._selectedElement.classList.add("selected");
    303 
    304         this._selectedElement.scrollIntoViewIfNeeded(false);
    305     },
    306 
    307     /**
    308      * @param {!Array.<string>} completions
    309      * @param {boolean} canShowForSingleItem
    310      * @param {string} userEnteredText
    311      */
    312     _canShowBox: function(completions, canShowForSingleItem, userEnteredText)
    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] !== 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      * @param {string} userEnteredText
    338      */
    339     updateSuggestions: function(anchorBox, completions, selectedIndex, canShowForSingleItem, userEnteredText)
    340     {
    341         if (this._canShowBox(completions, canShowForSingleItem, userEnteredText)) {
    342             this._updateItems(completions, selectedIndex, userEnteredText);
    343             this._updateBoxPosition(anchorBox);
    344             if (!this.visible())
    345                 this._bodyElement.appendChild(this._element);
    346             this._rememberRowCountPerViewport();
    347         } else
    348             this.hide();
    349     },
    350 
    351     /**
    352      * @param {KeyboardEvent} event
    353      * @return {boolean}
    354      */
    355     keyPressed: function(event)
    356     {
    357         switch (event.keyIdentifier) {
    358         case "Up":
    359             return this.upKeyPressed();
    360         case "Down":
    361             return this.downKeyPressed();
    362         case "PageUp":
    363             return this.pageUpKeyPressed();
    364         case "PageDown":
    365             return this.pageDownKeyPressed();
    366         case "Enter":
    367             return this.enterKeyPressed();
    368         }
    369         return false;
    370     },
    371 
    372     /**
    373      * @return {boolean}
    374      */
    375     upKeyPressed: function()
    376     {
    377         return this._selectClosest(-1, true);
    378     },
    379 
    380     /**
    381      * @return {boolean}
    382      */
    383     downKeyPressed: function()
    384     {
    385         return this._selectClosest(1, true);
    386     },
    387 
    388     /**
    389      * @return {boolean}
    390      */
    391     pageUpKeyPressed: function()
    392     {
    393         return this._selectClosest(-this._rowCountPerViewport, false);
    394     },
    395 
    396     /**
    397      * @return {boolean}
    398      */
    399     pageDownKeyPressed: function()
    400     {
    401         return this._selectClosest(this._rowCountPerViewport, false);
    402     },
    403 
    404     /**
    405      * @return {boolean}
    406      */
    407     enterKeyPressed: function()
    408     {
    409         var hasSelectedItem = !!this._selectedElement;
    410         this.acceptSuggestion();
    411 
    412         // Report the event as non-handled if there is no selected item,
    413         // to commit the input or handle it otherwise.
    414         return hasSelectedItem;
    415     }
    416 }
    417