SuggestBox.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  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. * @interface
  32. */
  33. WebInspector.SuggestBoxDelegate = function()
  34. {
  35. }
  36. WebInspector.SuggestBoxDelegate.prototype = {
  37. /**
  38. * @param {string} suggestion
  39. * @param {boolean=} isIntermediateSuggestion
  40. */
  41. applySuggestion: function(suggestion, isIntermediateSuggestion) { },
  42. /**
  43. * acceptSuggestion will be always called after call to applySuggestion with isIntermediateSuggestion being equal to false.
  44. */
  45. acceptSuggestion: function() { },
  46. }
  47. /**
  48. * @constructor
  49. * @param {WebInspector.SuggestBoxDelegate} suggestBoxDelegate
  50. * @param {Element} anchorElement
  51. * @param {string=} className
  52. * @param {number=} maxItemsHeight
  53. */
  54. WebInspector.SuggestBox = function(suggestBoxDelegate, anchorElement, className, maxItemsHeight)
  55. {
  56. this._suggestBoxDelegate = suggestBoxDelegate;
  57. this._anchorElement = anchorElement;
  58. this._length = 0;
  59. this._selectedIndex = -1;
  60. this._selectedElement = null;
  61. this._maxItemsHeight = maxItemsHeight;
  62. this._boundOnScroll = this._onScrollOrResize.bind(this, true);
  63. this._boundOnResize = this._onScrollOrResize.bind(this, false);
  64. window.addEventListener("scroll", this._boundOnScroll, true);
  65. window.addEventListener("resize", this._boundOnResize, true);
  66. this._bodyElement = anchorElement.ownerDocument.body;
  67. this._element = anchorElement.ownerDocument.createElement("div");
  68. this._element.className = "suggest-box " + (className || "");
  69. this._element.addEventListener("mousedown", this._onBoxMouseDown.bind(this), true);
  70. this.containerElement = this._element.createChild("div", "container");
  71. this.contentElement = this.containerElement.createChild("div", "content");
  72. }
  73. WebInspector.SuggestBox.prototype = {
  74. /**
  75. * @return {boolean}
  76. */
  77. visible: function()
  78. {
  79. return !!this._element.parentElement;
  80. },
  81. /**
  82. * @param {boolean} isScroll
  83. * @param {Event} event
  84. */
  85. _onScrollOrResize: function(isScroll, event)
  86. {
  87. if (isScroll && this._element.isAncestor(event.target) || !this.visible())
  88. return;
  89. this._updateBoxPosition(this._anchorBox);
  90. },
  91. /**
  92. * @param {AnchorBox} anchorBox
  93. */
  94. setPosition: function(anchorBox)
  95. {
  96. this._updateBoxPosition(anchorBox);
  97. },
  98. /**
  99. * @param {AnchorBox} anchorBox
  100. */
  101. _updateBoxPosition: function(anchorBox)
  102. {
  103. this._anchorBox = anchorBox;
  104. // Measure the content element box.
  105. this.contentElement.style.display = "inline-block";
  106. document.body.appendChild(this.contentElement);
  107. this.contentElement.positionAt(0, 0);
  108. var contentWidth = this.contentElement.offsetWidth;
  109. var contentHeight = this.contentElement.offsetHeight;
  110. this.contentElement.style.display = "block";
  111. this.containerElement.appendChild(this.contentElement);
  112. const spacer = 6;
  113. const suggestBoxPaddingX = 21;
  114. const suggestBoxPaddingY = 2;
  115. var maxWidth = document.body.offsetWidth - anchorBox.x - spacer;
  116. var width = Math.min(contentWidth, maxWidth - suggestBoxPaddingX) + suggestBoxPaddingX;
  117. var paddedWidth = contentWidth + suggestBoxPaddingX;
  118. var boxX = anchorBox.x;
  119. if (width < paddedWidth) {
  120. // Shift the suggest box to the left to accommodate the content without trimming to the BODY edge.
  121. maxWidth = document.body.offsetWidth - spacer;
  122. width = Math.min(contentWidth, maxWidth - suggestBoxPaddingX) + suggestBoxPaddingX;
  123. boxX = document.body.offsetWidth - width;
  124. }
  125. var boxY;
  126. var aboveHeight = anchorBox.y;
  127. var underHeight = document.body.offsetHeight - anchorBox.y - anchorBox.height;
  128. var maxHeight = this._maxItemsHeight ? contentHeight * this._maxItemsHeight / this._length : Math.max(underHeight, aboveHeight) - spacer;
  129. var height = Math.min(contentHeight, maxHeight - suggestBoxPaddingY) + suggestBoxPaddingY;
  130. if (underHeight >= aboveHeight) {
  131. // Locate the suggest box under the anchorBox.
  132. boxY = anchorBox.y + anchorBox.height;
  133. this._element.removeStyleClass("above-anchor");
  134. this._element.addStyleClass("under-anchor");
  135. } else {
  136. // Locate the suggest box above the anchorBox.
  137. boxY = anchorBox.y - height;
  138. this._element.removeStyleClass("under-anchor");
  139. this._element.addStyleClass("above-anchor");
  140. }
  141. this._element.positionAt(boxX, boxY);
  142. this._element.style.width = width + "px";
  143. this._element.style.height = height + "px";
  144. },
  145. /**
  146. * @param {Event} event
  147. */
  148. _onBoxMouseDown: function(event)
  149. {
  150. event.preventDefault();
  151. },
  152. hide: function()
  153. {
  154. if (!this.visible())
  155. return;
  156. this._element.remove();
  157. delete this._selectedElement;
  158. },
  159. removeFromElement: function()
  160. {
  161. window.removeEventListener("scroll", this._boundOnScroll, true);
  162. window.removeEventListener("resize", this._boundOnResize, true);
  163. this.hide();
  164. },
  165. /**
  166. * @param {string=} text
  167. * @param {boolean=} isIntermediateSuggestion
  168. */
  169. _applySuggestion: function(text, isIntermediateSuggestion)
  170. {
  171. if (!this.visible() || !(text || this._selectedElement))
  172. return false;
  173. var suggestion = text || this._selectedElement.textContent;
  174. if (!suggestion)
  175. return false;
  176. this._suggestBoxDelegate.applySuggestion(suggestion, isIntermediateSuggestion);
  177. return true;
  178. },
  179. /**
  180. * @param {string=} text
  181. */
  182. acceptSuggestion: function(text)
  183. {
  184. var result = this._applySuggestion(text, false);
  185. this.hide();
  186. if (!result)
  187. return false;
  188. this._suggestBoxDelegate.acceptSuggestion();
  189. return true;
  190. },
  191. /**
  192. * @param {number} shift
  193. * @param {boolean=} isCircular
  194. * @return {boolean} is changed
  195. */
  196. _selectClosest: function(shift, isCircular)
  197. {
  198. if (!this._length)
  199. return false;
  200. var index = this._selectedIndex + shift;
  201. if (isCircular)
  202. index = (this._length + index) % this._length;
  203. else
  204. index = Number.constrain(index, 0, this._length - 1);
  205. this._selectItem(index);
  206. this._applySuggestion(undefined, true);
  207. return true;
  208. },
  209. /**
  210. * @param {string} text
  211. * @param {Event} event
  212. */
  213. _onItemMouseDown: function(text, event)
  214. {
  215. this.acceptSuggestion(text);
  216. event.consume(true);
  217. },
  218. /**
  219. * @param {string} prefix
  220. * @param {string} text
  221. */
  222. _createItemElement: function(prefix, text)
  223. {
  224. var element = document.createElement("div");
  225. element.className = "suggest-box-content-item source-code";
  226. element.tabIndex = -1;
  227. if (prefix && prefix.length && !text.indexOf(prefix)) {
  228. var prefixElement = element.createChild("span", "prefix");
  229. prefixElement.textContent = prefix;
  230. var suffixElement = element.createChild("span", "suffix");
  231. suffixElement.textContent = text.substring(prefix.length);
  232. } else {
  233. var suffixElement = element.createChild("span", "suffix");
  234. suffixElement.textContent = text;
  235. }
  236. element.addEventListener("mousedown", this._onItemMouseDown.bind(this, text), false);
  237. return element;
  238. },
  239. /**
  240. * @param {!Array.<string>} items
  241. * @param {number} selectedIndex
  242. * @param {string} userEnteredText
  243. */
  244. _updateItems: function(items, selectedIndex, userEnteredText)
  245. {
  246. this._length = items.length;
  247. this.contentElement.removeChildren();
  248. for (var i = 0; i < items.length; ++i) {
  249. var item = items[i];
  250. var currentItemElement = this._createItemElement(userEnteredText, item);
  251. this.contentElement.appendChild(currentItemElement);
  252. }
  253. this._selectedElement = null;
  254. if (typeof selectedIndex === "number")
  255. this._selectItem(selectedIndex);
  256. },
  257. /**
  258. * @param {number} index
  259. */
  260. _selectItem: function(index)
  261. {
  262. if (this._selectedElement)
  263. this._selectedElement.classList.remove("selected");
  264. this._selectedIndex = index;
  265. this._selectedElement = this.contentElement.children[index];
  266. this._selectedElement.classList.add("selected");
  267. this._selectedElement.scrollIntoViewIfNeeded(false);
  268. },
  269. /**
  270. * @param {!Array.<string>} completions
  271. * @param {boolean} canShowForSingleItem
  272. * @param {string} userEnteredText
  273. */
  274. _canShowBox: function(completions, canShowForSingleItem, userEnteredText)
  275. {
  276. if (!completions || !completions.length)
  277. return false;
  278. if (completions.length > 1)
  279. return true;
  280. // Do not show a single suggestion if it is the same as user-entered prefix, even if allowed to show single-item suggest boxes.
  281. return canShowForSingleItem && completions[0] !== userEnteredText;
  282. },
  283. _rememberRowCountPerViewport: function()
  284. {
  285. if (!this.contentElement.firstChild)
  286. return;
  287. this._rowCountPerViewport = Math.floor(this.containerElement.offsetHeight / this.contentElement.firstChild.offsetHeight);
  288. },
  289. /**
  290. * @param {AnchorBox} anchorBox
  291. * @param {!Array.<string>} completions
  292. * @param {number} selectedIndex
  293. * @param {boolean} canShowForSingleItem
  294. * @param {string} userEnteredText
  295. */
  296. updateSuggestions: function(anchorBox, completions, selectedIndex, canShowForSingleItem, userEnteredText)
  297. {
  298. if (this._canShowBox(completions, canShowForSingleItem, userEnteredText)) {
  299. this._updateItems(completions, selectedIndex, userEnteredText);
  300. this._updateBoxPosition(anchorBox);
  301. if (!this.visible())
  302. this._bodyElement.appendChild(this._element);
  303. this._rememberRowCountPerViewport();
  304. } else
  305. this.hide();
  306. },
  307. /**
  308. * @param {KeyboardEvent} event
  309. * @return {boolean}
  310. */
  311. keyPressed: function(event)
  312. {
  313. switch (event.keyIdentifier) {
  314. case "Up":
  315. return this.upKeyPressed();
  316. case "Down":
  317. return this.downKeyPressed();
  318. case "PageUp":
  319. return this.pageUpKeyPressed();
  320. case "PageDown":
  321. return this.pageDownKeyPressed();
  322. case "Enter":
  323. return this.enterKeyPressed();
  324. }
  325. return false;
  326. },
  327. /**
  328. * @return {boolean}
  329. */
  330. upKeyPressed: function()
  331. {
  332. return this._selectClosest(-1, true);
  333. },
  334. /**
  335. * @return {boolean}
  336. */
  337. downKeyPressed: function()
  338. {
  339. return this._selectClosest(1, true);
  340. },
  341. /**
  342. * @return {boolean}
  343. */
  344. pageUpKeyPressed: function()
  345. {
  346. return this._selectClosest(-this._rowCountPerViewport, false);
  347. },
  348. /**
  349. * @return {boolean}
  350. */
  351. pageDownKeyPressed: function()
  352. {
  353. return this._selectClosest(this._rowCountPerViewport, false);
  354. },
  355. /**
  356. * @return {boolean}
  357. */
  358. enterKeyPressed: function()
  359. {
  360. var hasSelectedItem = !!this._selectedElement;
  361. this.acceptSuggestion();
  362. // Report the event as non-handled if there is no selected item,
  363. // to commit the input or handle it otherwise.
  364. return hasSelectedItem;
  365. }
  366. }