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