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 { connect } from 'react-redux'; import { handleFileUpload, costumeUpload } from '../lib/file-uploader.js'; import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; import DragConstants from '../lib/drag-constants'; import { emptyCostume } from '../lib/empty-assets'; import sharedMessages from '../lib/shared-messages'; import downloadBlob from '../lib/download-blob'; import { openCostumeLibrary, openBackdropLibrary } from '../reducers/modals'; import { activateTab, SOUNDS_TAB_INDEX } from '../reducers/editor-tab'; import { setRestore } from '../reducers/restore-deletion'; import { showStandardAlert, closeAlertWithId } from '../reducers/alerts'; 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 surpriseIcon from '../components/action-menu/icon--surprise.svg'; import searchIcon from '../components/action-menu/icon--search.svg'; import { getCostumeLibrary, getBackdropLibrary } from '../lib/libraries/tw-async-libraries'; let 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' }, addFileBackdropMsg: { defaultMessage: 'Upload Backdrop', description: 'Button to add a backdrop by uploading a file in the editor tab', id: 'gui.costumeTab.addFileBackdrop' }, addFileCostumeMsg: { defaultMessage: 'Upload Costume', description: 'Button to add a costume by uploading a file in the editor tab', id: 'gui.costumeTab.addFileCostume' } }); messages = { ...messages, ...sharedMessages }; class CostumeTab extends React.Component { constructor(props) { super(props); bindAll(this, [ 'handleSelectCostume', 'handleDeleteCostume', 'handleDuplicateCostume', 'handleExportCostume', 'handleNewCostume', 'handleNewBlankCostume', 'handleSurpriseCostume', 'handleSurpriseBackdrop', 'handleFileUploadClick', 'handleCostumeUpload', 'handleDrop', '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) { const restoreCostumeFun = this.props.vm.deleteCostume(costumeIndex); this.props.dispatchUpdateRestore({ restoreFun: restoreCostumeFun, deletedItem: 'Costume' }); } handleDuplicateCostume(costumeIndex) { this.props.vm.duplicateCostume(costumeIndex); } handleExportCostume(costumeIndex) { const item = this.props.vm.editingTarget.sprite.costumes[costumeIndex]; const blob = new Blob([ this.props.vm.getExportedCostume(item, true) ], { type: item.asset.assetType.contentType }); downloadBlob(`${item.name}.${item.asset.dataFormat}`, blob); } handleNewCostume(costume, fromCostumeLibrary, targetId) { const costumes = Array.isArray(costume) ? costume : [costume]; return Promise.all(costumes.map(c => { if (fromCostumeLibrary) { return this.props.vm.addCostumeFromLibrary(c.md5, c); } // If targetId is falsy, VM should default it to editingTarget.id // However, targetId should be provided to prevent #5876, // if making new costume takes a while return this.props.vm.addCostume(c.md5, c, targetId); })); } handleNewBlankCostume() { const name = this.props.vm.editingTarget.isStage ? this.props.intl.formatMessage(messages.backdrop, { index: 1 }) : this.props.intl.formatMessage(messages.costume, { index: 1 }); this.handleNewCostume(emptyCostume(name)); } async handleSurpriseCostume() { const costumeLibraryContent = await getCostumeLibrary(); const item = costumeLibraryContent[Math.floor(Math.random() * costumeLibraryContent.length)]; const vmCostume = { name: item.name, md5: item.md5ext, rotationCenterX: item.rotationCenterX, rotationCenterY: item.rotationCenterY, bitmapResolution: item.bitmapResolution, skinId: null }; if (item.fromPenguinModLibrary) { vmCostume.fromPenguinModLibrary = true; vmCostume.libraryId = item.libraryFilePage; vmCostume.dataFormat = item.dataFormat; }; this.handleNewCostume(vmCostume, true /* fromCostumeLibrary */); } async handleSurpriseBackdrop() { const backdropLibraryContent = await getBackdropLibrary(); const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; const vmCostume = { name: item.name, md5: item.md5ext, rotationCenterX: item.rotationCenterX, rotationCenterY: item.rotationCenterY, bitmapResolution: item.bitmapResolution, skinId: null }; if (item.fromPenguinModLibrary) { vmCostume.fromPenguinModLibrary = true; vmCostume.libraryId = item.libraryFilePage; vmCostume.dataFormat = item.dataFormat; }; this.handleNewCostume(vmCostume); } handleCostumeUpload(e) { const vm = this.props.vm; const targetId = this.props.vm.editingTarget.id; this.props.onShowImporting(); handleFileUpload(e.target, (buffer, fileType, fileName, fileIndex, fileCount) => { costumeUpload(buffer, fileType, vm, vmCostumes => { vmCostumes.forEach((costume, i) => { costume.name = `${fileName}${i ? i + 1 : ''}`; }); this.handleNewCostume(vmCostumes, false, targetId).then(() => { if (fileIndex === fileCount - 1) { this.props.onCloseImporting(); } }); }, this.props.onCloseImporting); }, this.props.onCloseImporting); } handleFileUploadClick() { this.fileInput.click(); } handleDrop(dropInfo) { if (dropInfo.dragType === DragConstants.COSTUME) { const sprite = this.props.vm.editingTarget.sprite; const activeCostume = sprite.costumes[this.state.selectedCostumeIndex]; this.props.vm.reorderCostume(this.props.vm.editingTarget.id, dropInfo.index, dropInfo.newIndex); this.setState({ selectedCostumeIndex: sprite.costumes.indexOf(activeCostume) }); } else if (dropInfo.dragType === DragConstants.BACKPACK_COSTUME) { this.props.vm.addCostume(dropInfo.payload.body, { name: dropInfo.payload.name }); } else if (dropInfo.dragType === DragConstants.BACKPACK_SOUND) { this.props.onActivateSoundsTab(); this.props.vm.addSound({ md5: dropInfo.payload.body, name: dropInfo.payload.name }); } } setFileInput(input) { this.fileInput = input; } formatCostumeDetails(size, optResolution) { // If no resolution is given, assume that the costume is an SVG const resolution = optResolution ? optResolution : 1; // Convert size to stage units by dividing by resolution // 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] / resolution)} x ${Math.ceil(size[1] / resolution)}`; } render() { const { dispatchUpdateRestore, // eslint-disable-line no-unused-vars intl, isRtl, onNewLibraryBackdropClick, onNewLibraryCostumeClick, vm } = this.props; if (!vm.editingTarget) { return null; } const isStage = vm.editingTarget.isStage; const target = vm.editingTarget.sprite; const addLibraryMessage = isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg; const addFileMessage = isStage ? messages.addFileBackdropMsg : messages.addFileCostumeMsg; const addSurpriseFunc = isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume; const addLibraryFunc = isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick; const addLibraryIcon = isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon; const costumeData = target.costumes ? target.costumes.map(costume => ({ name: costume.name, asset: costume.asset, details: costume.size ? this.formatCostumeDetails(costume.size, costume.bitmapResolution) : null, dragPayload: costume })) : []; return ( 1 ? this.handleDeleteCostume : null} onDrop={this.handleDrop} onDuplicateClick={this.handleDuplicateCostume} onExportClick={this.handleExportCostume} onItemClick={this.handleSelectCostume} > {target.costumes ? : null } ); } } CostumeTab.propTypes = { dispatchUpdateRestore: PropTypes.func, editingTarget: PropTypes.string, intl: intlShape, isDark: PropTypes.bool, isRtl: PropTypes.bool, onActivateSoundsTab: PropTypes.func.isRequired, onCloseImporting: PropTypes.func.isRequired, onNewLibraryBackdropClick: PropTypes.func.isRequired, onNewLibraryCostumeClick: PropTypes.func.isRequired, onShowImporting: 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.scratchGui.targets.editingTarget, isRtl: state.locales.isRtl, sprites: state.scratchGui.targets.sprites, stage: state.scratchGui.targets.stage, dragging: state.scratchGui.assetDrag.dragging }); const mapDispatchToProps = dispatch => ({ onActivateSoundsTab: () => dispatch(activateTab(SOUNDS_TAB_INDEX)), onNewLibraryBackdropClick: e => { e.preventDefault(); dispatch(openBackdropLibrary()); }, onNewLibraryCostumeClick: e => { e.preventDefault(); dispatch(openCostumeLibrary()); }, dispatchUpdateRestore: restoreState => { dispatch(setRestore(restoreState)); }, onCloseImporting: () => dispatch(closeAlertWithId('importingAsset')), onShowImporting: () => dispatch(showStandardAlert('importingAsset')) }); export default errorBoundaryHOC('Costume Tab')( injectIntl(connect( mapStateToProps, mapDispatchToProps )(CostumeTab)) );