import PropTypes from 'prop-types'; import React from 'react'; import bindAll from 'lodash.bindall'; import {defineMessages, intlShape, injectIntl} from 'react-intl'; 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 { closeCostumeLibrary, closeBackdropLibrary, openCostumeLibrary, openBackdropLibrary } from '../reducers/modals'; import addLibraryBackdropIcon from '../components/asset-panel/icon--add-backdrop-lib.svg'; import addLibraryCostumeIcon from '../components/asset-panel/icon--add-costume-lib.svg'; import fileUploadIcon from '../components/action-menu/icon--file-upload.svg'; import paintIcon from '../components/action-menu/icon--paint.svg'; import cameraIcon from '../components/action-menu/icon--camera.svg'; import surpriseIcon from '../components/action-menu/icon--surprise.svg'; import costumeLibraryContent from '../lib/libraries/costumes.json'; import backdropLibraryContent from '../lib/libraries/backdrops.json'; const messages = defineMessages({ addLibraryBackdropMsg: { defaultMessage: 'Choose a Backdrop', description: 'Button to add a backdrop in the editor tab', id: 'gui.costumeTab.addBackdropFromLibrary' }, addLibraryCostumeMsg: { defaultMessage: 'Choose a Costume', description: 'Button to add a costume in the editor tab', id: 'gui.costumeTab.addCostumeFromLibrary' }, addBlankCostumeMsg: { defaultMessage: 'Paint', description: 'Button to add a blank costume in the editor tab', id: 'gui.costumeTab.addBlankCostume' }, addSurpriseCostumeMsg: { defaultMessage: 'Surprise', description: 'Button to add a surprise costume in the editor tab', id: 'gui.costumeTab.addSurpriseCostume' }, addFileCostumeMsg: { defaultMessage: 'Upload Costume', description: 'Button to add a file upload costume in the editor tab', id: 'gui.costumeTab.addFileCostume' }, addCameraCostumeMsg: { defaultMessage: 'Coming Soon', description: 'Button to use the camera to create a costume costume in the editor tab', id: 'gui.costumeTab.addCameraCostume' } }); class CostumeTab extends React.Component { constructor (props) { super(props); bindAll(this, [ 'handleSelectCostume', 'handleDeleteCostume', 'handleDuplicateCostume', 'handleNewBlankCostume', 'handleSurpriseCostume', 'handleSurpriseBackdrop', 'handleFileUploadClick', 'handleCostumeUpload', 'setFileInput' ]); const { editingTarget, sprites, stage } = props; const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage; if (target && target.currentCostume) { this.state = {selectedCostumeIndex: target.currentCostume}; } else { this.state = {selectedCostumeIndex: 0}; } } componentWillReceiveProps (nextProps) { const { editingTarget, sprites, stage } = nextProps; const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage; if (!target || !target.costumes) { return; } if (this.props.editingTarget === editingTarget) { // If costumes have been added or removed, change costumes to the editing target's // current costume. const oldTarget = this.props.sprites[editingTarget] ? this.props.sprites[editingTarget] : this.props.stage; // @todo: Find and switch to the index of the costume that is new. This is blocked by // https://github.com/LLK/scratch-vm/issues/967 // Right now, you can land on the wrong costume if a costume changing script is running. if (oldTarget.costumeCount !== target.costumeCount) { this.setState({selectedCostumeIndex: target.currentCostume}); } } else { // If switching editing targets, update the costume index this.setState({selectedCostumeIndex: target.currentCostume}); } } handleSelectCostume (costumeIndex) { this.props.vm.editingTarget.setCostume(costumeIndex); this.setState({selectedCostumeIndex: costumeIndex}); } handleDeleteCostume (costumeIndex) { this.props.vm.deleteCostume(costumeIndex); } handleDuplicateCostume (costumeIndex) { this.props.vm.duplicateCostume(costumeIndex); } handleNewBlankCostume () { const emptyItem = costumeLibraryContent.find(item => ( item.name === 'Empty' )); const name = this.props.vm.editingTarget.isStage ? `backdrop1` : `costume1`; const vmCostume = { name: name, 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); } handleSurpriseCostume () { const item = costumeLibraryContent[Math.floor(Math.random() * costumeLibraryContent.length)]; const vmCostume = { name: item.name, 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); } handleSurpriseBackdrop () { const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; const vmCostume = { name: item.name, 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); } 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; // Cache the image in storage const costumeBuffer = reader.result; 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 { costumeFormat = storage.DataFormat.PNG; assetType = storage.AssetType.ImageBitmap; } const md5 = storage.builtinHelper.cache( assetType, costumeFormat, new Uint8Array(costumeBuffer)); const md5Ext = `${md5}.${costumeFormat}`; const vmCostume = { name: 'costume1', dataFormat: costumeFormat, md5: `${md5Ext}` }; this.props.vm.addCostume(md5Ext, vmCostume); }; if (thisFileInput.files) { thisFile = thisFileInput.files[0]; reader.readAsArrayBuffer(thisFile); } } handleFileUploadClick () { this.fileInput.click(); } setFileInput (input) { this.fileInput = input; } formatCostumeDetails (size) { // Round up width and height for scratch-flash compatibility // https://github.com/LLK/scratch-flash/blob/9fbac92ef3d09ceca0c0782f8a08deaa79e4df69/src/ui/media/MediaInfo.as#L224-L237 return `${Math.ceil(size[0])} x ${Math.ceil(size[1])}`; } render () { const { intl, onNewLibraryBackdropClick, onNewLibraryCostumeClick, backdropLibraryVisible, costumeLibraryVisible, onRequestCloseBackdropLibrary, onRequestCloseCostumeLibrary, editingTarget, sprites, stage, vm } = this.props; const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage; if (!target) { return null; } const addLibraryMessage = target.isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg; const addSurpriseFunc = target.isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume; const addLibraryFunc = target.isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick; const addLibraryIcon = target.isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon; const costumeData = (target.costumes || []).map(costume => ({ name: costume.name, assetId: costume.assetId, details: costume.size ? this.formatCostumeDetails(costume.size) : null })); return ( <AssetPanel buttons={[ { title: intl.formatMessage(addLibraryMessage), img: addLibraryIcon, onClick: addLibraryFunc }, { title: intl.formatMessage(messages.addCameraCostumeMsg), img: cameraIcon }, { title: intl.formatMessage(messages.addFileCostumeMsg), img: fileUploadIcon, onClick: this.handleFileUploadClick, fileAccept: '.svg,', // .png, .jpg, .jpeg coming soon fileChange: this.handleCostumeUpload, fileInput: this.setFileInput }, { title: intl.formatMessage(messages.addSurpriseCostumeMsg), img: surpriseIcon, onClick: addSurpriseFunc }, { title: intl.formatMessage(messages.addBlankCostumeMsg), img: paintIcon, onClick: this.handleNewBlankCostume } ]} items={costumeData} selectedItemIndex={this.state.selectedCostumeIndex} onDeleteClick={target && target.costumes && target.costumes.length > 1 ? this.handleDeleteCostume : null} onDuplicateClick={this.handleDuplicateCostume} onItemClick={this.handleSelectCostume} > {target.costumes ? <PaintEditorWrapper selectedCostumeIndex={this.state.selectedCostumeIndex} /> : null } {costumeLibraryVisible ? ( <CostumeLibrary vm={vm} onRequestClose={onRequestCloseCostumeLibrary} /> ) : null} {backdropLibraryVisible ? ( <BackdropLibrary vm={vm} onRequestClose={onRequestCloseBackdropLibrary} /> ) : null} </AssetPanel> ); } } CostumeTab.propTypes = { backdropLibraryVisible: PropTypes.bool, costumeLibraryVisible: PropTypes.bool, editingTarget: PropTypes.string, intl: intlShape, onNewLibraryBackdropClick: PropTypes.func.isRequired, onNewLibraryCostumeClick: PropTypes.func.isRequired, onRequestCloseBackdropLibrary: PropTypes.func.isRequired, onRequestCloseCostumeLibrary: PropTypes.func.isRequired, sprites: PropTypes.shape({ id: PropTypes.shape({ costumes: PropTypes.arrayOf(PropTypes.shape({ url: PropTypes.string, name: PropTypes.string.isRequired, skinId: PropTypes.number })) }) }), stage: PropTypes.shape({ sounds: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string.isRequired })) }), vm: PropTypes.instanceOf(VM) }; const mapStateToProps = state => ({ editingTarget: state.targets.editingTarget, sprites: state.targets.sprites, stage: state.targets.stage, costumeLibraryVisible: state.modals.costumeLibrary, backdropLibraryVisible: state.modals.backdropLibrary }); const mapDispatchToProps = dispatch => ({ onNewLibraryBackdropClick: e => { e.preventDefault(); dispatch(openBackdropLibrary()); }, onNewLibraryCostumeClick: e => { e.preventDefault(); dispatch(openCostumeLibrary()); }, onRequestCloseBackdropLibrary: () => { dispatch(closeBackdropLibrary()); }, onRequestCloseCostumeLibrary: () => { dispatch(closeCostumeLibrary()); } }); export default injectIntl(connect( mapStateToProps, mapDispatchToProps )(CostumeTab));