text search of any node in the outliner
[apps/outliner/.git] / src / search.ts
1 import { create, insert, insertBatch, search } from '@lyrasearch/lyra';
2 import { map } from 'lodash';
3 import { OutlineNode } from 'outline';
4 import keyboardJS from 'keyboardjs';
5
6 const searchModal = `
7 <div class="modal">
8 <div class="modal-content" id="search">
9 <input type="text" id="search-query" placeholder="enter fuzzy search terms">
10 <ul id="search-results">
11 </ul>
12 </div>
13 </div>
14 `;
15
16 export class Search {
17   db: any;
18   debounce: any;
19   state: 'ready' | 'notready'
20
21   onTermSelection: any;
22   constructor() {
23     this.state = 'notready';
24   }
25
26   async createIndex(schema: Record<string, any>) {
27     this.db = await create({
28       schema
29     });
30     this.state = 'ready';
31   }
32
33   bindEvents() {
34     keyboardJS.withContext('search', () => {
35       keyboardJS.bind('escape', e => {
36         document.querySelector('.modal').remove();
37         keyboardJS.setContext('navigation');
38       });
39
40       keyboardJS.bind('down', e => {
41         document.getElementById('search-query').blur();
42         const el = document.querySelector('.search-result.selected');
43         if(el.nextElementSibling) {
44           el.classList.remove('selected');
45           el.nextElementSibling.classList.add('selected');
46         }
47       });
48
49       keyboardJS.bind('up', () => {
50         const el = document.querySelector('.search-result.selected');
51         if(el.previousElementSibling) {
52           el.classList.remove('selected');
53           el.previousElementSibling.classList.add('selected');
54         }
55       })
56
57       keyboardJS.bind('enter', e => {
58         const el = document.querySelector('.search-result.selected');
59         const docId = el.getAttribute('data-id');
60
61         document.querySelector('.modal').remove();
62         keyboardJS.setContext('navigation');
63
64         if(this.onTermSelection) {
65           this.onTermSelection(docId);
66         }
67       });
68     });
69
70     keyboardJS.withContext('navigation', () => {
71       keyboardJS.bind('shift + f', e => {
72         e.preventDefault();
73         e.stopPropagation(); 
74
75         document.querySelector('body').innerHTML += searchModal;
76         const el = document.getElementById('search-query');
77         el.focus();
78         el.addEventListener('keyup', this.debounceSearch.bind(this));
79         keyboardJS.setContext('search');
80       });
81     });
82   }
83
84   debounceSearch(e: KeyboardEvent) {
85     if(this.debounce) {
86       clearInterval(this.debounce);
87     }
88
89     const el = e.target as HTMLTextAreaElement;
90     const query = el.value.toString().trim();
91
92     if(query.length) {
93       this.debounce = setTimeout(() => {
94         this.displaySearch(query, e);
95       }, 100);
96     }
97   }
98
99   async displaySearch(terms: string, e: KeyboardEvent) {
100     if(!this.state) {
101       return;
102     }
103     const res = await this.search(terms);
104
105     const resultContainer = document.getElementById('search-results');
106
107     if(res.hits.length === 0) {
108       resultContainer.innerHTML = '<li><em>No Results</em></li>';
109       return;
110     }
111
112     const html = res.hits.map((doc, idx) => {
113       const content = doc.document.content.toString();
114       const display = content.substring(0, 100);
115
116       return `
117       <li class="search-result ${idx === 0 ? 'selected' : ''}" data-id="${doc.id}">${display}${content.length > display.length ? '...': ''}</li>
118       `;
119     });
120
121     resultContainer.innerHTML = html.join("\n");
122   }
123
124   indexDoc(doc: Record<string, any>) {
125     return insert(this.db, doc)
126   }
127
128   indexBatch(docs: Record<string, OutlineNode>) {
129     return insertBatch(this.db, map(docs, doc => doc as any));
130   }
131
132   search(term: string) {
133     return search(this.db, {
134       term: term.trim(),
135       properties: ["content"]
136     });
137   }
138 }