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