X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/a6633642232efd164d4708967ab59e498fbff896..refs/pull/4827/head:/resources/js/components/dropzone.js diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js index e7273df62..1cac09b4a 100644 --- a/resources/js/components/dropzone.js +++ b/resources/js/components/dropzone.js @@ -1,77 +1,248 @@ -import DropZoneLib from "dropzone"; -import {fadeOut} from "../services/animations"; +import {Component} from './component'; +import {Clipboard} from '../services/clipboard'; +import { + elem, getLoading, onSelect, removeLoading, +} from '../services/dom'; + +export class Dropzone extends Component { -/** - * Dropzone - * @extends {Component} - */ -class Dropzone { setup() { this.container = this.$el; + this.statusArea = this.$refs.statusArea; + this.dropTarget = this.$refs.dropTarget; + this.selectButtons = this.$manyRefs.selectButton || []; + + this.isActive = true; + this.url = this.$opts.url; + this.method = (this.$opts.method || 'post').toUpperCase(); this.successMessage = this.$opts.successMessage; - this.removeMessage = this.$opts.removeMessage; + this.errorMessage = this.$opts.errorMessage; + this.uploadLimitMb = Number(this.$opts.uploadLimit); this.uploadLimitMessage = this.$opts.uploadLimitMessage; - this.timeoutMessage = this.$opts.timeoutMessage; - - const _this = this; - this.dz = new DropZoneLib(this.container, { - addRemoveLinks: true, - dictRemoveFile: this.removeMessage, - timeout: Number(window.uploadTimeout) || 60000, - maxFilesize: Number(window.uploadLimit) || 256, - url: this.url, - withCredentials: true, - init() { - this.dz = this; - this.dz.on('sending', _this.onSending.bind(_this)); - this.dz.on('success', _this.onSuccess.bind(_this)); - this.dz.on('error', _this.onError.bind(_this)); + this.zoneText = this.$opts.zoneText; + this.fileAcceptTypes = this.$opts.fileAccept; + this.allowMultiple = this.$opts.allowMultiple === 'true'; + + this.setupListeners(); + } + + /** + * Public method to allow external disabling/enabling of this drag+drop dropzone. + * @param {Boolean} active + */ + toggleActive(active) { + this.isActive = active; + } + + setupListeners() { + onSelect(this.selectButtons, this.manualSelectHandler.bind(this)); + this.setupDropTargetHandlers(); + } + + setupDropTargetHandlers() { + let depth = 0; + + const reset = () => { + this.hideOverlay(); + depth = 0; + }; + + this.dropTarget.addEventListener('dragenter', event => { + event.preventDefault(); + depth += 1; + + if (depth === 1 && this.isActive) { + this.showOverlay(); + } + }); + + this.dropTarget.addEventListener('dragover', event => { + event.preventDefault(); + }); + + this.dropTarget.addEventListener('dragend', reset); + this.dropTarget.addEventListener('dragleave', () => { + depth -= 1; + if (depth === 0) { + reset(); + } + }); + this.dropTarget.addEventListener('drop', event => { + event.preventDefault(); + reset(); + + if (!this.isActive) { + return; + } + + const clipboard = new Clipboard(event.dataTransfer); + const files = clipboard.getFiles(); + for (const file of files) { + this.createUploadFromFile(file); } }); } - onSending(file, xhr, data) { + manualSelectHandler() { + const input = elem('input', { + type: 'file', + style: 'left: -400px; visibility: hidden; position: fixed;', + accept: this.fileAcceptTypes, + multiple: this.allowMultiple ? '' : null, + }); + this.container.append(input); + input.click(); + input.addEventListener('change', () => { + for (const file of input.files) { + this.createUploadFromFile(file); + } + input.remove(); + }); + } - const token = window.document.querySelector('meta[name=token]').getAttribute('content'); - data.append('_token', token); + showOverlay() { + const overlay = this.dropTarget.querySelector('.dropzone-overlay'); + if (!overlay) { + const zoneElem = elem('div', {class: 'dropzone-overlay'}, [this.zoneText]); + this.dropTarget.append(zoneElem); + } + } - xhr.ontimeout = (e) => { - this.dz.emit('complete', file); - this.dz.emit('error', file, this.timeoutMessage); + hideOverlay() { + const overlay = this.dropTarget.querySelector('.dropzone-overlay'); + if (overlay) { + overlay.remove(); } } - onSuccess(file, data) { - this.$emit('success', {file, data}); + /** + * @param {File} file + * @return {Upload} + */ + createUploadFromFile(file) { + const { + dom, status, progress, dismiss, + } = this.createDomForFile(file); + this.statusArea.append(dom); + const component = this; + + const upload = { + file, + dom, + updateProgress(percentComplete) { + progress.textContent = `${percentComplete}%`; + progress.style.width = `${percentComplete}%`; + }, + markError(message) { + status.setAttribute('data-status', 'error'); + status.textContent = message; + removeLoading(dom); + this.updateProgress(100); + }, + markSuccess(message) { + status.setAttribute('data-status', 'success'); + status.textContent = message; + removeLoading(dom); + setTimeout(dismiss, 2400); + component.$emit('upload-success', { + name: file.name, + }); + }, + }; - if (this.successMessage) { - window.$events.emit('success', this.successMessage); + // Enforce early upload filesize limit + if (file.size > (this.uploadLimitMb * 1000000)) { + upload.markError(this.uploadLimitMessage); + return upload; } - fadeOut(file.previewElement, 800, () => { - this.dz.removeFile(file); + this.startXhrForUpload(upload); + + return upload; + } + + /** + * @param {Upload} upload + */ + startXhrForUpload(upload) { + const formData = new FormData(); + formData.append('file', upload.file, upload.file.name); + if (this.method !== 'POST') { + formData.append('_method', this.method); + } + const component = this; + + const req = window.$http.createXMLHttpRequest('POST', this.url, { + error() { + upload.markError(component.errorMessage); + }, + readystatechange() { + if (this.readyState === XMLHttpRequest.DONE && this.status === 200) { + upload.markSuccess(component.successMessage); + } else if (this.readyState === XMLHttpRequest.DONE && this.status >= 400) { + const content = this.responseText; + const data = content.startsWith('{') ? JSON.parse(content) : {message: content}; + const message = data?.message || data?.error || content; + upload.markError(message); + } + }, + }); + + req.upload.addEventListener('progress', evt => { + const percent = Math.min(Math.ceil((evt.loaded / evt.total) * 100), 100); + upload.updateProgress(percent); }); + + req.setRequestHeader('Accept', 'application/json'); + req.send(formData); } - onError(file, errorMessage, xhr) { - this.$emit('error', {file, errorMessage, xhr}); + /** + * @param {File} file + * @return {{image: Element, dom: Element, progress: Element, status: Element, dismiss: function}} + */ + createDomForFile(file) { + 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"}); + const status = elem('div', {class: 'dropzone-file-item-status'}, []); + const progress = elem('div', {class: 'dropzone-file-item-progress'}); + const imageWrap = elem('div', {class: 'dropzone-file-item-image-wrap'}, [image]); - const setMessage = (message) => { - const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]'); - messsageEl.textContent = message; - } + const dom = elem('div', {class: 'dropzone-file-item'}, [ + imageWrap, + elem('div', {class: 'dropzone-file-item-text-wrap'}, [ + elem('div', {class: 'dropzone-file-item-label'}, [file.name]), + getLoading(), + status, + ]), + progress, + ]); - if (xhr && xhr.status === 413) { - setMessage(this.uploadLimitMessage); - } else if (errorMessage.file) { - setMessage(errorMessage.file); + if (file.type.startsWith('image/')) { + image.src = URL.createObjectURL(file); } - } - removeAll() { - this.dz.removeAllFiles(true); + const dismiss = () => { + dom.classList.add('dismiss'); + dom.addEventListener('animationend', () => { + dom.remove(); + }); + }; + + dom.addEventListener('click', dismiss); + + return { + dom, progress, status, dismiss, + }; } + } -export default Dropzone; \ No newline at end of file +/** + * @typedef Upload + * @property {File} file + * @property {Element} dom + * @property {function(Number)} updateProgress + * @property {function(String)} markError + * @property {function(String)} markSuccess + */