]> BookStack Code Mirror - bookstack/blob - resources/js/components/image-manager.js
Images: Rolled out image memory handling to image actions
[bookstack] / resources / js / components / image-manager.js
1 import {onChildEvent, onSelect, removeLoading, showLoading,} from '../services/dom';
2 import {Component} from './component';
3
4 export class ImageManager extends Component {
5
6     setup() {
7         // Options
8         this.uploadedTo = this.$opts.uploadedTo;
9
10         // Element References
11         this.container = this.$el;
12         this.popupEl = this.$refs.popup;
13         this.searchForm = this.$refs.searchForm;
14         this.searchInput = this.$refs.searchInput;
15         this.cancelSearch = this.$refs.cancelSearch;
16         this.listContainer = this.$refs.listContainer;
17         this.filterTabs = this.$manyRefs.filterTabs;
18         this.selectButton = this.$refs.selectButton;
19         this.uploadButton = this.$refs.uploadButton;
20         this.uploadHint = this.$refs.uploadHint;
21         this.formContainer = this.$refs.formContainer;
22         this.formContainerPlaceholder = this.$refs.formContainerPlaceholder;
23         this.dropzoneContainer = this.$refs.dropzoneContainer;
24         this.loadMore = this.$refs.loadMore;
25
26         // Instance data
27         this.type = 'gallery';
28         this.lastSelected = {};
29         this.lastSelectedTime = 0;
30         this.callback = null;
31         this.resetState = () => {
32             this.hasData = false;
33             this.page = 1;
34             this.filter = 'all';
35         };
36         this.resetState();
37
38         this.setupListeners();
39     }
40
41     setupListeners() {
42         // Filter tab click
43         onSelect(this.filterTabs, e => {
44             this.resetAll();
45             this.filter = e.target.dataset.filter;
46             this.setActiveFilterTab(this.filter);
47             this.loadGallery();
48         });
49
50         // Search submit
51         this.searchForm.addEventListener('submit', event => {
52             this.resetListView();
53             this.loadGallery();
54             this.cancelSearch.toggleAttribute('hidden', !this.searchInput.value);
55             event.preventDefault();
56         });
57
58         // Cancel search button
59         onSelect(this.cancelSearch, () => {
60             this.resetListView();
61             this.resetSearchView();
62             this.loadGallery();
63         });
64
65         // Load more button click
66         onChildEvent(this.container, '.load-more button', 'click', this.runLoadMore.bind(this));
67
68         // Select image event
69         this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
70
71         // Image load error handling
72         this.listContainer.addEventListener('error', event => {
73             event.target.src = window.baseUrl('loading_error.png');
74         }, true);
75
76         // Footer select button click
77         onSelect(this.selectButton, () => {
78             if (this.callback) {
79                 this.callback(this.lastSelected);
80             }
81             this.hide();
82         });
83
84         // Delete button click
85         onChildEvent(this.formContainer, '#image-manager-delete', 'click', () => {
86             if (this.lastSelected) {
87                 this.loadImageEditForm(this.lastSelected.id, true);
88             }
89         });
90
91         // Rebuild thumbs click
92         onChildEvent(this.formContainer, '#image-manager-rebuild-thumbs', 'click', async (_, button) => {
93             button.disabled = true;
94             if (this.lastSelected) {
95                 await this.rebuildThumbnails(this.lastSelected.id);
96             }
97             button.disabled = false;
98         });
99
100         // Edit form submit
101         this.formContainer.addEventListener('ajax-form-success', () => {
102             this.refreshGallery();
103             this.resetEditForm();
104         });
105
106         // Image upload success
107         this.container.addEventListener('dropzone-upload-success', this.refreshGallery.bind(this));
108
109         // Auto load-more on scroll
110         const scrollZone = this.listContainer.parentElement;
111         let scrollEvents = [];
112         scrollZone.addEventListener('wheel', event => {
113             const scrollOffset = Math.ceil(scrollZone.scrollHeight - scrollZone.scrollTop);
114             const bottomedOut = scrollOffset === scrollZone.clientHeight;
115             if (!bottomedOut || event.deltaY < 1) {
116                 return;
117             }
118
119             const secondAgo = Date.now() - 1000;
120             scrollEvents.push(Date.now());
121             scrollEvents = scrollEvents.filter(d => d >= secondAgo);
122             if (scrollEvents.length > 5 && this.canLoadMore()) {
123                 this.runLoadMore();
124             }
125         });
126     }
127
128     show(callback, type = 'gallery') {
129         this.resetAll();
130
131         this.callback = callback;
132         this.type = type;
133         this.getPopup().show();
134
135         const hideUploads = type !== 'gallery';
136         this.dropzoneContainer.classList.toggle('hidden', hideUploads);
137         this.uploadButton.classList.toggle('hidden', hideUploads);
138         this.uploadHint.classList.toggle('hidden', hideUploads);
139
140         /** @var {Dropzone} * */
141         const dropzone = window.$components.firstOnElement(this.container, 'dropzone');
142         dropzone.toggleActive(!hideUploads);
143
144         if (!this.hasData) {
145             this.loadGallery();
146             this.hasData = true;
147         }
148     }
149
150     hide() {
151         this.getPopup().hide();
152     }
153
154     /**
155      * @returns {Popup}
156      */
157     getPopup() {
158         return window.$components.firstOnElement(this.popupEl, 'popup');
159     }
160
161     async loadGallery() {
162         const params = {
163             page: this.page,
164             search: this.searchInput.value || null,
165             uploaded_to: this.uploadedTo,
166             filter_type: this.filter === 'all' ? null : this.filter,
167         };
168
169         const {data: html} = await window.$http.get(`images/${this.type}`, params);
170         if (params.page === 1) {
171             this.listContainer.innerHTML = '';
172         }
173         this.addReturnedHtmlElementsToList(html);
174         removeLoading(this.listContainer);
175     }
176
177     addReturnedHtmlElementsToList(html) {
178         const el = document.createElement('div');
179         el.innerHTML = html;
180
181         const loadMore = el.querySelector('.load-more');
182         if (loadMore) {
183             loadMore.remove();
184             this.loadMore.innerHTML = loadMore.innerHTML;
185         }
186         this.loadMore.toggleAttribute('hidden', !loadMore);
187
188         window.$components.init(el);
189         for (const child of [...el.children]) {
190             this.listContainer.appendChild(child);
191         }
192     }
193
194     setActiveFilterTab(filterName) {
195         for (const tab of this.filterTabs) {
196             const selected = tab.dataset.filter === filterName;
197             tab.setAttribute('aria-selected', selected ? 'true' : 'false');
198         }
199     }
200
201     resetAll() {
202         this.resetState();
203         this.resetListView();
204         this.resetSearchView();
205         this.resetEditForm();
206         this.setActiveFilterTab('all');
207         this.selectButton.classList.add('hidden');
208     }
209
210     resetSearchView() {
211         this.searchInput.value = '';
212         this.cancelSearch.toggleAttribute('hidden', true);
213     }
214
215     resetEditForm() {
216         this.formContainer.innerHTML = '';
217         this.formContainerPlaceholder.removeAttribute('hidden');
218     }
219
220     resetListView() {
221         showLoading(this.listContainer);
222         this.page = 1;
223     }
224
225     refreshGallery() {
226         this.resetListView();
227         this.loadGallery();
228     }
229
230     async onImageSelectEvent(event) {
231         let image = JSON.parse(event.detail.data);
232         const isDblClick = ((image && image.id === this.lastSelected.id)
233             && Date.now() - this.lastSelectedTime < 400);
234         const alreadySelected = event.target.classList.contains('selected');
235         [...this.listContainer.querySelectorAll('.selected')].forEach(el => {
236             el.classList.remove('selected');
237         });
238
239         if (!alreadySelected && !isDblClick) {
240             event.target.classList.add('selected');
241             image = await this.loadImageEditForm(image.id);
242         } else if (!isDblClick) {
243             this.resetEditForm();
244         } else if (isDblClick) {
245             image = this.lastSelected;
246         }
247
248         this.selectButton.classList.toggle('hidden', alreadySelected);
249
250         if (isDblClick && this.callback) {
251             this.callback(image);
252             this.hide();
253         }
254
255         this.lastSelected = image;
256         this.lastSelectedTime = Date.now();
257     }
258
259     async loadImageEditForm(imageId, requestDelete = false) {
260         if (!requestDelete) {
261             this.formContainer.innerHTML = '';
262         }
263
264         const params = requestDelete ? {delete: true} : {};
265         const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params);
266         this.formContainer.innerHTML = formHtml;
267         this.formContainerPlaceholder.setAttribute('hidden', '');
268         window.$components.init(this.formContainer);
269
270         const imageDataEl = this.formContainer.querySelector('#image-manager-form-image-data');
271         return JSON.parse(imageDataEl.text);
272     }
273
274     runLoadMore() {
275         showLoading(this.loadMore);
276         this.page += 1;
277         this.loadGallery();
278     }
279
280     canLoadMore() {
281         return this.loadMore.querySelector('button') && !this.loadMore.hasAttribute('hidden');
282     }
283
284     async rebuildThumbnails(imageId) {
285         try {
286             const response = await window.$http.put(`/images/${imageId}/rebuild-thumbnails`);
287             window.$events.success(response.data);
288             this.refreshGallery();
289         } catch (err) {
290             window.$events.showResponseError(err);
291         }
292     }
293
294 }