| /* |
| * Copyright (C) 2013 Google Inc. All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions are |
| * met: |
| * |
| * * Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * * Redistributions in binary form must reproduce the above |
| * copyright notice, this list of conditions and the following disclaimer |
| * in the documentation and/or other materials provided with the |
| * distribution. |
| * * Neither the name of Google Inc. nor the names of its |
| * contributors may be used to endorse or promote products derived from |
| * this software without specific prior written permission. |
| * |
| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| /** |
| * @interface |
| */ |
| WebInspector.SuggestBoxDelegate = function() |
| { |
| } |
| |
| WebInspector.SuggestBoxDelegate.prototype = { |
| /** |
| * @param {string} suggestion |
| * @param {boolean=} isIntermediateSuggestion |
| */ |
| applySuggestion: function(suggestion, isIntermediateSuggestion) { }, |
| |
| /** |
| * acceptSuggestion will be always called after call to applySuggestion with isIntermediateSuggestion being equal to false. |
| */ |
| acceptSuggestion: function() { }, |
| } |
| |
| /** |
| * @constructor |
| * @param {!WebInspector.SuggestBoxDelegate} suggestBoxDelegate |
| * @param {number=} maxItemsHeight |
| */ |
| WebInspector.SuggestBox = function(suggestBoxDelegate, maxItemsHeight) |
| { |
| this._suggestBoxDelegate = suggestBoxDelegate; |
| this._length = 0; |
| this._selectedIndex = -1; |
| this._selectedElement = null; |
| this._maxItemsHeight = maxItemsHeight; |
| this._maybeHideBound = this._maybeHide.bind(this); |
| this._container = createElementWithClass("div", "suggest-box-container"); |
| this._element = this._container.createChild("div", "suggest-box"); |
| this._element.addEventListener("mousedown", this._onBoxMouseDown.bind(this), true); |
| this._detailsPopup = this._container.createChild("div", "suggest-box details-popup monospace"); |
| this._detailsPopup.classList.add("hidden"); |
| this._asyncDetailsCallback = null; |
| /** @type {!Map<number, !Promise<{detail: string, description: string}>>} */ |
| this._asyncDetailsPromises = new Map(); |
| } |
| |
| /** |
| * @typedef Array.<{title: string, className: (string|undefined)}> |
| */ |
| WebInspector.SuggestBox.Suggestions; |
| |
| WebInspector.SuggestBox.prototype = { |
| /** |
| * @return {boolean} |
| */ |
| visible: function() |
| { |
| return !!this._container.parentElement; |
| }, |
| |
| /** |
| * @param {!AnchorBox} anchorBox |
| */ |
| setPosition: function(anchorBox) |
| { |
| this._updateBoxPosition(anchorBox); |
| }, |
| |
| /** |
| * @param {!AnchorBox} anchorBox |
| */ |
| _updateBoxPosition: function(anchorBox) |
| { |
| console.assert(this._overlay); |
| if (this._lastAnchorBox && this._lastAnchorBox.equals(anchorBox)) |
| return; |
| this._lastAnchorBox = anchorBox; |
| |
| // Position relative to main DevTools element. |
| var container = WebInspector.Dialog.modalHostView().element; |
| anchorBox = anchorBox.relativeToElement(container); |
| var totalHeight = container.offsetHeight; |
| var aboveHeight = anchorBox.y; |
| var underHeight = totalHeight - anchorBox.y - anchorBox.height; |
| |
| this._overlay.setLeftOffset(anchorBox.x); |
| |
| var under = underHeight >= aboveHeight; |
| if (under) |
| this._overlay.setVerticalOffset(anchorBox.y + anchorBox.height, true); |
| else |
| this._overlay.setVerticalOffset(totalHeight - anchorBox.y, false); |
| |
| /** const */ var rowHeight = 17; |
| /** const */ var spacer = 6; |
| var maxHeight = this._maxItemsHeight ? this._maxItemsHeight * rowHeight : Math.max(underHeight, aboveHeight) - spacer; |
| this._element.style.maxHeight = maxHeight + "px"; |
| }, |
| |
| /** |
| * @param {!Event} event |
| */ |
| _onBoxMouseDown: function(event) |
| { |
| if (this._hideTimeoutId) { |
| window.clearTimeout(this._hideTimeoutId); |
| delete this._hideTimeoutId; |
| } |
| event.preventDefault(); |
| }, |
| |
| _maybeHide: function() |
| { |
| if (!this._hideTimeoutId) |
| this._hideTimeoutId = window.setTimeout(this.hide.bind(this), 0); |
| }, |
| |
| /** |
| * // FIXME: make SuggestBox work for multiple documents. |
| * @suppressGlobalPropertiesCheck |
| */ |
| _show: function() |
| { |
| if (this.visible()) |
| return; |
| this._bodyElement = document.body; |
| this._bodyElement.addEventListener("mousedown", this._maybeHideBound, true); |
| this._overlay = new WebInspector.SuggestBox.Overlay(); |
| this._overlay.setContentElement(this._container); |
| }, |
| |
| hide: function() |
| { |
| if (!this.visible()) |
| return; |
| |
| this._bodyElement.removeEventListener("mousedown", this._maybeHideBound, true); |
| delete this._bodyElement; |
| this._container.remove(); |
| this._overlay.dispose(); |
| delete this._overlay; |
| delete this._selectedElement; |
| this._selectedIndex = -1; |
| delete this._lastAnchorBox; |
| }, |
| |
| removeFromElement: function() |
| { |
| this.hide(); |
| }, |
| |
| /** |
| * @param {boolean=} isIntermediateSuggestion |
| */ |
| _applySuggestion: function(isIntermediateSuggestion) |
| { |
| if (!this.visible() || !this._selectedElement) |
| return false; |
| |
| var suggestion = this._selectedElement.__fullValue; |
| if (!suggestion) |
| return false; |
| |
| this._suggestBoxDelegate.applySuggestion(suggestion, isIntermediateSuggestion); |
| return true; |
| }, |
| |
| /** |
| * @return {boolean} |
| */ |
| acceptSuggestion: function() |
| { |
| var result = this._applySuggestion(); |
| this.hide(); |
| if (!result) |
| return false; |
| |
| this._suggestBoxDelegate.acceptSuggestion(); |
| |
| return true; |
| }, |
| |
| /** |
| * @param {number} shift |
| * @param {boolean=} isCircular |
| * @return {boolean} is changed |
| */ |
| _selectClosest: function(shift, isCircular) |
| { |
| if (!this._length) |
| return false; |
| |
| if (this._selectedIndex === -1 && shift < 0) |
| shift += 1; |
| |
| var index = this._selectedIndex + shift; |
| |
| if (isCircular) |
| index = (this._length + index) % this._length; |
| else |
| index = Number.constrain(index, 0, this._length - 1); |
| |
| this._selectItem(index, true); |
| this._applySuggestion(true); |
| return true; |
| }, |
| |
| /** |
| * @param {!Event} event |
| */ |
| _onItemMouseDown: function(event) |
| { |
| this._selectedElement = event.currentTarget; |
| this.acceptSuggestion(); |
| event.consume(true); |
| }, |
| |
| /** |
| * @param {string} prefix |
| * @param {string} text |
| * @param {string|undefined} className |
| * @param {number} index |
| */ |
| _createItemElement: function(prefix, text, className, index) |
| { |
| var element = createElementWithClass("div", "suggest-box-content-item source-code " + (className || "")); |
| element.tabIndex = -1; |
| if (prefix && prefix.length && !text.indexOf(prefix)) { |
| element.createChild("span", "prefix").textContent = prefix; |
| element.createChild("span", "suffix").textContent = text.substring(prefix.length).trimEnd(50); |
| } else { |
| element.createChild("span", "suffix").textContent = text.trimEnd(50); |
| } |
| element.__fullValue = text; |
| element.createChild("span", "spacer"); |
| element.addEventListener("mousedown", this._onItemMouseDown.bind(this), false); |
| return element; |
| }, |
| |
| /** |
| * @param {!WebInspector.SuggestBox.Suggestions} items |
| * @param {string} userEnteredText |
| * @param {function(number): !Promise<{detail:string, description:string}>=} asyncDetails |
| */ |
| _updateItems: function(items, userEnteredText, asyncDetails) |
| { |
| this._length = items.length; |
| this._asyncDetailsPromises.clear(); |
| this._asyncDetailsCallback = asyncDetails; |
| this._element.removeChildren(); |
| delete this._selectedElement; |
| |
| for (var i = 0; i < items.length; ++i) { |
| var item = items[i]; |
| var currentItemElement = this._createItemElement(userEnteredText, item.title, item.className, i); |
| this._element.appendChild(currentItemElement); |
| } |
| }, |
| |
| /** |
| * @param {number} index |
| * @return {!Promise<?{detail: string, description: string}>} |
| */ |
| _asyncDetails: function(index) |
| { |
| if (!this._asyncDetailsCallback) |
| return Promise.resolve(/** @type {?{description: string, detail: string}} */(null)); |
| if (!this._asyncDetailsPromises.has(index)) |
| this._asyncDetailsPromises.set(index, this._asyncDetailsCallback(index)); |
| return /** @type {!Promise<?{detail: string, description: string}>} */(this._asyncDetailsPromises.get(index)); |
| }, |
| |
| /** |
| * @param {?{detail: string, description: string}} details |
| */ |
| _showDetailsPopup: function(details) |
| { |
| this._detailsPopup.removeChildren(); |
| if (!details) |
| return; |
| this._detailsPopup.createChild("section", "detail").createTextChild(details.detail); |
| this._detailsPopup.createChild("section", "description").createTextChild(details.description); |
| this._detailsPopup.classList.remove("hidden"); |
| }, |
| |
| /** |
| * @param {number} index |
| * @param {boolean} scrollIntoView |
| */ |
| _selectItem: function(index, scrollIntoView) |
| { |
| if (this._selectedElement) |
| this._selectedElement.classList.remove("selected"); |
| |
| this._selectedIndex = index; |
| if (index < 0) |
| return; |
| |
| this._selectedElement = this._element.children[index]; |
| this._selectedElement.classList.add("selected"); |
| this._detailsPopup.classList.add("hidden"); |
| var elem = this._selectedElement; |
| this._asyncDetails(index).then(showDetails.bind(this), function(){}); |
| |
| if (scrollIntoView) |
| this._selectedElement.scrollIntoViewIfNeeded(false); |
| |
| /** |
| * @param {?{detail: string, description: string}} details |
| * @this {WebInspector.SuggestBox} |
| */ |
| function showDetails(details) |
| { |
| if (elem === this._selectedElement) |
| this._showDetailsPopup(details); |
| } |
| }, |
| |
| /** |
| * @param {!WebInspector.SuggestBox.Suggestions} completions |
| * @param {boolean} canShowForSingleItem |
| * @param {string} userEnteredText |
| */ |
| _canShowBox: function(completions, canShowForSingleItem, userEnteredText) |
| { |
| if (!completions || !completions.length) |
| return false; |
| |
| if (completions.length > 1) |
| return true; |
| |
| // Do not show a single suggestion if it is the same as user-entered prefix, even if allowed to show single-item suggest boxes. |
| return canShowForSingleItem && completions[0].title !== userEnteredText; |
| }, |
| |
| _ensureRowCountPerViewport: function() |
| { |
| if (this._rowCountPerViewport) |
| return; |
| if (!this._element.firstChild) |
| return; |
| |
| this._rowCountPerViewport = Math.floor(this._element.offsetHeight / this._element.firstChild.offsetHeight); |
| }, |
| |
| /** |
| * @param {!AnchorBox} anchorBox |
| * @param {!WebInspector.SuggestBox.Suggestions} completions |
| * @param {number} selectedIndex |
| * @param {boolean} canShowForSingleItem |
| * @param {string} userEnteredText |
| * @param {function(number): !Promise<{detail:string, description:string}>=} asyncDetails |
| */ |
| updateSuggestions: function(anchorBox, completions, selectedIndex, canShowForSingleItem, userEnteredText, asyncDetails) |
| { |
| if (this._canShowBox(completions, canShowForSingleItem, userEnteredText)) { |
| this._updateItems(completions, userEnteredText, asyncDetails); |
| this._show(); |
| this._updateBoxPosition(anchorBox); |
| this._selectItem(selectedIndex, selectedIndex > 0); |
| delete this._rowCountPerViewport; |
| } else |
| this.hide(); |
| }, |
| |
| /** |
| * @param {!KeyboardEvent} event |
| * @return {boolean} |
| */ |
| keyPressed: function(event) |
| { |
| switch (event.key) { |
| case "ArrowUp": |
| return this.upKeyPressed(); |
| case "ArrowDown": |
| return this.downKeyPressed(); |
| case "PageUp": |
| return this.pageUpKeyPressed(); |
| case "PageDown": |
| return this.pageDownKeyPressed(); |
| case "Enter": |
| return this.enterKeyPressed(); |
| } |
| return false; |
| }, |
| |
| /** |
| * @return {boolean} |
| */ |
| upKeyPressed: function() |
| { |
| return this._selectClosest(-1, true); |
| }, |
| |
| /** |
| * @return {boolean} |
| */ |
| downKeyPressed: function() |
| { |
| return this._selectClosest(1, true); |
| }, |
| |
| /** |
| * @return {boolean} |
| */ |
| pageUpKeyPressed: function() |
| { |
| this._ensureRowCountPerViewport(); |
| return this._selectClosest(-this._rowCountPerViewport, false); |
| }, |
| |
| /** |
| * @return {boolean} |
| */ |
| pageDownKeyPressed: function() |
| { |
| this._ensureRowCountPerViewport(); |
| return this._selectClosest(this._rowCountPerViewport, false); |
| }, |
| |
| /** |
| * @return {boolean} |
| */ |
| enterKeyPressed: function() |
| { |
| var hasSelectedItem = !!this._selectedElement; |
| this.acceptSuggestion(); |
| |
| // Report the event as non-handled if there is no selected item, |
| // to commit the input or handle it otherwise. |
| return hasSelectedItem; |
| } |
| } |
| |
| /** |
| * @constructor |
| * // FIXME: make SuggestBox work for multiple documents. |
| * @suppressGlobalPropertiesCheck |
| */ |
| WebInspector.SuggestBox.Overlay = function() |
| { |
| this.element = createElementWithClass("div", "suggest-box-overlay"); |
| var root = WebInspector.createShadowRootWithCoreStyles(this.element, "ui/suggestBox.css"); |
| this._leftSpacerElement = root.createChild("div", "suggest-box-left-spacer"); |
| this._horizontalElement = root.createChild("div", "suggest-box-horizontal"); |
| this._topSpacerElement = this._horizontalElement.createChild("div", "suggest-box-top-spacer"); |
| this._bottomSpacerElement = this._horizontalElement.createChild("div", "suggest-box-bottom-spacer"); |
| this._resize(); |
| document.body.appendChild(this.element); |
| } |
| |
| WebInspector.SuggestBox.Overlay.prototype = { |
| /** |
| * @param {number} offset |
| */ |
| setLeftOffset: function(offset) |
| { |
| this._leftSpacerElement.style.flexBasis = offset + "px"; |
| }, |
| |
| /** |
| * @param {number} offset |
| * @param {boolean} isTopOffset |
| */ |
| setVerticalOffset: function(offset, isTopOffset) |
| { |
| this.element.classList.toggle("under-anchor", isTopOffset); |
| |
| if (isTopOffset) { |
| this._bottomSpacerElement.style.flexBasis = "auto"; |
| this._topSpacerElement.style.flexBasis = offset + "px"; |
| } else { |
| this._bottomSpacerElement.style.flexBasis = offset + "px"; |
| this._topSpacerElement.style.flexBasis = "auto"; |
| } |
| }, |
| |
| /** |
| * @param {!Element} element |
| */ |
| setContentElement: function(element) |
| { |
| this._horizontalElement.insertBefore(element, this._bottomSpacerElement); |
| }, |
| |
| _resize: function() |
| { |
| var container = WebInspector.Dialog.modalHostView().element; |
| var containerBox = container.boxInWindow(container.ownerDocument.defaultView); |
| |
| this.element.style.left = containerBox.x + "px"; |
| this.element.style.top = containerBox.y + "px"; |
| this.element.style.height = containerBox.height + "px"; |
| this.element.style.width = containerBox.width + "px"; |
| }, |
| |
| dispose: function() |
| { |
| this.element.remove(); |
| } |
| } |