diff --git a/package.json b/package.json index 41888d82b5e5fbebb8f9351850a995efb5eb155f..5a2fe29f397b79f2893c7ddffc320e38a340a033 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-modal": "1.7.2", "react-redux": "5.0.3", "react-style-proptype": "2.0.1", + "react-tabs": "0.8.2", "redux": "3.6.0", "redux-throttle": "0.1.1", "scratch-audio": "latest", diff --git a/src/components/asset-panel/asset-panel.css b/src/components/asset-panel/asset-panel.css new file mode 100644 index 0000000000000000000000000000000000000000..8ff5ca4cb3d41a9f37e87a15f0ef3b2f63a749f7 --- /dev/null +++ b/src/components/asset-panel/asset-panel.css @@ -0,0 +1,18 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; + +.wrapper { + display: flex; + flex-grow: 1; + border: 1px solid $ui-pane-border; + border-top-right-radius: $space; + background: $ui-pane-gray; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 0.85rem; +} + +.detail-area { + flex-grow: 1; + flex-shrink: 0; + border-left: 1px solid $ui-pane-border; +} diff --git a/src/components/asset-panel/asset-panel.jsx b/src/components/asset-panel/asset-panel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2d6ea710b0294da15989db524ee126ce405f4141 --- /dev/null +++ b/src/components/asset-panel/asset-panel.jsx @@ -0,0 +1,23 @@ +const React = require('react'); + +const Box = require('../box/box.jsx'); +const Selector = require('./selector.jsx'); +const styles = require('./asset-panel.css'); + +const AssetPanel = props => ( + <Box className={styles.wrapper}> + <Selector + className={styles.selector} + {...props} + /> + <Box className={styles.detailArea}> + {/* @todo editor area */} + </Box> + </Box> +); + +AssetPanel.propTypes = { + ...Selector.propTypes +}; + +module.exports = AssetPanel; diff --git a/src/components/target-pane/icon--sound-dark.svg b/src/components/asset-panel/icon--sound.svg similarity index 100% rename from src/components/target-pane/icon--sound-dark.svg rename to src/components/asset-panel/icon--sound.svg diff --git a/src/components/asset-panel/selector.css b/src/components/asset-panel/selector.css new file mode 100644 index 0000000000000000000000000000000000000000..8964c9b13923e9782544fabfaf090867f2c30ebe --- /dev/null +++ b/src/components/asset-panel/selector.css @@ -0,0 +1,38 @@ +/* Need to use a fixed height for the new container to make list scrollable */ +$new-height: 60px; + +.wrapper { + width: 200px; + position: relative; +} + +.new-item { + background: white; + border-bottom: 1px solid #ddd; + height: $new-height; + display: flex; + align-items: center; + padding-left: 1.75rem; + cursor: pointer; + font-size: 0.85rem; +} + +.list-area { + position: absolute; + width: 100%; + top: 60px; + height: calc(100% - $new-height); + overflow-y: scroll; +} + +.list-item { + width: 5rem; + min-height: 5rem; + margin: 1rem auto; +} + +.delete-button { + position: absolute; + top: 2px; + right: 2px; +} diff --git a/src/components/asset-panel/selector.jsx b/src/components/asset-panel/selector.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ef92e7696821d42b380d9a42e301b46fd509cacc --- /dev/null +++ b/src/components/asset-panel/selector.jsx @@ -0,0 +1,56 @@ +const React = require('react'); + +const SpriteSelectorItem = require('../../containers/sprite-selector-item.jsx'); + +const Box = require('../box/box.jsx'); +const styles = require('./selector.css'); + +const Selector = props => { + const { + items, + newText, + selectedItemIndex, + onDeleteClick, + onItemClick, + onNewClick + } = props; + + return ( + <Box className={styles.wrapper}> + <Box + className={styles.newItem} + onClick={onNewClick} + > + {newText} + </Box> + <Box className={styles.listArea}> + {items.map((item, index) => ( + <SpriteSelectorItem + className={styles.listItem} + costumeURL={item.image} + id={index} + key={`asset-${index}`} + name={item.name} + selected={index === selectedItemIndex} + onClick={onItemClick} + onDeleteButtonClick={onDeleteClick} + /> + ))} + </Box> + </Box> + ); +}; + +Selector.propTypes = { + items: React.PropTypes.arrayOf(React.PropTypes.shape({ + image: React.PropTypes.string, + name: React.PropTypes.string + })), + newText: React.PropTypes.string, + onDeleteClick: React.PropTypes.func, + onItemClick: React.PropTypes.func, + onNewClick: React.PropTypes.func, + selectedItemIndex: React.PropTypes.number +}; + +module.exports = Selector; diff --git a/src/components/blocks/blocks.css b/src/components/blocks/blocks.css index 137a8f88664dc0851555d389cdc6c1981f8775ed..80689e48c3d9cafbc2f7882dac12a17ffd5d6853 100644 --- a/src/components/blocks/blocks.css +++ b/src/components/blocks/blocks.css @@ -1,4 +1,7 @@ -$border-style: 1px solid #dbdbdb; +@import "../../css/units.css"; +@import "../../css/colors.css"; + +$border-style: 1px solid $ui-pane-border; .blocks :global(.injectionDiv){ position: absolute; @@ -7,12 +10,7 @@ $border-style: 1px solid #dbdbdb; bottom: 0; left: 0; border: $border-style; - - /* - @todo: using _space doesn't compute to the right amount? - Related to `scratch-blocks` - */ - border-top-right-radius: 0.75rem; + border-top-right-radius: $space; } .blocks :global(.blocklyMainBackground) { diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index e587122a7c7bdc8131b7e9fbe99de0df03fdbfdb..e2fe4db4333437657c6c3009fdde3c8df478b158 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -21,24 +21,71 @@ height: 100%; } -.blocks-wrapper { - /* - scratch-blocks is based on absolute positioning, which injects - inside this element and becomes the child - */ +.editor-wrapper { + flex-basis: 600px; + flex-grow: 1; + flex-shrink: 0; position: relative; - flex-basis: 600px; + display: flex; + flex-direction: column; +} + +.tab-list { + height: $stage-menu-height; + width: 250px; /* Match width of the toolbox */ + display: flex; + align-items: flex-end; + flex-shrink: 0; + + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 500; + font-size: 0.80rem; + + /* Overrides for react-tabs styling */ + margin: 0 !important; + border-bottom: 0 !important; +} + +.tab-list .tab { + flex-grow: 1; + height: 80%; + margin-left: 1px; + + border-radius: $space $space 0 0; + border: none; + + background-color: #F6F8FA; + color: #9AA1B5; + + display: flex; + justify-content: center; + align-items: center; +} + + +.tab-list .tab[aria-selected="true"] { + color: #40B9F5; +} + +.tabs { + position: relative; flex-grow: 1; flex-shrink: 0; + display: flex; + flex-direction: column; +} - /* - Normally we'd use padding, but the absolute positioning ignores it, - so use margin instead. Temporary, until tabs are inserted. - */ - margin-top: $stage-menu-height; +.tab-panel { + position: relative; + flex-grow: 1; + flex-shrink: 0; + display: flex; +} - background: #e8edf1; +.blocks-wrapper { + flex-grow: 1; + position: relative; } .stage-and-target-wrapper { @@ -66,7 +113,7 @@ padding-right: $space; /* Hides negative space between edge of rounded corners + container, when selected */ - user-select: none; + user-select: none; } .target-wrapper { diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 36dbec0e2a0e77c04b9b9391c6d18637d238a1d3..0a7c2704dd892d66cd859287cf049cac0ba240d3 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -1,12 +1,14 @@ const React = require('react'); const VM = require('scratch-vm'); - const Blocks = require('../../containers/blocks.jsx'); +const CostumeTab = require('../../containers/costume-tab.jsx'); const GreenFlag = require('../../containers/green-flag.jsx'); const TargetPane = require('../../containers/target-pane.jsx'); +const SoundTab = require('../../containers/sound-tab.jsx'); const Stage = require('../../containers/stage.jsx'); const StopAll = require('../../containers/stop-all.jsx'); const MenuBar = require('../menu-bar/menu-bar.jsx'); +const {Tab, Tabs, TabList, TabPanel} = require('react-tabs'); const Box = require('../box/box.jsx'); const styles = require('./gui.css'); @@ -25,6 +27,14 @@ const GUIComponent = props => { </Box> ); } + + // @todo hack to resize blockly manually in case resize happened while hidden + const handleTabSelect = tabIndex => { + if (tabIndex === 0) { + setTimeout(() => window.dispatchEvent(new Event('resize'))); + } + }; + return ( <Box className={styles.pageWrapper} @@ -33,14 +43,35 @@ const GUIComponent = props => { <MenuBar /> <Box className={styles.bodyWrapper}> <Box className={styles.flexWrapper}> - <Box className={styles.blocksWrapper}> - <Blocks - grow={1} - options={{ - media: `${basePath}static/blocks-media/` - }} - vm={vm} - /> + <Box className={styles.editorWrapper}> + <Tabs + className={styles.tabs} + forceRenderTabPanel={true} // eslint-disable-line react/jsx-boolean-value + onSelect={handleTabSelect} + > + <TabList className={styles.tabList}> + <Tab className={styles.tab}>Scripts</Tab> + <Tab className={styles.tab}>Costumes</Tab> + <Tab className={styles.tab}>Sounds</Tab> + </TabList> + <TabPanel className={styles.tabPanel}> + <Box className={styles.blocksWrapper}> + <Blocks + grow={1} + options={{ + media: `${basePath}static/blocks-media/` + }} + vm={vm} + /> + </Box> + </TabPanel> + <TabPanel className={styles.tabPanel}> + <CostumeTab vm={vm} /> + </TabPanel> + <TabPanel className={styles.tabPanel}> + <SoundTab vm={vm} /> + </TabPanel> + </Tabs> </Box> <Box className={styles.stageAndTargetWrapper} > @@ -48,7 +79,7 @@ const GUIComponent = props => { <GreenFlag vm={vm} /> <StopAll vm={vm} /> </Box> - + <Box className={styles.stageWrapper} > <Stage shrink={0} diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index e4e86e4de306d82d521d101a87590519cc4ab587..d8b3ec3df3776fb47f5cad516549ccd05f8f2ec2 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -162,7 +162,6 @@ TargetPane.propTypes = { onChangeSpriteY: React.PropTypes.func, onDeleteSprite: React.PropTypes.func, onNewBackdropClick: React.PropTypes.func, - onNewSoundClick: React.PropTypes.func, onNewSpriteClick: React.PropTypes.func, onRequestCloseBackdropLibrary: React.PropTypes.func, onRequestCloseCostumeLibrary: React.PropTypes.func, diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx new file mode 100644 index 0000000000000000000000000000000000000000..539e364307967c5b662a244120987532ac0abdca --- /dev/null +++ b/src/containers/costume-tab.jsx @@ -0,0 +1,97 @@ +const React = require('react'); +const bindAll = require('lodash.bindall'); + +const VM = require('scratch-vm'); + +const AssetPanel = require('../components/asset-panel/asset-panel.jsx'); + +const {connect} = require('react-redux'); + +const { + openCostumeLibrary +} = require('../reducers/modals'); + +class CostumeTab extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleSelectCostume', + 'handleDeleteCostume' + ]); + this.state = {selectedCostumeIndex: 0}; + } + + handleSelectCostume (costumeIndex) { + this.setState({selectedCostumeIndex: costumeIndex}); + } + + handleDeleteCostume (costumeIndex) { + // @todo the VM should handle all of this logic + const {editingTarget} = this.props.vm; + + if (costumeIndex === editingTarget.currentCostume) { + editingTarget.setCostume(costumeIndex - 1); + } + + editingTarget.sprite.costumes = editingTarget.sprite.costumes + .slice(0, costumeIndex) + .concat(editingTarget.sprite.costumes.slice(costumeIndex + 1)); + this.props.vm.emitTargetsUpdate(); + // @todo not sure if this is getting redrawn correctly + this.props.vm.runtime.requestRedraw(); + + this.setState({ + selectedCostumeIndex: this.state.selectedCostumeIndex % editingTarget.sprite.costumes.length + }); + } + + render () { + const { + vm, + onNewCostumeClick + } = this.props; + + const costumes = vm.editingTarget ? vm.editingTarget.sprite.costumes.map(costume => ( + { + image: costume.skin, + name: costume.name + } + )) : []; + + const addText = vm.editingTarget && vm.editingTarget.isStage ? 'Add Backdrop' : 'Add Costume'; + + return ( + <AssetPanel + items={costumes} + newText={addText} + selectedItemIndex={this.state.selectedCostumeIndex} + onDeleteClick={this.handleDeleteCostume} + onItemClick={this.handleSelectCostume} + onNewClick={onNewCostumeClick} + /> + ); + } +} + +CostumeTab.propTypes = { + ...AssetPanel.propTypes, + vm: React.PropTypes.instanceOf(VM) +}; + +const mapStateToProps = state => ({ + editingTarget: state.targets.editingTarget, + sprites: state.targets.sprites, + costumeLibraryVisible: state.modals.costumeLibrary +}); + +const mapDispatchToProps = dispatch => ({ + onNewCostumeClick: e => { + e.preventDefault(); + dispatch(openCostumeLibrary()); + } +}); + +module.exports = connect( + mapStateToProps, + mapDispatchToProps +)(CostumeTab); diff --git a/src/containers/sound-library.jsx b/src/containers/sound-library.jsx index 010f8e23e5ddcd9a322462e01ee40b05d5805ed6..4db5ee3ea9ee2f9fce60b5df5edb1d739744420f 100644 --- a/src/containers/sound-library.jsx +++ b/src/containers/sound-library.jsx @@ -4,7 +4,8 @@ const VM = require('scratch-vm'); const AudioEngine = require('scratch-audio'); const LibaryComponent = require('../components/library/library.jsx'); -const soundIcon = require('../components/target-pane/icon--sound-dark.svg'); + +const soundIcon = require('../components/asset-panel/icon--sound.svg'); const soundLibraryContent = require('../lib/libraries/sounds.json'); diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx new file mode 100644 index 0000000000000000000000000000000000000000..960e93ffa8de55a5d7283abd281635ac7559424e --- /dev/null +++ b/src/containers/sound-tab.jsx @@ -0,0 +1,93 @@ +const React = require('react'); +const bindAll = require('lodash.bindall'); + +const VM = require('scratch-vm'); + +const AssetPanel = require('../components/asset-panel/asset-panel.jsx'); +const soundIcon = require('../components/asset-panel/icon--sound.svg'); + +const {connect} = require('react-redux'); + +const { + openSoundLibrary +} = require('../reducers/modals'); + +class SoundTab extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleSelectSound', + 'handleDeleteSound' + ]); + this.state = {selectedSoundIndex: 0}; + } + + handleSelectSound (soundIndex) { + const sound = this.props.vm.editingTarget.sprite.sounds[soundIndex]; + this.props.vm.editingTarget.audioPlayer.playSound(sound.md5); + this.setState({selectedSoundIndex: soundIndex}); + } + + handleDeleteSound (soundIndex) { + // @todo the VM should handle all of this logic + const {editingTarget} = this.props.vm; + editingTarget.sprite.sounds = editingTarget.sprite.sounds + .slice(0, soundIndex) + .concat(editingTarget.sprite.sounds.slice(soundIndex + 1)); + this.props.vm.emitTargetsUpdate(); + this.props.vm.runtime.requestRedraw(); + + this.setState({ + selectedSoundIndex: this.state.selectedSoundIndex % editingTarget.sprite.sounds.length + }); + } + + render () { + const { + vm, + onNewSoundClick + } = this.props; + + const sounds = vm.editingTarget ? vm.editingTarget.sprite.sounds.map(sound => ( + { + image: soundIcon, + name: sound.name + } + )) : []; + + + return ( + <AssetPanel + items={sounds} + newText={'Add Sound'} + selectedItemIndex={this.state.selectedSoundIndex} + onDeleteClick={this.handleDeleteSound} + onItemClick={this.handleSelectSound} + onNewClick={onNewSoundClick} + /> + ); + } +} + +SoundTab.propTypes = { + ...AssetPanel.propTypes, + vm: React.PropTypes.instanceOf(VM) +}; + +const mapStateToProps = state => ({ + editingTarget: state.targets.editingTarget, + sprites: state.targets.sprites, + soundLibraryVisible: state.modals.soundLibrary +}); + +const mapDispatchToProps = dispatch => ({ + onNewSoundClick: e => { + e.preventDefault(); + dispatch(openSoundLibrary()); + } +}); + +module.exports = connect( + mapStateToProps, + mapDispatchToProps +)(SoundTab); diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx index a9e538f30d4f11a3d134d3ef6348dc4ed9f2b554..c2620071585b882c1164284a306ff850bbd8ca8a 100644 --- a/src/containers/sprite-selector-item.jsx +++ b/src/containers/sprite-selector-item.jsx @@ -40,7 +40,7 @@ class SpriteSelectorItem extends React.Component { SpriteSelectorItem.propTypes = { costumeURL: React.PropTypes.string, - id: React.PropTypes.string, + id: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]), name: React.PropTypes.string, onClick: React.PropTypes.func, onDeleteButtonClick: React.PropTypes.func, diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index 9a66cbb779265d2c13c35bc3e3302a7932a01c6e..41feed8e5215c546e464c9e0d073871a8dc302c8 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -5,7 +5,6 @@ const {connect} = require('react-redux'); const { openBackdropLibrary, - openSoundLibrary, openSpriteLibrary, closeBackdropLibrary, closeCostumeLibrary, @@ -99,10 +98,6 @@ const mapDispatchToProps = dispatch => ({ e.preventDefault(); dispatch(openBackdropLibrary()); }, - onNewSoundClick: e => { - e.preventDefault(); - dispatch(openSoundLibrary()); - }, onNewSpriteClick: e => { e.preventDefault(); dispatch(openSpriteLibrary()); diff --git a/src/css/colors.css b/src/css/colors.css index 03bf83a7a0e0ab7ceda4cf04a5c6030aabe0dc5d..c223ea39e92a23c741da43d08ce3a59b691eb356 100644 --- a/src/css/colors.css +++ b/src/css/colors.css @@ -1,2 +1,3 @@ -$ui-pane-gray: #f9f9f9; -$blue: #4c97ff; +$ui-pane-border: #D9D9D9; +$ui-pane-gray: #F9F9F9; +$blue: #4C97FF;