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