UNPKG

enketo-core

Version:

Extensible Enketo form engine

426 lines (380 loc) 14.4 kB
import $ from 'jquery'; import fileManager from 'enketo/file-manager'; import { t } from 'enketo/translator'; import dialog from 'enketo/dialog'; import Widget from '../../js/widget'; import { getFilename, resizeImage, isNumber } from '../../js/utils'; import downloadUtils from '../../js/download-utils'; import events from '../../js/event'; import TranslatedError from '../../js/translated-error'; import { empty } from '../../js/dom-utils'; // TODO: remove remaining jquery (events, namespaces) // TODO: run (some) standard widget tests /** * FilePicker that works both offline and online. It abstracts the file storage/cache away * with the injected fileManager. * * @augments Widget */ class Filepicker extends Widget { /** * @type {string} */ static get selector() { return '.question:not(.or-appearance-draw):not(.or-appearance-signature):not(.or-appearance-annotate) input[type="file"]'; } _init() { const existingFileName = this.element.getAttribute( 'data-loaded-file-name' ); const that = this; this.element.classList.add('hide'); this.question.classList.add('with-media', 'clearfix'); const fragment = document.createRange().createContextualFragment( `<div class="widget file-picker"> <input class="ignore fake-file-input"/> <div class="file-feedback"></div> <div class="file-preview"></div> </div>` ); fragment.querySelector('input').after(this.downloadButtonHtml); fragment.querySelector('input').after(this.resetButtonHtml); this.element.after(fragment); this.disable(); const widget = this.question.querySelector('.widget'); this.feedback = widget.querySelector('.file-feedback'); this.preview = widget.querySelector('.file-preview'); this.fakeInput = widget.querySelector('.fake-file-input'); this.downloadLink = widget.querySelector('.btn-download'); that._setResetButtonListener(widget.querySelector('.btn-reset')); // Focus listener needs to be added synchronously that._setFocusListener(); // show loaded file name or placeholder regardless of whether widget is supported this._showFileName(existingFileName); if (fileManager.isWaitingForPermissions()) { this._showFeedback( t('filepicker.waitingForPermissions'), 'warning' ); } // Monitor maxSize changes to update placeholder text. This facilitates asynchronous // obtaining of max size from server without slowing down form loading. this._updatePlaceholder(); this.element .closest('form.or') .addEventListener( events.UpdateMaxSize().type, this._updatePlaceholder.bind(this) ); fileManager .init() .then(() => { that._showFeedback(); that._setChangeListener(); if (!that.props.readonly) { that.enable(); } if (existingFileName) { fileManager .getFileUrl(existingFileName) .then((url) => { that._showPreview(url, that.props.mediaType); that._updateDownloadLink(url, existingFileName); }) .catch(() => { that._showFeedback( t('filepicker.notFound', { existing: existingFileName, }), 'error' ); }); } }) .catch((error) => { that._showFeedback(error, 'error'); }); } /** * Updates placeholder */ _updatePlaceholder() { this.fakeInput.setAttribute( 'placeholder', t('filepicker.placeholder', { maxSize: fileManager.getMaxSizeReadable() || '?MB', }) ); } /** * Click action of reset button * * @param {Element} resetButton - reset button HTML element */ _setResetButtonListener(resetButton) { if (resetButton) { resetButton.addEventListener('click', () => { if (this.originalInputValue || this.value) { dialog .confirm( t('filepicker.resetWarning', { item: t('filepicker.file'), }) ) .then((confirmed) => { if (confirmed) { this.originalInputValue = ''; } }) .catch(() => { // Ignore error }); } }); } } /** * Handles change listener */ _setChangeListener() { const that = this; $(this.element) .on('click', (event) => { // The purpose of this handler is to block the filepicker window // when the label is clicked outside of the input. if (that.props.readonly || event.namespace !== 'propagate') { that.fakeInput.focus(); event.stopImmediatePropagation(); return false; } }) .on('change.propagate', (event) => { let file; let fileName; let postfix; const loadedFileName = this.element.getAttribute( 'data-loaded-file-name' ); const now = new Date(); if (event.namespace === 'propagate') { // Trigger eventhandler to update instance value $(this.element).trigger('change.file'); return false; } event.stopImmediatePropagation(); // Get the file file = event.target.files[0]; postfix = `-${now.getHours()}_${now.getMinutes()}_${now.getSeconds()}`; event.target.dataset.filenamePostfix = postfix; fileName = getFilename(file, postfix); // Process the file // Resize the file. Currently will resize an image. this._resizeFile(file, that.props.mediaType) .then((resizedFile) => { // Put information in file element that file is resized // Put resizedDataURI that will be used by fileManager.getCurrentFiles to get blob synchronously event.target.dataset.resized = true; event.target.dataset.resizedDataURI = resizedFile.dataURI; file = resizedFile.blob; }) .catch(() => { // Ignore error }) .finally(() => { fileManager .getFileUrl(file, fileName) .then((url) => { // Update UI that._showPreview(url, that.props.mediaType); that._showFeedback(); that._showFileName(fileName); if ( loadedFileName && loadedFileName !== fileName ) { that.element.removeAttribute( 'data-loaded-file-name' ); } that._updateDownloadLink(url, fileName); // Update record $(that.element).trigger('change.propagate'); }) .catch((error) => { // Update record to clear any existing valid value $(that.element) .val('') .trigger('change.propagate'); // Update UI that._showFileName(''); that._showPreview(null); that._showFeedback(error, 'error'); that._updateDownloadLink('', ''); }); }); }); this.fakeInput.addEventListener('click', (event) => { /* The purpose of this handler is to selectively propagate clicks on the fake input to the underlying file input (to show the file picker window). It blocks propagation if the filepicker has a value to avoid accidentally clearing files in a loaded record, hereby blocking native browser file input behavior to clear values. Instead the reset button is the only way to clear a value. */ event.preventDefault(); if (this.props.readonly || this.originalInputValue || this.value) { this.fakeInput.focus(); event.stopImmediatePropagation(); return; } $(that.element).trigger('click.propagate'); }); // For robustness, avoid any editing of filenames by user. this.fakeInput.addEventListener('change', (event) => { event.preventDefault(); event.stopPropagation(); }); } /** * Handle focus listener */ _setFocusListener() { // Handle focus on original input (goTo functionality) this.element.addEventListener(events.ApplyFocus().type, () => { this.fakeInput.focus(); }); } /** * Sets file name as value * * @param {string} fileName - filename */ _showFileName(fileName) { this.value = fileName; this.fakeInput.readOnly = !!fileName; } /** * @param {TranslatedError|Error} fb - Error instance * @param {string} [status] - status */ _showFeedback(fb, status) { const message = fb instanceof TranslatedError ? t(fb.translationKey, fb.translationOptions) : fb instanceof Error ? fb.message : fb || ''; status = status || ''; // replace text and replace all existing classes with the new status class this.feedback.textContent = message; this.feedback.setAttribute('class', `file-feedback ${status}`); } /** * @param {string} url - URL * @param {string} mediaType - media type */ _showPreview(url, mediaType) { let htmlStr; empty(this.preview); switch (mediaType) { case 'image/*': htmlStr = '<img />'; break; case 'audio/*': htmlStr = '<audio controls="controls"/>'; break; case 'video/*': htmlStr = '<video controls="controls"/>'; break; } if (url && htmlStr) { const fragment = document .createRange() .createContextualFragment(htmlStr); fragment.querySelector('*').src = url; this.preview.append(fragment); } } /** * @param {File} file - image file to be resized * @param {string} mediaType - media type * @return {Promise<Blob|File>} resolves with blob, rejects with input file */ _resizeFile(file, mediaType) { return new Promise((resolve, reject) => { if (mediaType !== 'image/*') { reject(file); } // file is image, resize it if (this.props && this.props.maxPixels) { resizeImage(file, this.props.maxPixels) .then((blob) => { const reader = new FileReader(); reader.addEventListener( 'load', () => { resolve({ blob, dataURI: reader.result }); }, false ); reader.readAsDataURL(blob); }) .catch(() => { reject(file); }); } else { reject(file); } }); } /** * @param {string} objectUrl - ObjectURL * @param {string} fileName - filename */ _updateDownloadLink(objectUrl, fileName) { downloadUtils.updateDownloadLink( this.downloadLink, objectUrl, fileName ); } /** * Disables widget */ disable() { this.element.disabled = true; this.question.querySelector('.btn-reset').disabled = true; } /** * Enables widget */ enable() { this.element.disabled = false; this.question.querySelector('.btn-reset').disabled = false; } /** * @type {object} */ get props() { const props = this._props; props.mediaType = this.element.getAttribute('accept'); if ( this.element.dataset.maxPixels && isNumber(this.element.dataset.maxPixels) ) { props.maxPixels = parseInt(this.element.dataset.maxPixels, 10); } return props; } /** * @type {string} */ get value() { return this.fakeInput.value; } set value(value) { this.fakeInput.value = value; } } export default Filepicker;