search-widget.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import fuzzy, { FilterResult } from 'fuzzy';
  2. import { RecentSearchRecorder } from './recent-search-recorder';
  3. type Section = 'developer-guide' | 'user-guide' | 'config' | 'gql';
  4. interface IndexItem {
  5. section: Section;
  6. title: string;
  7. parent: string;
  8. headings: string[];
  9. url: string;
  10. }
  11. interface DenormalizedItem {
  12. section: Section;
  13. title: string;
  14. parent: string;
  15. heading: string;
  16. url: string;
  17. }
  18. const KeyCode = {
  19. UP: 38,
  20. DOWN: 40,
  21. ENTER: 13,
  22. ESCAPE: 27,
  23. };
  24. /**
  25. * This class implements the auto-suggest search box for searching the docs.
  26. */
  27. export class SearchWidget {
  28. private readonly MAX_RESULTS = 7;
  29. private searchIndex: Promise<DenormalizedItem[]> | undefined;
  30. private results: Array<FilterResult<DenormalizedItem>> = [];
  31. private selectedIndex = -1;
  32. private autocompleteDiv: HTMLDivElement;
  33. private wrapperDiv: HTMLDivElement;
  34. private listElement: HTMLUListElement;
  35. private recentSearchRecorder: RecentSearchRecorder;
  36. constructor(private inputElement: HTMLInputElement,
  37. private autoCompleteWrapperElement: HTMLDivElement,
  38. private overlayElement: HTMLDivElement,
  39. private triggerElement: HTMLInputElement) {
  40. this.attachAutocomplete();
  41. this.recentSearchRecorder = new RecentSearchRecorder();
  42. this.recentSearchRecorder.init();
  43. inputElement.addEventListener('input', (e) => {
  44. this.handleInput(e as KeyboardEvent);
  45. });
  46. inputElement.addEventListener('keydown', (e: KeyboardEvent) => {
  47. const listItemCount = this.inputElement.value === '' ? this.recentSearchRecorder.list.length : this.results.length;
  48. switch (e.keyCode) {
  49. case KeyCode.UP:
  50. this.selectedIndex = this.selectedIndex === 0 ? listItemCount - 1 : this.selectedIndex - 1;
  51. e.preventDefault();
  52. break;
  53. case KeyCode.DOWN:
  54. this.selectedIndex = this.selectedIndex === (listItemCount - 1) ? 0 : this.selectedIndex + 1;
  55. e.preventDefault();
  56. break;
  57. case KeyCode.ENTER:
  58. const selected = this.autocompleteDiv.querySelector('li.selected a') as HTMLAnchorElement;
  59. if (selected) {
  60. selected.click();
  61. this.results = [];
  62. }
  63. break;
  64. case KeyCode.ESCAPE:
  65. this.results = [];
  66. this.inputElement.blur();
  67. break;
  68. }
  69. this.render();
  70. setTimeout(() => {
  71. Array.from(this.listElement.querySelectorAll('.result')).forEach(titleEl => {
  72. titleEl.addEventListener('click', this.recordClickToHistory)
  73. });
  74. });
  75. });
  76. this.wrapperDiv.addEventListener('click', (e) => {
  77. this.results = [];
  78. this.render();
  79. });
  80. const openModal = () => {
  81. this.overlayElement.click();
  82. setTimeout(() => {
  83. this.inputElement.value = this.triggerElement.value;
  84. this.triggerElement.value = '';
  85. this.inputElement.focus();
  86. }, 50);
  87. this.render();
  88. }
  89. this.triggerElement.addEventListener('click', openModal);
  90. this.triggerElement.addEventListener('keypress', openModal);
  91. window.addEventListener('keydown', e => {
  92. if (e.ctrlKey && e.key === 'k') {
  93. openModal();
  94. e.preventDefault();
  95. }
  96. });
  97. }
  98. toggleActive() {
  99. this.wrapperDiv.classList.toggle('focus');
  100. if (this.wrapperDiv.classList.contains('focus')) {
  101. this.inputElement.focus();
  102. }
  103. }
  104. /**
  105. * Groups the results by section and renders as a list
  106. */
  107. private render() {
  108. const sections: Section[] = ['developer-guide', 'user-guide', 'gql', 'config'];
  109. let html = '';
  110. let i = 0;
  111. if (this.inputElement.value === '' && this.recentSearchRecorder.list.length) {
  112. html += `<li class="section recent">Recent</li>`;
  113. html += this.recentSearchRecorder.list.map(({parent, page, title, url}) => {
  114. const inner = `<div class="title"><span class="parent">${parent}</span> › ${page}</div><div class="heading">${title}</div>`;
  115. const selected = i === this.selectedIndex ? 'selected' : '';
  116. i++;
  117. return `<li class="${selected} result recent"><a href="${url}">${inner}</a></li>`;
  118. }).join('\n');
  119. } else {
  120. for (const sec of sections) {
  121. const matches = this.results.filter(r => r.original.section === sec);
  122. if (matches.length) {
  123. const sectionName = this.getSectionName(sec);
  124. html += `<li class="section ${sec}">${sectionName}</li>`;
  125. }
  126. html += matches.map((result) => {
  127. const {section, title, parent, heading, url} = result.original;
  128. const anchor = heading !== title ? '#' + this.headingToAnchor(heading) : '';
  129. const inner = `<div class="title"><span class="parent">${parent}</span> › <span class="page">${title}</span></div><div class="heading">${result.string}</div>`;
  130. const selected = i === this.selectedIndex ? 'selected' : '';
  131. i++;
  132. return `<li class="${selected} result ${sec}"><a href="${url + anchor}">${inner}</a></li>`;
  133. }).join('\n');
  134. }
  135. }
  136. this.listElement.innerHTML = html;
  137. }
  138. private recordClickToHistory = (e: Event) => {
  139. const title = (e.currentTarget as HTMLDivElement).querySelector('.heading')?.textContent;
  140. const parent = (e.currentTarget as HTMLDivElement).querySelector('.parent')?.textContent;
  141. const page = (e.currentTarget as HTMLDivElement).querySelector('.page')?.textContent;
  142. const url = (e.currentTarget as HTMLDivElement).querySelector('a')?.href;
  143. if (title && url && parent && page) {
  144. this.recentSearchRecorder.record({
  145. parent,
  146. page,
  147. title,
  148. url,
  149. });
  150. }
  151. }
  152. private getSectionName(sec: Section): string {
  153. switch (sec) {
  154. case 'developer-guide':
  155. return 'Developer Guide';
  156. case 'config':
  157. return 'TypeScript API';
  158. case 'gql':
  159. return 'GraphQL API';
  160. case 'user-guide':
  161. return 'Administrator Guide';
  162. }
  163. }
  164. private attachAutocomplete() {
  165. this.autocompleteDiv = document.createElement('div');
  166. this.autocompleteDiv.classList.add('autocomplete');
  167. this.listElement = document.createElement('ul');
  168. this.autocompleteDiv.appendChild(this.listElement);
  169. this.wrapperDiv = this.autoCompleteWrapperElement;
  170. this.wrapperDiv.classList.add('autocomplete-wrapper');
  171. const parent = this.inputElement.parentElement;
  172. if (parent) {
  173. // parent.insertBefore(this.wrapperDiv, this.inputElement);
  174. // this.wrapperDiv.appendChild(this.inputElement);
  175. this.wrapperDiv.appendChild(this.autocompleteDiv);
  176. }
  177. }
  178. private async handleInput(e: KeyboardEvent) {
  179. const term = (e.target as HTMLInputElement).value.trim();
  180. this.results = term ? await this.getResults(term) : [];
  181. this.selectedIndex = 0;
  182. this.render();
  183. }
  184. private async getResults(term: string) {
  185. const items = await this.getSearchIndex();
  186. const results = fuzzy.filter(
  187. term,
  188. items,
  189. {
  190. pre: '<span class="hl">',
  191. post: '</span>',
  192. extract(input: DenormalizedItem): string {
  193. return input.heading;
  194. },
  195. },
  196. );
  197. if (this.MAX_RESULTS < results.length) {
  198. // limit the maximum number of results from a particular
  199. // section to prevent other possibly relevant results getting
  200. // buried.
  201. const devGuides = results.filter(r => r.original.section === 'developer-guide');
  202. const adminGuides = results.filter(r => r.original.section === 'user-guide');
  203. const gql = results.filter(r => r.original.section === 'gql');
  204. const config = results.filter(r => r.original.section === 'config');
  205. let pool = [devGuides, adminGuides, gql, config].filter(p => p.length);
  206. const balancedResults = [];
  207. for (let i = 0; i < this.MAX_RESULTS; i++) {
  208. const next = pool[i % pool.length].shift();
  209. if (next) {
  210. balancedResults.push(next);
  211. }
  212. pool = [devGuides, adminGuides, gql, config].filter(p => p.length);
  213. }
  214. return balancedResults;
  215. }
  216. return results;
  217. }
  218. private getSearchIndex(): Promise<DenormalizedItem[]> {
  219. if (!this.searchIndex) {
  220. // tslint:disable:no-eval
  221. this.searchIndex = fetch('/searchindex/index.html')
  222. .then(res => res.text())
  223. .then(res => eval(res))
  224. .then((items: IndexItem[]) => {
  225. const denormalized: DenormalizedItem[] = [];
  226. for (const {section, title, parent, headings, url} of items) {
  227. denormalized.push({
  228. section,
  229. title,
  230. parent,
  231. heading: title,
  232. url,
  233. });
  234. if (headings.length) {
  235. for (const heading of headings) {
  236. if (heading !== title) {
  237. denormalized.push({
  238. section,
  239. title,
  240. parent,
  241. heading,
  242. url,
  243. });
  244. }
  245. }
  246. }
  247. }
  248. return denormalized;
  249. });
  250. }
  251. return this.searchIndex;
  252. }
  253. private headingToAnchor(heading: string): string {
  254. return heading.toLowerCase()
  255. .replace(/\s/g, '-')
  256. .replace(/[:]/g, '');
  257. }
  258. }