diff --git a/package.json b/package.json
index 7840f355b..a58e55026 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"eslint-config-scratch": "^5.0.0",
"eslint-plugin-import": "^2.8.0",
"eslint-plugin-react": "^7.5.1",
- "file-loader": "1.1.6",
+ "file-loader": "1.1.9",
"get-float-time-domain-data": "0.1.0",
"get-user-media-promise": "1.1.1",
"gh-pages": "github:rschamp/gh-pages#publish-branch-to-subfolder",
@@ -77,8 +77,8 @@
"react-ga": "2.4.1",
"react-intl": "2.4.0",
"react-intl-redux": "0.7.0",
- "react-modal": "3.1.13",
- "react-redux": "5.0.6",
+ "react-modal": "3.3.1",
+ "react-redux": "5.0.7",
"react-responsive": "4.0.3",
"react-style-proptype": "3.2.0",
"react-tabs": "2.2.1",
@@ -89,12 +89,12 @@
"redux-throttle": "0.1.1",
"rimraf": "^2.6.1",
"scratch-audio": "0.1.0-prerelease.1516198804",
- "scratch-blocks": "0.1.0-prerelease.1518210548",
+ "scratch-blocks": "0.1.0-prerelease.1519256473",
"scratch-l10n": "2.0.20180108132626",
- "scratch-paint": "0.1.0-prerelease.20180209163938",
+ "scratch-paint": "0.2.0-prerelease.20180227161624",
"scratch-render": "0.1.0-prerelease.1516837442",
"scratch-storage": "0.4.0",
- "scratch-vm": "0.1.0-prerelease.1518098842-prerelease.1518098861",
+ "scratch-vm": "0.1.0-prerelease.1519681201-prerelease.1519681262",
"selenium-webdriver": "3.6.0",
"startaudiocontext": "1.2.1",
"style-loader": "^0.20.0",
diff --git a/src/components/action-menu/action-menu.css b/src/components/action-menu/action-menu.css
new file mode 100644
index 000000000..c222216b1
--- /dev/null
+++ b/src/components/action-menu/action-menu.css
@@ -0,0 +1,174 @@
+@import "../../css/colors.css";
+
+$main-button-size: 2.75rem;
+$more-button-size: 2.25rem;
+
+.menu-container {
+ display: flex;
+ flex-direction: column-reverse;
+ transition: 0.2s;
+ position: relative;
+}
+
+.button {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ background: $motion-primary;
+ outline: none;
+ border: none;
+ transition: background-color 0.2s;
+}
+
+.button:hover {
+ background: $pen-primary;
+}
+
+.button.coming-soon:hover {
+ background: $data-primary;
+}
+
+.main-button {
+ border-radius: 100%;
+ width: $main-button-size;
+ height: $main-button-size;
+ z-index: 20; /* TODO reorder layout to prevent z-index need */
+}
+
+.main-icon {
+ width: calc($main-button-size - 1rem);
+ height: calc($main-button-size - 1rem);
+}
+
+.more-buttons-outer {
+ /*
+ Need to use two divs to set different overflow x/y
+ which is needed to get animation to look right while
+ allowing the tooltips to be visible.
+ */
+ overflow-y: hidden;
+
+ background: $motion-tertiary;
+ border-top-left-radius: $more-button-size;
+ border-top-right-radius: $more-button-size;
+ width: $more-button-size;
+ margin-left: calc(($main-button-size - $more-button-size) / 2);
+
+ position: absolute;
+ bottom: calc($main-button-size);
+
+ margin-bottom: calc($main-button-size / -2);
+ padding-bottom: calc($main-button-size / 2);
+}
+
+.more-buttons {
+ max-height: 0;
+ transition: max-height 1s;
+ overflow-x: visible;
+ display: flex;
+ flex-direction: column;
+ z-index: 10; /* @todo justify */
+}
+
+.expanded .more-buttons {
+ max-height: 1000px; /* Arbitrary, needs to be a value in order for animation to run */
+}
+
+.force-hidden .more-buttons {
+ display: none; /* This property does not animate */
+}
+
+.more-buttons:first-child { /* Round off top button */
+ border-top-right-radius: $more-button-size;
+ border-top-left-radius: $more-button-size;
+}
+
+.more-button {
+ width: $more-button-size;
+ height: $more-button-size;
+ background: $motion-tertiary;
+}
+
+.more-icon {
+ width: calc($more-button-size - 1rem);
+ height: calc($more-button-size - 1rem);
+}
+
+.coming-soon .more-icon {
+ opacity: 0.5;
+}
+
+/*
+ @todo needs to be refactored with coming soon tooltip overrides.
+ The "!important"s are for the same reason as with coming soon, the library
+ is not very easy to style.
+*/
+.tooltip {
+ background-color: $pen-primary !important;
+ opacity: 1 !important;
+ border: 1px solid hsla(0, 0%, 0%, .1) !important;
+ box-shadow: 0 0 .5rem hsla(0, 0%, 0%, .25) !important;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important;
+}
+
+.tooltip:after {
+ background-color: $pen-primary;
+}
+
+.coming-soon-tooltip {
+ background-color: $data-primary !important;
+}
+
+.coming-soon-tooltip:after {
+ background-color: $data-primary !important;
+}
+
+.tooltip {
+ border: 1px solid hsla(0, 0%, 0%, .1) !important;
+ border-radius: .25rem !important;
+ box-shadow: 0 0 .5rem hsla(0, 0%, 0%, .25) !important;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important;
+ z-index: 100 !important;
+}
+
+$arrow-size: 0.5rem;
+$arrow-inset: -0.25rem;
+$arrow-rounding: 0.125rem;
+
+.tooltip:after {
+ content: "";
+ border-top: 1px solid hsla(0, 0%, 0%, .1) !important;
+ border-left: 0 !important;
+ border-bottom: 0 !important;
+ border-right: 1px solid hsla(0, 0%, 0%, .1) !important;
+ border-radius: $arrow-rounding;
+ height: $arrow-size !important;
+ width: $arrow-size !important;
+}
+
+.tooltip:global(.place-left):after {
+ margin-top: $arrow-inset !important;
+ right: $arrow-inset !important;
+ transform: rotate(45deg) !important;
+}
+
+.tooltip:global(.place-right):after {
+ margin-top: $arrow-inset !important;
+ left: $arrow-inset !important;
+ transform: rotate(-135deg) !important;
+}
+
+.tooltip:global(.place-top):after {
+ margin-right: $arrow-inset !important;
+ bottom: $arrow-inset !important;
+ transform: rotate(135deg) !important;
+}
+
+.tooltip:global(.place-bottom):after {
+ margin-left: $arrow-inset !important;
+ top: $arrow-inset !important;
+ transform: rotate(-45deg) !important;
+}
diff --git a/src/components/action-menu/action-menu.jsx b/src/components/action-menu/action-menu.jsx
new file mode 100644
index 000000000..910d1ab6a
--- /dev/null
+++ b/src/components/action-menu/action-menu.jsx
@@ -0,0 +1,189 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import bindAll from 'lodash.bindall';
+import ReactTooltip from 'react-tooltip';
+
+import styles from './action-menu.css';
+
+const CLOSE_DELAY = 300; // ms
+
+class ActionMenu extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'clickDelayer',
+ 'handleClosePopover',
+ 'handleToggleOpenState',
+ 'handleTouchStart',
+ 'handleTouchOutside',
+ 'setButtonRef',
+ 'setContainerRef'
+ ]);
+ this.state = {
+ isOpen: false,
+ forceHide: false
+ };
+ }
+ componentDidMount () {
+ // Touch start on the main button is caught to trigger open and not click
+ this.buttonRef.addEventListener('touchstart', this.handleTouchStart);
+ // Touch start on document is used to trigger close if it is outside
+ document.addEventListener('touchstart', this.handleTouchOutside);
+ }
+ shouldComponentUpdate (newProps, newState) {
+ // This check prevents re-rendering while the project is updating.
+ // @todo check only the state and the title because it is enough to know
+ // if anything substantial has changed
+ // This is needed because of the sloppy way the props are passed as a new object,
+ // which should be refactored.
+ return newState.isOpen !== this.state.isOpen ||
+ newState.forceHide !== this.state.forceHide ||
+ newProps.title !== this.props.title;
+ }
+ componentWillUnmount () {
+ this.buttonRef.removeEventListener('touchstart', this.handleTouchStart);
+ document.removeEventListener('touchstart', this.handleTouchOutside);
+ }
+ handleClosePopover () {
+ this.closeTimeoutId = setTimeout(() => {
+ this.setState({isOpen: false});
+ this.closeTimeoutId = null;
+ }, CLOSE_DELAY);
+ }
+ handleToggleOpenState () {
+ // Mouse enter back in after timeout was started prevents it from closing.
+ if (this.closeTimeoutId) {
+ clearTimeout(this.closeTimeoutId);
+ this.closeTimeoutId = null;
+ } else if (!this.state.isOpen) {
+ this.setState({
+ isOpen: true,
+ forceHide: false
+ });
+ }
+ }
+ handleTouchOutside (e) {
+ if (this.state.isOpen && !this.containerRef.contains(e.target)) {
+ this.setState({isOpen: false});
+ }
+ }
+ clickDelayer (fn) {
+ // Return a wrapped action that manages the menu closing.
+ // @todo we may be able to use react-transition for this in the future
+ // for now all this work is to ensure the menu closes BEFORE the
+ // (possibly slow) action is started.
+ return event => {
+ this.setState({forceHide: true, isOpen: false}, () => {
+ if (fn) fn(event);
+ setTimeout(() => this.setState({forceHide: false}));
+ });
+ };
+ }
+ handleTouchStart (e) {
+ // Prevent this touch from becoming a click if menu is closed
+ if (!this.state.isOpen) {
+ e.preventDefault();
+ this.handleToggleOpenState();
+ }
+ }
+ setButtonRef (ref) {
+ this.buttonRef = ref;
+ }
+ setContainerRef (ref) {
+ this.containerRef = ref;
+ }
+ render () {
+ const {
+ className,
+ img: mainImg,
+ title: mainTitle,
+ moreButtons,
+ onClick
+ } = this.props;
+
+ const mainTooltipId = `tooltip-${Math.random()}`;
+
+ return (
+
+
+
+
+
+ {(moreButtons || []).map(({img, title, onClick: handleClick}) => {
+ const isComingSoon = !handleClick;
+ const tooltipId = `tooltip-${Math.random()}`;
+ return (
+
+
+
+
+ );
+ })}
+
+
+
+ );
+ }
+}
+
+ActionMenu.propTypes = {
+ className: PropTypes.string,
+ img: PropTypes.string,
+ moreButtons: PropTypes.arrayOf(PropTypes.shape({
+ img: PropTypes.string,
+ title: PropTypes.node.isRequired,
+ onClick: PropTypes.func // Optional, "coming soon" if no callback provided
+ })),
+ onClick: PropTypes.func.isRequired,
+ title: PropTypes.node.isRequired
+};
+
+export default ActionMenu;
diff --git a/src/components/stage-selector/icon--backdrop.svg b/src/components/action-menu/icon--backdrop.svg
similarity index 100%
rename from src/components/stage-selector/icon--backdrop.svg
rename to src/components/action-menu/icon--backdrop.svg
diff --git a/src/components/action-menu/icon--camera.svg b/src/components/action-menu/icon--camera.svg
new file mode 100644
index 000000000..e8c442d8f
--- /dev/null
+++ b/src/components/action-menu/icon--camera.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/src/components/action-menu/icon--file-upload.svg b/src/components/action-menu/icon--file-upload.svg
new file mode 100644
index 000000000..57337d959
--- /dev/null
+++ b/src/components/action-menu/icon--file-upload.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/src/components/action-menu/icon--paint.svg b/src/components/action-menu/icon--paint.svg
new file mode 100644
index 000000000..f79d562c3
--- /dev/null
+++ b/src/components/action-menu/icon--paint.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/src/components/sprite-selector/icon--sprite.svg b/src/components/action-menu/icon--sprite.svg
similarity index 100%
rename from src/components/sprite-selector/icon--sprite.svg
rename to src/components/action-menu/icon--sprite.svg
diff --git a/src/components/action-menu/icon--surprise.svg b/src/components/action-menu/icon--surprise.svg
new file mode 100644
index 000000000..41655999f
--- /dev/null
+++ b/src/components/action-menu/icon--surprise.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/src/components/asset-button/asset-button.css b/src/components/asset-button/asset-button.css
deleted file mode 100644
index b36fa76b8..000000000
--- a/src/components/asset-button/asset-button.css
+++ /dev/null
@@ -1,21 +0,0 @@
-@import "../../css/colors.css";
-
-.container {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
- background: $motion-primary;
- border-radius: 100%;
- width: 2.75rem;
- height: 2.75rem;
- outline: none;
- border: none;
-}
-
-.icon {
- width: 1.75rem;
- height: 1.75rem;
-}
diff --git a/src/components/asset-button/asset-button.jsx b/src/components/asset-button/asset-button.jsx
deleted file mode 100644
index 088838990..000000000
--- a/src/components/asset-button/asset-button.jsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import classNames from 'classnames';
-import styles from './asset-button.css';
-
-const AssetButton = ({
- img,
- className,
- title,
- onClick
-}) => (
-
-);
-
-AssetButton.propTypes = {
- className: PropTypes.string,
- img: PropTypes.string,
- onClick: PropTypes.func.isRequired,
- title: PropTypes.node.isRequired
-};
-
-export default AssetButton;
diff --git a/src/components/asset-panel/selector.css b/src/components/asset-panel/selector.css
index 2a19296d6..db9bca069 100644
--- a/src/components/asset-panel/selector.css
+++ b/src/components/asset-panel/selector.css
@@ -11,13 +11,31 @@
}
.new-buttons {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
- margin: 0.75rem 0;
+ padding: 0.75rem 0;
color: $motion-primary;
text-align: center;
+ background: none;
+}
+
+$fade-out-distance: 100px;
+
+.new-buttons:before {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ background: linear-gradient(rgba(232,237,241, 0),rgba(232,237,241, 1));
+ height: $fade-out-distance;
+ width: 100%;
+ pointer-events: none;
}
.new-buttons > button + button {
diff --git a/src/components/asset-panel/selector.jsx b/src/components/asset-panel/selector.jsx
index 012206517..f2ab58cae 100644
--- a/src/components/asset-panel/selector.jsx
+++ b/src/components/asset-panel/selector.jsx
@@ -4,7 +4,7 @@ import React from 'react';
import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx';
import Box from '../box/box.jsx';
-import AssetButton from '../asset-button/asset-button.jsx';
+import ActionMenu from '../action-menu/action-menu.jsx';
import styles from './selector.css';
const Selector = props => {
@@ -13,9 +13,27 @@ const Selector = props => {
items,
selectedItemIndex,
onDeleteClick,
+ onDuplicateClick,
onItemClick
} = props;
+ let newButtonSection = null;
+
+ if (buttons.length > 0) {
+ const {img, title, onClick} = buttons[0];
+ const moreButtons = buttons.slice(1);
+ newButtonSection = (
+
+
+
+ );
+ }
+
return (
@@ -30,19 +48,11 @@ const Selector = props => {
selected={index === selectedItemIndex}
onClick={onItemClick}
onDeleteButtonClick={onDeleteClick}
+ onDuplicateButtonClick={onDuplicateClick}
/>
))}
-
- {buttons.map(({message, img, onClick}, index) => (
-
- ))}
-
+ {newButtonSection}
);
};
@@ -58,6 +68,7 @@ Selector.propTypes = {
name: PropTypes.string.isRequired
})),
onDeleteClick: PropTypes.func,
+ onDuplicateClick: PropTypes.func,
onItemClick: PropTypes.func.isRequired,
selectedItemIndex: PropTypes.number.isRequired
};
diff --git a/src/components/close-button/close-button.css b/src/components/close-button/close-button.css
index 1864e7b2a..88ebb526e 100644
--- a/src/components/close-button/close-button.css
+++ b/src/components/close-button/close-button.css
@@ -49,3 +49,18 @@
width: 0.75rem;
height: 0.75rem;
}
+
+.back-icon {
+ position: relative;
+ margin: 0.25rem;
+ user-select: none;
+}
+
+.small .back-icon {
+ width: 50%;
+}
+
+.large .back-icon {
+ width: 2rem;
+ height: 2rem;
+}
diff --git a/src/components/close-button/close-button.jsx b/src/components/close-button/close-button.jsx
index 039a8750b..befac229f 100644
--- a/src/components/close-button/close-button.jsx
+++ b/src/components/close-button/close-button.jsx
@@ -4,6 +4,7 @@ import classNames from 'classnames';
import styles from './close-button.css';
import closeIcon from './icon--close.svg';
+import backIcon from './icon--back.svg';
const CloseButton = props => (
(
tabIndex="0"
onClick={props.onClick}
>
-

+ {props.buttonType === 'back' ?
+

:
+

+ }
);
@@ -31,13 +38,15 @@ CloseButton.SIZE_SMALL = 'small';
CloseButton.SIZE_LARGE = 'large';
CloseButton.propTypes = {
+ buttonType: PropTypes.oneOf(['back', 'close']),
className: PropTypes.string,
onClick: PropTypes.func.isRequired,
size: PropTypes.oneOf([CloseButton.SIZE_SMALL, CloseButton.SIZE_LARGE])
};
CloseButton.defaultProps = {
- size: CloseButton.SIZE_LARGE
+ size: CloseButton.SIZE_LARGE,
+ buttonType: 'close'
};
export default CloseButton;
diff --git a/src/components/close-button/icon--back.svg b/src/components/close-button/icon--back.svg
new file mode 100644
index 000000000..e3f82a6fd
--- /dev/null
+++ b/src/components/close-button/icon--back.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/src/components/green-flag/green-flag.css b/src/components/green-flag/green-flag.css
index 2c1452838..35ba29726 100644
--- a/src/components/green-flag/green-flag.css
+++ b/src/components/green-flag/green-flag.css
@@ -5,6 +5,7 @@
padding: 0.375rem;
border-radius: 0.25rem;
user-select: none;
+ user-drag: none;
cursor: pointer;
transition: 0.2s ease-out;
}
diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css
index 92b83acc2..5132af685 100644
--- a/src/components/gui/gui.css
+++ b/src/components/gui/gui.css
@@ -140,7 +140,8 @@
For making the sprite-selector a scrollable pane
@todo: Not working in Safari
*/
- overflow: hidden;
+ /* TODO this also breaks the thermometer menu */
+ /* overflow: hidden; */
}
.extension-button-container {
@@ -156,6 +157,19 @@
box-sizing: content-box; /* To match scratch-block vertical toolbox borders */
}
+$fade-out-distance: 15px;
+
+.extension-button-container:before {
+ content: "";
+ position: absolute;
+ top: calc(calc(-1 * $fade-out-distance) - 1px);
+ left: -1px;
+ background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, 0.15));
+ height: $fade-out-distance;
+ width: calc(100% + 0.5px);
+}
+
+
.extension-button {
background: none;
border: none;
diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx
index fe58243ba..5c7a378dc 100644
--- a/src/components/gui/gui.jsx
+++ b/src/components/gui/gui.jsx
@@ -19,6 +19,7 @@ import Box from '../box/box.jsx';
import FeedbackForm from '../feedback-form/feedback-form.jsx';
import MenuBar from '../menu-bar/menu-bar.jsx';
import PreviewModal from '../../containers/preview-modal.jsx';
+import ImportModal from '../../containers/import-modal.jsx';
import WebGlModal from '../../containers/webgl-modal.jsx';
import layout from '../../lib/layout-constants.js';
@@ -35,16 +36,19 @@ const messages = defineMessages({
const GUIComponent = props => {
const {
+ activeTabIndex,
basePath,
+ blocksTabVisible,
children,
- enableExtensions,
- intl,
+ costumesTabVisible,
feedbackFormVisible,
- vm,
- previewInfoVisible,
+ importInfoVisible,
+ intl,
onExtensionButtonClick,
- onTabSelect,
- tabIndex,
+ onActivateTab,
+ previewInfoVisible,
+ soundsTabVisible,
+ vm,
...componentProps
} = props;
if (children) {
@@ -74,6 +78,9 @@ const GUIComponent = props => {
{previewInfoVisible ? (
) : null}
+ {importInfoVisible ? (
+
+ ) : null}
{feedbackFormVisible ? (
) : null}
@@ -87,9 +94,10 @@ const GUIComponent = props => {
Blocks
@@ -100,7 +108,7 @@ const GUIComponent = props => {
{
- {tabIndex === 1 ? : null}
+ {costumesTabVisible ? : null}
- {tabIndex === 2 ? : null}
+ {soundsTabVisible ? : null}
@@ -162,15 +168,19 @@ const GUIComponent = props => {
);
};
GUIComponent.propTypes = {
+ activeTabIndex: PropTypes.number,
basePath: PropTypes.string,
+ blocksTabVisible: PropTypes.bool,
children: PropTypes.node,
- enableExtensions: PropTypes.bool,
+ costumesTabVisible: PropTypes.bool,
feedbackFormVisible: PropTypes.bool,
+ importInfoVisible: PropTypes.bool,
intl: intlShape.isRequired,
+ onActivateTab: PropTypes.func,
onExtensionButtonClick: PropTypes.func,
onTabSelect: PropTypes.func,
previewInfoVisible: PropTypes.bool,
- tabIndex: PropTypes.number,
+ soundsTabVisible: PropTypes.bool,
vm: PropTypes.instanceOf(VM).isRequired
};
GUIComponent.defaultProps = {
diff --git a/src/components/import-modal/import-modal.css b/src/components/import-modal/import-modal.css
new file mode 100644
index 000000000..b794fd753
--- /dev/null
+++ b/src/components/import-modal/import-modal.css
@@ -0,0 +1,185 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+@import "../../css/typography.css";
+
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1000;
+ background-color: hsla(215, 100%, 65%, .9);
+}
+
+.modal-content {
+ margin: 100px auto;
+ outline: none;
+ border: .25rem solid hsla(0, 100%, 100%, .25);
+ padding: 0;
+ border-radius: $space;
+ user-select: none;
+ width: 500px;
+ color: $text-primary;
+ overflow: hidden;
+}
+
+/*
+ TODO figure out how to remove filter altogether
+ since it is null...
+ Modal header has 3 items:
+ |x title filter|
+
+ Use the same width for both side item containers,
+ so that title remains centered
+*/
+$sides: 20rem;
+
+.header {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+ height: $library-header-height;
+
+ box-sizing: border-box;
+ width: 100%;
+ background-color: $import-primary;
+}
+
+.header-item {
+ display: flex;
+ align-items: center;
+ padding: 1rem;
+ text-decoration: none;
+ color: white;
+ user-select: none;
+}
+
+.header-item-filter {
+ display: flex;
+ flex-basis: $sides;
+ justify-content: flex-end;
+}
+
+.header-item-title {
+ flex-grow: 1;
+ flex-shrink: 0;
+ justify-content: center;
+ user-select: none;
+ letter-spacing: 0.4px;
+ cursor: default;
+}
+
+.header-item-title h2 {
+ font-size: 1.25rem;
+}
+
+.header-item-close {
+ display: flex;
+ flex-basis: $sides;
+ justify-content: flex-start;
+}
+
+.body {
+ background: $ui-pane-gray;
+ padding: 1.5rem 2.25rem;
+ text-align: center;
+}
+
+.input-row {
+ margin: 1.5rem 0;
+ font-weight: bolder;
+ text-align: right;
+ display: flex;
+ justify-content: center;
+ border: 1px solid;
+ border-radius: 0.25rem;
+ overflow: hidden;
+}
+
+.ok-input-container {
+ border-color: $motion-primary;
+ box-shadow: 0 0 0 0.2rem $motion-transparent;
+}
+
+.bad-input-container {
+ border-color: $data-primary;
+ box-shadow: 0 0 0 0.2rem hsla(30, 100%, 55%, 0.15);
+}
+
+.input-row input {
+ width: 100%;
+ padding: 0 1rem;
+ height: 3rem;
+ color: #6b6b6b;
+ font-size: .875rem;
+ outline: none;
+ border: none;
+}
+
+.input-row input::placeholder {
+ font-style: italic;
+ color: rgba(87,94,117,0.5);
+}
+
+.input-row button {
+ padding: 0.5rem 2rem;
+ font-weight: bold;
+ font-size: .875rem;
+ cursor: pointer;
+ border: 0px solid $import-primary;
+ outline: none;
+}
+
+.input-row button.ok-button {
+ background: $import-primary;
+ color: white;
+}
+
+.error-row {
+ margin: 1.5rem 0;
+ text-align: center;
+ display: flex;
+ justify-content: center;
+ background: hsla(30, 100%, 55%, 0.25);
+ color: $data-primary;
+ border: 1px solid $data-primary;
+ border-radius: 0.25rem;
+}
+
+.error-row p {
+ font-size: 0.875rem;
+ font-weight: bold;
+}
+
+/* Confirmation buttons at the bottom of the modal */
+.button-row {
+ margin: 1.5rem 0;
+ font-weight: bolder;
+ text-align: right;
+ display: flex;
+ justify-content: center;
+}
+
+.button-row button {
+ border: 1px solid $motion-primary;
+ border-radius: 0.25rem;
+ padding: 0.5rem 1.5rem;
+ background: white;
+ font-weight: bold;
+ font-size: .875rem;
+ cursor: pointer;
+ color: $motion-primary;
+}
+
+.faq-link-text {
+ margin: 2rem 0 .5rem 0;
+ font-size: .875rem;
+ color: $text-primary;
+}
+
+.faq-link {
+ color: $motion-primary;
+ text-decoration: none;
+}
diff --git a/src/components/import-modal/import-modal.jsx b/src/components/import-modal/import-modal.jsx
new file mode 100644
index 000000000..ad42929e7
--- /dev/null
+++ b/src/components/import-modal/import-modal.jsx
@@ -0,0 +1,155 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReactModal from 'react-modal';
+import Box from '../box/box.jsx';
+import {defineMessages, injectIntl, intlShape, FormattedMessage} from 'react-intl';
+import classNames from 'classnames';
+
+import CloseButton from '../close-button/close-button.jsx';
+
+import styles from './import-modal.css';
+
+const messages = defineMessages({
+ title: {
+ id: 'gui.importInfo.title',
+ defaultMessage: 'View a Scratch 2.0 Project',
+ description: 'Scratch 2.0 import modal label - for accessibility'
+ },
+ formDescription: {
+ defaultMessage:
+ 'Enter a link to one of your shared Scratch projects. Changes made in this 3.0 Preview will not be saved.',
+ description: 'Import project message',
+ id: 'gui.importInfo.message'
+ },
+ invalidFormatError: {
+ id: 'gui.importInfo.invalidFormatError',
+ defaultMessage: 'Uh oh, that project link or id doesn\'t look quite right.',
+ description: 'Invalid project link or id message'
+ }
+});
+
+const ImportModal = ({intl, ...props}) => (
+
+
+
+
+
+
+
+
+ {intl.formatMessage({...messages.title})}
+
+
+
+ {null}
+
+
+
+
+
+
+ {intl.formatMessage({...messages.formDescription})}
+
+
+
+
+
+ {props.hasValidationError ?
+
+
+
+
+ : null
+ }
+
+
+
+
+
+
+
+ )
+ }}
+ />
+
+
+
+);
+
+ImportModal.propTypes = {
+ errorMessage: PropTypes.string.isRequired,
+ hasValidationError: PropTypes.bool.isRequired,
+ inputValue: PropTypes.string.isRequired,
+ intl: intlShape.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onGoBack: PropTypes.func.isRequired,
+ onKeyPress: PropTypes.func.isRequired,
+ onViewProject: PropTypes.func.isRequired,
+ placeholder: PropTypes.string
+};
+
+export default injectIntl(ImportModal);
diff --git a/src/components/monitor/monitor.jsx b/src/components/monitor/monitor.jsx
index 17b562120..5fb521e9c 100644
--- a/src/components/monitor/monitor.jsx
+++ b/src/components/monitor/monitor.jsx
@@ -9,7 +9,8 @@ const categories = {
sensing: '#5CB1D6',
sound: '#CF63CF',
looks: '#9966FF',
- motion: '#4C97FF'
+ motion: '#4C97FF',
+ list: '#FC662C'
};
const MonitorComponent = props => (
diff --git a/src/components/preview-modal/preview-modal.css b/src/components/preview-modal/preview-modal.css
index 858beb5ba..c6e5037e8 100644
--- a/src/components/preview-modal/preview-modal.css
+++ b/src/components/preview-modal/preview-modal.css
@@ -51,7 +51,7 @@
.button-row button {
border: 1px solid $motion-primary;
border-radius: 0.25rem;
- padding: 0.5rem 2rem;
+ padding: 0.5rem 1.5rem;
background: white;
font-weight: bold;
font-size: .875rem;
@@ -66,6 +66,13 @@
.button-row button.no-button {
color: $motion-primary;
}
+
+.button-row button.view-project-button {
+ background: $import-primary;
+ border-color: $import-primary;
+ color: white;
+}
+
.button-row button + button {
margin-left: 0.5rem;
}
diff --git a/src/components/preview-modal/preview-modal.jsx b/src/components/preview-modal/preview-modal.jsx
index 71a3b029c..5772b5185 100644
--- a/src/components/preview-modal/preview-modal.jsx
+++ b/src/components/preview-modal/preview-modal.jsx
@@ -71,6 +71,17 @@ const PreviewModal = ({intl, ...props}) => (
}}
/>
+
(
PreviewModal.propTypes = {
intl: intlShape.isRequired,
onCancel: PropTypes.func.isRequired,
- onTryIt: PropTypes.func.isRequired
+ onTryIt: PropTypes.func.isRequired,
+ onViewProject: PropTypes.func.isRequired
};
export default injectIntl(PreviewModal);
diff --git a/src/components/sprite-selector-item/sprite-selector-item.jsx b/src/components/sprite-selector-item/sprite-selector-item.jsx
index aa292cfaa..abb47bb54 100644
--- a/src/components/sprite-selector-item/sprite-selector-item.jsx
+++ b/src/components/sprite-selector-item/sprite-selector-item.jsx
@@ -18,7 +18,9 @@ const SpriteSelectorItem = props => (
className: classNames(props.className, styles.spriteSelectorItem, {
[styles.isSelected]: props.selected
}),
- onClick: props.onClick
+ onClick: props.onClick,
+ onMouseEnter: props.onMouseEnter,
+ onMouseLeave: props.onMouseLeave
}}
id={`${props.name}-${contextMenuId}`}
>
@@ -70,6 +72,8 @@ SpriteSelectorItem.propTypes = {
onClick: PropTypes.func,
onDeleteButtonClick: PropTypes.func,
onDuplicateButtonClick: PropTypes.func,
+ onMouseEnter: PropTypes.func,
+ onMouseLeave: PropTypes.func,
selected: PropTypes.bool.isRequired
};
diff --git a/src/components/sprite-selector/sprite-selector.css b/src/components/sprite-selector/sprite-selector.css
index 67b0532d3..ec5cfee2b 100644
--- a/src/components/sprite-selector/sprite-selector.css
+++ b/src/components/sprite-selector/sprite-selector.css
@@ -2,7 +2,7 @@
.sprite-selector {
flex-grow: 1;
- position: relative;
+ position: relative;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
margin-right: calc($space / 2);
background-color: #f9f9f9;
@@ -16,14 +16,14 @@
/* In prep for renaming sprite-selector-item to sprite */
.sprite {
- /*
+ /*
Our goal is to fit sprites evenly in a row without leftover space.
- Flexbox's `space between` property gets us close, but doesn't flow
+ Flexbox's `space between` property gets us close, but doesn't flow
well when the # of items per row > 1 and less than the max per row.
- Solving by explicitly calc'ing the width of each sprite. Setting
- `border-box` simplifies things, because content, padding and
- border-width all are included in the width, leaving us only to subtract
+ Solving by explicitly calc'ing the width of each sprite. Setting
+ `border-box` simplifies things, because content, padding and
+ border-width all are included in the width, leaving us only to subtract
the left + right margins.
@todo: make room for the scrollbar
@@ -32,7 +32,7 @@
width: calc((100% / $sprites-per-row ) - $space);
min-width: 4rem;
min-height: 4rem; /* @todo: calc height same as width */
- margin: calc($space / 2);
+ margin: calc($space / 2);
}
@@ -41,11 +41,11 @@
Sets the sprite-selector items as a scrollable pane
@todo: Safari: pane doesn't stretch to fill height;
- @todo: Adding `position: relative` still doesn't fix Safari scrolling pane, and
- also introduces a new bug in Chrome when vertically resizing window down,
- then back up, introduces white space in the outside the page container.
+ @todo: Adding `position: relative` still doesn't fix Safari scrolling pane, and
+ also introduces a new bug in Chrome when vertically resizing window down,
+ then back up, introduces white space in the outside the page container.
*/
- height: calc(100% - $sprite-info-height);
+ height: calc(100% - $sprite-info-height);
overflow-y: scroll;
}
@@ -57,11 +57,90 @@
padding-top: calc($space / 2);
padding-left: calc($space / 2);
padding-right: calc($space / 2);
- padding-bottom: $space;
+ padding-bottom: $space;
}
.add-button {
position: absolute;
bottom: 0.75rem;
right: 1rem;
+ z-index: 1; /* TODO overlaps the stage, this doesn't work, fix! */
+}
+
+.raised {
+ background-color: #cce1ff;
+ border-color: #8cbcff;
+ transition: all 0.25s ease;
+}
+
+.raised:hover {
+ background-color: #8cbcff;
+ transform: scale(1.05);
+}
+
+.raised:hover {
+ -webkit-animation-name: wiggle;
+ -ms-animation-name: wiggle;
+ -ms-animation-duration: 500ms;
+ -webkit-animation-duration: 500ms;
+ -webkit-animation-iteration-count: 1;
+ -ms-animation-iteration-count: 1;
+ -webkit-animation-timing-function: ease-in-out;
+ -ms-animation-timing-function: ease-in-out;
+ background-color: #8cbcff;
+}
+
+@-webkit-keyframes wiggle {
+ 0% {-webkit-transform: rotate(3deg) scale(1.05);}
+ 25% {-webkit-transform: rotate(-3deg) scale(1.05);}
+ 50% {-webkit-transform: rotate(5deg) scale(1.05);}
+ 75% {-webkit-transform: rotate(-2deg) scale(1.05);}
+ 100% {-webkit-transform: rotate(0deg) scale(1.05);}
+}
+
+@-ms-keyframes wiggle {
+ 0% {-ms-transform: rotate(3deg) scale(1.05);}
+ 25% {-ms-transform: rotate(-3deg) scale(1.05);}
+ 50% {-ms-transform: rotate(5deg) scale(1.05);}
+ 75% {-ms-transform: rotate(-2deg) scale(1.05);}
+ 100% {-ms-transform: rotate(0deg) scale(1.05);}
+}
+
+@keyframes wiggle {
+ 0% {transform: rotate(3deg) scale(1.05);}
+ 25% {transform: rotate(-3deg) scale(1.05);}
+ 50% {transform: rotate(5deg) scale(1.05);}
+ 75% {transform: rotate(-2deg) scale(1.05);}
+ 100% {transform: rotate(0deg) scale(1.05);}
+}
+
+.receivedBlocks {
+ -webkit-animation: glowing 250ms;
+ -moz-animation: glowing 250ms;
+ -o-animation: glowing 250ms;
+ animation: glowing 250ms;
+}
+
+@-webkit-keyframes glowing {
+ 10% { -webkit-box-shadow: 0 0 10px #7fff1e; }
+ 90% { -webkit-box-shadow: 0 0 10px #7fff1e; }
+ 100% { -webkit-box-shadow: none; }
+}
+
+@-moz-keyframes glowing {
+ 10% { -moz-box-shadow: 0 0 10px #7fff1e; }
+ 90% { -moz-box-shadow: 0 0 10px #7fff1e; }
+ 100% { -moz-box-shadow: none; }
+}
+
+@-o-keyframes glowing {
+ 10% { box-shadow: 0 0 10px #7fff1e; }
+ 90% { box-shadow: 0 0 10px #7fff1e; }
+ 100% { box-shadow: none; }
+}
+
+@keyframes glowing {
+ 10% { box-shadow: 0 0 10px #7fff1e; }
+ 90% { box-shadow: 0 0 10px #7fff1e; }
+ 100% { box-shadow: none; }
}
diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx
index 41e51a316..24e5315c6 100644
--- a/src/components/sprite-selector/sprite-selector.jsx
+++ b/src/components/sprite-selector/sprite-selector.jsx
@@ -1,25 +1,53 @@
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 AssetButton from '../asset-button/asset-button.jsx';
+import ActionMenu from '../action-menu/action-menu.jsx';
import styles from './sprite-selector.css';
-import spriteIcon from './icon--sprite.svg';
+
+import cameraIcon from '../action-menu/icon--camera.svg';
+import fileUploadIcon from '../action-menu/icon--file-upload.svg';
+import paintIcon from '../action-menu/icon--paint.svg';
+import spriteIcon from '../action-menu/icon--sprite.svg';
+import surpriseIcon from '../action-menu/icon--surprise.svg';
const messages = defineMessages({
- addSprite: {
- id: 'gui.spriteSelector.addSprite',
- description: 'Button to add a sprite in the target pane',
- defaultMessage: 'Add Sprite'
+ addSpriteFromLibrary: {
+ id: 'gui.spriteSelector.addSpriteFromLibrary',
+ description: 'Button to add a sprite in the target pane from library',
+ defaultMessage: 'Sprite Library'
+ },
+ addSpriteFromPaint: {
+ id: 'gui.spriteSelector.addSpriteFromPaint',
+ description: 'Button to add a sprite in the target pane from paint',
+ defaultMessage: 'Paint'
+ },
+ addSpriteFromSurprise: {
+ id: 'gui.spriteSelector.addSpriteFromSurprise',
+ description: 'Button to add a random sprite in the target pane',
+ defaultMessage: 'Surprise'
+ },
+ addSpriteFromFile: {
+ id: 'gui.spriteSelector.addSpriteFromFile',
+ description: 'Button to add a sprite in the target pane from file',
+ defaultMessage: 'Coming Soon'
+ },
+ addSpriteFromCamera: {
+ id: 'gui.spriteSelector.addSpriteFromCamera',
+ description: 'Button to add a sprite in the target pane from camera',
+ defaultMessage: 'Coming Soon'
}
});
const SpriteSelectorComponent = function (props) {
const {
+ editingTarget,
+ hoveredTarget,
intl,
onChangeSpriteDirection,
onChangeSpriteName,
@@ -30,7 +58,10 @@ const SpriteSelectorComponent = function (props) {
onDeleteSprite,
onDuplicateSprite,
onNewSpriteClick,
+ onSurpriseSpriteClick,
+ onPaintSpriteClick,
onSelectSprite,
+ raised,
selectedId,
sprites,
...componentProps
@@ -72,7 +103,12 @@ const SpriteSelectorComponent = function (props) {
.map(sprite => (
-
@@ -96,6 +149,11 @@ const SpriteSelectorComponent = function (props) {
};
SpriteSelectorComponent.propTypes = {
+ editingTarget: PropTypes.string,
+ hoveredTarget: PropTypes.shape({
+ hoveredSprite: PropTypes.string,
+ receivedBlocks: PropTypes.bool
+ }),
intl: intlShape.isRequired,
onChangeSpriteDirection: PropTypes.func,
onChangeSpriteName: PropTypes.func,
@@ -106,7 +164,10 @@ SpriteSelectorComponent.propTypes = {
onDeleteSprite: PropTypes.func,
onDuplicateSprite: PropTypes.func,
onNewSpriteClick: PropTypes.func,
+ onPaintSpriteClick: PropTypes.func,
onSelectSprite: PropTypes.func,
+ onSurpriseSpriteClick: PropTypes.func,
+ raised: PropTypes.bool,
selectedId: PropTypes.string,
sprites: PropTypes.shape({
id: PropTypes.shape({
diff --git a/src/components/stage-selector/stage-selector.jsx b/src/components/stage-selector/stage-selector.jsx
index eea811409..073ca257c 100644
--- a/src/components/stage-selector/stage-selector.jsx
+++ b/src/components/stage-selector/stage-selector.jsx
@@ -4,16 +4,41 @@ import React from 'react';
import {defineMessages, intlShape, injectIntl, FormattedMessage} from 'react-intl';
import Box from '../box/box.jsx';
-import AssetButton from '../asset-button/asset-button.jsx';
+import ActionMenu from '../action-menu/action-menu.jsx';
import CostumeCanvas from '../costume-canvas/costume-canvas.jsx';
import styles from './stage-selector.css';
-import backdropIcon from './icon--backdrop.svg';
+
+import backdropIcon from '../action-menu/icon--backdrop.svg';
+import cameraIcon from '../action-menu/icon--camera.svg';
+import fileUploadIcon from '../action-menu/icon--file-upload.svg';
+import paintIcon from '../action-menu/icon--paint.svg';
+import surpriseIcon from '../action-menu/icon--surprise.svg';
const messages = defineMessages({
- addBackdrop: {
- id: 'gui.stageSelector.targetPaneAddBackdrop',
- description: 'Button to add a backdrop in the target pane',
- defaultMessage: 'Add Backdrop'
+ addBackdropFromLibrary: {
+ id: 'gui.spriteSelector.addBackdropFromLibrary',
+ description: 'Button to add a stage in the target pane from library',
+ defaultMessage: 'Backdrop Library'
+ },
+ addBackdropFromPaint: {
+ id: 'gui.stageSelector.addBackdropFromPaint',
+ description: 'Button to add a stage in the target pane from paint',
+ defaultMessage: 'Paint'
+ },
+ addBackdropFromSurprise: {
+ id: 'gui.stageSelector.addBackdropFromSurprise',
+ description: 'Button to add a random stage in the target pane',
+ defaultMessage: 'Surprise'
+ },
+ addBackdropFromFile: {
+ id: 'gui.stageSelector.addBackdropFromFile',
+ description: 'Button to add a stage in the target pane from file',
+ defaultMessage: 'Coming Soon'
+ },
+ addBackdropFromCamera: {
+ id: 'gui.stageSelector.addBackdropFromCamera',
+ description: 'Button to add a stage in the target pane from camera',
+ defaultMessage: 'Coming Soon'
}
});
@@ -25,6 +50,8 @@ const StageSelector = props => {
url,
onClick,
onNewBackdropClick,
+ onSurpriseBackdropClick,
+ onEmptyBackdropClick,
...componentProps
} = props;
return (
@@ -54,10 +81,28 @@ const StageSelector = props => {
/>
{backdropCount}
-
@@ -68,8 +113,11 @@ StageSelector.propTypes = {
backdropCount: PropTypes.number.isRequired,
intl: intlShape.isRequired,
onClick: PropTypes.func,
+ onEmptyBackdropClick: PropTypes.func,
onNewBackdropClick: PropTypes.func,
+ onSurpriseBackdropClick: PropTypes.func,
selected: PropTypes.bool.isRequired,
url: PropTypes.string
};
+
export default injectIntl(StageSelector);
diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx
index 30267688f..1044073a1 100644
--- a/src/components/target-pane/target-pane.jsx
+++ b/src/components/target-pane/target-pane.jsx
@@ -19,6 +19,7 @@ import styles from './target-pane.css';
const TargetPane = ({
backdropLibraryVisible,
editingTarget,
+ hoveredTarget,
spriteLibraryVisible,
onChangeSpriteDirection,
onChangeSpriteName,
@@ -29,9 +30,12 @@ const TargetPane = ({
onDeleteSprite,
onDuplicateSprite,
onNewSpriteClick,
+ onSurpriseSpriteClick,
+ onPaintSpriteClick,
onRequestCloseSpriteLibrary,
onRequestCloseBackdropLibrary,
onSelectSprite,
+ raiseSprites,
stage,
sprites,
vm,
@@ -43,6 +47,9 @@ const TargetPane = ({
>
{stage.id &&
{
+ this.setState({selectedCostumeIndex: costumeIndex + 1});
+ });
+ }
handleNewCostume () {
if (!this.props.vm.editingTarget) return;
- const costumes = this.props.vm.editingTarget.sprite.costumes || [];
+ const costumes = this.props.vm.editingTarget.getCostumes() || [];
this.setState({selectedCostumeIndex: Math.max(costumes.length - 1, 0)});
}
handleNewBlankCostume () {
@@ -97,6 +128,32 @@ class CostumeTab extends React.Component {
this.handleNewCostume();
});
}
+ handleSurpriseCostume () {
+ const item = costumeLibraryContent[Math.floor(Math.random() * costumeLibraryContent.length)];
+ const vmCostume = {
+ name: item.name,
+ rotationCenterX: item.info[0],
+ rotationCenterY: item.info[1],
+ bitmapResolution: item.info.length > 2 ? item.info[2] : 1,
+ skinId: null
+ };
+ this.props.vm.addCostume(item.md5, vmCostume).then(() => {
+ this.handleNewCostume();
+ });
+ }
+ handleSurpriseBackdrop () {
+ const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
+ const vmCostume = {
+ name: item.name,
+ rotationCenterX: item.info[0] && item.info[0] / 2,
+ rotationCenterY: item.info[1] && item.info[1] / 2,
+ bitmapResolution: item.info.length > 2 ? item.info[2] : 1,
+ skinId: null
+ };
+ this.props.vm.addCostume(item.md5, vmCostume).then(() => {
+ this.handleNewCostume();
+ });
+ }
render () {
// For paint wrapper
const {
@@ -104,9 +161,7 @@ class CostumeTab extends React.Component {
onNewLibraryBackdropClick,
onNewLibraryCostumeClick,
costumeLibraryVisible,
- backdropLibraryVisible,
onRequestCloseCostumeLibrary,
- onRequestCloseBackdropLibrary,
...props
} = this.props;
@@ -124,7 +179,7 @@ class CostumeTab extends React.Component {
}
const addLibraryMessage = target.isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg;
- const addBlankMessage = target.isStage ? messages.addBlankBackdropMsg : messages.addBlankCostumeMsg;
+ const addSurpriseFunc = target.isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume;
const addLibraryFunc = target.isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick;
const addLibraryIcon = target.isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon;
@@ -132,19 +187,33 @@ class CostumeTab extends React.Component {
1 ? this.handleDeleteCostume : null}
+ onDuplicateClick={this.handleDuplicateCostume}
onItemClick={this.handleSelectCostume}
>
{target.costumes ?
@@ -161,13 +230,6 @@ class CostumeTab extends React.Component {
onRequestClose={onRequestCloseCostumeLibrary}
/>
) : null}
- {backdropLibraryVisible ? (
-
- ) : null}
);
}
@@ -186,7 +248,8 @@ CostumeTab.propTypes = {
id: PropTypes.shape({
costumes: PropTypes.arrayOf(PropTypes.shape({
url: PropTypes.string,
- name: PropTypes.string.isRequired
+ name: PropTypes.string.isRequired,
+ skinId: PropTypes.number
}))
})
}),
@@ -215,9 +278,6 @@ const mapDispatchToProps = dispatch => ({
e.preventDefault();
dispatch(openCostumeLibrary());
},
- onRequestCloseBackdropLibrary: () => {
- dispatch(closeBackdropLibrary());
- },
onRequestCloseCostumeLibrary: () => {
dispatch(closeCostumeLibrary());
}
diff --git a/src/containers/extension-library.jsx b/src/containers/extension-library.jsx
index 9998fbb3e..d036caa23 100644
--- a/src/containers/extension-library.jsx
+++ b/src/containers/extension-library.jsx
@@ -7,7 +7,7 @@ import extensionLibraryContent from '../lib/libraries/extensions/index';
import analytics from '../lib/analytics';
import LibraryComponent from '../components/library/library.jsx';
-import extensionIcon from '../components/sprite-selector/icon--sprite.svg';
+import extensionIcon from '../components/action-menu/icon--sprite.svg';
class ExtensionLibrary extends React.PureComponent {
constructor (props) {
diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx
index 539705a58..de4fe27a2 100644
--- a/src/containers/gui.jsx
+++ b/src/containers/gui.jsx
@@ -2,23 +2,21 @@ import AudioEngine from 'scratch-audio';
import PropTypes from 'prop-types';
import React from 'react';
import VM from 'scratch-vm';
-import bindAll from 'lodash.bindall';
import {connect} from 'react-redux';
-import {openExtensionLibrary} from '../reducers/modals.js';
+import {openExtensionLibrary} from '../reducers/modals';
+import {
+ activateTab,
+ BLOCKS_TAB_INDEX,
+ COSTUMES_TAB_INDEX,
+ SOUNDS_TAB_INDEX
+} from '../reducers/editor-tab';
import vmListenerHOC from '../lib/vm-listener-hoc.jsx';
import GUIComponent from '../components/gui/gui.jsx';
class GUI extends React.Component {
- constructor (props) {
- super(props);
- bindAll(this, [
- 'handleTabSelect'
- ]);
- this.state = {tabIndex: 0};
- }
componentDidMount () {
this.audioEngine = new AudioEngine();
this.props.vm.attachAudioEngine(this.audioEngine);
@@ -34,9 +32,6 @@ class GUI extends React.Component {
componentWillUnmount () {
this.props.vm.stopAll();
}
- handleTabSelect (tabIndex) {
- this.setState({tabIndex});
- }
render () {
const {
children,
@@ -46,10 +41,7 @@ class GUI extends React.Component {
} = this.props;
return (
{children}
@@ -61,6 +53,7 @@ class GUI extends React.Component {
GUI.propTypes = {
...GUIComponent.propTypes,
feedbackFormVisible: PropTypes.bool,
+ importInfoVisible: PropTypes.bool,
previewInfoVisible: PropTypes.bool,
projectData: PropTypes.string,
vm: PropTypes.instanceOf(VM)
@@ -69,12 +62,18 @@ GUI.propTypes = {
GUI.defaultProps = GUIComponent.defaultProps;
const mapStateToProps = state => ({
+ activeTabIndex: state.editorTab.activeTabIndex,
+ blocksTabVisible: state.editorTab.activeTabIndex === BLOCKS_TAB_INDEX,
+ costumesTabVisible: state.editorTab.activeTabIndex === COSTUMES_TAB_INDEX,
feedbackFormVisible: state.modals.feedbackForm,
- previewInfoVisible: state.modals.previewInfo
+ importInfoVisible: state.modals.importInfo,
+ previewInfoVisible: state.modals.previewInfo,
+ soundsTabVisible: state.editorTab.activeTabIndex === SOUNDS_TAB_INDEX
});
const mapDispatchToProps = dispatch => ({
- onExtensionButtonClick: () => dispatch(openExtensionLibrary())
+ onExtensionButtonClick: () => dispatch(openExtensionLibrary()),
+ onActivateTab: tab => dispatch(activateTab(tab))
});
const ConnectedGUI = connect(
diff --git a/src/containers/import-modal.jsx b/src/containers/import-modal.jsx
new file mode 100644
index 000000000..1c2ddbefb
--- /dev/null
+++ b/src/containers/import-modal.jsx
@@ -0,0 +1,111 @@
+import bindAll from 'lodash.bindall';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {connect} from 'react-redux';
+
+import ImportModalComponent from '../components/import-modal/import-modal.jsx';
+
+import {
+ closeImportInfo,
+ openPreviewInfo
+} from '../reducers/modals';
+
+class ImportModal extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'handleViewProject',
+ 'handleCancel',
+ 'handleChange',
+ 'handleGoBack',
+ 'handleKeyPress'
+ ]);
+
+ this.state = {
+ inputValue: '',
+ hasValidationError: false,
+ errorMessage: ''
+ };
+ }
+ handleKeyPress (event) {
+ if (event.key === 'Enter') this.handleViewProject();
+ }
+ handleViewProject () {
+ const inputValue = this.state.inputValue;
+ const projectId = this.validate(inputValue);
+
+ if (projectId) {
+ const projectLink = document.createElement('a');
+ document.body.appendChild(projectLink);
+ projectLink.href = `#${projectId}`;
+ projectLink.click();
+ document.body.removeChild(projectLink);
+ this.props.onViewProject();
+ } else {
+ this.setState({
+ hasValidationError: true,
+ errorMessage: `invalidFormatError`});
+ }
+ }
+ handleChange (e) {
+ this.setState({inputValue: e.target.value, hasValidationError: false});
+ }
+ validate (input) {
+ const urlMatches = input.match(/^(?:https:\/\/)?scratch\.mit\.edu\/projects\/(\d+)/);
+ if (urlMatches && urlMatches.length > 0) {
+ return urlMatches[1];
+ }
+ const projectIdMatches = input.match(/^#?(\d+)$/);
+ if (projectIdMatches && projectIdMatches.length > 0) {
+ return projectIdMatches[1];
+ }
+ return null;
+ }
+ handleCancel () {
+ this.props.onCancel();
+ }
+ handleGoBack () {
+ this.props.onBack();
+ }
+ render () {
+ return (
+
+ );
+ }
+}
+
+ImportModal.propTypes = {
+ onBack: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ onViewProject: PropTypes.func
+};
+
+const mapStateToProps = () => ({});
+
+const mapDispatchToProps = dispatch => ({
+ onBack: () => {
+ dispatch(closeImportInfo());
+ dispatch(openPreviewInfo());
+ },
+ onCancel: () => {
+ dispatch(closeImportInfo());
+ },
+ onViewProject: () => {
+ dispatch(closeImportInfo());
+ }
+});
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(ImportModal);
diff --git a/src/containers/preview-modal.jsx b/src/containers/preview-modal.jsx
index 8d50dc83c..1209f251f 100644
--- a/src/containers/preview-modal.jsx
+++ b/src/containers/preview-modal.jsx
@@ -8,7 +8,8 @@ import PreviewModalComponent from '../components/preview-modal/preview-modal.jsx
import BrowserModalComponent from '../components/browser-modal/browser-modal.jsx';
import {
- closePreviewInfo
+ closePreviewInfo,
+ openImportInfo
} from '../reducers/modals';
class PreviewModal extends React.Component {
@@ -16,7 +17,8 @@ class PreviewModal extends React.Component {
super(props);
bindAll(this, [
'handleTryIt',
- 'handleCancel'
+ 'handleCancel',
+ 'handleViewProject'
]);
this.state = {
@@ -30,6 +32,9 @@ class PreviewModal extends React.Component {
handleCancel () {
window.location.replace('https://scratch.mit.edu');
}
+ handleViewProject () {
+ this.props.onViewProject();
+ }
supportedBrowser () {
if (platform.name === 'IE') {
return false;
@@ -42,6 +47,7 @@ class PreviewModal extends React.Component {
previewing={this.state.previewing}
onCancel={this.handleCancel}
onTryIt={this.handleTryIt}
+ onViewProject={this.handleViewProject}
/> :
({});
@@ -59,6 +66,10 @@ const mapStateToProps = () => ({});
const mapDispatchToProps = dispatch => ({
onTryIt: () => {
dispatch(closePreviewInfo());
+ },
+ onViewProject: () => {
+ dispatch(closePreviewInfo());
+ dispatch(openImportInfo());
}
});
diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx
index 781b2cc5f..bbe032c0b 100644
--- a/src/containers/sound-editor.jsx
+++ b/src/containers/sound-editor.jsx
@@ -1,6 +1,7 @@
import bindAll from 'lodash.bindall';
import PropTypes from 'prop-types';
import React from 'react';
+import WavEncoder from 'wav-encoder';
import {connect} from 'react-redux';
@@ -9,6 +10,7 @@ import {computeChunkedRMS} from '../lib/audio/audio-util.js';
import AudioEffects from '../lib/audio/audio-effects.js';
import SoundEditorComponent from '../components/sound-editor/sound-editor.jsx';
import AudioBufferPlayer from '../lib/audio/audio-buffer-player.js';
+import log from '../lib/log.js';
const UNDO_STACK_SIZE = 99;
@@ -72,11 +74,25 @@ class SoundEditor extends React.Component {
}
this.undoStack.push(this.copyCurrentBuffer());
}
+ // Encode the new sound into a wav so that it can be stored
+ let wavBuffer = null;
+ try {
+ wavBuffer = WavEncoder.encode.sync({
+ sampleRate: sampleRate,
+ channelData: [samples]
+ });
+ } catch (e) {
+ // This error state is mostly for the mock sounds used during testing.
+ // Any incorrect sound buffer trying to get interpretd as a Wav file
+ // should yield this error.
+ log.error(`Encountered error while trying to encode sound update: ${e}`);
+ }
+
this.resetState(samples, sampleRate);
this.props.onUpdateSoundBuffer(
this.props.soundIndex,
- this.audioBufferPlayer.buffer
- );
+ this.audioBufferPlayer.buffer,
+ wavBuffer ? new Uint8Array(wavBuffer) : new Uint8Array());
}
handlePlay () {
this.audioBufferPlayer.play(
diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx
index 9e30ba2ed..471ebe99e 100644
--- a/src/containers/sound-tab.jsx
+++ b/src/containers/sound-tab.jsx
@@ -8,10 +8,15 @@ import AssetPanel from '../components/asset-panel/asset-panel.jsx';
import soundIcon from '../components/asset-panel/icon--sound.svg';
import addSoundFromLibraryIcon from '../components/asset-panel/icon--add-sound-lib.svg';
import addSoundFromRecordingIcon from '../components/asset-panel/icon--add-sound-record.svg';
+import fileUploadIcon from '../components/action-menu/icon--file-upload.svg';
+import surpriseIcon from '../components/action-menu/icon--surprise.svg';
+
import RecordModal from './record-modal.jsx';
import SoundEditor from './sound-editor.jsx';
import SoundLibrary from './sound-library.jsx';
+import soundLibraryContent from '../lib/libraries/sounds.json';
+
import {connect} from 'react-redux';
import {
@@ -26,7 +31,9 @@ class SoundTab extends React.Component {
bindAll(this, [
'handleSelectSound',
'handleDeleteSound',
- 'handleNewSound'
+ 'handleDuplicateSound',
+ 'handleNewSound',
+ 'handleSurpriseSound'
]);
this.state = {selectedSoundIndex: 0};
}
@@ -51,6 +58,15 @@ class SoundTab extends React.Component {
handleDeleteSound (soundIndex) {
this.props.vm.deleteSound(soundIndex);
+ if (soundIndex >= this.state.selectedSoundIndex) {
+ this.setState({selectedSoundIndex: Math.max(0, soundIndex - 1)});
+ }
+ }
+
+ handleDuplicateSound (soundIndex) {
+ this.props.vm.duplicateSound(soundIndex).then(() => {
+ this.setState({selectedSoundIndex: soundIndex + 1});
+ });
}
handleNewSound () {
@@ -62,6 +78,20 @@ class SoundTab extends React.Component {
this.setState({selectedSoundIndex: Math.max(sounds.length - 1, 0)});
}
+ handleSurpriseSound () {
+ const soundItem = soundLibraryContent[Math.floor(Math.random() * soundLibraryContent.length)];
+ const vmSound = {
+ format: soundItem.format,
+ md5: soundItem.md5,
+ rate: soundItem.rate,
+ sampleCount: soundItem.sampleCount,
+ name: soundItem.name
+ };
+ this.props.vm.addSound(vmSound).then(() => {
+ this.handleNewSound();
+ });
+ }
+
render () {
const {
intl,
@@ -84,28 +114,45 @@ class SoundTab extends React.Component {
)) : [];
const messages = defineMessages({
+ fileUploadSound: {
+ defaultMessage: 'Coming Soon',
+ description: 'Button to upload sound from file in the editor tab',
+ id: 'gui.soundTab.fileUploadSound'
+ },
+ surpriseSound: {
+ defaultMessage: 'Surprise',
+ description: 'Button to get a random sound in the editor tab',
+ id: 'gui.soundTab.surpriseSound'
+ },
recordSound: {
- defaultMessage: 'Record Sound',
+ defaultMessage: 'Record',
description: 'Button to record a sound in the editor tab',
id: 'gui.soundTab.recordSound'
},
addSound: {
- defaultMessage: 'Add Sound',
+ defaultMessage: 'Sound Library',
description: 'Button to add a sound in the editor tab',
- id: 'gui.soundTab.addSound'
+ id: 'gui.soundTab.addSoundFromLibrary'
}
});
return (
({
url: soundIcon,
@@ -113,6 +160,7 @@ class SoundTab extends React.Component {
}))}
selectedItemIndex={this.state.selectedSoundIndex}
onDeleteClick={this.handleDeleteSound}
+ onDuplicateClick={this.handleDuplicateSound}
onItemClick={this.handleSelectSound}
>
{sprite.sounds && sprite.sounds[this.state.selectedSoundIndex] ? (
diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx
index 9879d8003..edb124b2c 100644
--- a/src/containers/sprite-selector-item.jsx
+++ b/src/containers/sprite-selector-item.jsx
@@ -1,9 +1,10 @@
import bindAll from 'lodash.bindall';
import PropTypes from 'prop-types';
import React from 'react';
-
import {connect} from 'react-redux';
+import {setHoveredSprite} from '../reducers/hovered-target';
+
import SpriteSelectorItemComponent from '../components/sprite-selector-item/sprite-selector-item.jsx';
class SpriteSelectorItem extends React.Component {
@@ -12,14 +13,17 @@ class SpriteSelectorItem extends React.Component {
bindAll(this, [
'handleClick',
'handleDelete',
- 'handleDuplicate'
+ 'handleDuplicate',
+ 'handleMouseEnter',
+ 'handleMouseLeave'
]);
}
handleClick (e) {
e.preventDefault();
this.props.onClick(this.props.id);
}
- handleDelete () {
+ handleDelete (e) {
+ e.stopPropagation(); // To prevent from bubbling back to handleClick
// @todo add i18n here
// eslint-disable-next-line no-alert
if (window.confirm('Are you sure you want to delete this?')) {
@@ -30,6 +34,12 @@ class SpriteSelectorItem extends React.Component {
e.stopPropagation(); // To prevent from bubbling back to handleClick
this.props.onDuplicateButtonClick(this.props.id);
}
+ handleMouseLeave () {
+ this.props.dispatchSetHoveredSprite(null);
+ }
+ handleMouseEnter () {
+ this.props.dispatchSetHoveredSprite(this.props.id);
+ }
render () {
const {
/* eslint-disable no-unused-vars */
@@ -38,6 +48,7 @@ class SpriteSelectorItem extends React.Component {
onClick,
onDeleteButtonClick,
onDuplicateButtonClick,
+ receivedBlocks,
/* eslint-enable no-unused-vars */
...props
} = this.props;
@@ -46,6 +57,8 @@ class SpriteSelectorItem extends React.Component {
onClick={this.handleClick}
onDeleteButtonClick={onDeleteButtonClick ? this.handleDelete : null}
onDuplicateButtonClick={onDuplicateButtonClick ? this.handleDuplicate : null}
+ onMouseEnter={this.handleMouseEnter}
+ onMouseLeave={this.handleMouseLeave}
{...props}
/>
);
@@ -55,18 +68,28 @@ class SpriteSelectorItem extends React.Component {
SpriteSelectorItem.propTypes = {
assetId: PropTypes.string,
costumeURL: PropTypes.string,
+ dispatchSetHoveredSprite: PropTypes.func.isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
name: PropTypes.string,
onClick: PropTypes.func,
onDeleteButtonClick: PropTypes.func,
onDuplicateButtonClick: PropTypes.func,
+ receivedBlocks: PropTypes.bool.isRequired,
selected: PropTypes.bool
};
-const mapStateToProps = (state, {assetId, costumeURL}) => ({
- costumeURL: costumeURL || (assetId && state.vm.runtime.storage.get(assetId).encodeDataURI())
+const mapStateToProps = (state, {assetId, costumeURL, id}) => ({
+ costumeURL: costumeURL || (assetId && state.vm.runtime.storage.get(assetId).encodeDataURI()),
+ receivedBlocks: state.hoveredTarget.receivedBlocks &&
+ state.hoveredTarget.sprite === id
+});
+const mapDispatchToProps = dispatch => ({
+ dispatchSetHoveredSprite: spriteId => {
+ dispatch(setHoveredSprite(spriteId));
+ }
});
export default connect(
- mapStateToProps
+ mapStateToProps,
+ mapDispatchToProps
)(SpriteSelectorItem);
diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx
index 83a5a1bb0..27075cef8 100644
--- a/src/containers/stage-selector.jsx
+++ b/src/containers/stage-selector.jsx
@@ -4,24 +4,57 @@ import React from 'react';
import {connect} from 'react-redux';
import {openBackdropLibrary} from '../reducers/modals';
+import {activateTab, COSTUMES_TAB_INDEX} from '../reducers/editor-tab';
+
import StageSelectorComponent from '../components/stage-selector/stage-selector.jsx';
+import backdropLibraryContent from '../lib/libraries/backdrops.json';
+import costumeLibraryContent from '../lib/libraries/costumes.json';
+
class StageSelector extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
- 'handleClick'
+ 'handleClick',
+ 'handleSurpriseBackdrop',
+ 'handleEmptyBackdrop',
+ 'addBackdropFromLibraryItem'
]);
}
+ addBackdropFromLibraryItem (item) {
+ const vmBackdrop = {
+ name: item.name,
+ rotationCenterX: item.info[0] && item.info[0] / 2,
+ rotationCenterY: item.info[1] && item.info[1] / 2,
+ bitmapResolution: item.info.length > 2 ? item.info[2] : 1,
+ skinId: null
+ };
+ return this.props.vm.addBackdrop(item.md5, vmBackdrop);
+ }
handleClick (e) {
e.preventDefault();
this.props.onSelect(this.props.id);
}
+ handleSurpriseBackdrop () {
+ // @todo should this not add a backdrop you already have?
+ const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
+ this.addBackdropFromLibraryItem(item);
+ }
+ handleEmptyBackdrop () {
+ // @todo this is brittle, will need to be refactored for localized libraries
+ const emptyItem = costumeLibraryContent.find(item => item.name === 'Empty');
+ if (emptyItem) {
+ this.addBackdropFromLibraryItem(emptyItem).then(() => {
+ this.props.onActivateTab(COSTUMES_TAB_INDEX);
+ });
+ }
+ }
render () {
const {
/* eslint-disable no-unused-vars */
assetId,
id,
+ onActivateTab,
onSelect,
/* eslint-enable no-unused-vars */
...componentProps
@@ -29,6 +62,8 @@ class StageSelector extends React.Component {
return (
);
@@ -41,13 +76,17 @@ StageSelector.propTypes = {
};
const mapStateToProps = (state, {assetId}) => ({
- url: assetId && state.vm.runtime.storage.get(assetId).encodeDataURI()
+ url: assetId && state.vm.runtime.storage.get(assetId).encodeDataURI(),
+ vm: state.vm
});
const mapDispatchToProps = dispatch => ({
onNewBackdropClick: e => {
e.preventDefault();
dispatch(openBackdropLibrary());
+ },
+ onActivateTab: tabIndex => {
+ dispatch(activateTab(tabIndex));
}
});
diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx
index 5095f86dd..c386112b3 100644
--- a/src/containers/stage.jsx
+++ b/src/containers/stage.jsx
@@ -31,6 +31,7 @@ class Stage extends React.Component {
'onMouseDown',
'onStartDrag',
'onStopDrag',
+ 'onWheel',
'updateRect',
'questionListener',
'setCanvas'
@@ -98,6 +99,7 @@ class Stage extends React.Component {
document.addEventListener('touchend', this.onMouseUp);
canvas.addEventListener('mousedown', this.onMouseDown);
canvas.addEventListener('touchstart', this.onMouseDown);
+ canvas.addEventListener('wheel', this.onWheel);
}
detachMouseEvents (canvas) {
document.removeEventListener('mousemove', this.onMouseMove);
@@ -106,6 +108,7 @@ class Stage extends React.Component {
document.removeEventListener('touchend', this.onMouseUp);
canvas.removeEventListener('mousedown', this.onMouseDown);
canvas.removeEventListener('touchstart', this.onMouseDown);
+ canvas.removeEventListener('wheel', this.onWheel);
}
attachRectEvents () {
window.addEventListener('resize', this.updateRect);
@@ -232,6 +235,13 @@ class Stage extends React.Component {
this.setState({colorInfo: null});
}
}
+ onWheel (e) {
+ const data = {
+ deltaX: e.deltaX,
+ deltaY: e.deltaY
+ };
+ this.props.vm.postIOData('mouseWheel', data);
+ }
cancelMouseDownTimeout () {
if (this.state.mouseDownTimeoutId !== null) {
clearTimeout(this.state.mouseDownTimeoutId);
diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx
index 32ad448bc..889d84185 100644
--- a/src/containers/target-pane.jsx
+++ b/src/containers/target-pane.jsx
@@ -9,12 +9,17 @@ import {
closeSpriteLibrary
} from '../reducers/modals';
+import {activateTab, COSTUMES_TAB_INDEX} from '../reducers/editor-tab';
+import {setReceivedBlocks} from '../reducers/hovered-target';
+
import TargetPaneComponent from '../components/target-pane/target-pane.jsx';
+import spriteLibraryContent from '../lib/libraries/sprites.json';
class TargetPane extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
+ 'handleBlockDragEnd',
'handleChangeSpriteDirection',
'handleChangeSpriteName',
'handleChangeSpriteSize',
@@ -23,9 +28,17 @@ class TargetPane extends React.Component {
'handleChangeSpriteY',
'handleDeleteSprite',
'handleDuplicateSprite',
- 'handleSelectSprite'
+ 'handleSelectSprite',
+ 'handleSurpriseSpriteClick',
+ 'handlePaintSpriteClick'
]);
}
+ componentDidMount () {
+ this.props.vm.addListener('BLOCK_DRAG_END', this.handleBlockDragEnd);
+ }
+ componentWillUnmount () {
+ this.props.vm.removeListener('BLOCK_DRAG_END', this.handleBlockDragEnd);
+ }
handleChangeSpriteDirection (direction) {
this.props.vm.postSpriteInfo({direction});
}
@@ -53,10 +66,33 @@ class TargetPane extends React.Component {
handleSelectSprite (id) {
this.props.vm.setEditingTarget(id);
}
+ handleSurpriseSpriteClick () {
+ const item = spriteLibraryContent[Math.floor(Math.random() * spriteLibraryContent.length)];
+ this.props.vm.addSprite2(JSON.stringify(item.json));
+ }
+ handlePaintSpriteClick () {
+ // @todo this is brittle, will need to be refactored for localized libraries
+ const emptyItem = spriteLibraryContent.find(item => item.name === 'Empty');
+ if (emptyItem) {
+ this.props.vm.addSprite2(JSON.stringify(emptyItem.json)).then(() => {
+ this.props.onActivateTab(COSTUMES_TAB_INDEX);
+ });
+ }
+ }
+ handleBlockDragEnd (blocks) {
+ if (this.props.hoveredTarget.sprite && this.props.hoveredTarget.sprite !== this.props.editingTarget) {
+ this.props.vm.shareBlocksToTarget(blocks, this.props.hoveredTarget.sprite);
+ this.props.onReceivedBlocks(true);
+ }
+ }
render () {
+ const {
+ onActivateTab, // eslint-disable-line no-unused-vars
+ ...componentProps
+ } = this.props;
return (
);
}
@@ -82,6 +120,7 @@ TargetPane.propTypes = {
const mapStateToProps = state => ({
editingTarget: state.targets.editingTarget,
+ hoveredTarget: state.hoveredTarget,
sprites: Object.keys(state.targets.sprites).reduce((sprites, k) => {
let {direction, size, x, y, ...sprite} = state.targets.sprites[k];
if (typeof direction !== 'undefined') direction = Math.round(direction);
@@ -92,6 +131,7 @@ const mapStateToProps = state => ({
return sprites;
}, {}),
stage: state.targets.stage,
+ raiseSprites: state.blockDrag,
spriteLibraryVisible: state.modals.spriteLibrary,
backdropLibraryVisible: state.modals.backdropLibrary
});
@@ -105,6 +145,12 @@ const mapDispatchToProps = dispatch => ({
},
onRequestCloseBackdropLibrary: () => {
dispatch(closeBackdropLibrary());
+ },
+ onActivateTab: tabIndex => {
+ dispatch(activateTab(tabIndex));
+ },
+ onReceivedBlocks: receivedBlocks => {
+ dispatch(setReceivedBlocks(receivedBlocks));
}
});
diff --git a/src/css/colors.css b/src/css/colors.css
index e270217f9..be8a9a0db 100644
--- a/src/css/colors.css
+++ b/src/css/colors.css
@@ -18,4 +18,8 @@ $control-primary: #FFAB19;
$data-primary: #FF8C1A;
+$pen-primary: #11B581;
+
$form-border: #E9EEF2;
+
+$import-primary: #0FBD8C;
diff --git a/src/lib/blocks.js b/src/lib/blocks.js
index af15abc87..06c9a459c 100644
--- a/src/lib/blocks.js
+++ b/src/lib/blocks.js
@@ -36,15 +36,15 @@ export default function (vm) {
};
const costumesMenu = function () {
- if (vm.editingTarget && vm.editingTarget.sprite.costumes.length > 0) {
- return vm.editingTarget.sprite.costumes.map(costume => [costume.name, costume.name]);
+ if (vm.editingTarget && vm.editingTarget.getCostumes().length > 0) {
+ return vm.editingTarget.getCostumes().map(costume => [costume.name, costume.name]);
}
return [['', '']];
};
const backdropsMenu = function () {
- if (vm.runtime.targets[0] && vm.runtime.targets[0].sprite.costumes.length > 0) {
- return vm.runtime.targets[0].sprite.costumes.map(costume => [costume.name, costume.name])
+ if (vm.runtime.targets[0] && vm.runtime.targets[0].getCostumes().length > 0) {
+ return vm.runtime.targets[0].getCostumes().map(costume => [costume.name, costume.name])
.concat([['next backdrop', 'next backdrop'], ['previous backdrop', 'previous backdrop']]);
}
return [['', '']];
diff --git a/src/lib/opcode-labels.js b/src/lib/opcode-labels.js
index c4fa07bcf..e877a2a51 100644
--- a/src/lib/opcode-labels.js
+++ b/src/lib/opcode-labels.js
@@ -37,10 +37,10 @@ const opcodeMap = {
labelFn: params => params.VARIABLE
},
data_listcontents: {
- category: 'data',
+ category: 'list',
labelFn: params => params.LIST
},
-
+
// Sound
sound_volume: {
category: 'sound',
diff --git a/src/lib/vm-listener-hoc.jsx b/src/lib/vm-listener-hoc.jsx
index 081817c78..8e7a0ca17 100644
--- a/src/lib/vm-listener-hoc.jsx
+++ b/src/lib/vm-listener-hoc.jsx
@@ -6,6 +6,7 @@ import VM from 'scratch-vm';
import {connect} from 'react-redux';
import {updateEditingTarget, updateTargets} from '../reducers/targets';
+import {updateBlockDrag} from '../reducers/block-drag';
import {updateMonitors} from '../reducers/monitors';
/*
@@ -29,7 +30,7 @@ const vmListenerHOC = function (WrappedComponent) {
// we need to start listening before mounting the wrapped component.
this.props.vm.on('targetsUpdate', this.props.onTargetsUpdate);
this.props.vm.on('MONITORS_UPDATE', this.props.onMonitorsUpdate);
-
+ this.props.vm.on('BLOCK_DRAG_UPDATE', this.props.onBlockDragUpdate);
}
componentDidMount () {
if (this.props.attachKeyboardEvents) {
@@ -74,6 +75,7 @@ const vmListenerHOC = function (WrappedComponent) {
const {
/* eslint-disable no-unused-vars */
attachKeyboardEvents,
+ onBlockDragUpdate,
onKeyDown,
onKeyUp,
onMonitorsUpdate,
@@ -86,17 +88,20 @@ const vmListenerHOC = function (WrappedComponent) {
}
VMListener.propTypes = {
attachKeyboardEvents: PropTypes.bool,
+ onBlockDragUpdate: PropTypes.func.isRequired,
onKeyDown: PropTypes.func,
onKeyUp: PropTypes.func,
- onMonitorsUpdate: PropTypes.func,
- onTargetsUpdate: PropTypes.func,
+ onMonitorsUpdate: PropTypes.func.isRequired,
+ onTargetsUpdate: PropTypes.func.isRequired,
vm: PropTypes.instanceOf(VM).isRequired
};
VMListener.defaultProps = {
attachKeyboardEvents: true
};
const mapStateToProps = state => ({
- vm: state.vm
+ vm: state.vm,
+ hoveredSprite: state.hoveredTarget.sprite,
+ editingTarget: state.targets.editingTarget
});
const mapDispatchToProps = dispatch => ({
onTargetsUpdate: data => {
@@ -105,6 +110,9 @@ const vmListenerHOC = function (WrappedComponent) {
},
onMonitorsUpdate: monitorList => {
dispatch(updateMonitors(monitorList));
+ },
+ onBlockDragUpdate: areBlocksOverGui => {
+ dispatch(updateBlockDrag(areBlocksOverGui));
}
});
return connect(
diff --git a/src/reducers/block-drag.js b/src/reducers/block-drag.js
new file mode 100644
index 000000000..5a261ceaa
--- /dev/null
+++ b/src/reducers/block-drag.js
@@ -0,0 +1,28 @@
+const BLOCK_DRAG_UPDATE = 'scratch-gui/block-drag/BLOCK_DRAG_UPDATE';
+
+const initialState = false;
+
+const reducer = function (state, action) {
+ if (typeof state === 'undefined') state = initialState;
+ switch (action.type) {
+ case BLOCK_DRAG_UPDATE:
+ return action.areBlocksOverGui;
+ default:
+ return state;
+ }
+};
+
+const updateBlockDrag = function (areBlocksOverGui) {
+ return {
+ type: BLOCK_DRAG_UPDATE,
+ areBlocksOverGui: areBlocksOverGui,
+ meta: {
+ throttle: 30
+ }
+ };
+};
+
+export {
+ reducer as default,
+ updateBlockDrag
+};
diff --git a/src/reducers/editor-tab.js b/src/reducers/editor-tab.js
new file mode 100644
index 000000000..21b3534e2
--- /dev/null
+++ b/src/reducers/editor-tab.js
@@ -0,0 +1,37 @@
+const ACTIVATE_TAB = 'scratch-gui/navigation/ACTIVATE_TAB';
+
+// Constants use numbers to make it easier to work with react-tabs
+const BLOCKS_TAB_INDEX = 0;
+const COSTUMES_TAB_INDEX = 1;
+const SOUNDS_TAB_INDEX = 2;
+
+const initialState = {
+ activeTabIndex: BLOCKS_TAB_INDEX
+};
+
+const reducer = function (state, action) {
+ if (typeof state === 'undefined') state = initialState;
+ switch (action.type) {
+ case ACTIVATE_TAB:
+ return Object.assign({}, state, {
+ activeTabIndex: action.activeTabIndex
+ });
+ default:
+ return state;
+ }
+};
+
+const activateTab = function (tab) {
+ return {
+ type: ACTIVATE_TAB,
+ activeTabIndex: tab
+ };
+};
+
+export {
+ reducer as default,
+ activateTab,
+ BLOCKS_TAB_INDEX,
+ COSTUMES_TAB_INDEX,
+ SOUNDS_TAB_INDEX
+};
diff --git a/src/reducers/gui.js b/src/reducers/gui.js
index d49144b4f..d89a195c9 100644
--- a/src/reducers/gui.js
+++ b/src/reducers/gui.js
@@ -1,6 +1,9 @@
import {combineReducers} from 'redux';
import colorPickerReducer from './color-picker';
import customProceduresReducer from './custom-procedures';
+import blockDragReducer from './block-drag';
+import editorTabReducer from './editor-tab';
+import hoveredTargetReducer from './hovered-target';
import intlReducer from './intl';
import modalReducer from './modals';
import monitorReducer from './monitors';
@@ -12,8 +15,11 @@ import stageSizeReducer from './stage-size';
import {ScratchPaintReducer} from 'scratch-paint';
export default combineReducers({
+ blockDrag: blockDragReducer,
colorPicker: colorPickerReducer,
customProcedures: customProceduresReducer,
+ editorTab: editorTabReducer,
+ hoveredTarget: hoveredTargetReducer,
intl: intlReducer,
stageSize: stageSizeReducer,
modals: modalReducer,
diff --git a/src/reducers/hovered-target.js b/src/reducers/hovered-target.js
new file mode 100644
index 000000000..43bf87ac0
--- /dev/null
+++ b/src/reducers/hovered-target.js
@@ -0,0 +1,48 @@
+const SET_HOVERED_SPRITE = 'scratch-gui/hovered-target/SET_HOVERED_SPRITE';
+const SET_RECEIVED_BLOCKS = 'scratch-gui/hovered-target/SET_RECEIVED_BLOCKS';
+
+const initialState = {
+ sprite: null,
+ receivedBlocks: false
+};
+
+const reducer = function (state, action) {
+ if (typeof state === 'undefined') state = initialState;
+ switch (action.type) {
+ case SET_HOVERED_SPRITE:
+ return {
+ sprite: action.spriteId,
+ receivedBlocks: false
+ };
+ case SET_RECEIVED_BLOCKS:
+ return {
+ sprite: state.sprite,
+ receivedBlocks: action.receivedBlocks
+ };
+ default:
+ return state;
+ }
+};
+
+const setHoveredSprite = function (spriteId) {
+ return {
+ type: SET_HOVERED_SPRITE,
+ spriteId: spriteId,
+ meta: {
+ throttle: 30
+ }
+ };
+};
+
+const setReceivedBlocks = function (receivedBlocks) {
+ return {
+ type: SET_RECEIVED_BLOCKS,
+ receivedBlocks: receivedBlocks
+ };
+};
+
+export {
+ reducer as default,
+ setHoveredSprite,
+ setReceivedBlocks
+};
diff --git a/src/reducers/modals.js b/src/reducers/modals.js
index ffbca3f8c..ba038ff34 100644
--- a/src/reducers/modals.js
+++ b/src/reducers/modals.js
@@ -7,6 +7,7 @@ const MODAL_BACKDROP_LIBRARY = 'backdropLibrary';
const MODAL_COSTUME_LIBRARY = 'costumeLibrary';
const MODAL_EXTENSION_LIBRARY = 'extensionLibrary';
const MODAL_FEEDBACK_FORM = 'feedbackForm';
+const MODAL_IMPORT_INFO = 'importInfo';
const MODAL_PREVIEW_INFO = 'previewInfo';
const MODAL_SOUND_LIBRARY = 'soundLibrary';
const MODAL_SPRITE_LIBRARY = 'spriteLibrary';
@@ -18,6 +19,7 @@ const initialState = {
[MODAL_COSTUME_LIBRARY]: false,
[MODAL_EXTENSION_LIBRARY]: false,
[MODAL_FEEDBACK_FORM]: false,
+ [MODAL_IMPORT_INFO]: false,
[MODAL_PREVIEW_INFO]: true,
[MODAL_SOUND_LIBRARY]: false,
[MODAL_SPRITE_LIBRARY]: false,
@@ -67,6 +69,14 @@ const openFeedbackForm = function () {
analytics.pageview('/modals/feedback');
return openModal(MODAL_FEEDBACK_FORM);
};
+const openImportInfo = function () {
+ analytics.pageview('modals/import');
+ return openModal(MODAL_IMPORT_INFO);
+};
+const openPreviewInfo = function () {
+ analytics.pageview('/modals/preview');
+ return openModal(MODAL_PREVIEW_INFO);
+};
const openSoundLibrary = function () {
analytics.pageview('/libraries/sounds');
return openModal(MODAL_SOUND_LIBRARY);
@@ -79,10 +89,6 @@ const openSoundRecorder = function () {
analytics.pageview('/modals/microphone');
return openModal(MODAL_SOUND_RECORDER);
};
-const openPreviewInfo = function () {
- analytics.pageview('/modals/preview');
- return openModal(MODAL_PREVIEW_INFO);
-};
const closeBackdropLibrary = function () {
return closeModal(MODAL_BACKDROP_LIBRARY);
};
@@ -95,6 +101,9 @@ const closeExtensionLibrary = function () {
const closeFeedbackForm = function () {
return closeModal(MODAL_FEEDBACK_FORM);
};
+const closeImportInfo = function () {
+ return closeModal(MODAL_IMPORT_INFO);
+};
const closePreviewInfo = function () {
return closeModal(MODAL_PREVIEW_INFO);
};
@@ -113,6 +122,7 @@ export {
openCostumeLibrary,
openExtensionLibrary,
openFeedbackForm,
+ openImportInfo,
openPreviewInfo,
openSoundLibrary,
openSpriteLibrary,
@@ -121,6 +131,7 @@ export {
closeCostumeLibrary,
closeExtensionLibrary,
closeFeedbackForm,
+ closeImportInfo,
closePreviewInfo,
closeSpriteLibrary,
closeSoundLibrary,
diff --git a/test/helpers/selenium-helper.js b/test/helpers/selenium-helper.js
index c8ead9072..b63ea2bf6 100644
--- a/test/helpers/selenium-helper.js
+++ b/test/helpers/selenium-helper.js
@@ -25,6 +25,7 @@ class SeleniumHelper {
// List of useful xpath scopes for finding elements
return {
blocksTab: "*[@id='react-tabs-1']",
+ costumesTab: "*[@id='react-tabs-3']",
modal: '*[@class="ReactModalPortal"]',
reportedValue: '*[@class="blocklyDropDownContent"]',
soundsTab: "*[@id='react-tabs-5']",
diff --git a/test/integration/costumes.test.js b/test/integration/costumes.test.js
index 98f31629a..00700cba2 100644
--- a/test/integration/costumes.test.js
+++ b/test/integration/costumes.test.js
@@ -7,7 +7,9 @@ const {
findByXpath,
getDriver,
getLogs,
- loadUri
+ loadUri,
+ rightClickText,
+ scope
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
@@ -27,7 +29,7 @@ describe('Working with costumes', () => {
await loadUri(uri);
await clickXpath('//button[@title="tryit"]');
await clickText('Costumes');
- await clickXpath('//button[@title="Add Costume"]');
+ await clickXpath('//button[@aria-label="Costume Library"]');
const el = await findByXpath("//input[@placeholder='what are you looking for?']");
await el.sendKeys('abb');
await clickText('Abby-a'); // Should close the modal, then click the costumes in the selector
@@ -36,10 +38,26 @@ describe('Working with costumes', () => {
await expect(logs).toEqual([]);
});
+ test('Duplicating a costume', async () => {
+ await loadUri(uri);
+ await clickXpath('//button[@title="tryit"]');
+ await clickText('Costumes');
+
+ await rightClickText('costume1', scope.costumesTab);
+ await clickText('duplicate', scope.costumesTab);
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for duplication to finish
+
+ // Make sure the duplicated costume is named correctly.
+ await clickText('costume3', scope.costumesTab);
+
+ const logs = await getLogs();
+ await expect(logs).toEqual([]);
+ });
+
test('Adding a backdrop', async () => {
await loadUri(uri);
await clickXpath('//button[@title="tryit"]');
- await clickXpath('//button[@title="Add Backdrop"]');
+ await clickXpath('//button[@aria-label="Backdrop Library"]');
const el = await findByXpath("//input[@placeholder='what are you looking for?']");
await el.sendKeys('blue');
await clickText('Blue Sky'); // Should close the modal
diff --git a/test/integration/project-loading.test.js b/test/integration/project-loading.test.js
index 9d5cb0384..6add67c42 100644
--- a/test/integration/project-loading.test.js
+++ b/test/integration/project-loading.test.js
@@ -4,6 +4,7 @@ import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
+ findByXpath,
getDriver,
getLogs,
loadUri
@@ -31,7 +32,40 @@ describe('Loading scratch gui', () => {
describe('Loading projects by ID', () => {
- test('Load a project by ID', async () => {
+ test('Load 2.0 project using import modal', async () => {
+ await loadUri(uri);
+ await clickText('View 2.0 Project');
+ const el = await findByXpath("//input[@placeholder='scratch.mit.edu/projects/123456789']");
+ const projectId = '96708228';
+ await el.sendKeys(`scratch.mit.edu/projects/${projectId}`);
+ await clickXpath('//button[@title="viewproject"]');
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ await clickXpath('//img[@title="Go"]');
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ await clickXpath('//img[@title="Stop"]');
+ const logs = await getLogs();
+ await expect(logs).toEqual([]);
+ });
+
+ test('Invalid url when loading project through modal lets you try again', async () => {
+ await loadUri(uri);
+ await clickText('View 2.0 Project');
+ let el = await findByXpath("//input[@placeholder='scratch.mit.edu/projects/123456789']");
+ await el.sendKeys('thisisnotaurl');
+ await clickXpath('//button[@title="viewproject"]');
+ el = await findByXpath("//input[@placeholder='scratch.mit.edu/projects/123456789']");
+ await el.clear();
+ await el.sendKeys('scratch.mit.edu/projects/96708228');
+ await clickXpath('//button[@title="viewproject"]');
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ await clickXpath('//img[@title="Go"]');
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ await clickXpath('//img[@title="Stop"]');
+ const logs = await getLogs();
+ await expect(logs).toEqual([]);
+ });
+
+ test('Load a project by ID directly through url', async () => {
const projectId = '96708228';
await loadUri(`${uri}#${projectId}`);
await clickXpath('//button[@title="tryit"]');
diff --git a/test/integration/sounds.test.js b/test/integration/sounds.test.js
index 244ebb9b4..fc36c55a5 100644
--- a/test/integration/sounds.test.js
+++ b/test/integration/sounds.test.js
@@ -37,13 +37,13 @@ describe('Working with sounds', () => {
.accept();
// Add it back
- await clickXpath('//button[@title="Add Sound"]');
+ await clickXpath('//button[@aria-label="Sound Library"]');
let el = await findByXpath("//input[@placeholder='what are you looking for?']");
await el.sendKeys('meow');
await clickText('Meow', scope.modal); // Should close the modal
// Add a new sound
- await clickXpath('//button[@title="Add Sound"]');
+ await clickXpath('//button[@aria-label="Sound Library"]');
el = await findByXpath("//input[@placeholder='what are you looking for?']");
await el.sendKeys('chom');
await clickText('Chomp'); // Should close the modal, then click the sounds in the selector
@@ -63,6 +63,22 @@ describe('Working with sounds', () => {
await expect(logs).toEqual([]);
});
+ test('Duplicating a sound', async () => {
+ await loadUri(uri);
+ await clickXpath('//button[@title="tryit"]');
+ await clickText('Sounds');
+
+ await rightClickText('Meow', scope.soundsTab);
+ await clickText('duplicate', scope.soundsTab);
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for error
+
+ // Make sure the duplicated sound is named correctly.
+ await clickText('Meow2', scope.soundsTab);
+
+ const logs = await getLogs();
+ await expect(logs).toEqual([]);
+ });
+
// Regression test for gui issue #1320
test('Switching sprites with different numbers of sounds', async () => {
await loadUri(uri);
@@ -70,11 +86,11 @@ describe('Working with sounds', () => {
// Add a sound so this sprite has 2 sounds.
await clickText('Sounds');
- await clickXpath('//button[@title="Add Sound"]');
+ await clickXpath('//button[@aria-label="Sound Library"]');
await clickText('A Bass'); // Closes the modal
// Now add a sprite with only one sound.
- await clickXpath('//button[@title="Add Sprite"]');
+ await clickXpath('//button[@aria-label="Sprite Library"]');
await clickText('Abby'); // Doing this used to crash the editor.
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for error
diff --git a/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap
index 19abc2a16..4ecf19565 100644
--- a/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap
+++ b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap
@@ -6,6 +6,8 @@ exports[`SpriteSelectorItemComponent matches snapshot when selected 1`] = `
onClick={[Function]}
onContextMenu={[Function]}
onMouseDown={[Function]}
+ onMouseEnter={undefined}
+ onMouseLeave={undefined}
onMouseOut={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
diff --git a/test/unit/containers/sprite-selector-item.test.jsx b/test/unit/containers/sprite-selector-item.test.jsx
index 2a371a1a0..a423fe816 100644
--- a/test/unit/containers/sprite-selector-item.test.jsx
+++ b/test/unit/containers/sprite-selector-item.test.jsx
@@ -12,6 +12,7 @@ describe('SpriteSelectorItem Container', () => {
let costumeURL;
let name;
let onClick;
+ let dispatchSetHoveredSprite;
let onDeleteButtonClick;
let selected;
let id;
@@ -23,6 +24,7 @@ describe('SpriteSelectorItem Container', () => {
{
};
beforeEach(() => {
- store = mockStore();
+ store = mockStore({hoveredTarget: {receivedBlocks: false, sprite: null}});
className = 'ponies';
costumeURL = 'https://scratch.mit.edu/foo/bar/pony';
id = 1337;
name = 'Pony sprite';
onClick = jest.fn();
onDeleteButtonClick = jest.fn();
+ dispatchSetHoveredSprite = jest.fn();
selected = true;
// Mock window.confirm() which is called when the close button is clicked.
global.confirm = jest.fn(() => true);
diff --git a/webpack.config.js b/webpack.config.js
index c92f72ea7..389cf8abd 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -111,10 +111,6 @@ module.exports = {
from: 'node_modules/scratch-blocks/media',
to: 'static/blocks-media'
}]),
- new CopyWebpackPlugin([{
- from: 'node_modules/scratch-vm/dist/node/assets',
- to: 'static/extension-assets'
- }]),
new CopyWebpackPlugin([{
from: 'extensions/**',
to: 'static',