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