From 73cd5b30190f9710e06b62ccf0ccfaa36e9e9843 Mon Sep 17 00:00:00 2001 From: Karishma Chadha <kchadha@scratch.mit.edu> Date: Mon, 23 Apr 2018 15:16:28 -0400 Subject: [PATCH] File uploading abstractions --- src/containers/costume-tab.jsx | 72 ++----------- src/containers/sound-tab.jsx | 38 ++----- src/containers/stage-selector.jsx | 72 ++----------- src/lib/fileUploader.js | 174 ++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 160 deletions(-) create mode 100644 src/lib/fileUploader.js diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index e33bf5c77..da325362c 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -2,15 +2,15 @@ import PropTypes from 'prop-types'; import React from 'react'; import bindAll from 'lodash.bindall'; import {defineMessages, intlShape, injectIntl} from 'react-intl'; -import {importBitmap} from 'scratch-svg-renderer'; import VM from 'scratch-vm'; + import AssetPanel from '../components/asset-panel/asset-panel.jsx'; import PaintEditorWrapper from './paint-editor-wrapper.jsx'; import CostumeLibrary from './costume-library.jsx'; import BackdropLibrary from './backdrop-library.jsx'; import {connect} from 'react-redux'; -import log from '../lib/log.js'; +import {handleFileUpload, costumeUpload} from '../lib/fileUploader.js'; import { closeCostumeLibrary, @@ -169,68 +169,12 @@ class CostumeTab extends React.Component { this.props.vm.addCostume(item.md5, vmCostume); } handleCostumeUpload (e) { - const thisFileInput = e.target; - let thisFile = null; - const reader = new FileReader(); - reader.onload = () => { - // Reset the file input value now that we have everything we need - // so that the user can upload the same image multiple times - // if they choose - thisFileInput.value = null; - - - const storage = this.props.vm.runtime.storage; - const fileType = thisFile.type; // check what the browser thinks this is - // Only handling png and svg right now - let costumeFormat = null; - let assetType = null; - if (fileType === 'image/svg+xml') { - costumeFormat = storage.DataFormat.SVG; - assetType = storage.AssetType.ImageVector; - } else if (fileType === 'image/jpeg') { - costumeFormat = storage.DataFormat.JPG; - assetType = storage.AssetType.ImageBitmap; - } else if (fileType === 'image/png') { - costumeFormat = storage.DataFormat.PNG; - assetType = storage.AssetType.ImageBitmap; - } - if (!costumeFormat) return; - - const addCostumeFromBuffer = (function (error, costumeBuffer) { - if (error) { - log.warn(`An error occurred while trying to extract image data: ${error}`); - return; - } - - const md5 = storage.builtinHelper.cache( - assetType, costumeFormat, costumeBuffer); - - const md5Ext = `${md5}.${costumeFormat}`; - - const vmCostume = { - name: 'costume1', - dataFormat: costumeFormat, - md5: `${md5Ext}` - }; - - this.props.vm.addCostume(md5Ext, vmCostume); - }).bind(this); - - if (costumeFormat === storage.DataFormat.SVG) { - // Must pass in file data as a Uint8Array, - // passing in an array buffer causes the sprite/costume - // thumbnails to not display because the data URI for the costume - // is invalid - addCostumeFromBuffer(null, new Uint8Array(reader.result)); - } else { - // otherwise it's a bitmap - importBitmap(reader.result, addCostumeFromBuffer); - } - }; - if (thisFileInput.files) { - thisFile = thisFileInput.files[0]; - reader.readAsArrayBuffer(thisFile); - } + const storage = this.props.vm.runtime.storage; + const handleNewCostume = function (md5Ext, vmCostume) { + this.props.vm.addCostume(md5Ext, vmCostume); + }.bind(this); + const costumeOnload = costumeUpload.bind(this, storage, handleNewCostume); + handleFileUpload(e.target, costumeOnload); } handleFileUploadClick () { this.fileInput.click(); diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx index 94e72125d..a74101924 100644 --- a/src/containers/sound-tab.jsx +++ b/src/containers/sound-tab.jsx @@ -16,6 +16,7 @@ import SoundEditor from './sound-editor.jsx'; import SoundLibrary from './sound-library.jsx'; import soundLibraryContent from '../lib/libraries/sounds.json'; +import {handleFileUpload, soundUpload} from '../lib/fileUploader.js'; import {connect} from 'react-redux'; @@ -106,41 +107,14 @@ class SoundTab extends React.Component { } handleSoundUpload (e) { - const thisFileInput = e.target; - let thisFile = null; - const reader = new FileReader(); - reader.onload = () => { - // Reset the file input value now that we have everything we need - // so that the user can upload the same sound multiple times if - // they choose - thisFileInput.value = null; - // Cache the sound in storage - const soundBuffer = reader.result; - const storage = this.props.vm.runtime.storage; - - const fileType = thisFile.type; // what file type does the browser think this is - const soundFormat = fileType === 'audio/mp3' ? storage.DataFormat.MP3 : storage.DataFormat.WAV; - const md5 = storage.builtinHelper.cache( - storage.AssetType.Sound, - soundFormat, - new Uint8Array(soundBuffer), - ); - // Add the sound to vm - const newSound = { - format: '', - name: 'sound1', - dataFormat: soundFormat, - md5: `${md5}.${soundFormat}` - }; - + const storage = this.props.vm.runtime.storage; + const handleSound = function (newSound) { this.props.vm.addSound(newSound).then(() => { this.handleNewSound(); }); - }; - if (thisFileInput.files) { - thisFile = thisFileInput.files[0]; - reader.readAsArrayBuffer(thisFile); - } + }.bind(this); + const soundOnload = soundUpload.bind(this, storage, handleSound); + handleFileUpload(e.target, soundOnload); } setFileInput (input) { diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx index bac150fe6..40d632b17 100644 --- a/src/containers/stage-selector.jsx +++ b/src/containers/stage-selector.jsx @@ -10,8 +10,7 @@ import StageSelectorComponent from '../components/stage-selector/stage-selector. import backdropLibraryContent from '../lib/libraries/backdrops.json'; import costumeLibraryContent from '../lib/libraries/costumes.json'; -import {importBitmap} from 'scratch-svg-renderer'; -import log from '../lib/log.js'; +import {handleFileUpload, costumeUpload} from '../lib/fileUploader'; class StageSelector extends React.Component { constructor (props) { @@ -56,68 +55,13 @@ class StageSelector extends React.Component { } } handleBackdropUpload (e) { - const thisFileInput = e.target; - let thisFile = null; - const reader = new FileReader(); - reader.onload = () => { - // Reset the file input value now that we have everything we need - // so that the user can upload the same image multiple times - // if they choose - thisFileInput.value = null; - // Cache the image in storage - const storage = this.props.vm.runtime.storage; - const fileType = thisFile.type; // check what the browser thinks this is - // Only handling png and svg right now - let backdropFormat = null; - let assetType = null; - if (fileType === 'image/svg+xml') { - backdropFormat = storage.DataFormat.SVG; - assetType = storage.AssetType.ImageVector; - } else if (fileType === 'image/jpeg') { - backdropFormat = storage.DataFormat.JPG; - assetType = storage.AssetType.ImageBitmap; - } else if (fileType === 'image/png') { - backdropFormat = storage.DataFormat.PNG; - assetType = storage.AssetType.ImageBitmap; - } - if (!backdropFormat) return; - - const addBackdropFromBuffer = (function (error, backdropBuffer) { - if (error) { - log.warn(`An error occurred while trying to extract image data: ${error}`); - return; - } - - const md5 = storage.builtinHelper.cache( - assetType, backdropFormat, backdropBuffer); - - const md5Ext = `${md5}.${backdropFormat}`; - - const vmBackdrop = { - name: 'backdrop1', - dataFormat: backdropFormat, - md5: `${md5Ext}` - }; - - this.props.vm.addBackdrop(md5Ext, vmBackdrop); - this.props.onActivateTab(COSTUMES_TAB_INDEX); - }).bind(this); - - if (backdropFormat === storage.DataFormat.SVG) { - // Must pass in file data as a Uint8Array, - // passing in an array buffer causes the sprite/costume - // thumbnails to not display because the data URI for the costume - // is invalid - addBackdropFromBuffer(null, new Uint8Array(reader.result)); - } else { - // otherwise it's a bitmap - importBitmap(reader.result, addBackdropFromBuffer); - } - }; - if (thisFileInput.files) { - thisFile = thisFileInput.files[0]; - reader.readAsArrayBuffer(thisFile); - } + const storage = this.props.vm.runtime.storage; + const handleNewBackdrop = function (md5Ext, vmBackdrop) { + this.props.vm.addBackdrop(md5Ext, vmBackdrop); + this.props.onActivateTab(COSTUMES_TAB_INDEX); + }.bind(this); + const costumeOnload = costumeUpload.bind(this, storage, handleNewBackdrop); + handleFileUpload(e.target, costumeOnload); } handleFileUploadClick () { this.fileInput.click(); diff --git a/src/lib/fileUploader.js b/src/lib/fileUploader.js new file mode 100644 index 000000000..cfdff91f3 --- /dev/null +++ b/src/lib/fileUploader.js @@ -0,0 +1,174 @@ +import {importBitmap} from 'scratch-svg-renderer'; +import log from './log.js'; + +/** + * Extract the file name given a string of the form fileName + ext + * @param {string} nameExt File name + extension (e.g. 'my_image.png') + * @return {string} The name without the extension, or the full name if + * there was no '.' in the string (e.g. 'my_image') + */ +const extractFileName = function (nameExt) { + // There could be multiple dots, but get the stuff before the first . + const nameParts = nameExt.split('.', 1); // we only care about the first . + return nameParts[0]; +}; + +/** + * Handle a file upload given the input element that contains the file, + * and a function to handle loading the file. + * @param {Input} fileInput The <input/> element that contains the file being loaded + * @param {Function} onload The function that handles loading the file + */ +const handleFileUpload = function (fileInput, onload) { + let thisFile = null; + const reader = new FileReader(); + reader.onload = () => { + // Reset the file input value now that we have everything we need + // so that the user can upload the same sound multiple times if + // they choose + fileInput.value = null; + const fileType = thisFile.type; + const fileName = extractFileName(thisFile.name); + + onload(reader.result, fileType, fileName); + }; + if (fileInput.files) { + thisFile = fileInput.files[0]; + reader.readAsArrayBuffer(thisFile); + } +}; + +/** + * @typedef VMAsset + * @property {string} name The user-readable name of this asset - This will + * automatically get translated to a fresh name if this one already exists in the + * scope of this vm asset (e.g. if a sound already exists with the same name for + * the same target) + * @property {string} dataFormat The data format of this asset, typically + * the extension to be used for that particular asset, e.g. 'svg' for vector images + * @property {string} md5 The md5 hash of the asset data, followed by '.'' and dataFormat + */ + +/** + * Cache an asset (costume, sound) in storage and return an object representation + * of the asset to track in the VM. + * @param {ScratchStorage} storage The storage to cache the asset in + * @param {string} fileName The name of the asset + * @param {AssetType} assetType A ScratchStorage AssetType indicating what kind of + * asset this is. + * @param {string} dataFormat The format of this data (typically the file extension) + * @param {UInt8Array} data The asset data buffer + * @return {VMAsset} An object representing this asset and relevant information + * which can be used to look up the data in storage + */ +const cacheAsset = function (storage, fileName, assetType, dataFormat, data) { + const md5 = storage.builtinHelper.cache( + assetType, + dataFormat, + data + ); + + return { + name: fileName, + dataFormat: dataFormat, + md5: `${md5}.${dataFormat}` + }; +}; + +/** + * Handles loading a costume or a backdrop using the provided, context-relevant information. + * @param {ScratchStorage} storage The ScratchStorage instance to cache the costume data + * @param {Function} then The function to execute on the costume object returned after + * caching this costume in storage - This function should be responsible for + * adding the costume to the VM and handling other UI flow that should come after adding the costume + * @param {ArrayBuffer} fileData The costume data to load + * @param {string} fileType The MIME type of this file + * @param {string} costumeName The user-readable name to use for the costume. + */ +const costumeUpload = function (storage, then, fileData, fileType, costumeName) { + // Only handling png and svg right now + let costumeFormat = null; + let assetType = null; + switch (fileType) { + case 'image/svg+xml': { + costumeFormat = storage.DataFormat.SVG; + assetType = storage.AssetType.ImageVector; + break; + } + case 'image/jpeg': { + costumeFormat = storage.DataFormat.JPG; + assetType = storage.AssetType.ImageBitmap; + break; + } + case 'image/png': { + costumeFormat = storage.DataFormat.PNG; + assetType = storage.AssetType.ImageBitmap; + break; + } + default: + return; + } + + const addCostumeFromBuffer = (function (error, costumeBuffer) { + if (error) { + log.warn(`An error occurred while trying to extract image data: ${error}`); + return; + } + + const vmCostume = cacheAsset(storage, costumeName, assetType, costumeFormat, costumeBuffer); + then(vmCostume.md5, vmCostume); + }); + + if (costumeFormat === storage.DataFormat.SVG) { + // Must pass in file data as a Uint8Array, + // passing in an array buffer causes the sprite/costume + // thumbnails to not display because the data URI for the costume + // is invalid + addCostumeFromBuffer(null, new Uint8Array(fileData)); + } else { + // otherwise it's a bitmap + importBitmap(fileData, addCostumeFromBuffer); + } +}; + +/** + * Handles loading a sound using the provided, context-relevant information. + * @param {ScratchStorage} storage The ScratchStorage instance to cache the sound data + * @param {Function} then The function to execute on the sound object of type VMAsset + * This function should be responsible for adding the sound to the VM + * as well as handling other UI flow that should come after adding the sound + * @param {ArrayBuffer} fileData The sound data to load + * @param {string} fileType The MIME type of this file; This function will exit + * early if the fileType is unexpected. + * @param {string} soundName The user-readable name to use for the sound. + */ +const soundUpload = function (storage, then, fileData, fileType, soundName) { + let soundFormat; + switch (fileType) { + case 'audio/mp3': { + soundFormat = storage.DataFormat.MP3; + break; + } + case 'audio/wav': { // TODO support audio/x-wav? Do we see this in the wild? + soundFormat = storage.DataFormat.WAV; + break; + } + default: + return; + } + + const vmSound = cacheAsset( + storage, + soundName, + storage.AssetType.Sound, + soundFormat, + new Uint8Array(fileData)); + + then(vmSound); +}; + +export { + handleFileUpload, + costumeUpload, + soundUpload +}; -- GitLab