diff --git a/package.json b/package.json index 54d7f7f2f954ce07c745b544613aba57b2d3ad2d..eeabbe19feb400c151450533a50b8e8ddaa34392 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "gh-pages": "^1.0.0", "html-webpack-plugin": "2.28.0", "lodash.bindall": "4.4.0", + "lodash.debounce": "4.0.8", "lodash.defaultsdeep": "4.6.0", "lodash.isequal": "4.5.0", "lodash.omit": "4.5.0", diff --git a/src/components/close-button/close-button.css b/src/components/close-button/close-button.css index ce9cc99ac96ba61f8b30bcdcd815de1dbfe63ded..66b79d8a6fd84948e29d846277eb2ed3577155c0 100644 --- a/src/components/close-button/close-button.css +++ b/src/components/close-button/close-button.css @@ -6,40 +6,46 @@ align-items: center; justify-content: center; - color: gray; - background-color: $blue; - + overflow: hidden; /* Mask the icon animation */ + background-color: rgba(0, 0, 0, 0.1); border-radius: 50%; - border-color: #dbdbdb; - border-style: solid; - + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - cursor: pointer; user-select: none; - transition: all 0.15s ease-out; /* @todo: standardize with var */ + cursor: pointer; + transition: all 0.15s ease-out; +} + +.close-button.large:hover { + transform: scale(1.1, 1.1); + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.15); } .small { width: 1rem; height: 1rem; - border-width: 1px; + color: #FFF; + background-color: $blue; } .large { - width: 2.75rem; - height: 2.75rem; - border-width: 2px; + width: 1.75rem; + height: 1.75rem; } -.close-button:hover { - transform: scale(1.1, 1.1); -} - -/* Same icon as Sprite Selector Add button, but rotated. - @todo: reuse? -*/ .close-icon { + position: relative; + margin: 0.25rem; + user-select: none; transform-origin: 50%; transform: rotate(45deg); +} + +.small .close-icon { width: 40%; } + +.large .close-icon { + width: 0.75rem; + height: 0.75rem; +} diff --git a/src/components/close-button/close-button.jsx b/src/components/close-button/close-button.jsx index f7c7fc141f28a93bd2833c67aaea1651e6e6d652..c2d78c79620b207c36e934ad16edf8106482cafe 100644 --- a/src/components/close-button/close-button.jsx +++ b/src/components/close-button/close-button.jsx @@ -11,8 +11,8 @@ const CloseButton = props => ( styles.closeButton, props.className, { - [styles.large]: props.size === CloseButton.SIZE_LARGE, - [styles.small]: props.size === CloseButton.SIZE_SMALL + [styles.small]: props.size === CloseButton.SIZE_SMALL, + [styles.large]: props.size === CloseButton.SIZE_LARGE } )} onClick={props.onClick} @@ -24,13 +24,13 @@ const CloseButton = props => ( </div> ); -CloseButton.SIZE_LARGE = 'large'; CloseButton.SIZE_SMALL = 'small'; +CloseButton.SIZE_LARGE = 'large'; CloseButton.propTypes = { className: PropTypes.string, onClick: PropTypes.func.isRequired, - size: PropTypes.oneOf([CloseButton.SIZE_LARGE, CloseButton.SIZE_SMALL]) + size: PropTypes.oneOf([CloseButton.SIZE_SMALL, CloseButton.SIZE_LARGE]) }; CloseButton.defaultProps = { diff --git a/src/components/filter/filter.css b/src/components/filter/filter.css new file mode 100644 index 0000000000000000000000000000000000000000..fa8e072e10bc8cea39150526b1d0184f46b9fa75 --- /dev/null +++ b/src/components/filter/filter.css @@ -0,0 +1,103 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; + +.filter { + display: flex; + flex-direction: row; + align-items: center; + /* + Should we use width 100% instead, for cases when component's + parent is not a flexbox container? + */ + flex-grow: 1; + + padding: 0.3rem 0.5rem; + background: rgba(0, 0, 0, 0.10); + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 10rem; + user-select: none; +} + +.filter:hover { + background: rgba(0, 0, 0, 0.15); +} + +.filter-icon { + height: 0.9rem; + width: 0.9rem; + margin: 0 0.5rem 0 0.75rem; +} + +/* + Hidden state +*/ +.x-icon-wrapper { + opacity: 0; + + display: flex; + justify-content: center; + align-items: center; + + overflow: hidden; /* Mask the icon animation */ + height: 1.25rem; + width: 1.25rem; + margin: 0 0.25rem 0 0.5rem; /* @todo: move to parent to make component*/ + + border-radius: 50%; + pointer-events: none; + cursor: default; + transition: opacity 0.05s linear; +} + +/* + Shown state +*/ +.filter.is-active .x-icon-wrapper { + pointer-events: auto; + cursor: pointer; + opacity: 1; + transition: opacity 0.05s linear; +} + +.filter.is-active .x-icon-wrapper:hover { + transform: scale(1.2, 1.2); +} + +/* + Hidden state +*/ +.x-icon { + position: relative; + margin: 0.25rem; + user-select: none; + transform: translateX(0.5rem); + transition: transform 0.085s cubic-bezier(0.78, 1, 1, 1); +} + +/* + Shown state +*/ +.filter.is-active .x-icon-wrapper .x-icon { + transform: translateX(0); +} + +.filter-input { + flex-grow: 1; + line-height: 1.25rem; + background-color: transparent; + -webkit-appearance: none; + outline: none; + border: 0; + color: white; + font-size: 0.75rem; + letter-spacing: 0.15px; + cursor: text; +} + +.filter-input::placeholder { + opacity: 0.75; + padding: 0 0 0 0.25rem; + color: white; + font-size: 0.75rem; + letter-spacing: 0.15px; +} diff --git a/src/components/filter/filter.jsx b/src/components/filter/filter.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6e140eb80b3c4bd6bd0f8bad350f447cb7865b36 --- /dev/null +++ b/src/components/filter/filter.jsx @@ -0,0 +1,57 @@ +const classNames = require('classnames'); +const PropTypes = require('prop-types'); +const React = require('react'); + +const filterIcon = require('./icon--filter.svg'); +const xIcon = require('./icon--x.svg'); +const styles = require('./filter.css'); + +const FilterComponent = props => { + const { + onChange, + onClear, + placeholderText, + filterQuery + } = props; + return ( + <div + className={classNames({ + [styles.filter]: true, + [styles.isActive]: filterQuery.length > 0 + })} + > + <img + className={styles.filterIcon} + src={filterIcon} + /> + <input + autoFocus + className={styles.filterInput} + placeholder={placeholderText} + type="text" + value={filterQuery} + onChange={onChange} + /> + <div + className={styles.xIconWrapper} + onClick={onClear} + > + <img + className={styles.xIcon} + src={xIcon} + /> + </div> + </div> + ); +}; + +FilterComponent.propTypes = { + filterQuery: PropTypes.string, + onChange: PropTypes.func, + onClear: PropTypes.func, + placeholderText: PropTypes.string +}; +FilterComponent.defaultProps = { + placeholderText: 'what are you looking for?' +}; +module.exports = FilterComponent; diff --git a/src/components/filter/icon--filter.svg b/src/components/filter/icon--filter.svg new file mode 100644 index 0000000000000000000000000000000000000000..2a0c75fcb9ffc6aa604b66b0ce6320994e2b086a Binary files /dev/null and b/src/components/filter/icon--filter.svg differ diff --git a/src/components/filter/icon--x.svg b/src/components/filter/icon--x.svg new file mode 100644 index 0000000000000000000000000000000000000000..e78a2a8e24a8fe526004cd1ec4e1a2622dec76d2 Binary files /dev/null and b/src/components/filter/icon--x.svg differ diff --git a/src/components/library-item/library-item.css b/src/components/library-item/library-item.css index c702492f8939412c2222f49bf0d99daa5076a33f..12253a55202602010b71b2a98014d1ecbbe787df 100644 --- a/src/components/library-item/library-item.css +++ b/src/components/library-item/library-item.css @@ -6,8 +6,9 @@ align-items: center; justify-content: center; flex-basis: 160px; + max-width: 160px; height: 160px; - margin: calc($space / 2); + margin: $space; padding: 1rem 1rem 0 1rem; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; color: #575e75; @@ -23,12 +24,18 @@ .library-item:hover { border-width: 2px; border-color: #1dacf4; - transition: 0.1s ease-out; - transform: scale(1.02, 1.02); +} + +.library-item-image-container-wrapper { + height: 100px; + width: 100%; + position: relative; } .library-item-image-container { + position: absolute; height: 100px; + width: 100%; } .library-item-image { diff --git a/src/components/library-item/library-item.jsx b/src/components/library-item/library-item.jsx index 8e113c9be08af583a36272da486fe8afe31e0367..cdb1415a12db8370c0e8e5a66f7c6d732ba5073e 100644 --- a/src/components/library-item/library-item.jsx +++ b/src/components/library-item/library-item.jsx @@ -14,6 +14,9 @@ class LibraryItem extends React.Component { 'handleMouseLeave' ]); } + shouldComponentUpdate (nextProps) { + return this.props.iconURL !== nextProps.iconURL; + } handleClick (e) { this.props.onSelect(this.props.id); e.preventDefault(); @@ -32,11 +35,14 @@ class LibraryItem extends React.Component { onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} > - <Box className={styles.libraryItemImageContainer}> - <img - className={styles.libraryItemImage} - src={this.props.iconURL} - /> + {/* Layers of wrapping is to prevent layout thrashing on animation */} + <Box className={styles.libraryItemImageContainerWrapper}> + <Box className={styles.libraryItemImageContainer}> + <img + className={styles.libraryItemImage} + src={this.props.iconURL} + /> + </Box> </Box> <span className={styles.libraryItemName}>{this.props.name}</span> </Box> diff --git a/src/components/library/library.css b/src/components/library/library.css index b8147a1736f330d8c5c8cd3ec6ef9e63984bfc6f..73f615191d159177433d271193fb8b7b73f9f47c 100644 --- a/src/components/library/library.css +++ b/src/components/library/library.css @@ -1,24 +1,14 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; + .library-scroll-grid { display: flex; + justify-content: center; + align-content: flex-start; + background: $ui-pane-gray; flex-grow: 1; - justify-content: space-between; flex-wrap: wrap; - overflow-y: scroll; - height: calc(100% - 6rem); /* @todo: currently estimate, fix precision */ - - /* - Gives sprites a bit of room so they don't get cut off when they grow on hover - @todo: sync as a var, with the transform defined on .library-item:hover - */ - padding: 0.15rem; -} - -.modal-header { - width: 100%; - margin-bottom: 2rem; + overflow-y: auto; + height: calc(100% - $library-header-height); padding: 0.5rem; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 1.5rem; - font-weight: normal; - color: #8e8f95; } diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx index 71b29ff612ff1552eae7df150f2eaa9782a9120d..ad33743be1e025b8587db9c031df00f6b5378a5b 100644 --- a/src/components/library/library.jsx +++ b/src/components/library/library.jsx @@ -11,40 +11,58 @@ class LibraryComponent extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleSelect', + 'handleFilterChange', + 'handleFilterClear', 'handleMouseEnter', - 'handleMouseLeave' + 'handleMouseLeave', + 'handleSelect' ]); + this.state = { + selectedItem: null, + filterQuery: '' + }; } handleSelect (id) { this.props.onRequestClose(); - this.props.onItemSelected(this.props.data[id]); + this.props.onItemSelected(this.getFilteredData()[id]); } handleMouseEnter (id) { - if (this.props.onItemMouseEnter) this.props.onItemMouseEnter(this.props.data[id]); + if (this.props.onItemMouseEnter) this.props.onItemMouseEnter(this.getFilteredData()[id]); } handleMouseLeave (id) { - if (this.props.onItemMouseLeave) this.props.onItemMouseLeave(this.props.data[id]); + if (this.props.onItemMouseLeave) this.props.onItemMouseLeave(this.getFilteredData()[id]); + } + handleFilterChange (event) { + this.setState({filterQuery: event.target.value}); + } + handleFilterClear () { + this.setState({filterQuery: ''}); + } + getFilteredData () { + return this.props.data.filter(dataItem => + dataItem.name.toLowerCase().indexOf(this.state.filterQuery.toLowerCase()) !== -1); } render () { if (!this.props.visible) return null; return ( <ModalComponent contentLabel={this.props.title} + filterQuery={this.state.filterQuery} visible={this.props.visible} + onFilterChange={this.handleFilterChange} + onFilterClear={this.handleFilterClear} onRequestClose={this.props.onRequestClose} > - <h1 className={styles.modalHeader}>{this.props.title}</h1> <div className={styles.libraryScrollGrid}> - {this.props.data.map((dataItem, itemId) => { + {this.getFilteredData().map((dataItem, index) => { const scratchURL = dataItem.md5 ? `https://cdn.assets.scratch.mit.edu/internalapi/asset/${dataItem.md5}/get/` : dataItem.rawURL; return ( <LibraryItem iconURL={scratchURL} - id={itemId} - key={`item_${itemId}`} + id={index} + key={`item_${index}`} name={dataItem.name} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} @@ -61,6 +79,7 @@ class LibraryComponent extends React.Component { LibraryComponent.propTypes = { data: PropTypes.arrayOf( /* eslint-disable react/no-unused-prop-types, lines-around-comment */ + // An item in the library PropTypes.shape({ // @todo remove md5/rawURL prop from library, refactor to use storage md5: PropTypes.string, diff --git a/src/components/modal/modal.css b/src/components/modal/modal.css index 25a134f5e689d5d80dae6ca88fe6a7a693dccce3..a72a4f06dbc18c67a672a166b7fddb57fbe66a0f 100644 --- a/src/components/modal/modal.css +++ b/src/components/modal/modal.css @@ -11,32 +11,69 @@ background-color: rgba(0, 0, 0, .75); } -.modal-content { +/* @todo: extract to type: full ? */ +.full-modal-content { position: absolute; outline: none; - overflow-y: scroll; + overflow-y: auto; -webkit-overflow-scrolling: 'touch'; - border: 1px solid #ccc; - padding: 0; - top: 5%; - right: 5%; - bottom: 5%; - left: 5%; - border-radius: $space; user-select: none; + height: 100%; + width: 100%; + display: flex; } -.modal-children { - overflow: hidden; - height: 100%; - z-index: 0; - padding: 2rem; - background: $ui-pane-gray; +/* + Modal header has 3 items: + |filter title x| + + Use the same width for both side item containers, + so that title remains centered +*/ +$sides: 20rem; + +.header { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + height: $library-header-height; + + box-sizing: border-box; + width: 100%; + background-color: $blue; + + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 1rem; + font-weight: normal; } -.close-button { - position: absolute; - top: 1rem; - right: 1rem; - z-index: 2; +.header-item { + display: flex; + align-items: center; + padding: 1rem; + text-decoration: none; + color: white; + user-select: none; } + +.header-item-filter { + display: flex; + flex-basis: $sides; + justify-content: flex-start; +} + +.header-item-title { + flex-grow: 1; + flex-shrink: 0; + justify-content: center; + user-select: none; + letter-spacing: 0.4px; + cursor: default; +} + +.header-item-close { + flex-basis: $sides; + justify-content: flex-end; +} + diff --git a/src/components/modal/modal.jsx b/src/components/modal/modal.jsx index 3a3fc25db56a00488657e121ea6a41c076c3bfc6..2a743bd74447dae5cbf7465a906134551dfeafd0 100644 --- a/src/components/modal/modal.jsx +++ b/src/components/modal/modal.jsx @@ -1,9 +1,11 @@ +const classNames = require('classnames'); const PropTypes = require('prop-types'); const React = require('react'); const ReactModal = require('react-modal'); const Box = require('../box/box.jsx'); const CloseButton = require('../close-button/close-button.jsx'); +const Filter = require('../filter/filter.jsx'); const styles = require('./modal.css'); @@ -11,21 +13,44 @@ class ModalComponent extends React.Component { render () { return ( <ReactModal - className={styles.modalContent} + className={styles.fullModalContent} contentLabel={this.props.contentLabel} isOpen={this.props.visible} overlayClassName={styles.modalOverlay} ref={m => (this.modal = m)} - onRequestClose={this.props.onRequestClose} > - <CloseButton - className={styles.closeButton} - onClick={this.props.onRequestClose} - /> <Box - className={styles.modalChildren} direction="column" + grow={1} > + <div className={styles.header}> + <div className={classNames(styles.headerItem, styles.headerItemFilter)}> + <Filter + filterQuery={this.props.filterQuery} + onChange={this.props.onFilterChange} + onClear={this.props.onFilterClear} + /> + </div> + <div + className={classNames( + styles.headerItem, + styles.headerItemTitle + )} + > + {this.props.contentLabel} + </div> + <div + className={classNames( + styles.headerItem, + styles.headerItemClose + )} + > + <CloseButton + size={CloseButton.SIZE_LARGE} + onClick={this.props.onRequestClose} + /> + </div> + </div> {this.props.children} </Box> </ReactModal> @@ -36,7 +61,10 @@ class ModalComponent extends React.Component { ModalComponent.propTypes = { children: PropTypes.node, contentLabel: PropTypes.string.isRequired, - onRequestClose: PropTypes.func.isRequired, + filterQuery: PropTypes.string, + onFilterChange: PropTypes.func, + onFilterClear: PropTypes.func, + onRequestClose: PropTypes.func, visible: PropTypes.bool.isRequired }; diff --git a/src/components/monitor/monitor.jsx b/src/components/monitor/monitor.jsx index edd0fb0439a39ab0ccc080c67aa6cd068e603a3a..54b1ebe8a6cb6e3a20b78dadfc8fe7fd3a477bb7 100644 --- a/src/components/monitor/monitor.jsx +++ b/src/components/monitor/monitor.jsx @@ -7,7 +7,7 @@ const styles = require('./monitor.css'); const categories = { data: '#FF8C1A', sensing: '#5CB1D6', - sounds: '#CF63CF', + sound: '#CF63CF', looks: '#9966FF', motion: '#4C97FF' }; diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 4309c119984706a1e19f37d74abf9fcaaff91490..13b3975fdaaf77320b215bef5faadadbcc94e70b 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -1,4 +1,5 @@ const bindAll = require('lodash.bindall'); +const debounce = require('lodash.debounce'); const defaultsDeep = require('lodash.defaultsdeep'); const PropTypes = require('prop-types'); const React = require('react'); @@ -30,6 +31,7 @@ class Blocks extends React.Component { 'onScriptGlowOff', 'onBlockGlowOn', 'onBlockGlowOff', + 'onTargetsUpdate', 'onVisualReport', 'onWorkspaceUpdate', 'onWorkspaceMetricsChange', @@ -40,6 +42,7 @@ class Blocks extends React.Component { workspaceMetrics: {}, prompt: null }; + this.onTargetsUpdate = debounce(this.onTargetsUpdate, 100); } componentDidMount () { const workspaceConfig = defaultsDeep({}, Blocks.defaultOptions, this.props.options); @@ -85,6 +88,7 @@ class Blocks extends React.Component { this.props.vm.addListener('BLOCK_GLOW_OFF', this.onBlockGlowOff); this.props.vm.addListener('VISUAL_REPORT', this.onVisualReport); this.props.vm.addListener('workspaceUpdate', this.onWorkspaceUpdate); + this.props.vm.addListener('targetsUpdate', this.onTargetsUpdate); } detachVM () { this.props.vm.removeListener('SCRIPT_GLOW_ON', this.onScriptGlowOn); @@ -93,8 +97,25 @@ class Blocks extends React.Component { this.props.vm.removeListener('BLOCK_GLOW_OFF', this.onBlockGlowOff); this.props.vm.removeListener('VISUAL_REPORT', this.onVisualReport); this.props.vm.removeListener('workspaceUpdate', this.onWorkspaceUpdate); + this.props.vm.removeListener('targetsUpdate', this.onTargetsUpdate); + } + updateToolboxBlockValue (id, value) { + this.workspace + .getFlyout() + .getWorkspace() + .getBlockById(id) + .inputList[0] + .fieldRow[0] + .setValue(value); + } + onTargetsUpdate () { + if (this.props.vm.editingTarget) { + ['glide', 'move', 'set'].forEach(prefix => { + this.updateToolboxBlockValue(`${prefix}x`, this.props.vm.editingTarget.x.toFixed(0)); + this.updateToolboxBlockValue(`${prefix}y`, this.props.vm.editingTarget.y.toFixed(0)); + }); + } } - onWorkspaceMetricsChange () { const target = this.props.vm.editingTarget; if (target && target.id) { diff --git a/src/containers/costume-library.jsx b/src/containers/costume-library.jsx index d0a004ba674e37c4710c581ef1c72bd7658d798d..f245c098fcc179fc76fb17ba2c9590f449a379cc 100644 --- a/src/containers/costume-library.jsx +++ b/src/containers/costume-library.jsx @@ -7,7 +7,7 @@ const costumeLibraryContent = require('../lib/libraries/costumes.json'); const LibraryComponent = require('../components/library/library.jsx'); -class CostumeLibrary extends React.Component { +class CostumeLibrary extends React.PureComponent { constructor (props) { super(props); bindAll(this, [ diff --git a/src/containers/sound-library.jsx b/src/containers/sound-library.jsx index 53aa6bde02f9d54e0d394691549bd04416c7bce5..d82512225f5ccdadb4ce2ec548275d9f7acbfb3d 100644 --- a/src/containers/sound-library.jsx +++ b/src/containers/sound-library.jsx @@ -9,7 +9,7 @@ const soundIcon = require('../components/asset-panel/icon--sound.svg'); const soundLibraryContent = require('../lib/libraries/sounds.json'); -class SoundLibrary extends React.Component { +class SoundLibrary extends React.PureComponent { constructor (props) { super(props); bindAll(this, [ diff --git a/src/containers/sprite-library.jsx b/src/containers/sprite-library.jsx index 0a1bcaa3ebcad309733aa10c5f98faf25c68f56a..6395b421a7abb0664b334a8839105067548c9f0e 100644 --- a/src/containers/sprite-library.jsx +++ b/src/containers/sprite-library.jsx @@ -7,22 +7,68 @@ const spriteLibraryContent = require('../lib/libraries/sprites.json'); const LibraryComponent = require('../components/library/library.jsx'); -class SpriteLibrary extends React.Component { +class SpriteLibrary extends React.PureComponent { constructor (props) { super(props); bindAll(this, [ - 'handleItemSelect' + 'handleItemSelect', + 'handleMouseEnter', + 'handleMouseLeave', + 'rotateCostume', + 'startRotatingCostumes', + 'stopRotatingCostumes' ]); + this.state = { + activeSprite: null, + costumeIndex: 0, + sprites: spriteLibraryContent + }; + } + componentWillReceiveProps (newProps) { + if (!newProps.visible) clearInterval(this.intervalId); } handleItemSelect (item) { this.props.vm.addSprite2(JSON.stringify(item.json)); } + handleMouseEnter (item) { + this.stopRotatingCostumes(); + this.setState({activeSprite: item}, this.startRotatingCostumes); + } + handleMouseLeave () { + this.stopRotatingCostumes(); + } + startRotatingCostumes () { + if (!this.state.activeSprite) return; + this.rotateCostume(); + this.intervalId = setInterval(this.rotateCostume, 300); + } + stopRotatingCostumes () { + this.intervalId = clearInterval(this.intervalId); + } + rotateCostume () { + const costumes = this.state.activeSprite.json.costumes; + const nextCostumeIndex = (this.state.costumeIndex + 1) % costumes.length; + this.setState({ + costumeIndex: nextCostumeIndex, + sprites: this.state.sprites.map(sprite => { + if (sprite.name === this.state.activeSprite.name) { + return { + ...sprite, + md5: sprite.json.costumes[nextCostumeIndex].baseLayerMD5 + }; + } + return sprite; + }) + }); + } render () { return ( <LibraryComponent - data={spriteLibraryContent} + data={this.state.sprites} title="Sprite Library" visible={this.props.visible} + onItemMouseEnter={this.handleMouseEnter} + onItemMouseLeave={this.handleMouseLeave} onItemSelected={this.handleItemSelect} onRequestClose={this.props.onRequestClose} /> diff --git a/src/css/units.css b/src/css/units.css index ab4393ac1afefb3d770289b67b7c1b8983e6c2b9..3f9aaba17d8319e322b48c817438ed4e8b92458e 100644 --- a/src/css/units.css +++ b/src/css/units.css @@ -3,5 +3,7 @@ $space: 0.5rem; $sprites-per-row: 5; $menu-bar-height: 3rem; -$sprite-info-height: 5.25rem; /* TODO: SpriteInfo isn't explicitly set to this height yet */ +$sprite-info-height: 5.25rem; /* @todo: SpriteInfo isn't explicitly set to this height yet */ $stage-menu-height: 3rem; + +$library-header-height: 4.375rem; \ No newline at end of file diff --git a/src/lib/monitor-adapter.js b/src/lib/monitor-adapter.js new file mode 100644 index 0000000000000000000000000000000000000000..8dcc802a48e9414062431e6ca9137f8d2eef6cde --- /dev/null +++ b/src/lib/monitor-adapter.js @@ -0,0 +1,26 @@ +/** + * Convert monitors from VM format to what the GUI needs to render. + * - Convert opcode to a label and a category + * - Add missing XY position data if needed + */ +const OpcodeLabels = require('../lib/opcode-labels.js'); + +const PADDING = 5; +const MONITOR_HEIGHT = 23; + +const isUndefined = a => typeof a === 'undefined'; + +module.exports = function ({id, opcode, params, value, x, y}, monitorIndex) { + let {label, category, labelFn} = OpcodeLabels(opcode); + + // Use labelFn if provided for dynamic labelling (e.g. variables) + if (!isUndefined(labelFn)) label = labelFn(params); + + // Simple layout if x or y are undefined + // @todo scratch2 has a more complex layout behavior we may want to adopt + // @todo e.g. this does not work well when monitors have already been moved + if (isUndefined(x)) x = PADDING; + if (isUndefined(y)) y = PADDING + (monitorIndex * (PADDING + MONITOR_HEIGHT)); + + return {id, label, category, value, x, y}; +}; diff --git a/src/lib/opcode-labels.js b/src/lib/opcode-labels.js new file mode 100644 index 0000000000000000000000000000000000000000..bebe14743630de643343e9b88318b88fdf312b07 --- /dev/null +++ b/src/lib/opcode-labels.js @@ -0,0 +1,75 @@ +const opcodeMap = { + // Motion + motion_direction: { + category: 'motion', + label: 'direction' + }, + motion_xposition: { + category: 'motion', + label: 'x position' + }, + motion_yposition: { + category: 'motion', + label: 'y position' + }, + + // Looks + looks_size: { + category: 'looks', + label: 'size' + }, + looks_costumeorder: { + category: 'looks', + label: 'costume #' + }, + looks_backdroporder: { + category: 'looks', + label: 'backdrop #' + }, + looks_backdropname: { + category: 'looks', + label: 'backdrop name' + }, + + // Data + data_variable: { + category: 'data', + labelFn: params => params.VARIABLE + }, + + // Sound + sound_volume: { + category: 'sound', + label: 'volume' + }, + sound_tempo: { + category: 'sound', + label: 'tempo' + }, + + // Sensing + sensing_loudness: { + category: 'sensing', + label: 'loudness' + }, + sensing_of: { + category: 'sensing', + labelFn: params => `${params.PROPERTY} of ${params.OBJECT}` + }, + sensing_current: { + category: 'sensing', + labelFn: params => params.CURRENTMENU.toLowerCase() + }, + sensing_timer: { + category: 'sensing', + label: 'timer' + } +}; + +module.exports = function (opcode) { + if (opcode in opcodeMap) return opcodeMap[opcode]; + return { + category: 'data', + label: opcode + }; +}; diff --git a/src/reducers/monitors.js b/src/reducers/monitors.js index e8b4fa947eee4862f24be5ea8727caef6793fe5c..18e9529f4ad417645101a5cc85ed60b6b8adecf0 100644 --- a/src/reducers/monitors.js +++ b/src/reducers/monitors.js @@ -1,3 +1,5 @@ +const monitorAdapter = require('../lib/monitor-adapter.js'); + const UPDATE_MONITORS = 'scratch-gui/monitors/UPDATE_MONITORS'; const initialState = []; @@ -6,7 +8,7 @@ const reducer = function (state, action) { if (typeof state === 'undefined') state = initialState; switch (action.type) { case UPDATE_MONITORS: - return [...action.monitors]; + return action.monitors.map(monitorAdapter); default: return state; }