]> BookStack Code Mirror - bookstack/blob - resources/js/components/entity-selector.js
Added new endpoint for search suggestions
[bookstack] / resources / js / components / entity-selector.js
1 import {onChildEvent} from "../services/dom";
2
3 /**
4  * Entity Selector
5  * @extends {Component}
6  */
7 class EntitySelector {
8
9     setup() {
10         this.elem = this.$el;
11         this.entityTypes = this.$opts.entityTypes || 'page,book,chapter';
12         this.entityPermission = this.$opts.entityPermission || 'view';
13
14         this.input = this.$refs.input;
15         this.searchInput = this.$refs.search;
16         this.loading = this.$refs.loading;
17         this.resultsContainer = this.$refs.results;
18         this.addButton = this.$refs.add;
19
20         this.search = '';
21         this.lastClick = 0;
22         this.selectedItemData = null;
23
24         this.setupListeners();
25         this.showLoading();
26         this.initialLoad();
27     }
28
29     setupListeners() {
30         this.elem.addEventListener('click', this.onClick.bind(this));
31
32         let lastSearch = 0;
33         this.searchInput.addEventListener('input', event => {
34             lastSearch = Date.now();
35             this.showLoading();
36             setTimeout(() => {
37                 if (Date.now() - lastSearch < 199) return;
38                 this.searchEntities(this.searchInput.value);
39             }, 200);
40         });
41
42         this.searchInput.addEventListener('keydown', event => {
43             if (event.keyCode === 13) event.preventDefault();
44         });
45
46         if (this.addButton) {
47             this.addButton.addEventListener('click', event => {
48                 if (this.selectedItemData) {
49                     this.confirmSelection(this.selectedItemData);
50                     this.unselectAll();
51                 }
52             });
53         }
54
55         // Keyboard navigation
56         onChildEvent(this.$el, '[data-entity-type]', 'keydown', (e, el) => {
57             if (e.ctrlKey && e.code === 'Enter') {
58                 const form = this.$el.closest('form');
59                 if (form) {
60                     form.submit();
61                     e.preventDefault();
62                     return;
63                 }
64             }
65
66             if (e.code === 'ArrowDown') {
67                 this.focusAdjacent(true);
68             }
69             if (e.code === 'ArrowUp') {
70                 this.focusAdjacent(false);
71             }
72         });
73
74         this.searchInput.addEventListener('keydown', e => {
75             if (e.code === 'ArrowDown') {
76                 this.focusAdjacent(true);
77             }
78         })
79     }
80
81     focusAdjacent(forward = true) {
82         const items = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
83         const selectedIndex = items.indexOf(document.activeElement);
84         const newItem = items[selectedIndex+ (forward ? 1 : -1)] || items[0];
85         if (newItem) {
86             newItem.focus();
87         }
88     }
89
90     reset() {
91         this.searchInput.value = '';
92         this.showLoading();
93         this.initialLoad();
94     }
95
96     focusSearch() {
97         this.searchInput.focus();
98     }
99
100     showLoading() {
101         this.loading.style.display = 'block';
102         this.resultsContainer.style.display = 'none';
103     }
104
105     hideLoading() {
106         this.loading.style.display = 'none';
107         this.resultsContainer.style.display = 'block';
108     }
109
110     initialLoad() {
111         window.$http.get(this.searchUrl()).then(resp => {
112             this.resultsContainer.innerHTML = resp.data;
113             this.hideLoading();
114         })
115     }
116
117     searchUrl() {
118         return `/search/entity-selector?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
119     }
120
121     searchEntities(searchTerm) {
122         this.input.value = '';
123         const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
124         window.$http.get(url).then(resp => {
125             this.resultsContainer.innerHTML = resp.data;
126             this.hideLoading();
127         });
128     }
129
130     isDoubleClick() {
131         const now = Date.now();
132         const answer = now - this.lastClick < 300;
133         this.lastClick = now;
134         return answer;
135     }
136
137     onClick(event) {
138         const listItem = event.target.closest('[data-entity-type]');
139         if (listItem) {
140             event.preventDefault();
141             event.stopPropagation();
142             this.selectItem(listItem);
143         }
144     }
145
146     selectItem(item) {
147         const isDblClick = this.isDoubleClick();
148         const type = item.getAttribute('data-entity-type');
149         const id = item.getAttribute('data-entity-id');
150         const isSelected = (!item.classList.contains('selected') || isDblClick);
151
152         this.unselectAll();
153         this.input.value = isSelected ? `${type}:${id}` : '';
154
155         const link = item.getAttribute('href');
156         const name = item.querySelector('.entity-list-item-name').textContent;
157         const data = {id: Number(id), name: name, link: link};
158
159         if (isSelected) {
160             item.classList.add('selected');
161             this.selectedItemData = data;
162         } else {
163             window.$events.emit('entity-select-change', null)
164         }
165
166         if (!isDblClick && !isSelected) return;
167
168         if (isDblClick) {
169             this.confirmSelection(data);
170         }
171         if (isSelected) {
172             window.$events.emit('entity-select-change', data)
173         }
174     }
175
176     confirmSelection(data) {
177         window.$events.emit('entity-select-confirm', data);
178     }
179
180     unselectAll() {
181         const selected = this.elem.querySelectorAll('.selected');
182         for (const selectedElem of selected) {
183             selectedElem.classList.remove('selected', 'primary-background');
184         }
185         this.selectedItemData = null;
186     }
187
188 }
189
190 export default EntitySelector;