Spaces:
Running
Running
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)) | |
); | |