Spaces:
Runtime error
Runtime error
| 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) | |
| ], { 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 ( | |
| <AssetPanel | |
| buttons={[ | |
| { | |
| title: intl.formatMessage(addLibraryMessage), | |
| img: addLibraryIcon, | |
| onClick: addLibraryFunc | |
| }, | |
| { | |
| title: intl.formatMessage(addFileMessage), | |
| img: fileUploadIcon, | |
| onClick: this.handleFileUploadClick, | |
| fileAccept: '.svg, .png, .bmp, .jpg, .jpeg, .jfif, .webp, .gif', | |
| fileChange: this.handleCostumeUpload, | |
| fileInput: this.setFileInput, | |
| fileMultiple: true | |
| }, | |
| { | |
| title: intl.formatMessage(messages.addSurpriseCostumeMsg), | |
| img: surpriseIcon, | |
| onClick: addSurpriseFunc | |
| }, | |
| { | |
| title: intl.formatMessage(messages.addBlankCostumeMsg), | |
| img: paintIcon, | |
| onClick: this.handleNewBlankCostume | |
| }, | |
| { | |
| title: intl.formatMessage(addLibraryMessage), | |
| img: searchIcon, | |
| onClick: addLibraryFunc | |
| } | |
| ]} | |
| dragType={DragConstants.COSTUME} | |
| isRtl={isRtl} | |
| items={costumeData} | |
| selectedItemIndex={this.state.selectedCostumeIndex} | |
| onDeleteClick={target && target.costumes && target.costumes.length > 1 ? | |
| this.handleDeleteCostume : null} | |
| onDrop={this.handleDrop} | |
| onDuplicateClick={this.handleDuplicateCostume} | |
| onExportClick={this.handleExportCostume} | |
| onItemClick={this.handleSelectCostume} | |
| > | |
| {target.costumes ? | |
| <PaintEditorWrapper | |
| selectedCostumeIndex={this.state.selectedCostumeIndex} | |
| isDark={this.props.isDark} | |
| /> : | |
| null | |
| } | |
| </AssetPanel> | |
| ); | |
| } | |
| } | |
| 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)) | |
| ); | |