diff --git a/src/components/action-menu/action-menu.css b/src/components/action-menu/action-menu.css new file mode 100644 index 0000000000000000000000000000000000000000..c222216b1a24a1a48b04a455312bd85d1562fae9 --- /dev/null +++ b/src/components/action-menu/action-menu.css @@ -0,0 +1,174 @@ +@import "../../css/colors.css"; + +$main-button-size: 2.75rem; +$more-button-size: 2.25rem; + +.menu-container { + display: flex; + flex-direction: column-reverse; + transition: 0.2s; + position: relative; +} + +.button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + background: $motion-primary; + outline: none; + border: none; + transition: background-color 0.2s; +} + +.button:hover { + background: $pen-primary; +} + +.button.coming-soon:hover { + background: $data-primary; +} + +.main-button { + border-radius: 100%; + width: $main-button-size; + height: $main-button-size; + z-index: 20; /* TODO reorder layout to prevent z-index need */ +} + +.main-icon { + width: calc($main-button-size - 1rem); + height: calc($main-button-size - 1rem); +} + +.more-buttons-outer { + /* + Need to use two divs to set different overflow x/y + which is needed to get animation to look right while + allowing the tooltips to be visible. + */ + overflow-y: hidden; + + background: $motion-tertiary; + border-top-left-radius: $more-button-size; + border-top-right-radius: $more-button-size; + width: $more-button-size; + margin-left: calc(($main-button-size - $more-button-size) / 2); + + position: absolute; + bottom: calc($main-button-size); + + margin-bottom: calc($main-button-size / -2); + padding-bottom: calc($main-button-size / 2); +} + +.more-buttons { + max-height: 0; + transition: max-height 1s; + overflow-x: visible; + display: flex; + flex-direction: column; + z-index: 10; /* @todo justify */ +} + +.expanded .more-buttons { + max-height: 1000px; /* Arbitrary, needs to be a value in order for animation to run */ +} + +.force-hidden .more-buttons { + display: none; /* This property does not animate */ +} + +.more-buttons:first-child { /* Round off top button */ + border-top-right-radius: $more-button-size; + border-top-left-radius: $more-button-size; +} + +.more-button { + width: $more-button-size; + height: $more-button-size; + background: $motion-tertiary; +} + +.more-icon { + width: calc($more-button-size - 1rem); + height: calc($more-button-size - 1rem); +} + +.coming-soon .more-icon { + opacity: 0.5; +} + +/* + @todo needs to be refactored with coming soon tooltip overrides. + The "!important"s are for the same reason as with coming soon, the library + is not very easy to style. +*/ +.tooltip { + background-color: $pen-primary !important; + opacity: 1 !important; + border: 1px solid hsla(0, 0%, 0%, .1) !important; + box-shadow: 0 0 .5rem hsla(0, 0%, 0%, .25) !important; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important; +} + +.tooltip:after { + background-color: $pen-primary; +} + +.coming-soon-tooltip { + background-color: $data-primary !important; +} + +.coming-soon-tooltip:after { + background-color: $data-primary !important; +} + +.tooltip { + border: 1px solid hsla(0, 0%, 0%, .1) !important; + border-radius: .25rem !important; + box-shadow: 0 0 .5rem hsla(0, 0%, 0%, .25) !important; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important; + z-index: 100 !important; +} + +$arrow-size: 0.5rem; +$arrow-inset: -0.25rem; +$arrow-rounding: 0.125rem; + +.tooltip:after { + content: ""; + border-top: 1px solid hsla(0, 0%, 0%, .1) !important; + border-left: 0 !important; + border-bottom: 0 !important; + border-right: 1px solid hsla(0, 0%, 0%, .1) !important; + border-radius: $arrow-rounding; + height: $arrow-size !important; + width: $arrow-size !important; +} + +.tooltip:global(.place-left):after { + margin-top: $arrow-inset !important; + right: $arrow-inset !important; + transform: rotate(45deg) !important; +} + +.tooltip:global(.place-right):after { + margin-top: $arrow-inset !important; + left: $arrow-inset !important; + transform: rotate(-135deg) !important; +} + +.tooltip:global(.place-top):after { + margin-right: $arrow-inset !important; + bottom: $arrow-inset !important; + transform: rotate(135deg) !important; +} + +.tooltip:global(.place-bottom):after { + margin-left: $arrow-inset !important; + top: $arrow-inset !important; + transform: rotate(-45deg) !important; +} diff --git a/src/components/action-menu/action-menu.jsx b/src/components/action-menu/action-menu.jsx new file mode 100644 index 0000000000000000000000000000000000000000..910d1ab6a03b1331be7cc370f89a740b3c8d7ead --- /dev/null +++ b/src/components/action-menu/action-menu.jsx @@ -0,0 +1,189 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import bindAll from 'lodash.bindall'; +import ReactTooltip from 'react-tooltip'; + +import styles from './action-menu.css'; + +const CLOSE_DELAY = 300; // ms + +class ActionMenu extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'clickDelayer', + 'handleClosePopover', + 'handleToggleOpenState', + 'handleTouchStart', + 'handleTouchOutside', + 'setButtonRef', + 'setContainerRef' + ]); + this.state = { + isOpen: false, + forceHide: false + }; + } + componentDidMount () { + // Touch start on the main button is caught to trigger open and not click + this.buttonRef.addEventListener('touchstart', this.handleTouchStart); + // Touch start on document is used to trigger close if it is outside + document.addEventListener('touchstart', this.handleTouchOutside); + } + shouldComponentUpdate (newProps, newState) { + // This check prevents re-rendering while the project is updating. + // @todo check only the state and the title because it is enough to know + // if anything substantial has changed + // This is needed because of the sloppy way the props are passed as a new object, + // which should be refactored. + return newState.isOpen !== this.state.isOpen || + newState.forceHide !== this.state.forceHide || + newProps.title !== this.props.title; + } + componentWillUnmount () { + this.buttonRef.removeEventListener('touchstart', this.handleTouchStart); + document.removeEventListener('touchstart', this.handleTouchOutside); + } + handleClosePopover () { + this.closeTimeoutId = setTimeout(() => { + this.setState({isOpen: false}); + this.closeTimeoutId = null; + }, CLOSE_DELAY); + } + handleToggleOpenState () { + // Mouse enter back in after timeout was started prevents it from closing. + if (this.closeTimeoutId) { + clearTimeout(this.closeTimeoutId); + this.closeTimeoutId = null; + } else if (!this.state.isOpen) { + this.setState({ + isOpen: true, + forceHide: false + }); + } + } + handleTouchOutside (e) { + if (this.state.isOpen && !this.containerRef.contains(e.target)) { + this.setState({isOpen: false}); + } + } + clickDelayer (fn) { + // Return a wrapped action that manages the menu closing. + // @todo we may be able to use react-transition for this in the future + // for now all this work is to ensure the menu closes BEFORE the + // (possibly slow) action is started. + return event => { + this.setState({forceHide: true, isOpen: false}, () => { + if (fn) fn(event); + setTimeout(() => this.setState({forceHide: false})); + }); + }; + } + handleTouchStart (e) { + // Prevent this touch from becoming a click if menu is closed + if (!this.state.isOpen) { + e.preventDefault(); + this.handleToggleOpenState(); + } + } + setButtonRef (ref) { + this.buttonRef = ref; + } + setContainerRef (ref) { + this.containerRef = ref; + } + render () { + const { + className, + img: mainImg, + title: mainTitle, + moreButtons, + onClick + } = this.props; + + const mainTooltipId = `tooltip-${Math.random()}`; + + return ( + <div + className={classNames(styles.menuContainer, className, { + [styles.expanded]: this.state.isOpen, + [styles.forceHidden]: this.state.forceHide + })} + ref={this.setContainerRef} + onMouseEnter={this.handleToggleOpenState} + onMouseLeave={this.handleClosePopover} + > + <button + aria-label={mainTitle} + className={classNames(styles.button, styles.mainButton)} + data-for={mainTooltipId} + data-tip={mainTitle} + ref={this.setButtonRef} + onClick={this.clickDelayer(onClick)} + > + <img + className={styles.mainIcon} + draggable={false} + src={mainImg} + /> + </button> + <ReactTooltip + className={styles.tooltip} + effect="solid" + id={mainTooltipId} + place="left" + /> + <div className={styles.moreButtonsOuter}> + <div className={styles.moreButtons}> + {(moreButtons || []).map(({img, title, onClick: handleClick}) => { + const isComingSoon = !handleClick; + const tooltipId = `tooltip-${Math.random()}`; + return ( + <div key={tooltipId}> + <button + aria-label={title} + className={classNames(styles.button, styles.moreButton, { + [styles.comingSoon]: isComingSoon + })} + data-for={tooltipId} + data-tip={title} + onClick={this.clickDelayer(handleClick)} + > + <img + className={styles.moreIcon} + draggable={false} + src={img} + /> + </button> + <ReactTooltip + className={classNames(styles.tooltip, { + [styles.comingSoonTooltip]: isComingSoon + })} + effect="solid" + id={tooltipId} + place="left" + /> + </div> + ); + })} + </div> + </div> + </div> + ); + } +} + +ActionMenu.propTypes = { + className: PropTypes.string, + img: PropTypes.string, + moreButtons: PropTypes.arrayOf(PropTypes.shape({ + img: PropTypes.string, + title: PropTypes.node.isRequired, + onClick: PropTypes.func // Optional, "coming soon" if no callback provided + })), + onClick: PropTypes.func.isRequired, + title: PropTypes.node.isRequired +}; + +export default ActionMenu; diff --git a/src/components/stage-selector/icon--backdrop.svg b/src/components/action-menu/icon--backdrop.svg similarity index 100% rename from src/components/stage-selector/icon--backdrop.svg rename to src/components/action-menu/icon--backdrop.svg diff --git a/src/components/action-menu/icon--camera.svg b/src/components/action-menu/icon--camera.svg new file mode 100644 index 0000000000000000000000000000000000000000..e8c442d8f5d994a764942dcd957cff4239fde281 Binary files /dev/null and b/src/components/action-menu/icon--camera.svg differ diff --git a/src/components/action-menu/icon--file-upload.svg b/src/components/action-menu/icon--file-upload.svg new file mode 100644 index 0000000000000000000000000000000000000000..57337d959c121a7763f12ce8f80798d2f2527e04 Binary files /dev/null and b/src/components/action-menu/icon--file-upload.svg differ diff --git a/src/components/action-menu/icon--paint.svg b/src/components/action-menu/icon--paint.svg new file mode 100644 index 0000000000000000000000000000000000000000..f79d562c350a71f602003d8e8add687a90a01cde Binary files /dev/null and b/src/components/action-menu/icon--paint.svg differ diff --git a/src/components/sprite-selector/icon--sprite.svg b/src/components/action-menu/icon--sprite.svg similarity index 100% rename from src/components/sprite-selector/icon--sprite.svg rename to src/components/action-menu/icon--sprite.svg diff --git a/src/components/action-menu/icon--surprise.svg b/src/components/action-menu/icon--surprise.svg new file mode 100644 index 0000000000000000000000000000000000000000..41655999f677de3fa14becbce299cd3bc517dba8 Binary files /dev/null and b/src/components/action-menu/icon--surprise.svg differ diff --git a/src/components/asset-button/asset-button.css b/src/components/asset-button/asset-button.css deleted file mode 100644 index b36fa76b8ec097d30a3dafac6a8a3046e000841a..0000000000000000000000000000000000000000 --- a/src/components/asset-button/asset-button.css +++ /dev/null @@ -1,21 +0,0 @@ -@import "../../css/colors.css"; - -.container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - cursor: pointer; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - background: $motion-primary; - border-radius: 100%; - width: 2.75rem; - height: 2.75rem; - outline: none; - border: none; -} - -.icon { - width: 1.75rem; - height: 1.75rem; -} diff --git a/src/components/asset-button/asset-button.jsx b/src/components/asset-button/asset-button.jsx deleted file mode 100644 index 08883899094b051009df92ad3e3991c79bd44439..0000000000000000000000000000000000000000 --- a/src/components/asset-button/asset-button.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import classNames from 'classnames'; -import styles from './asset-button.css'; - -const AssetButton = ({ - img, - className, - title, - onClick -}) => ( - <button - className={classNames(styles.container, className)} - title={title} - onClick={onClick} - > - <img - className={styles.icon} - draggable={false} - src={img} - /> - </button> -); - -AssetButton.propTypes = { - className: PropTypes.string, - img: PropTypes.string, - onClick: PropTypes.func.isRequired, - title: PropTypes.node.isRequired -}; - -export default AssetButton; diff --git a/src/components/asset-panel/selector.css b/src/components/asset-panel/selector.css index 2a19296d66a7e11524dbb9a7036848ac76d56b09..db9bca0695cc8e1c5c7baf704d779e1730571fd3 100644 --- a/src/components/asset-panel/selector.css +++ b/src/components/asset-panel/selector.css @@ -11,13 +11,31 @@ } .new-buttons { + position: absolute; + bottom: 0; + width: 100%; + display: flex; flex-direction: column; align-items: center; justify-content: space-around; - margin: 0.75rem 0; + padding: 0.75rem 0; color: $motion-primary; text-align: center; + background: none; +} + +$fade-out-distance: 100px; + +.new-buttons:before { + content: ""; + position: absolute; + bottom: 0; + left: 0; + background: linear-gradient(rgba(232,237,241, 0),rgba(232,237,241, 1)); + height: $fade-out-distance; + width: 100%; + pointer-events: none; } .new-buttons > button + button { diff --git a/src/components/asset-panel/selector.jsx b/src/components/asset-panel/selector.jsx index b6970778155ca4ccd205092e593a310554e16821..f2ab58cae0dc4c084cc525ffc50252eaad777b8e 100644 --- a/src/components/asset-panel/selector.jsx +++ b/src/components/asset-panel/selector.jsx @@ -4,7 +4,7 @@ import React from 'react'; import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx'; import Box from '../box/box.jsx'; -import AssetButton from '../asset-button/asset-button.jsx'; +import ActionMenu from '../action-menu/action-menu.jsx'; import styles from './selector.css'; const Selector = props => { @@ -17,6 +17,23 @@ const Selector = props => { onItemClick } = props; + let newButtonSection = null; + + if (buttons.length > 0) { + const {img, title, onClick} = buttons[0]; + const moreButtons = buttons.slice(1); + newButtonSection = ( + <Box className={styles.newButtons}> + <ActionMenu + img={img} + moreButtons={moreButtons} + title={title} + onClick={onClick} + /> + </Box> + ); + } + return ( <Box className={styles.wrapper}> <Box className={styles.listArea}> @@ -35,16 +52,7 @@ const Selector = props => { /> ))} </Box> - <Box className={styles.newButtons}> - {buttons.map(({message, img, onClick}, index) => ( - <AssetButton - img={img} - key={index} - title={message} - onClick={onClick} - /> - ))} - </Box> + {newButtonSection} </Box> ); }; diff --git a/src/components/green-flag/green-flag.css b/src/components/green-flag/green-flag.css index 2c145283869ac0788d3e5be4f96e8b6afd04594f..35ba2972638ee8abc82eaf3b12c0cc4f422fb251 100644 --- a/src/components/green-flag/green-flag.css +++ b/src/components/green-flag/green-flag.css @@ -5,6 +5,7 @@ padding: 0.375rem; border-radius: 0.25rem; user-select: none; + user-drag: none; cursor: pointer; transition: 0.2s ease-out; } diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 92b83acc2db4cb41966505fd890f55c4e1edee24..5132af685bfc81f10541157fcd6e93a6e99292da 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -140,7 +140,8 @@ For making the sprite-selector a scrollable pane @todo: Not working in Safari */ - overflow: hidden; + /* TODO this also breaks the thermometer menu */ + /* overflow: hidden; */ } .extension-button-container { @@ -156,6 +157,19 @@ box-sizing: content-box; /* To match scratch-block vertical toolbox borders */ } +$fade-out-distance: 15px; + +.extension-button-container:before { + content: ""; + position: absolute; + top: calc(calc(-1 * $fade-out-distance) - 1px); + left: -1px; + background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, 0.15)); + height: $fade-out-distance; + width: calc(100% + 0.5px); +} + + .extension-button { background: none; border: none; diff --git a/src/components/sprite-selector/sprite-selector.css b/src/components/sprite-selector/sprite-selector.css index 368a3451cab11cb926d8f900d0681e241a4b85a3..d46e050869d806bece00779ed089a4d514c06400 100644 --- a/src/components/sprite-selector/sprite-selector.css +++ b/src/components/sprite-selector/sprite-selector.css @@ -2,7 +2,7 @@ .sprite-selector { flex-grow: 1; - position: relative; + position: relative; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; margin-right: calc($space / 2); background-color: #f9f9f9; @@ -16,14 +16,14 @@ /* In prep for renaming sprite-selector-item to sprite */ .sprite { - /* + /* Our goal is to fit sprites evenly in a row without leftover space. - Flexbox's `space between` property gets us close, but doesn't flow + Flexbox's `space between` property gets us close, but doesn't flow well when the # of items per row > 1 and less than the max per row. - Solving by explicitly calc'ing the width of each sprite. Setting - `border-box` simplifies things, because content, padding and - border-width all are included in the width, leaving us only to subtract + Solving by explicitly calc'ing the width of each sprite. Setting + `border-box` simplifies things, because content, padding and + border-width all are included in the width, leaving us only to subtract the left + right margins. @todo: make room for the scrollbar @@ -32,7 +32,7 @@ width: calc((100% / $sprites-per-row ) - $space); min-width: 4rem; min-height: 4rem; /* @todo: calc height same as width */ - margin: calc($space / 2); + margin: calc($space / 2); } @@ -41,11 +41,11 @@ Sets the sprite-selector items as a scrollable pane @todo: Safari: pane doesn't stretch to fill height; - @todo: Adding `position: relative` still doesn't fix Safari scrolling pane, and - also introduces a new bug in Chrome when vertically resizing window down, - then back up, introduces white space in the outside the page container. + @todo: Adding `position: relative` still doesn't fix Safari scrolling pane, and + also introduces a new bug in Chrome when vertically resizing window down, + then back up, introduces white space in the outside the page container. */ - height: calc(100% - $sprite-info-height); + height: calc(100% - $sprite-info-height); overflow-y: scroll; } @@ -57,13 +57,14 @@ padding-top: calc($space / 2); padding-left: calc($space / 2); padding-right: calc($space / 2); - padding-bottom: $space; + padding-bottom: $space; } .add-button { position: absolute; bottom: 0.75rem; right: 1rem; + z-index: 1; /* TODO overlaps the stage, this doesn't work, fix! */ } .raised { diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index fdec937a9fdd146e919ade7dc6d56319ac99567f..24e5315c6527f88e2208909232a17c431704b0de 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -6,16 +6,41 @@ import {defineMessages, injectIntl, intlShape} from 'react-intl'; import Box from '../box/box.jsx'; import SpriteInfo from '../../containers/sprite-info.jsx'; import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx'; -import AssetButton from '../asset-button/asset-button.jsx'; +import ActionMenu from '../action-menu/action-menu.jsx'; import styles from './sprite-selector.css'; -import spriteIcon from './icon--sprite.svg'; + +import cameraIcon from '../action-menu/icon--camera.svg'; +import fileUploadIcon from '../action-menu/icon--file-upload.svg'; +import paintIcon from '../action-menu/icon--paint.svg'; +import spriteIcon from '../action-menu/icon--sprite.svg'; +import surpriseIcon from '../action-menu/icon--surprise.svg'; const messages = defineMessages({ - addSprite: { - id: 'gui.spriteSelector.addSprite', - description: 'Button to add a sprite in the target pane', - defaultMessage: 'Add Sprite' + addSpriteFromLibrary: { + id: 'gui.spriteSelector.addSpriteFromLibrary', + description: 'Button to add a sprite in the target pane from library', + defaultMessage: 'Sprite Library' + }, + addSpriteFromPaint: { + id: 'gui.spriteSelector.addSpriteFromPaint', + description: 'Button to add a sprite in the target pane from paint', + defaultMessage: 'Paint' + }, + addSpriteFromSurprise: { + id: 'gui.spriteSelector.addSpriteFromSurprise', + description: 'Button to add a random sprite in the target pane', + defaultMessage: 'Surprise' + }, + addSpriteFromFile: { + id: 'gui.spriteSelector.addSpriteFromFile', + description: 'Button to add a sprite in the target pane from file', + defaultMessage: 'Coming Soon' + }, + addSpriteFromCamera: { + id: 'gui.spriteSelector.addSpriteFromCamera', + description: 'Button to add a sprite in the target pane from camera', + defaultMessage: 'Coming Soon' } }); @@ -33,6 +58,8 @@ const SpriteSelectorComponent = function (props) { onDeleteSprite, onDuplicateSprite, onNewSpriteClick, + onSurpriseSpriteClick, + onPaintSpriteClick, onSelectSprite, raised, selectedId, @@ -94,10 +121,27 @@ const SpriteSelectorComponent = function (props) { } </Box> </Box> - <AssetButton + <ActionMenu className={styles.addButton} img={spriteIcon} - title={intl.formatMessage(messages.addSprite)} + moreButtons={[ + { + title: intl.formatMessage(messages.addSpriteFromCamera), + img: cameraIcon + }, { + title: intl.formatMessage(messages.addSpriteFromFile), + img: fileUploadIcon + }, { + title: intl.formatMessage(messages.addSpriteFromSurprise), + img: surpriseIcon, + onClick: onSurpriseSpriteClick // TODO need real function for this + }, { + title: intl.formatMessage(messages.addSpriteFromPaint), + img: paintIcon, + onClick: onPaintSpriteClick // TODO need real function for this + } + ]} + title={intl.formatMessage(messages.addSpriteFromLibrary)} onClick={onNewSpriteClick} /> </Box> @@ -120,7 +164,9 @@ SpriteSelectorComponent.propTypes = { onDeleteSprite: PropTypes.func, onDuplicateSprite: PropTypes.func, onNewSpriteClick: PropTypes.func, + onPaintSpriteClick: PropTypes.func, onSelectSprite: PropTypes.func, + onSurpriseSpriteClick: PropTypes.func, raised: PropTypes.bool, selectedId: PropTypes.string, sprites: PropTypes.shape({ diff --git a/src/components/stage-selector/stage-selector.jsx b/src/components/stage-selector/stage-selector.jsx index eea81140946fd2f72f684f350e738e6912cfa33f..073ca257c15bfe735bafb67ce25966e69a3dfb40 100644 --- a/src/components/stage-selector/stage-selector.jsx +++ b/src/components/stage-selector/stage-selector.jsx @@ -4,16 +4,41 @@ import React from 'react'; import {defineMessages, intlShape, injectIntl, FormattedMessage} from 'react-intl'; import Box from '../box/box.jsx'; -import AssetButton from '../asset-button/asset-button.jsx'; +import ActionMenu from '../action-menu/action-menu.jsx'; import CostumeCanvas from '../costume-canvas/costume-canvas.jsx'; import styles from './stage-selector.css'; -import backdropIcon from './icon--backdrop.svg'; + +import backdropIcon from '../action-menu/icon--backdrop.svg'; +import cameraIcon from '../action-menu/icon--camera.svg'; +import fileUploadIcon from '../action-menu/icon--file-upload.svg'; +import paintIcon from '../action-menu/icon--paint.svg'; +import surpriseIcon from '../action-menu/icon--surprise.svg'; const messages = defineMessages({ - addBackdrop: { - id: 'gui.stageSelector.targetPaneAddBackdrop', - description: 'Button to add a backdrop in the target pane', - defaultMessage: 'Add Backdrop' + addBackdropFromLibrary: { + id: 'gui.spriteSelector.addBackdropFromLibrary', + description: 'Button to add a stage in the target pane from library', + defaultMessage: 'Backdrop Library' + }, + addBackdropFromPaint: { + id: 'gui.stageSelector.addBackdropFromPaint', + description: 'Button to add a stage in the target pane from paint', + defaultMessage: 'Paint' + }, + addBackdropFromSurprise: { + id: 'gui.stageSelector.addBackdropFromSurprise', + description: 'Button to add a random stage in the target pane', + defaultMessage: 'Surprise' + }, + addBackdropFromFile: { + id: 'gui.stageSelector.addBackdropFromFile', + description: 'Button to add a stage in the target pane from file', + defaultMessage: 'Coming Soon' + }, + addBackdropFromCamera: { + id: 'gui.stageSelector.addBackdropFromCamera', + description: 'Button to add a stage in the target pane from camera', + defaultMessage: 'Coming Soon' } }); @@ -25,6 +50,8 @@ const StageSelector = props => { url, onClick, onNewBackdropClick, + onSurpriseBackdropClick, + onEmptyBackdropClick, ...componentProps } = props; return ( @@ -54,10 +81,28 @@ const StageSelector = props => { /> </div> <div className={styles.count}>{backdropCount}</div> - <AssetButton + <ActionMenu className={styles.addButton} img={backdropIcon} - title={intl.formatMessage(messages.addBackdrop)} + moreButtons={[ + { + title: intl.formatMessage(messages.addBackdropFromCamera), + img: cameraIcon + }, { + title: intl.formatMessage(messages.addBackdropFromFile), + img: fileUploadIcon + }, { + title: intl.formatMessage(messages.addBackdropFromSurprise), + img: surpriseIcon, + onClick: onSurpriseBackdropClick // TODO NEED REAL FUNCTION + + }, { + title: intl.formatMessage(messages.addBackdropFromPaint), + img: paintIcon, + onClick: onEmptyBackdropClick // TODO NEED REAL FUNCTION + } + ]} + title={intl.formatMessage(messages.addBackdropFromLibrary)} onClick={onNewBackdropClick} /> </Box> @@ -68,8 +113,11 @@ StageSelector.propTypes = { backdropCount: PropTypes.number.isRequired, intl: intlShape.isRequired, onClick: PropTypes.func, + onEmptyBackdropClick: PropTypes.func, onNewBackdropClick: PropTypes.func, + onSurpriseBackdropClick: PropTypes.func, selected: PropTypes.bool.isRequired, url: PropTypes.string }; + export default injectIntl(StageSelector); diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index 014ed6498608fcbdf4063270cf1e033fb2cf850b..1044073a1cc18b438633e487019d03f2d10065e4 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -30,6 +30,8 @@ const TargetPane = ({ onDeleteSprite, onDuplicateSprite, onNewSpriteClick, + onSurpriseSpriteClick, + onPaintSpriteClick, onRequestCloseSpriteLibrary, onRequestCloseBackdropLibrary, onSelectSprite, @@ -59,7 +61,9 @@ const TargetPane = ({ onDeleteSprite={onDeleteSprite} onDuplicateSprite={onDuplicateSprite} onNewSpriteClick={onNewSpriteClick} + onPaintSpriteClick={onPaintSpriteClick} onSelectSprite={onSelectSprite} + onSurpriseSpriteClick={onSurpriseSpriteClick} /> <div className={styles.stageSelectorWrapper}> {stage.id && <StageSelector @@ -125,10 +129,12 @@ TargetPane.propTypes = { onDeleteSprite: PropTypes.func, onDuplicateSprite: PropTypes.func, onNewSpriteClick: PropTypes.func, + onPaintSpriteClick: PropTypes.func, onRequestCloseBackdropLibrary: PropTypes.func, onRequestCloseExtensionLibrary: PropTypes.func, onRequestCloseSpriteLibrary: PropTypes.func, onSelectSprite: PropTypes.func, + onSurpriseSpriteClick: PropTypes.func, raiseSprites: PropTypes.bool, spriteLibraryVisible: PropTypes.bool, sprites: PropTypes.objectOf(spriteShape), diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index e22baebf203f86de87d305a6a0b79a58f74051e8..2e897c451031eae029dad95609c484f2ef645d72 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -7,41 +7,54 @@ import VM from 'scratch-vm'; import AssetPanel from '../components/asset-panel/asset-panel.jsx'; import PaintEditorWrapper from './paint-editor-wrapper.jsx'; import CostumeLibrary from './costume-library.jsx'; -import BackdropLibrary from './backdrop-library.jsx'; import {connect} from 'react-redux'; import { closeCostumeLibrary, - closeBackdropLibrary, openCostumeLibrary, openBackdropLibrary } from '../reducers/modals'; -import addBlankCostumeIcon from '../components/asset-panel/icon--add-blank-costume.svg'; 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 cameraIcon from '../components/action-menu/icon--camera.svg'; +import surpriseIcon from '../components/action-menu/icon--surprise.svg'; + import costumeLibraryContent from '../lib/libraries/costumes.json'; +import backdropLibraryContent from '../lib/libraries/backdrops.json'; const messages = defineMessages({ addLibraryBackdropMsg: { - defaultMessage: 'Add Backdrop From Library', + defaultMessage: 'Backdrop Library', description: 'Button to add a backdrop in the editor tab', - id: 'gui.costumeTab.addBackdrop' + id: 'gui.costumeTab.addBackdropFromLibrary' }, addLibraryCostumeMsg: { - defaultMessage: 'Add Costume From Library', + defaultMessage: 'Costume Library', description: 'Button to add a costume in the editor tab', - id: 'gui.costumeTab.addCostume' - }, - addBlankBackdropMsg: { - defaultMessage: 'Add Blank Backdrop', - description: 'Button to add a blank backdrop in the editor tab', - id: 'gui.costumeTab.addBlankBackdrop' + id: 'gui.costumeTab.addCostumeFromLibrary' }, addBlankCostumeMsg: { - defaultMessage: 'Add Blank Costume', + 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' + }, + addFileCostumeMsg: { + defaultMessage: 'Coming Soon', + description: 'Button to add a file upload costume in the editor tab', + id: 'gui.costumeTab.addFileCostume' + }, + addCameraCostumeMsg: { + defaultMessage: 'Coming Soon', + description: 'Button to use the camera to create a costume costume in the editor tab', + id: 'gui.costumeTab.addCameraCostume' } }); @@ -53,7 +66,9 @@ class CostumeTab extends React.Component { 'handleDeleteCostume', 'handleDuplicateCostume', 'handleNewCostume', - 'handleNewBlankCostume' + 'handleNewBlankCostume', + 'handleSurpriseCostume', + 'handleSurpriseBackdrop' ]); const { editingTarget, @@ -113,6 +128,32 @@ class CostumeTab extends React.Component { this.handleNewCostume(); }); } + handleSurpriseCostume () { + const item = costumeLibraryContent[Math.floor(Math.random() * costumeLibraryContent.length)]; + const vmCostume = { + name: item.name, + rotationCenterX: item.info[0], + rotationCenterY: item.info[1], + bitmapResolution: item.info.length > 2 ? item.info[2] : 1, + skinId: null + }; + this.props.vm.addCostume(item.md5, vmCostume).then(() => { + this.handleNewCostume(); + }); + } + handleSurpriseBackdrop () { + const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; + const vmCostume = { + name: item.name, + rotationCenterX: item.info[0] && item.info[0] / 2, + rotationCenterY: item.info[1] && item.info[1] / 2, + bitmapResolution: item.info.length > 2 ? item.info[2] : 1, + skinId: null + }; + this.props.vm.addCostume(item.md5, vmCostume).then(() => { + this.handleNewCostume(); + }); + } render () { // For paint wrapper const { @@ -120,9 +161,7 @@ class CostumeTab extends React.Component { onNewLibraryBackdropClick, onNewLibraryCostumeClick, costumeLibraryVisible, - backdropLibraryVisible, onRequestCloseCostumeLibrary, - onRequestCloseBackdropLibrary, ...props } = this.props; @@ -140,7 +179,7 @@ class CostumeTab extends React.Component { } const addLibraryMessage = target.isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg; - const addBlankMessage = target.isStage ? messages.addBlankBackdropMsg : messages.addBlankCostumeMsg; + const addSurpriseFunc = target.isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume; const addLibraryFunc = target.isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick; const addLibraryIcon = target.isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon; @@ -148,14 +187,27 @@ class CostumeTab extends React.Component { <AssetPanel buttons={[ { - message: intl.formatMessage(addBlankMessage), - img: addBlankCostumeIcon, - onClick: this.handleNewBlankCostume - }, - { - message: intl.formatMessage(addLibraryMessage), + title: intl.formatMessage(addLibraryMessage), img: addLibraryIcon, onClick: addLibraryFunc + }, + { + title: intl.formatMessage(messages.addCameraCostumeMsg), + img: cameraIcon + }, + { + title: intl.formatMessage(messages.addFileCostumeMsg), + img: fileUploadIcon + }, + { + title: intl.formatMessage(messages.addSurpriseCostumeMsg), + img: surpriseIcon, + onClick: addSurpriseFunc + }, + { + title: intl.formatMessage(messages.addBlankCostumeMsg), + img: paintIcon, + onClick: this.handleNewBlankCostume } ]} items={target.costumes || []} @@ -178,13 +230,6 @@ class CostumeTab extends React.Component { onRequestClose={onRequestCloseCostumeLibrary} /> ) : null} - {backdropLibraryVisible ? ( - <BackdropLibrary - vm={vm} - onNewBackdrop={this.handleNewCostume} - onRequestClose={onRequestCloseBackdropLibrary} - /> - ) : null} </AssetPanel> ); } @@ -233,9 +278,6 @@ const mapDispatchToProps = dispatch => ({ e.preventDefault(); dispatch(openCostumeLibrary()); }, - onRequestCloseBackdropLibrary: () => { - dispatch(closeBackdropLibrary()); - }, onRequestCloseCostumeLibrary: () => { dispatch(closeCostumeLibrary()); } diff --git a/src/containers/extension-library.jsx b/src/containers/extension-library.jsx index 9998fbb3e91da581176c3fbcc439ea093370652d..d036caa236d86622341dc4ed5727c8308a9644ef 100644 --- a/src/containers/extension-library.jsx +++ b/src/containers/extension-library.jsx @@ -7,7 +7,7 @@ import extensionLibraryContent from '../lib/libraries/extensions/index'; import analytics from '../lib/analytics'; import LibraryComponent from '../components/library/library.jsx'; -import extensionIcon from '../components/sprite-selector/icon--sprite.svg'; +import extensionIcon from '../components/action-menu/icon--sprite.svg'; class ExtensionLibrary extends React.PureComponent { constructor (props) { diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx index 8686ae6f90528c6ad010e40cc72965730dec6e7c..471ebe99e65a37301979f431f304096e392c1a27 100644 --- a/src/containers/sound-tab.jsx +++ b/src/containers/sound-tab.jsx @@ -8,10 +8,15 @@ import AssetPanel from '../components/asset-panel/asset-panel.jsx'; import soundIcon from '../components/asset-panel/icon--sound.svg'; import addSoundFromLibraryIcon from '../components/asset-panel/icon--add-sound-lib.svg'; import addSoundFromRecordingIcon from '../components/asset-panel/icon--add-sound-record.svg'; +import fileUploadIcon from '../components/action-menu/icon--file-upload.svg'; +import surpriseIcon from '../components/action-menu/icon--surprise.svg'; + import RecordModal from './record-modal.jsx'; import SoundEditor from './sound-editor.jsx'; import SoundLibrary from './sound-library.jsx'; +import soundLibraryContent from '../lib/libraries/sounds.json'; + import {connect} from 'react-redux'; import { @@ -27,7 +32,8 @@ class SoundTab extends React.Component { 'handleSelectSound', 'handleDeleteSound', 'handleDuplicateSound', - 'handleNewSound' + 'handleNewSound', + 'handleSurpriseSound' ]); this.state = {selectedSoundIndex: 0}; } @@ -72,6 +78,20 @@ class SoundTab extends React.Component { this.setState({selectedSoundIndex: Math.max(sounds.length - 1, 0)}); } + handleSurpriseSound () { + const soundItem = soundLibraryContent[Math.floor(Math.random() * soundLibraryContent.length)]; + const vmSound = { + format: soundItem.format, + md5: soundItem.md5, + rate: soundItem.rate, + sampleCount: soundItem.sampleCount, + name: soundItem.name + }; + this.props.vm.addSound(vmSound).then(() => { + this.handleNewSound(); + }); + } + render () { const { intl, @@ -94,28 +114,45 @@ class SoundTab extends React.Component { )) : []; const messages = defineMessages({ + fileUploadSound: { + defaultMessage: 'Coming Soon', + description: 'Button to upload sound from file in the editor tab', + id: 'gui.soundTab.fileUploadSound' + }, + surpriseSound: { + defaultMessage: 'Surprise', + description: 'Button to get a random sound in the editor tab', + id: 'gui.soundTab.surpriseSound' + }, recordSound: { - defaultMessage: 'Record Sound', + defaultMessage: 'Record', description: 'Button to record a sound in the editor tab', id: 'gui.soundTab.recordSound' }, addSound: { - defaultMessage: 'Add Sound', + defaultMessage: 'Sound Library', description: 'Button to add a sound in the editor tab', - id: 'gui.soundTab.addSound' + id: 'gui.soundTab.addSoundFromLibrary' } }); return ( <AssetPanel buttons={[{ - message: intl.formatMessage(messages.recordSound), - img: addSoundFromRecordingIcon, - onClick: onNewSoundFromRecordingClick - }, { - message: intl.formatMessage(messages.addSound), + title: intl.formatMessage(messages.addSound), img: addSoundFromLibraryIcon, onClick: onNewSoundFromLibraryClick + }, { + title: intl.formatMessage(messages.fileUploadSound), + img: fileUploadIcon + }, { + title: intl.formatMessage(messages.surpriseSound), + img: surpriseIcon, + onClick: this.handleSurpriseSound + }, { + title: intl.formatMessage(messages.recordSound), + img: addSoundFromRecordingIcon, + onClick: onNewSoundFromRecordingClick }]} items={sounds.map(sound => ({ url: soundIcon, diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx index 83a5a1bb06fb2bfcdc8df0c58e3488bc1686ffcf..27075cef8afb92f04a664ee0ae3c1e8d43930b61 100644 --- a/src/containers/stage-selector.jsx +++ b/src/containers/stage-selector.jsx @@ -4,24 +4,57 @@ import React from 'react'; import {connect} from 'react-redux'; import {openBackdropLibrary} from '../reducers/modals'; +import {activateTab, COSTUMES_TAB_INDEX} from '../reducers/editor-tab'; + import StageSelectorComponent from '../components/stage-selector/stage-selector.jsx'; +import backdropLibraryContent from '../lib/libraries/backdrops.json'; +import costumeLibraryContent from '../lib/libraries/costumes.json'; + class StageSelector extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleClick' + 'handleClick', + 'handleSurpriseBackdrop', + 'handleEmptyBackdrop', + 'addBackdropFromLibraryItem' ]); } + addBackdropFromLibraryItem (item) { + const vmBackdrop = { + name: item.name, + rotationCenterX: item.info[0] && item.info[0] / 2, + rotationCenterY: item.info[1] && item.info[1] / 2, + bitmapResolution: item.info.length > 2 ? item.info[2] : 1, + skinId: null + }; + return this.props.vm.addBackdrop(item.md5, vmBackdrop); + } handleClick (e) { e.preventDefault(); this.props.onSelect(this.props.id); } + handleSurpriseBackdrop () { + // @todo should this not add a backdrop you already have? + const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; + this.addBackdropFromLibraryItem(item); + } + handleEmptyBackdrop () { + // @todo this is brittle, will need to be refactored for localized libraries + const emptyItem = costumeLibraryContent.find(item => item.name === 'Empty'); + if (emptyItem) { + this.addBackdropFromLibraryItem(emptyItem).then(() => { + this.props.onActivateTab(COSTUMES_TAB_INDEX); + }); + } + } render () { const { /* eslint-disable no-unused-vars */ assetId, id, + onActivateTab, onSelect, /* eslint-enable no-unused-vars */ ...componentProps @@ -29,6 +62,8 @@ class StageSelector extends React.Component { return ( <StageSelectorComponent onClick={this.handleClick} + onEmptyBackdropClick={this.handleEmptyBackdrop} + onSurpriseBackdropClick={this.handleSurpriseBackdrop} {...componentProps} /> ); @@ -41,13 +76,17 @@ StageSelector.propTypes = { }; const mapStateToProps = (state, {assetId}) => ({ - url: assetId && state.vm.runtime.storage.get(assetId).encodeDataURI() + url: assetId && state.vm.runtime.storage.get(assetId).encodeDataURI(), + vm: state.vm }); const mapDispatchToProps = dispatch => ({ onNewBackdropClick: e => { e.preventDefault(); dispatch(openBackdropLibrary()); + }, + onActivateTab: tabIndex => { + dispatch(activateTab(tabIndex)); } }); diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index 7dc6adb2514187cd4b6886c96596d44a7f14d4a7..de6d6e9b1ced54abfc01f670d04e271817936b54 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -9,7 +9,10 @@ import { closeSpriteLibrary } from '../reducers/modals'; +import {activateTab, COSTUMES_TAB_INDEX} from '../reducers/editor-tab'; + import TargetPaneComponent from '../components/target-pane/target-pane.jsx'; +import spriteLibraryContent from '../lib/libraries/sprites.json'; class TargetPane extends React.Component { constructor (props) { @@ -23,7 +26,9 @@ class TargetPane extends React.Component { 'handleChangeSpriteY', 'handleDeleteSprite', 'handleDuplicateSprite', - 'handleSelectSprite' + 'handleSelectSprite', + 'handleSurpriseSpriteClick', + 'handlePaintSpriteClick' ]); } handleChangeSpriteDirection (direction) { @@ -53,10 +58,27 @@ class TargetPane extends React.Component { handleSelectSprite (id) { this.props.vm.setEditingTarget(id); } + handleSurpriseSpriteClick () { + const item = spriteLibraryContent[Math.floor(Math.random() * spriteLibraryContent.length)]; + this.props.vm.addSprite2(JSON.stringify(item.json)); + } + handlePaintSpriteClick () { + // @todo this is brittle, will need to be refactored for localized libraries + const emptyItem = spriteLibraryContent.find(item => item.name === 'Empty'); + if (emptyItem) { + this.props.vm.addSprite2(JSON.stringify(emptyItem.json)).then(() => { + this.props.onActivateTab(COSTUMES_TAB_INDEX); + }); + } + } render () { + const { + onActivateTab, // eslint-disable-line no-unused-vars + ...componentProps + } = this.props; return ( <TargetPaneComponent - {...this.props} + {...componentProps} onChangeSpriteDirection={this.handleChangeSpriteDirection} onChangeSpriteName={this.handleChangeSpriteName} onChangeSpriteSize={this.handleChangeSpriteSize} @@ -65,7 +87,9 @@ class TargetPane extends React.Component { onChangeSpriteY={this.handleChangeSpriteY} onDeleteSprite={this.handleDeleteSprite} onDuplicateSprite={this.handleDuplicateSprite} + onPaintSpriteClick={this.handlePaintSpriteClick} onSelectSprite={this.handleSelectSprite} + onSurpriseSpriteClick={this.handleSurpriseSpriteClick} /> ); } @@ -107,6 +131,9 @@ const mapDispatchToProps = dispatch => ({ }, onRequestCloseBackdropLibrary: () => { dispatch(closeBackdropLibrary()); + }, + onActivateTab: tabIndex => { + dispatch(activateTab(tabIndex)); } }); diff --git a/src/css/colors.css b/src/css/colors.css index e270217f9a595bf09c26b66f1cafd5ca25dba308..f4611c06e25e2ffad536fd7520186dd57fd29101 100644 --- a/src/css/colors.css +++ b/src/css/colors.css @@ -18,4 +18,6 @@ $control-primary: #FFAB19; $data-primary: #FF8C1A; +$pen-primary: #11B581; + $form-border: #E9EEF2; diff --git a/test/integration/costumes.test.js b/test/integration/costumes.test.js index f07c3d0f309cd27ecdddc7c7b2eaa769f2c8448a..00700cba2b41b486afa657277605c3ed142457f2 100644 --- a/test/integration/costumes.test.js +++ b/test/integration/costumes.test.js @@ -29,7 +29,7 @@ describe('Working with costumes', () => { await loadUri(uri); await clickXpath('//button[@title="tryit"]'); await clickText('Costumes'); - await clickXpath('//button[@title="Add Costume"]'); + await clickXpath('//button[@aria-label="Costume Library"]'); const el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('abb'); await clickText('Abby-a'); // Should close the modal, then click the costumes in the selector @@ -57,7 +57,7 @@ describe('Working with costumes', () => { test('Adding a backdrop', async () => { await loadUri(uri); await clickXpath('//button[@title="tryit"]'); - await clickXpath('//button[@title="Add Backdrop"]'); + await clickXpath('//button[@aria-label="Backdrop Library"]'); const el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('blue'); await clickText('Blue Sky'); // Should close the modal diff --git a/test/integration/sounds.test.js b/test/integration/sounds.test.js index e3154868417ae9194c3e0c4eb736ca3a4f70cdc9..fc36c55a5535e84ed0c9dd7e5f1bd57ba52614cb 100644 --- a/test/integration/sounds.test.js +++ b/test/integration/sounds.test.js @@ -37,13 +37,13 @@ describe('Working with sounds', () => { .accept(); // Add it back - await clickXpath('//button[@title="Add Sound"]'); + await clickXpath('//button[@aria-label="Sound Library"]'); let el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('meow'); await clickText('Meow', scope.modal); // Should close the modal // Add a new sound - await clickXpath('//button[@title="Add Sound"]'); + await clickXpath('//button[@aria-label="Sound Library"]'); el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('chom'); await clickText('Chomp'); // Should close the modal, then click the sounds in the selector @@ -86,11 +86,11 @@ describe('Working with sounds', () => { // Add a sound so this sprite has 2 sounds. await clickText('Sounds'); - await clickXpath('//button[@title="Add Sound"]'); + await clickXpath('//button[@aria-label="Sound Library"]'); await clickText('A Bass'); // Closes the modal // Now add a sprite with only one sound. - await clickXpath('//button[@title="Add Sprite"]'); + await clickXpath('//button[@aria-label="Sprite Library"]'); await clickText('Abby'); // Doing this used to crash the editor. await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for error