]> BookStack Code Mirror - website/blob - themes/bookstack/static/js/script.js
Added arrow key support to search dialog
[website] / themes / bookstack / static / js / script.js
1
2 // Mobile menu
3
4 const menuButton = document.getElementById('menu-button');
5 const menuDropDown = document.querySelector('#header .main-nav');
6
7 menuButton.addEventListener('click', function(event) {
8   menuDropDown.classList.toggle('showing');
9   event.stopPropagation();
10 });
11
12 document.body.addEventListener('click', function(event) {
13   const isShown = menuDropDown.classList.contains('showing');
14   if (isShown) {
15     menuDropDown.classList.remove('showing');
16     event.stopPropagation();  
17   }
18 });
19
20
21 // Handle video click to play
22 const videos = document.querySelectorAll('video');
23 for (let i = 0; i < videos.length; i++) {
24     videos[i].addEventListener('click', videoClick)
25 }
26
27 function videoClick() {
28     if (typeof InstallTrigger !== 'undefined') return;
29     this.paused ? this.play() : this.pause();
30 }
31
32 // Header double click URL reference
33 document.body.addEventListener('dblclick', event => {
34   const isHeader = event.target.matches('h1, h2, h3, h4, h5, h6');
35   if (isHeader && event.target.id) {
36     window.location.hash = event.target.id;
37   }
38 });
39
40 function el(tag, attributes = {}, children = []) {
41   const elem = document.createElement(tag);
42   for (const attr of Object.keys(attributes)) {
43     elem.setAttribute(attr, attributes[attr]);
44   }
45   for (let child of children) {
46     if (typeof child === 'string') {
47       child = new Text(child);
48     }
49     elem.append(child);
50   }
51   return elem;
52 }
53
54 // Site search
55 const searchForm = document.getElementById('site-search-form');
56 const searchInput = document.getElementById('site-search-input');
57 const searchDialog = searchForm.querySelector('dialog');
58
59 async function runSearch() {
60   const searchTerm = searchInput.value;
61
62   let pages = [];
63   try {
64     const resp = await fetch(`/search.php?query=${encodeURIComponent(searchTerm)}`);
65     pages = await resp.json();
66   } catch (error) {
67     searchDialog.innerHTML = '<strong class="search-category-title">Failed to load search results</strong>';
68     console.error(error);
69     return;
70   }
71
72   // Sort pages to prioritise those with word in title
73   const lowerSearchTerm = searchTerm.toLowerCase();
74   pages.sort((a, b) => {
75     const aScore = (a.url.includes(lowerSearchTerm) || a.title.toLowerCase().includes(lowerSearchTerm)) ? 1 : 0;
76     const bScore = (b.url.includes(lowerSearchTerm) || b.title.toLowerCase().includes(lowerSearchTerm)) ? 1 : 0;
77     return bScore - aScore;
78   });
79
80   // Categorizes pages to display
81   const categorised = {
82     docs: {title: 'Documentation', filter: '/docs/', pages: []},
83     hacks: {title: 'Hacks', filter: '/hacks/', pages: []}, 
84     blog: {title: 'From the blog', filter: '/blog/', pages: []}, 
85     other: {title: 'Site Pages', filter: '', pages: []},
86   };
87   const categoryNames = Object.keys(categorised);
88
89   for (const page of pages) {
90     let pageCategory = null;
91     for (const categoryName of categoryNames) {
92       const category = categorised[categoryName];
93       if (page.url.startsWith(category.filter)) {
94         pageCategory = category;
95         break;
96       }
97     }
98     (pageCategory || categorised.other).pages.push(page);
99   }
100
101   const categoryResults = categoryNames.map(name => {
102     const category = categorised[name];
103     if (category.pages.length === 0) {
104       return null;
105     }
106     return el('div', {}, [
107       el('strong', {class: 'search-category-title'}, [category.title]),
108       el('div', {}, category.pages.slice(0, 5).map(page => {
109         return el('a', {href: page.url}, [page.title]);
110       })),
111     ]);
112   }).filter(Boolean);
113
114   const emptyResult = el('strong', {class: 'search-category-title'}, [el('em', {}, 'No results found')]);
115   const resultList = categoryResults.length ? categoryResults : [emptyResult];
116   const results = el('div', {}, resultList);
117
118   while (searchDialog.firstChild) {
119     searchDialog.removeChild(searchDialog.firstChild);
120   }
121   
122   searchDialog.append(results);
123   showSearchDialog();
124 }
125
126 function showSearchDialog() {
127   if (searchDialog.open) {
128     return;
129   }
130   searchDialog.show();
131   searchInput.focus();
132
133   const clickListener = e => {
134     if (!e.target.closest('dialog')) {
135       closeListener();
136     }
137   };
138
139   const escListener = e => {
140     if (e.key === 'Escape') {
141       closeListener();
142     }
143   };
144
145   const arrowListener = e => {
146     if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
147       return;
148     }
149     e.preventDefault();
150
151     const links = Array.from(searchDialog.querySelectorAll('a'));
152     const focusables = [searchInput, ...links];
153     if (focusables.length < 2) { // Only search input
154       return;
155     }
156
157     const active = document.activeElement;
158     let currentIndex = focusables.indexOf(active);
159
160     if (e.key === 'ArrowDown') {
161       currentIndex = (currentIndex + 1) % focusables.length;
162     } else { // ArrowUp
163       currentIndex = (currentIndex - 1 + focusables.length) % focusables.length;
164     }
165
166     focusables[currentIndex].focus();
167   };
168
169   const mouseLeaveListener = e => {
170     closeListener();
171   };
172
173   const closeListener = () => {
174     searchDialog.close();
175     document.removeEventListener('click', clickListener);
176     document.removeEventListener('keydown', escListener);
177     searchForm.removeEventListener('mouseleave', mouseLeaveListener);
178     searchForm.removeEventListener('keydown', arrowListener);
179   };
180
181   document.addEventListener('click', clickListener);
182   document.addEventListener('keydown', escListener);
183   searchForm.addEventListener('mouseleave', mouseLeaveListener);
184   searchForm.addEventListener('keydown', arrowListener);
185 }
186
187 function showSearchLoading() {
188   searchDialog.innerHTML = `<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>`;
189   showSearchDialog();
190 }
191
192 searchForm.addEventListener('submit', event => {
193   event.preventDefault();
194   showSearchLoading();
195   runSearch();
196 });
197
198 searchInput.addEventListener('input', event => {
199   const termLength = searchInput.value.length;
200   if (termLength === 0) {
201     searchDialog.close();
202   } else if (termLength > 2) {
203     showSearchLoading();
204     runSearch();
205   }
206 });
207
208
209 // Email display
210 const emailDisplayLinks = document.querySelectorAll('a.email-display');
211 const eb64 = 'ZW1haWxAYm9v' + 'a3N0YWNrYXBwLmNvbQ==';
212 for (const link of emailDisplayLinks) {
213   const email = atob(eb64);
214   link.addEventListener('click', e => {
215     e.preventDefault();
216     e.target.textContent = email;
217     e.target.href = 'mailto:' + email;
218   });
219 }