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         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