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