SearchPad.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. import {
  2. assignStyle,
  3. clear as domClear,
  4. delegate as domDelegate,
  5. query as domQuery,
  6. classes as domClasses,
  7. attr as domAttr,
  8. domify as domify
  9. } from 'min-dom';
  10. import {
  11. getBBox as getBoundingBox
  12. } from '../../util/Elements';
  13. import {
  14. escapeHTML
  15. } from '../../util/EscapeUtil';
  16. import { isKey } from '../keyboard/KeyboardUtil';
  17. /**
  18. * Provides searching infrastructure
  19. */
  20. export default function SearchPad(canvas, eventBus, overlays, selection) {
  21. this._open = false;
  22. this._results = [];
  23. this._eventMaps = [];
  24. this._canvas = canvas;
  25. this._eventBus = eventBus;
  26. this._overlays = overlays;
  27. this._selection = selection;
  28. // setup elements
  29. this._container = domify(SearchPad.BOX_HTML);
  30. this._searchInput = domQuery(SearchPad.INPUT_SELECTOR, this._container);
  31. this._resultsContainer = domQuery(SearchPad.RESULTS_CONTAINER_SELECTOR, this._container);
  32. // attach search pad
  33. this._canvas.getContainer().appendChild(this._container);
  34. // cleanup on destroy
  35. eventBus.on([ 'canvas.destroy', 'diagram.destroy' ], this.close, this);
  36. }
  37. SearchPad.$inject = [
  38. 'canvas',
  39. 'eventBus',
  40. 'overlays',
  41. 'selection'
  42. ];
  43. /**
  44. * Binds and keeps track of all event listereners
  45. */
  46. SearchPad.prototype._bindEvents = function() {
  47. var self = this;
  48. function listen(el, selector, type, fn) {
  49. self._eventMaps.push({
  50. el: el,
  51. type: type,
  52. listener: domDelegate.bind(el, selector, type, fn)
  53. });
  54. }
  55. // close search on clicking anywhere outside
  56. listen(document, 'html', 'click', function(e) {
  57. self.close();
  58. });
  59. // stop event from propagating and closing search
  60. // focus on input
  61. listen(this._container, SearchPad.INPUT_SELECTOR, 'click', function(e) {
  62. e.stopPropagation();
  63. e.delegateTarget.focus();
  64. });
  65. // preselect result on hover
  66. listen(this._container, SearchPad.RESULT_SELECTOR, 'mouseover', function(e) {
  67. e.stopPropagation();
  68. self._scrollToNode(e.delegateTarget);
  69. self._preselect(e.delegateTarget);
  70. });
  71. // selects desired result on mouse click
  72. listen(this._container, SearchPad.RESULT_SELECTOR, 'click', function(e) {
  73. e.stopPropagation();
  74. self._select(e.delegateTarget);
  75. });
  76. // prevent cursor in input from going left and right when using up/down to
  77. // navigate results
  78. listen(this._container, SearchPad.INPUT_SELECTOR, 'keydown', function(e) {
  79. if (isKey('ArrowUp', e)) {
  80. e.preventDefault();
  81. }
  82. if (isKey('ArrowDown', e)) {
  83. e.preventDefault();
  84. }
  85. });
  86. // handle keyboard input
  87. listen(this._container, SearchPad.INPUT_SELECTOR, 'keyup', function(e) {
  88. if (isKey('Escape', e)) {
  89. return self.close();
  90. }
  91. if (isKey('Enter', e)) {
  92. var selected = self._getCurrentResult();
  93. return selected ? self._select(selected) : self.close();
  94. }
  95. if (isKey('ArrowUp', e)) {
  96. return self._scrollToDirection(true);
  97. }
  98. if (isKey('ArrowDown', e)) {
  99. return self._scrollToDirection();
  100. }
  101. // do not search while navigating text input
  102. if (isKey([ 'ArrowLeft', 'ArrowRight' ], e)) {
  103. return;
  104. }
  105. // anything else
  106. self._search(e.delegateTarget.value);
  107. });
  108. };
  109. /**
  110. * Unbinds all previously established listeners
  111. */
  112. SearchPad.prototype._unbindEvents = function() {
  113. this._eventMaps.forEach(function(m) {
  114. domDelegate.unbind(m.el, m.type, m.listener);
  115. });
  116. };
  117. /**
  118. * Performs a search for the given pattern.
  119. *
  120. * @param {string} pattern
  121. */
  122. SearchPad.prototype._search = function(pattern) {
  123. var self = this;
  124. this._clearResults();
  125. // do not search on empty query
  126. if (!pattern || pattern === '') {
  127. return;
  128. }
  129. var searchResults = this._searchProvider.find(pattern);
  130. if (!searchResults.length) {
  131. return;
  132. }
  133. // append new results
  134. searchResults.forEach(function(result) {
  135. var id = result.element.id;
  136. var node = self._createResultNode(result, id);
  137. self._results[id] = {
  138. element: result.element,
  139. node: node
  140. };
  141. });
  142. // preselect first result
  143. var node = domQuery(SearchPad.RESULT_SELECTOR, this._resultsContainer);
  144. this._scrollToNode(node);
  145. this._preselect(node);
  146. };
  147. /**
  148. * Navigate to the previous/next result. Defaults to next result.
  149. * @param {boolean} previous
  150. */
  151. SearchPad.prototype._scrollToDirection = function(previous) {
  152. var selected = this._getCurrentResult();
  153. if (!selected) {
  154. return;
  155. }
  156. var node = previous ? selected.previousElementSibling : selected.nextElementSibling;
  157. if (node) {
  158. this._scrollToNode(node);
  159. this._preselect(node);
  160. }
  161. };
  162. /**
  163. * Scroll to the node if it is not visible.
  164. *
  165. * @param {Element} node
  166. */
  167. SearchPad.prototype._scrollToNode = function(node) {
  168. if (!node || node === this._getCurrentResult()) {
  169. return;
  170. }
  171. var nodeOffset = node.offsetTop;
  172. var containerScroll = this._resultsContainer.scrollTop;
  173. var bottomScroll = nodeOffset - this._resultsContainer.clientHeight + node.clientHeight;
  174. if (nodeOffset < containerScroll) {
  175. this._resultsContainer.scrollTop = nodeOffset;
  176. } else if (containerScroll < bottomScroll) {
  177. this._resultsContainer.scrollTop = bottomScroll;
  178. }
  179. };
  180. /**
  181. * Clears all results data.
  182. */
  183. SearchPad.prototype._clearResults = function() {
  184. domClear(this._resultsContainer);
  185. this._results = [];
  186. this._resetOverlay();
  187. this._eventBus.fire('searchPad.cleared');
  188. };
  189. /**
  190. * Get currently selected result.
  191. *
  192. * @return {Element}
  193. */
  194. SearchPad.prototype._getCurrentResult = function() {
  195. return domQuery(SearchPad.RESULT_SELECTED_SELECTOR, this._resultsContainer);
  196. };
  197. /**
  198. * Create result DOM element within results container
  199. * that corresponds to a search result.
  200. *
  201. * 'result' : one of the elements returned by SearchProvider
  202. * 'id' : id attribute value to assign to the new DOM node
  203. * return : created DOM element
  204. *
  205. * @param {SearchResult} result
  206. * @param {string} id
  207. * @return {Element}
  208. */
  209. SearchPad.prototype._createResultNode = function(result, id) {
  210. var node = domify(SearchPad.RESULT_HTML);
  211. // create only if available
  212. if (result.primaryTokens.length > 0) {
  213. createInnerTextNode(node, result.primaryTokens, SearchPad.RESULT_PRIMARY_HTML);
  214. }
  215. // secondary tokens (represent element ID) are allways available
  216. createInnerTextNode(node, result.secondaryTokens, SearchPad.RESULT_SECONDARY_HTML);
  217. domAttr(node, SearchPad.RESULT_ID_ATTRIBUTE, id);
  218. this._resultsContainer.appendChild(node);
  219. return node;
  220. };
  221. /**
  222. * Register search element provider.
  223. *
  224. * SearchProvider.find - provides search function over own elements
  225. * (pattern) => [{ text: <String>, element: <Element>}, ...]
  226. *
  227. * @param {SearchProvider} provider
  228. */
  229. SearchPad.prototype.registerProvider = function(provider) {
  230. this._searchProvider = provider;
  231. };
  232. /**
  233. * Open search pad.
  234. */
  235. SearchPad.prototype.open = function() {
  236. if (!this._searchProvider) {
  237. throw new Error('no search provider registered');
  238. }
  239. if (this.isOpen()) {
  240. return;
  241. }
  242. this._bindEvents();
  243. this._open = true;
  244. domClasses(this._container).add('open');
  245. this._searchInput.focus();
  246. this._eventBus.fire('searchPad.opened');
  247. };
  248. /**
  249. * Close search pad.
  250. */
  251. SearchPad.prototype.close = function() {
  252. if (!this.isOpen()) {
  253. return;
  254. }
  255. this._unbindEvents();
  256. this._open = false;
  257. domClasses(this._container).remove('open');
  258. this._clearResults();
  259. this._searchInput.value = '';
  260. this._searchInput.blur();
  261. this._resetOverlay();
  262. this._eventBus.fire('searchPad.closed');
  263. };
  264. /**
  265. * Toggles search pad on/off.
  266. */
  267. SearchPad.prototype.toggle = function() {
  268. this.isOpen() ? this.close() : this.open();
  269. };
  270. /**
  271. * Report state of search pad.
  272. */
  273. SearchPad.prototype.isOpen = function() {
  274. return this._open;
  275. };
  276. /**
  277. * Preselect result entry.
  278. *
  279. * @param {Element} element
  280. */
  281. SearchPad.prototype._preselect = function(node) {
  282. var selectedNode = this._getCurrentResult();
  283. // already selected
  284. if (node === selectedNode) {
  285. return;
  286. }
  287. // removing preselection from current node
  288. if (selectedNode) {
  289. domClasses(selectedNode).remove(SearchPad.RESULT_SELECTED_CLASS);
  290. }
  291. var id = domAttr(node, SearchPad.RESULT_ID_ATTRIBUTE);
  292. var element = this._results[id].element;
  293. domClasses(node).add(SearchPad.RESULT_SELECTED_CLASS);
  294. this._resetOverlay(element);
  295. this._canvas.scrollToElement(element, { top: 400 });
  296. this._selection.select(element);
  297. this._eventBus.fire('searchPad.preselected', element);
  298. };
  299. /**
  300. * Select result node.
  301. *
  302. * @param {Element} element
  303. */
  304. SearchPad.prototype._select = function(node) {
  305. var id = domAttr(node, SearchPad.RESULT_ID_ATTRIBUTE);
  306. var element = this._results[id].element;
  307. this.close();
  308. this._resetOverlay();
  309. this._canvas.scrollToElement(element, { top: 400 });
  310. this._selection.select(element);
  311. this._eventBus.fire('searchPad.selected', element);
  312. };
  313. /**
  314. * Reset overlay removes and, optionally, set
  315. * overlay to a new element.
  316. *
  317. * @param {Element} element
  318. */
  319. SearchPad.prototype._resetOverlay = function(element) {
  320. if (this._overlayId) {
  321. this._overlays.remove(this._overlayId);
  322. }
  323. if (element) {
  324. var box = getBoundingBox(element);
  325. var overlay = constructOverlay(box);
  326. this._overlayId = this._overlays.add(element, overlay);
  327. }
  328. };
  329. /**
  330. * Construct overlay object for the given bounding box.
  331. *
  332. * @param {BoundingBox} box
  333. * @return {Object}
  334. */
  335. function constructOverlay(box) {
  336. var offset = 6;
  337. var w = box.width + offset * 2;
  338. var h = box.height + offset * 2;
  339. var styles = {
  340. width: w + 'px',
  341. height: h + 'px'
  342. };
  343. var html = domify('<div class="' + SearchPad.OVERLAY_CLASS + '"></div>');
  344. assignStyle(html, styles);
  345. return {
  346. position: {
  347. bottom: h - offset,
  348. right: w - offset
  349. },
  350. show: true,
  351. html: html
  352. };
  353. }
  354. /**
  355. * Creates and appends child node from result tokens and HTML template.
  356. *
  357. * @param {Element} node
  358. * @param {Array<Object>} tokens
  359. * @param {string} template
  360. */
  361. function createInnerTextNode(parentNode, tokens, template) {
  362. var text = createHtmlText(tokens);
  363. var childNode = domify(template);
  364. childNode.innerHTML = text;
  365. parentNode.appendChild(childNode);
  366. }
  367. /**
  368. * Create internal HTML markup from result tokens.
  369. * Caters for highlighting pattern matched tokens.
  370. *
  371. * @param {Array<Object>} tokens
  372. * @return {string}
  373. */
  374. function createHtmlText(tokens) {
  375. var htmlText = '';
  376. tokens.forEach(function(t) {
  377. if (t.matched) {
  378. htmlText += '<strong class="' + SearchPad.RESULT_HIGHLIGHT_CLASS + '">' + escapeHTML(t.matched) + '</strong>';
  379. } else {
  380. htmlText += escapeHTML(t.normal);
  381. }
  382. });
  383. return htmlText !== '' ? htmlText : null;
  384. }
  385. /**
  386. * CONSTANTS
  387. */
  388. SearchPad.CONTAINER_SELECTOR = '.djs-search-container';
  389. SearchPad.INPUT_SELECTOR = '.djs-search-input input';
  390. SearchPad.RESULTS_CONTAINER_SELECTOR = '.djs-search-results';
  391. SearchPad.RESULT_SELECTOR = '.djs-search-result';
  392. SearchPad.RESULT_SELECTED_CLASS = 'djs-search-result-selected';
  393. SearchPad.RESULT_SELECTED_SELECTOR = '.' + SearchPad.RESULT_SELECTED_CLASS;
  394. SearchPad.RESULT_ID_ATTRIBUTE = 'data-result-id';
  395. SearchPad.RESULT_HIGHLIGHT_CLASS = 'djs-search-highlight';
  396. SearchPad.OVERLAY_CLASS = 'djs-search-overlay';
  397. SearchPad.BOX_HTML =
  398. '<div class="djs-search-container djs-draggable djs-scrollable">' +
  399. '<div class="djs-search-input">' +
  400. '<input type="text"/>' +
  401. '</div>' +
  402. '<div class="djs-search-results"></div>' +
  403. '</div>';
  404. SearchPad.RESULT_HTML =
  405. '<div class="djs-search-result"></div>';
  406. SearchPad.RESULT_PRIMARY_HTML =
  407. '<div class="djs-search-result-primary"></div>';
  408. SearchPad.RESULT_SECONDARY_HTML =
  409. '<p class="djs-search-result-secondary"></p>';