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