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 {number=} maxItemsHeight 55 */ 56 WebInspector.SuggestBox = function(suggestBoxDelegate, maxItemsHeight) 57 { 58 this._suggestBoxDelegate = suggestBoxDelegate; 59 this._length = 0; 60 this._selectedIndex = -1; 61 this._selectedElement = null; 62 this._maxItemsHeight = maxItemsHeight; 63 this._bodyElement = document.body; 64 this._maybeHideBound = this._maybeHide.bind(this); 65 this._element = document.createElementWithClass("div", "suggest-box"); 66 this._element.addEventListener("mousedown", this._onBoxMouseDown.bind(this), true); 67 } 68 69 WebInspector.SuggestBox.prototype = { 70 /** 71 * @return {boolean} 72 */ 73 visible: function() 74 { 75 return !!this._element.parentElement; 76 }, 77 78 /** 79 * @param {!AnchorBox} anchorBox 80 */ 81 setPosition: function(anchorBox) 82 { 83 this._updateBoxPosition(anchorBox); 84 }, 85 86 /** 87 * @param {!AnchorBox} anchorBox 88 */ 89 _updateBoxPosition: function(anchorBox) 90 { 91 console.assert(this._overlay); 92 if (this._lastAnchorBox && this._lastAnchorBox.equals(anchorBox)) 93 return; 94 this._lastAnchorBox = anchorBox; 95 96 // Position relative to main DevTools element. 97 var container = WebInspector.Dialog.modalHostView().element; 98 anchorBox = anchorBox.relativeToElement(container); 99 var totalWidth = container.offsetWidth; 100 var totalHeight = container.offsetHeight; 101 var aboveHeight = anchorBox.y; 102 var underHeight = totalHeight - anchorBox.y - anchorBox.height; 103 104 var rowHeight = 17; 105 const spacer = 6; 106 107 var maxHeight = this._maxItemsHeight ? this._maxItemsHeight * rowHeight : Math.max(underHeight, aboveHeight) - spacer; 108 var under = underHeight >= aboveHeight; 109 this._leftSpacerElement.style.flexBasis = anchorBox.x + "px"; 110 111 this._overlay.element.classList.toggle("under-anchor", under); 112 113 if (under) { 114 this._bottomSpacerElement.style.flexBasis = "auto"; 115 this._topSpacerElement.style.flexBasis = (anchorBox.y + anchorBox.height) + "px"; 116 } else { 117 this._bottomSpacerElement.style.flexBasis = (totalHeight - anchorBox.y) + "px"; 118 this._topSpacerElement.style.flexBasis = "auto"; 119 } 120 this._element.style.maxHeight = maxHeight + "px"; 121 }, 122 123 /** 124 * @param {!Event} event 125 */ 126 _onBoxMouseDown: function(event) 127 { 128 if (this._hideTimeoutId) { 129 window.clearTimeout(this._hideTimeoutId); 130 delete this._hideTimeoutId; 131 } 132 event.preventDefault(); 133 }, 134 135 _maybeHide: function() 136 { 137 if (!this._hideTimeoutId) 138 this._hideTimeoutId = window.setTimeout(this.hide.bind(this), 0); 139 }, 140 141 _show: function() 142 { 143 if (this.visible()) 144 return; 145 this._overlay = new WebInspector.SuggestBox.Overlay(); 146 this._bodyElement.addEventListener("mousedown", this._maybeHideBound, true); 147 148 this._leftSpacerElement = this._overlay.element.createChild("div", "suggest-box-left-spacer"); 149 this._horizontalElement = this._overlay.element.createChild("div", "suggest-box-horizontal"); 150 this._topSpacerElement = this._horizontalElement.createChild("div", "suggest-box-top-spacer"); 151 this._horizontalElement.appendChild(this._element); 152 this._bottomSpacerElement = this._horizontalElement.createChild("div", "suggest-box-bottom-spacer"); 153 }, 154 155 hide: function() 156 { 157 if (!this.visible()) 158 return; 159 160 this._bodyElement.removeEventListener("mousedown", this._maybeHideBound, true); 161 this._element.remove(); 162 this._overlay.dispose(); 163 delete this._overlay; 164 delete this._selectedElement; 165 this._selectedIndex = -1; 166 delete this._lastAnchorBox; 167 }, 168 169 removeFromElement: function() 170 { 171 this.hide(); 172 }, 173 174 /** 175 * @param {boolean=} isIntermediateSuggestion 176 */ 177 _applySuggestion: function(isIntermediateSuggestion) 178 { 179 if (!this.visible() || !this._selectedElement) 180 return false; 181 182 var suggestion = this._selectedElement.textContent; 183 if (!suggestion) 184 return false; 185 186 this._suggestBoxDelegate.applySuggestion(suggestion, isIntermediateSuggestion); 187 return true; 188 }, 189 190 /** 191 * @return {boolean} 192 */ 193 acceptSuggestion: function() 194 { 195 var result = this._applySuggestion(); 196 this.hide(); 197 if (!result) 198 return false; 199 200 this._suggestBoxDelegate.acceptSuggestion(); 201 202 return true; 203 }, 204 205 /** 206 * @param {number} shift 207 * @param {boolean=} isCircular 208 * @return {boolean} is changed 209 */ 210 _selectClosest: function(shift, isCircular) 211 { 212 if (!this._length) 213 return false; 214 215 if (this._selectedIndex === -1 && shift < 0) 216 shift += 1; 217 218 var index = this._selectedIndex + shift; 219 220 if (isCircular) 221 index = (this._length + index) % this._length; 222 else 223 index = Number.constrain(index, 0, this._length - 1); 224 225 this._selectItem(index, true); 226 this._applySuggestion(true); 227 return true; 228 }, 229 230 /** 231 * @param {!Event} event 232 */ 233 _onItemMouseDown: function(event) 234 { 235 this._selectedElement = event.currentTarget; 236 this.acceptSuggestion(); 237 event.consume(true); 238 }, 239 240 /** 241 * @param {string} prefix 242 * @param {string} text 243 */ 244 _createItemElement: function(prefix, text) 245 { 246 var element = document.createElementWithClass("div", "suggest-box-content-item source-code"); 247 element.tabIndex = -1; 248 if (prefix && prefix.length && !text.indexOf(prefix)) { 249 element.createChild("span", "prefix").textContent = prefix; 250 element.createChild("span", "suffix").textContent = text.substring(prefix.length); 251 } else { 252 element.createChild("span", "suffix").textContent = text; 253 } 254 element.createChild("span", "spacer"); 255 element.addEventListener("mousedown", this._onItemMouseDown.bind(this), false); 256 return element; 257 }, 258 259 /** 260 * @param {!Array.<string>} items 261 * @param {string} userEnteredText 262 */ 263 _updateItems: function(items, userEnteredText) 264 { 265 this._length = items.length; 266 this._element.removeChildren(); 267 delete this._selectedElement; 268 269 for (var i = 0; i < items.length; ++i) { 270 var item = items[i]; 271 var currentItemElement = this._createItemElement(userEnteredText, item); 272 this._element.appendChild(currentItemElement); 273 } 274 }, 275 276 /** 277 * @param {number} index 278 * @param {boolean} scrollIntoView 279 */ 280 _selectItem: function(index, scrollIntoView) 281 { 282 if (this._selectedElement) 283 this._selectedElement.classList.remove("selected"); 284 285 this._selectedIndex = index; 286 if (index < 0) 287 return; 288 289 this._selectedElement = this._element.children[index]; 290 this._selectedElement.classList.add("selected"); 291 292 if (scrollIntoView) 293 this._selectedElement.scrollIntoViewIfNeeded(false); 294 }, 295 296 /** 297 * @param {!Array.<string>} completions 298 * @param {boolean} canShowForSingleItem 299 * @param {string} userEnteredText 300 */ 301 _canShowBox: function(completions, canShowForSingleItem, userEnteredText) 302 { 303 if (!completions || !completions.length) 304 return false; 305 306 if (completions.length > 1) 307 return true; 308 309 // Do not show a single suggestion if it is the same as user-entered prefix, even if allowed to show single-item suggest boxes. 310 return canShowForSingleItem && completions[0] !== userEnteredText; 311 }, 312 313 _ensureRowCountPerViewport: function() 314 { 315 if (this._rowCountPerViewport) 316 return; 317 if (!this._element.firstChild) 318 return; 319 320 this._rowCountPerViewport = Math.floor(this._element.offsetHeight / this._element.firstChild.offsetHeight); 321 }, 322 323 /** 324 * @param {!AnchorBox} anchorBox 325 * @param {!Array.<string>} completions 326 * @param {number} selectedIndex 327 * @param {boolean} canShowForSingleItem 328 * @param {string} userEnteredText 329 */ 330 updateSuggestions: function(anchorBox, completions, selectedIndex, canShowForSingleItem, userEnteredText) 331 { 332 if (this._canShowBox(completions, canShowForSingleItem, userEnteredText)) { 333 this._updateItems(completions, userEnteredText); 334 this._show(); 335 this._updateBoxPosition(anchorBox); 336 this._selectItem(selectedIndex, selectedIndex > 0); 337 delete this._rowCountPerViewport; 338 } else 339 this.hide(); 340 }, 341 342 /** 343 * @param {!KeyboardEvent} event 344 * @return {boolean} 345 */ 346 keyPressed: function(event) 347 { 348 switch (event.keyIdentifier) { 349 case "Up": 350 return this.upKeyPressed(); 351 case "Down": 352 return this.downKeyPressed(); 353 case "PageUp": 354 return this.pageUpKeyPressed(); 355 case "PageDown": 356 return this.pageDownKeyPressed(); 357 case "Enter": 358 return this.enterKeyPressed(); 359 } 360 return false; 361 }, 362 363 /** 364 * @return {boolean} 365 */ 366 upKeyPressed: function() 367 { 368 return this._selectClosest(-1, true); 369 }, 370 371 /** 372 * @return {boolean} 373 */ 374 downKeyPressed: function() 375 { 376 return this._selectClosest(1, true); 377 }, 378 379 /** 380 * @return {boolean} 381 */ 382 pageUpKeyPressed: function() 383 { 384 this._ensureRowCountPerViewport(); 385 return this._selectClosest(-this._rowCountPerViewport, false); 386 }, 387 388 /** 389 * @return {boolean} 390 */ 391 pageDownKeyPressed: function() 392 { 393 this._ensureRowCountPerViewport(); 394 return this._selectClosest(this._rowCountPerViewport, false); 395 }, 396 397 /** 398 * @return {boolean} 399 */ 400 enterKeyPressed: function() 401 { 402 var hasSelectedItem = !!this._selectedElement; 403 this.acceptSuggestion(); 404 405 // Report the event as non-handled if there is no selected item, 406 // to commit the input or handle it otherwise. 407 return hasSelectedItem; 408 } 409 } 410 411 /** 412 * @constructor 413 */ 414 WebInspector.SuggestBox.Overlay = function() 415 { 416 this.element = document.createElementWithClass("div", "suggest-box-overlay"); 417 this._resize(); 418 document.body.appendChild(this.element); 419 } 420 421 WebInspector.SuggestBox.Overlay.prototype = { 422 _resize: function() 423 { 424 var container = WebInspector.Dialog.modalHostView().element; 425 var containerBox = container.boxInWindow(container.ownerDocument.defaultView); 426 427 this.element.style.left = containerBox.x + "px"; 428 this.element.style.top = containerBox.y + "px"; 429 this.element.style.height = containerBox.height + "px"; 430 this.element.style.width = containerBox.width + "px"; 431 }, 432 433 dispose: function() 434 { 435 this.element.remove(); 436 } 437 } 438