]> BookStack Code Mirror - bookstack/blob - resources/js/components/dropzone.js
2b8b35081188f8754a744b5fd412a86076224670
[bookstack] / resources / js / components / dropzone.js
1 import {Component} from './component';
2 import {Clipboard} from '../services/clipboard';
3 import {
4     elem, getLoading, onSelect, removeLoading,
5 } from '../services/dom';
6
7 export class Dropzone extends Component {
8
9     setup() {
10         this.container = this.$el;
11         this.statusArea = this.$refs.statusArea;
12         this.dropTarget = this.$refs.dropTarget;
13         this.selectButtons = this.$manyRefs.selectButton || [];
14
15         this.isActive = true;
16
17         this.url = this.$opts.url;
18         this.successMessage = this.$opts.successMessage;
19         this.errorMessage = this.$opts.errorMessage;
20         this.uploadLimitMb = Number(this.$opts.uploadLimit);
21         this.uploadLimitMessage = this.$opts.uploadLimitMessage;
22         this.zoneText = this.$opts.zoneText;
23         this.fileAcceptTypes = this.$opts.fileAccept;
24         this.allowMultiple = this.$opts.allowMultiple === 'true';
25
26         this.setupListeners();
27     }
28
29     /**
30      * Public method to allow external disabling/enabling of this drag+drop dropzone.
31      * @param {Boolean} active
32      */
33     toggleActive(active) {
34         this.isActive = active;
35     }
36
37     setupListeners() {
38         onSelect(this.selectButtons, this.manualSelectHandler.bind(this));
39         this.setupDropTargetHandlers();
40     }
41
42     setupDropTargetHandlers() {
43         let depth = 0;
44
45         const reset = () => {
46             this.hideOverlay();
47             depth = 0;
48         };
49
50         this.dropTarget.addEventListener('dragenter', event => {
51             event.preventDefault();
52             depth += 1;
53
54             if (depth === 1 && this.isActive) {
55                 this.showOverlay();
56             }
57         });
58
59         this.dropTarget.addEventListener('dragover', event => {
60             event.preventDefault();
61         });
62
63         this.dropTarget.addEventListener('dragend', reset);
64         this.dropTarget.addEventListener('dragleave', () => {
65             depth -= 1;
66             if (depth === 0) {
67                 reset();
68             }
69         });
70         this.dropTarget.addEventListener('drop', event => {
71             event.preventDefault();
72             reset();
73
74             if (!this.isActive) {
75                 return;
76             }
77
78             const clipboard = new Clipboard(event.dataTransfer);
79             const files = clipboard.getFiles();
80             for (const file of files) {
81                 this.createUploadFromFile(file);
82             }
83         });
84     }
85
86     manualSelectHandler() {
87         const input = elem('input', {
88             type: 'file',
89             style: 'left: -400px; visibility: hidden; position: fixed;',
90             accept: this.fileAcceptTypes,
91             multiple: this.allowMultiple ? '' : null,
92         });
93         this.container.append(input);
94         input.click();
95         input.addEventListener('change', () => {
96             for (const file of input.files) {
97                 this.createUploadFromFile(file);
98             }
99             input.remove();
100         });
101     }
102
103     showOverlay() {
104         const overlay = this.dropTarget.querySelector('.dropzone-overlay');
105         if (!overlay) {
106             const zoneElem = elem('div', {class: 'dropzone-overlay'}, [this.zoneText]);
107             this.dropTarget.append(zoneElem);
108         }
109     }
110
111     hideOverlay() {
112         const overlay = this.dropTarget.querySelector('.dropzone-overlay');
113         if (overlay) {
114             overlay.remove();
115         }
116     }
117
118     /**
119      * @param {File} file
120      * @return {Upload}
121      */
122     createUploadFromFile(file) {
123         const {
124             dom, status, progress, dismiss,
125         } = this.createDomForFile(file);
126         this.statusArea.append(dom);
127         const component = this;
128
129         const upload = {
130             file,
131             dom,
132             updateProgress(percentComplete) {
133                 progress.textContent = `${percentComplete}%`;
134                 progress.style.width = `${percentComplete}%`;
135             },
136             markError(message) {
137                 status.setAttribute('data-status', 'error');
138                 status.textContent = message;
139                 removeLoading(dom);
140                 this.updateProgress(100);
141             },
142             markSuccess(message) {
143                 status.setAttribute('data-status', 'success');
144                 status.textContent = message;
145                 removeLoading(dom);
146                 setTimeout(dismiss, 2400);
147                 component.$emit('upload-success', {
148                     name: file.name,
149                 });
150             },
151         };
152
153         // Enforce early upload filesize limit
154         if (file.size > (this.uploadLimitMb * 1000000)) {
155             upload.markError(this.uploadLimitMessage);
156             return upload;
157         }
158
159         this.startXhrForUpload(upload);
160
161         return upload;
162     }
163
164     /**
165      * @param {Upload} upload
166      */
167     startXhrForUpload(upload) {
168         const formData = new FormData();
169         formData.append('file', upload.file, upload.file.name);
170         const component = this;
171
172         const req = window.$http.createXMLHttpRequest('POST', this.url, {
173             error() {
174                 upload.markError(component.errorMessage);
175             },
176             readystatechange() {
177                 if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
178                     upload.markSuccess(component.successMessage);
179                 } else if (this.readyState === XMLHttpRequest.DONE && this.status >= 400) {
180                     const content = this.responseText;
181                     const data = content.startsWith('{') ? JSON.parse(content) : {message: content};
182                     const message = data?.message || data?.error || content;
183                     upload.markError(message);
184                 }
185             },
186         });
187
188         req.upload.addEventListener('progress', evt => {
189             const percent = Math.min(Math.ceil((evt.loaded / evt.total) * 100), 100);
190             upload.updateProgress(percent);
191         });
192
193         req.setRequestHeader('Accept', 'application/json');
194         req.send(formData);
195     }
196
197     /**
198      * @param {File} file
199      * @return {{image: Element, dom: Element, progress: Element, status: Element, dismiss: function}}
200      */
201     createDomForFile(file) {
202         const image = elem('img', {src: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9.224 7.373a.924.924 0 0 0-.92.925l-.006 7.404c0 .509.412.925.921.925h5.557a.928.928 0 0 0 .926-.925v-5.553l-2.777-2.776Zm3.239 3.239V8.067l2.545 2.545z' style='fill:%23000;fill-opacity:.75'/%3E%3C/svg%3E"});
203         const status = elem('div', {class: 'dropzone-file-item-status'}, []);
204         const progress = elem('div', {class: 'dropzone-file-item-progress'});
205         const imageWrap = elem('div', {class: 'dropzone-file-item-image-wrap'}, [image]);
206
207         const dom = elem('div', {class: 'dropzone-file-item'}, [
208             imageWrap,
209             elem('div', {class: 'dropzone-file-item-text-wrap'}, [
210                 elem('div', {class: 'dropzone-file-item-label'}, [file.name]),
211                 getLoading(),
212                 status,
213             ]),
214             progress,
215         ]);
216
217         if (file.type.startsWith('image/')) {
218             image.src = URL.createObjectURL(file);
219         }
220
221         const dismiss = () => {
222             dom.classList.add('dismiss');
223             dom.addEventListener('animationend', () => {
224                 dom.remove();
225             });
226         };
227
228         dom.addEventListener('click', dismiss);
229
230         return {
231             dom, progress, status, dismiss,
232         };
233     }
234
235 }
236
237 /**
238  * @typedef Upload
239  * @property {File} file
240  * @property {Element} dom
241  * @property {function(Number)} updateProgress
242  * @property {function(String)} markError
243  * @property {function(String)} markSuccess
244  */