diff --git a/.travis.yml b/.travis.yml index 9ef064ce111a7c278c285ca2008e46817e80189d..714d64bb83ee776b0ab26fe831b4e481cf6b15cd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,22 +20,24 @@ script: - npm test - if [ "$TRAVIS_EVENT_TYPE" == "pull_request" ] && [ "$TRAVIS_BRANCH" == "master" ]; then npm run test:smoke; fi before_deploy: -- npm --no-git-tag-version version 0.1.0-prerelease.$(date +%Y%m%d%H%M%S) -- if [ "$TRAVIS_BRANCH" == "develop" ]; then export NPM_TAG=develop; fi -- git config --global user.email $(git log --pretty=format:"%ae" -n1) -- git config --global user.name $(git log --pretty=format:"%an" -n1) +- > + if [ -z "$BEFORE_DEPLOY_RAN" ]; then + npm --no-git-tag-version version 0.1.0-prerelease.$(date +%Y%m%d%H%M%S) + if [ "$TRAVIS_BRANCH" == "develop" ]; then export NPM_TAG=develop; fi + git config --global user.email $(git log --pretty=format:"%ae" -n1) + git config --global user.name $(git log --pretty=format:"%an" -n1) + export BEFORE_DEPLOY_RAN=true + fi deploy: - provider: script on: all_branches: true - condition: $TRAVIS_BRANCH != master skip_cleanup: true script: npm run deploy -- -x -e $TRAVIS_BRANCH -r https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git - provider: script on: - branch: master - skip_cleanup: true - script: npm run --silent deploy -- -x -a -r https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git + all_branches: true + script: npm run prune -- https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git - provider: npm on: branch: diff --git a/package.json b/package.json index 4e60e963fab86da413c505fdfdb0d3b6660c2c42..a5875e0f7bc893903cafafe6348b03209b57ffde 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "npm run clean && webpack --progress --colors --bail", "clean": "rimraf ./build && mkdirp build && rimraf ./dist && mkdirp dist", "deploy": "touch build/.nojekyll && gh-pages -t -d build -m \"Build for $(git log --pretty=format:%H -n1)\"", + "prune": "./prune-gh-pages.sh", "i18n:src": "babel src > tmp.js && rimraf tmp.js && build-i18n-src ./translations/messages/ ./translations/", "start": "webpack-dev-server", "test": "npm run test:lint && npm run test:unit && npm run build && npm run test:integration", @@ -39,8 +40,8 @@ "babel-preset-env": "^1.6.1", "babel-preset-react": "^6.22.0", "bowser": "1.9.3", - "chromedriver": "2.39.0", - "classnames": "2.2.5", + "chromedriver": "2.40.0", + "classnames": "2.2.6", "copy-webpack-plugin": "^4.5.1", "css-loader": "^0.28.11", "enzyme": "^3.1.0", @@ -57,6 +58,7 @@ "html-webpack-plugin": "^3.2.0", "immutable": "3.8.2", "jest": "^21.0.0", + "keymirror": "0.1.1", "lodash.bindall": "4.4.0", "lodash.debounce": "4.0.8", "lodash.defaultsdeep": "4.6.0", @@ -91,14 +93,14 @@ "redux-mock-store": "^1.2.3", "redux-throttle": "0.1.1", "rimraf": "^2.6.1", - "scratch-audio": "0.1.0-prerelease.1528210666", - "scratch-blocks": "0.1.0-prerelease.1528400332", - "scratch-l10n": "3.0.20180604162003", - "scratch-paint": "0.2.0-prerelease.20180607153112", - "scratch-render": "0.1.0-prerelease.20180605145739", - "scratch-storage": "0.5.0", - "scratch-vm": "0.1.0-prerelease.1528399883", - "scratch-svg-renderer": "0.2.0-prerelease.20180607141644", + "scratch-audio": "0.1.0-prerelease.1528996828", + "scratch-blocks": "0.1.0-prerelease.1529016587", + "scratch-l10n": "3.0.20180611175036", + "scratch-paint": "0.2.0-prerelease.20180614161221", + "scratch-render": "0.1.0-prerelease.20180615131212", + "scratch-storage": "0.5.1", + "scratch-svg-renderer": "0.2.0-prerelease.20180613184320", + "scratch-vm": "0.1.0-prerelease.1529017807", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.21.0", diff --git a/prune-gh-pages.sh b/prune-gh-pages.sh new file mode 100755 index 0000000000000000000000000000000000000000..c7d594fe01c3d24fb0d3c9e7ad7bc084ee0a8b91 --- /dev/null +++ b/prune-gh-pages.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# gh-pages cleanup script: Switches to gh-pages branch, and removes all +# directories that aren't listed as remote branches + +function deslash () { + # Recursively build a string of a directory's parents. E.g., + # deslashed "feature/test/branch" returns feature/test feature + deslashed=$(dirname $1) + if [[ $deslashed =~ .*/.* ]] + then + echo $deslashed $(deslash $deslashed) + else + echo $deslashed + fi +} + +repository=origin + +if [[ $1 != "" ]] +then + repository=$1 +fi + +# Cache current branch +current=$(git rev-parse --abbrev-ref HEAD) + +# Checkout most recent gh-pages +git fetch --force $repository gh-pages:gh-pages +git checkout gh-pages +git clean -fdx + +# Make an array of directories to not delete, from the list of remote branches +branches=$(git ls-remote --refs --quiet $repository | awk '{print $2}' | sed -e 's/refs\/heads\///') + +# Add parent directories of branches to the exclusion list (e.g. greenkeeper/) +for branch in $branches; do + if [[ $branch =~ .*/.* ]]; then + branches+=" $(deslash $branch)" + fi +done + +# Dedupe all the greenkeepers (or other duplicate parent directories) +branches=$(echo "${branches[@]}" | tr ' ' '\n' | sort -u | tr '\n' ' ') + +# Remove all directories that don't have corresponding branches +# It would be nice if we could exclude everything in .gitignore, but we're +# not on the branch with the .gitignore anymore... so we can't. +find . -type d \ + \( \ + -path ./.git -o \ + -path ./node_modules \ + $(printf " -o -path ./%s" $branches) \ + \) -prune \ + -o -mindepth 1 -type d \ + -exec rm -rfv {} \; + +# Push +git add -u +git commit -m "Remove stale directories" +git push $repository gh-pages + +# Return to where we were +git checkout -f $current +exit diff --git a/src/components/action-menu/action-menu.jsx b/src/components/action-menu/action-menu.jsx index 69d154fcc4df73d92e66cb6e935cc6f64e14495c..2725c502b421007f85c7ec4d8621ff213f35b162 100644 --- a/src/components/action-menu/action-menu.jsx +++ b/src/components/action-menu/action-menu.jsx @@ -76,8 +76,8 @@ class ActionMenu extends React.Component { // (possibly slow) action is started. return event => { ReactTooltip.hide(); + if (fn) fn(event); this.setState({forceHide: true, isOpen: false}, () => { - if (fn) fn(event); setTimeout(() => this.setState({forceHide: false})); }); }; diff --git a/src/components/asset-panel/selector.css b/src/components/asset-panel/selector.css index 98dff10c272eb58441dda16f7d1da69590a0d97a..3dc4e803d9e9d7f9c26c2c8c5a1da69b388bd943 100644 --- a/src/components/asset-panel/selector.css +++ b/src/components/asset-panel/selector.css @@ -47,12 +47,14 @@ $fade-out-distance: 100px; height: 0; flex-grow: 1; overflow-y: scroll; + display: flex; + flex-direction: column; } .list-item { width: 5rem; min-height: 5rem; - margin: 1rem auto; + margin: 0.5rem auto; } @media only screen and (max-width: $full-size-paint) { @@ -64,3 +66,9 @@ $fade-out-distance: 100px; width: 4rem; } } + + +.list-item.placeholder { + background: black; + filter: opacity(15%) brightness(20%); +} diff --git a/src/components/asset-panel/selector.jsx b/src/components/asset-panel/selector.jsx index 6c3f72edb19f70f6c35958b62bd40a19058139cf..a9f69134ba43a1c3e79c1467f11875acbb8b535a 100644 --- a/src/components/asset-panel/selector.jsx +++ b/src/components/asset-panel/selector.jsx @@ -1,22 +1,33 @@ import PropTypes from 'prop-types'; import React from 'react'; - +import classNames from 'classnames'; import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx'; - import Box from '../box/box.jsx'; import ActionMenu from '../action-menu/action-menu.jsx'; +import SortableAsset from './sortable-asset.jsx'; +import SortableHOC from '../../lib/sortable-hoc.jsx'; +import DragConstants from '../../lib/drag-constants'; + import styles from './selector.css'; const Selector = props => { const { buttons, + dragType, items, selectedItemIndex, + draggingIndex, + draggingType, + ordering, + onAddSortable, + onRemoveSortable, onDeleteClick, onDuplicateClick, onItemClick } = props; + const isRelevantDrag = draggingType === dragType; + let newButtonSection = null; if (buttons.length > 0) { @@ -38,20 +49,31 @@ const Selector = props => { <Box className={styles.wrapper}> <Box className={styles.listArea}> {items.map((item, index) => ( - <SpriteSelectorItem - assetId={item.assetId} - className={styles.listItem} - costumeURL={item.url} - details={item.details} - id={index} - key={`asset-${index}`} - name={item.name} - number={index + 1 /* 1-indexed */} - selected={index === selectedItemIndex} - onClick={onItemClick} - onDeleteButtonClick={onDeleteClick} - onDuplicateButtonClick={onDuplicateClick} - /> + <SortableAsset + id={item.name} + index={isRelevantDrag ? ordering.indexOf(index) : index} + key={item.name} + onAddSortable={onAddSortable} + onRemoveSortable={onRemoveSortable} + > + <SpriteSelectorItem + assetId={item.assetId} + className={classNames(styles.listItem, { + [styles.placeholder]: isRelevantDrag && index === draggingIndex + })} + costumeURL={item.url} + details={item.details} + dragType={dragType} + id={index} + index={index} + name={item.name} + number={index + 1 /* 1-indexed */} + selected={index === selectedItemIndex} + onClick={onItemClick} + onDeleteButtonClick={onDeleteClick} + onDuplicateButtonClick={onDuplicateClick} + /> + </SortableAsset> ))} </Box> {newButtonSection} @@ -65,14 +87,20 @@ Selector.propTypes = { img: PropTypes.string.isRequired, onClick: PropTypes.func })), + dragType: PropTypes.oneOf(Object.keys(DragConstants)), + draggingIndex: PropTypes.number, + draggingType: PropTypes.oneOf(Object.keys(DragConstants)), items: PropTypes.arrayOf(PropTypes.shape({ url: PropTypes.string, name: PropTypes.string.isRequired })), + onAddSortable: PropTypes.func, onDeleteClick: PropTypes.func, onDuplicateClick: PropTypes.func, onItemClick: PropTypes.func.isRequired, + onRemoveSortable: PropTypes.func, + ordering: PropTypes.arrayOf(PropTypes.number), selectedItemIndex: PropTypes.number.isRequired }; -export default Selector; +export default SortableHOC(Selector); diff --git a/src/components/asset-panel/sortable-asset.jsx b/src/components/asset-panel/sortable-asset.jsx new file mode 100644 index 0000000000000000000000000000000000000000..838ce6ebde5f6e6c347c5ccd542b8baa1b7a0119 --- /dev/null +++ b/src/components/asset-panel/sortable-asset.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import bindAll from 'lodash.bindall'; + +class SortableAsset extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'setRef' + ]); + } + componentDidMount () { + this.props.onAddSortable(this.ref); + } + componentWillUnmount () { + this.props.onRemoveSortable(this.ref); + } + setRef (ref) { + this.ref = ref; + } + render () { + return ( + <div + className={this.props.className} + ref={this.setRef} + style={{ + order: this.props.index + }} + > + {this.props.children} + </div> + ); + } +} + +SortableAsset.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, + index: PropTypes.number.isRequired, + onAddSortable: PropTypes.func.isRequired, + onRemoveSortable: PropTypes.func.isRequired +}; + +export default SortableAsset; diff --git a/src/components/browser-modal/browser-modal.css b/src/components/browser-modal/browser-modal.css index b93eb0db43f2ad5e67e4ecb30f552bd0ba8502f3..aa8840c4cc822574ff565273a858d52ced169227 100644 --- a/src/components/browser-modal/browser-modal.css +++ b/src/components/browser-modal/browser-modal.css @@ -1,6 +1,7 @@ @import "../../css/colors.css"; @import "../../css/units.css"; @import "../../css/typography.css"; +@import "../../css/z-index.css"; .modal-overlay { position: fixed; @@ -8,7 +9,7 @@ left: 0; right: 0; bottom: 0; - z-index: 1000; + z-index: $z-index-modal; background-color: $ui-modal-overlay; } diff --git a/src/components/cards/card.css b/src/components/cards/card.css index 84b6e6bf784773577d29e29954b663d19f74ab5a..1aeb1cb718d4e8c0877a12d5d70bfec9b99cd405 100644 --- a/src/components/cards/card.css +++ b/src/components/cards/card.css @@ -1,9 +1,11 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +@import "../../css/z-index.css"; .card-container { position:absolute; - z-index: 100; + z-index: $z-index-card; + margin: 0.5rem 2rem; } .left-card, .right-card { @@ -13,7 +15,7 @@ background: $ui-white; border: 1px solid $ui-tertiary; width: 10px; - z-index: 99; + z-index: 10; opacity: 0.9; overflow: hidden; } @@ -46,7 +48,7 @@ position: absolute; top: 50%; margin-top: -15px; - z-index: 101; + z-index: 20; user-select: none; cursor: pointer; background: $motion-primary; @@ -73,7 +75,7 @@ display: flex; flex-direction: column; cursor: move; - z-index: 101; + z-index: 20; overflow: hidden; box-shadow: 0px 5px 25px 5px $ui-black-transparent; align-items: center; diff --git a/src/components/coming-soon/coming-soon.css b/src/components/coming-soon/coming-soon.css index db737e1c80f5a8eefca91d8b1a5e66e8a0d629cd..bdd7f87eb4f031a9155aec4a0fd3f6affda1ad0d 100644 --- a/src/components/coming-soon/coming-soon.css +++ b/src/components/coming-soon/coming-soon.css @@ -5,17 +5,18 @@ */ @import "../../css/colors.css"; +@import "../../css/z-index.css"; .coming-soon { background-color: $data-primary !important; - border: 1px solid $ui-black-transparent) !important; + border: 1px solid $ui-black-transparent !important; border-radius: .25rem !important; box-shadow: 0 0 .5rem $ui-black-transparent !important; padding: .75rem 1rem !important; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important; font-size: 1rem !important; line-height: 1.25rem !important; - z-index: 100 !important; + z-index: $z-index-coming-soon !important; } .coming-soon:after { diff --git a/src/components/context-menu/context-menu.css b/src/components/context-menu/context-menu.css index ff2505ff955e0c318c8edcd01864f21e20253abc..5a02ce797d9498990738f75f497a299acf441608 100644 --- a/src/components/context-menu/context-menu.css +++ b/src/components/context-menu/context-menu.css @@ -1,5 +1,6 @@ @import "../../css/colors.css"; @import "../../css/units.css"; +@import "../../css/z-index.css"; .context-menu { min-width: 130px; @@ -13,7 +14,7 @@ box-shadow: 0px 0px 5px 1px $ui-black-transparent; pointer-events: none; transition: opacity 0.2s ease; - z-index: 200; /* Above the stage */ + z-index: $z-index-context-menu; } .menu-item { diff --git a/src/components/drag-layer/drag-layer.css b/src/components/drag-layer/drag-layer.css index 711b5eea813d6a2d80b97d75c98c40fbb68e8457..af6ae5dac2cb920bebbc6ee83351894e60826aa8 100644 --- a/src/components/drag-layer/drag-layer.css +++ b/src/components/drag-layer/drag-layer.css @@ -1,10 +1,11 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +@import "../../css/z-index.css"; .drag-layer { position: fixed; pointer-events: none; - z-index: 1000; /* Above everything */ + z-index: $z-index-drag-layer; left: 0; top: 0; width: 100%; @@ -18,11 +19,19 @@ .image { max-width: 80px; + max-height: 80px; + min-width: 50px; + min-height: 50px; /* Center the dragging image on the given position */ margin-left: -50%; margin-top: -50%; + padding: 0.25rem; + border: 2px solid $motion-primary; + background: $ui-white; + border-radius: 0.5rem; + /* Use the same drop shadow as stage dragging */ - filter: drop-shadow(5px 5px 5px $ui-black-transparent); + box-shadow: 5px 5px 5px $ui-black-transparent; } diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 2e4e7b846811c8fced93abbc6c004c19fde51ad8..af7500fb0a3d9e0fd57c3bda81a3fa3fe5d97a95 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -1,5 +1,6 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +@import "../../css/z-index.css"; .page-wrapper { height: 100%; @@ -162,11 +163,23 @@ */ display: flex; flex-direction: column; +} - /* Fix the max width to max stage size (defined in layout_constants.js) + gutter size */ +.stage-and-target-wrapper.large { + /* Fix the max width to max large stage size (defined in layout_constants.js) + gutter size */ max-width: calc(480px + calc($space * 2)); } +.stage-and-target-wrapper.large-constrained { + /* Fix the max width to max largeConstrained stage size (defined in layout_constants.js) + gutter size */ + max-width: calc(408px + calc($space * 2)); +} + +.stage-and-target-wrapper.small { + /* Fix the max width to max small stage size (defined in layout_constants.js) + gutter size */ + max-width: calc(240px + calc($space * 2)); +} + .target-wrapper { display: flex; flex-grow: 1; @@ -176,6 +189,8 @@ padding-left: $space; padding-right: $space; + min-height: 0; /* this makes it work in Firefox */ + /* For making the sprite-selector a scrollable pane @todo: Not working in Safari @@ -190,7 +205,7 @@ position: absolute; bottom: 0; left: 0; - z-index: 50; /** Force extension button above the ScratchBlocks flyout. */ + z-index: $z-index-extension-button; background: $motion-primary; border: 1px solid $motion-primary; diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 66a08f64f3d611c7d51a93a75df274710a7b0250..2d3cf79b8869eab536a4f3d7b2a1ab79f2fb59f7 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -1,7 +1,10 @@ import classNames from 'classnames'; +import omit from 'lodash.omit'; import PropTypes from 'prop-types'; import React from 'react'; import {defineMessages, FormattedMessage, injectIntl, intlShape} from 'react-intl'; +import {connect} from 'react-redux'; +import MediaQuery from 'react-responsive'; import {Tab, Tabs, TabList, TabPanel} from 'react-tabs'; import tabStyles from 'react-tabs/style/react-tabs.css'; import VM from 'scratch-vm'; @@ -15,6 +18,8 @@ import StageWrapper from '../../containers/stage-wrapper.jsx'; import Loader from '../loader/loader.jsx'; import Box from '../box/box.jsx'; import MenuBar from '../menu-bar/menu-bar.jsx'; +import CostumeLibrary from '../../containers/costume-library.jsx'; +import BackdropLibrary from '../../containers/backdrop-library.jsx'; import Backpack from '../../containers/backpack.jsx'; import PreviewModal from '../../containers/preview-modal.jsx'; @@ -24,6 +29,9 @@ import TipsLibrary from '../../containers/tips-library.jsx'; import Cards from '../../containers/cards.jsx'; import DragLayer from '../../containers/drag-layer.jsx'; +import layout, {STAGE_SIZE_MODES} from '../../lib/layout-constants'; +import {resolveStageSize} from '../../lib/screen-utils'; + import styles from './gui.css'; import addExtensionIcon from './icon--extensions.svg'; import codeIcon from './icon--code.svg'; @@ -38,7 +46,7 @@ const messages = defineMessages({ } }); -// Cache this value to only retreive it once the first time. +// Cache this value to only retrieve it once the first time. // Assume that it doesn't change for a session. let isRendererSupported = null; @@ -46,10 +54,12 @@ const GUIComponent = props => { const { activeTabIndex, basePath, + backdropLibraryVisible, backpackOptions, blocksTabVisible, cardsVisible, children, + costumeLibraryVisible, costumesTabVisible, enableCommunity, importInfoVisible, @@ -60,13 +70,16 @@ const GUIComponent = props => { onActivateCostumesTab, onActivateSoundsTab, onActivateTab, + onRequestCloseBackdropLibrary, + onRequestCloseCostumeLibrary, previewInfoVisible, targetIsStage, soundsTabVisible, + stageSizeMode, tipsLibraryVisible, vm, ...componentProps - } = props; + } = omit(props, 'dispatch'); if (children) { return <Box {...componentProps}>{children}</Box>; } @@ -84,149 +97,173 @@ const GUIComponent = props => { isRendererSupported = Renderer.isSupported(); } - return isPlayerOnly ? ( - <StageWrapper - isRendererSupported={isRendererSupported} - vm={vm} - /> - ) : ( - <Box - className={styles.pageWrapper} - {...componentProps} - > - {previewInfoVisible ? ( - <PreviewModal /> - ) : null} - {loading ? ( - <Loader /> - ) : null} - {importInfoVisible ? ( - <ImportModal /> - ) : null} - {isRendererSupported ? null : ( - <WebGlModal /> - )} - {tipsLibraryVisible ? ( - <TipsLibrary /> - ) : null} - {cardsVisible ? ( - <Cards /> - ) : null} - <MenuBar enableCommunity={enableCommunity} /> - <Box className={styles.bodyWrapper}> - <Box className={styles.flexWrapper}> - <Box className={styles.editorWrapper}> - <Tabs - className={tabClassNames.tabs} - forceRenderTabPanel={true} // eslint-disable-line react/jsx-boolean-value - selectedIndex={activeTabIndex} - selectedTabClassName={tabClassNames.tabSelected} - selectedTabPanelClassName={tabClassNames.tabPanelSelected} - onSelect={onActivateTab} - > - <TabList className={tabClassNames.tabList}> - <Tab className={tabClassNames.tab}> - <img - draggable={false} - src={codeIcon} - /> - <FormattedMessage - defaultMessage="Code" - description="Button to get to the code panel" - id="gui.gui.codeTab" - /> - </Tab> - <Tab - className={tabClassNames.tab} - onClick={onActivateCostumesTab} - > - <img - draggable={false} - src={costumesIcon} - /> - {targetIsStage ? ( - <FormattedMessage - defaultMessage="Backdrops" - description="Button to get to the backdrops panel" - id="gui.gui.backdropsTab" + return (<MediaQuery minWidth={layout.fullSizeMinWidth}>{isFullSize => { + const stageSize = resolveStageSize(stageSizeMode, isFullSize); + + return isPlayerOnly ? ( + <StageWrapper + isRendererSupported={isRendererSupported} + stageSize={stageSize} + vm={vm} + /> + ) : ( + <Box + className={styles.pageWrapper} + {...componentProps} + > + {previewInfoVisible ? ( + <PreviewModal /> + ) : null} + {loading ? ( + <Loader /> + ) : null} + {importInfoVisible ? ( + <ImportModal /> + ) : null} + {isRendererSupported ? null : ( + <WebGlModal /> + )} + {tipsLibraryVisible ? ( + <TipsLibrary /> + ) : null} + {cardsVisible ? ( + <Cards /> + ) : null} + {costumeLibraryVisible ? ( + <CostumeLibrary + vm={vm} + onRequestClose={onRequestCloseCostumeLibrary} + /> + ) : null} + {backdropLibraryVisible ? ( + <BackdropLibrary + vm={vm} + onRequestClose={onRequestCloseBackdropLibrary} + /> + ) : null} + <MenuBar enableCommunity={enableCommunity} /> + <Box className={styles.bodyWrapper}> + <Box className={styles.flexWrapper}> + <Box className={styles.editorWrapper}> + <Tabs + forceRenderTabPanel + className={tabClassNames.tabs} + selectedIndex={activeTabIndex} + selectedTabClassName={tabClassNames.tabSelected} + selectedTabPanelClassName={tabClassNames.tabPanelSelected} + onSelect={onActivateTab} + > + <TabList className={tabClassNames.tabList}> + <Tab className={tabClassNames.tab}> + <img + draggable={false} + src={codeIcon} /> - ) : ( <FormattedMessage - defaultMessage="Costumes" - description="Button to get to the costumes panel" - id="gui.gui.costumesTab" + defaultMessage="Code" + description="Button to get to the code panel" + id="gui.gui.codeTab" /> - )} - </Tab> - <Tab - className={tabClassNames.tab} - onClick={onActivateSoundsTab} - > - <img - draggable={false} - src={soundsIcon} - /> - <FormattedMessage - defaultMessage="Sounds" - description="Button to get to the sounds panel" - id="gui.gui.soundsTab" - /> - </Tab> - </TabList> - <TabPanel className={tabClassNames.tabPanel}> - <Box className={styles.blocksWrapper}> - <Blocks - grow={1} - isVisible={blocksTabVisible} - options={{ - media: `${basePath}static/blocks-media/` - }} - vm={vm} - /> - </Box> - <Box className={styles.extensionButtonContainer}> - <button - className={styles.extensionButton} - title={intl.formatMessage(messages.addExtension)} - onClick={onExtensionButtonClick} + </Tab> + <Tab + className={tabClassNames.tab} + onClick={onActivateCostumesTab} > <img - className={styles.extensionButtonIcon} draggable={false} - src={addExtensionIcon} + src={costumesIcon} /> - </button> - </Box> - </TabPanel> - <TabPanel className={tabClassNames.tabPanel}> - {costumesTabVisible ? <CostumeTab vm={vm} /> : null} - </TabPanel> - <TabPanel className={tabClassNames.tabPanel}> - {soundsTabVisible ? <SoundTab vm={vm} /> : null} - </TabPanel> - </Tabs> - {backpackOptions.visible ? ( - <Backpack host={backpackOptions.host} /> - ) : null} - </Box> + {targetIsStage ? ( + <FormattedMessage + defaultMessage="Backdrops" + description="Button to get to the backdrops panel" + id="gui.gui.backdropsTab" + /> + ) : ( + <FormattedMessage + defaultMessage="Costumes" + description="Button to get to the costumes panel" + id="gui.gui.costumesTab" + /> + )} + </Tab> + <Tab + className={tabClassNames.tab} + onClick={onActivateSoundsTab} + > + <img + draggable={false} + src={soundsIcon} + /> + <FormattedMessage + defaultMessage="Sounds" + description="Button to get to the sounds panel" + id="gui.gui.soundsTab" + /> + </Tab> + </TabList> + <TabPanel className={tabClassNames.tabPanel}> + <Box className={styles.blocksWrapper}> + <Blocks + grow={1} + isVisible={blocksTabVisible} + options={{ + media: `${basePath}static/blocks-media/` + }} + stageSize={stageSize} + vm={vm} + /> + </Box> + <Box className={styles.extensionButtonContainer}> + <button + className={styles.extensionButton} + title={intl.formatMessage(messages.addExtension)} + onClick={onExtensionButtonClick} + > + <img + className={styles.extensionButtonIcon} + draggable={false} + src={addExtensionIcon} + /> + </button> + </Box> + </TabPanel> + <TabPanel className={tabClassNames.tabPanel}> + {costumesTabVisible ? <CostumeTab vm={vm} /> : null} + </TabPanel> + <TabPanel className={tabClassNames.tabPanel}> + {soundsTabVisible ? <SoundTab vm={vm} /> : null} + </TabPanel> + </Tabs> + {backpackOptions.visible ? ( + <Backpack host={backpackOptions.host} /> + ) : null} + </Box> - <Box className={styles.stageAndTargetWrapper}> - <StageWrapper - isRendererSupported={isRendererSupported} - vm={vm} - /> - <Box className={styles.targetWrapper}> - <TargetPane vm={vm} /> + <Box className={classNames(styles.stageAndTargetWrapper, styles[stageSize])}> + <StageWrapper + isRendererSupported={isRendererSupported} + stageSize={stageSize} + vm={vm} + /> + <Box className={styles.targetWrapper}> + <TargetPane + stageSize={stageSize} + vm={vm} + /> + </Box> </Box> </Box> </Box> + <DragLayer /> </Box> - <DragLayer /> - </Box> - ); + ); + }}</MediaQuery>); }; + GUIComponent.propTypes = { activeTabIndex: PropTypes.number, + backdropLibraryVisible: PropTypes.bool, backpackOptions: PropTypes.shape({ host: PropTypes.string, visible: PropTypes.bool @@ -235,6 +272,7 @@ GUIComponent.propTypes = { blocksTabVisible: PropTypes.bool, cardsVisible: PropTypes.bool, children: PropTypes.node, + costumeLibraryVisible: PropTypes.bool, costumesTabVisible: PropTypes.bool, enableCommunity: PropTypes.bool, importInfoVisible: PropTypes.bool, @@ -245,9 +283,12 @@ GUIComponent.propTypes = { onActivateSoundsTab: PropTypes.func, onActivateTab: PropTypes.func, onExtensionButtonClick: PropTypes.func, + onRequestCloseBackdropLibrary: PropTypes.func, + onRequestCloseCostumeLibrary: PropTypes.func, onTabSelect: PropTypes.func, previewInfoVisible: PropTypes.bool, soundsTabVisible: PropTypes.bool, + stageSizeMode: PropTypes.oneOf(Object.keys(STAGE_SIZE_MODES)), targetIsStage: PropTypes.bool, tipsLibraryVisible: PropTypes.bool, vm: PropTypes.instanceOf(VM).isRequired @@ -257,6 +298,15 @@ GUIComponent.defaultProps = { host: null, visible: false }, - basePath: './' + basePath: './', + stageSizeMode: STAGE_SIZE_MODES.large }; -export default injectIntl(GUIComponent); + +const mapStateToProps = state => ({ + // This is the button's mode, as opposed to the actual current state + stageSizeMode: state.scratchGui.stageSize.stageSize +}); + +export default injectIntl(connect( + mapStateToProps +)(GUIComponent)); diff --git a/src/components/import-modal/import-modal.css b/src/components/import-modal/import-modal.css index 93480429b19e92ee3f8d4fa08883d2a87b9a7530..b41c02b1baaa92923fc92af9ecbe3d22667cef1f 100644 --- a/src/components/import-modal/import-modal.css +++ b/src/components/import-modal/import-modal.css @@ -1,6 +1,7 @@ @import "../../css/colors.css"; @import "../../css/units.css"; @import "../../css/typography.css"; +@import "../../css/z-index.css"; .modal-overlay { position: fixed; @@ -8,7 +9,7 @@ left: 0; right: 0; bottom: 0; - z-index: 1000; + z-index: $z-index-modal; background-color: $ui-modal-overlay; } diff --git a/src/components/language-selector/language-selector.css b/src/components/language-selector/language-selector.css index 5da79cf25f3375ae6a00bdcbde105205c2e4dc61..d8ae588ae13fd05fc9d6b07eb6316de7427a4613 100644 --- a/src/components/language-selector/language-selector.css +++ b/src/components/language-selector/language-selector.css @@ -1,13 +1,6 @@ @import "../../css/colors.css"; @import "../../css/units.css"; -.group { - display: inline-flex; - flex-direction: row; /* makes columns, for each label/form group */ - align-items: center; - vertical-align: middle; -} - .language-icon { height: 1.5rem; } @@ -17,7 +10,7 @@ } .language-select { - width: 100%; + margin: .5rem; height: 1.85rem; border: 1px solid $motion-primary; user-select: none; diff --git a/src/components/language-selector/language-selector.jsx b/src/components/language-selector/language-selector.jsx index befe4797681a1097ad47c05f360129b045658b65..ad7a9aa90f96aac8e27e7c97994f29049d98edcf 100644 --- a/src/components/language-selector/language-selector.jsx +++ b/src/components/language-selector/language-selector.jsx @@ -1,56 +1,48 @@ -import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; import Box from '../box/box.jsx'; import locales from 'scratch-l10n'; -import languageIcon from './language-icon.svg'; -import dropdownCaret from './dropdown-caret.svg'; import styles from './language-selector.css'; -const LanguageSelector = ({ - currentLocale, - onChange, - open, - ...props -}) => ( - <Box {...props}> - <div className={styles.group}> - {open ? ( - <select - disabled - aria-label="language selector" - className={styles.languageSelect} - value={currentLocale} - onChange={onChange} +class LanguageSelector extends React.Component { + render () { + const { + componentRef, + currentLocale, + onChange, + ...componentProps + } = this.props; + return ( + <Box + {...componentProps} + > + <div + className={styles.group} + ref={componentRef} > - {Object.keys(locales).map(locale => ( - <option - key={locale} - value={locale} - > - {locales[locale].name} - </option> - ))} - </select> - ) : ( - <React.Fragment> - <img - className={classNames(styles.languageIcon, styles.disabled)} - src={languageIcon} - /> - <img - className={classNames(styles.dropdownCaret, styles.disabled)} - src={dropdownCaret} - /> - </React.Fragment> - )} - </div> - </Box> -); - + <select + className={styles.languageSelect} + value={currentLocale} + onChange={onChange} + > + {Object.keys(locales).map(locale => ( + <option + key={locale} + value={locale} + > + {locales[locale].name} + </option> + ))} + </select> + </div> + </Box> + ); + } +} LanguageSelector.propTypes = { + componentRef: PropTypes.func, currentLocale: PropTypes.string, onChange: PropTypes.func, open: PropTypes.bool diff --git a/src/components/library-item/library-item.css b/src/components/library-item/library-item.css index 7b91cc96acf857c8957877fa73ab431f239385b9..3258b4a7bd16162f239d73990a26df77894f2ea0 100644 --- a/src/components/library-item/library-item.css +++ b/src/components/library-item/library-item.css @@ -5,7 +5,7 @@ display: flex; flex-direction: column; align-items: center; - justify-content: center; + justify-content: flex-start; flex-basis: 160px; max-width: 160px; height: 160px; diff --git a/src/components/library/library.css b/src/components/library/library.css index 5a2b6e1f0f47a9f58822298f791453d860ed5042..8c132c389347c66ca9d539c58f8deed967764e76 100644 --- a/src/components/library/library.css +++ b/src/components/library/library.css @@ -5,6 +5,7 @@ display: flex; justify-content: flex-start; align-content: flex-start; + align-items: flex-start; background: $ui-secondary; flex-grow: 1; flex-wrap: wrap; diff --git a/src/components/loader/loader.css b/src/components/loader/loader.css index 8ebbdb5cfe9d47e7a9eb880ee66e38272c8d86d4..9f97b3e811596bb6a6811d707229caaaada97159 100644 --- a/src/components/loader/loader.css +++ b/src/components/loader/loader.css @@ -1,4 +1,5 @@ @import "../../css/colors.css"; +@import "../../css/z-index.css"; .background { position: fixed; @@ -6,7 +7,7 @@ left: 0; width: 100%; height: 100%; - z-index: 999; /* Below preview modal */ + z-index: $z-index-loader; display: flex; justify-content: center; align-items: center; diff --git a/src/components/menu-bar/menu-bar.css b/src/components/menu-bar/menu-bar.css index 96c64b345fd7e60c9ff9d1bc52c01dcdc9aa6ed8..eba5f885eff82eae922578bdb57acaa75efbecd0 100644 --- a/src/components/menu-bar/menu-bar.css +++ b/src/components/menu-bar/menu-bar.css @@ -1,5 +1,6 @@ @import "../../css/colors.css"; @import "../../css/units.css"; +@import "../../css/z-index.css"; .menu-bar { display: flex; @@ -43,9 +44,16 @@ vertical-align: middle; } +.language-icon { + height: 1.5rem; +} + +.language-menu { + display: inline-flex; +} + .menu { - /* blocklyToolboxDiv is 40 */ - z-index: 50; + z-index: $z-index-menu-bar; top: $menu-bar-height; } diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 7742e210e362ac0d1772e98c447cb792ecec8a46..e4de61ffded598b2c3f60db74d61457aab486009 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import {connect} from 'react-redux'; -import {FormattedMessage} from 'react-intl'; +import {defineMessages, FormattedMessage, injectIntl, intlShape} from 'react-intl'; import PropTypes from 'prop-types'; import React from 'react'; @@ -22,7 +22,10 @@ import { fileMenuOpen, openEditMenu, closeEditMenu, - editMenuOpen + editMenuOpen, + openLanguageMenu, + closeLanguageMenu, + languageMenuOpen } from '../../reducers/menus'; import styles from './menu-bar.css'; @@ -32,29 +35,56 @@ import feedbackIcon from './icon--feedback.svg'; import profileIcon from './icon--profile.png'; import communityIcon from './icon--see-community.svg'; import dropdownCaret from '../language-selector/dropdown-caret.svg'; +import languageIcon from '../language-selector/language-icon.svg'; + import scratchLogo from './scratch-logo.svg'; import helpIcon from './icon--help.svg'; +const ariaMessages = defineMessages({ + language: { + id: 'gui.menuBar.LanguageSelector', + defaultMessage: 'language selector', + description: 'accessibility text for the language selection menu' + }, + howTo: { + id: 'gui.menuBar.howToLibrary', + defaultMessage: 'How-to Library', + description: 'accessibility text for the how-to library button' + } +}); + const MenuBarItemTooltip = ({ children, className, + enable, id, place = 'bottom' -}) => ( - <ComingSoonTooltip - className={classNames(styles.comingSoon, className)} - place={place} - tooltipClassName={styles.comingSoonTooltip} - tooltipId={id} - > - {children} - </ComingSoonTooltip> -); +}) => { + if (enable) { + return ( + <React.Fragment> + {children} + </React.Fragment> + ); + } + return ( + <ComingSoonTooltip + className={classNames(styles.comingSoon, className)} + place={place} + tooltipClassName={styles.comingSoonTooltip} + tooltipId={id} + > + {children} + </ComingSoonTooltip> + ); +}; + MenuBarItemTooltip.propTypes = { children: PropTypes.node, className: PropTypes.string, + enable: PropTypes.bool, id: PropTypes.string, place: PropTypes.oneOf(['top', 'bottom', 'left', 'right']) }; @@ -111,12 +141,37 @@ const MenuBar = props => ( src={scratchLogo} /> </div> - <div className={classNames(styles.menuBarItem, styles.hoverable)}> + <div + className={classNames(styles.menuBarItem, styles.hoverable, { + [styles.active]: props.languageMenuOpen + })} + onMouseUp={props.onClickLanguage} + > <MenuBarItemTooltip + enable={window.location.search.indexOf('enable=language') !== -1} id="menubar-selector" place="right" > - <LanguageSelector /> + <div + aria-label={props.intl.formatMessage(ariaMessages.language)} + className={classNames(styles.languageMenu)} + > + <img + className={styles.languageIcon} + src={languageIcon} + /> + <img + className={styles.dropdownCaret} + src={dropdownCaret} + /> + </div> + <MenuBarMenu + open={props.languageMenuOpen} + onRequestClose={props.onRequestCloseLanguage} + > + <LanguageSelector /> + </MenuBarMenu> + </MenuBarItemTooltip> </div> <div @@ -314,7 +369,7 @@ const MenuBar = props => ( </div> <div className={styles.accountInfoWrapper}> <div - aria-label="How-to Library" + aria-label={props.intl.formatMessage(ariaMessages.howTo)} className={classNames(styles.menuBarItem, styles.hoverable)} onClick={props.onOpenTipLibrary} > @@ -369,17 +424,22 @@ MenuBar.propTypes = { editMenuOpen: PropTypes.bool, enableCommunity: PropTypes.bool, fileMenuOpen: PropTypes.bool, + intl: intlShape, + languageMenuOpen: PropTypes.bool, onClickEdit: PropTypes.func, onClickFile: PropTypes.func, + onClickLanguage: PropTypes.func, onOpenTipLibrary: PropTypes.func, onRequestCloseEdit: PropTypes.func, onRequestCloseFile: PropTypes.func, + onRequestCloseLanguage: PropTypes.func, onSeeCommunity: PropTypes.func }; const mapStateToProps = state => ({ fileMenuOpen: fileMenuOpen(state), - editMenuOpen: editMenuOpen(state) + editMenuOpen: editMenuOpen(state), + languageMenuOpen: languageMenuOpen(state) }); const mapDispatchToProps = dispatch => ({ @@ -388,10 +448,12 @@ const mapDispatchToProps = dispatch => ({ onRequestCloseFile: () => dispatch(closeFileMenu()), onClickEdit: () => dispatch(openEditMenu()), onRequestCloseEdit: () => dispatch(closeEditMenu()), + onClickLanguage: () => dispatch(openLanguageMenu()), + onRequestCloseLanguage: () => dispatch(closeLanguageMenu()), onSeeCommunity: () => dispatch(setPlayer(true)) }); -export default connect( +export default injectIntl(connect( mapStateToProps, mapDispatchToProps -)(MenuBar); +)(MenuBar)); diff --git a/src/components/modal/modal.css b/src/components/modal/modal.css index 2a69d8ba63df54fa1973bcc98e8141dcbc499126..3aa21280beba80b61181349e5ae8a1bc38b52459 100644 --- a/src/components/modal/modal.css +++ b/src/components/modal/modal.css @@ -1,5 +1,6 @@ @import "../../css/colors.css"; @import "../../css/units.css"; +@import "../../css/z-index.css"; .modal-overlay { position: fixed; @@ -7,7 +8,7 @@ left: 0; right: 0; bottom: 0; - z-index: 1000; + z-index: $z-index-modal; background-color: $ui-modal-overlay; } diff --git a/src/components/monitor/monitor.css b/src/components/monitor/monitor.css index f7337e90f6c9217f112f08169ee96332e0873aae..97a62e02d23916200db10035b79b42f2adc4aa3d 100644 --- a/src/components/monitor/monitor.css +++ b/src/components/monitor/monitor.css @@ -1,10 +1,11 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +@import "../../css/z-index.css"; .monitor-container { position: absolute; background: $ui-primary; - z-index: 100; + z-index: $z-index-monitor; border: 1px solid $ui-black-transparent; border-radius: calc($space / 2); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; @@ -19,7 +20,7 @@ } .dragging { - z-index: 101; + z-index: $z-index-monitor-dragging; box-shadow: 3px 3px 5px #888888; } diff --git a/src/components/monitor/monitor.jsx b/src/components/monitor/monitor.jsx index 77726194d14885371f269a79560728574c1f38e4..7be0875e5bc5960206ec5fa084c1350463bd3cae 100644 --- a/src/components/monitor/monitor.jsx +++ b/src/components/monitor/monitor.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import Draggable from 'react-draggable'; import {FormattedMessage} from 'react-intl'; @@ -52,7 +53,11 @@ const MonitorComponent = props => ( })} </Box> </Draggable> - {props.mode === 'list' ? null : ( + {props.mode === 'list' ? null : ReactDOM.createPortal(( + // Use a portal to render the context menu outside the flow to avoid + // positioning conflicts between the monitors `transform: scale` and + // the context menus `position: fixed`. For more details, see + // http://meyerweb.com/eric/thoughts/2011/09/12/un-fixing-fixed-elements-with-css-transforms/ <ContextMenu id={`monitor-${props.label}`}> <MenuItem onClick={props.onSetModeToDefault}> <FormattedMessage @@ -78,7 +83,7 @@ const MonitorComponent = props => ( </MenuItem> ) : null} </ContextMenu> - )} + ), document.body)} </ContextMenuTrigger> ); diff --git a/src/components/preview-modal/preview-modal.css b/src/components/preview-modal/preview-modal.css index 837ed92cfef69fd1ff77f9567483455f7c02d074..2c35acf63e0f78fbca407f74b063e691e105f2ec 100644 --- a/src/components/preview-modal/preview-modal.css +++ b/src/components/preview-modal/preview-modal.css @@ -1,6 +1,7 @@ @import "../../css/colors.css"; @import "../../css/units.css"; @import "../../css/typography.css"; +@import "../../css/z-index.css"; .modal-overlay { position: fixed; @@ -8,7 +9,7 @@ left: 0; right: 0; bottom: 0; - z-index: 1000; + z-index: $z-index-modal; background-color: $ui-modal-overlay; } diff --git a/src/components/sprite-info/sprite-info.jsx b/src/components/sprite-info/sprite-info.jsx index 7213277b4a265b2b1cb3e2100202da8941db3efe..09d5c96c7a0da7253f18b5652d22a4ae6d7c5272 100644 --- a/src/components/sprite-info/sprite-info.jsx +++ b/src/components/sprite-info/sprite-info.jsx @@ -1,7 +1,6 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; -import MediaQuery from 'react-responsive'; import Box from '../box/box.jsx'; import Label from '../forms/label.jsx'; @@ -9,7 +8,7 @@ import Input from '../forms/input.jsx'; import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl'; -import layout from '../../lib/layout-constants.js'; +import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; import styles from './sprite-info.css'; import xIcon from './icon--x.svg'; @@ -34,12 +33,17 @@ class SpriteInfo extends React.Component { this.props.disabled !== nextProps.disabled || this.props.name !== nextProps.name || this.props.size !== nextProps.size || + this.props.stageSize !== nextProps.stageSize || this.props.visible !== nextProps.visible || this.props.x !== nextProps.x || this.props.y !== nextProps.y ); } render () { + const { + stageSize + } = this.props; + const sprite = ( <FormattedMessage defaultMessage="Sprite" @@ -68,80 +72,110 @@ class SpriteInfo extends React.Component { id="gui.SpriteInfo.direction" /> ); - return ( - <Box - className={styles.spriteInfo} - > - <div className={classNames(styles.row, styles.rowPrimary)}> - <div className={styles.group}> - <Label text={sprite}> - <BufferedInput - className={styles.spriteInput} - disabled={this.props.disabled} - placeholder={this.props.intl.formatMessage(messages.spritePlaceholder)} - tabIndex="0" - type="text" - value={this.props.disabled ? '' : this.props.name} - onSubmit={this.props.onChangeName} + + const spriteNameInput = ( + <BufferedInput + className={styles.spriteInput} + disabled={this.props.disabled} + placeholder={this.props.intl.formatMessage(messages.spritePlaceholder)} + tabIndex="0" + type="text" + value={this.props.disabled ? '' : this.props.name} + onSubmit={this.props.onChangeName} + /> + ); + + const xPosition = ( + <div className={styles.group}> + { + (stageSize === STAGE_DISPLAY_SIZES.large) ? + <div className={styles.iconWrapper}> + <img + aria-hidden="true" + className={classNames(styles.xIcon, styles.icon)} + src={xIcon} /> - </Label> - </div> + </div> : + null + } + <Label text="x"> + <BufferedInput + small + disabled={this.props.disabled} + placeholder="x" + tabIndex="0" + type="text" + value={this.props.disabled ? '' : this.props.x} + onSubmit={this.props.onChangeX} + /> + </Label> + </div> + ); - <div className={styles.group}> - <MediaQuery minWidth={layout.fullSizeMinWidth}> - <div className={styles.iconWrapper}> - <img - aria-hidden="true" - className={classNames(styles.xIcon, styles.icon)} - src={xIcon} - /> - </div> - </MediaQuery> - <Label text="x"> - <BufferedInput - small - disabled={this.props.disabled} - placeholder="x" - tabIndex="0" - type="text" - value={this.props.disabled ? '' : this.props.x} - onSubmit={this.props.onChangeX} + const yPosition = ( + <div className={styles.group}> + { + (stageSize === STAGE_DISPLAY_SIZES.large) ? + <div className={styles.iconWrapper}> + <img + aria-hidden="true" + className={classNames(styles.yIcon, styles.icon)} + src={yIcon} /> - </Label> + </div> : + null + } + <Label text="y"> + <BufferedInput + small + disabled={this.props.disabled} + placeholder="y" + tabIndex="0" + type="text" + value={this.props.disabled ? '' : this.props.y} + onSubmit={this.props.onChangeY} + /> + </Label> + </div> + ); + + if (stageSize === STAGE_DISPLAY_SIZES.small) { + return ( + <Box className={styles.spriteInfo}> + <div className={classNames(styles.row, styles.rowPrimary)}> + <div className={styles.group}> + {spriteNameInput} + </div> + </div> + <div className={classNames(styles.row, styles.rowSecondary)}> + {xPosition} + {yPosition} </div> + </Box> + ); + } + return ( + <Box className={styles.spriteInfo}> + <div className={classNames(styles.row, styles.rowPrimary)}> <div className={styles.group}> - <MediaQuery minWidth={layout.fullSizeMinWidth}> - <div className={styles.iconWrapper}> - <img - aria-hidden="true" - className={classNames(styles.yIcon, styles.icon)} - src={yIcon} - /> - </div> - </MediaQuery> - <Label text="y"> - <BufferedInput - small - disabled={this.props.disabled} - placeholder="y" - tabIndex="0" - type="text" - value={this.props.disabled ? '' : this.props.y} - onSubmit={this.props.onChangeY} - /> + <Label text={sprite}> + {spriteNameInput} </Label> </div> + {xPosition} + {yPosition} </div> - <div className={classNames(styles.row, styles.rowSecondary)}> <div className={styles.group}> - <MediaQuery minWidth={layout.fullSizeMinWidth}> - <Label - secondary - text={showLabel} - /> - </MediaQuery> + { + stageSize === STAGE_DISPLAY_SIZES.large ? + <Label + secondary + text={showLabel} + /> : + null + } <div> <div className={classNames( @@ -242,6 +276,7 @@ SpriteInfo.propTypes = { PropTypes.string, PropTypes.number ]), + stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, visible: PropTypes.bool, x: PropTypes.oneOfType([ PropTypes.string, diff --git a/src/components/sprite-selector-item/sprite-selector-item.css b/src/components/sprite-selector-item/sprite-selector-item.css index ed95f7134a3e93480a17e4d984aa45253d663b44..b54e8f720743d4559b1b4e0bb4604233835a7dc4 100644 --- a/src/components/sprite-selector-item/sprite-selector-item.css +++ b/src/components/sprite-selector-item/sprite-selector-item.css @@ -19,6 +19,8 @@ text-align: center; cursor: pointer; transition: 0.25s ease-out; + + user-select: none; } .sprite-selector-item.is-selected { diff --git a/src/components/sprite-selector/sprite-list.jsx b/src/components/sprite-selector/sprite-list.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f36a2f1f5ac07a0f5b30cf65d9338281b389faa0 --- /dev/null +++ b/src/components/sprite-selector/sprite-list.jsx @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; + +import DragConstants from '../../lib/drag-constants'; + +import Box from '../box/box.jsx'; +import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx'; +import SortableHOC from '../../lib/sortable-hoc.jsx'; +import SortableAsset from '../asset-panel/sortable-asset.jsx'; + +import styles from './sprite-selector.css'; + +const SpriteList = function (props) { + const { + editingTarget, + draggingIndex, + draggingType, + hoveredTarget, + onDeleteSprite, + onDuplicateSprite, + onSelectSprite, + onAddSortable, + onRemoveSortable, + ordering, + raised, + selectedId, + items + } = props; + + const isSpriteDrag = draggingType === DragConstants.SPRITE; + + return ( + <Box className={styles.itemsWrapper}> + {items.map((sprite, index) => { + + // If the sprite has just received a block drop, used for green highlight + const receivedBlocks = ( + hoveredTarget.sprite === sprite.id && + sprite.id !== editingTarget && + hoveredTarget.receivedBlocks + ); + + // If the sprite is indicating it can receive block dropping, used for blue highlight + const isRaised = !receivedBlocks && raised && sprite.id !== editingTarget; + + return ( + <SortableAsset + className={classNames(styles.itemWrapper, { + [styles.placeholder]: isSpriteDrag && index === draggingIndex})} + index={isSpriteDrag ? ordering.indexOf(index) : index} + key={sprite.name} + onAddSortable={onAddSortable} + onRemoveSortable={onRemoveSortable} + > + <SpriteSelectorItem + assetId={sprite.costume && sprite.costume.assetId} + className={classNames(styles.sprite, { + [styles.raised]: isRaised, + [styles.receivedBlocks]: receivedBlocks + })} + dragType={DragConstants.SPRITE} + id={sprite.id} + index={index} + key={sprite.id} + name={sprite.name} + selected={sprite.id === selectedId} + onClick={onSelectSprite} + onDeleteButtonClick={onDeleteSprite} + onDuplicateButtonClick={onDuplicateSprite} + /> + </SortableAsset> + ); + })} + </Box> + ); +}; + +SpriteList.propTypes = { + draggingIndex: PropTypes.number, + draggingType: PropTypes.oneOf(Object.keys(DragConstants)), + editingTarget: PropTypes.string, + hoveredTarget: PropTypes.shape({ + hoveredSprite: PropTypes.string, + receivedBlocks: PropTypes.bool + }), + items: PropTypes.arrayOf(PropTypes.shape({ + costume: PropTypes.shape({ + url: PropTypes.string, + name: PropTypes.string.isRequired, + bitmapResolution: PropTypes.number.isRequired, + rotationCenterX: PropTypes.number.isRequired, + rotationCenterY: PropTypes.number.isRequired + }), + name: PropTypes.string.isRequired, + order: PropTypes.number.isRequired + })), + onAddSortable: PropTypes.func, + onDeleteSprite: PropTypes.func, + onDuplicateSprite: PropTypes.func, + onRemoveSortable: PropTypes.func, + onSelectSprite: PropTypes.func, + ordering: PropTypes.arrayOf(PropTypes.number), + raised: PropTypes.bool, + selectedId: PropTypes.string +}; + +export default SortableHOC(SpriteList); diff --git a/src/components/sprite-selector/sprite-selector.css b/src/components/sprite-selector/sprite-selector.css index 8b1e76df56f5788c24803ae2a9b50e680e5285a9..cbd785d82f7fee6bcc7daef090f8407ab2b60ddb 100644 --- a/src/components/sprite-selector/sprite-selector.css +++ b/src/components/sprite-selector/sprite-selector.css @@ -1,5 +1,6 @@ @import "../../css/colors.css"; @import "../../css/units.css"; +@import "../../css/z-index.css"; .sprite-selector { flex-grow: 1; @@ -47,7 +48,7 @@ then back up, introduces white space in the outside the page container. */ height: calc(100% - $sprite-info-height); - overflow-y: scroll; + overflow-y: auto; } .items-wrapper { @@ -65,7 +66,7 @@ position: absolute; bottom: 0.75rem; right: 1rem; - z-index: 1; /* TODO overlaps the stage, this doesn't work, fix! */ + z-index: $z-index-add-button; } .raised { @@ -103,3 +104,8 @@ 90% { box-shadow: 0 0 10px #7fff1e; } 100% { box-shadow: none; } } + +.placeholder > .sprite { + background: black; + filter: opacity(15%) brightness(20%); +} diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index 21cef41e9a6c66b2072b2625d6ad3f1a828f7154..9907dffce3a78d58c056ce74ab33a0c59e2b4c10 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -1,12 +1,13 @@ import PropTypes from 'prop-types'; import React from 'react'; -import classNames from 'classnames'; 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 SpriteList from './sprite-list.jsx'; import ActionMenu from '../action-menu/action-menu.jsx'; +import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants'; import styles from './sprite-selector.css'; @@ -49,6 +50,7 @@ const SpriteSelectorComponent = function (props) { onChangeSpriteVisibility, onChangeSpriteX, onChangeSpriteY, + onDrop, onDeleteSprite, onDuplicateSprite, onFileUploadClick, @@ -61,6 +63,7 @@ const SpriteSelectorComponent = function (props) { selectedId, spriteFileInput, sprites, + stageSize, ...componentProps } = props; let selectedSprite = sprites[selectedId]; @@ -80,6 +83,7 @@ const SpriteSelectorComponent = function (props) { disabled={spriteInfoDisabled} name={selectedSprite.name} size={selectedSprite.size} + stageSize={stageSize} visible={selectedSprite.visible} x={selectedSprite.x} y={selectedSprite.y} @@ -92,31 +96,17 @@ const SpriteSelectorComponent = function (props) { /> <Box className={styles.scrollWrapper}> - <Box className={styles.itemsWrapper}> - {Object.keys(sprites) - // Re-order by list order - .sort((id1, id2) => sprites[id1].order - sprites[id2].order) - .map(id => sprites[id]) - .map(sprite => ( - <SpriteSelectorItem - assetId={sprite.costume && sprite.costume.assetId} - className={hoveredTarget.sprite === sprite.id && - sprite.id !== editingTarget && - hoveredTarget.receivedBlocks ? - classNames(styles.sprite, styles.receivedBlocks) : - raised && sprite.id !== editingTarget ? - classNames(styles.sprite, styles.raised) : styles.sprite} - id={sprite.id} - key={sprite.id} - name={sprite.name} - selected={sprite.id === selectedId} - onClick={onSelectSprite} - onDeleteButtonClick={onDeleteSprite} - onDuplicateButtonClick={onDuplicateSprite} - /> - )) - } - </Box> + <SpriteList + editingTarget={editingTarget} + hoveredTarget={hoveredTarget} + items={Object.keys(sprites).map(id => sprites[id])} + raised={raised} + selectedId={selectedId} + onDeleteSprite={onDeleteSprite} + onDrop={onDrop} + onDuplicateSprite={onDuplicateSprite} + onSelectSprite={onSelectSprite} + /> </Box> <ActionMenu className={styles.addButton} @@ -160,6 +150,7 @@ SpriteSelectorComponent.propTypes = { onChangeSpriteX: PropTypes.func, onChangeSpriteY: PropTypes.func, onDeleteSprite: PropTypes.func, + onDrop: PropTypes.func, onDuplicateSprite: PropTypes.func, onFileUploadClick: PropTypes.func, onNewSpriteClick: PropTypes.func, @@ -182,7 +173,8 @@ SpriteSelectorComponent.propTypes = { name: PropTypes.string.isRequired, order: PropTypes.number.isRequired }) - }) + }), + stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired }; export default injectIntl(SpriteSelectorComponent); diff --git a/src/components/stage-header/icon--small-stage.svg b/src/components/stage-header/icon--small-stage.svg index f73f5b242583675de267ad84dde99a7173a7e032..377939959cf7ec1abab502c18c324e8203db0d47 100644 Binary files a/src/components/stage-header/icon--small-stage.svg and b/src/components/stage-header/icon--small-stage.svg differ diff --git a/src/components/stage-header/stage-header.css b/src/components/stage-header/stage-header.css index 2ac722efc5579336874b97e565ecedaab235e213..93ef5b4408a1d701fc624a0f41b9ad994844dd58 100644 --- a/src/components/stage-header/stage-header.css +++ b/src/components/stage-header/stage-header.css @@ -1,5 +1,6 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +@import "../../css/z-index.css"; .stage-header-wrapper { position: relative; @@ -11,7 +12,7 @@ top: 0; left: 0; right: 0; - z-index: 5000; + z-index: $z-index-stage-header; } .stage-menu-wrapper { @@ -61,8 +62,6 @@ border-bottom-right-radius: 0; } -.stage-button-disabled { - opacity: .5; - cursor: auto; - background: transparent; +.stage-button-toggled-off { + filter: saturate(0); } diff --git a/src/components/stage-header/stage-header.jsx b/src/components/stage-header/stage-header.jsx index efada70ed553a7321e250bd22229789a9d6697ad..21a156a95a6add7ff7caa60b21aa23c28b04b6d7 100644 --- a/src/components/stage-header/stage-header.jsx +++ b/src/components/stage-header/stage-header.jsx @@ -2,13 +2,14 @@ import classNames from 'classnames'; import {defineMessages, injectIntl, intlShape} from 'react-intl'; import PropTypes from 'prop-types'; import React from 'react'; +import {connect} from 'react-redux'; import VM from 'scratch-vm'; import Box from '../box/box.jsx'; import Button from '../button/button.jsx'; -import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx'; import Controls from '../../containers/controls.jsx'; -import {getStageSize} from '../../lib/screen-utils.js'; +import {getStageDimensions} from '../../lib/screen-utils'; +import {STAGE_SIZE_MODES} from '../../lib/layout-constants'; import fullScreenIcon from './icon--fullscreen.svg'; import largeStageIcon from './icon--large-stage.svg'; @@ -51,20 +52,22 @@ const StageHeaderComponent = function (props) { isPlayerOnly, onKeyPress, onSetStageLarge, + onSetStageSmall, onSetStageFull, onSetStageUnFull, + stageSizeMode, vm } = props; let header = null; - const stageSize = getStageSize(isFullScreen); if (isFullScreen) { + const stageDimensions = getStageDimensions(null, true); header = ( <Box className={styles.stageHeaderWrapperOverlay}> <Box className={styles.stageMenuWrapper} - style={{width: stageSize.width}} + style={{width: stageDimensions.width}} > <Controls vm={vm} /> <Button @@ -89,31 +92,30 @@ const StageHeaderComponent = function (props) { [] ) : ( <div className={styles.stageSizeToggleGroup}> - <ComingSoonTooltip - place="left" - tooltipId="small-stage-button" - > - <div - disabled + <div> + <Button className={classNames( styles.stageButton, styles.stageButtonLeft, - styles.stageButtonDisabled + (stageSizeMode === STAGE_SIZE_MODES.small) ? null : styles.stageButtonToggledOff )} - role="button" + onClick={onSetStageSmall} > <img - disabled alt={props.intl.formatMessage(messages.smallStageSizeMessage)} className={styles.stageButtonIcon} draggable={false} src={smallStageIcon} /> - </div> - </ComingSoonTooltip> + </Button> + </div> <div> <Button - className={classNames(styles.stageButton, styles.stageButtonRight)} + className={classNames( + styles.stageButton, + styles.stageButtonRight, + (stageSizeMode === STAGE_SIZE_MODES.large) ? null : styles.stageButtonToggledOff + )} onClick={onSetStageLarge} > <img @@ -155,6 +157,11 @@ const StageHeaderComponent = function (props) { return header; }; +const mapStateToProps = state => ({ + // This is the button's mode, as opposed to the actual current state + stageSizeMode: state.scratchGui.stageSize.stageSize +}); + StageHeaderComponent.propTypes = { intl: intlShape, isFullScreen: PropTypes.bool.isRequired, @@ -162,8 +169,16 @@ StageHeaderComponent.propTypes = { onKeyPress: PropTypes.func.isRequired, onSetStageFull: PropTypes.func.isRequired, onSetStageLarge: PropTypes.func.isRequired, + onSetStageSmall: PropTypes.func.isRequired, onSetStageUnFull: PropTypes.func.isRequired, + stageSizeMode: PropTypes.oneOf(Object.keys(STAGE_SIZE_MODES)), vm: PropTypes.instanceOf(VM).isRequired }; -export default injectIntl(StageHeaderComponent); +StageHeaderComponent.defaultProps = { + stageSizeMode: STAGE_SIZE_MODES.large +}; + +export default injectIntl(connect( + mapStateToProps +)(StageHeaderComponent)); diff --git a/src/components/stage-selector/stage-selector.css b/src/components/stage-selector/stage-selector.css index b8ae410bb5e4c338a26a72045c9de7283344f1cd..e845160faee34e2c5768d08883f121079ad06275 100644 --- a/src/components/stage-selector/stage-selector.css +++ b/src/components/stage-selector/stage-selector.css @@ -1,5 +1,6 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +@import "../../css/z-index.css"; $header-height: calc($stage-menu-height - 2px); @@ -79,6 +80,7 @@ $header-height: calc($stage-menu-height - 2px); .add-button { position: absolute; bottom: 0.75rem; + z-index: $z-index-add-button } .raised, .raised .header { diff --git a/src/components/stage-wrapper/stage-wrapper.jsx b/src/components/stage-wrapper/stage-wrapper.jsx index 8cec64e40a9ffdd74d617e2bb611a4637e6e6b6b..eb0d129f82cc6bcbdae4aca55365e4d8ef78cef6 100644 --- a/src/components/stage-wrapper/stage-wrapper.jsx +++ b/src/components/stage-wrapper/stage-wrapper.jsx @@ -1,10 +1,9 @@ import PropTypes from 'prop-types'; import React from 'react'; -import MediaQuery from 'react-responsive'; import VM from 'scratch-vm'; import Box from '../box/box.jsx'; -import layout from '../../lib/layout-constants.js'; +import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; import StageHeader from '../../containers/stage-header.jsx'; import Stage from '../../containers/stage.jsx'; @@ -13,27 +12,28 @@ import styles from './stage-wrapper.css'; const StageWrapperComponent = function (props) { const { isRendererSupported, + stageSize, vm } = props; return ( <Box className={styles.stageWrapper}> <Box className={styles.stageMenuWrapper}> - <StageHeader vm={vm} /> + <StageHeader + stageSize={stageSize} + vm={vm} + /> </Box> <Box className={styles.stageCanvasWrapper}> - {/* eslint-disable arrow-body-style */} - <MediaQuery minWidth={layout.fullSizeMinWidth}>{isFullSize => { - return isRendererSupported ? ( + { + isRendererSupported ? <Stage - height={isFullSize ? layout.fullStageHeight : layout.smallerStageHeight} shrink={0} + stageSize={stageSize} vm={vm} - width={isFullSize ? layout.fullStageWidth : layout.smallerStageWidth} - /> - ) : null; - }}</MediaQuery> - {/* eslint-enable arrow-body-style */} + /> : + null + } </Box> </Box> ); @@ -41,6 +41,7 @@ const StageWrapperComponent = function (props) { StageWrapperComponent.propTypes = { isRendererSupported: PropTypes.bool.isRequired, + stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, vm: PropTypes.instanceOf(VM).isRequired }; diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index d2c7894c7832a935b6794a0784a3a89afa58dda3..15ac8bcc1d0267d12903ebf472b4273a75401b9d 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -1,5 +1,6 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +@import "../../css/z-index.css"; .stage { /* @@ -21,7 +22,7 @@ .with-color-picker { cursor: none; - z-index: 2001; + z-index: $z-index-stage-with-color-picker; } .color-picker-background { @@ -30,7 +31,7 @@ height: 100%; background-color: rgba(0, 0, 0, 0.55); display: block; - z-index: 2000; + z-index: $z-index-stage-color-picker-background; top: 0; left: 0; } @@ -45,7 +46,7 @@ left: 0; right: 0; bottom: 0; - z-index: 5000; + z-index: $z-index-stage-wrapper-overlay; background-color: $ui-white; } @@ -86,6 +87,6 @@ position: absolute; top: 0; left: 0; - z-index: 1000; /* Above everything so it is draggable into other panes */ + z-index: $z-index-dragging-sprite; filter: drop-shadow(5px 5px 5px $ui-black-transparent); } diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx index 658114a608a69757c746b3f64da2ac43c6ce1ad3..37b8bced0b49b77d680557e42dd27fd1ce45b034 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -6,26 +6,26 @@ import Box from '../box/box.jsx'; import Loupe from '../loupe/loupe.jsx'; import MonitorList from '../../containers/monitor-list.jsx'; import Question from '../../containers/question.jsx'; -import {getStageSize} from '../../lib/screen-utils.js'; +import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; +import {getStageDimensions} from '../../lib/screen-utils.js'; import styles from './stage.css'; const StageComponent = props => { const { canvasRef, dragRef, - height, isColorPicking, isFullScreen, - width, colorInfo, - onDeactivateColorPicker, question, - onQuestionAnswered, + stageSize, useEditorDragStyle, + onDeactivateColorPicker, + onQuestionAnswered, ...boxProps } = props; - const stageSize = getStageSize(isFullScreen, height, width); + const stageDimensions = getStageDimensions(stageSize, isFullScreen); return ( <div> @@ -43,14 +43,14 @@ const StageComponent = props => { )} componentRef={canvasRef} element="canvas" - height={stageSize.height} - width={stageSize.width} + height={stageDimensions.height} + width={stageDimensions.width} {...boxProps} /> <Box className={styles.monitorWrapper}> <MonitorList draggable={useEditorDragStyle} - stageSize={stageSize} + stageSize={stageDimensions} /> </Box> {isColorPicking && colorInfo ? ( @@ -67,7 +67,7 @@ const StageComponent = props => { > <div className={styles.questionWrapper} - style={{width: stageSize.width}} + style={{width: stageDimensions.width}} > <Question question={question} @@ -96,19 +96,16 @@ StageComponent.propTypes = { canvasRef: PropTypes.func, colorInfo: Loupe.propTypes.colorInfo, dragRef: PropTypes.func, - height: PropTypes.number, isColorPicking: PropTypes.bool, isFullScreen: PropTypes.bool.isRequired, onDeactivateColorPicker: PropTypes.func, onQuestionAnswered: PropTypes.func, question: PropTypes.string, - useEditorDragStyle: PropTypes.bool, - width: PropTypes.number + stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, + useEditorDragStyle: PropTypes.bool }; StageComponent.defaultProps = { canvasRef: () => {}, - dragRef: () => {}, - width: 480, - height: 360 + dragRef: () => {} }; export default StageComponent; diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index fb08f3b413c8da8105a40309571c4c5a95cb383b..57b10e0c99e72d4df2e28a364f82c25c66547b66 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -6,6 +6,7 @@ import VM from 'scratch-vm'; import SpriteLibrary from '../../containers/sprite-library.jsx'; import SpriteSelectorComponent from '../sprite-selector/sprite-selector.jsx'; import StageSelector from '../../containers/stage-selector.jsx'; +import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants'; import styles from './target-pane.css'; @@ -27,6 +28,7 @@ const TargetPane = ({ onChangeSpriteX, onChangeSpriteY, onDeleteSprite, + onDrop, onDuplicateSprite, onFileUploadClick, onNewSpriteClick, @@ -37,6 +39,7 @@ const TargetPane = ({ onSurpriseSpriteClick, raiseSprites, stage, + stageSize, sprites, vm, ...componentProps @@ -53,6 +56,7 @@ const TargetPane = ({ selectedId={editingTarget} spriteFileInput={fileInputRef} sprites={sprites} + stageSize={stageSize} onChangeSpriteDirection={onChangeSpriteDirection} onChangeSpriteName={onChangeSpriteName} onChangeSpriteSize={onChangeSpriteSize} @@ -60,6 +64,7 @@ const TargetPane = ({ onChangeSpriteX={onChangeSpriteX} onChangeSpriteY={onChangeSpriteY} onDeleteSprite={onDeleteSprite} + onDrop={onDrop} onDuplicateSprite={onDuplicateSprite} onFileUploadClick={onFileUploadClick} onNewSpriteClick={onNewSpriteClick} @@ -126,6 +131,7 @@ TargetPane.propTypes = { onChangeSpriteX: PropTypes.func, onChangeSpriteY: PropTypes.func, onDeleteSprite: PropTypes.func, + onDrop: PropTypes.func, onDuplicateSprite: PropTypes.func, onFileUploadClick: PropTypes.func, onNewSpriteClick: PropTypes.func, @@ -139,6 +145,7 @@ TargetPane.propTypes = { spriteLibraryVisible: PropTypes.bool, sprites: PropTypes.objectOf(spriteShape), stage: spriteShape, + stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, vm: PropTypes.instanceOf(VM) }; diff --git a/src/components/webgl-modal/webgl-modal.css b/src/components/webgl-modal/webgl-modal.css index 334ef8f685adb061cea151422056edb422d26d49..f1b6ff6efee57e7a40a5ad1a1ebc3325e8167dcd 100644 --- a/src/components/webgl-modal/webgl-modal.css +++ b/src/components/webgl-modal/webgl-modal.css @@ -1,6 +1,7 @@ @import "../../css/colors.css"; @import "../../css/units.css"; @import "../../css/typography.css"; +@import "../../css/z-index.css"; .modal-overlay { position: fixed; @@ -8,7 +9,7 @@ left: 0; right: 0; bottom: 0; - z-index: 1000; + z-index: $z-index-modal; background-color: $ui-modal-overlay; } diff --git a/src/containers/backdrop-library.jsx b/src/containers/backdrop-library.jsx index 98604bf18918ee3c30fd687d53f2820a5a7e8a15..5a02660385dcac2232b7598ac4483c3a31413923 100644 --- a/src/containers/backdrop-library.jsx +++ b/src/containers/backdrop-library.jsx @@ -2,8 +2,14 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import {defineMessages, injectIntl, intlShape} from 'react-intl'; +import {connect} from 'react-redux'; import VM from 'scratch-vm'; +import { + activateTab, + COSTUMES_TAB_INDEX +} from '../reducers/editor-tab'; + import analytics from '../lib/analytics'; import backdropLibraryContent from '../lib/libraries/backdrops.json'; import backdropTags from '../lib/libraries/backdrop-tags'; @@ -33,6 +39,8 @@ class BackdropLibrary extends React.Component { bitmapResolution: item.info.length > 2 ? item.info[2] : 1, skinId: null }; + this.props.vm.setEditingTarget(this.props.stageID); + this.props.onActivateTab(COSTUMES_TAB_INDEX); this.props.vm.addBackdrop(item.md5, vmBackdrop); analytics.event({ category: 'library', @@ -56,8 +64,21 @@ class BackdropLibrary extends React.Component { BackdropLibrary.propTypes = { intl: intlShape.isRequired, + onActivateTab: PropTypes.func.isRequired, onRequestClose: PropTypes.func, + stageID: PropTypes.string.isRequired, vm: PropTypes.instanceOf(VM).isRequired }; -export default injectIntl(BackdropLibrary); +const mapStateToProps = state => ({ + stageID: state.scratchGui.targets.stage.id +}); + +const mapDispatchToProps = dispatch => ({ + onActivateTab: tab => dispatch(activateTab(tab)) +}); + +export default injectIntl(connect( + mapStateToProps, + mapDispatchToProps +)(BackdropLibrary)); diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index ac1fd3ee3b9470b7cfa93752bca176c193521e56..5f1ec793e63b7a153ce6a628480a56b04bfbeca0 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -13,6 +13,7 @@ import BlocksComponent from '../components/blocks/blocks.jsx'; import ExtensionLibrary from './extension-library.jsx'; import CustomProcedures from './custom-procedures.jsx'; import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; +import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants'; import {connect} from 'react-redux'; import {updateToolbox} from '../reducers/toolbox'; @@ -97,7 +98,8 @@ class Blocks extends React.Component { this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible || this.props.customProceduresVisible !== nextProps.customProceduresVisible || this.props.locale !== nextProps.locale || - this.props.anyModalVisible !== nextProps.anyModalVisible + this.props.anyModalVisible !== nextProps.anyModalVisible || + this.props.stageSize !== nextProps.stageSize ); } componentDidUpdate (prevProps) { @@ -118,6 +120,10 @@ class Blocks extends React.Component { }, 0); } if (this.props.isVisible === prevProps.isVisible) { + if (this.props.stageSize !== prevProps.stageSize) { + // force workspace to redraw for the new stage size + window.dispatchEvent(new Event('resize')); + } return; } // @todo hack to resize blockly manually in case resize happened while hidden @@ -329,6 +335,7 @@ class Blocks extends React.Component { customProceduresVisible, extensionLibraryVisible, options, + stageSize, vm, isVisible, onActivateColorPicker, @@ -409,6 +416,7 @@ Blocks.propTypes = { comments: PropTypes.bool, collapse: PropTypes.bool }), + stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, toolboxXML: PropTypes.string, updateToolboxState: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 3ce034caa200b15cac7e7f7bc8042fdf9843d2f3..965f81abef7b99417f7f521d932cd6460dbd80c9 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -6,17 +6,14 @@ 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 CameraModal from './camera-modal.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 { closeCameraCapture, - closeCostumeLibrary, - closeBackdropLibrary, openCameraCapture, openCostumeLibrary, openBackdropLibrary @@ -84,6 +81,7 @@ class CostumeTab extends React.Component { 'handleFileUploadClick', 'handleCostumeUpload', 'handleCameraBuffer', + 'handleDrop', 'setFileInput' ]); const { @@ -192,6 +190,17 @@ class CostumeTab extends React.Component { handleFileUploadClick () { this.fileInput.click(); } + handleDrop (dropInfo) { + // Eventually will handle other kinds of drop events, right now just + // the reordering events. + 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)}); + } + } setFileInput (input) { this.fileInput = input; } @@ -209,36 +218,29 @@ class CostumeTab extends React.Component { onNewCostumeFromCameraClick, onNewLibraryBackdropClick, onNewLibraryCostumeClick, - backdropLibraryVisible, cameraModalVisible, - costumeLibraryVisible, - onRequestCloseBackdropLibrary, onRequestCloseCameraModal, - onRequestCloseCostumeLibrary, - editingTarget, - sprites, - stage, vm } = this.props; - const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage; - - if (!target) { + if (!vm.editingTarget) { return null; } - const addLibraryMessage = target.isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg; - const addFileMessage = target.isStage ? messages.addFileBackdropMsg : messages.addFileCostumeMsg; - const addSurpriseFunc = target.isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume; - const addLibraryFunc = target.isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick; - const addLibraryIcon = target.isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon; + 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 || []).map(costume => ({ + const costumeData = target.costumes ? target.costumes.map(costume => ({ name: costume.name, assetId: costume.assetId, details: costume.size ? this.formatCostumeDetails(costume.size, costume.bitmapResolution) : null - })); - + })) : []; return ( <AssetPanel buttons={[ @@ -271,10 +273,12 @@ class CostumeTab extends React.Component { onClick: this.handleNewBlankCostume } ]} + dragType={DragConstants.COSTUME} items={costumeData} selectedItemIndex={this.state.selectedCostumeIndex} onDeleteClick={target && target.costumes && target.costumes.length > 1 ? this.handleDeleteCostume : null} + onDrop={this.handleDrop} onDuplicateClick={this.handleDuplicateCostume} onItemClick={this.handleSelectCostume} > @@ -284,18 +288,6 @@ class CostumeTab extends React.Component { /> : null } - {costumeLibraryVisible ? ( - <CostumeLibrary - vm={vm} - onRequestClose={onRequestCloseCostumeLibrary} - /> - ) : null} - {backdropLibraryVisible ? ( - <BackdropLibrary - vm={vm} - onRequestClose={onRequestCloseBackdropLibrary} - /> - ) : null} {cameraModalVisible ? ( <CameraModal onClose={onRequestCloseCameraModal} @@ -308,17 +300,13 @@ class CostumeTab extends React.Component { } CostumeTab.propTypes = { - backdropLibraryVisible: PropTypes.bool, cameraModalVisible: PropTypes.bool, - costumeLibraryVisible: PropTypes.bool, editingTarget: PropTypes.string, intl: intlShape, onNewCostumeFromCameraClick: PropTypes.func.isRequired, onNewLibraryBackdropClick: PropTypes.func.isRequired, onNewLibraryCostumeClick: PropTypes.func.isRequired, - onRequestCloseBackdropLibrary: PropTypes.func.isRequired, onRequestCloseCameraModal: PropTypes.func.isRequired, - onRequestCloseCostumeLibrary: PropTypes.func.isRequired, sprites: PropTypes.shape({ id: PropTypes.shape({ costumes: PropTypes.arrayOf(PropTypes.shape({ @@ -340,9 +328,8 @@ const mapStateToProps = state => ({ editingTarget: state.scratchGui.targets.editingTarget, sprites: state.scratchGui.targets.sprites, stage: state.scratchGui.targets.stage, - cameraModalVisible: state.scratchGui.modals.cameraCapture, - costumeLibraryVisible: state.scratchGui.modals.costumeLibrary, - backdropLibraryVisible: state.scratchGui.modals.backdropLibrary + dragging: state.scratchGui.assetDrag.dragging, + cameraModalVisible: state.scratchGui.modals.cameraCapture }); const mapDispatchToProps = dispatch => ({ @@ -357,12 +344,6 @@ const mapDispatchToProps = dispatch => ({ onNewCostumeFromCameraClick: () => { dispatch(openCameraCapture()); }, - onRequestCloseBackdropLibrary: () => { - dispatch(closeBackdropLibrary()); - }, - onRequestCloseCostumeLibrary: () => { - dispatch(closeCostumeLibrary()); - }, onRequestCloseCameraModal: () => { dispatch(closeCameraCapture()); } diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index ed2ec178587147662aa9e666f617d80ee2ae39cb..bdd552968f4f55c8c1a7fca096206e7574a63960 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -14,6 +14,11 @@ import { SOUNDS_TAB_INDEX } from '../reducers/editor-tab'; +import { + closeCostumeLibrary, + closeBackdropLibrary +} from '../reducers/modals'; + import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx'; import vmListenerHOC from '../lib/vm-listener-hoc.jsx'; @@ -103,8 +108,10 @@ GUI.defaultProps = GUIComponent.defaultProps; const mapStateToProps = state => ({ activeTabIndex: state.scratchGui.editorTab.activeTabIndex, + backdropLibraryVisible: state.scratchGui.modals.backdropLibrary, blocksTabVisible: state.scratchGui.editorTab.activeTabIndex === BLOCKS_TAB_INDEX, cardsVisible: state.scratchGui.cards.visible, + costumeLibraryVisible: state.scratchGui.modals.costumeLibrary, costumesTabVisible: state.scratchGui.editorTab.activeTabIndex === COSTUMES_TAB_INDEX, importInfoVisible: state.scratchGui.modals.importInfo, isPlayerOnly: state.scratchGui.mode.isPlayerOnly, @@ -122,7 +129,9 @@ const mapDispatchToProps = dispatch => ({ onExtensionButtonClick: () => dispatch(openExtensionLibrary()), onActivateTab: tab => dispatch(activateTab(tab)), onActivateCostumesTab: () => dispatch(activateTab(COSTUMES_TAB_INDEX)), - onActivateSoundsTab: () => dispatch(activateTab(SOUNDS_TAB_INDEX)) + onActivateSoundsTab: () => dispatch(activateTab(SOUNDS_TAB_INDEX)), + onRequestCloseBackdropLibrary: () => dispatch(closeBackdropLibrary()), + onRequestCloseCostumeLibrary: () => dispatch(closeCostumeLibrary()) }); const ConnectedGUI = connect( diff --git a/src/containers/language-selector.jsx b/src/containers/language-selector.jsx index 316b1577a367a4314ee7a610f72b9bee2516874d..49fb60943ada6bdefa77f95548dc40e2b3080d79 100644 --- a/src/containers/language-selector.jsx +++ b/src/containers/language-selector.jsx @@ -1,18 +1,56 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; import {connect} from 'react-redux'; +import {updateIntl} from 'react-intl-redux'; +import {closeLanguageMenu} from '../reducers/menus'; import LanguageSelectorComponent from '../components/language-selector/language-selector.jsx'; +class LanguageSelector extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleChange' + ]); + } + handleChange (e) { + this.props.onChangeLanguage(e.target.value); + } + render () { + const { + onChangeLanguage, // eslint-disable-line no-unused-vars + children, + ...props + } = this.props; + return ( + <LanguageSelectorComponent + onChange={this.handleChange} + {...props} + > + {children} + </LanguageSelectorComponent> + ); + } +} + +LanguageSelector.propTypes = { + children: PropTypes.node, + onChangeLanguage: PropTypes.func.isRequired +}; + const mapStateToProps = state => ({ currentLocale: state.intl.locale }); -const mapDispatchToProps = () => ({ - onChange: e => { - e.preventDefault(); +const mapDispatchToProps = dispatch => ({ + onChangeLanguage: locale => { + dispatch(updateIntl({locale: locale, messages: {}})); + dispatch(closeLanguageMenu()); } }); export default connect( mapStateToProps, mapDispatchToProps -)(LanguageSelectorComponent); +)(LanguageSelector); diff --git a/src/containers/paint-editor-wrapper.jsx b/src/containers/paint-editor-wrapper.jsx index 9b606f3dae07f3746dd5b51b5c5cd6851c4a7be3..390f6ef598b7ca63f52868529ba9720a0f652d24 100644 --- a/src/containers/paint-editor-wrapper.jsx +++ b/src/containers/paint-editor-wrapper.jsx @@ -43,7 +43,6 @@ class PaintEditorWrapper extends React.Component { return ( <PaintEditor {...this.props} - image={this.props.vm.getCostume(this.props.selectedCostumeIndex)} onUpdateImage={this.handleUpdateImage} onUpdateName={this.handleUpdateName} /> @@ -62,19 +61,19 @@ PaintEditorWrapper.propTypes = { }; const mapStateToProps = (state, {selectedCostumeIndex}) => { - const { - editingTarget, - sprites, - stage - } = state.scratchGui.targets; - const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage; - const costume = target && target.costumes[selectedCostumeIndex]; + const targetId = state.scratchGui.vm.editingTarget.id; + const sprite = state.scratchGui.vm.editingTarget.sprite; + // Make sure the costume index doesn't go out of range. + const index = selectedCostumeIndex < sprite.costumes.length ? + selectedCostumeIndex : sprite.costumes.length - 1; + const costume = state.scratchGui.vm.editingTarget.sprite.costumes[index]; return { name: costume && costume.name, rotationCenterX: costume && costume.rotationCenterX, rotationCenterY: costume && costume.rotationCenterY, imageFormat: costume && costume.dataFormat, - imageId: editingTarget && `${editingTarget}${costume.skinId}`, + imageId: targetId && `${targetId}${costume.skinId}`, + image: state.scratchGui.vm.getCostume(index), vm: state.scratchGui.vm }; }; diff --git a/src/containers/sound-library.jsx b/src/containers/sound-library.jsx index 3d68ea6f32ad19134508c847321147c4f51daf93..5d7cb67f6fdb50a941460336282b46dfac4128bd 100644 --- a/src/containers/sound-library.jsx +++ b/src/containers/sound-library.jsx @@ -29,20 +29,63 @@ class SoundLibrary extends React.PureComponent { 'handleItemMouseEnter', 'handleItemMouseLeave' ]); + + /** + * AudioEngine that will decode and play sounds for us. + * @type {AudioEngine} + */ + this.audioEngine = null; + /** + * A promise for the sound queued to play as soon as it loads and + * decodes. + * @type {Promise<SoundPlayer>} + */ + this.playingSoundPromise = null; } componentDidMount () { this.audioEngine = new AudioEngine(); - this.player = this.audioEngine.createPlayer(); + this.playingSoundPromise = null; } componentWillUnmount () { - this.player.stopAllSounds(); + this.stopPlayingSound(); + } + stopPlayingSound () { + // Playback is queued, playing, or has played recently and finished + // normally. + if (this.playingSoundPromise !== null) { + // Queued playback began playing before this method. + if (this.playingSoundPromise.isPlaying) { + // Fetch the player from the promise and stop playback soon. + this.playingSoundPromise.then(soundPlayer => { + soundPlayer.stop(); + }); + } else { + // Fetch the player from the promise and stop immediately. Since + // the sound is not playing yet, this callback will be called + // immediately after the sound starts playback. Stopping it + // immediately will have the effect of no sound being played. + this.playingSoundPromise.then(soundPlayer => { + soundPlayer.stopImmediately(); + }); + } + // No further work should be performed on this promise and its + // soundPlayer. + this.playingSoundPromise = null; + } } handleItemMouseEnter (soundItem) { const md5ext = soundItem._md5; const idParts = md5ext.split('.'); const md5 = idParts[0]; const vm = this.props.vm; - vm.runtime.storage.load(vm.runtime.storage.AssetType.Sound, md5) + + // In case enter is called twice without a corresponding leave + // inbetween, stop the last playback before queueing a new sound. + this.stopPlayingSound(); + + // Save the promise so code to stop the sound may queue the stop + // instruction after the play instruction. + this.playingSoundPromise = vm.runtime.storage.load(vm.runtime.storage.AssetType.Sound, md5) .then(soundAsset => { const sound = { md5: md5ext, @@ -50,14 +93,23 @@ class SoundLibrary extends React.PureComponent { format: soundItem.format, data: soundAsset.data }; - return this.audioEngine.decodeSound(sound); + return this.audioEngine.decodeSoundPlayer(sound); }) - .then(soundId => { - this.player.playSound(soundId); + .then(soundPlayer => { + soundPlayer.connect(this.audioEngine); + // Play the sound. Playing the sound will always come before a + // paired stop if the sound must stop early. + soundPlayer.play(); + // Set that the sound is playing. This affects the type of stop + // instruction given if the sound must stop early. + if (this.playingSoundPromise !== null) { + this.playingSoundPromise.isPlaying = true; + } + return soundPlayer; }); } handleItemMouseLeave () { - this.player.stopAllSounds(); + this.stopPlayingSound(); } handleItemSelected (soundItem) { const vmSound = { diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx index d532c5ee9bc0b3cb359dfae24a75dd03ff9eec38..097457d8a45a0e0b28ce5eaf26712f2da1a8becb 100644 --- a/src/containers/sound-tab.jsx +++ b/src/containers/sound-tab.jsx @@ -18,6 +18,7 @@ import SoundLibrary from './sound-library.jsx'; import soundLibraryContent from '../lib/libraries/sounds.json'; import {handleFileUpload, soundUpload} from '../lib/file-uploader.js'; import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; +import DragConstants from '../lib/drag-constants'; import {connect} from 'react-redux'; @@ -38,6 +39,7 @@ class SoundTab extends React.Component { 'handleSurpriseSound', 'handleFileUploadClick', 'handleSoundUpload', + 'handleDrop', 'setFileInput' ]); this.state = {selectedSoundIndex: 0}; @@ -117,6 +119,20 @@ class SoundTab extends React.Component { }); } + handleDrop (dropInfo) { + // Eventually will handle other kinds of drop events, right now just + // the reordering events. + if (dropInfo.dragType === DragConstants.SOUND) { + const sprite = this.props.vm.editingTarget.sprite; + const activeSound = sprite.sounds[this.state.selectedSoundIndex]; + + this.props.vm.reorderSound(this.props.vm.editingTarget.id, + dropInfo.index, dropInfo.newIndex); + + this.setState({selectedSoundIndex: sprite.sounds.indexOf(activeSound)}); + } + } + setFileInput (input) { this.fileInput = input; } @@ -188,12 +204,11 @@ class SoundTab extends React.Component { img: addSoundFromRecordingIcon, onClick: onNewSoundFromRecordingClick }]} - items={sounds.map(sound => ({ - url: soundIcon, - ...sound - }))} + dragType={DragConstants.SOUND} + items={sounds} selectedItemIndex={this.state.selectedSoundIndex} onDeleteClick={this.handleDeleteSound} + onDrop={this.handleDrop} onDuplicateClick={this.handleDuplicateSound} onItemClick={this.handleSelectSound} > diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx index 1a6c77b9d970831cda6f8b5c307de99fbf21d7f3..5efe494280f1fa004b55a4502f5027a37634e238 100644 --- a/src/containers/sprite-selector-item.jsx +++ b/src/containers/sprite-selector-item.jsx @@ -34,7 +34,12 @@ class SpriteSelectorItem extends React.Component { this.props.onDrag({ img: null, currentOffset: null, - dragging: false + dragging: false, + dragType: null, + index: null + }); + setTimeout(() => { + this.noClick = false; }); } handleMouseMove (e) { @@ -45,8 +50,11 @@ class SpriteSelectorItem extends React.Component { this.props.onDrag({ img: this.props.costumeURL, currentOffset: currentOffset, - dragging: true + dragging: true, + dragType: this.props.dragType, + index: this.props.index }); + this.noClick = true; } e.preventDefault(); } @@ -59,7 +67,9 @@ class SpriteSelectorItem extends React.Component { } handleClick (e) { e.preventDefault(); - this.props.onClick(this.props.id); + if (!this.noClick) { + this.props.onClick(this.props.id); + } } handleDelete (e) { e.stopPropagation(); // To prevent from bubbling back to handleClick @@ -84,6 +94,7 @@ class SpriteSelectorItem extends React.Component { /* eslint-disable no-unused-vars */ assetId, id, + index, onClick, onDeleteButtonClick, onDuplicateButtonClick, @@ -109,7 +120,9 @@ SpriteSelectorItem.propTypes = { assetId: PropTypes.string, costumeURL: PropTypes.string, dispatchSetHoveredSprite: PropTypes.func.isRequired, + dragType: PropTypes.string, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + index: PropTypes.number, name: PropTypes.string, onClick: PropTypes.func, onDeleteButtonClick: PropTypes.func, diff --git a/src/containers/stage-header.jsx b/src/containers/stage-header.jsx index 883c882fa120c343673febefbe5b14b814ced929..11ffab43364bef2e843d7211a7b0bac15cf202f9 100644 --- a/src/containers/stage-header.jsx +++ b/src/containers/stage-header.jsx @@ -2,7 +2,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import bindAll from 'lodash.bindall'; import VM from 'scratch-vm'; -import {setStageSize, STAGE_SIZES} from '../reducers/stage-size'; +import {STAGE_SIZE_MODES} from '../lib/layout-constants'; +import {setStageSize} from '../reducers/stage-size'; import {setFullScreen} from '../reducers/mode'; import {connect} from 'react-redux'; @@ -45,19 +46,19 @@ StageHeader.propTypes = { isFullScreen: PropTypes.bool, isPlayerOnly: PropTypes.bool, onSetStageUnFull: PropTypes.func.isRequired, - stageSize: PropTypes.oneOf(Object.keys(STAGE_SIZES)), + stageSizeMode: PropTypes.oneOf(Object.keys(STAGE_SIZE_MODES)), vm: PropTypes.instanceOf(VM).isRequired }; const mapStateToProps = state => ({ - stageSize: state.scratchGui.stageSize.stageSize, + stageSizeMode: state.scratchGui.stageSize.stageSize, isFullScreen: state.scratchGui.mode.isFullScreen, isPlayerOnly: state.scratchGui.mode.isPlayerOnly }); const mapDispatchToProps = dispatch => ({ - onSetStageLarge: () => dispatch(setStageSize(STAGE_SIZES.large)), - onSetStageSmall: () => dispatch(setStageSize(STAGE_SIZES.small)), + onSetStageLarge: () => dispatch(setStageSize(STAGE_SIZE_MODES.large)), + onSetStageSmall: () => dispatch(setStageSize(STAGE_SIZE_MODES.small)), onSetStageFull: () => dispatch(setFullScreen(true)), onSetStageUnFull: () => dispatch(setFullScreen(false)) }); diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx index 4d84746000ba09fb846937323d7dec216eab409a..99c233c7d8e7ea7b8a28f497274fe85da966948d 100644 --- a/src/containers/stage-selector.jsx +++ b/src/containers/stage-selector.jsx @@ -1,4 +1,5 @@ import bindAll from 'lodash.bindall'; +import omit from 'lodash.omit'; import PropTypes from 'prop-types'; import React from 'react'; @@ -78,15 +79,8 @@ class StageSelector extends React.Component { this.fileInput = input; } render () { - const { - /* eslint-disable no-unused-vars */ - assetId, - id, - onActivateTab, - onSelect, - /* eslint-enable no-unused-vars */ - ...componentProps - } = this.props; + const componentProps = omit(this.props, [ + 'assetId', 'dispatchSetHoveredSprite', 'id', 'onActivateTab', 'onSelect']); return ( <StageSelectorComponent fileInputRef={this.setFileInput} @@ -119,8 +113,7 @@ const mapStateToProps = (state, {assetId, id}) => ({ const mapDispatchToProps = dispatch => ({ onNewBackdropClick: e => { - e.preventDefault(); - dispatch(activateTab(COSTUMES_TAB_INDEX)); + e.stopPropagation(); dispatch(openBackdropLibrary()); }, onActivateTab: tabIndex => { diff --git a/src/containers/stage-wrapper.jsx b/src/containers/stage-wrapper.jsx index 039f3ab3ca61a3b9001863ccff784cdabdfdc37b..e8ca1e76994f96d677f046f3c63b9d7781c0650d 100644 --- a/src/containers/stage-wrapper.jsx +++ b/src/containers/stage-wrapper.jsx @@ -1,12 +1,14 @@ import PropTypes from 'prop-types'; import React from 'react'; import VM from 'scratch-vm'; +import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants.js'; import StageWrapperComponent from '../components/stage-wrapper/stage-wrapper.jsx'; const StageWrapper = props => <StageWrapperComponent {...props} />; StageWrapper.propTypes = { isRendererSupported: PropTypes.bool.isRequired, + stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, vm: PropTypes.instanceOf(VM).isRequired }; diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index 881c9c929aa3cef447a1f9b8831670158b321969..0013307f8d31bee981f324ff83d182980badf8a2 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -5,6 +5,7 @@ import Renderer from 'scratch-render'; import VM from 'scratch-vm'; import {connect} from 'react-redux'; +import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants'; import {getEventXY} from '../lib/touch-utils'; import VideoProvider from '../lib/video/video-provider'; import {SVGRenderer as V2SVGAdapter} from 'scratch-svg-renderer'; @@ -63,8 +64,7 @@ class Stage extends React.Component { this.props.vm.setVideoProvider(new VideoProvider()); } shouldComponentUpdate (nextProps, nextState) { - return this.props.width !== nextProps.width || - this.props.height !== nextProps.height || + return this.props.stageSize !== nextProps.stageSize || this.props.isColorPicking !== nextProps.isColorPicking || this.state.colorInfo !== nextState.colorInfo || this.props.isFullScreen !== nextProps.isFullScreen || @@ -380,14 +380,13 @@ class Stage extends React.Component { } Stage.propTypes = { - height: PropTypes.number, isColorPicking: PropTypes.bool, isFullScreen: PropTypes.bool.isRequired, onActivateColorPicker: PropTypes.func, onDeactivateColorPicker: PropTypes.func, + stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, useEditorDragStyle: PropTypes.bool, - vm: PropTypes.instanceOf(VM).isRequired, - width: PropTypes.number + vm: PropTypes.instanceOf(VM).isRequired }; Stage.defaultProps = { diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index dd20a4742f5825851410f3b8c5f901b815c5ace5..994f923bb4c5b7af34153f6232d02da2d04373d7 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -10,7 +10,7 @@ import { import {activateTab, COSTUMES_TAB_INDEX} from '../reducers/editor-tab'; import {setReceivedBlocks} from '../reducers/hovered-target'; - +import DragConstants from '../lib/drag-constants'; import TargetPaneComponent from '../components/target-pane/target-pane.jsx'; import spriteLibraryContent from '../lib/libraries/sprites.json'; import {handleFileUpload, spriteUpload} from '../lib/file-uploader.js'; @@ -27,6 +27,7 @@ class TargetPane extends React.Component { 'handleChangeSpriteX', 'handleChangeSpriteY', 'handleDeleteSprite', + 'handleDrop', 'handleDuplicateSprite', 'handleNewSprite', 'handleSelectSprite', @@ -106,6 +107,14 @@ class TargetPane extends React.Component { this.props.onReceivedBlocks(true); } } + + handleDrop (dragInfo) { + if (dragInfo.dragType === DragConstants.SPRITE) { + // Add one to both new and target index because we are not counting/moving the stage + this.props.vm.reorderTarget(dragInfo.index + 1, dragInfo.newIndex + 1); + } + } + render () { const { onActivateTab, // eslint-disable-line no-unused-vars @@ -123,6 +132,7 @@ class TargetPane extends React.Component { onChangeSpriteX={this.handleChangeSpriteX} onChangeSpriteY={this.handleChangeSpriteY} onDeleteSprite={this.handleDeleteSprite} + onDrop={this.handleDrop} onDuplicateSprite={this.handleDuplicateSprite} onFileUploadClick={this.handleFileUploadClick} onPaintSpriteClick={this.handlePaintSpriteClick} diff --git a/src/css/z-index.css b/src/css/z-index.css new file mode 100644 index 0000000000000000000000000000000000000000..b3368fc2bd638ef4763bf495171b41a7f41d7c53 --- /dev/null +++ b/src/css/z-index.css @@ -0,0 +1,28 @@ +/* + Contains constants for the z-index values of elements that are part of the global stack context. + In other words, z-index values that are "inside" a component are not added here. + This prevents conflicts between identical z-index values in different components. +*/ + +$z-index-extension-button: 50; /* Force extension button above the ScratchBlocks flyout */ +$z-index-menu-bar: 50; /* blocklyToolboxDiv is 40 */ + +$z-index-monitor: 100; +$z-index-coming-soon: 110; +$z-index-add-button: 120; + +$z-index-card: 490; +$z-index-loader: 500; +$z-index-modal: 510; + +$z-index-drag-layer: 1000; +$z-index-monitor-dragging: 1010; +$z-index-dragging-sprite: 1020; /* so it is draggable into other panes */ + +$z-index-stage-color-picker-background: 2000; +$z-index-stage-with-color-picker: 2010; +$z-index-stage-header: 5000; +$z-index-stage-wrapper-overlay: 5000; + +/* in most interfaces, the context menu is always on top */ +$z-index-context-menu: 10000; diff --git a/src/lib/drag-constants.js b/src/lib/drag-constants.js new file mode 100644 index 0000000000000000000000000000000000000000..86f064da90e556b50b5653be17aa8d9f17708811 --- /dev/null +++ b/src/lib/drag-constants.js @@ -0,0 +1,5 @@ +export default { + SOUND: 'SOUND', + COSTUME: 'COSTUME', + SPRITE: 'SPRITE' +}; diff --git a/src/lib/drag-utils.js b/src/lib/drag-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..e3f5c1bd0e65bd6fbade42ad9dda30b260e650a9 --- /dev/null +++ b/src/lib/drag-utils.js @@ -0,0 +1,47 @@ +/** + * @fileoverview + * Utility functions for drag interactions, e.g. sorting items in a grid/list. + */ + +/** + * From an xy position and a list of boxes {top, left, bottom, right}, return there + * corresponding box index the position is over. The boxes are in a (possibly wrapped) + * list, the only requirement being all boxes are flush against the edges, that is, + * if they are along an outer edge, the position of that edge is identical. + * This functionality works for a single column of items, a wrapped list with + * many rows, or a single row of items. + * @param {{x: number, y: number}} position The xy coordinates to retreive the corresponding index of. + * @param {Array.<DOMRect>} boxes The rects of the items, returned from `getBoundingClientRect` + * @return {?number} index of the corresponding box, or null if one could not be found. + */ +const indexForPositionOnList = ({x, y}, boxes) => { + if (boxes.length === 0) return null; + let index = null; + const leftEdge = Math.min.apply(null, boxes.map(b => b.left)); + const rightEdge = Math.max.apply(null, boxes.map(b => b.right)); + const topEdge = Math.min.apply(null, boxes.map(b => b.top)); + const bottomEdge = Math.max.apply(null, boxes.map(b => b.bottom)); + for (let n = 0; n < boxes.length; n++) { + const box = boxes[n]; + // Construct an "extended" box for each, extending out to infinity if + // the box is along a boundary. + const minX = box.left === leftEdge ? -Infinity : box.left; + const minY = box.top === topEdge ? -Infinity : box.top; + const maxY = box.bottom === bottomEdge ? Infinity : box.bottom; + // The last item in the wrapped list gets a right edge at infinity, even + // if it isn't the farthest right. Add this as an "or" condition for extension. + const maxX = (n === boxes.length - 1 || box.right === rightEdge) ? + Infinity : box.right; + + // Check if the point is in the bounds. + if (x > minX && x <= maxX && y > minY && y <= maxY) { + index = n; + break; // No need to keep looking. + } + } + return index; +}; + +export { + indexForPositionOnList +}; diff --git a/src/lib/layout-constants.js b/src/lib/layout-constants.js index 67c2ae44cc0ecc3c8b979626d4ba51e85a176b63..89cc7f3d9b6e34a768108771cceb4426dc1116dc 100644 --- a/src/lib/layout-constants.js +++ b/src/lib/layout-constants.js @@ -1,8 +1,56 @@ +import keyMirror from 'keymirror'; + +/** + * Names for each state of the stage size toggle + * @enum {string} + */ +const STAGE_SIZE_MODES = keyMirror({ + /** + * The "large stage" button is pressed; the user would like a large stage. + */ + large: null, + + /** + * The "small stage" button is pressed; the user would like a small stage. + */ + small: null +}); + +/** + * Names for each stage render size + * @enum {string} + */ +const STAGE_DISPLAY_SIZES = keyMirror({ + /** + * Large stage with wide browser + */ + large: null, + + /** + * Large stage with narrow browser + */ + largeConstrained: null, + + /** + * Small stage (ignores browser width) + */ + small: null +}); + +const STAGE_DISPLAY_SCALES = {}; +STAGE_DISPLAY_SCALES[STAGE_DISPLAY_SIZES.large] = 1; // large mode, wide browser (standard) +STAGE_DISPLAY_SCALES[STAGE_DISPLAY_SIZES.largeConstrained] = 0.85; // large mode but narrow browser +STAGE_DISPLAY_SCALES[STAGE_DISPLAY_SIZES.small] = 0.5; // small mode, regardless of browser size + export default { - fullStageWidth: 480, - fullStageHeight: 360, - smallerStageWidth: 480 * 0.85, - smallerStageHeight: 360 * 0.85, + standardStageWidth: 480, + standardStageHeight: 360, fullSizeMinWidth: 1096, fullSizePaintMinWidth: 1250 }; + +export { + STAGE_DISPLAY_SCALES, + STAGE_DISPLAY_SIZES, + STAGE_SIZE_MODES +}; diff --git a/src/lib/screen-utils.js b/src/lib/screen-utils.js index 866014913c11f8a37884d1abc5610eadd9e7ff3d..e233993e26155a8f493363c7a6424422d2e1e054 100644 --- a/src/lib/screen-utils.js +++ b/src/lib/screen-utils.js @@ -1,39 +1,73 @@ -const STAGE_SIZE_DEFAULTS = { - heightSmall: 360, - widthSmall: 480, +import layout, {STAGE_DISPLAY_SCALES, STAGE_SIZE_MODES, STAGE_DISPLAY_SIZES} from '../lib/layout-constants'; + +/** + * @typedef {object} StageDimensions + * @property {int} height - the height to be used for the stage in the current situation. + * @property {int} width - the width to be used for the stage in the current situation. + * @property {number} scale - the scale factor from the stage's default size to its current size. + * @property {int} heightDefault - the height of the stage in its default (large) size. + * @property {int} widthDefault - the width of the stage in its default (large) size. + */ + +const STAGE_DIMENSION_DEFAULTS = { spacingBorderAdjustment: 9, menuHeightAdjustment: 40 }; -const getStageSize = ( - isFullScreen = false, - height = STAGE_SIZE_DEFAULTS.heightSmall, - width = STAGE_SIZE_DEFAULTS.widthSmall) => { +/** + * Resolve the current GUI and browser state to an actual stage size enum value. + * @param {STAGE_SIZE_MODES} stageSizeMode - the state of the stage size toggle button. + * @param {boolean} isFullSize - true if the window is large enough for the large stage at its full size. + * @return {STAGE_DISPLAY_SIZES} - the stage size enum value we should use in this situation. + */ +const resolveStageSize = (stageSizeMode, isFullSize) => { + if (stageSizeMode === STAGE_SIZE_MODES.small) { + return STAGE_DISPLAY_SIZES.small; + } + if (isFullSize) { + return STAGE_DISPLAY_SIZES.large; + } + return STAGE_DISPLAY_SIZES.largeConstrained; +}; - const stageSize = { - heightDefault: height, - widthDefault: width, - height: height, - width: width +/** + * Retrieve info used to determine the actual stage size based on the current GUI and browser state. + * @param {STAGE_DISPLAY_SIZES} stageSize - the current fully-resolved stage size. + * @param {boolean} isFullScreen - true if full-screen mode is enabled. + * @return {StageDimensions} - an object describing the dimensions of the stage. + */ +const getStageDimensions = (stageSize, isFullScreen) => { + const stageDimensions = { + heightDefault: layout.standardStageHeight, + widthDefault: layout.standardStageWidth, + height: 0, + width: 0, + scale: 0 }; if (isFullScreen) { - stageSize.height = window.innerHeight - - STAGE_SIZE_DEFAULTS.menuHeightAdjustment - - STAGE_SIZE_DEFAULTS.spacingBorderAdjustment; + stageDimensions.height = window.innerHeight - + STAGE_DIMENSION_DEFAULTS.menuHeightAdjustment - + STAGE_DIMENSION_DEFAULTS.spacingBorderAdjustment; - stageSize.width = stageSize.height + (stageSize.height / 3); + stageDimensions.width = stageDimensions.height + (stageDimensions.height / 3); - if (stageSize.width > window.innerWidth) { - stageSize.width = window.innerWidth; - stageSize.height = stageSize.width * .75; + if (stageDimensions.width > window.innerWidth) { + stageDimensions.width = window.innerWidth; + stageDimensions.height = stageDimensions.width * .75; } + + stageDimensions.scale = stageDimensions.width / stageDimensions.widthDefault; + } else { + stageDimensions.scale = STAGE_DISPLAY_SCALES[stageSize]; + stageDimensions.height = stageDimensions.scale * stageDimensions.heightDefault; + stageDimensions.width = stageDimensions.scale * stageDimensions.widthDefault; } - return stageSize; + return stageDimensions; }; export { - getStageSize, - STAGE_SIZE_DEFAULTS + getStageDimensions, + resolveStageSize }; diff --git a/src/lib/sortable-hoc.jsx b/src/lib/sortable-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..665c5bc43a64fb055217786e4e96551de3c496ba --- /dev/null +++ b/src/lib/sortable-hoc.jsx @@ -0,0 +1,120 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; +import {indexForPositionOnList} from './drag-utils'; + +const SortableHOC = function (WrappedComponent) { + class SortableWrapper extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleAddSortable', + 'handleRemoveSortable' + ]); + + this.sortableRefs = []; + this.boxes = null; + } + + componentWillReceiveProps (newProps) { + if (newProps.dragInfo.dragging && !this.props.dragInfo.dragging) { + // Drag just started, snapshot the sorted bounding boxes for sortables. + this.boxes = this.sortableRefs.map(el => el && el.getBoundingClientRect()); + this.boxes.sort((a, b) => { // Sort top-to-bottom, left-to-right. + if (a.top === b.top) return a.left - b.left; + return a.top - b.top; + }); + } else if (!newProps.dragInfo.dragging && this.props.dragInfo.dragging) { + this.props.onDrop(Object.assign({}, + this.props.dragInfo, {newIndex: this.getMouseOverIndex()})); + } + } + + handleAddSortable (node) { + this.sortableRefs.push(node); + } + + handleRemoveSortable (node) { + const index = this.sortableRefs.indexOf(node); + this.sortableRefs = this.sortableRefs.slice(0, index) + .concat(this.sortableRefs.slice(index + 1)); + } + + getOrdering (items, draggingIndex, newIndex) { + // An "Ordering" is an array of indices, where the position array value corresponds + // to the position of the item in props.items, and the index of the value + // is the index at which the item should appear. + // That is, if props.items is ['a', 'b', 'c', 'd'], and we want the GUI to display + // ['b', 'c', 'a, 'd'], the value of "ordering" would be [1, 2, 0, 3]. + // This mapping is used because it is easy to translate to flexbox ordering, + // the `order` property for item N is ordering.indexOf(N). + // If the user-facing order matches props.items, the ordering is just [0, 1, 2, ...] + let ordering = Array(this.props.items.length).fill(0) + .map((_, i) => i); + const isNumber = v => typeof v === 'number' && !isNaN(v); + if (isNumber(draggingIndex) && isNumber(newIndex)) { + ordering = ordering.slice(0, draggingIndex).concat(ordering.slice(draggingIndex + 1)); + ordering.splice(newIndex, 0, draggingIndex); + } + return ordering; + } + getMouseOverIndex () { + // MouseOverIndex is the index that the current drag wants to place the + // the dragging object. Obviously only exists if there is a drag (i.e. currentOffset). + let mouseOverIndex = null; + if (this.props.dragInfo.currentOffset) { + mouseOverIndex = indexForPositionOnList( + this.props.dragInfo.currentOffset, this.boxes); + } + return mouseOverIndex; + } + render () { + const {dragInfo: {index: dragIndex, dragType}, items} = this.props; + const mouseOverIndex = this.getMouseOverIndex(); + const ordering = this.getOrdering(items, dragIndex, mouseOverIndex); + return ( + <WrappedComponent + draggingIndex={dragIndex} + draggingType={dragType} + mouseOverIndex={mouseOverIndex} + ordering={ordering} + onAddSortable={this.handleAddSortable} + onRemoveSortable={this.handleRemoveSortable} + {...this.props} + /> + ); + } + } + + SortableWrapper.propTypes = { + dragInfo: PropTypes.shape({ + currentOffset: PropTypes.shape({ + x: PropTypes.number, + y: PropTypes.number + }), + dragType: PropTypes.string, + dragging: PropTypes.bool, + index: PropTypes.number + }), + items: PropTypes.arrayOf(PropTypes.shape({ + url: PropTypes.string, + name: PropTypes.string.isRequired + })), + onClose: PropTypes.func, + onDrop: PropTypes.func + }; + + const mapStateToProps = state => ({ + dragInfo: state.scratchGui.assetDrag + }); + + const mapDispatchToProps = () => ({}); + + return connect( + mapStateToProps, + mapDispatchToProps + )(SortableWrapper); +}; + +export default SortableHOC; diff --git a/src/reducers/menus.js b/src/reducers/menus.js index 9d9ab0be9882e9001c5bd8f63195d9755a82631d..6693160c177fc988232fbd505f5a6c1e5af8d134 100644 --- a/src/reducers/menus.js +++ b/src/reducers/menus.js @@ -3,11 +3,13 @@ const CLOSE_MENU = 'scratch-gui/menus/CLOSE_MENU'; const MENU_FILE = 'fileMenu'; const MENU_EDIT = 'editMenu'; +const MENU_LANGUAGE = 'languageMenu'; const initialState = { [MENU_FILE]: false, - [MENU_EDIT]: false + [MENU_EDIT]: false, + [MENU_LANGUAGE]: false }; const reducer = function (state, action) { @@ -39,6 +41,9 @@ const fileMenuOpen = state => state.scratchGui.menus[MENU_FILE]; const openEditMenu = () => openMenu(MENU_EDIT); const closeEditMenu = () => closeMenu(MENU_EDIT); const editMenuOpen = state => state.scratchGui.menus[MENU_EDIT]; +const openLanguageMenu = () => openMenu(MENU_LANGUAGE); +const closeLanguageMenu = () => closeMenu(MENU_LANGUAGE); +const languageMenuOpen = state => state.scratchGui.menus[MENU_LANGUAGE]; export { reducer as default, @@ -47,6 +52,9 @@ export { closeFileMenu, openEditMenu, closeEditMenu, + openLanguageMenu, + closeLanguageMenu, fileMenuOpen, - editMenuOpen + editMenuOpen, + languageMenuOpen }; diff --git a/src/reducers/stage-size.js b/src/reducers/stage-size.js index 74653f19e778169509ceeb898e6d87060742ca5a..44246ca43ae9decef5b27090fa2cccb697257ab2 100644 --- a/src/reducers/stage-size.js +++ b/src/reducers/stage-size.js @@ -1,13 +1,9 @@ +import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants.js'; + const SET_STAGE_SIZE = 'scratch-gui/StageSize/SET_STAGE_SIZE'; const initialState = { - stageSize: 'large' -}; - -// stage size constants -const STAGE_SIZES = { - small: 'small', - large: 'large' + stageSize: STAGE_DISPLAY_SIZES.large }; const reducer = function (state, action) { @@ -32,6 +28,5 @@ const setStageSize = function (stageSize) { export { reducer as default, initialState as stageSizeInitialState, - setStageSize, - STAGE_SIZES + setStageSize }; diff --git a/test/unit/util/drag-utils.test.js b/test/unit/util/drag-utils.test.js new file mode 100644 index 0000000000000000000000000000000000000000..73029564eb584bf87d55745be9f9883fab472dbf --- /dev/null +++ b/test/unit/util/drag-utils.test.js @@ -0,0 +1,41 @@ +import {indexForPositionOnList} from '../../../src/lib/drag-utils'; + +const box = (top, right, bottom, left) => ({top, right, bottom, left}); + +describe('indexForPositionOnList', () => { + test('returns null when not given any boxes', () => { + expect(indexForPositionOnList({x: 0, y: 0}, [])).toEqual(null); + }); + + test('wrapped list with incomplete last row', () => { + const boxes = [ + box(0, 100, 100, 0), // index: 0 + box(0, 200, 100, 100), // index: 1 + box(0, 300, 100, 200), // index: 2 + box(100, 100, 200, 0), // index: 3 (second row) + box(100, 200, 200, 100) // index: 4 (second row, left incomplete intentionally) + ]; + + // Inside the second box. + expect(indexForPositionOnList({x: 150, y: 50}, boxes)).toEqual(1); + + // On the border edge of the first and second box. Given to the first box. + expect(indexForPositionOnList({x: 100, y: 50}, boxes)).toEqual(0); + + // Off the top/left edge. + expect(indexForPositionOnList({x: -100, y: -100}, boxes)).toEqual(0); + + // Off the left edge, in the second row. + expect(indexForPositionOnList({x: -100, y: 175}, boxes)).toEqual(3); + + // Off the right edge, in the first row. + expect(indexForPositionOnList({x: 400, y: 75}, boxes)).toEqual(2); + + // Off the top edge, middle of second item. + expect(indexForPositionOnList({x: 150, y: -75}, boxes)).toEqual(1); + + // Within the right edge bounds, but on the second (incomplete) row. + // This tests that wrapped lists with incomplete final rows work correctly. + expect(indexForPositionOnList({x: 375, y: 175}, boxes)).toEqual(4); + }); +});