diff --git a/package.json b/package.json index 432d01e293f9dd922af101a449522c02fc98c7da..be23d9f7dcaffdb218866d6240207eb63d0fab6f 100644 --- a/package.json +++ b/package.json @@ -92,9 +92,10 @@ "scratch-blocks": "0.1.0-prerelease.1524267558", "scratch-l10n": "2.0.20180108132626", "scratch-paint": "0.2.0-prerelease.20180423143518", - "scratch-render": "0.1.0-prerelease.1523453612", + "scratch-render": "0.1.0-prerelease.20180423214437", + "scratch-svg-renderer": "0.1.0-prerelease.20180423193917", "scratch-storage": "0.4.0", - "scratch-vm": "0.1.0-prerelease.1524254811", + "scratch-vm": "0.1.0-prerelease.1524520946", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.20.0", diff --git a/src/components/stage-selector/stage-selector.jsx b/src/components/stage-selector/stage-selector.jsx index 8af363c09312afda80c554e75ecd0627b923c362..66fbc1fe9e20bf3afdc3944daa45091e94fe6d1b 100644 --- a/src/components/stage-selector/stage-selector.jsx +++ b/src/components/stage-selector/stage-selector.jsx @@ -32,16 +32,19 @@ const messages = defineMessages({ addBackdropFromFile: { id: 'gui.stageSelector.addBackdropFromFile', description: 'Button to add a stage in the target pane from file', - defaultMessage: 'Coming Soon' + defaultMessage: 'Upload Backdrop' } }); const StageSelector = props => { const { backdropCount, + fileInputRef, intl, selected, url, + onBackdropFileUploadClick, + onBackdropFileUpload, onClick, onNewBackdropClick, onSurpriseBackdropClick, @@ -81,7 +84,11 @@ const StageSelector = props => { moreButtons={[ { title: intl.formatMessage(messages.addBackdropFromFile), - img: fileUploadIcon + img: fileUploadIcon, + onClick: onBackdropFileUploadClick, + fileAccept: '.svg, .png, .jpg, .jpeg', // Bitmap coming soon + fileChange: onBackdropFileUpload, + fileInput: fileInputRef }, { title: intl.formatMessage(messages.addBackdropFromSurprise), img: surpriseIcon, @@ -102,7 +109,10 @@ const StageSelector = props => { StageSelector.propTypes = { backdropCount: PropTypes.number.isRequired, + fileInputRef: PropTypes.func, intl: intlShape.isRequired, + onBackdropFileUpload: PropTypes.func, + onBackdropFileUploadClick: PropTypes.func, onClick: PropTypes.func, onEmptyBackdropClick: PropTypes.func, onNewBackdropClick: PropTypes.func, diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index 7cc7a1ed25963029ea60a6fd3decfb69c52722df..4be489f6948e718c7fe8c2cecb90c44db388726a 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -89,9 +89,11 @@ const spriteShape = PropTypes.shape({ costume: PropTypes.shape({ url: PropTypes.string, name: PropTypes.string.isRequired, - bitmapResolution: PropTypes.number.isRequired, - rotationCenterX: PropTypes.number.isRequired, - rotationCenterY: PropTypes.number.isRequired + // The following are optional because costumes uploaded from disk + // will not have these properties available + bitmapResolution: PropTypes.number, + rotationCenterX: PropTypes.number, + rotationCenterY: PropTypes.number }), direction: PropTypes.number, id: PropTypes.string, diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 43a98bd430321e6d894279509ba39758a00de867..6042ceaaac8003eb03e4594faa2a91eb5b563b77 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -9,6 +9,7 @@ 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 {handleFileUpload, costumeUpload} from '../lib/file-uploader.js'; import { closeCostumeLibrary, @@ -48,9 +49,14 @@ const messages = defineMessages({ description: 'Button to add a surprise costume in the editor tab', id: 'gui.costumeTab.addSurpriseCostume' }, + addFileBackdropMsg: { + defaultMessage: 'Upload Backdrop', + description: 'Button to add a backdrop by uploading a file in the editor tab', + id: 'gui.costumeTab.addFileBackdrop' + }, addFileCostumeMsg: { - defaultMessage: 'Coming Soon', - description: 'Button to add a file upload costume in the editor tab', + defaultMessage: 'Upload Costume', + description: 'Button to add a costume by uploading a file in the editor tab', id: 'gui.costumeTab.addFileCostume' }, addCameraCostumeMsg: { @@ -67,9 +73,13 @@ class CostumeTab extends React.Component { 'handleSelectCostume', 'handleDeleteCostume', 'handleDuplicateCostume', + 'handleNewCostume', 'handleNewBlankCostume', 'handleSurpriseCostume', - 'handleSurpriseBackdrop' + 'handleSurpriseBackdrop', + 'handleFileUploadClick', + 'handleCostumeUpload', + 'setFileInput' ]); const { editingTarget, @@ -121,6 +131,9 @@ class CostumeTab extends React.Component { handleDuplicateCostume (costumeIndex) { this.props.vm.duplicateCostume(costumeIndex); } + handleNewCostume (costume) { + this.props.vm.addCostume(costume.md5, costume); + } handleNewBlankCostume () { const emptyItem = costumeLibraryContent.find(item => ( item.name === 'Empty' @@ -128,35 +141,50 @@ class CostumeTab extends React.Component { const name = this.props.vm.editingTarget.isStage ? `backdrop1` : `costume1`; const vmCostume = { name: name, + md5: emptyItem.md5, rotationCenterX: emptyItem.info[0], rotationCenterY: emptyItem.info[1], bitmapResolution: emptyItem.info.length > 2 ? emptyItem.info[2] : 1, skinId: null }; - this.props.vm.addCostume(emptyItem.md5, vmCostume); + this.handleNewCostume(vmCostume); } handleSurpriseCostume () { const item = costumeLibraryContent[Math.floor(Math.random() * costumeLibraryContent.length)]; const vmCostume = { name: item.name, + md5: item.md5, rotationCenterX: item.info[0], rotationCenterY: item.info[1], bitmapResolution: item.info.length > 2 ? item.info[2] : 1, skinId: null }; - this.props.vm.addCostume(item.md5, vmCostume); + this.handleNewCostume(vmCostume); } handleSurpriseBackdrop () { const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; const vmCostume = { name: item.name, + md5: item.md5, rotationCenterX: item.info[0] && item.info[0] / 2, rotationCenterY: item.info[1] && item.info[1] / 2, bitmapResolution: item.info.length > 2 ? item.info[2] : 1, skinId: null }; - this.props.vm.addCostume(item.md5, vmCostume); + this.handleNewCostume(vmCostume); + } + handleCostumeUpload (e) { + const storage = this.props.vm.runtime.storage; + handleFileUpload(e.target, (buffer, fileType, fileName) => { + costumeUpload(buffer, fileType, fileName, storage, this.handleNewCostume); + }); + } + handleFileUploadClick () { + this.fileInput.click(); + } + setFileInput (input) { + this.fileInput = input; } formatCostumeDetails (size) { // Round up width and height for scratch-flash compatibility @@ -185,6 +213,7 @@ class CostumeTab extends React.Component { } const addLibraryMessage = target.isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg; + const addFileMessage = target.isStage ? messages.addFileBackdropMsg : messages.addFileCostumeMsg; const addSurpriseFunc = target.isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume; const addLibraryFunc = target.isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick; const addLibraryIcon = target.isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon; @@ -208,8 +237,12 @@ class CostumeTab extends React.Component { img: cameraIcon }, { - title: intl.formatMessage(messages.addFileCostumeMsg), - img: fileUploadIcon + title: intl.formatMessage(addFileMessage), + img: fileUploadIcon, + onClick: this.handleFileUploadClick, + fileAccept: '.svg, .png, .jpg, .jpeg', // coming soon + fileChange: this.handleCostumeUpload, + fileInput: this.setFileInput }, { title: intl.formatMessage(messages.addSurpriseCostumeMsg), diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx index 94e72125d15f62333c10de5fee7e704f8a425626..ab4c9062bdebc5fa1b5055919a0df50bc1c3f3a3 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/file-uploader.js'; import {connect} from 'react-redux'; @@ -106,41 +107,13 @@ 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 storage = this.props.vm.runtime.storage; + const handleSound = newSound => this.props.vm.addSound(newSound) + .then(() => this.handleNewSound()); - 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}` - }; - - this.props.vm.addSound(newSound).then(() => { - this.handleNewSound(); - }); - }; - if (thisFileInput.files) { - thisFile = thisFileInput.files[0]; - reader.readAsArrayBuffer(thisFile); - } + handleFileUpload(e.target, (buffer, fileType, fileName) => { + soundUpload(buffer, fileType, fileName, storage, handleSound); + }); } setFileInput (input) { diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx index bf749c12a08fa7d997e484aa19b98d252e2a9d30..f3497a54cd9f2e187ffc5a5586b20f7492027f0c 100644 --- a/src/containers/stage-selector.jsx +++ b/src/containers/stage-selector.jsx @@ -10,47 +10,64 @@ import StageSelectorComponent from '../components/stage-selector/stage-selector. import backdropLibraryContent from '../lib/libraries/backdrops.json'; import costumeLibraryContent from '../lib/libraries/costumes.json'; +import {handleFileUpload, costumeUpload} from '../lib/file-uploader.js'; class StageSelector extends React.Component { constructor (props) { super(props); bindAll(this, [ 'handleClick', + 'handleNewBackdrop', 'handleSurpriseBackdrop', 'handleEmptyBackdrop', - 'addBackdropFromLibraryItem' + 'addBackdropFromLibraryItem', + 'handleFileUploadClick', + 'handleBackdropUpload', + 'setFileInput' ]); } addBackdropFromLibraryItem (item) { const vmBackdrop = { name: item.name, + md5: item.md5, rotationCenterX: item.info[0] && item.info[0] / 2, rotationCenterY: item.info[1] && item.info[1] / 2, bitmapResolution: item.info.length > 2 ? item.info[2] : 1, skinId: null }; - return this.props.vm.addBackdrop(item.md5, vmBackdrop); + this.handleNewBackdrop(vmBackdrop); } - handleClick (e) { - e.preventDefault(); + handleClick () { this.props.onSelect(this.props.id); } + handleNewBackdrop (backdrop) { + this.props.vm.addBackdrop(backdrop.md5, backdrop).then(() => + this.props.onActivateTab(COSTUMES_TAB_INDEX)); + } handleSurpriseBackdrop () { // @todo should this not add a backdrop you already have? const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; - this.addBackdropFromLibraryItem(item).then(() => { - this.props.onActivateTab(COSTUMES_TAB_INDEX); - }); + this.addBackdropFromLibraryItem(item); } handleEmptyBackdrop () { // @todo this is brittle, will need to be refactored for localized libraries const emptyItem = costumeLibraryContent.find(item => item.name === 'Empty'); if (emptyItem) { - this.addBackdropFromLibraryItem(emptyItem).then(() => { - this.props.onActivateTab(COSTUMES_TAB_INDEX); - }); + this.addBackdropFromLibraryItem(emptyItem); } } + handleBackdropUpload (e) { + const storage = this.props.vm.runtime.storage; + handleFileUpload(e.target, (buffer, fileType, fileName) => { + costumeUpload(buffer, fileType, fileName, storage, this.handleNewBackdrop); + }); + } + handleFileUploadClick () { + this.fileInput.click(); + } + setFileInput (input) { + this.fileInput = input; + } render () { const { /* eslint-disable no-unused-vars */ @@ -63,9 +80,13 @@ class StageSelector extends React.Component { } = this.props; return ( <StageSelectorComponent + fileInputRef={this.setFileInput} + onBackdropFileUpload={this.handleBackdropUpload} + onBackdropFileUploadClick={this.handleFileUploadClick} onClick={this.handleClick} onEmptyBackdropClick={this.handleEmptyBackdrop} onSurpriseBackdropClick={this.handleSurpriseBackdrop} + {...componentProps} /> ); diff --git a/src/lib/file-uploader.js b/src/lib/file-uploader.js new file mode 100644 index 0000000000000000000000000000000000000000..cea0aaeb11abc1258672bd0220abd0522ea76b33 --- /dev/null +++ b/src/lib/file-uploader.js @@ -0,0 +1,173 @@ +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 {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. + * @param {ScratchStorage} storage The ScratchStorage instance to cache the costume data + * @param {Function} handleCostume 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 + */ +const costumeUpload = function (fileData, fileType, costumeName, storage, handleCostume) { + 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); + handleCostume(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 {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. + * @param {ScratchStorage} storage The ScratchStorage instance to cache the sound data + * @param {Function} handleSound 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 + */ +const soundUpload = function (fileData, fileType, soundName, storage, handleSound) { + 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)); + + handleSound(vmSound); +}; + +export { + handleFileUpload, + costumeUpload, + soundUpload +};