| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- import fuzzy, { FilterResult } from 'fuzzy';
- import { RecentSearchRecorder } from './recent-search-recorder';
- type Section = 'developer-guide' | 'user-guide' | 'config' | 'gql';
- interface IndexItem {
- section: Section;
- title: string;
- parent: string;
- headings: string[];
- url: string;
- }
- interface DenormalizedItem {
- section: Section;
- title: string;
- parent: string;
- heading: string;
- url: string;
- }
- const KeyCode = {
- UP: 38,
- DOWN: 40,
- ENTER: 13,
- ESCAPE: 27,
- };
- /**
- * This class implements the auto-suggest search box for searching the docs.
- */
- export class SearchWidget {
- private readonly MAX_RESULTS = 7;
- private searchIndex: Promise<DenormalizedItem[]> | undefined;
- private results: Array<FilterResult<DenormalizedItem>> = [];
- private selectedIndex = -1;
- private autocompleteDiv: HTMLDivElement;
- private wrapperDiv: HTMLDivElement;
- private listElement: HTMLUListElement;
- private recentSearchRecorder: RecentSearchRecorder;
- constructor(private inputElement: HTMLInputElement,
- private autoCompleteWrapperElement: HTMLDivElement,
- private overlayElement: HTMLDivElement,
- private triggerElement: HTMLInputElement) {
- this.attachAutocomplete();
- this.recentSearchRecorder = new RecentSearchRecorder();
- this.recentSearchRecorder.init();
- inputElement.addEventListener('input', (e) => {
- this.handleInput(e as KeyboardEvent);
- });
- inputElement.addEventListener('keydown', (e: KeyboardEvent) => {
- const listItemCount = this.inputElement.value === '' ? this.recentSearchRecorder.list.length : this.results.length;
- switch (e.keyCode) {
- case KeyCode.UP:
- this.selectedIndex = this.selectedIndex === 0 ? listItemCount - 1 : this.selectedIndex - 1;
- e.preventDefault();
- break;
- case KeyCode.DOWN:
- this.selectedIndex = this.selectedIndex === (listItemCount - 1) ? 0 : this.selectedIndex + 1;
- e.preventDefault();
- break;
- case KeyCode.ENTER:
- const selected = this.autocompleteDiv.querySelector('li.selected a') as HTMLAnchorElement;
- if (selected) {
- selected.click();
- this.results = [];
- }
- break;
- case KeyCode.ESCAPE:
- this.results = [];
- this.inputElement.blur();
- break;
- }
- this.render();
- setTimeout(() => {
- Array.from(this.listElement.querySelectorAll('.result')).forEach(titleEl => {
- titleEl.addEventListener('click', this.recordClickToHistory)
- });
- });
- });
- this.wrapperDiv.addEventListener('click', (e) => {
- this.results = [];
- this.render();
- });
- const openModal = () => {
- this.overlayElement.click();
- setTimeout(() => {
- this.inputElement.value = this.triggerElement.value;
- this.triggerElement.value = '';
- this.inputElement.focus();
- }, 50);
- this.render();
- }
- this.triggerElement.addEventListener('click', openModal);
- this.triggerElement.addEventListener('keypress', openModal);
- window.addEventListener('keydown', e => {
- if (e.ctrlKey && e.key === 'k') {
- openModal();
- e.preventDefault();
- }
- });
- }
- toggleActive() {
- this.wrapperDiv.classList.toggle('focus');
- if (this.wrapperDiv.classList.contains('focus')) {
- this.inputElement.focus();
- }
- }
- /**
- * Groups the results by section and renders as a list
- */
- private render() {
- const sections: Section[] = ['developer-guide', 'user-guide', 'gql', 'config'];
- let html = '';
- let i = 0;
- if (this.inputElement.value === '' && this.recentSearchRecorder.list.length) {
- html += `<li class="section recent">Recent</li>`;
- html += this.recentSearchRecorder.list.map(({parent, page, title, url}) => {
- const inner = `<div class="title"><span class="parent">${parent}</span> › ${page}</div><div class="heading">${title}</div>`;
- const selected = i === this.selectedIndex ? 'selected' : '';
- i++;
- return `<li class="${selected} result recent"><a href="${url}">${inner}</a></li>`;
- }).join('\n');
- } else {
- for (const sec of sections) {
- const matches = this.results.filter(r => r.original.section === sec);
- if (matches.length) {
- const sectionName = this.getSectionName(sec);
- html += `<li class="section ${sec}">${sectionName}</li>`;
- }
- html += matches.map((result) => {
- const {section, title, parent, heading, url} = result.original;
- const anchor = heading !== title ? '#' + this.headingToAnchor(heading) : '';
- const inner = `<div class="title"><span class="parent">${parent}</span> › <span class="page">${title}</span></div><div class="heading">${result.string}</div>`;
- const selected = i === this.selectedIndex ? 'selected' : '';
- i++;
- return `<li class="${selected} result ${sec}"><a href="${url + anchor}">${inner}</a></li>`;
- }).join('\n');
- }
- }
- this.listElement.innerHTML = html;
- }
- private recordClickToHistory = (e: Event) => {
- const title = (e.currentTarget as HTMLDivElement).querySelector('.heading')?.textContent;
- const parent = (e.currentTarget as HTMLDivElement).querySelector('.parent')?.textContent;
- const page = (e.currentTarget as HTMLDivElement).querySelector('.page')?.textContent;
- const url = (e.currentTarget as HTMLDivElement).querySelector('a')?.href;
- if (title && url && parent && page) {
- this.recentSearchRecorder.record({
- parent,
- page,
- title,
- url,
- });
- }
- }
- private getSectionName(sec: Section): string {
- switch (sec) {
- case 'developer-guide':
- return 'Developer Guide';
- case 'config':
- return 'TypeScript API';
- case 'gql':
- return 'GraphQL API';
- case 'user-guide':
- return 'Administrator Guide';
- }
- }
- private attachAutocomplete() {
- this.autocompleteDiv = document.createElement('div');
- this.autocompleteDiv.classList.add('autocomplete');
- this.listElement = document.createElement('ul');
- this.autocompleteDiv.appendChild(this.listElement);
- this.wrapperDiv = this.autoCompleteWrapperElement;
- this.wrapperDiv.classList.add('autocomplete-wrapper');
- const parent = this.inputElement.parentElement;
- if (parent) {
- // parent.insertBefore(this.wrapperDiv, this.inputElement);
- // this.wrapperDiv.appendChild(this.inputElement);
- this.wrapperDiv.appendChild(this.autocompleteDiv);
- }
- }
- private async handleInput(e: KeyboardEvent) {
- const term = (e.target as HTMLInputElement).value.trim();
- this.results = term ? await this.getResults(term) : [];
- this.selectedIndex = 0;
- this.render();
- }
- private async getResults(term: string) {
- const items = await this.getSearchIndex();
- const results = fuzzy.filter(
- term,
- items,
- {
- pre: '<span class="hl">',
- post: '</span>',
- extract(input: DenormalizedItem): string {
- return input.heading;
- },
- },
- );
- if (this.MAX_RESULTS < results.length) {
- // limit the maximum number of results from a particular
- // section to prevent other possibly relevant results getting
- // buried.
- const devGuides = results.filter(r => r.original.section === 'developer-guide');
- const adminGuides = results.filter(r => r.original.section === 'user-guide');
- const gql = results.filter(r => r.original.section === 'gql');
- const config = results.filter(r => r.original.section === 'config');
- let pool = [devGuides, adminGuides, gql, config].filter(p => p.length);
- const balancedResults = [];
- for (let i = 0; i < this.MAX_RESULTS; i++) {
- const next = pool[i % pool.length].shift();
- if (next) {
- balancedResults.push(next);
- }
- pool = [devGuides, adminGuides, gql, config].filter(p => p.length);
- }
- return balancedResults;
- }
- return results;
- }
- private getSearchIndex(): Promise<DenormalizedItem[]> {
- if (!this.searchIndex) {
- // tslint:disable:no-eval
- this.searchIndex = fetch('/searchindex/index.html')
- .then(res => res.text())
- .then(res => eval(res))
- .then((items: IndexItem[]) => {
- const denormalized: DenormalizedItem[] = [];
- for (const {section, title, parent, headings, url} of items) {
- denormalized.push({
- section,
- title,
- parent,
- heading: title,
- url,
- });
- if (headings.length) {
- for (const heading of headings) {
- if (heading !== title) {
- denormalized.push({
- section,
- title,
- parent,
- heading,
- url,
- });
- }
- }
- }
- }
- return denormalized;
- });
- }
- return this.searchIndex;
- }
- private headingToAnchor(heading: string): string {
- return heading.toLowerCase()
- .replace(/\s/g, '-')
- .replace(/[:]/g, '');
- }
- }
|