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