Initial commit of 001code-html Scratch frontend project.
Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
53
scratch-gui/src/containers/account-nav.jsx
Normal file
53
scratch-gui/src/containers/account-nav.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
NOTE: this file only temporarily resides in scratch-gui.
|
||||
Nearly identical code appears in scratch-www, and the two should
|
||||
eventually be consolidated.
|
||||
*/
|
||||
|
||||
import {injectIntl} from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import AccountNavComponent from '../components/menu-bar/account-nav.jsx';
|
||||
|
||||
const AccountNav = function (props) {
|
||||
const {
|
||||
...componentProps
|
||||
} = props;
|
||||
return (
|
||||
<AccountNavComponent
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
AccountNav.propTypes = {
|
||||
classroomId: PropTypes.string,
|
||||
isEducator: PropTypes.bool,
|
||||
isRtl: PropTypes.bool,
|
||||
isStudent: PropTypes.bool,
|
||||
profileUrl: PropTypes.string,
|
||||
thumbnailUrl: PropTypes.string,
|
||||
username: PropTypes.string
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
classroomId: state.session && state.session.session && state.session.session.user ?
|
||||
state.session.session.user.classroomId : '',
|
||||
isEducator: state.session && state.session.permissions && state.session.permissions.educator,
|
||||
isStudent: state.session && state.session.permissions && state.session.permissions.student,
|
||||
profileUrl: state.session && state.session.session && state.session.session.user ?
|
||||
`/users/${state.session.session.user.username}` : '',
|
||||
thumbnailUrl: state.session && state.session.session && state.session.session.user ?
|
||||
state.session.session.user.thumbnailUrl : null,
|
||||
username: state.session && state.session.session && state.session.session.user ?
|
||||
state.session.session.user.username : ''
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(AccountNav));
|
||||
97
scratch-gui/src/containers/alert.jsx
Normal file
97
scratch-gui/src/containers/alert.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
import SB3Downloader from './sb3-downloader.jsx';
|
||||
import AlertComponent from '../components/alerts/alert.jsx';
|
||||
import {openConnectionModal} from '../reducers/modals';
|
||||
import {setConnectionModalExtensionId} from '../reducers/connection-modal';
|
||||
import {manualUpdateProject} from '../reducers/project-state';
|
||||
|
||||
class Alert extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleOnCloseAlert',
|
||||
'handleOnReconnect'
|
||||
]);
|
||||
}
|
||||
handleOnCloseAlert () {
|
||||
this.props.onCloseAlert(this.props.index);
|
||||
}
|
||||
handleOnReconnect () {
|
||||
this.props.onOpenConnectionModal(this.props.extensionId);
|
||||
this.handleOnCloseAlert();
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
closeButton,
|
||||
content,
|
||||
extensionName,
|
||||
index, // eslint-disable-line no-unused-vars
|
||||
level,
|
||||
iconSpinner,
|
||||
iconURL,
|
||||
message,
|
||||
onSaveNow,
|
||||
showDownload,
|
||||
showReconnect,
|
||||
showSaveNow
|
||||
} = this.props;
|
||||
return (
|
||||
<SB3Downloader>{(_, downloadProject) => (
|
||||
<AlertComponent
|
||||
closeButton={closeButton}
|
||||
content={content}
|
||||
extensionName={extensionName}
|
||||
iconSpinner={iconSpinner}
|
||||
iconURL={iconURL}
|
||||
level={level}
|
||||
message={message}
|
||||
showDownload={showDownload}
|
||||
showReconnect={showReconnect}
|
||||
showSaveNow={showSaveNow}
|
||||
onCloseAlert={this.handleOnCloseAlert}
|
||||
onDownload={downloadProject}
|
||||
onReconnect={this.handleOnReconnect}
|
||||
onSaveNow={onSaveNow}
|
||||
/>
|
||||
)}</SB3Downloader>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = () => ({});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onOpenConnectionModal: id => {
|
||||
dispatch(setConnectionModalExtensionId(id));
|
||||
dispatch(openConnectionModal());
|
||||
},
|
||||
onSaveNow: () => {
|
||||
dispatch(manualUpdateProject());
|
||||
}
|
||||
});
|
||||
|
||||
Alert.propTypes = {
|
||||
closeButton: PropTypes.bool,
|
||||
content: PropTypes.element,
|
||||
extensionId: PropTypes.string,
|
||||
extensionName: PropTypes.string,
|
||||
iconSpinner: PropTypes.bool,
|
||||
iconURL: PropTypes.string,
|
||||
index: PropTypes.number,
|
||||
level: PropTypes.string.isRequired,
|
||||
message: PropTypes.string,
|
||||
onCloseAlert: PropTypes.func.isRequired,
|
||||
onOpenConnectionModal: PropTypes.func,
|
||||
onSaveNow: PropTypes.func,
|
||||
showDownload: PropTypes.bool,
|
||||
showReconnect: PropTypes.bool,
|
||||
showSaveNow: PropTypes.bool
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Alert);
|
||||
42
scratch-gui/src/containers/alerts.jsx
Normal file
42
scratch-gui/src/containers/alerts.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {
|
||||
closeAlert,
|
||||
filterPopupAlerts
|
||||
} from '../reducers/alerts';
|
||||
|
||||
import AlertsComponent from '../components/alerts/alerts.jsx';
|
||||
|
||||
const Alerts = ({
|
||||
alertsList,
|
||||
className,
|
||||
onCloseAlert
|
||||
}) => (
|
||||
<AlertsComponent
|
||||
// only display standard and extension alerts here
|
||||
alertsList={filterPopupAlerts(alertsList)}
|
||||
className={className}
|
||||
onCloseAlert={onCloseAlert}
|
||||
/>
|
||||
);
|
||||
|
||||
Alerts.propTypes = {
|
||||
alertsList: PropTypes.arrayOf(PropTypes.object),
|
||||
className: PropTypes.string,
|
||||
onCloseAlert: PropTypes.func
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
alertsList: state.scratchGui.alerts.alertsList
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onCloseAlert: index => dispatch(closeAlert(index))
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Alerts);
|
||||
156
scratch-gui/src/containers/audio-selector.jsx
Normal file
156
scratch-gui/src/containers/audio-selector.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import AudioSelectorComponent from '../components/audio-trimmer/audio-selector.jsx';
|
||||
import {getEventXY} from '../lib/touch-utils';
|
||||
import DragRecognizer from '../lib/drag-recognizer';
|
||||
|
||||
const MIN_LENGTH = 0.01;
|
||||
const MIN_DURATION = 500;
|
||||
|
||||
class AudioSelector extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleNewSelectionMouseDown',
|
||||
'handleTrimStartMouseDown',
|
||||
'handleTrimEndMouseDown',
|
||||
'handleTrimStartMouseMove',
|
||||
'handleTrimEndMouseMove',
|
||||
'handleTrimStartMouseUp',
|
||||
'handleTrimEndMouseUp',
|
||||
'storeRef'
|
||||
]);
|
||||
|
||||
this.state = {
|
||||
trimStart: props.trimStart,
|
||||
trimEnd: props.trimEnd
|
||||
};
|
||||
|
||||
this.clickStartTime = 0;
|
||||
|
||||
this.trimStartDragRecognizer = new DragRecognizer({
|
||||
onDrag: this.handleTrimStartMouseMove,
|
||||
onDragEnd: this.handleTrimStartMouseUp,
|
||||
touchDragAngle: 90,
|
||||
distanceThreshold: 0
|
||||
});
|
||||
this.trimEndDragRecognizer = new DragRecognizer({
|
||||
onDrag: this.handleTrimEndMouseMove,
|
||||
onDragEnd: this.handleTrimEndMouseUp,
|
||||
touchDragAngle: 90,
|
||||
distanceThreshold: 0
|
||||
});
|
||||
}
|
||||
componentWillReceiveProps (newProps) {
|
||||
const {trimStart, trimEnd} = this.props;
|
||||
if (newProps.trimStart === trimStart && newProps.trimEnd === trimEnd) return;
|
||||
this.setState({
|
||||
trimStart: newProps.trimStart,
|
||||
trimEnd: newProps.trimEnd
|
||||
});
|
||||
}
|
||||
clearSelection () {
|
||||
this.props.onSetTrim(null, null);
|
||||
}
|
||||
handleNewSelectionMouseDown (e) {
|
||||
const {width, left} = this.containerElement.getBoundingClientRect();
|
||||
this.initialTrimEnd = (getEventXY(e).x - left) / width;
|
||||
this.initialTrimStart = this.initialTrimEnd;
|
||||
this.props.onSetTrim(this.initialTrimStart, this.initialTrimEnd);
|
||||
|
||||
this.clickStartTime = Date.now();
|
||||
|
||||
this.containerSize = width;
|
||||
this.trimEndDragRecognizer.start(e);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
handleTrimStartMouseMove (currentOffset, initialOffset) {
|
||||
const dx = (currentOffset.x - initialOffset.x) / this.containerSize;
|
||||
const newTrim = Math.max(0, Math.min(1, this.initialTrimStart + dx));
|
||||
if (newTrim > this.initialTrimEnd) {
|
||||
this.setState({
|
||||
trimStart: this.initialTrimEnd,
|
||||
trimEnd: newTrim
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
trimStart: newTrim,
|
||||
trimEnd: this.initialTrimEnd
|
||||
});
|
||||
}
|
||||
}
|
||||
handleTrimEndMouseMove (currentOffset, initialOffset) {
|
||||
const dx = (currentOffset.x - initialOffset.x) / this.containerSize;
|
||||
const newTrim = Math.min(1, Math.max(0, this.initialTrimEnd + dx));
|
||||
if (newTrim < this.initialTrimStart) {
|
||||
this.setState({
|
||||
trimStart: newTrim,
|
||||
trimEnd: this.initialTrimStart
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
trimStart: this.initialTrimStart,
|
||||
trimEnd: newTrim
|
||||
});
|
||||
}
|
||||
}
|
||||
handleTrimStartMouseUp () {
|
||||
this.props.onSetTrim(this.state.trimStart, this.state.trimEnd);
|
||||
}
|
||||
handleTrimEndMouseUp () {
|
||||
// If the selection was made quickly (tooFast) and is small (tooShort),
|
||||
// deselect instead. This allows click-to-deselect even if you drag
|
||||
// a little bit by accident. It also allows very quickly making a
|
||||
// selection, as long as it is above a minimum length.
|
||||
const tooFast = (Date.now() - this.clickStartTime) < MIN_DURATION;
|
||||
const tooShort = (this.state.trimEnd - this.state.trimStart) < MIN_LENGTH;
|
||||
if (tooFast && tooShort) {
|
||||
this.clearSelection();
|
||||
} else {
|
||||
this.props.onSetTrim(this.state.trimStart, this.state.trimEnd);
|
||||
}
|
||||
}
|
||||
handleTrimStartMouseDown (e) {
|
||||
this.containerSize = this.containerElement.getBoundingClientRect().width;
|
||||
this.trimStartDragRecognizer.start(e);
|
||||
this.initialTrimStart = this.props.trimStart;
|
||||
this.initialTrimEnd = this.props.trimEnd;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
handleTrimEndMouseDown (e) {
|
||||
this.containerSize = this.containerElement.getBoundingClientRect().width;
|
||||
this.trimEndDragRecognizer.start(e);
|
||||
this.initialTrimEnd = this.props.trimEnd;
|
||||
this.initialTrimStart = this.props.trimStart;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
storeRef (el) {
|
||||
this.containerElement = el;
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<AudioSelectorComponent
|
||||
containerRef={this.storeRef}
|
||||
playhead={this.props.playhead}
|
||||
trimEnd={this.state.trimEnd}
|
||||
trimStart={this.state.trimStart}
|
||||
onNewSelectionMouseDown={this.handleNewSelectionMouseDown}
|
||||
onTrimEndMouseDown={this.handleTrimEndMouseDown}
|
||||
onTrimStartMouseDown={this.handleTrimStartMouseDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AudioSelector.propTypes = {
|
||||
onSetTrim: PropTypes.func,
|
||||
playhead: PropTypes.number,
|
||||
trimEnd: PropTypes.number,
|
||||
trimStart: PropTypes.number
|
||||
};
|
||||
|
||||
export default AudioSelector;
|
||||
76
scratch-gui/src/containers/audio-trimmer.jsx
Normal file
76
scratch-gui/src/containers/audio-trimmer.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import AudioTrimmerComponent from '../components/audio-trimmer/audio-trimmer.jsx';
|
||||
import DragRecognizer from '../lib/drag-recognizer';
|
||||
|
||||
const MIN_LENGTH = 0.01; // Used to stop sounds being trimmed smaller than 1%
|
||||
|
||||
class AudioTrimmer extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleTrimStartMouseDown',
|
||||
'handleTrimEndMouseDown',
|
||||
'handleTrimStartMouseMove',
|
||||
'handleTrimEndMouseMove',
|
||||
'storeRef'
|
||||
]);
|
||||
this.trimStartDragRecognizer = new DragRecognizer({
|
||||
onDrag: this.handleTrimStartMouseMove,
|
||||
touchDragAngle: 90,
|
||||
distanceThreshold: 0
|
||||
});
|
||||
this.trimEndDragRecognizer = new DragRecognizer({
|
||||
onDrag: this.handleTrimEndMouseMove,
|
||||
touchDragAngle: 90,
|
||||
distanceThreshold: 0
|
||||
});
|
||||
|
||||
}
|
||||
handleTrimStartMouseMove (currentOffset, initialOffset) {
|
||||
const dx = (currentOffset.x - initialOffset.x) / this.containerSize;
|
||||
const newTrim = Math.max(0, Math.min(this.props.trimEnd - MIN_LENGTH, this.initialTrim + dx));
|
||||
this.props.onSetTrimStart(newTrim);
|
||||
}
|
||||
handleTrimEndMouseMove (currentOffset, initialOffset) {
|
||||
const dx = (currentOffset.x - initialOffset.x) / this.containerSize;
|
||||
const newTrim = Math.min(1, Math.max(this.props.trimStart + MIN_LENGTH, this.initialTrim + dx));
|
||||
this.props.onSetTrimEnd(newTrim);
|
||||
}
|
||||
handleTrimStartMouseDown (e) {
|
||||
this.containerSize = this.containerElement.getBoundingClientRect().width;
|
||||
this.trimStartDragRecognizer.start(e);
|
||||
this.initialTrim = this.props.trimStart;
|
||||
}
|
||||
handleTrimEndMouseDown (e) {
|
||||
this.containerSize = this.containerElement.getBoundingClientRect().width;
|
||||
this.trimEndDragRecognizer.start(e);
|
||||
this.initialTrim = this.props.trimEnd;
|
||||
}
|
||||
storeRef (el) {
|
||||
this.containerElement = el;
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<AudioTrimmerComponent
|
||||
containerRef={this.storeRef}
|
||||
playhead={this.props.playhead}
|
||||
trimEnd={this.props.trimEnd}
|
||||
trimStart={this.props.trimStart}
|
||||
onTrimEndMouseDown={this.handleTrimEndMouseDown}
|
||||
onTrimStartMouseDown={this.handleTrimStartMouseDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AudioTrimmer.propTypes = {
|
||||
onSetTrimEnd: PropTypes.func,
|
||||
onSetTrimStart: PropTypes.func,
|
||||
playhead: PropTypes.number,
|
||||
trimEnd: PropTypes.number,
|
||||
trimStart: PropTypes.number
|
||||
};
|
||||
|
||||
export default AudioTrimmer;
|
||||
89
scratch-gui/src/containers/auto-scanning-step.jsx
Normal file
89
scratch-gui/src/containers/auto-scanning-step.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import ScanningStepComponent, {PHASES} from '../components/connection-modal/auto-scanning-step.jsx';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
class AutoScanningStep extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handlePeripheralListUpdate',
|
||||
'handlePeripheralScanTimeout',
|
||||
'handleStartScan',
|
||||
'handleRefresh'
|
||||
]);
|
||||
this.state = {
|
||||
phase: PHASES.prescan
|
||||
};
|
||||
}
|
||||
componentWillUnmount () {
|
||||
// @todo: stop the peripheral scan here
|
||||
this.unbindPeripheralUpdates();
|
||||
}
|
||||
handlePeripheralScanTimeout () {
|
||||
this.setState({
|
||||
phase: PHASES.notfound
|
||||
});
|
||||
this.unbindPeripheralUpdates();
|
||||
}
|
||||
handlePeripheralListUpdate (newList) {
|
||||
// TODO: sort peripherals by signal strength? so they don't jump around
|
||||
const peripheralArray = Object.keys(newList).map(id =>
|
||||
newList[id]
|
||||
);
|
||||
if (peripheralArray.length > 0) {
|
||||
this.props.onConnecting(peripheralArray[0].peripheralId);
|
||||
}
|
||||
}
|
||||
bindPeripheralUpdates () {
|
||||
this.props.vm.on(
|
||||
'PERIPHERAL_LIST_UPDATE', this.handlePeripheralListUpdate);
|
||||
this.props.vm.on(
|
||||
'PERIPHERAL_SCAN_TIMEOUT', this.handlePeripheralScanTimeout);
|
||||
}
|
||||
unbindPeripheralUpdates () {
|
||||
this.props.vm.removeListener(
|
||||
'PERIPHERAL_LIST_UPDATE', this.handlePeripheralListUpdate);
|
||||
this.props.vm.removeListener(
|
||||
'PERIPHERAL_SCAN_TIMEOUT', this.handlePeripheralScanTimeout);
|
||||
}
|
||||
handleRefresh () {
|
||||
// @todo: stop the peripheral scan here, it is more important for auto scan
|
||||
// due to timeout and cancellation
|
||||
this.setState({
|
||||
phase: PHASES.prescan
|
||||
});
|
||||
this.unbindPeripheralUpdates();
|
||||
}
|
||||
handleStartScan () {
|
||||
this.bindPeripheralUpdates();
|
||||
this.props.vm.scanForPeripheral(this.props.extensionId);
|
||||
this.setState({
|
||||
phase: PHASES.pressbutton
|
||||
});
|
||||
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<ScanningStepComponent
|
||||
connectionTipIconURL={this.props.connectionTipIconURL}
|
||||
phase={this.state.phase}
|
||||
title={this.props.extensionId}
|
||||
onRefresh={this.handleRefresh}
|
||||
onStartScan={this.handleStartScan}
|
||||
onUpdatePeripheral={this.props.onUpdatePeripheral}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AutoScanningStep.propTypes = {
|
||||
connectionTipIconURL: PropTypes.string,
|
||||
extensionId: PropTypes.string.isRequired,
|
||||
onConnecting: PropTypes.func.isRequired,
|
||||
onUpdatePeripheral: PropTypes.func,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
export default AutoScanningStep;
|
||||
68
scratch-gui/src/containers/backdrop-library.jsx
Normal file
68
scratch-gui/src/containers/backdrop-library.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import {getBackdropLibrary} from '../lib/libraries/tw-async-libraries';
|
||||
import backdropTags from '../lib/libraries/backdrop-tags';
|
||||
import LibraryComponent from '../components/library/library.jsx';
|
||||
|
||||
const messages = defineMessages({
|
||||
libraryTitle: {
|
||||
defaultMessage: 'Choose a Backdrop',
|
||||
description: 'Heading for the backdrop library',
|
||||
id: 'gui.costumeLibrary.chooseABackdrop'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
class BackdropLibrary extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleItemSelect'
|
||||
]);
|
||||
this.state = {
|
||||
data: getBackdropLibrary()
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
if (this.state.data.then) {
|
||||
this.state.data.then(data => this.setState({
|
||||
data
|
||||
}));
|
||||
}
|
||||
}
|
||||
handleItemSelect (item) {
|
||||
const vmBackdrop = {
|
||||
name: item.name,
|
||||
rotationCenterX: item.rotationCenterX,
|
||||
rotationCenterY: item.rotationCenterY,
|
||||
bitmapResolution: item.bitmapResolution,
|
||||
skinId: null
|
||||
};
|
||||
// Do not switch to stage, just add the backdrop
|
||||
this.props.vm.addBackdrop(item.md5ext, vmBackdrop);
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<LibraryComponent
|
||||
data={this.state.data.then ? null : this.state.data}
|
||||
id="backdropLibrary"
|
||||
tags={backdropTags}
|
||||
title={this.props.intl.formatMessage(messages.libraryTitle)}
|
||||
onItemSelected={this.handleItemSelect}
|
||||
onRequestClose={this.props.onRequestClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BackdropLibrary.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onRequestClose: PropTypes.func,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
export default injectIntl(BackdropLibrary);
|
||||
321
scratch-gui/src/containers/backpack.jsx
Normal file
321
scratch-gui/src/containers/backpack.jsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
import BackpackComponent from '../components/backpack/backpack.jsx';
|
||||
import {
|
||||
getBackpackContents,
|
||||
saveBackpackObject,
|
||||
deleteBackpackObject,
|
||||
updateBackpackObject,
|
||||
soundPayload,
|
||||
costumePayload,
|
||||
spritePayload,
|
||||
codePayload,
|
||||
LOCAL_API
|
||||
} from '../lib/backpack-api';
|
||||
import DragConstants from '../lib/drag-constants';
|
||||
import DropAreaHOC from '../lib/drop-area-hoc.jsx';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import storage from '../lib/storage';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
const dragTypes = [DragConstants.COSTUME, DragConstants.SOUND, DragConstants.SPRITE];
|
||||
const DroppableBackpack = DropAreaHOC(dragTypes)(BackpackComponent);
|
||||
|
||||
const messages = defineMessages({
|
||||
rename: {
|
||||
defaultMessage: 'New name:',
|
||||
description: 'Renaming a backpack item',
|
||||
id: 'tw.backpack.rename'
|
||||
}
|
||||
});
|
||||
|
||||
class Backpack extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleDrop',
|
||||
'handleToggle',
|
||||
'handleDelete',
|
||||
'handleRename',
|
||||
'getBackpackAssetURL',
|
||||
'getContents',
|
||||
'handleMouseEnter',
|
||||
'handleMouseLeave',
|
||||
'handleBlockDragEnd',
|
||||
'handleBlockDragUpdate',
|
||||
'handleMore'
|
||||
]);
|
||||
this.state = {
|
||||
// While the DroppableHOC manages drop interactions for asset tiles,
|
||||
// we still need to micromanage drops coming from the block workspace.
|
||||
// TODO this may be refactorable with the share-the-love logic in SpriteSelectorItem
|
||||
blockDragOutsideWorkspace: false,
|
||||
blockDragOverBackpack: false,
|
||||
error: false,
|
||||
itemsPerPage: 20,
|
||||
moreToLoad: false,
|
||||
loading: false,
|
||||
expanded: false,
|
||||
contents: []
|
||||
};
|
||||
|
||||
// If a host is given, add it as a web source to the storage module
|
||||
// TODO remove the hacky flag that prevents double adding
|
||||
if (props.host && !storage._hasAddedBackpackSource && props.host !== LOCAL_API) {
|
||||
storage.addWebSource(
|
||||
[storage.AssetType.ImageVector, storage.AssetType.ImageBitmap, storage.AssetType.Sound],
|
||||
this.getBackpackAssetURL
|
||||
);
|
||||
storage._hasAddedBackpackSource = true;
|
||||
}
|
||||
}
|
||||
componentDidMount () {
|
||||
this.props.vm.addListener('BLOCK_DRAG_END', this.handleBlockDragEnd);
|
||||
this.props.vm.addListener('BLOCK_DRAG_UPDATE', this.handleBlockDragUpdate);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.props.vm.removeListener('BLOCK_DRAG_END', this.handleBlockDragEnd);
|
||||
this.props.vm.removeListener('BLOCK_DRAG_UPDATE', this.handleBlockDragUpdate);
|
||||
}
|
||||
getBackpackAssetURL (asset) {
|
||||
return `${this.props.host}/${asset.assetId}.${asset.dataFormat}`;
|
||||
}
|
||||
handleToggle () {
|
||||
const newState = !this.state.expanded;
|
||||
this.setState({expanded: newState, contents: []}, () => {
|
||||
// Emit resize on window to get blocks to resize
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
if (newState) {
|
||||
this.getContents();
|
||||
}
|
||||
}
|
||||
handleError (error) {
|
||||
this.setState({
|
||||
error: `${error}`,
|
||||
loading: false
|
||||
});
|
||||
// Log error to console and make the Promise reject.
|
||||
throw error;
|
||||
}
|
||||
handleDrop (dragInfo) {
|
||||
let payloader = null;
|
||||
let presaveAsset = null;
|
||||
switch (dragInfo.dragType) {
|
||||
case DragConstants.COSTUME:
|
||||
payloader = costumePayload;
|
||||
presaveAsset = dragInfo.payload.asset;
|
||||
break;
|
||||
case DragConstants.SOUND:
|
||||
payloader = soundPayload;
|
||||
presaveAsset = dragInfo.payload.asset;
|
||||
break;
|
||||
case DragConstants.SPRITE:
|
||||
payloader = spritePayload;
|
||||
break;
|
||||
case DragConstants.CODE:
|
||||
payloader = codePayload;
|
||||
break;
|
||||
}
|
||||
if (!payloader) return;
|
||||
|
||||
// Creating the payload is async, so set loading before starting
|
||||
this.setState({loading: true}, () => {
|
||||
payloader(dragInfo.payload, this.props.vm)
|
||||
.then(payload => {
|
||||
// Force the asset to save to the asset server before storing in backpack
|
||||
// Ensures any asset present in the backpack is also on the asset server
|
||||
if (presaveAsset && !presaveAsset.clean && !this.props.host === LOCAL_API) {
|
||||
return storage.store(
|
||||
presaveAsset.assetType,
|
||||
presaveAsset.dataFormat,
|
||||
presaveAsset.data,
|
||||
presaveAsset.assetId
|
||||
).then(() => payload);
|
||||
}
|
||||
return payload;
|
||||
})
|
||||
.then(payload => saveBackpackObject({
|
||||
host: this.props.host,
|
||||
token: this.props.token,
|
||||
username: this.props.username,
|
||||
...payload
|
||||
}))
|
||||
.then(item => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
contents: [item].concat(this.state.contents)
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleError(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
handleDelete (id) {
|
||||
this.setState({loading: true}, () => {
|
||||
deleteBackpackObject({
|
||||
host: this.props.host,
|
||||
token: this.props.token,
|
||||
username: this.props.username,
|
||||
id: id
|
||||
})
|
||||
.then(() => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
contents: this.state.contents.filter(o => o.id !== id)
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleError(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
findItemById (id) {
|
||||
return this.state.contents.find(i => i.id === id);
|
||||
}
|
||||
async handleRename (id) {
|
||||
const item = this.findItemById(id);
|
||||
// prompt() returns Promise in desktop app
|
||||
// eslint-disable-next-line no-alert
|
||||
const newName = await prompt(this.props.intl.formatMessage(messages.rename), item.name);
|
||||
if (!newName) {
|
||||
return;
|
||||
}
|
||||
this.setState({loading: true}, () => {
|
||||
updateBackpackObject({
|
||||
host: this.props.host,
|
||||
...item,
|
||||
name: newName
|
||||
})
|
||||
.then(newItem => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
contents: this.state.contents.map(i => (i === item ? newItem : i))
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleError(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
getContents () {
|
||||
if ((this.props.token && this.props.username) || this.props.host === LOCAL_API) {
|
||||
this.setState({loading: true, error: false}, () => {
|
||||
getBackpackContents({
|
||||
host: this.props.host,
|
||||
token: this.props.token,
|
||||
username: this.props.username,
|
||||
offset: this.state.contents.length,
|
||||
limit: this.state.itemsPerPage
|
||||
})
|
||||
.then(contents => {
|
||||
this.setState({
|
||||
contents: this.state.contents.concat(contents),
|
||||
moreToLoad: contents.length === this.state.itemsPerPage,
|
||||
loading: false
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleError(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
handleBlockDragUpdate (isOutsideWorkspace) {
|
||||
this.setState({
|
||||
blockDragOutsideWorkspace: isOutsideWorkspace
|
||||
});
|
||||
}
|
||||
handleMouseEnter () {
|
||||
if (this.state.blockDragOutsideWorkspace) {
|
||||
this.setState({
|
||||
blockDragOverBackpack: true
|
||||
});
|
||||
}
|
||||
}
|
||||
handleMouseLeave () {
|
||||
this.setState({
|
||||
blockDragOverBackpack: false
|
||||
});
|
||||
}
|
||||
handleBlockDragEnd (blocks, topBlockId) {
|
||||
if (this.state.blockDragOverBackpack) {
|
||||
this.handleDrop({
|
||||
dragType: DragConstants.CODE,
|
||||
payload: {
|
||||
blockObjects: this.props.vm.exportStandaloneBlocks(blocks),
|
||||
topBlockId: topBlockId
|
||||
}
|
||||
});
|
||||
}
|
||||
this.setState({
|
||||
blockDragOverBackpack: false,
|
||||
blockDragOutsideWorkspace: false
|
||||
});
|
||||
}
|
||||
handleMore () {
|
||||
this.getContents();
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<DroppableBackpack
|
||||
blockDragOver={this.state.blockDragOverBackpack}
|
||||
contents={this.state.contents}
|
||||
error={this.state.error}
|
||||
expanded={this.state.expanded}
|
||||
loading={this.state.loading}
|
||||
showMore={this.state.moreToLoad}
|
||||
onDelete={this.handleDelete}
|
||||
onRename={this.handleRename}
|
||||
onDrop={this.handleDrop}
|
||||
onMore={this.handleMore}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onToggle={this.props.host ? this.handleToggle : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Backpack.propTypes = {
|
||||
intl: intlShape,
|
||||
host: PropTypes.string,
|
||||
token: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
vm: PropTypes.instanceOf(VM)
|
||||
};
|
||||
|
||||
const getTokenAndUsername = state => {
|
||||
// Look for the session state provided by scratch-www
|
||||
if (state.session && state.session.session && state.session.session.user) {
|
||||
return {
|
||||
token: state.session.session.user.token,
|
||||
username: state.session.session.user.username
|
||||
};
|
||||
}
|
||||
// Otherwise try to pull testing params out of the URL, or return nulls
|
||||
// TODO a hack for testing the backpack
|
||||
const tokenMatches = window.location.href.match(/[?&]token=([^&]*)&?/);
|
||||
const usernameMatches = window.location.href.match(/[?&]username=([^&]*)&?/);
|
||||
return {
|
||||
token: tokenMatches ? tokenMatches[1] : null,
|
||||
username: usernameMatches ? usernameMatches[1] : null
|
||||
};
|
||||
};
|
||||
|
||||
const mapStateToProps = state => Object.assign(
|
||||
{
|
||||
dragInfo: state.scratchGui.assetDrag,
|
||||
vm: state.scratchGui.vm,
|
||||
blockDrag: state.scratchGui.blockDrag
|
||||
},
|
||||
getTokenAndUsername(state)
|
||||
);
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Backpack));
|
||||
20
scratch-gui/src/containers/balanced-formatted-message.jsx
Normal file
20
scratch-gui/src/containers/balanced-formatted-message.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import BalancedText from './balanced-text.jsx';
|
||||
|
||||
const BalancedFormattedMessage = props => {
|
||||
const {className, resize, style, ...otherProps} = props;
|
||||
const balancedTextProps = {className, resize, style};
|
||||
return (<FormattedMessage {...otherProps}>
|
||||
{(...children) => <BalancedText {...balancedTextProps}>{children}</BalancedText>}
|
||||
</FormattedMessage>);
|
||||
};
|
||||
|
||||
BalancedFormattedMessage.propTypes = {
|
||||
...FormattedMessage.propTypes,
|
||||
resize: PropTypes.bool
|
||||
};
|
||||
|
||||
export default BalancedFormattedMessage;
|
||||
97
scratch-gui/src/containers/balanced-text.jsx
Normal file
97
scratch-gui/src/containers/balanced-text.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Attempts to balance the length of each line of wrapped text.
|
||||
* If the text does not wrap, this component will have no effect.
|
||||
* See https://developer.chrome.com/blog/css-text-wrap-balance/
|
||||
* Patterned after `react-balance-text` with adjustments to support our method of styling.
|
||||
* We may want to replace this with react-wrap-balancer once we use React >= 16.8
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import balanceText from 'balance-text';
|
||||
import bindAll from 'lodash.bindall';
|
||||
|
||||
class BalancedText extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'balanceText',
|
||||
'handleResize'
|
||||
]);
|
||||
this.state = {
|
||||
forceHide: true
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
self.addEventListener('resize', this.handleResize);
|
||||
this.stopHiding();
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
this.balanceText();
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
self.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
handleResize () {
|
||||
if (this.props.resize) {
|
||||
this.balanceText();
|
||||
}
|
||||
}
|
||||
|
||||
balanceText () {
|
||||
const {container} = this;
|
||||
if (container) {
|
||||
balanceText(container, {});
|
||||
}
|
||||
}
|
||||
|
||||
stopHiding () {
|
||||
this.setState({forceHide: false});
|
||||
setTimeout(() => this.balanceText(), 0);
|
||||
}
|
||||
|
||||
render () {
|
||||
let {
|
||||
children,
|
||||
resize, // eslint-disable-line no-unused-vars
|
||||
style,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
if (this.state.forceHide) {
|
||||
style = Object.assign({}, style, {visibility: 'hidden'});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...otherProps}
|
||||
style={style}
|
||||
>
|
||||
<span
|
||||
ref={container => {
|
||||
this.container = container;
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BalancedText.propTypes = {
|
||||
children: PropTypes.node,
|
||||
resize: PropTypes.bool,
|
||||
style: PropTypes.object // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
BalancedText.defaultProps = {
|
||||
resize: true
|
||||
};
|
||||
|
||||
export default BalancedText;
|
||||
885
scratch-gui/src/containers/blocks.jsx
Normal file
885
scratch-gui/src/containers/blocks.jsx
Normal file
@@ -0,0 +1,885 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import debounce from 'lodash.debounce';
|
||||
import defaultsDeep from 'lodash.defaultsdeep';
|
||||
import makeToolboxXML from '../lib/make-toolbox-xml';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {intlShape, injectIntl, defineMessages} from 'react-intl';
|
||||
import VMScratchBlocks from '../lib/blocks';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import log from '../lib/log.js';
|
||||
import Prompt from './prompt.jsx';
|
||||
import BlocksComponent from '../components/blocks/blocks.jsx';
|
||||
import ExtensionLibrary from './extension-library.jsx';
|
||||
import extensionData from '../lib/libraries/extensions/index.jsx';
|
||||
import CustomProcedures from './custom-procedures.jsx';
|
||||
import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx';
|
||||
import {BLOCKS_DEFAULT_SCALE, STAGE_DISPLAY_SIZES} from '../lib/layout-constants';
|
||||
import DropAreaHOC from '../lib/drop-area-hoc.jsx';
|
||||
import DragConstants from '../lib/drag-constants';
|
||||
import defineDynamicBlock from '../lib/define-dynamic-block';
|
||||
import {Theme} from '../lib/themes';
|
||||
import {injectExtensionBlockTheme, injectExtensionCategoryTheme} from '../lib/themes/blockHelpers';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {updateToolbox} from '../reducers/toolbox';
|
||||
import {activateColorPicker} from '../reducers/color-picker';
|
||||
import {
|
||||
closeExtensionLibrary,
|
||||
openSoundRecorder,
|
||||
openConnectionModal,
|
||||
openCustomExtensionModal
|
||||
} from '../reducers/modals';
|
||||
import {activateCustomProcedures, deactivateCustomProcedures} from '../reducers/custom-procedures';
|
||||
import {setConnectionModalExtensionId} from '../reducers/connection-modal';
|
||||
import {updateMetrics} from '../reducers/workspace-metrics';
|
||||
import {isTimeTravel2020} from '../reducers/time-travel';
|
||||
|
||||
import {
|
||||
activateTab,
|
||||
SOUNDS_TAB_INDEX
|
||||
} from '../reducers/editor-tab';
|
||||
import AddonHooks from '../addons/hooks.js';
|
||||
import LoadScratchBlocksHOC from '../lib/tw-load-scratch-blocks-hoc.jsx';
|
||||
import {findTopBlock} from '../lib/backpack/code-payload.js';
|
||||
import {gentlyRequestPersistentStorage} from '../lib/tw-persistent-storage.js';
|
||||
|
||||
// TW: Strings we add to scratch-blocks are localized here
|
||||
const messages = defineMessages({
|
||||
PROCEDURES_RETURN: {
|
||||
defaultMessage: 'return {v}',
|
||||
// eslint-disable-next-line max-len
|
||||
description: 'The name of the "return" block from the Custom Reporters extension. {v} is replaced with a slot to insert a value.',
|
||||
id: 'tw.blocks.PROCEDURES_RETURN'
|
||||
},
|
||||
PROCEDURES_TO_REPORTER: {
|
||||
defaultMessage: 'Change To Reporter',
|
||||
// eslint-disable-next-line max-len
|
||||
description: 'Context menu item to change a command-shaped custom block into a reporter. Part of the Custom Reporters extension.',
|
||||
id: 'tw.blocks.PROCEDURES_TO_REPORTER'
|
||||
},
|
||||
PROCEDURES_TO_STATEMENT: {
|
||||
defaultMessage: 'Change To Statement',
|
||||
// eslint-disable-next-line max-len
|
||||
description: 'Context menu item to change a reporter-shaped custom block into a statement/command. Part of the Custom Reporters extension.',
|
||||
id: 'tw.blocks.PROCEDURES_TO_STATEMENT'
|
||||
},
|
||||
PROCEDURES_DOCS: {
|
||||
defaultMessage: 'How to use return',
|
||||
// eslint-disable-next-line max-len
|
||||
description: 'Button in extension list to learn how to use the "return" block from the Custom Reporters extension.',
|
||||
id: 'tw.blocks.PROCEDURES_DOCS'
|
||||
}
|
||||
});
|
||||
|
||||
const addFunctionListener = (object, property, callback) => {
|
||||
const oldFn = object[property];
|
||||
object[property] = function (...args) {
|
||||
const result = oldFn.apply(this, args);
|
||||
callback.apply(this, result);
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
const DroppableBlocks = DropAreaHOC([
|
||||
DragConstants.BACKPACK_CODE
|
||||
])(BlocksComponent);
|
||||
|
||||
class Blocks extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.ScratchBlocks = VMScratchBlocks(props.vm, false);
|
||||
|
||||
window.ScratchBlocks = this.ScratchBlocks;
|
||||
AddonHooks.blockly = this.ScratchBlocks;
|
||||
AddonHooks.blocklyCallbacks.forEach(i => i());
|
||||
AddonHooks.blocklyCallbacks.length = [];
|
||||
|
||||
bindAll(this, [
|
||||
'attachVM',
|
||||
'detachVM',
|
||||
'getToolboxXML',
|
||||
'handleCategorySelected',
|
||||
'handleConnectionModalStart',
|
||||
'handleDrop',
|
||||
'handleStatusButtonUpdate',
|
||||
'handleOpenSoundRecorder',
|
||||
'handlePromptStart',
|
||||
'handlePromptCallback',
|
||||
'handlePromptClose',
|
||||
'handleCustomProceduresClose',
|
||||
'onScriptGlowOn',
|
||||
'onScriptGlowOff',
|
||||
'onBlockGlowOn',
|
||||
'onBlockGlowOff',
|
||||
'handleMonitorsUpdate',
|
||||
'handleExtensionAdded',
|
||||
'handleBlocksInfoUpdate',
|
||||
'onTargetsUpdate',
|
||||
'onVisualReport',
|
||||
'onWorkspaceUpdate',
|
||||
'onWorkspaceMetricsChange',
|
||||
'setBlocks',
|
||||
'setLocale',
|
||||
'handleEnableProcedureReturns'
|
||||
]);
|
||||
this.ScratchBlocks.prompt = this.handlePromptStart;
|
||||
this.ScratchBlocks.statusButtonCallback = this.handleConnectionModalStart;
|
||||
this.ScratchBlocks.recordSoundCallback = this.handleOpenSoundRecorder;
|
||||
|
||||
this.state = {
|
||||
prompt: null
|
||||
};
|
||||
this.onTargetsUpdate = debounce(this.onTargetsUpdate, 100);
|
||||
this.toolboxUpdateQueue = [];
|
||||
}
|
||||
componentDidMount () {
|
||||
this.ScratchBlocks = VMScratchBlocks(this.props.vm, this.props.useCatBlocks);
|
||||
this.ScratchBlocks.prompt = this.handlePromptStart;
|
||||
this.ScratchBlocks.statusButtonCallback = this.handleConnectionModalStart;
|
||||
this.ScratchBlocks.recordSoundCallback = this.handleOpenSoundRecorder;
|
||||
|
||||
this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker;
|
||||
this.ScratchBlocks.Procedures.externalProcedureDefCallback = this.props.onActivateCustomProcedures;
|
||||
this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale);
|
||||
|
||||
const Msg = this.ScratchBlocks.Msg;
|
||||
Msg.PROCEDURES_RETURN = this.props.intl.formatMessage(messages.PROCEDURES_RETURN, {
|
||||
v: '%1'
|
||||
});
|
||||
Msg.PROCEDURES_TO_REPORTER = this.props.intl.formatMessage(messages.PROCEDURES_TO_REPORTER);
|
||||
Msg.PROCEDURES_TO_STATEMENT = this.props.intl.formatMessage(messages.PROCEDURES_TO_STATEMENT);
|
||||
Msg.PROCEDURES_DOCS = this.props.intl.formatMessage(messages.PROCEDURES_DOCS);
|
||||
|
||||
const workspaceConfig = defaultsDeep({},
|
||||
this.props.options,
|
||||
{
|
||||
rtl: this.props.isRtl,
|
||||
toolbox: this.props.toolboxXML,
|
||||
colours: this.props.theme.getBlockColors(),
|
||||
grid: {
|
||||
colour: this.props.theme.getBlockColors().gridColor
|
||||
}
|
||||
},
|
||||
Blocks.defaultOptions
|
||||
);
|
||||
this.workspace = this.ScratchBlocks.inject(this.blocks, workspaceConfig);
|
||||
|
||||
// Graph-block limit patch: Limit total blocks count to 500
|
||||
this.workspace.addChangeListener((event) => {
|
||||
if (event.type === 'create' && event.recordUndo) {
|
||||
const maxBlocks = 500;
|
||||
// 只统计非 shadow 并且真正属于当前主工作区的实体积木块
|
||||
const currentBlocksCount = this.workspace.getAllBlocks(false)
|
||||
.filter(b => !b.isShadow() && b.workspace === this.workspace).length;
|
||||
|
||||
if (currentBlocksCount > maxBlocks) {
|
||||
if (!this._lastAlertTime || Date.now() - this._lastAlertTime > 1000) {
|
||||
this._lastAlertTime = Date.now();
|
||||
alert('工作区积木块总数已达到最大限制(最多支持 500 个积木)!');
|
||||
}
|
||||
this.ScratchBlocks.Events.disable();
|
||||
try {
|
||||
const createdIds = event.ids || [event.blockId];
|
||||
for (const id of createdIds) {
|
||||
const block = this.workspace.getBlockById(id);
|
||||
if (block) {
|
||||
block.dispose(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.ScratchBlocks.Events.enable();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
AddonHooks.blocklyWorkspace = this.workspace;
|
||||
|
||||
// Register buttons under new callback keys for creating variables,
|
||||
// lists, and procedures from extensions.
|
||||
|
||||
const toolboxWorkspace = this.workspace.getFlyout().getWorkspace();
|
||||
|
||||
const varListButtonCallback = type =>
|
||||
(() => this.ScratchBlocks.Variables.createVariable(this.workspace, null, type));
|
||||
const procButtonCallback = () => {
|
||||
this.ScratchBlocks.Procedures.createProcedureDefCallback_(this.workspace);
|
||||
};
|
||||
|
||||
toolboxWorkspace.registerButtonCallback('MAKE_A_VARIABLE', varListButtonCallback(''));
|
||||
toolboxWorkspace.registerButtonCallback('MAKE_A_LIST', varListButtonCallback('list'));
|
||||
toolboxWorkspace.registerButtonCallback('MAKE_A_PROCEDURE', procButtonCallback);
|
||||
toolboxWorkspace.registerButtonCallback('EXTENSION_CALLBACK', block => {
|
||||
this.props.vm.handleExtensionButtonPress(block.callbackData_);
|
||||
});
|
||||
toolboxWorkspace.registerButtonCallback('OPEN_EXTENSION_DOCS', block => {
|
||||
const docsURI = block.callbackData_;
|
||||
const url = new URL(docsURI);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
window.open(docsURI, '_blank');
|
||||
}
|
||||
});
|
||||
toolboxWorkspace.registerButtonCallback('OPEN_RETURN_DOCS', () => {
|
||||
window.open('https://docs.turbowarp.org/return', '_blank');
|
||||
});
|
||||
|
||||
// Store the xml of the toolbox that is actually rendered.
|
||||
// This is used in componentDidUpdate instead of prevProps, because
|
||||
// the xml can change while e.g. on the costumes tab.
|
||||
this._renderedToolboxXML = this.props.toolboxXML;
|
||||
|
||||
// we actually never want the workspace to enable "refresh toolbox" - this basically re-renders the
|
||||
// entire toolbox every time we reset the workspace. We call updateToolbox as a part of
|
||||
// componentDidUpdate so the toolbox will still correctly be updated
|
||||
this.setToolboxRefreshEnabled = this.workspace.setToolboxRefreshEnabled.bind(this.workspace);
|
||||
this.workspace.setToolboxRefreshEnabled = () => {
|
||||
this.setToolboxRefreshEnabled(false);
|
||||
};
|
||||
|
||||
// @todo change this when blockly supports UI events
|
||||
addFunctionListener(this.workspace, 'translate', this.onWorkspaceMetricsChange);
|
||||
addFunctionListener(this.workspace, 'zoom', this.onWorkspaceMetricsChange);
|
||||
|
||||
this.props.vm.setCompilerOptions({
|
||||
warpTimer: true
|
||||
});
|
||||
|
||||
this.attachVM();
|
||||
// Only update blocks/vm locale when visible to avoid sizing issues
|
||||
// If locale changes while not visible it will get handled in didUpdate
|
||||
if (this.props.isVisible) {
|
||||
this.setLocale();
|
||||
}
|
||||
|
||||
// tw: Handle when extensions are added when Blocks isn't mounted
|
||||
for (const category of this.props.vm.runtime._blockInfo) {
|
||||
this.handleExtensionAdded(category);
|
||||
}
|
||||
|
||||
gentlyRequestPersistentStorage();
|
||||
}
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
return (
|
||||
this.state.prompt !== nextState.prompt ||
|
||||
this.props.isVisible !== nextProps.isVisible ||
|
||||
this._renderedToolboxXML !== nextProps.toolboxXML ||
|
||||
this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible ||
|
||||
this.props.customProceduresVisible !== nextProps.customProceduresVisible ||
|
||||
this.props.locale !== nextProps.locale ||
|
||||
this.props.anyModalVisible !== nextProps.anyModalVisible ||
|
||||
this.props.stageSize !== nextProps.stageSize ||
|
||||
this.props.customStageSize !== nextProps.customStageSize
|
||||
);
|
||||
}
|
||||
componentDidUpdate (prevProps) {
|
||||
// If any modals are open, call hideChaff to close z-indexed field editors
|
||||
if (this.props.anyModalVisible && !prevProps.anyModalVisible) {
|
||||
this.ScratchBlocks.hideChaff();
|
||||
}
|
||||
|
||||
// Only rerender the toolbox when the blocks are visible and the xml is
|
||||
// different from the previously rendered toolbox xml.
|
||||
// Do not check against prevProps.toolboxXML because that may not have been rendered.
|
||||
if (this.props.isVisible && this.props.toolboxXML !== this._renderedToolboxXML) {
|
||||
this.requestToolboxUpdate();
|
||||
}
|
||||
|
||||
if (this.props.isVisible === prevProps.isVisible) {
|
||||
if (
|
||||
this.props.stageSize !== prevProps.stageSize ||
|
||||
this.props.customStageSize !== prevProps.customStageSize
|
||||
) {
|
||||
// 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
|
||||
// @todo hack to reload the workspace due to gui bug #413
|
||||
if (this.props.isVisible) { // Scripts tab
|
||||
this.workspace.setVisible(true);
|
||||
if (prevProps.locale !== this.props.locale || this.props.locale !== this.props.vm.getLocale()) {
|
||||
// call setLocale if the locale has changed, or changed while the blocks were hidden.
|
||||
// vm.getLocale() will be out of sync if locale was changed while not visible
|
||||
this.setLocale();
|
||||
} else {
|
||||
this.props.vm.refreshWorkspace();
|
||||
this.requestToolboxUpdate();
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
} else {
|
||||
this.workspace.setVisible(false);
|
||||
}
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.detachVM();
|
||||
this.unmounted = true;
|
||||
this.workspace.dispose();
|
||||
clearTimeout(this.toolboxUpdateTimeout);
|
||||
|
||||
// Clear the flyout blocks so that they can be recreated on mount.
|
||||
this.props.vm.clearFlyoutBlocks();
|
||||
|
||||
AddonHooks.blocklyWorkspace = null;
|
||||
}
|
||||
requestToolboxUpdate () {
|
||||
clearTimeout(this.toolboxUpdateTimeout);
|
||||
this.toolboxUpdateTimeout = setTimeout(() => {
|
||||
this.updateToolbox();
|
||||
}, 0);
|
||||
}
|
||||
setLocale () {
|
||||
this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale);
|
||||
this.props.vm.setLocale(this.props.locale, this.props.messages)
|
||||
.then(() => {
|
||||
if (this.unmounted) return;
|
||||
this.workspace.getFlyout().setRecyclingEnabled(false);
|
||||
this.props.vm.refreshWorkspace();
|
||||
this.requestToolboxUpdate();
|
||||
this.withToolboxUpdates(() => {
|
||||
this.workspace.getFlyout().setRecyclingEnabled(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateToolbox () {
|
||||
this.toolboxUpdateTimeout = false;
|
||||
|
||||
const categoryId = this.workspace.toolbox_.getSelectedCategoryId();
|
||||
const offset = this.workspace.toolbox_.getCategoryScrollOffset();
|
||||
this.workspace.updateToolbox(this.props.toolboxXML);
|
||||
this._renderedToolboxXML = this.props.toolboxXML;
|
||||
|
||||
// In order to catch any changes that mutate the toolbox during "normal runtime"
|
||||
// (variable changes/etc), re-enable toolbox refresh.
|
||||
// Using the setter function will rerender the entire toolbox which we just rendered.
|
||||
this.workspace.toolboxRefreshEnabled_ = true;
|
||||
|
||||
const currentCategoryPos = this.workspace.toolbox_.getCategoryPositionById(categoryId);
|
||||
const currentCategoryLen = this.workspace.toolbox_.getCategoryLengthById(categoryId);
|
||||
if (offset < currentCategoryLen) {
|
||||
this.workspace.toolbox_.setFlyoutScrollPos(currentCategoryPos + offset);
|
||||
} else {
|
||||
this.workspace.toolbox_.setFlyoutScrollPos(currentCategoryPos);
|
||||
}
|
||||
|
||||
const queue = this.toolboxUpdateQueue;
|
||||
this.toolboxUpdateQueue = [];
|
||||
queue.forEach(fn => fn());
|
||||
}
|
||||
|
||||
withToolboxUpdates (fn) {
|
||||
// if there is a queued toolbox update, we need to wait
|
||||
if (this.toolboxUpdateTimeout) {
|
||||
this.toolboxUpdateQueue.push(fn);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
attachVM () {
|
||||
this.workspace.addChangeListener(this.props.vm.blockListener);
|
||||
this.flyoutWorkspace = this.workspace
|
||||
.getFlyout()
|
||||
.getWorkspace();
|
||||
this.flyoutWorkspace.addChangeListener(this.props.vm.flyoutBlockListener);
|
||||
this.flyoutWorkspace.addChangeListener(this.props.vm.monitorBlockListener);
|
||||
this.props.vm.addListener('SCRIPT_GLOW_ON', this.onScriptGlowOn);
|
||||
this.props.vm.addListener('SCRIPT_GLOW_OFF', this.onScriptGlowOff);
|
||||
this.props.vm.addListener('BLOCK_GLOW_ON', this.onBlockGlowOn);
|
||||
this.props.vm.addListener('BLOCK_GLOW_OFF', this.onBlockGlowOff);
|
||||
this.props.vm.addListener('VISUAL_REPORT', this.onVisualReport);
|
||||
this.props.vm.addListener('workspaceUpdate', this.onWorkspaceUpdate);
|
||||
this.props.vm.addListener('targetsUpdate', this.onTargetsUpdate);
|
||||
this.props.vm.addListener('MONITORS_UPDATE', this.handleMonitorsUpdate);
|
||||
this.props.vm.addListener('EXTENSION_ADDED', this.handleExtensionAdded);
|
||||
this.props.vm.addListener('BLOCKSINFO_UPDATE', this.handleBlocksInfoUpdate);
|
||||
this.props.vm.addListener('PERIPHERAL_CONNECTED', this.handleStatusButtonUpdate);
|
||||
this.props.vm.addListener('PERIPHERAL_DISCONNECTED', this.handleStatusButtonUpdate);
|
||||
}
|
||||
detachVM () {
|
||||
this.props.vm.removeListener('SCRIPT_GLOW_ON', this.onScriptGlowOn);
|
||||
this.props.vm.removeListener('SCRIPT_GLOW_OFF', this.onScriptGlowOff);
|
||||
this.props.vm.removeListener('BLOCK_GLOW_ON', this.onBlockGlowOn);
|
||||
this.props.vm.removeListener('BLOCK_GLOW_OFF', this.onBlockGlowOff);
|
||||
this.props.vm.removeListener('VISUAL_REPORT', this.onVisualReport);
|
||||
this.props.vm.removeListener('workspaceUpdate', this.onWorkspaceUpdate);
|
||||
this.props.vm.removeListener('targetsUpdate', this.onTargetsUpdate);
|
||||
this.props.vm.removeListener('MONITORS_UPDATE', this.handleMonitorsUpdate);
|
||||
this.props.vm.removeListener('EXTENSION_ADDED', this.handleExtensionAdded);
|
||||
this.props.vm.removeListener('BLOCKSINFO_UPDATE', this.handleBlocksInfoUpdate);
|
||||
this.props.vm.removeListener('PERIPHERAL_CONNECTED', this.handleStatusButtonUpdate);
|
||||
this.props.vm.removeListener('PERIPHERAL_DISCONNECTED', this.handleStatusButtonUpdate);
|
||||
}
|
||||
|
||||
updateToolboxBlockValue (id, value) {
|
||||
this.withToolboxUpdates(() => {
|
||||
const block = this.workspace
|
||||
.getFlyout()
|
||||
.getWorkspace()
|
||||
.getBlockById(id);
|
||||
if (block) {
|
||||
block.inputList[0].fieldRow[0].setValue(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onTargetsUpdate () {
|
||||
if (this.props.vm.editingTarget && this.workspace.getFlyout()) {
|
||||
['glide', 'move', 'set'].forEach(prefix => {
|
||||
this.updateToolboxBlockValue(`${prefix}x`, Math.round(this.props.vm.editingTarget.x).toString());
|
||||
this.updateToolboxBlockValue(`${prefix}y`, Math.round(this.props.vm.editingTarget.y).toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
onWorkspaceMetricsChange () {
|
||||
const target = this.props.vm.editingTarget;
|
||||
if (target && target.id) {
|
||||
// Dispatch updateMetrics later, since onWorkspaceMetricsChange may be (very indirectly)
|
||||
// called from a reducer, i.e. when you create a custom procedure.
|
||||
// TODO: Is this a vehement hack?
|
||||
setTimeout(() => {
|
||||
this.props.updateMetrics({
|
||||
targetID: target.id,
|
||||
scrollX: this.workspace.scrollX,
|
||||
scrollY: this.workspace.scrollY,
|
||||
scale: this.workspace.scale
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
onScriptGlowOn (data) {
|
||||
this.workspace.glowStack(data.id, true);
|
||||
}
|
||||
onScriptGlowOff (data) {
|
||||
this.workspace.glowStack(data.id, false);
|
||||
}
|
||||
onBlockGlowOn (data) {
|
||||
this.workspace.glowBlock(data.id, true);
|
||||
}
|
||||
onBlockGlowOff (data) {
|
||||
this.workspace.glowBlock(data.id, false);
|
||||
}
|
||||
onVisualReport (data) {
|
||||
this.workspace.reportValue(data.id, data.value);
|
||||
}
|
||||
getToolboxXML () {
|
||||
// Use try/catch because this requires digging pretty deep into the VM
|
||||
// Code inside intentionally ignores several error situations (no stage, etc.)
|
||||
// Because they would get caught by this try/catch
|
||||
try {
|
||||
let {editingTarget: target, runtime} = this.props.vm;
|
||||
const stage = runtime.getTargetForStage();
|
||||
if (!target) target = stage; // If no editingTarget, use the stage
|
||||
|
||||
const stageCostumes = stage.getCostumes();
|
||||
const targetCostumes = target.getCostumes();
|
||||
const targetSounds = target.getSounds();
|
||||
const dynamicBlocksXML = injectExtensionCategoryTheme(
|
||||
this.props.vm.runtime.getBlocksXML(target),
|
||||
this.props.theme
|
||||
);
|
||||
return makeToolboxXML(false, target.isStage, target.id, dynamicBlocksXML,
|
||||
targetCostumes[targetCostumes.length - 1].name,
|
||||
stageCostumes[stageCostumes.length - 1].name,
|
||||
targetSounds.length > 0 ? targetSounds[targetSounds.length - 1].name : '',
|
||||
this.props.theme.getBlockColors()
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
onWorkspaceUpdate (data) {
|
||||
// When we change sprites, update the toolbox to have the new sprite's blocks
|
||||
const toolboxXML = this.getToolboxXML();
|
||||
if (toolboxXML) {
|
||||
this.props.updateToolboxState(toolboxXML);
|
||||
}
|
||||
|
||||
if (this.props.vm.editingTarget && !this.props.workspaceMetrics.targets[this.props.vm.editingTarget.id]) {
|
||||
this.onWorkspaceMetricsChange();
|
||||
}
|
||||
|
||||
// Remove and reattach the workspace listener (but allow flyout events)
|
||||
this.workspace.removeChangeListener(this.props.vm.blockListener);
|
||||
const dom = this.ScratchBlocks.Xml.textToDom(data.xml);
|
||||
try {
|
||||
this.ScratchBlocks.Xml.clearWorkspaceAndLoadFromXml(dom, this.workspace);
|
||||
} catch (error) {
|
||||
// The workspace is likely incomplete. What did update should be
|
||||
// functional.
|
||||
//
|
||||
// Instead of throwing the error, by logging it and continuing as
|
||||
// normal lets the other workspace update processes complete in the
|
||||
// gui and vm, which lets the vm run even if the workspace is
|
||||
// incomplete. Throwing the error would keep things like setting the
|
||||
// correct editing target from happening which can interfere with
|
||||
// some blocks and processes in the vm.
|
||||
if (error.message) {
|
||||
error.message = `Workspace Update Error: ${error.message}`;
|
||||
}
|
||||
log.error(error);
|
||||
}
|
||||
this.workspace.addChangeListener(this.props.vm.blockListener);
|
||||
|
||||
if (this.props.vm.editingTarget && this.props.workspaceMetrics.targets[this.props.vm.editingTarget.id]) {
|
||||
const {scrollX, scrollY, scale} = this.props.workspaceMetrics.targets[this.props.vm.editingTarget.id];
|
||||
this.workspace.scrollX = scrollX;
|
||||
this.workspace.scrollY = scrollY;
|
||||
this.workspace.scale = scale;
|
||||
this.workspace.resize();
|
||||
}
|
||||
|
||||
// Clear the undo state of the workspace since this is a
|
||||
// fresh workspace and we don't want any changes made to another sprites
|
||||
// workspace to be 'undone' here.
|
||||
this.workspace.clearUndo();
|
||||
}
|
||||
handleMonitorsUpdate (monitors) {
|
||||
// Update the checkboxes of the relevant monitors.
|
||||
// TODO: What about monitors that have fields? See todo in scratch-vm blocks.js changeBlock:
|
||||
// https://github.com/LLK/scratch-vm/blob/2373f9483edaf705f11d62662f7bb2a57fbb5e28/src/engine/blocks.js#L569-L576
|
||||
const flyout = this.workspace.getFlyout();
|
||||
for (const monitor of monitors.values()) {
|
||||
const blockId = monitor.get('id');
|
||||
const isVisible = monitor.get('visible');
|
||||
flyout.setCheckboxState(blockId, isVisible);
|
||||
// We also need to update the isMonitored flag for this block on the VM, since it's used to determine
|
||||
// whether the checkbox is activated or not when the checkbox is re-displayed (e.g. local variables/blocks
|
||||
// when switching between sprites).
|
||||
const block = this.props.vm.runtime.monitorBlocks.getBlock(blockId);
|
||||
if (block) {
|
||||
block.isMonitored = isVisible;
|
||||
}
|
||||
}
|
||||
}
|
||||
handleExtensionAdded (categoryInfo) {
|
||||
const defineBlocks = blockInfoArray => {
|
||||
if (blockInfoArray && blockInfoArray.length > 0) {
|
||||
const staticBlocksJson = [];
|
||||
const dynamicBlocksInfo = [];
|
||||
blockInfoArray.forEach(blockInfo => {
|
||||
if (blockInfo.info && blockInfo.info.isDynamic) {
|
||||
dynamicBlocksInfo.push(blockInfo);
|
||||
} else if (blockInfo.json) {
|
||||
staticBlocksJson.push(injectExtensionBlockTheme(blockInfo.json, this.props.theme));
|
||||
}
|
||||
// otherwise it's a non-block entry such as '---'
|
||||
});
|
||||
|
||||
this.ScratchBlocks.defineBlocksWithJsonArray(staticBlocksJson);
|
||||
dynamicBlocksInfo.forEach(blockInfo => {
|
||||
// This is creating the block factory / constructor -- NOT a specific instance of the block.
|
||||
// The factory should only know static info about the block: the category info and the opcode.
|
||||
// Anything else will be picked up from the XML attached to the block instance.
|
||||
const extendedOpcode = `${categoryInfo.id}_${blockInfo.info.opcode}`;
|
||||
const blockDefinition = defineDynamicBlock(
|
||||
this.ScratchBlocks,
|
||||
categoryInfo,
|
||||
blockInfo,
|
||||
extendedOpcode,
|
||||
this.props.theme
|
||||
);
|
||||
this.ScratchBlocks.Blocks[extendedOpcode] = blockDefinition;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// scratch-blocks implements a menu or custom field as a special kind of block ("shadow" block)
|
||||
// these actually define blocks and MUST run regardless of the UI state
|
||||
defineBlocks(
|
||||
Object.getOwnPropertyNames(categoryInfo.customFieldTypes)
|
||||
.map(fieldTypeName => categoryInfo.customFieldTypes[fieldTypeName].scratchBlocksDefinition));
|
||||
defineBlocks(categoryInfo.menus);
|
||||
defineBlocks(categoryInfo.blocks);
|
||||
|
||||
// Update the toolbox with new blocks if possible
|
||||
const toolboxXML = this.getToolboxXML();
|
||||
if (toolboxXML) {
|
||||
this.props.updateToolboxState(toolboxXML);
|
||||
}
|
||||
}
|
||||
handleBlocksInfoUpdate (categoryInfo) {
|
||||
// @todo Later we should replace this to avoid all the warnings from redefining blocks.
|
||||
this.handleExtensionAdded(categoryInfo);
|
||||
}
|
||||
handleCategorySelected (categoryId) {
|
||||
const extension = extensionData.find(ext => ext.extensionId === categoryId);
|
||||
if (extension && extension.launchPeripheralConnectionFlow) {
|
||||
this.handleConnectionModalStart(categoryId);
|
||||
}
|
||||
|
||||
this.withToolboxUpdates(() => {
|
||||
this.workspace.toolbox_.setSelectedCategoryById(categoryId);
|
||||
});
|
||||
}
|
||||
setBlocks (blocks) {
|
||||
this.blocks = blocks;
|
||||
}
|
||||
handlePromptStart (message, defaultValue, callback, optTitle, optVarType) {
|
||||
const p = {prompt: {callback, message, defaultValue}};
|
||||
p.prompt.title = optTitle ? optTitle :
|
||||
this.ScratchBlocks.Msg.VARIABLE_MODAL_TITLE;
|
||||
p.prompt.varType = typeof optVarType === 'string' ?
|
||||
optVarType : this.ScratchBlocks.SCALAR_VARIABLE_TYPE;
|
||||
p.prompt.showVariableOptions = // This flag means that we should show variable/list options about scope
|
||||
optVarType !== this.ScratchBlocks.BROADCAST_MESSAGE_VARIABLE_TYPE &&
|
||||
p.prompt.title !== this.ScratchBlocks.Msg.RENAME_VARIABLE_MODAL_TITLE &&
|
||||
p.prompt.title !== this.ScratchBlocks.Msg.RENAME_LIST_MODAL_TITLE;
|
||||
p.prompt.showCloudOption = (optVarType === this.ScratchBlocks.SCALAR_VARIABLE_TYPE) && this.props.canUseCloud;
|
||||
this.setState(p);
|
||||
}
|
||||
handleConnectionModalStart (extensionId) {
|
||||
this.props.onOpenConnectionModal(extensionId);
|
||||
}
|
||||
handleStatusButtonUpdate () {
|
||||
this.ScratchBlocks.refreshStatusButtons(this.workspace);
|
||||
}
|
||||
handleOpenSoundRecorder () {
|
||||
this.props.onOpenSoundRecorder();
|
||||
}
|
||||
|
||||
/*
|
||||
* Pass along information about proposed name and variable options (scope and isCloud)
|
||||
* and additional potentially conflicting variable names from the VM
|
||||
* to the variable validation prompt callback used in scratch-blocks.
|
||||
*/
|
||||
handlePromptCallback (input, variableOptions) {
|
||||
this.state.prompt.callback(
|
||||
input,
|
||||
this.props.vm.runtime.getAllVarNamesOfType(this.state.prompt.varType),
|
||||
variableOptions);
|
||||
this.handlePromptClose();
|
||||
}
|
||||
handlePromptClose () {
|
||||
this.setState({prompt: null});
|
||||
}
|
||||
handleCustomProceduresClose (data) {
|
||||
this.props.onRequestCloseCustomProcedures(data);
|
||||
const ws = this.workspace;
|
||||
ws.refreshToolboxSelection_();
|
||||
ws.toolbox_.scrollToCategoryById('myBlocks');
|
||||
}
|
||||
handleDrop (dragInfo) {
|
||||
fetch(dragInfo.payload.bodyUrl)
|
||||
.then(response => response.json())
|
||||
.then(payload => {
|
||||
// based on https://github.com/ScratchAddons/ScratchAddons/pull/7028
|
||||
const topBlock = findTopBlock(payload);
|
||||
if (topBlock) {
|
||||
const metrics = this.props.workspaceMetrics.targets[this.props.vm.editingTarget.id];
|
||||
if (metrics) {
|
||||
const {x, y} = dragInfo.currentOffset;
|
||||
const {left, right} = this.workspace.scrollbar.hScroll.outerSvg_.getBoundingClientRect();
|
||||
const {top} = this.workspace.scrollbar.vScroll.outerSvg_.getBoundingClientRect();
|
||||
topBlock.x = (
|
||||
this.props.isRtl ? metrics.scrollX - x + right : -metrics.scrollX + x - left
|
||||
) / metrics.scale;
|
||||
topBlock.y = (-metrics.scrollY - top + y) / metrics.scale;
|
||||
}
|
||||
}
|
||||
return this.props.vm.shareBlocksToTarget(payload, this.props.vm.editingTarget.id);
|
||||
})
|
||||
.then(() => {
|
||||
this.props.vm.refreshWorkspace();
|
||||
this.updateToolbox(); // To show new variables/custom blocks
|
||||
});
|
||||
}
|
||||
handleEnableProcedureReturns () {
|
||||
this.workspace.enableProcedureReturns();
|
||||
this.requestToolboxUpdate();
|
||||
}
|
||||
render () {
|
||||
/* eslint-disable no-unused-vars */
|
||||
const {
|
||||
anyModalVisible,
|
||||
canUseCloud,
|
||||
customStageSize,
|
||||
customProceduresVisible,
|
||||
extensionLibraryVisible,
|
||||
options,
|
||||
stageSize,
|
||||
vm,
|
||||
isRtl,
|
||||
isVisible,
|
||||
onActivateColorPicker,
|
||||
onOpenConnectionModal,
|
||||
onOpenSoundRecorder,
|
||||
onOpenCustomExtensionModal,
|
||||
reduxOnOpenCustomExtensionModal,
|
||||
updateToolboxState,
|
||||
onActivateCustomProcedures,
|
||||
onRequestCloseExtensionLibrary,
|
||||
onRequestCloseCustomProcedures,
|
||||
toolboxXML,
|
||||
updateMetrics: updateMetricsProp,
|
||||
useCatBlocks,
|
||||
workspaceMetrics,
|
||||
...props
|
||||
} = this.props;
|
||||
/* eslint-enable no-unused-vars */
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DroppableBlocks
|
||||
componentRef={this.setBlocks}
|
||||
onDrop={this.handleDrop}
|
||||
// 传入 vm,便于 UI 组件自行监听
|
||||
vm={vm}
|
||||
// 如果仍保留容器层控制遮罩,这里继续传入
|
||||
showOverlay={this.state.blocksOverlayActive}
|
||||
overlayText="执行中..."
|
||||
{...props}
|
||||
/>
|
||||
{this.state.prompt ? (
|
||||
<Prompt
|
||||
defaultValue={this.state.prompt.defaultValue}
|
||||
isStage={vm.runtime.getEditingTarget().isStage}
|
||||
showListMessage={this.state.prompt.varType === this.ScratchBlocks.LIST_VARIABLE_TYPE}
|
||||
label={this.state.prompt.message}
|
||||
showCloudOption={this.state.prompt.showCloudOption}
|
||||
showVariableOptions={this.state.prompt.showVariableOptions}
|
||||
title={this.state.prompt.title}
|
||||
vm={vm}
|
||||
onCancel={this.handlePromptClose}
|
||||
onOk={this.handlePromptCallback}
|
||||
/>
|
||||
) : null}
|
||||
{extensionLibraryVisible ? (
|
||||
<ExtensionLibrary
|
||||
vm={vm}
|
||||
onCategorySelected={this.handleCategorySelected}
|
||||
onEnableProcedureReturns={this.handleEnableProcedureReturns}
|
||||
onRequestClose={onRequestCloseExtensionLibrary}
|
||||
onOpenCustomExtensionModal={onOpenCustomExtensionModal || reduxOnOpenCustomExtensionModal}
|
||||
/>
|
||||
) : null}
|
||||
{customProceduresVisible ? (
|
||||
<CustomProcedures
|
||||
options={{
|
||||
media: options.media
|
||||
}}
|
||||
onRequestClose={this.handleCustomProceduresClose}
|
||||
/>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Blocks.propTypes = {
|
||||
intl: intlShape,
|
||||
anyModalVisible: PropTypes.bool,
|
||||
canUseCloud: PropTypes.bool,
|
||||
customStageSize: PropTypes.shape({
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number
|
||||
}),
|
||||
customProceduresVisible: PropTypes.bool,
|
||||
extensionLibraryVisible: PropTypes.bool,
|
||||
isRtl: PropTypes.bool,
|
||||
isVisible: PropTypes.bool,
|
||||
locale: PropTypes.string.isRequired,
|
||||
messages: PropTypes.objectOf(PropTypes.string),
|
||||
onActivateColorPicker: PropTypes.func,
|
||||
onActivateCustomProcedures: PropTypes.func,
|
||||
onOpenConnectionModal: PropTypes.func,
|
||||
onOpenSoundRecorder: PropTypes.func,
|
||||
onOpenCustomExtensionModal: PropTypes.func,
|
||||
reduxOnOpenCustomExtensionModal: PropTypes.func,
|
||||
onRequestCloseCustomProcedures: PropTypes.func,
|
||||
onRequestCloseExtensionLibrary: PropTypes.func,
|
||||
options: PropTypes.shape({
|
||||
media: PropTypes.string,
|
||||
zoom: PropTypes.shape({
|
||||
controls: PropTypes.bool,
|
||||
wheel: PropTypes.bool,
|
||||
startScale: PropTypes.number
|
||||
}),
|
||||
comments: PropTypes.bool,
|
||||
collapse: PropTypes.bool
|
||||
}),
|
||||
stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired,
|
||||
theme: PropTypes.instanceOf(Theme),
|
||||
toolboxXML: PropTypes.string,
|
||||
updateMetrics: PropTypes.func,
|
||||
updateToolboxState: PropTypes.func,
|
||||
useCatBlocks: PropTypes.bool,
|
||||
vm: PropTypes.instanceOf(VM).isRequired,
|
||||
workspaceMetrics: PropTypes.shape({
|
||||
targets: PropTypes.objectOf(PropTypes.object)
|
||||
})
|
||||
};
|
||||
|
||||
Blocks.defaultOptions = {
|
||||
zoom: {
|
||||
controls: true,
|
||||
wheel: true,
|
||||
startScale: BLOCKS_DEFAULT_SCALE
|
||||
},
|
||||
grid: {
|
||||
spacing: 40,
|
||||
length: 2,
|
||||
colour: '#ddd'
|
||||
},
|
||||
comments: true,
|
||||
collapse: false,
|
||||
sounds: false
|
||||
};
|
||||
|
||||
Blocks.defaultProps = {
|
||||
isVisible: true,
|
||||
options: Blocks.defaultOptions,
|
||||
theme: Theme.light
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
anyModalVisible: (
|
||||
Object.keys(state.scratchGui.modals).some(key => state.scratchGui.modals[key]) ||
|
||||
state.scratchGui.mode.isFullScreen
|
||||
),
|
||||
customStageSize: state.scratchGui.customStageSize,
|
||||
extensionLibraryVisible: state.scratchGui.modals.extensionLibrary,
|
||||
isRtl: state.locales.isRtl,
|
||||
locale: state.locales.locale,
|
||||
messages: state.locales.messages,
|
||||
toolboxXML: state.scratchGui.toolbox.toolboxXML,
|
||||
customProceduresVisible: state.scratchGui.customProcedures.active,
|
||||
workspaceMetrics: state.scratchGui.workspaceMetrics,
|
||||
useCatBlocks: isTimeTravel2020(state)
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onActivateColorPicker: callback => dispatch(activateColorPicker(callback)),
|
||||
onActivateCustomProcedures: (data, callback) => dispatch(activateCustomProcedures(data, callback)),
|
||||
onOpenConnectionModal: id => {
|
||||
dispatch(setConnectionModalExtensionId(id));
|
||||
dispatch(openConnectionModal());
|
||||
},
|
||||
onOpenSoundRecorder: () => {
|
||||
dispatch(activateTab(SOUNDS_TAB_INDEX));
|
||||
dispatch(openSoundRecorder());
|
||||
},
|
||||
reduxOnOpenCustomExtensionModal: () => dispatch(openCustomExtensionModal()),
|
||||
onRequestCloseExtensionLibrary: () => {
|
||||
dispatch(closeExtensionLibrary());
|
||||
},
|
||||
onRequestCloseCustomProcedures: data => {
|
||||
dispatch(deactivateCustomProcedures(data));
|
||||
},
|
||||
updateToolboxState: toolboxXML => {
|
||||
dispatch(updateToolbox(toolboxXML));
|
||||
},
|
||||
updateMetrics: metrics => {
|
||||
dispatch(updateMetrics(metrics));
|
||||
}
|
||||
});
|
||||
|
||||
export default injectIntl(errorBoundaryHOC('Blocks')(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LoadScratchBlocksHOC(Blocks))
|
||||
));
|
||||
78
scratch-gui/src/containers/cards.jsx
Normal file
78
scratch-gui/src/containers/cards.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import {connect} from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
activateDeck,
|
||||
closeCards,
|
||||
shrinkExpandCards,
|
||||
nextStep,
|
||||
prevStep,
|
||||
dragCard,
|
||||
startDrag,
|
||||
endDrag
|
||||
} from '../reducers/cards';
|
||||
|
||||
import {
|
||||
openTipsLibrary
|
||||
} from '../reducers/modals';
|
||||
|
||||
import CardsComponent from '../components/cards/cards.jsx';
|
||||
import {loadImageData} from '../lib/libraries/decks/translate-image.js';
|
||||
import {notScratchDesktop} from '../lib/isScratchDesktop';
|
||||
|
||||
class Cards extends React.Component {
|
||||
componentDidMount () {
|
||||
if (this.props.locale !== 'en') {
|
||||
loadImageData(this.props.locale);
|
||||
}
|
||||
}
|
||||
componentDidUpdate (prevProps) {
|
||||
if (this.props.locale !== prevProps.locale) {
|
||||
loadImageData(this.props.locale);
|
||||
}
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<CardsComponent {...this.props} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Cards.propTypes = {
|
||||
locale: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
visible: state.scratchGui.cards.visible,
|
||||
content: state.scratchGui.cards.content,
|
||||
activeDeckId: state.scratchGui.cards.activeDeckId,
|
||||
step: state.scratchGui.cards.step,
|
||||
expanded: state.scratchGui.cards.expanded,
|
||||
x: state.scratchGui.cards.x,
|
||||
y: state.scratchGui.cards.y,
|
||||
isRtl: state.locales.isRtl,
|
||||
locale: state.locales.locale,
|
||||
dragging: state.scratchGui.cards.dragging,
|
||||
showVideos: notScratchDesktop()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onActivateDeckFactory: id => () => dispatch(activateDeck(id)),
|
||||
onShowAll: () => {
|
||||
dispatch(openTipsLibrary());
|
||||
dispatch(closeCards());
|
||||
},
|
||||
onCloseCards: () => dispatch(closeCards()),
|
||||
onShrinkExpandCards: () => dispatch(shrinkExpandCards()),
|
||||
onNextStep: () => dispatch(nextStep()),
|
||||
onPrevStep: () => dispatch(prevStep()),
|
||||
onDrag: (e_, data) => dispatch(dragCard(data.x, data.y)),
|
||||
onStartDrag: () => dispatch(startDrag()),
|
||||
onEndDrag: () => dispatch(endDrag())
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Cards);
|
||||
182
scratch-gui/src/containers/connection-modal.jsx
Normal file
182
scratch-gui/src/containers/connection-modal.jsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import ConnectionModalComponent, {PHASES} from '../components/connection-modal/connection-modal.jsx';
|
||||
import VM from 'scratch-vm';
|
||||
import analytics from '../lib/analytics';
|
||||
import extensionData from '../lib/libraries/extensions/index.jsx';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {closeConnectionModal} from '../reducers/modals';
|
||||
import {isMicroBitUpdateSupported, selectAndUpdateMicroBit} from '../lib/microbit-update';
|
||||
|
||||
class ConnectionModal extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleScanning',
|
||||
'handleCancel',
|
||||
'handleConnected',
|
||||
'handleConnecting',
|
||||
'handleDisconnect',
|
||||
'handleError',
|
||||
'handleHelp',
|
||||
'handleSendUpdate',
|
||||
'handleUpdatePeripheral'
|
||||
]);
|
||||
this.state = {
|
||||
extension: extensionData.find(ext => ext.extensionId === props.extensionId),
|
||||
phase: props.vm.getPeripheralIsConnected(props.extensionId) ?
|
||||
PHASES.connected : PHASES.scanning
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
this.props.vm.on('PERIPHERAL_CONNECTED', this.handleConnected);
|
||||
this.props.vm.on('PERIPHERAL_REQUEST_ERROR', this.handleError);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.props.vm.removeListener('PERIPHERAL_CONNECTED', this.handleConnected);
|
||||
this.props.vm.removeListener('PERIPHERAL_REQUEST_ERROR', this.handleError);
|
||||
}
|
||||
handleScanning () {
|
||||
this.setState({
|
||||
phase: PHASES.scanning
|
||||
});
|
||||
}
|
||||
handleConnecting (peripheralId) {
|
||||
this.props.vm.connectPeripheral(this.props.extensionId, peripheralId);
|
||||
this.setState({
|
||||
phase: PHASES.connecting
|
||||
});
|
||||
analytics.event({
|
||||
category: 'extensions',
|
||||
action: 'connecting',
|
||||
label: this.props.extensionId
|
||||
});
|
||||
}
|
||||
handleDisconnect () {
|
||||
try {
|
||||
this.props.vm.disconnectPeripheral(this.props.extensionId);
|
||||
} finally {
|
||||
this.props.onCancel();
|
||||
}
|
||||
}
|
||||
handleCancel () {
|
||||
try {
|
||||
// If we're not connected to a peripheral, close the websocket so we stop scanning.
|
||||
if (!this.props.vm.getPeripheralIsConnected(this.props.extensionId)) {
|
||||
this.props.vm.disconnectPeripheral(this.props.extensionId);
|
||||
}
|
||||
} finally {
|
||||
// Close the modal.
|
||||
this.props.onCancel();
|
||||
}
|
||||
}
|
||||
handleError () {
|
||||
// Assume errors that come in during scanning phase are the result of not
|
||||
// having scratch-link installed.
|
||||
if (this.state.phase === PHASES.scanning || this.state.phase === PHASES.unavailable) {
|
||||
this.setState({
|
||||
phase: PHASES.unavailable
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
phase: PHASES.error
|
||||
});
|
||||
analytics.event({
|
||||
category: 'extensions',
|
||||
action: 'connecting error',
|
||||
label: this.props.extensionId
|
||||
});
|
||||
}
|
||||
}
|
||||
handleConnected () {
|
||||
this.setState({
|
||||
phase: PHASES.connected
|
||||
});
|
||||
analytics.event({
|
||||
category: 'extensions',
|
||||
action: 'connected',
|
||||
label: this.props.extensionId
|
||||
});
|
||||
}
|
||||
handleHelp () {
|
||||
window.open(this.state.extension.helpLink, '_blank');
|
||||
analytics.event({
|
||||
category: 'extensions',
|
||||
action: 'help',
|
||||
label: this.props.extensionId
|
||||
});
|
||||
}
|
||||
handleUpdatePeripheral () {
|
||||
this.setState({
|
||||
phase: PHASES.updatePeripheral
|
||||
});
|
||||
analytics.event({
|
||||
category: 'extensions',
|
||||
action: 'enter peripheral update flow',
|
||||
label: this.props.extensionId
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Handle sending an update to the peripheral.
|
||||
* @param {function(number): void} [progressCallback] Optional callback for progress updates in the range of [0..1].
|
||||
* @returns {Promise} Resolves when the update is complete.
|
||||
*/
|
||||
handleSendUpdate (progressCallback) {
|
||||
analytics.event({
|
||||
category: 'extensions',
|
||||
action: 'send update to peripheral',
|
||||
label: this.props.extensionId
|
||||
});
|
||||
|
||||
// TODO: get this functionality from the extension
|
||||
return selectAndUpdateMicroBit(progressCallback);
|
||||
}
|
||||
render () {
|
||||
const canUpdatePeripheral = (this.props.extensionId === 'microbit') && isMicroBitUpdateSupported();
|
||||
return (
|
||||
<ConnectionModalComponent
|
||||
connectingMessage={this.state.extension && this.state.extension.connectingMessage}
|
||||
connectionIconURL={this.state.extension && this.state.extension.connectionIconURL}
|
||||
connectionSmallIconURL={this.state.extension && this.state.extension.connectionSmallIconURL}
|
||||
connectionTipIconURL={this.state.extension && this.state.extension.connectionTipIconURL}
|
||||
extensionId={this.props.extensionId}
|
||||
name={this.state.extension && this.state.extension.name}
|
||||
phase={this.state.phase}
|
||||
title={this.props.extensionId}
|
||||
useAutoScan={this.state.extension && this.state.extension.useAutoScan}
|
||||
vm={this.props.vm}
|
||||
onCancel={this.handleCancel}
|
||||
onConnected={this.handleConnected}
|
||||
onConnecting={this.handleConnecting}
|
||||
onDisconnect={this.handleDisconnect}
|
||||
onHelp={this.handleHelp}
|
||||
onScanning={this.handleScanning}
|
||||
onSendPeripheralUpdate={canUpdatePeripheral ? this.handleSendUpdate : null}
|
||||
onUpdatePeripheral={canUpdatePeripheral ? this.handleUpdatePeripheral : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionModal.propTypes = {
|
||||
extensionId: PropTypes.string.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
extensionId: state.scratchGui.connectionModal.extensionId
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onCancel: () => {
|
||||
dispatch(closeConnectionModal());
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(ConnectionModal);
|
||||
82
scratch-gui/src/containers/controls.jsx
Normal file
82
scratch-gui/src/containers/controls.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import VM from 'scratch-vm';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import ControlsComponent from '../components/controls/controls.jsx';
|
||||
|
||||
class Controls extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleGreenFlagClick',
|
||||
'handleStopAllClick'
|
||||
]);
|
||||
}
|
||||
handleGreenFlagClick (e) {
|
||||
e.preventDefault();
|
||||
// tw: implement alt+click and right click to toggle FPS
|
||||
if (e.shiftKey || e.altKey || e.type === 'contextmenu') {
|
||||
if (e.shiftKey) {
|
||||
this.props.vm.setTurboMode(!this.props.turbo);
|
||||
}
|
||||
if (e.altKey || e.type === 'contextmenu') {
|
||||
if (this.props.framerate === 30) {
|
||||
this.props.vm.setFramerate(60);
|
||||
} else {
|
||||
this.props.vm.setFramerate(30);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!this.props.isStarted) {
|
||||
this.props.vm.start();
|
||||
}
|
||||
this.props.vm.greenFlag();
|
||||
}
|
||||
}
|
||||
handleStopAllClick (e) {
|
||||
e.preventDefault();
|
||||
this.props.vm.stopAll();
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
vm, // eslint-disable-line no-unused-vars
|
||||
isStarted, // eslint-disable-line no-unused-vars
|
||||
projectRunning,
|
||||
turbo,
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<ControlsComponent
|
||||
{...props}
|
||||
active={projectRunning && isStarted}
|
||||
turbo={turbo}
|
||||
onGreenFlagClick={this.handleGreenFlagClick}
|
||||
onStopAllClick={this.handleStopAllClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Controls.propTypes = {
|
||||
isStarted: PropTypes.bool.isRequired,
|
||||
projectRunning: PropTypes.bool.isRequired,
|
||||
turbo: PropTypes.bool.isRequired,
|
||||
framerate: PropTypes.number.isRequired,
|
||||
interpolation: PropTypes.bool.isRequired,
|
||||
isSmall: PropTypes.bool,
|
||||
vm: PropTypes.instanceOf(VM)
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isStarted: state.scratchGui.vmStatus.started,
|
||||
projectRunning: state.scratchGui.vmStatus.running,
|
||||
framerate: state.scratchGui.tw.framerate,
|
||||
interpolation: state.scratchGui.tw.interpolation,
|
||||
turbo: state.scratchGui.vmStatus.turbo
|
||||
});
|
||||
// no-op function to prevent dispatch prop being passed to component
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Controls);
|
||||
68
scratch-gui/src/containers/costume-library.jsx
Normal file
68
scratch-gui/src/containers/costume-library.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import {getCostumeLibrary} from '../lib/libraries/tw-async-libraries';
|
||||
import spriteTags from '../lib/libraries/sprite-tags';
|
||||
import LibraryComponent from '../components/library/library.jsx';
|
||||
|
||||
const messages = defineMessages({
|
||||
libraryTitle: {
|
||||
defaultMessage: 'Choose a Costume',
|
||||
description: 'Heading for the costume library',
|
||||
id: 'gui.costumeLibrary.chooseACostume'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
class CostumeLibrary extends React.PureComponent {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleItemSelected'
|
||||
]);
|
||||
this.state = {
|
||||
data: getCostumeLibrary()
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
if (this.state.data.then) {
|
||||
this.state.data.then(data => this.setState({
|
||||
data
|
||||
}));
|
||||
}
|
||||
}
|
||||
handleItemSelected (item) {
|
||||
const vmCostume = {
|
||||
name: item.name,
|
||||
rotationCenterX: item.rotationCenterX,
|
||||
rotationCenterY: item.rotationCenterY,
|
||||
bitmapResolution: item.bitmapResolution,
|
||||
skinId: null
|
||||
};
|
||||
this.props.vm.addCostumeFromLibrary(item.md5ext, vmCostume);
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<LibraryComponent
|
||||
data={this.state.data.then ? null : this.state.data}
|
||||
id="costumeLibrary"
|
||||
tags={spriteTags}
|
||||
title={this.props.intl.formatMessage(messages.libraryTitle)}
|
||||
removedTrademarks
|
||||
onItemSelected={this.handleItemSelected}
|
||||
onRequestClose={this.props.onRequestClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CostumeLibrary.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onRequestClose: PropTypes.func,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
export default injectIntl(CostumeLibrary);
|
||||
388
scratch-gui/src/containers/costume-tab.jsx
Normal file
388
scratch-gui/src/containers/costume-tab.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {defineMessages, intlShape, injectIntl} from 'react-intl';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import AssetPanel from '../components/asset-panel/asset-panel.jsx';
|
||||
import PaintEditorWrapper from './paint-editor-wrapper.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 {emptyCostume} from '../lib/empty-assets';
|
||||
import sharedMessages from '../lib/shared-messages';
|
||||
import downloadBlob from '../lib/download-blob';
|
||||
|
||||
import {
|
||||
openCostumeLibrary,
|
||||
openBackdropLibrary
|
||||
} from '../reducers/modals';
|
||||
|
||||
import {
|
||||
activateTab,
|
||||
SOUNDS_TAB_INDEX
|
||||
} from '../reducers/editor-tab';
|
||||
|
||||
import {setRestore} from '../reducers/restore-deletion';
|
||||
import {showStandardAlert, closeAlertWithId} from '../reducers/alerts';
|
||||
|
||||
import addLibraryBackdropIcon from '../components/asset-panel/icon--add-backdrop-lib.svg';
|
||||
import addLibraryCostumeIcon from '../components/asset-panel/icon--add-costume-lib.svg';
|
||||
import fileUploadIcon from '../components/action-menu/icon--file-upload.svg';
|
||||
import paintIcon from '../components/action-menu/icon--paint.svg';
|
||||
import surpriseIcon from '../components/action-menu/icon--surprise.svg';
|
||||
import searchIcon from '../components/action-menu/icon--search.svg';
|
||||
|
||||
import {getCostumeLibrary, getBackdropLibrary} from '../lib/libraries/tw-async-libraries';
|
||||
|
||||
let messages = defineMessages({
|
||||
addLibraryBackdropMsg: {
|
||||
defaultMessage: 'Choose a Backdrop',
|
||||
description: 'Button to add a backdrop in the editor tab',
|
||||
id: 'gui.costumeTab.addBackdropFromLibrary'
|
||||
},
|
||||
addLibraryCostumeMsg: {
|
||||
defaultMessage: 'Choose a Costume',
|
||||
description: 'Button to add a costume in the editor tab',
|
||||
id: 'gui.costumeTab.addCostumeFromLibrary'
|
||||
},
|
||||
addBlankCostumeMsg: {
|
||||
defaultMessage: 'Paint',
|
||||
description: 'Button to add a blank costume in the editor tab',
|
||||
id: 'gui.costumeTab.addBlankCostume'
|
||||
},
|
||||
addSurpriseCostumeMsg: {
|
||||
defaultMessage: 'Surprise',
|
||||
description: 'Button to add a surprise costume in the editor tab',
|
||||
id: 'gui.costumeTab.addSurpriseCostume'
|
||||
},
|
||||
addFileBackdropMsg: {
|
||||
defaultMessage: 'Upload Backdrop',
|
||||
description: 'Button to add a backdrop by uploading a file in the editor tab',
|
||||
id: 'gui.costumeTab.addFileBackdrop'
|
||||
},
|
||||
addFileCostumeMsg: {
|
||||
defaultMessage: 'Upload Costume',
|
||||
description: 'Button to add a costume by uploading a file in the editor tab',
|
||||
id: 'gui.costumeTab.addFileCostume'
|
||||
}
|
||||
});
|
||||
|
||||
messages = {...messages, ...sharedMessages};
|
||||
|
||||
class CostumeTab extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleSelectCostume',
|
||||
'handleDeleteCostume',
|
||||
'handleDuplicateCostume',
|
||||
'handleExportCostume',
|
||||
'handleNewCostume',
|
||||
'handleNewBlankCostume',
|
||||
'handleSurpriseCostume',
|
||||
'handleSurpriseBackdrop',
|
||||
'handleFileUploadClick',
|
||||
'handleCostumeUpload',
|
||||
'handleDrop',
|
||||
'setFileInput'
|
||||
]);
|
||||
const {
|
||||
editingTarget,
|
||||
sprites,
|
||||
stage
|
||||
} = props;
|
||||
const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage;
|
||||
if (target && target.currentCostume) {
|
||||
this.state = {selectedCostumeIndex: target.currentCostume};
|
||||
} else {
|
||||
this.state = {selectedCostumeIndex: 0};
|
||||
}
|
||||
}
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const {
|
||||
editingTarget,
|
||||
sprites,
|
||||
stage
|
||||
} = nextProps;
|
||||
|
||||
const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage;
|
||||
if (!target || !target.costumes) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.editingTarget === editingTarget) {
|
||||
// If costumes have been added or removed, change costumes to the editing target's
|
||||
// current costume.
|
||||
const oldTarget = this.props.sprites[editingTarget] ?
|
||||
this.props.sprites[editingTarget] : this.props.stage;
|
||||
// @todo: Find and switch to the index of the costume that is new. This is blocked by
|
||||
// https://github.com/LLK/scratch-vm/issues/967
|
||||
// Right now, you can land on the wrong costume if a costume changing script is running.
|
||||
if (oldTarget.costumeCount !== target.costumeCount) {
|
||||
this.setState({selectedCostumeIndex: target.currentCostume});
|
||||
}
|
||||
} else {
|
||||
// If switching editing targets, update the costume index
|
||||
this.setState({selectedCostumeIndex: target.currentCostume});
|
||||
}
|
||||
}
|
||||
handleSelectCostume (costumeIndex) {
|
||||
this.props.vm.editingTarget.setCostume(costumeIndex);
|
||||
this.setState({selectedCostumeIndex: costumeIndex});
|
||||
}
|
||||
handleDeleteCostume (costumeIndex) {
|
||||
const restoreCostumeFun = this.props.vm.deleteCostume(costumeIndex);
|
||||
this.props.dispatchUpdateRestore({
|
||||
restoreFun: restoreCostumeFun,
|
||||
deletedItem: 'Costume'
|
||||
});
|
||||
}
|
||||
handleDuplicateCostume (costumeIndex) {
|
||||
this.props.vm.duplicateCostume(costumeIndex);
|
||||
}
|
||||
handleExportCostume (costumeIndex) {
|
||||
const item = this.props.vm.editingTarget.sprite.costumes[costumeIndex];
|
||||
const blob = new Blob([
|
||||
this.props.vm.getExportedCostume(item)
|
||||
], {type: item.asset.assetType.contentType});
|
||||
downloadBlob(`${item.name}.${item.asset.dataFormat}`, blob);
|
||||
}
|
||||
handleNewCostume (costume, fromCostumeLibrary, targetId) {
|
||||
const costumes = Array.isArray(costume) ? costume : [costume];
|
||||
|
||||
return Promise.all(costumes.map(c => {
|
||||
if (fromCostumeLibrary) {
|
||||
return this.props.vm.addCostumeFromLibrary(c.md5, c);
|
||||
}
|
||||
// If targetId is falsy, VM should default it to editingTarget.id
|
||||
// However, targetId should be provided to prevent #5876,
|
||||
// if making new costume takes a while
|
||||
return this.props.vm.addCostume(c.md5, c, targetId);
|
||||
}));
|
||||
}
|
||||
handleNewBlankCostume () {
|
||||
const name = this.props.vm.editingTarget.isStage ?
|
||||
this.props.intl.formatMessage(messages.backdrop, {index: 1}) :
|
||||
this.props.intl.formatMessage(messages.costume, {index: 1});
|
||||
this.handleNewCostume(emptyCostume(name));
|
||||
}
|
||||
async handleSurpriseCostume () {
|
||||
const costumeLibraryContent = await getCostumeLibrary();
|
||||
const item = costumeLibraryContent[Math.floor(Math.random() * costumeLibraryContent.length)];
|
||||
const vmCostume = {
|
||||
name: item.name,
|
||||
md5: item.md5ext,
|
||||
rotationCenterX: item.rotationCenterX,
|
||||
rotationCenterY: item.rotationCenterY,
|
||||
bitmapResolution: item.bitmapResolution,
|
||||
skinId: null
|
||||
};
|
||||
this.handleNewCostume(vmCostume, true /* fromCostumeLibrary */);
|
||||
}
|
||||
async handleSurpriseBackdrop () {
|
||||
const backdropLibraryContent = await getBackdropLibrary();
|
||||
const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
|
||||
const vmCostume = {
|
||||
name: item.name,
|
||||
md5: item.md5ext,
|
||||
rotationCenterX: item.rotationCenterX,
|
||||
rotationCenterY: item.rotationCenterY,
|
||||
bitmapResolution: item.bitmapResolution,
|
||||
skinId: null
|
||||
};
|
||||
this.handleNewCostume(vmCostume);
|
||||
}
|
||||
handleCostumeUpload (e) {
|
||||
const vm = this.props.vm;
|
||||
const targetId = this.props.vm.editingTarget.id;
|
||||
this.props.onShowImporting();
|
||||
handleFileUpload(e.target, (buffer, fileType, fileName, fileIndex, fileCount) => {
|
||||
costumeUpload(buffer, fileType, vm, vmCostumes => {
|
||||
vmCostumes.forEach((costume, i) => {
|
||||
costume.name = `${fileName}${i ? i + 1 : ''}`;
|
||||
});
|
||||
this.handleNewCostume(vmCostumes, false, targetId).then(() => {
|
||||
if (fileIndex === fileCount - 1) {
|
||||
this.props.onCloseImporting();
|
||||
}
|
||||
});
|
||||
}, this.props.onCloseImporting);
|
||||
}, this.props.onCloseImporting);
|
||||
}
|
||||
handleFileUploadClick () {
|
||||
this.fileInput.click();
|
||||
}
|
||||
handleDrop (dropInfo) {
|
||||
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)});
|
||||
} else if (dropInfo.dragType === DragConstants.BACKPACK_COSTUME) {
|
||||
this.props.vm.addCostume(dropInfo.payload.body, {
|
||||
name: dropInfo.payload.name
|
||||
});
|
||||
} else if (dropInfo.dragType === DragConstants.BACKPACK_SOUND) {
|
||||
this.props.onActivateSoundsTab();
|
||||
this.props.vm.addSound({
|
||||
md5: dropInfo.payload.body,
|
||||
name: dropInfo.payload.name
|
||||
});
|
||||
}
|
||||
}
|
||||
setFileInput (input) {
|
||||
this.fileInput = input;
|
||||
}
|
||||
formatCostumeDetails (size, optResolution) {
|
||||
// If no resolution is given, assume that the costume is an SVG
|
||||
const resolution = optResolution ? optResolution : 1;
|
||||
// Convert size to stage units by dividing by resolution
|
||||
// Round up width and height for scratch-flash compatibility
|
||||
// https://github.com/LLK/scratch-flash/blob/9fbac92ef3d09ceca0c0782f8a08deaa79e4df69/src/ui/media/MediaInfo.as#L224-L237
|
||||
return `${Math.ceil(size[0] / resolution)} x ${Math.ceil(size[1] / resolution)}`;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
dispatchUpdateRestore, // eslint-disable-line no-unused-vars
|
||||
intl,
|
||||
isRtl,
|
||||
onNewLibraryBackdropClick,
|
||||
onNewLibraryCostumeClick,
|
||||
vm
|
||||
} = this.props;
|
||||
|
||||
if (!vm.editingTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 ? target.costumes.map(costume => ({
|
||||
name: costume.name,
|
||||
asset: costume.asset,
|
||||
details: costume.size ? this.formatCostumeDetails(costume.size, costume.bitmapResolution) : null,
|
||||
dragPayload: costume
|
||||
})) : [];
|
||||
return (
|
||||
<AssetPanel
|
||||
buttons={[
|
||||
{
|
||||
title: intl.formatMessage(addLibraryMessage),
|
||||
img: addLibraryIcon,
|
||||
onClick: addLibraryFunc
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage(addFileMessage),
|
||||
img: fileUploadIcon,
|
||||
onClick: this.handleFileUploadClick,
|
||||
fileAccept: '.svg, .png, .bmp, .jpg, .jpeg, .jfif, .webp, .gif',
|
||||
fileChange: this.handleCostumeUpload,
|
||||
fileInput: this.setFileInput,
|
||||
fileMultiple: true
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage(messages.addSurpriseCostumeMsg),
|
||||
img: surpriseIcon,
|
||||
onClick: addSurpriseFunc
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage(messages.addBlankCostumeMsg),
|
||||
img: paintIcon,
|
||||
onClick: this.handleNewBlankCostume
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage(addLibraryMessage),
|
||||
img: searchIcon,
|
||||
onClick: addLibraryFunc
|
||||
}
|
||||
]}
|
||||
dragType={DragConstants.COSTUME}
|
||||
isRtl={isRtl}
|
||||
items={costumeData}
|
||||
selectedItemIndex={this.state.selectedCostumeIndex}
|
||||
onDeleteClick={target && target.costumes && target.costumes.length > 1 ?
|
||||
this.handleDeleteCostume : null}
|
||||
onDrop={this.handleDrop}
|
||||
onDuplicateClick={this.handleDuplicateCostume}
|
||||
onExportClick={this.handleExportCostume}
|
||||
onItemClick={this.handleSelectCostume}
|
||||
>
|
||||
{target.costumes ?
|
||||
<PaintEditorWrapper
|
||||
selectedCostumeIndex={this.state.selectedCostumeIndex}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</AssetPanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CostumeTab.propTypes = {
|
||||
dispatchUpdateRestore: PropTypes.func,
|
||||
editingTarget: PropTypes.string,
|
||||
intl: intlShape,
|
||||
isRtl: PropTypes.bool,
|
||||
onActivateSoundsTab: PropTypes.func.isRequired,
|
||||
onCloseImporting: PropTypes.func.isRequired,
|
||||
onNewLibraryBackdropClick: PropTypes.func.isRequired,
|
||||
onNewLibraryCostumeClick: PropTypes.func.isRequired,
|
||||
onShowImporting: PropTypes.func.isRequired,
|
||||
sprites: PropTypes.shape({
|
||||
id: PropTypes.shape({
|
||||
costumes: PropTypes.arrayOf(PropTypes.shape({
|
||||
url: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
skinId: PropTypes.number
|
||||
}))
|
||||
})
|
||||
}),
|
||||
stage: PropTypes.shape({
|
||||
sounds: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string.isRequired
|
||||
}))
|
||||
}),
|
||||
vm: PropTypes.instanceOf(VM)
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
editingTarget: state.scratchGui.targets.editingTarget,
|
||||
isRtl: state.locales.isRtl,
|
||||
sprites: state.scratchGui.targets.sprites,
|
||||
stage: state.scratchGui.targets.stage,
|
||||
dragging: state.scratchGui.assetDrag.dragging
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onActivateSoundsTab: () => dispatch(activateTab(SOUNDS_TAB_INDEX)),
|
||||
onNewLibraryBackdropClick: e => {
|
||||
e.preventDefault();
|
||||
dispatch(openBackdropLibrary());
|
||||
},
|
||||
onNewLibraryCostumeClick: e => {
|
||||
e.preventDefault();
|
||||
dispatch(openCostumeLibrary());
|
||||
},
|
||||
dispatchUpdateRestore: restoreState => {
|
||||
dispatch(setRestore(restoreState));
|
||||
},
|
||||
onCloseImporting: () => dispatch(closeAlertWithId('importingAsset')),
|
||||
onShowImporting: () => dispatch(showStandardAlert('importingAsset'))
|
||||
});
|
||||
|
||||
export default errorBoundaryHOC('Costume Tab')(
|
||||
injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(CostumeTab))
|
||||
);
|
||||
196
scratch-gui/src/containers/custom-procedures.jsx
Normal file
196
scratch-gui/src/containers/custom-procedures.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import defaultsDeep from 'lodash.defaultsdeep';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import CustomProceduresComponent from '../components/custom-procedures/custom-procedures.jsx';
|
||||
import LazyScratchBlocks from '../lib/tw-lazy-scratch-blocks';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
class CustomProcedures extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleAddLabel',
|
||||
'handleAddBoolean',
|
||||
'handleAddTextNumber',
|
||||
'handleToggleWarp',
|
||||
'handleCancel',
|
||||
'handleOk',
|
||||
'setBlocks'
|
||||
]);
|
||||
this.state = {
|
||||
rtlOffset: 0,
|
||||
warp: false
|
||||
};
|
||||
}
|
||||
componentWillUnmount () {
|
||||
if (this.workspace) {
|
||||
this.workspace.dispose();
|
||||
}
|
||||
}
|
||||
setBlocks (blocksRef) {
|
||||
if (!blocksRef) return;
|
||||
this.blocks = blocksRef;
|
||||
const workspaceConfig = defaultsDeep({},
|
||||
CustomProcedures.defaultOptions,
|
||||
this.props.options,
|
||||
{rtl: this.props.isRtl}
|
||||
);
|
||||
|
||||
const ScratchBlocks = LazyScratchBlocks.get();
|
||||
// @todo This is a hack to make there be no toolbox.
|
||||
const oldDefaultToolbox = ScratchBlocks.Blocks.defaultToolbox;
|
||||
ScratchBlocks.Blocks.defaultToolbox = null;
|
||||
this.workspace = ScratchBlocks.inject(this.blocks, workspaceConfig);
|
||||
ScratchBlocks.Blocks.defaultToolbox = oldDefaultToolbox;
|
||||
|
||||
// Create the procedure declaration block for editing the mutation.
|
||||
this.mutationRoot = this.workspace.newBlock('procedures_declaration');
|
||||
// Make the declaration immovable, undeletable and have no context menu
|
||||
this.mutationRoot.setMovable(false);
|
||||
this.mutationRoot.setDeletable(false);
|
||||
this.mutationRoot.contextMenu = false;
|
||||
|
||||
this.workspace.addChangeListener(() => {
|
||||
this.mutationRoot.onChangeFn();
|
||||
// Keep the block centered on the workspace
|
||||
const metrics = this.workspace.getMetrics();
|
||||
const {x, y} = this.mutationRoot.getRelativeToSurfaceXY();
|
||||
const dy = (metrics.viewHeight / 2) - (this.mutationRoot.height / 2) - y;
|
||||
let dx;
|
||||
if (this.props.isRtl) {
|
||||
// // TODO: https://github.com/LLK/scratch-gui/issues/2838
|
||||
// This is temporary until we can figure out what's going on width
|
||||
// block positioning on the workspace for RTL.
|
||||
// Workspace is always origin top-left, with x increasing to the right
|
||||
// Calculate initial starting offset and save it, every other move
|
||||
// has to take the original offset into account.
|
||||
// Calculate a new left postion based on new width
|
||||
// Convert current x position into LTR (mirror) x position (uses original offset)
|
||||
// Use the difference between ltrX and mirrorX as the amount to move
|
||||
const ltrX = ((metrics.viewWidth / 2) - (this.mutationRoot.width / 2) + 25);
|
||||
const mirrorX = x - ((x - this.state.rtlOffset) * 2);
|
||||
if (mirrorX === ltrX) {
|
||||
return;
|
||||
}
|
||||
dx = mirrorX - ltrX;
|
||||
const midPoint = metrics.viewWidth / 2;
|
||||
if (x === 0) {
|
||||
// if it's the first time positioning, it should always move right
|
||||
if (this.mutationRoot.width < midPoint) {
|
||||
dx = ltrX;
|
||||
} else if (this.mutationRoot.width < metrics.viewWidth) {
|
||||
dx = midPoint - ((metrics.viewWidth - this.mutationRoot.width) / 2);
|
||||
} else {
|
||||
dx = midPoint + (this.mutationRoot.width - metrics.viewWidth);
|
||||
}
|
||||
this.mutationRoot.moveBy(dx, dy);
|
||||
this.setState({rtlOffset: this.mutationRoot.getRelativeToSurfaceXY().x});
|
||||
return;
|
||||
}
|
||||
if (this.mutationRoot.width > metrics.viewWidth) {
|
||||
dx = dx + this.mutationRoot.width - metrics.viewWidth;
|
||||
}
|
||||
} else {
|
||||
dx = (metrics.viewWidth / 2) - (this.mutationRoot.width / 2) - x;
|
||||
// If the procedure declaration is wider than the view width,
|
||||
// keep the right-hand side of the procedure in view.
|
||||
if (this.mutationRoot.width > metrics.viewWidth) {
|
||||
dx = metrics.viewWidth - this.mutationRoot.width - x;
|
||||
}
|
||||
}
|
||||
this.mutationRoot.moveBy(dx, dy);
|
||||
});
|
||||
this.mutationRoot.domToMutation(this.props.mutator);
|
||||
this.mutationRoot.initSvg();
|
||||
this.mutationRoot.render();
|
||||
this.setState({warp: this.mutationRoot.getWarp()});
|
||||
// Allow the initial events to run to position this block, then focus.
|
||||
setTimeout(() => {
|
||||
this.mutationRoot.focusLastEditor_();
|
||||
});
|
||||
}
|
||||
handleCancel () {
|
||||
this.props.onRequestClose();
|
||||
}
|
||||
handleOk () {
|
||||
const newMutation = this.mutationRoot ? this.mutationRoot.mutationToDom(true) : null;
|
||||
this.props.onRequestClose(newMutation);
|
||||
}
|
||||
handleAddLabel () {
|
||||
if (this.mutationRoot) {
|
||||
this.mutationRoot.addLabelExternal();
|
||||
}
|
||||
}
|
||||
handleAddBoolean () {
|
||||
if (this.mutationRoot) {
|
||||
this.mutationRoot.addBooleanExternal();
|
||||
}
|
||||
}
|
||||
handleAddTextNumber () {
|
||||
if (this.mutationRoot) {
|
||||
this.mutationRoot.addStringNumberExternal();
|
||||
}
|
||||
}
|
||||
handleToggleWarp () {
|
||||
if (this.mutationRoot) {
|
||||
const newWarp = !this.mutationRoot.getWarp();
|
||||
this.mutationRoot.setWarp(newWarp);
|
||||
this.setState({warp: newWarp});
|
||||
}
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<CustomProceduresComponent
|
||||
componentRef={this.setBlocks}
|
||||
warp={this.state.warp}
|
||||
onAddBoolean={this.handleAddBoolean}
|
||||
onAddLabel={this.handleAddLabel}
|
||||
onAddTextNumber={this.handleAddTextNumber}
|
||||
onCancel={this.handleCancel}
|
||||
onOk={this.handleOk}
|
||||
onToggleWarp={this.handleToggleWarp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CustomProcedures.propTypes = {
|
||||
isRtl: PropTypes.bool,
|
||||
mutator: PropTypes.instanceOf(Element),
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
options: PropTypes.shape({
|
||||
media: PropTypes.string,
|
||||
zoom: PropTypes.shape({
|
||||
controls: PropTypes.bool,
|
||||
wheel: PropTypes.bool,
|
||||
startScale: PropTypes.number
|
||||
}),
|
||||
comments: PropTypes.bool,
|
||||
collapse: PropTypes.bool
|
||||
})
|
||||
};
|
||||
|
||||
CustomProcedures.defaultOptions = {
|
||||
zoom: {
|
||||
controls: false,
|
||||
wheel: false,
|
||||
startScale: 0.9
|
||||
},
|
||||
comments: false,
|
||||
collapse: false,
|
||||
scrollbars: true
|
||||
};
|
||||
|
||||
CustomProcedures.defaultProps = {
|
||||
options: CustomProcedures.defaultOptions
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isRtl: state.locales.isRtl,
|
||||
mutator: state.scratchGui.customProcedures.mutator
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps
|
||||
)(CustomProcedures);
|
||||
70
scratch-gui/src/containers/deletion-restorer.jsx
Normal file
70
scratch-gui/src/containers/deletion-restorer.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {setRestore} from '../reducers/restore-deletion';
|
||||
|
||||
/**
|
||||
* DeletionRestorer component passes a restoreDeletion function to its child.
|
||||
* It expects this child to be a function with the signature
|
||||
* function (restoreDeletion, props) {}
|
||||
* The component can then be used to attach deletion restoring functionality
|
||||
* to any other component:
|
||||
*
|
||||
* <DeletionRestorer>{(restoreDeletion, props) => (
|
||||
* <MyCoolComponent
|
||||
* onClick={restoreDeletion}
|
||||
* {...props}
|
||||
* />
|
||||
* )}</DeletionRestorer>
|
||||
*/
|
||||
class DeletionRestorer extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'restoreDeletion'
|
||||
]);
|
||||
}
|
||||
restoreDeletion () {
|
||||
if (typeof this.props.restore === 'function') {
|
||||
this.props.restore();
|
||||
this.props.dispatchUpdateRestore({restoreFun: null, deletedItem: ''});
|
||||
}
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
children,
|
||||
dispatchUpdateRestore,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...props
|
||||
} = this.props;
|
||||
const restorable = typeof this.props.restore === 'function';
|
||||
return this.props.children(this.restoreDeletion, {
|
||||
...props,
|
||||
restorable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DeletionRestorer.propTypes = {
|
||||
children: PropTypes.func,
|
||||
deletedItem: PropTypes.string,
|
||||
dispatchUpdateRestore: PropTypes.func,
|
||||
restore: PropTypes.func
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
deletedItem: state.scratchGui.restoreDeletion.deletedItem,
|
||||
restore: state.scratchGui.restoreDeletion.restoreFun
|
||||
});
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
dispatchUpdateRestore: updatedState => {
|
||||
dispatch(setRestore(updatedState));
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(DeletionRestorer);
|
||||
64
scratch-gui/src/containers/direction-picker.jsx
Normal file
64
scratch-gui/src/containers/direction-picker.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import DirectionComponent, {RotationStyles} from '../components/direction-picker/direction-picker.jsx';
|
||||
|
||||
class DirectionPicker extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleOpenPopover',
|
||||
'handleClosePopover',
|
||||
'handleClickLeftRight',
|
||||
'handleClickDontRotate',
|
||||
'handleClickAllAround'
|
||||
]);
|
||||
this.state = {
|
||||
popoverOpen: false
|
||||
};
|
||||
}
|
||||
handleOpenPopover () {
|
||||
this.setState({popoverOpen: true});
|
||||
}
|
||||
handleClosePopover () {
|
||||
this.setState({popoverOpen: false});
|
||||
}
|
||||
handleClickAllAround () {
|
||||
this.props.onChangeRotationStyle(RotationStyles.ALL_AROUND);
|
||||
}
|
||||
handleClickLeftRight () {
|
||||
this.props.onChangeRotationStyle(RotationStyles.LEFT_RIGHT);
|
||||
}
|
||||
handleClickDontRotate () {
|
||||
this.props.onChangeRotationStyle(RotationStyles.DONT_ROTATE);
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<DirectionComponent
|
||||
direction={this.props.direction}
|
||||
disabled={this.props.disabled}
|
||||
labelAbove={this.props.labelAbove}
|
||||
popoverOpen={this.state.popoverOpen && !this.props.disabled}
|
||||
rotationStyle={this.props.rotationStyle}
|
||||
onChangeDirection={this.props.onChangeDirection}
|
||||
onClickAllAround={this.handleClickAllAround}
|
||||
onClickDontRotate={this.handleClickDontRotate}
|
||||
onClickLeftRight={this.handleClickLeftRight}
|
||||
onClosePopover={this.handleClosePopover}
|
||||
onOpenPopover={this.handleOpenPopover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DirectionPicker.propTypes = {
|
||||
direction: PropTypes.number,
|
||||
disabled: PropTypes.bool,
|
||||
labelAbove: PropTypes.bool,
|
||||
onChangeDirection: PropTypes.func,
|
||||
onChangeRotationStyle: PropTypes.func,
|
||||
rotationStyle: PropTypes.string
|
||||
};
|
||||
|
||||
export default DirectionPicker;
|
||||
53
scratch-gui/src/containers/dom-element-renderer.jsx
Normal file
53
scratch-gui/src/containers/dom-element-renderer.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import omit from 'lodash.omit';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Style from 'to-style';
|
||||
import stylePropType from 'react-style-proptype';
|
||||
|
||||
/*
|
||||
* DOMElementRenderer wraps a DOM element, allowing it to be
|
||||
* rendered by React. It's up to the containing component
|
||||
* to retain a reference to the element prop, or else it
|
||||
* will be garbage collected after unmounting.
|
||||
*
|
||||
* Props passed to the DOMElementRenderer will be set on the
|
||||
* DOM element like it's a normal component.
|
||||
*/
|
||||
class DOMElementRenderer extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.setContainer = this.setContainer.bind(this);
|
||||
}
|
||||
componentDidMount () {
|
||||
this.container.appendChild(this.props.domElement);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.container.removeChild(this.props.domElement);
|
||||
}
|
||||
setContainer (c) {
|
||||
this.container = c;
|
||||
}
|
||||
render () {
|
||||
// Apply props to the DOM element, so its attributes
|
||||
// are updated as if it were a normal component.
|
||||
// Look at me, I'm the React now!
|
||||
Object.assign(
|
||||
this.props.domElement,
|
||||
omit(this.props, ['domElement', 'children', 'style'])
|
||||
);
|
||||
|
||||
// Convert react style prop to dom element styling.
|
||||
if (this.props.style) {
|
||||
this.props.domElement.style.cssText = Style.string(this.props.style);
|
||||
}
|
||||
|
||||
return <div ref={this.setContainer} />;
|
||||
}
|
||||
}
|
||||
|
||||
DOMElementRenderer.propTypes = {
|
||||
domElement: PropTypes.instanceOf(Element).isRequired,
|
||||
style: stylePropType
|
||||
};
|
||||
|
||||
export default DOMElementRenderer;
|
||||
10
scratch-gui/src/containers/drag-layer.jsx
Normal file
10
scratch-gui/src/containers/drag-layer.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import {connect} from 'react-redux';
|
||||
import DragLayer from '../components/drag-layer/drag-layer.jsx';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
dragging: state.scratchGui.assetDrag.dragging,
|
||||
currentOffset: state.scratchGui.assetDrag.currentOffset,
|
||||
img: state.scratchGui.assetDrag.img
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(DragLayer);
|
||||
95
scratch-gui/src/containers/error-boundary.jsx
Normal file
95
scratch-gui/src/containers/error-boundary.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import CrashMessageComponent from '../components/crash-message/crash-message.jsx';
|
||||
import log from '../lib/log.js';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error caught by this ErrorBoundary component.
|
||||
* @param {Error} error - the error that was caught.
|
||||
* @param {React.ErrorInfo} errorInfo - the React error info associated with the error.
|
||||
*/
|
||||
componentDidCatch (error, errorInfo) {
|
||||
// Error object may be undefined (IE?)
|
||||
error = error || {
|
||||
stack: 'Unknown stack',
|
||||
message: 'Unknown error'
|
||||
};
|
||||
errorInfo = errorInfo || {
|
||||
componentStack: 'Unknown component stack'
|
||||
};
|
||||
|
||||
// only remember the first error: later errors might just be side effects of that first one
|
||||
if (!this.state.error) {
|
||||
// store error & errorInfo for debugging
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo
|
||||
});
|
||||
}
|
||||
|
||||
// report every error in the console
|
||||
log.error([
|
||||
`Unhandled Error with action='${this.props.action}': ${error.stack}`,
|
||||
`Component stack: ${errorInfo.componentStack}`
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
handleBack () {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
handleReload () {
|
||||
window.location.replace(window.location.origin + window.location.pathname);
|
||||
}
|
||||
|
||||
formatErrorMessage () {
|
||||
let message = '';
|
||||
|
||||
if (this.state.error) {
|
||||
message += `${this.state.error}`;
|
||||
} else {
|
||||
message += 'Unknown error';
|
||||
}
|
||||
|
||||
if (this.state.errorInfo) {
|
||||
const firstCoupleLines = this.state
|
||||
.errorInfo
|
||||
.componentStack
|
||||
.trim()
|
||||
.split('\n')
|
||||
.slice(0, 2)
|
||||
.map(i => i.trim());
|
||||
message += `\nComponent stack: ${firstCoupleLines.join(' ')} ...`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<CrashMessageComponent
|
||||
errorMessage={this.formatErrorMessage()}
|
||||
onReload={this.handleReload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
ErrorBoundary.propTypes = {
|
||||
action: PropTypes.string.isRequired, // Used for defining tracking action
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
205
scratch-gui/src/containers/extension-library.jsx
Normal file
205
scratch-gui/src/containers/extension-library.jsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import VM from 'scratch-vm';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
import log from '../lib/log';
|
||||
|
||||
import extensionLibraryContent, {
|
||||
galleryError,
|
||||
galleryLoading,
|
||||
galleryMore
|
||||
} from '../lib/libraries/extensions/index.jsx';
|
||||
import extensionTags from '../lib/libraries/tw-extension-tags';
|
||||
|
||||
import LibraryComponent from '../components/library/library.jsx';
|
||||
import extensionIcon from '../components/action-menu/icon--sprite.svg';
|
||||
|
||||
const messages = defineMessages({
|
||||
extensionTitle: {
|
||||
defaultMessage: 'Choose an Extension',
|
||||
description: 'Heading for the extension library',
|
||||
id: 'gui.extensionLibrary.chooseAnExtension'
|
||||
}
|
||||
});
|
||||
|
||||
const toLibraryItem = extension => {
|
||||
if (typeof extension === 'object') {
|
||||
return ({
|
||||
rawURL: extension.iconURL || extensionIcon,
|
||||
...extension
|
||||
});
|
||||
}
|
||||
return extension;
|
||||
};
|
||||
|
||||
const translateGalleryItem = (extension, locale) => ({
|
||||
...extension,
|
||||
name: extension.nameTranslations[locale] || extension.name,
|
||||
description: extension.descriptionTranslations[locale] || extension.description
|
||||
});
|
||||
|
||||
let cachedGallery = null;
|
||||
|
||||
const fetchLibrary = async () => {
|
||||
const res = await fetch('https://extensions.turbowarp.org/generated-metadata/extensions-v0.json');
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP status ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.extensions.map(extension => ({
|
||||
name: extension.name,
|
||||
nameTranslations: extension.nameTranslations || {},
|
||||
description: extension.description,
|
||||
descriptionTranslations: extension.descriptionTranslations || {},
|
||||
extensionId: extension.id,
|
||||
extensionURL: `https://extensions.turbowarp.org/${extension.slug}.js`,
|
||||
iconURL: `https://extensions.turbowarp.org/${extension.image || 'images/unknown.svg'}`,
|
||||
tags: ['tw'],
|
||||
credits: [
|
||||
...(extension.by || []),
|
||||
...(extension.original || [])
|
||||
].map(credit => {
|
||||
if (credit.link) {
|
||||
return (
|
||||
<a
|
||||
href={credit.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
key={credit.name}
|
||||
>
|
||||
{credit.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return credit.name;
|
||||
}),
|
||||
docsURI: extension.docs ? `https://extensions.turbowarp.org/${extension.slug}` : null,
|
||||
samples: extension.samples ? extension.samples.map(sample => ({
|
||||
href: `${process.env.ROOT}editor?project_url=https://extensions.turbowarp.org/samples/${encodeURIComponent(sample)}.sb3`,
|
||||
text: sample
|
||||
})) : null,
|
||||
incompatibleWithScratch: true,
|
||||
featured: true
|
||||
}));
|
||||
};
|
||||
|
||||
class ExtensionLibrary extends React.PureComponent {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleItemSelect'
|
||||
]);
|
||||
this.state = {
|
||||
gallery: cachedGallery,
|
||||
galleryError: null,
|
||||
galleryTimedOut: false
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
if (!this.state.gallery) {
|
||||
const timeout = setTimeout(() => {
|
||||
this.setState({
|
||||
galleryTimedOut: true
|
||||
});
|
||||
}, 750);
|
||||
|
||||
fetchLibrary()
|
||||
.then(gallery => {
|
||||
cachedGallery = gallery;
|
||||
this.setState({
|
||||
gallery
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
})
|
||||
.catch(error => {
|
||||
log.error(error);
|
||||
this.setState({
|
||||
galleryError: error
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
}
|
||||
}
|
||||
handleItemSelect (item) {
|
||||
if (item.href) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extensionId = item.extensionId;
|
||||
|
||||
if (extensionId === 'custom_extension') {
|
||||
this.props.onOpenCustomExtensionModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (extensionId === 'procedures_enable_return') {
|
||||
this.props.onEnableProcedureReturns();
|
||||
this.props.onCategorySelected('myBlocks');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = item.extensionURL ? item.extensionURL : extensionId;
|
||||
if (!item.disabled) {
|
||||
if (this.props.vm.extensionManager.isExtensionLoaded(extensionId)) {
|
||||
this.props.onCategorySelected(extensionId);
|
||||
} else {
|
||||
this.props.vm.extensionManager.loadExtensionURL(url)
|
||||
.then(() => {
|
||||
this.props.onCategorySelected(extensionId);
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(err);
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
render () {
|
||||
let library = null;
|
||||
if (this.state.gallery || this.state.galleryError || this.state.galleryTimedOut) {
|
||||
library = extensionLibraryContent.map(toLibraryItem);
|
||||
library.push('---');
|
||||
if (this.state.gallery) {
|
||||
library.push(toLibraryItem(galleryMore));
|
||||
const locale = this.props.intl.locale;
|
||||
library.push(
|
||||
...this.state.gallery
|
||||
.map(i => translateGalleryItem(i, locale))
|
||||
.map(toLibraryItem)
|
||||
);
|
||||
} else if (this.state.galleryError) {
|
||||
library.push(toLibraryItem(galleryError));
|
||||
} else {
|
||||
library.push(toLibraryItem(galleryLoading));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LibraryComponent
|
||||
data={library}
|
||||
filterable
|
||||
persistableKey="extensionId"
|
||||
id="extensionLibrary"
|
||||
tags={extensionTags}
|
||||
title={this.props.intl.formatMessage(messages.extensionTitle)}
|
||||
visible={this.props.visible}
|
||||
onItemSelected={this.handleItemSelect}
|
||||
onRequestClose={this.props.onRequestClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ExtensionLibrary.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onCategorySelected: PropTypes.func,
|
||||
onEnableProcedureReturns: PropTypes.func,
|
||||
onOpenCustomExtensionModal: PropTypes.func,
|
||||
onRequestClose: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
vm: PropTypes.instanceOf(VM).isRequired // eslint-disable-line react/no-unused-prop-types
|
||||
};
|
||||
|
||||
export default injectIntl(ExtensionLibrary);
|
||||
65
scratch-gui/src/containers/green-flag-overlay.jsx
Normal file
65
scratch-gui/src/containers/green-flag-overlay.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import VM from 'scratch-vm';
|
||||
import Box from '../components/box/box.jsx';
|
||||
import greenFlag from '../components/green-flag/icon--green-flag.svg';
|
||||
import {setStartedState} from '../reducers/vm-status.js';
|
||||
|
||||
class GreenFlagOverlay extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleClick'
|
||||
]);
|
||||
}
|
||||
|
||||
handleClick () {
|
||||
this.props.vm.start();
|
||||
this.props.vm.greenFlag();
|
||||
|
||||
// FIXME: some unknown edge cases are causing start() to be called but for the
|
||||
// RUNTIME_STARTED listener to not update redux, causing this to always be
|
||||
// shown and never go away. this is a temporary hack to avoid that...
|
||||
this.props.onStarted();
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Box
|
||||
className={this.props.wrapperClass}
|
||||
onClick={this.handleClick}
|
||||
>
|
||||
<div className={this.props.className}>
|
||||
<img
|
||||
draggable={false}
|
||||
src={greenFlag}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GreenFlagOverlay.propTypes = {
|
||||
className: PropTypes.string,
|
||||
vm: PropTypes.instanceOf(VM),
|
||||
wrapperClass: PropTypes.string,
|
||||
onStarted: PropTypes.func
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onStarted: () => dispatch(setStartedState(true))
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(GreenFlagOverlay);
|
||||
219
scratch-gui/src/containers/gui.jsx
Normal file
219
scratch-gui/src/containers/gui.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {compose} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
import ReactModal from 'react-modal';
|
||||
import VM from 'scratch-vm';
|
||||
import {injectIntl, intlShape} from 'react-intl';
|
||||
|
||||
import ErrorBoundaryHOC from '../lib/error-boundary-hoc.jsx';
|
||||
import {
|
||||
getIsError,
|
||||
getIsShowingProject
|
||||
} from '../reducers/project-state';
|
||||
import {
|
||||
activateTab,
|
||||
BLOCKS_TAB_INDEX,
|
||||
COSTUMES_TAB_INDEX,
|
||||
SOUNDS_TAB_INDEX
|
||||
} from '../reducers/editor-tab';
|
||||
|
||||
import {
|
||||
closeCostumeLibrary,
|
||||
closeBackdropLibrary,
|
||||
closeTelemetryModal,
|
||||
openExtensionLibrary
|
||||
} from '../reducers/modals';
|
||||
|
||||
import FontLoaderHOC from '../lib/font-loader-hoc.jsx';
|
||||
import LocalizationHOC from '../lib/localization-hoc.jsx';
|
||||
import SBFileUploaderHOC from '../lib/sb-file-uploader-hoc.jsx';
|
||||
import ProjectFetcherHOC from '../lib/project-fetcher-hoc.jsx';
|
||||
import TitledHOC from '../lib/titled-hoc.jsx';
|
||||
import ProjectSaverHOC from '../lib/project-saver-hoc.jsx';
|
||||
import storage from '../lib/storage';
|
||||
import vmListenerHOC from '../lib/vm-listener-hoc.jsx';
|
||||
import vmManagerHOC from '../lib/vm-manager-hoc.jsx';
|
||||
import cloudManagerHOC from '../lib/cloud-manager-hoc.jsx';
|
||||
|
||||
import GUIComponent from '../components/gui/gui.jsx';
|
||||
import {setIsScratchDesktop} from '../lib/isScratchDesktop.js';
|
||||
import TWFullScreenResizerHOC from '../lib/tw-fullscreen-resizer-hoc.jsx';
|
||||
import TWThemeManagerHOC from './tw-theme-manager-hoc.jsx';
|
||||
|
||||
const {RequestMetadata, setMetadata, unsetMetadata} = storage.scratchFetch;
|
||||
|
||||
const setProjectIdMetadata = projectId => {
|
||||
// If project ID is '0' or zero, it's not a real project ID. In that case, remove the project ID metadata.
|
||||
// Same if it's null undefined.
|
||||
if (projectId && projectId !== '0') {
|
||||
setMetadata(RequestMetadata.ProjectId, projectId);
|
||||
} else {
|
||||
unsetMetadata(RequestMetadata.ProjectId);
|
||||
}
|
||||
};
|
||||
|
||||
class GUI extends React.Component {
|
||||
componentDidMount () {
|
||||
setIsScratchDesktop(this.props.isScratchDesktop);
|
||||
this.props.onStorageInit(storage);
|
||||
this.props.onVmInit(this.props.vm);
|
||||
setProjectIdMetadata(this.props.projectId);
|
||||
}
|
||||
componentDidUpdate (prevProps) {
|
||||
if (this.props.projectId !== prevProps.projectId) {
|
||||
if (this.props.projectId !== null) {
|
||||
this.props.onUpdateProjectId(this.props.projectId);
|
||||
}
|
||||
setProjectIdMetadata(this.props.projectId);
|
||||
}
|
||||
if (this.props.isShowingProject && !prevProps.isShowingProject) {
|
||||
// this only notifies container when a project changes from not yet loaded to loaded
|
||||
// At this time the project view in www doesn't need to know when a project is unloaded
|
||||
this.props.onProjectLoaded();
|
||||
}
|
||||
}
|
||||
render () {
|
||||
if (this.props.isError) {
|
||||
throw this.props.error;
|
||||
}
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
assetHost,
|
||||
cloudHost,
|
||||
error,
|
||||
isError,
|
||||
isScratchDesktop,
|
||||
isShowingProject,
|
||||
onProjectLoaded,
|
||||
onStorageInit,
|
||||
onUpdateProjectId,
|
||||
onVmInit,
|
||||
projectHost,
|
||||
projectId,
|
||||
/* eslint-enable no-unused-vars */
|
||||
children,
|
||||
fetchingProject,
|
||||
isLoading,
|
||||
loadingStateVisible,
|
||||
...componentProps
|
||||
} = this.props;
|
||||
return (
|
||||
<GUIComponent
|
||||
loading={fetchingProject || isLoading || loadingStateVisible}
|
||||
{...componentProps}
|
||||
>
|
||||
{children}
|
||||
</GUIComponent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GUI.propTypes = {
|
||||
assetHost: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
cloudHost: PropTypes.string,
|
||||
error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
|
||||
fetchingProject: PropTypes.bool,
|
||||
intl: intlShape,
|
||||
isError: PropTypes.bool,
|
||||
isEmbedded: PropTypes.bool,
|
||||
isFullScreen: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
isScratchDesktop: PropTypes.bool,
|
||||
isShowingProject: PropTypes.bool,
|
||||
isTotallyNormal: PropTypes.bool,
|
||||
loadingStateVisible: PropTypes.bool,
|
||||
onProjectLoaded: PropTypes.func,
|
||||
onSeeCommunity: PropTypes.func,
|
||||
onStorageInit: PropTypes.func,
|
||||
onUpdateProjectId: PropTypes.func,
|
||||
onVmInit: PropTypes.func,
|
||||
projectHost: PropTypes.string,
|
||||
projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
telemetryModalVisible: PropTypes.bool,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
GUI.defaultProps = {
|
||||
isScratchDesktop: false,
|
||||
isTotallyNormal: false,
|
||||
onStorageInit: storageInstance => storageInstance.addOfficialScratchWebStores(),
|
||||
onProjectLoaded: () => {},
|
||||
onUpdateProjectId: () => {},
|
||||
onVmInit: (/* vm */) => {}
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const loadingState = state.scratchGui.projectState.loadingState;
|
||||
return {
|
||||
activeTabIndex: state.scratchGui.editorTab.activeTabIndex,
|
||||
alertsVisible: state.scratchGui.alerts.visible,
|
||||
backdropLibraryVisible: state.scratchGui.modals.backdropLibrary,
|
||||
blocksTabVisible: state.scratchGui.editorTab.activeTabIndex === BLOCKS_TAB_INDEX,
|
||||
cardsVisible: state.scratchGui.cards.visible,
|
||||
connectionModalVisible: state.scratchGui.modals.connectionModal,
|
||||
costumeLibraryVisible: state.scratchGui.modals.costumeLibrary,
|
||||
costumesTabVisible: state.scratchGui.editorTab.activeTabIndex === COSTUMES_TAB_INDEX,
|
||||
error: state.scratchGui.projectState.error,
|
||||
isError: getIsError(loadingState),
|
||||
isEmbedded: state.scratchGui.mode.isEmbedded,
|
||||
isFullScreen: state.scratchGui.mode.isFullScreen || state.scratchGui.mode.isEmbedded,
|
||||
isPlayerOnly: state.scratchGui.mode.isPlayerOnly,
|
||||
isRtl: state.locales.isRtl,
|
||||
isShowingProject: getIsShowingProject(loadingState),
|
||||
loadingStateVisible: state.scratchGui.modals.loadingProject,
|
||||
projectId: state.scratchGui.projectState.projectId,
|
||||
soundsTabVisible: state.scratchGui.editorTab.activeTabIndex === SOUNDS_TAB_INDEX,
|
||||
targetIsStage: (
|
||||
state.scratchGui.targets.stage &&
|
||||
state.scratchGui.targets.stage.id === state.scratchGui.targets.editingTarget
|
||||
),
|
||||
telemetryModalVisible: state.scratchGui.modals.telemetryModal,
|
||||
tipsLibraryVisible: state.scratchGui.modals.tipsLibrary,
|
||||
usernameModalVisible: state.scratchGui.modals.usernameModal,
|
||||
settingsModalVisible: state.scratchGui.modals.settingsModal,
|
||||
customExtensionModalVisible: state.scratchGui.modals.customExtensionModal,
|
||||
fontsModalVisible: state.scratchGui.modals.fontsModal,
|
||||
unknownPlatformModalVisible: state.scratchGui.modals.unknownPlatformModal,
|
||||
invalidProjectModalVisible: state.scratchGui.modals.invalidProjectModal,
|
||||
vm: state.scratchGui.vm
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onExtensionButtonClick: () => dispatch(openExtensionLibrary()),
|
||||
onActivateTab: tab => dispatch(activateTab(tab)),
|
||||
onActivateCostumesTab: () => dispatch(activateTab(COSTUMES_TAB_INDEX)),
|
||||
onActivateSoundsTab: () => dispatch(activateTab(SOUNDS_TAB_INDEX)),
|
||||
onRequestCloseBackdropLibrary: () => dispatch(closeBackdropLibrary()),
|
||||
onRequestCloseCostumeLibrary: () => dispatch(closeCostumeLibrary()),
|
||||
onRequestCloseTelemetryModal: () => dispatch(closeTelemetryModal())
|
||||
});
|
||||
|
||||
const ConnectedGUI = injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(GUI));
|
||||
|
||||
// note that redux's 'compose' function is just being used as a general utility to make
|
||||
// the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's
|
||||
// ability to compose reducers.
|
||||
const WrappedGui = compose(
|
||||
LocalizationHOC,
|
||||
ErrorBoundaryHOC('Top Level App'),
|
||||
TWThemeManagerHOC, // componentDidUpdate() needs to run very early for icons to update immediately
|
||||
TWFullScreenResizerHOC,
|
||||
FontLoaderHOC,
|
||||
// QueryParserHOC, // tw: HOC is unused
|
||||
ProjectFetcherHOC,
|
||||
TitledHOC,
|
||||
ProjectSaverHOC,
|
||||
vmListenerHOC,
|
||||
vmManagerHOC,
|
||||
SBFileUploaderHOC,
|
||||
cloudManagerHOC
|
||||
)(ConnectedGUI);
|
||||
|
||||
WrappedGui.setAppElement = ReactModal.setAppElement;
|
||||
export default WrappedGui;
|
||||
56
scratch-gui/src/containers/inline-messages.jsx
Normal file
56
scratch-gui/src/containers/inline-messages.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {
|
||||
filterInlineAlerts
|
||||
} from '../reducers/alerts';
|
||||
|
||||
import InlineMessageComponent from '../components/alerts/inline-message.jsx';
|
||||
|
||||
const InlineMessages = ({
|
||||
alertsList,
|
||||
className
|
||||
}) => {
|
||||
if (!alertsList) {
|
||||
return null;
|
||||
}
|
||||
// only display inline alerts here
|
||||
const inlineAlerts = filterInlineAlerts(alertsList);
|
||||
if (!inlineAlerts || !inlineAlerts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get first alert
|
||||
const firstInlineAlert = inlineAlerts[0];
|
||||
const {
|
||||
content,
|
||||
iconSpinner,
|
||||
level
|
||||
} = firstInlineAlert;
|
||||
|
||||
return (
|
||||
<InlineMessageComponent
|
||||
className={className}
|
||||
content={content}
|
||||
iconSpinner={iconSpinner}
|
||||
level={level}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
InlineMessages.propTypes = {
|
||||
alertsList: PropTypes.arrayOf(PropTypes.object),
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
alertsList: state.scratchGui.alerts.alertsList
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(InlineMessages);
|
||||
66
scratch-gui/src/containers/language-selector.jsx
Normal file
66
scratch-gui/src/containers/language-selector.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {selectLocale} from '../reducers/locales';
|
||||
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'
|
||||
]);
|
||||
document.documentElement.lang = props.currentLocale;
|
||||
}
|
||||
handleChange (e) {
|
||||
const newLocale = e.target.value;
|
||||
if (this.props.messagesByLocale[newLocale]) {
|
||||
this.props.onChangeLanguage(newLocale);
|
||||
document.documentElement.lang = newLocale;
|
||||
}
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
onChangeLanguage, // eslint-disable-line no-unused-vars
|
||||
messagesByLocale, // eslint-disable-line no-unused-vars
|
||||
children,
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<LanguageSelectorComponent
|
||||
onChange={this.handleChange}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</LanguageSelectorComponent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LanguageSelector.propTypes = {
|
||||
children: PropTypes.node,
|
||||
currentLocale: PropTypes.string.isRequired,
|
||||
// Only checking key presence for messagesByLocale, no need to be more specific than object
|
||||
messagesByLocale: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
onChangeLanguage: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
currentLocale: state.locales.locale,
|
||||
messagesByLocale: state.locales.messagesByLocale
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChangeLanguage: locale => {
|
||||
dispatch(selectLocale(locale));
|
||||
dispatch(closeLanguageMenu());
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LanguageSelector);
|
||||
227
scratch-gui/src/containers/library-item.jsx
Normal file
227
scratch-gui/src/containers/library-item.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {injectIntl, intlShape, defineMessages} from 'react-intl';
|
||||
|
||||
import LibraryItemComponent from '../components/library-item/library-item.jsx';
|
||||
|
||||
const messages = defineMessages({
|
||||
incompatible: {
|
||||
// eslint-disable-next-line max-len
|
||||
defaultMessage: 'This extension is incompatible with Scratch. Projects made with it cannot be uploaded to the Scratch website. Are you sure you want to enable it?',
|
||||
description: 'Confirm loading Scratch-incompatible extension',
|
||||
id: 'tw.confirmIncompatibleExtension'
|
||||
}
|
||||
});
|
||||
|
||||
class LibraryItem extends React.PureComponent {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleBlur',
|
||||
'handleClick',
|
||||
'handleFavorite',
|
||||
'handleFocus',
|
||||
'handleKeyPress',
|
||||
'handleMouseEnter',
|
||||
'handleMouseLeave',
|
||||
'handlePlay',
|
||||
'handleStop',
|
||||
'rotateIcon',
|
||||
'startRotatingIcons',
|
||||
'stopRotatingIcons'
|
||||
]);
|
||||
this.state = {
|
||||
iconIndex: 0,
|
||||
isRotatingIcon: false
|
||||
};
|
||||
}
|
||||
componentWillUnmount () {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
handleBlur (id) {
|
||||
this.handleMouseLeave(id);
|
||||
}
|
||||
handleClick (e) {
|
||||
if (e.target.closest('a')) {
|
||||
// Allow clicking on links inside the item
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.props.favorite &&
|
||||
this.props.incompatibleWithScratch &&
|
||||
!e.shiftKey &&
|
||||
// eslint-disable-next-line no-alert
|
||||
!confirm(this.props.intl.formatMessage(messages.incompatible))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.props.disabled) {
|
||||
if (this.props.href) {
|
||||
window.open(this.props.href);
|
||||
} else {
|
||||
this.props.onSelect(this.props.id);
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
handleFavorite (e) {
|
||||
e.stopPropagation();
|
||||
this.props.onFavorite(this.props.id);
|
||||
}
|
||||
handleFocus (id) {
|
||||
if (!this.props.showPlayButton) {
|
||||
this.handleMouseEnter(id);
|
||||
}
|
||||
}
|
||||
handleKeyPress (e) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.props.onSelect(this.props.id);
|
||||
}
|
||||
}
|
||||
handleMouseEnter () {
|
||||
// only show hover effects on the item if not showing a play button
|
||||
if (!this.props.showPlayButton) {
|
||||
this.props.onMouseEnter(this.props.id);
|
||||
if (this.props.icons && this.props.icons.length) {
|
||||
this.stopRotatingIcons();
|
||||
this.setState({
|
||||
isRotatingIcon: true
|
||||
}, this.startRotatingIcons);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleMouseLeave () {
|
||||
// only show hover effects on the item if not showing a play button
|
||||
if (!this.props.showPlayButton) {
|
||||
this.props.onMouseLeave(this.props.id);
|
||||
if (this.props.icons && this.props.icons.length) {
|
||||
this.setState({
|
||||
isRotatingIcon: false
|
||||
}, this.stopRotatingIcons);
|
||||
}
|
||||
}
|
||||
}
|
||||
handlePlay () {
|
||||
this.props.onMouseEnter(this.props.id);
|
||||
}
|
||||
handleStop () {
|
||||
this.props.onMouseLeave(this.props.id);
|
||||
}
|
||||
startRotatingIcons () {
|
||||
this.rotateIcon();
|
||||
this.intervalId = setInterval(this.rotateIcon, 300);
|
||||
}
|
||||
stopRotatingIcons () {
|
||||
if (this.intervalId) {
|
||||
this.intervalId = clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
rotateIcon () {
|
||||
const nextIconIndex = (this.state.iconIndex + 1) % this.props.icons.length;
|
||||
this.setState({iconIndex: nextIconIndex});
|
||||
}
|
||||
curIconMd5 () {
|
||||
const iconMd5Prop = this.props.iconMd5;
|
||||
if (this.props.icons &&
|
||||
this.state.isRotatingIcon &&
|
||||
this.state.iconIndex < this.props.icons.length) {
|
||||
const icon = this.props.icons[this.state.iconIndex] || {};
|
||||
return icon.md5ext || // 3.0 library format
|
||||
icon.baseLayerMD5 || // 2.0 library format, TODO GH-5084
|
||||
iconMd5Prop;
|
||||
}
|
||||
return iconMd5Prop;
|
||||
}
|
||||
render () {
|
||||
const iconMd5 = this.curIconMd5();
|
||||
const iconURL = iconMd5 ?
|
||||
`https://cdn.assets.scratch.mit.edu/internalapi/asset/${iconMd5}/get/` :
|
||||
this.props.iconRawURL;
|
||||
return (
|
||||
<LibraryItemComponent
|
||||
intl={this.props.intl}
|
||||
bluetoothRequired={this.props.bluetoothRequired}
|
||||
collaborator={this.props.collaborator}
|
||||
description={this.props.description}
|
||||
disabled={this.props.disabled}
|
||||
extensionId={this.props.extensionId}
|
||||
featured={this.props.featured}
|
||||
hidden={this.props.hidden}
|
||||
iconURL={iconURL}
|
||||
icons={this.props.icons}
|
||||
id={this.props.id}
|
||||
insetIconURL={this.props.insetIconURL}
|
||||
internetConnectionRequired={this.props.internetConnectionRequired}
|
||||
isPlaying={this.props.isPlaying}
|
||||
name={this.props.name}
|
||||
credits={this.props.credits}
|
||||
docsURI={this.props.docsURI}
|
||||
samples={this.props.samples}
|
||||
favorite={this.props.favorite}
|
||||
onFavorite={this.handleFavorite}
|
||||
showPlayButton={this.props.showPlayButton}
|
||||
onBlur={this.handleBlur}
|
||||
onClick={this.handleClick}
|
||||
onFocus={this.handleFocus}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onPlay={this.handlePlay}
|
||||
onStop={this.handleStop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LibraryItem.propTypes = {
|
||||
intl: intlShape,
|
||||
bluetoothRequired: PropTypes.bool,
|
||||
collaborator: PropTypes.string,
|
||||
description: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.node
|
||||
]),
|
||||
disabled: PropTypes.bool,
|
||||
extensionId: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
featured: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
iconMd5: PropTypes.string,
|
||||
iconRawURL: PropTypes.string,
|
||||
icons: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
baseLayerMD5: PropTypes.string, // 2.0 library format, TODO GH-5084
|
||||
md5ext: PropTypes.string // 3.0 library format
|
||||
})
|
||||
),
|
||||
id: PropTypes.number.isRequired,
|
||||
incompatibleWithScratch: PropTypes.bool,
|
||||
insetIconURL: PropTypes.string,
|
||||
internetConnectionRequired: PropTypes.bool,
|
||||
isPlaying: PropTypes.bool,
|
||||
name: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.node
|
||||
]),
|
||||
credits: PropTypes.arrayOf(PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.node
|
||||
])),
|
||||
docsURI: PropTypes.string,
|
||||
samples: PropTypes.arrayOf(PropTypes.shape({
|
||||
href: PropTypes.string,
|
||||
text: PropTypes.string
|
||||
})),
|
||||
favorite: PropTypes.bool,
|
||||
onFavorite: PropTypes.func,
|
||||
onMouseEnter: PropTypes.func.isRequired,
|
||||
onMouseLeave: PropTypes.func.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
showPlayButton: PropTypes.bool
|
||||
};
|
||||
|
||||
export default injectIntl(LibraryItem);
|
||||
208
scratch-gui/src/containers/list-monitor.jsx
Normal file
208
scratch-gui/src/containers/list-monitor.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import VM from 'scratch-vm';
|
||||
import {connect} from 'react-redux';
|
||||
import {getEventXY} from '../lib/touch-utils';
|
||||
import {getVariableValue, setVariableValue} from '../lib/variable-utils';
|
||||
import ListMonitorComponent from '../components/monitor/list-monitor.jsx';
|
||||
import {Map} from 'immutable';
|
||||
|
||||
class ListMonitor extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleActivate',
|
||||
'handleDeactivate',
|
||||
'handleInput',
|
||||
'handleRemove',
|
||||
'handleKeyPress',
|
||||
'handleFocus',
|
||||
'handleAdd',
|
||||
'handleResizeMouseDown'
|
||||
]);
|
||||
|
||||
this.state = {
|
||||
activeIndex: null,
|
||||
activeValue: null,
|
||||
width: props.width || 100,
|
||||
height: props.height || 200
|
||||
};
|
||||
}
|
||||
|
||||
handleActivate (index) {
|
||||
// Do nothing if activating the currently active item
|
||||
if (this.state.activeIndex === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
activeIndex: index,
|
||||
activeValue: this.props.value[index]
|
||||
});
|
||||
}
|
||||
|
||||
handleDeactivate () {
|
||||
// Submit any in-progress value edits on blur
|
||||
if (this.state.activeIndex !== null) {
|
||||
const {vm, targetId, id: variableId} = this.props;
|
||||
const newListValue = getVariableValue(vm, targetId, variableId);
|
||||
newListValue[this.state.activeIndex] = this.state.activeValue;
|
||||
setVariableValue(vm, targetId, variableId, newListValue);
|
||||
this.setState({activeIndex: null, activeValue: null});
|
||||
}
|
||||
}
|
||||
|
||||
handleFocus (e) {
|
||||
// Select all the text in the input when it is focused.
|
||||
e.target.select();
|
||||
}
|
||||
|
||||
handleKeyPress (e) {
|
||||
// Special case for tab, arrow keys and enter.
|
||||
// Tab / shift+tab navigate down / up the list.
|
||||
// Arrow down / arrow up navigate down / up the list.
|
||||
// Enter / shift+enter insert new blank item below / above.
|
||||
const previouslyActiveIndex = this.state.activeIndex;
|
||||
const {vm, targetId, id: variableId} = this.props;
|
||||
|
||||
let navigateDirection = 0;
|
||||
if (e.key === 'Tab') navigateDirection = e.shiftKey ? -1 : 1;
|
||||
else if (e.key === 'ArrowUp') navigateDirection = -1;
|
||||
else if (e.key === 'ArrowDown') navigateDirection = 1;
|
||||
if (navigateDirection) {
|
||||
this.handleDeactivate(); // Submit in-progress edits
|
||||
const newIndex = this.wrapListIndex(previouslyActiveIndex + navigateDirection, this.props.value.length);
|
||||
this.setState({
|
||||
activeIndex: newIndex,
|
||||
activeValue: this.props.value[newIndex]
|
||||
});
|
||||
e.preventDefault(); // Stop default tab behavior, handled by this state change
|
||||
} else if (e.key === 'Enter') {
|
||||
this.handleDeactivate(); // Submit in-progress edits
|
||||
const newListItemValue = ''; // Enter adds a blank item
|
||||
const newValueOffset = e.shiftKey ? 0 : 1; // Shift-enter inserts above
|
||||
const listValue = getVariableValue(vm, targetId, variableId);
|
||||
const newListValue = listValue.slice(0, previouslyActiveIndex + newValueOffset)
|
||||
.concat([newListItemValue])
|
||||
.concat(listValue.slice(previouslyActiveIndex + newValueOffset));
|
||||
setVariableValue(vm, targetId, variableId, newListValue);
|
||||
const newIndex = this.wrapListIndex(previouslyActiveIndex + newValueOffset, newListValue.length);
|
||||
this.setState({
|
||||
activeIndex: newIndex,
|
||||
activeValue: newListItemValue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleInput (e) {
|
||||
this.setState({activeValue: e.target.value});
|
||||
}
|
||||
|
||||
handleRemove (e) {
|
||||
e.preventDefault(); // Default would blur input, prevent that.
|
||||
e.stopPropagation(); // Bubbling would activate, which will be handled here
|
||||
const {vm, targetId, id: variableId} = this.props;
|
||||
const listValue = getVariableValue(vm, targetId, variableId);
|
||||
const newListValue = listValue.slice(0, this.state.activeIndex)
|
||||
.concat(listValue.slice(this.state.activeIndex + 1));
|
||||
setVariableValue(vm, targetId, variableId, newListValue);
|
||||
const newActiveIndex = Math.min(newListValue.length - 1, this.state.activeIndex);
|
||||
this.setState({
|
||||
activeIndex: newActiveIndex,
|
||||
activeValue: newListValue[newActiveIndex]
|
||||
});
|
||||
}
|
||||
|
||||
handleAdd () {
|
||||
// Add button appends a blank value and switches to it
|
||||
const {vm, targetId, id: variableId} = this.props;
|
||||
const newListValue = getVariableValue(vm, targetId, variableId).concat(['']);
|
||||
setVariableValue(vm, targetId, variableId, newListValue);
|
||||
this.setState({activeIndex: newListValue.length - 1, activeValue: ''});
|
||||
}
|
||||
|
||||
handleResizeMouseDown (e) {
|
||||
this.initialPosition = getEventXY(e);
|
||||
this.initialWidth = this.state.width;
|
||||
this.initialHeight = this.state.height;
|
||||
|
||||
const onMouseMove = ev => {
|
||||
const newPosition = getEventXY(ev);
|
||||
const dx = newPosition.x - this.initialPosition.x;
|
||||
const dy = newPosition.y - this.initialPosition.y;
|
||||
this.setState({
|
||||
width: Math.max(Math.min(this.initialWidth + dx, this.props.customStageSize.width), 100),
|
||||
height: Math.max(Math.min(this.initialHeight + dy, this.props.customStageSize.height), 60)
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = ev => {
|
||||
onMouseMove(ev); // Make sure width/height are up-to-date
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
this.props.vm.runtime.requestUpdateMonitor(Map({
|
||||
id: this.props.id,
|
||||
height: this.state.height,
|
||||
width: this.state.width
|
||||
}));
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
}
|
||||
|
||||
wrapListIndex (index, length) {
|
||||
return (index + length) % length;
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
vm, // eslint-disable-line no-unused-vars
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<ListMonitorComponent
|
||||
{...props}
|
||||
activeIndex={this.state.activeIndex}
|
||||
activeValue={this.state.activeValue}
|
||||
height={this.state.height}
|
||||
width={this.state.width}
|
||||
onActivate={this.handleActivate}
|
||||
onAdd={this.handleAdd}
|
||||
onDeactivate={this.handleDeactivate}
|
||||
onFocus={this.handleFocus}
|
||||
onInput={this.handleInput}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onRemove={this.handleRemove}
|
||||
onResizeMouseDown={this.handleResizeMouseDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ListMonitor.propTypes = {
|
||||
height: PropTypes.number,
|
||||
id: PropTypes.string,
|
||||
customStageSize: PropTypes.shape({
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number
|
||||
}),
|
||||
targetId: PropTypes.string,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.number,
|
||||
PropTypes.string
|
||||
]),
|
||||
vm: PropTypes.instanceOf(VM),
|
||||
width: PropTypes.number,
|
||||
x: PropTypes.number,
|
||||
y: PropTypes.number
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
customStageSize: state.scratchGui.customStageSize,
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(ListMonitor);
|
||||
77
scratch-gui/src/containers/menu-bar-hoc.jsx
Normal file
77
scratch-gui/src/containers/menu-bar-hoc.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import {connect} from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import React from 'react';
|
||||
import SB3Downloader from './sb3-downloader.jsx';
|
||||
|
||||
const MenuBarHOC = function (WrappedComponent) {
|
||||
class MenuBarContainer extends React.PureComponent {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
bindAll(this, [
|
||||
'confirmReadyToReplaceProject',
|
||||
'shouldSaveBeforeTransition'
|
||||
]);
|
||||
}
|
||||
confirmReadyToReplaceProject (message) {
|
||||
let readyToReplaceProject = true;
|
||||
if (this.props.projectChanged && !this.props.canCreateNew) {
|
||||
readyToReplaceProject = this.props.confirmWithMessage(message);
|
||||
}
|
||||
return readyToReplaceProject;
|
||||
}
|
||||
shouldSaveBeforeTransition () {
|
||||
return (this.props.canSave && this.props.projectChanged);
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
projectChanged,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<SB3Downloader
|
||||
showSaveFilePicker={this.props.showSaveFilePicker}
|
||||
>
|
||||
{(_className, _downloadProject, extended) => (
|
||||
<WrappedComponent
|
||||
confirmReadyToReplaceProject={this.confirmReadyToReplaceProject}
|
||||
shouldSaveBeforeTransition={this.shouldSaveBeforeTransition}
|
||||
handleSaveProject={extended.smartSave}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</SB3Downloader>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarContainer.propTypes = {
|
||||
canCreateNew: PropTypes.bool,
|
||||
canSave: PropTypes.bool,
|
||||
confirmWithMessage: PropTypes.func,
|
||||
projectChanged: PropTypes.bool,
|
||||
showSaveFilePicker: PropTypes.func
|
||||
};
|
||||
MenuBarContainer.defaultProps = {
|
||||
// default to using standard js confirm
|
||||
confirmWithMessage: message => (confirm(message)) // eslint-disable-line no-alert
|
||||
};
|
||||
const mapStateToProps = state => ({
|
||||
projectChanged: state.scratchGui.projectChanged
|
||||
});
|
||||
const mapDispatchToProps = () => ({});
|
||||
// Allow incoming props to override redux-provided props. Used to mock in tests.
|
||||
const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign(
|
||||
{}, stateProps, dispatchProps, ownProps
|
||||
);
|
||||
return connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
mergeProps
|
||||
)(MenuBarContainer);
|
||||
};
|
||||
|
||||
export default MenuBarHOC;
|
||||
43
scratch-gui/src/containers/menu-item.jsx
Normal file
43
scratch-gui/src/containers/menu-item.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import {MenuItem as MenuItemComponent} from '../components/menu/menu.jsx';
|
||||
|
||||
class MenuItem extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'navigateToHref'
|
||||
]);
|
||||
}
|
||||
navigateToHref () {
|
||||
if (this.props.href) window.location.href = this.props.href;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
onClick
|
||||
} = this.props;
|
||||
const clickAction = onClick ? onClick : this.navigateToHref;
|
||||
return (
|
||||
<MenuItemComponent
|
||||
className={className}
|
||||
onClick={clickAction}
|
||||
>
|
||||
{children}
|
||||
</MenuItemComponent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MenuItem.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
// can take an onClick prop, or take an href and build an onClick handler
|
||||
href: PropTypes.string,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
export default MenuItem;
|
||||
19
scratch-gui/src/containers/menu.jsx
Normal file
19
scratch-gui/src/containers/menu.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import MenuComponent from '../components/menu/menu.jsx';
|
||||
|
||||
const Menu = ({open, children, ...props}) => (
|
||||
open ? (
|
||||
<MenuComponent {...props}>
|
||||
{children}
|
||||
</MenuComponent>
|
||||
) : null
|
||||
);
|
||||
|
||||
Menu.propTypes = {
|
||||
children: PropTypes.node,
|
||||
open: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
62
scratch-gui/src/containers/modal.jsx
Normal file
62
scratch-gui/src/containers/modal.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import ModalComponent from '../components/modal/modal.jsx';
|
||||
|
||||
class Modal extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'addEventListeners',
|
||||
'removeEventListeners',
|
||||
'handlePopState',
|
||||
'pushHistory'
|
||||
]);
|
||||
this.addEventListeners();
|
||||
}
|
||||
componentDidMount () {
|
||||
// Add a history event only if it's not currently for our modal. This
|
||||
// avoids polluting the history with many entries. We only need one.
|
||||
this.pushHistory(this.id, (history.state === null || history.state !== this.id));
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.removeEventListeners();
|
||||
}
|
||||
addEventListeners () {
|
||||
window.addEventListener('popstate', this.handlePopState);
|
||||
}
|
||||
removeEventListeners () {
|
||||
window.removeEventListener('popstate', this.handlePopState);
|
||||
}
|
||||
handlePopState () {
|
||||
// Whenever someone navigates, we want to be closed
|
||||
this.props.onRequestClose();
|
||||
}
|
||||
get id () {
|
||||
return `modal-${this.props.id}`;
|
||||
}
|
||||
pushHistory (state, push) {
|
||||
if (push) return history.pushState(state, this.id, null);
|
||||
history.replaceState(state, this.id, null);
|
||||
}
|
||||
render () {
|
||||
return <ModalComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
Modal.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
isRtl: PropTypes.bool,
|
||||
onRequestClose: PropTypes.func,
|
||||
onRequestOpen: PropTypes.func
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isRtl: state.locales.isRtl
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps
|
||||
)(Modal);
|
||||
78
scratch-gui/src/containers/monitor-list.jsx
Normal file
78
scratch-gui/src/containers/monitor-list.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {injectIntl, intlShape} from 'react-intl';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {moveMonitorRect, resetMonitorLayout} from '../reducers/monitor-layout';
|
||||
|
||||
import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx';
|
||||
import OpcodeLabels from '../lib/opcode-labels';
|
||||
|
||||
import MonitorListComponent from '../components/monitor-list/monitor-list.jsx';
|
||||
|
||||
class MonitorList extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleMonitorChange'
|
||||
]);
|
||||
OpcodeLabels.setTranslatorFunction(props.intl.formatMessage);
|
||||
this.state = {
|
||||
key: 0
|
||||
};
|
||||
}
|
||||
componentWillReceiveProps (nextProps) {
|
||||
// TW: When stage size changes, we'll force all monitors to re-render completely
|
||||
// This is important because the VM moves monitors after resize to preserve locations but
|
||||
// Scratch's monitor layout logic is very complex and it won't notice that
|
||||
if (this.props.customStageSize !== nextProps.customStageSize) {
|
||||
this.props.resetMonitorLayout();
|
||||
this.setState({
|
||||
key: this.state.key + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
handleMonitorChange (id, x, y) { // eslint-disable-line no-unused-vars
|
||||
this.props.moveMonitorRect(id, x, y);
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<MonitorListComponent
|
||||
onMonitorChange={this.handleMonitorChange}
|
||||
key={this.state.key}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MonitorList.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
customStageSize: PropTypes.shape({
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number
|
||||
}),
|
||||
monitorLayout: PropTypes.shape({
|
||||
monitors: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
savedMonitorPositions: PropTypes.object // eslint-disable-line react/forbid-prop-types
|
||||
}).isRequired,
|
||||
moveMonitorRect: PropTypes.func.isRequired,
|
||||
resetMonitorLayout: PropTypes.func
|
||||
};
|
||||
const mapStateToProps = state => ({
|
||||
customStageSize: state.scratchGui.customStageSize,
|
||||
monitors: state.scratchGui.monitors,
|
||||
monitorLayout: state.scratchGui.monitorLayout
|
||||
});
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
moveMonitorRect: (id, x, y) => dispatch(moveMonitorRect(id, x, y)),
|
||||
resetMonitorLayout: () => dispatch(resetMonitorLayout())
|
||||
});
|
||||
|
||||
export default errorBoundaryHOC('Monitors')(
|
||||
injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(MonitorList))
|
||||
);
|
||||
301
scratch-gui/src/containers/monitor.jsx
Normal file
301
scratch-gui/src/containers/monitor.jsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {injectIntl, intlShape, defineMessages} from 'react-intl';
|
||||
|
||||
import monitorAdapter from '../lib/monitor-adapter.js';
|
||||
import MonitorComponent, {monitorModes} from '../components/monitor/monitor.jsx';
|
||||
import {addMonitorRect, getInitialPosition, resizeMonitorRect, removeMonitorRect} from '../reducers/monitor-layout';
|
||||
import {getVariable, setVariableValue} from '../lib/variable-utils';
|
||||
import importCSV from '../lib/import-csv';
|
||||
import downloadBlob from '../lib/download-blob';
|
||||
import {Theme} from '../lib/themes';
|
||||
import SliderPrompt from './slider-prompt.jsx';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {Map} from 'immutable';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
const availableModes = opcode => (
|
||||
monitorModes.filter(t => {
|
||||
if (opcode === 'data_variable') {
|
||||
return t !== 'list';
|
||||
} else if (opcode === 'data_listcontents') {
|
||||
return t === 'list';
|
||||
}
|
||||
return t !== 'slider' && t !== 'list';
|
||||
})
|
||||
);
|
||||
|
||||
const messages = defineMessages({
|
||||
columnPrompt: {
|
||||
defaultMessage: 'Which column should be used (1-{numberOfColumns})?',
|
||||
description: 'Prompt for which column should be used',
|
||||
id: 'gui.monitors.importListColumnPrompt'
|
||||
}
|
||||
});
|
||||
|
||||
class Monitor extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleDragEnd',
|
||||
'handleHide',
|
||||
'handleNextMode',
|
||||
'handleSetModeToDefault',
|
||||
'handleSetModeToLarge',
|
||||
'handleSetModeToSlider',
|
||||
'handleSliderPromptClose',
|
||||
'handleSliderPromptOk',
|
||||
'handleSliderPromptOpen',
|
||||
'handleImport',
|
||||
'handleExport',
|
||||
'setElement'
|
||||
]);
|
||||
this.state = {
|
||||
sliderPrompt: false
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
let rect;
|
||||
|
||||
const isNum = num => typeof num === 'number' && !isNaN(num);
|
||||
|
||||
// Load the VM provided position if not loaded already
|
||||
// If a monitor has numbers for the x and y positions, load the saved position.
|
||||
// Otherwise, auto-position the monitor.
|
||||
if (isNum(this.props.x) && isNum(this.props.y) &&
|
||||
!this.props.monitorLayout.savedMonitorPositions[this.props.id]) {
|
||||
rect = {
|
||||
upperStart: {x: this.props.x, y: this.props.y},
|
||||
lowerEnd: {x: this.props.x + this.element.offsetWidth, y: this.props.y + this.element.offsetHeight}
|
||||
};
|
||||
this.props.addMonitorRect(this.props.id, rect, true /* savePosition */);
|
||||
} else { // Newly created user monitor
|
||||
rect = getInitialPosition(
|
||||
this.props.monitorLayout, this.props.id, this.element.offsetWidth, this.element.offsetHeight);
|
||||
this.props.addMonitorRect(this.props.id, rect);
|
||||
this.props.vm.runtime.requestUpdateMonitor(Map({
|
||||
id: this.props.id,
|
||||
x: rect.upperStart.x,
|
||||
y: rect.upperStart.y
|
||||
}));
|
||||
}
|
||||
this.element.style.top = `${rect.upperStart.y}px`;
|
||||
this.element.style.left = `${rect.upperStart.x}px`;
|
||||
}
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
if (nextState !== this.state) {
|
||||
return true;
|
||||
}
|
||||
for (const key of Object.getOwnPropertyNames(nextProps)) {
|
||||
// Don't need to rerender when other monitors are moved.
|
||||
// monitorLayout is only used during initial layout.
|
||||
if (key !== 'monitorLayout' && nextProps[key] !== this.props[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
componentDidUpdate () {
|
||||
// tw: if monitor is not draggable (ie. not in editor), do not calculate size of monitor for performance
|
||||
if (!this.props.draggable) {
|
||||
return;
|
||||
}
|
||||
this.props.resizeMonitorRect(this.props.id, this.element.offsetWidth, this.element.offsetHeight);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.props.removeMonitorRect(this.props.id);
|
||||
}
|
||||
handleDragEnd (e, {x, y}) {
|
||||
const newX = parseInt(this.element.style.left, 10) + x;
|
||||
const newY = parseInt(this.element.style.top, 10) + y;
|
||||
this.props.onDragEnd(
|
||||
this.props.id,
|
||||
newX,
|
||||
newY
|
||||
);
|
||||
this.props.vm.runtime.requestUpdateMonitor(Map({
|
||||
id: this.props.id,
|
||||
x: newX,
|
||||
y: newY
|
||||
}));
|
||||
}
|
||||
handleHide () {
|
||||
this.props.vm.runtime.requestUpdateMonitor(Map({
|
||||
id: this.props.id,
|
||||
visible: false
|
||||
}));
|
||||
}
|
||||
handleNextMode () {
|
||||
const modes = availableModes(this.props.opcode);
|
||||
const modeIndex = modes.indexOf(this.props.mode);
|
||||
const newMode = modes[(modeIndex + 1) % modes.length];
|
||||
this.props.vm.runtime.requestUpdateMonitor(Map({
|
||||
id: this.props.id,
|
||||
mode: newMode
|
||||
}));
|
||||
}
|
||||
handleSetModeToDefault () {
|
||||
this.props.vm.runtime.requestUpdateMonitor(Map({
|
||||
id: this.props.id,
|
||||
mode: 'default'
|
||||
}));
|
||||
}
|
||||
handleSetModeToLarge () {
|
||||
this.props.vm.runtime.requestUpdateMonitor(Map({
|
||||
id: this.props.id,
|
||||
mode: 'large'
|
||||
}));
|
||||
}
|
||||
handleSetModeToSlider () {
|
||||
this.props.vm.runtime.requestUpdateMonitor(Map({
|
||||
id: this.props.id,
|
||||
mode: 'slider'
|
||||
}));
|
||||
}
|
||||
handleSliderPromptClose () {
|
||||
this.setState({sliderPrompt: false});
|
||||
}
|
||||
handleSliderPromptOpen () {
|
||||
this.setState({sliderPrompt: true});
|
||||
}
|
||||
handleSliderPromptOk (min, max, isDiscrete) {
|
||||
const realMin = Math.min(min, max);
|
||||
const realMax = Math.max(min, max);
|
||||
this.props.vm.runtime.requestUpdateMonitor(Map({
|
||||
id: this.props.id,
|
||||
sliderMin: realMin,
|
||||
sliderMax: realMax,
|
||||
isDiscrete: isDiscrete
|
||||
}));
|
||||
this.handleSliderPromptClose();
|
||||
}
|
||||
setElement (monitorElt) {
|
||||
this.element = monitorElt;
|
||||
}
|
||||
handleImport () {
|
||||
importCSV().then(async ({rows, text}) => {
|
||||
const numberOfColumns = rows[0].length;
|
||||
let columnNumber = 1;
|
||||
if (numberOfColumns > 1) {
|
||||
const msg = this.props.intl.formatMessage(messages.columnPrompt, {numberOfColumns});
|
||||
// prompt() returns Promise in desktop app
|
||||
columnNumber = parseInt(await prompt(msg), 10); // eslint-disable-line no-alert
|
||||
}
|
||||
let newListValue;
|
||||
if (isNaN(columnNumber) || numberOfColumns === 1) {
|
||||
newListValue = text.replace(/\r/g, '').split('\n');
|
||||
} else {
|
||||
newListValue = rows.map(row => row[columnNumber - 1])
|
||||
.filter(item => typeof item === 'string'); // CSV importer can leave undefineds
|
||||
}
|
||||
const {vm, targetId, id: variableId} = this.props;
|
||||
setVariableValue(vm, targetId, variableId, newListValue);
|
||||
});
|
||||
}
|
||||
handleExport () {
|
||||
const {vm, targetId, id: variableId} = this.props;
|
||||
const variable = getVariable(vm, targetId, variableId);
|
||||
const text = variable.value.join('\r\n');
|
||||
const blob = new Blob([text], {type: 'text/plain;charset=utf-8'});
|
||||
downloadBlob(`${variable.name}.txt`, blob);
|
||||
}
|
||||
render () {
|
||||
const monitorProps = monitorAdapter(this.props);
|
||||
const showSliderOption = availableModes(this.props.opcode).indexOf('slider') !== -1;
|
||||
const isList = this.props.mode === 'list';
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.state.sliderPrompt && <SliderPrompt
|
||||
isDiscrete={this.props.isDiscrete}
|
||||
maxValue={parseFloat(this.props.max)}
|
||||
minValue={parseFloat(this.props.min)}
|
||||
onCancel={this.handleSliderPromptClose}
|
||||
onOk={this.handleSliderPromptOk}
|
||||
/>}
|
||||
<MonitorComponent
|
||||
componentRef={this.setElement}
|
||||
{...monitorProps}
|
||||
opcode={this.props.opcode}
|
||||
draggable={this.props.draggable}
|
||||
height={this.props.height}
|
||||
isDiscrete={this.props.isDiscrete}
|
||||
max={this.props.max}
|
||||
min={this.props.min}
|
||||
mode={this.props.mode}
|
||||
targetId={this.props.targetId}
|
||||
theme={this.props.theme}
|
||||
width={this.props.width}
|
||||
onDragEnd={this.handleDragEnd}
|
||||
onExport={isList ? this.handleExport : null}
|
||||
onImport={isList ? this.handleImport : null}
|
||||
onHide={this.handleHide}
|
||||
onNextMode={this.handleNextMode}
|
||||
onSetModeToDefault={isList ? null : this.handleSetModeToDefault}
|
||||
onSetModeToLarge={isList ? null : this.handleSetModeToLarge}
|
||||
onSetModeToSlider={showSliderOption ? this.handleSetModeToSlider : null}
|
||||
onSliderPromptOpen={this.handleSliderPromptOpen}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Monitor.propTypes = {
|
||||
addMonitorRect: PropTypes.func.isRequired,
|
||||
draggable: PropTypes.bool,
|
||||
height: PropTypes.number,
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape,
|
||||
isDiscrete: PropTypes.bool,
|
||||
max: PropTypes.number,
|
||||
min: PropTypes.number,
|
||||
mode: PropTypes.oneOf(['default', 'slider', 'large', 'list']),
|
||||
monitorLayout: PropTypes.shape({
|
||||
monitors: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
savedMonitorPositions: PropTypes.object // eslint-disable-line react/forbid-prop-types
|
||||
}).isRequired,
|
||||
onDragEnd: PropTypes.func.isRequired,
|
||||
opcode: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types
|
||||
params: PropTypes.object, // eslint-disable-line react/no-unused-prop-types, react/forbid-prop-types
|
||||
removeMonitorRect: PropTypes.func.isRequired,
|
||||
resizeMonitorRect: PropTypes.func.isRequired,
|
||||
spriteName: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
|
||||
targetId: PropTypes.string,
|
||||
theme: PropTypes.instanceOf(Theme),
|
||||
toolboxXML: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.arrayOf(PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number
|
||||
]))
|
||||
]), // eslint-disable-line react/no-unused-prop-types
|
||||
vm: PropTypes.instanceOf(VM),
|
||||
width: PropTypes.number,
|
||||
x: PropTypes.number,
|
||||
y: PropTypes.number
|
||||
};
|
||||
Monitor.defaultProps = {
|
||||
theme: Theme.light
|
||||
};
|
||||
const mapStateToProps = state => ({
|
||||
monitorLayout: state.scratchGui.monitorLayout,
|
||||
theme: state.scratchGui.theme.theme,
|
||||
// render on toolbox updates since changes to the blocks could affect monitor labels, i.e. updated locale
|
||||
toolboxXML: state.scratchGui.toolbox.toolboxXML,
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
addMonitorRect: (id, rect, savePosition) =>
|
||||
dispatch(addMonitorRect(id, rect.upperStart, rect.lowerEnd, savePosition)),
|
||||
resizeMonitorRect: (id, newWidth, newHeight) => dispatch(resizeMonitorRect(id, newWidth, newHeight)),
|
||||
removeMonitorRect: id => dispatch(removeMonitorRect(id))
|
||||
});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Monitor));
|
||||
137
scratch-gui/src/containers/paint-editor-wrapper.jsx
Normal file
137
scratch-gui/src/containers/paint-editor-wrapper.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import VM from 'scratch-vm';
|
||||
import PaintEditor from '../lib/tw-scratch-paint';
|
||||
import {inlineSvgFonts} from '@turbowarp/scratch-svg-renderer';
|
||||
import ErrorBoundaryHOC from '../lib/error-boundary-hoc.jsx';
|
||||
import {openFontsModal} from '../reducers/modals';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {Theme} from '../lib/themes/index.js';
|
||||
|
||||
class PaintEditorWrapper extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleUpdateImage',
|
||||
'handleUpdateName',
|
||||
'handleUpdateFonts',
|
||||
'fontInlineFn'
|
||||
]);
|
||||
this.state = {
|
||||
fonts: this.props.vm.runtime.fontManager.getFonts()
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
this.props.vm.runtime.fontManager.on('change', this.handleUpdateFonts);
|
||||
}
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
return this.props.imageId !== nextProps.imageId ||
|
||||
this.props.rtl !== nextProps.rtl ||
|
||||
this.props.name !== nextProps.name ||
|
||||
this.props.theme !== nextProps.theme ||
|
||||
this.props.customStageSize !== nextProps.customStageSize ||
|
||||
this.state.fonts !== nextState.fonts;
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.props.vm.runtime.fontManager.off('change', this.handleUpdateFonts);
|
||||
}
|
||||
handleUpdateFonts () {
|
||||
this.setState({
|
||||
fonts: this.props.vm.runtime.fontManager.getFonts()
|
||||
});
|
||||
}
|
||||
handleUpdateName (name) {
|
||||
this.props.vm.renameCostume(this.props.selectedCostumeIndex, name);
|
||||
}
|
||||
handleUpdateImage (isVector, image, rotationCenterX, rotationCenterY) {
|
||||
if (isVector) {
|
||||
this.props.vm.updateSvg(
|
||||
this.props.selectedCostumeIndex,
|
||||
image,
|
||||
rotationCenterX,
|
||||
rotationCenterY);
|
||||
} else {
|
||||
this.props.vm.updateBitmap(
|
||||
this.props.selectedCostumeIndex,
|
||||
image,
|
||||
rotationCenterX,
|
||||
rotationCenterY,
|
||||
2 /* bitmapResolution */);
|
||||
}
|
||||
}
|
||||
fontInlineFn (svgString) {
|
||||
return inlineSvgFonts(svgString, this.props.vm.renderer.customFonts);
|
||||
}
|
||||
render () {
|
||||
if (!this.props.imageId) return null;
|
||||
const {
|
||||
selectedCostumeIndex,
|
||||
vm,
|
||||
...componentProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<PaintEditor
|
||||
{...componentProps}
|
||||
image={vm.getCostume(selectedCostumeIndex)}
|
||||
onUpdateImage={this.handleUpdateImage}
|
||||
onUpdateName={this.handleUpdateName}
|
||||
fontInlineFn={this.fontInlineFn}
|
||||
theme={this.props.theme.isDark() ? 'dark' : 'light'}
|
||||
customFonts={this.state.fonts}
|
||||
width={this.props.customStageSize.width}
|
||||
height={this.props.customStageSize.height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PaintEditorWrapper.propTypes = {
|
||||
customStageSize: PropTypes.shape({
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number
|
||||
}),
|
||||
onManageFonts: PropTypes.func.isRequired,
|
||||
imageFormat: PropTypes.string.isRequired,
|
||||
imageId: PropTypes.string.isRequired,
|
||||
theme: PropTypes.instanceOf(Theme),
|
||||
name: PropTypes.string,
|
||||
rotationCenterX: PropTypes.number,
|
||||
rotationCenterY: PropTypes.number,
|
||||
rtl: PropTypes.bool,
|
||||
selectedCostumeIndex: PropTypes.number.isRequired,
|
||||
vm: PropTypes.instanceOf(VM)
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, {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 {
|
||||
customStageSize: state.scratchGui.customStageSize,
|
||||
name: costume && costume.name,
|
||||
rotationCenterX: costume && costume.rotationCenterX,
|
||||
rotationCenterY: costume && costume.rotationCenterY,
|
||||
imageFormat: costume && costume.dataFormat,
|
||||
imageId: targetId && `${targetId}${costume.skinId}`,
|
||||
rtl: state.locales.isRtl,
|
||||
selectedCostumeIndex: index,
|
||||
theme: state.scratchGui.theme.theme,
|
||||
vm: state.scratchGui.vm,
|
||||
zoomLevelId: targetId
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onManageFonts: () => dispatch(openFontsModal())
|
||||
});
|
||||
|
||||
export default ErrorBoundaryHOC('paint')(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(PaintEditorWrapper));
|
||||
115
scratch-gui/src/containers/play-button.jsx
Normal file
115
scratch-gui/src/containers/play-button.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
|
||||
import PlayButtonComponent from '../components/play-button/play-button.jsx';
|
||||
|
||||
class PlayButton extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleClick',
|
||||
'handleMouseDown',
|
||||
'handleMouseEnter',
|
||||
'handleMouseLeave',
|
||||
'handleTouchStart',
|
||||
'setButtonRef'
|
||||
]);
|
||||
this.state = {
|
||||
touchStarted: false
|
||||
};
|
||||
}
|
||||
getDerivedStateFromProps (props, state) {
|
||||
// if touchStarted is true and it's not playing, the sound must have ended.
|
||||
// reset the touchStarted state to allow the sound to be replayed
|
||||
if (state.touchStarted && !props.isPlaying) {
|
||||
return {
|
||||
touchStarted: false
|
||||
};
|
||||
}
|
||||
return null; // nothing changed
|
||||
}
|
||||
componentDidMount () {
|
||||
// Touch start
|
||||
this.buttonRef.addEventListener('touchstart', this.handleTouchStart);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.buttonRef.removeEventListener('touchstart', this.handleTouchStart);
|
||||
}
|
||||
handleClick (e) {
|
||||
// stop the click from propagating out of the button
|
||||
e.stopPropagation();
|
||||
}
|
||||
handleMouseDown (e) {
|
||||
// prevent default (focus) on mouseDown
|
||||
e.preventDefault();
|
||||
if (this.props.isPlaying) {
|
||||
// stop sound and reset touch state
|
||||
this.props.onStop();
|
||||
if (this.state.touchstarted) this.setState({touchStarted: false});
|
||||
} else {
|
||||
this.props.onPlay();
|
||||
if (this.state.touchstarted) {
|
||||
// started on touch, but now clicked mouse
|
||||
this.setState({touchStarted: false});
|
||||
}
|
||||
}
|
||||
}
|
||||
handleTouchStart (e) {
|
||||
if (this.props.isPlaying) {
|
||||
// If playing, stop sound, and reset touch state
|
||||
e.preventDefault();
|
||||
this.setState({touchStarted: false});
|
||||
this.props.onStop();
|
||||
} else {
|
||||
// otherwise start playing, and set touch state
|
||||
e.preventDefault();
|
||||
this.setState({touchStarted: true});
|
||||
this.props.onPlay();
|
||||
}
|
||||
}
|
||||
handleMouseEnter (e) {
|
||||
// start the sound if it's not already playing
|
||||
e.preventDefault();
|
||||
if (!this.props.isPlaying) {
|
||||
this.props.onPlay();
|
||||
}
|
||||
}
|
||||
handleMouseLeave () {
|
||||
// stop the sound unless it was started by touch
|
||||
if (this.props.isPlaying && !this.state.touchstarted) {
|
||||
this.props.onStop();
|
||||
}
|
||||
}
|
||||
setButtonRef (ref) {
|
||||
this.buttonRef = ref;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
className,
|
||||
isPlaying,
|
||||
onPlay, // eslint-disable-line no-unused-vars
|
||||
onStop // eslint-disable-line no-unused-vars
|
||||
} = this.props;
|
||||
return (
|
||||
<PlayButtonComponent
|
||||
className={className}
|
||||
isPlaying={isPlaying}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
setButtonRef={this.setButtonRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PlayButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
isPlaying: PropTypes.bool.isRequired,
|
||||
onPlay: PropTypes.func.isRequired,
|
||||
onStop: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default PlayButton;
|
||||
58
scratch-gui/src/containers/playback-step.jsx
Normal file
58
scratch-gui/src/containers/playback-step.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PlaybackStepComponent from '../components/record-modal/playback-step.jsx';
|
||||
import AudioBufferPlayer from '../lib/audio/audio-buffer-player.js';
|
||||
|
||||
class PlaybackStep extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handlePlay',
|
||||
'handleStopPlaying'
|
||||
]);
|
||||
}
|
||||
componentDidMount () {
|
||||
this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.audioBufferPlayer.stop();
|
||||
}
|
||||
handlePlay () {
|
||||
this.audioBufferPlayer.play(
|
||||
this.props.trimStart,
|
||||
this.props.trimEnd,
|
||||
this.props.onSetPlayhead,
|
||||
this.props.onStopPlaying
|
||||
);
|
||||
this.props.onPlay();
|
||||
}
|
||||
handleStopPlaying () {
|
||||
this.audioBufferPlayer.stop();
|
||||
this.props.onStopPlaying();
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
sampleRate, // eslint-disable-line no-unused-vars
|
||||
onPlay, // eslint-disable-line no-unused-vars
|
||||
onStopPlaying, // eslint-disable-line no-unused-vars
|
||||
onSetPlayhead, // eslint-disable-line no-unused-vars
|
||||
...componentProps
|
||||
} = this.props;
|
||||
return (
|
||||
<PlaybackStepComponent
|
||||
onPlay={this.handlePlay}
|
||||
onStopPlaying={this.handleStopPlaying}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackStep.propTypes = {
|
||||
sampleRate: PropTypes.number.isRequired,
|
||||
samples: PropTypes.instanceOf(Float32Array).isRequired,
|
||||
...PlaybackStepComponent.propTypes
|
||||
};
|
||||
|
||||
export default PlaybackStep;
|
||||
78
scratch-gui/src/containers/project-watcher.jsx
Normal file
78
scratch-gui/src/containers/project-watcher.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {
|
||||
getIsShowingWithId
|
||||
} from '../reducers/project-state';
|
||||
|
||||
/**
|
||||
* Watches for project to finish updating before taking some action.
|
||||
*
|
||||
* To use ProjectWatcher, pass it a callback function using the onDoneUpdating prop.
|
||||
* ProjectWatcher passes a waitForUpdate function to its children, which they can call
|
||||
* to set ProjectWatcher to request that it call the onDoneUpdating callback when
|
||||
* project is no longer updating.
|
||||
*/
|
||||
class ProjectWatcher extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'waitForUpdate'
|
||||
]);
|
||||
|
||||
this.state = {
|
||||
waiting: false
|
||||
};
|
||||
}
|
||||
componentDidUpdate (prevProps) {
|
||||
if (this.state.waiting && this.props.isShowingWithId && !prevProps.isShowingWithId) {
|
||||
this.fulfill();
|
||||
}
|
||||
}
|
||||
fulfill () {
|
||||
this.props.onDoneUpdating();
|
||||
this.setState({ // eslint-disable-line react/no-did-update-set-state
|
||||
waiting: false
|
||||
});
|
||||
}
|
||||
waitForUpdate (isUpdating) {
|
||||
if (isUpdating) {
|
||||
this.setState({
|
||||
waiting: true
|
||||
});
|
||||
} else { // fulfill immediately
|
||||
this.fulfill();
|
||||
}
|
||||
}
|
||||
render () {
|
||||
return this.props.children(
|
||||
this.waitForUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ProjectWatcher.propTypes = {
|
||||
children: PropTypes.func,
|
||||
isShowingWithId: PropTypes.bool,
|
||||
onDoneUpdating: PropTypes.func
|
||||
};
|
||||
|
||||
ProjectWatcher.defaultProps = {
|
||||
onDoneUpdating: () => {}
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const loadingState = state.scratchGui.projectState.loadingState;
|
||||
return {
|
||||
isShowingWithId: getIsShowingWithId(loadingState)
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(ProjectWatcher);
|
||||
99
scratch-gui/src/containers/prompt.jsx
Normal file
99
scratch-gui/src/containers/prompt.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PromptComponent from '../components/prompt/prompt.jsx';
|
||||
import VM from 'scratch-vm';
|
||||
import {SCRATCH_MAX_CLOUD_VARIABLES} from '../lib/tw-cloud-limits.js';
|
||||
|
||||
class Prompt extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleOk',
|
||||
'handleScopeOptionSelection',
|
||||
'handleCancel',
|
||||
'handleChange',
|
||||
'handleKeyPress',
|
||||
'handleCloudVariableOptionChange'
|
||||
]);
|
||||
this.state = {
|
||||
isAddingCloudVariableScratchSafe: (
|
||||
props.vm &&
|
||||
props.vm.runtime.getNumberOfCloudVariables() < SCRATCH_MAX_CLOUD_VARIABLES
|
||||
) || false,
|
||||
inputValue: '',
|
||||
globalSelected: true,
|
||||
cloudSelected: false,
|
||||
canAddCloudVariable: (props.vm && props.vm.runtime.canAddCloudVariable()) || false
|
||||
};
|
||||
}
|
||||
handleKeyPress (event) {
|
||||
if (event.key === 'Enter') this.handleOk();
|
||||
}
|
||||
handleFocus (event) {
|
||||
event.target.select();
|
||||
}
|
||||
handleOk () {
|
||||
this.props.onOk(this.state.inputValue, {
|
||||
scope: this.state.globalSelected ? 'global' : 'local',
|
||||
isCloud: this.state.cloudSelected
|
||||
});
|
||||
}
|
||||
handleCancel () {
|
||||
this.props.onCancel();
|
||||
}
|
||||
handleChange (e) {
|
||||
this.setState({inputValue: e.target.value});
|
||||
}
|
||||
handleScopeOptionSelection (e) {
|
||||
this.setState({globalSelected: (e.target.value === 'global')});
|
||||
}
|
||||
handleCloudVariableOptionChange (e) {
|
||||
if (!this.props.showCloudOption) return;
|
||||
|
||||
const checked = e.target.checked;
|
||||
this.setState({cloudSelected: checked});
|
||||
if (checked) {
|
||||
this.setState({globalSelected: true});
|
||||
}
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<PromptComponent
|
||||
isAddingCloudVariableScratchSafe={this.state.isAddingCloudVariableScratchSafe}
|
||||
canAddCloudVariable={this.state.canAddCloudVariable}
|
||||
cloudSelected={this.state.cloudSelected}
|
||||
defaultValue={this.props.defaultValue}
|
||||
globalSelected={this.state.globalSelected}
|
||||
isStage={this.props.isStage}
|
||||
showListMessage={this.props.showListMessage}
|
||||
label={this.props.label}
|
||||
showCloudOption={this.props.showCloudOption}
|
||||
showVariableOptions={this.props.showVariableOptions}
|
||||
title={this.props.title}
|
||||
onCancel={this.handleCancel}
|
||||
onChange={this.handleChange}
|
||||
onCloudVarOptionChange={this.handleCloudVariableOptionChange}
|
||||
onFocus={this.handleFocus}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onOk={this.handleOk}
|
||||
onScopeOptionSelection={this.handleScopeOptionSelection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Prompt.propTypes = {
|
||||
defaultValue: PropTypes.string,
|
||||
isStage: PropTypes.bool.isRequired,
|
||||
showListMessage: PropTypes.bool.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onOk: PropTypes.func.isRequired,
|
||||
showCloudOption: PropTypes.bool.isRequired,
|
||||
showVariableOptions: PropTypes.bool.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
vm: PropTypes.instanceOf(VM)
|
||||
};
|
||||
|
||||
export default Prompt;
|
||||
45
scratch-gui/src/containers/question.jsx
Normal file
45
scratch-gui/src/containers/question.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import QuestionComponent from '../components/question/question.jsx';
|
||||
|
||||
class Question extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleChange',
|
||||
'handleKeyPress',
|
||||
'handleSubmit'
|
||||
]);
|
||||
this.state = {
|
||||
answer: ''
|
||||
};
|
||||
}
|
||||
handleChange (e) {
|
||||
this.setState({answer: e.target.value});
|
||||
}
|
||||
handleKeyPress (event) {
|
||||
if (event.key === 'Enter') this.handleSubmit();
|
||||
}
|
||||
handleSubmit () {
|
||||
this.props.onQuestionAnswered(this.state.answer);
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<QuestionComponent
|
||||
answer={this.state.answer}
|
||||
question={this.props.question}
|
||||
onChange={this.handleChange}
|
||||
onClick={this.handleSubmit}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Question.propTypes = {
|
||||
onQuestionAnswered: PropTypes.func.isRequired,
|
||||
question: PropTypes.string
|
||||
};
|
||||
|
||||
export default Question;
|
||||
131
scratch-gui/src/containers/record-modal.jsx
Normal file
131
scratch-gui/src/containers/record-modal.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import VM from 'scratch-vm';
|
||||
import {connect} from 'react-redux';
|
||||
import {encodeAndAddSoundToVM} from '../lib/audio/audio-util.js';
|
||||
|
||||
import RecordModalComponent from '../components/record-modal/record-modal.jsx';
|
||||
|
||||
import {
|
||||
closeSoundRecorder
|
||||
} from '../reducers/modals';
|
||||
|
||||
class RecordModal extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleRecord',
|
||||
'handleStopRecording',
|
||||
'handlePlay',
|
||||
'handleStopPlaying',
|
||||
'handleBack',
|
||||
'handleSubmit',
|
||||
'handleCancel',
|
||||
'handleSetPlayhead',
|
||||
'handleSetTrimStart',
|
||||
'handleSetTrimEnd'
|
||||
]);
|
||||
|
||||
this.state = {
|
||||
samples: null,
|
||||
encoding: false,
|
||||
levels: null,
|
||||
playhead: null,
|
||||
playing: false,
|
||||
recording: false,
|
||||
sampleRate: null,
|
||||
trimStart: 0,
|
||||
trimEnd: 1
|
||||
};
|
||||
}
|
||||
handleRecord () {
|
||||
this.setState({recording: true});
|
||||
}
|
||||
handleStopRecording (samples, sampleRate, levels, trimStart, trimEnd) {
|
||||
if (samples.length > 0) {
|
||||
this.setState({samples, sampleRate, levels, trimStart, trimEnd, recording: false});
|
||||
}
|
||||
}
|
||||
handlePlay () {
|
||||
this.setState({playing: true});
|
||||
}
|
||||
handleStopPlaying () {
|
||||
this.setState({playing: false, playhead: null});
|
||||
}
|
||||
handleBack () {
|
||||
this.setState({playing: false, samples: null});
|
||||
}
|
||||
handleSetTrimEnd (trimEnd) {
|
||||
this.setState({trimEnd});
|
||||
}
|
||||
handleSetTrimStart (trimStart) {
|
||||
this.setState({trimStart});
|
||||
}
|
||||
handleSetPlayhead (playhead) {
|
||||
this.setState({playhead});
|
||||
}
|
||||
handleSubmit () {
|
||||
this.setState({encoding: true}, () => {
|
||||
const sampleCount = this.state.samples.length;
|
||||
const startIndex = Math.floor(this.state.trimStart * sampleCount);
|
||||
const endIndex = Math.floor(this.state.trimEnd * sampleCount);
|
||||
const clippedSamples = this.state.samples.slice(startIndex, endIndex);
|
||||
|
||||
encodeAndAddSoundToVM(this.props.vm, clippedSamples, this.state.sampleRate, 'recording1',
|
||||
() => {
|
||||
this.props.onClose();
|
||||
this.props.onNewSound();
|
||||
});
|
||||
});
|
||||
}
|
||||
handleCancel () {
|
||||
this.props.onClose();
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<RecordModalComponent
|
||||
encoding={this.state.encoding}
|
||||
levels={this.state.levels}
|
||||
playhead={this.state.playhead}
|
||||
playing={this.state.playing}
|
||||
recording={this.state.recording}
|
||||
sampleRate={this.state.sampleRate}
|
||||
samples={this.state.samples}
|
||||
trimEnd={this.state.trimEnd}
|
||||
trimStart={this.state.trimStart}
|
||||
onBack={this.handleBack}
|
||||
onCancel={this.handleCancel}
|
||||
onPlay={this.handlePlay}
|
||||
onRecord={this.handleRecord}
|
||||
onSetPlayhead={this.handleSetPlayhead}
|
||||
onSetTrimEnd={this.handleSetTrimEnd}
|
||||
onSetTrimStart={this.handleSetTrimStart}
|
||||
onStopPlaying={this.handleStopPlaying}
|
||||
onStopRecording={this.handleStopRecording}
|
||||
onSubmit={this.handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RecordModal.propTypes = {
|
||||
onClose: PropTypes.func,
|
||||
onNewSound: PropTypes.func,
|
||||
vm: PropTypes.instanceOf(VM)
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onClose: () => {
|
||||
dispatch(closeSoundRecorder());
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(RecordModal);
|
||||
88
scratch-gui/src/containers/recording-step.jsx
Normal file
88
scratch-gui/src/containers/recording-step.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import RecordingStepComponent from '../components/record-modal/recording-step.jsx';
|
||||
import AudioRecorder from '../lib/audio/audio-recorder.js';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
import log from '../lib/log';
|
||||
|
||||
const messages = defineMessages({
|
||||
alertMsg: {
|
||||
defaultMessage: 'Could not start recording',
|
||||
description: 'Alert for recording error',
|
||||
id: 'gui.recordingStep.alertMsg'
|
||||
}
|
||||
});
|
||||
|
||||
class RecordingStep extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleRecord',
|
||||
'handleStopRecording',
|
||||
'handleStarted',
|
||||
'handleLevelUpdate',
|
||||
'handleRecordingError'
|
||||
]);
|
||||
|
||||
this.state = {
|
||||
listening: false,
|
||||
level: 0,
|
||||
levels: null
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
this.audioRecorder = new AudioRecorder();
|
||||
this.audioRecorder.startListening(this.handleStarted, this.handleLevelUpdate, this.handleRecordingError);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.audioRecorder.dispose();
|
||||
}
|
||||
handleStarted () {
|
||||
this.setState({listening: true});
|
||||
}
|
||||
handleRecordingError (error) {
|
||||
log.error(error);
|
||||
alert(this.props.intl.formatMessage(messages.alertMsg)); // eslint-disable-line no-alert
|
||||
}
|
||||
handleLevelUpdate (level) {
|
||||
this.setState({
|
||||
level: level,
|
||||
levels: this.props.recording ? (this.state.levels || []).concat([level]) : this.state.levels
|
||||
});
|
||||
}
|
||||
handleRecord () {
|
||||
this.audioRecorder.startRecording();
|
||||
this.props.onRecord();
|
||||
}
|
||||
handleStopRecording () {
|
||||
const {samples, sampleRate, levels, trimStart, trimEnd} = this.audioRecorder.stop();
|
||||
this.props.onStopRecording(samples, sampleRate, levels, trimStart, trimEnd);
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
onRecord, // eslint-disable-line no-unused-vars
|
||||
onStopRecording, // eslint-disable-line no-unused-vars
|
||||
...componentProps
|
||||
} = this.props;
|
||||
return (
|
||||
<RecordingStepComponent
|
||||
level={this.state.level}
|
||||
levels={this.state.levels}
|
||||
listening={this.state.listening}
|
||||
onRecord={this.handleRecord}
|
||||
onStopRecording={this.handleStopRecording}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RecordingStep.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onRecord: PropTypes.func.isRequired,
|
||||
onStopRecording: PropTypes.func.isRequired,
|
||||
recording: PropTypes.bool
|
||||
};
|
||||
|
||||
export default injectIntl(RecordingStep);
|
||||
321
scratch-gui/src/containers/sb3-downloader.jsx
Normal file
321
scratch-gui/src/containers/sb3-downloader.jsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {projectTitleInitialState, setProjectTitle} from '../reducers/project-title';
|
||||
import downloadBlob from '../lib/download-blob';
|
||||
import {setProjectUnchanged} from '../reducers/project-changed';
|
||||
import {showStandardAlert, showAlertWithTimeout} from '../reducers/alerts';
|
||||
import {setFileHandle} from '../reducers/tw';
|
||||
import {getIsShowingProject} from '../reducers/project-state';
|
||||
import log from '../lib/log';
|
||||
|
||||
// from sb-file-uploader-hoc.jsx
|
||||
const getProjectTitleFromFilename = fileInputFilename => {
|
||||
if (!fileInputFilename) return '';
|
||||
// only parse title with valid scratch project extensions
|
||||
// (.sb, .sb2, and .sb3)
|
||||
const matches = fileInputFilename.match(/^(.*)\.sb[23]?$/);
|
||||
if (!matches) return '';
|
||||
return matches[1].substring(0, 100); // truncate project title to max 100 chars
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Uint8Array[]} arrays List of byte arrays
|
||||
* @returns {number} Total length of the arrays
|
||||
*/
|
||||
const getLengthOfByteArrays = arrays => {
|
||||
let length = 0;
|
||||
for (let i = 0; i < arrays.length; i++) {
|
||||
length += arrays[i].byteLength;
|
||||
}
|
||||
return length;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Uint8Array[]} arrays List of byte arrays
|
||||
* @returns {Uint8Array} One big array containing all of the little arrays in order.
|
||||
*/
|
||||
const concatenateByteArrays = arrays => {
|
||||
const totalLength = getLengthOfByteArrays(arrays);
|
||||
const newArray = new Uint8Array(totalLength);
|
||||
let p = 0;
|
||||
for (let i = 0; i < arrays.length; i++) {
|
||||
newArray.set(arrays[i], p);
|
||||
p += arrays[i].byteLength;
|
||||
}
|
||||
return newArray;
|
||||
};
|
||||
|
||||
/**
|
||||
* Project saver component passes a downloadProject function to its child.
|
||||
* It expects this child to be a function with the signature
|
||||
* function (downloadProject, props) {}
|
||||
* The component can then be used to attach project saving functionality
|
||||
* to any other component:
|
||||
*
|
||||
* <SB3Downloader>{(downloadProject, props) => (
|
||||
* <MyCoolComponent
|
||||
* onClick={downloadProject}
|
||||
* {...props}
|
||||
* />
|
||||
* )}</SB3Downloader>
|
||||
*/
|
||||
class SB3Downloader extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'downloadProject',
|
||||
'saveAsNew',
|
||||
'saveToLastFile',
|
||||
'saveToLastFileOrNew'
|
||||
]);
|
||||
}
|
||||
startedSaving () {
|
||||
this.props.onShowSavingAlert();
|
||||
}
|
||||
finishedSaving () {
|
||||
this.props.onProjectUnchanged();
|
||||
this.props.onShowSaveSuccessAlert();
|
||||
if (this.props.onSaveFinished) {
|
||||
this.props.onSaveFinished();
|
||||
}
|
||||
}
|
||||
downloadProject () {
|
||||
if (!this.props.canSaveProject) {
|
||||
return;
|
||||
}
|
||||
this.startedSaving();
|
||||
this.props.saveProjectSb3().then(content => {
|
||||
this.finishedSaving();
|
||||
downloadBlob(this.props.projectFilename, content);
|
||||
});
|
||||
}
|
||||
async saveAsNew () {
|
||||
if (!this.props.canSaveProject) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const handle = await this.props.showSaveFilePicker({
|
||||
suggestedName: this.props.projectFilename,
|
||||
types: [
|
||||
{
|
||||
description: 'Scratch 3 Project',
|
||||
accept: {
|
||||
'application/x.scratch.sb3': '.sb3'
|
||||
}
|
||||
}
|
||||
],
|
||||
excludeAcceptAllOption: true
|
||||
});
|
||||
await this.saveToHandle(handle);
|
||||
this.props.onSetFileHandle(handle);
|
||||
const title = getProjectTitleFromFilename(handle.name);
|
||||
if (title) {
|
||||
this.props.onSetProjectTitle(title);
|
||||
}
|
||||
} catch (e) {
|
||||
this.handleSaveError(e);
|
||||
}
|
||||
}
|
||||
async saveToLastFile () {
|
||||
try {
|
||||
await this.saveToHandle(this.props.fileHandle);
|
||||
} catch (e) {
|
||||
this.handleSaveError(e);
|
||||
}
|
||||
}
|
||||
saveToLastFileOrNew () {
|
||||
if (this.props.fileHandle) {
|
||||
return this.saveToLastFile();
|
||||
}
|
||||
return this.saveAsNew();
|
||||
}
|
||||
async saveToHandle (handle) {
|
||||
if (!this.props.canSaveProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const writable = await handle.createWritable();
|
||||
this.startedSaving();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
// Projects can be very large, so we'll utilize JSZip's stream API to avoid having the
|
||||
// entire sb3 in memory at the same time.
|
||||
const jszipStream = this.props.saveProjectSb3Stream();
|
||||
|
||||
const abortController = new AbortController();
|
||||
jszipStream.on('error', error => {
|
||||
abortController.abort(error);
|
||||
});
|
||||
|
||||
// JSZip's stream pause() and resume() methods are not necessarily completely no-ops
|
||||
// if they are already paused or resumed. These also make it easier to add debug
|
||||
// logging of when we actually pause or resume.
|
||||
// Note that JSZip will keep sending some data after you ask it to pause.
|
||||
let jszipStreamRunning = false;
|
||||
const pauseJSZipStream = () => {
|
||||
if (jszipStreamRunning) {
|
||||
jszipStreamRunning = false;
|
||||
jszipStream.pause();
|
||||
}
|
||||
};
|
||||
const resumeJSZipStream = () => {
|
||||
if (!jszipStreamRunning) {
|
||||
jszipStreamRunning = true;
|
||||
jszipStream.resume();
|
||||
}
|
||||
};
|
||||
|
||||
// Allow the JSZip stream to run quite a bit ahead of file writing. This helps
|
||||
// reduce zip stream pauses on systems with high latency storage.
|
||||
const HIGH_WATER_MARK_BYTES = 1024 * 1024 * 5;
|
||||
|
||||
// Minimum size of buffer to pass into write(). Small buffers will be queued and
|
||||
// written in batches as they reach or exceed this size.
|
||||
const WRITE_BUFFER_TARGET_SIZE_BYTES = 1024 * 1024;
|
||||
|
||||
const zipStream = new ReadableStream({
|
||||
start: controller => {
|
||||
jszipStream.on('data', data => {
|
||||
controller.enqueue(data);
|
||||
if (controller.desiredSize <= 0) {
|
||||
pauseJSZipStream();
|
||||
}
|
||||
});
|
||||
jszipStream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
resumeJSZipStream();
|
||||
},
|
||||
pull: () => {
|
||||
resumeJSZipStream();
|
||||
},
|
||||
cancel: () => {
|
||||
pauseJSZipStream();
|
||||
}
|
||||
}, new ByteLengthQueuingStrategy({
|
||||
highWaterMark: HIGH_WATER_MARK_BYTES
|
||||
}));
|
||||
|
||||
const queuedChunks = [];
|
||||
const fileStream = new WritableStream({
|
||||
write: chunk => {
|
||||
queuedChunks.push(chunk);
|
||||
const currentSize = getLengthOfByteArrays(queuedChunks);
|
||||
if (currentSize >= WRITE_BUFFER_TARGET_SIZE_BYTES) {
|
||||
const newBuffer = concatenateByteArrays(queuedChunks);
|
||||
queuedChunks.length = 0;
|
||||
return writable.write(newBuffer);
|
||||
}
|
||||
// Otherwise wait for more data
|
||||
},
|
||||
close: async () => {
|
||||
// Write the last batch of data.
|
||||
const lastBuffer = concatenateByteArrays(queuedChunks);
|
||||
if (lastBuffer.byteLength) {
|
||||
await writable.write(lastBuffer);
|
||||
}
|
||||
// File handle must be closed at the end to actually save the file.
|
||||
await writable.close();
|
||||
},
|
||||
abort: async () => {
|
||||
await writable.abort();
|
||||
}
|
||||
});
|
||||
|
||||
zipStream.pipeTo(fileStream, {
|
||||
signal: abortController.signal
|
||||
})
|
||||
.then(() => {
|
||||
this.finishedSaving();
|
||||
resolve();
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
handleSaveError (e) {
|
||||
// AbortError can happen when someone cancels the file selector dialog
|
||||
if (e && e.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
log.error(e);
|
||||
this.props.onShowSaveErrorAlert();
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
children
|
||||
} = this.props;
|
||||
return children(
|
||||
this.props.className,
|
||||
this.downloadProject,
|
||||
this.props.showSaveFilePicker ? {
|
||||
available: true,
|
||||
name: this.props.fileHandle ? this.props.fileHandle.name : null,
|
||||
saveAsNew: this.saveAsNew,
|
||||
saveToLastFile: this.saveToLastFile,
|
||||
saveToLastFileOrNew: this.saveToLastFileOrNew,
|
||||
smartSave: this.saveToLastFileOrNew
|
||||
} : {
|
||||
available: false,
|
||||
smartSave: this.downloadProject
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getProjectFilename = (curTitle, defaultTitle) => {
|
||||
let filenameTitle = curTitle;
|
||||
if (!filenameTitle || filenameTitle.length === 0) {
|
||||
filenameTitle = defaultTitle;
|
||||
}
|
||||
return `${filenameTitle.substring(0, 100)}.sb3`;
|
||||
};
|
||||
|
||||
SB3Downloader.propTypes = {
|
||||
children: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
fileHandle: PropTypes.shape({
|
||||
name: PropTypes.string
|
||||
}),
|
||||
onSaveFinished: PropTypes.func,
|
||||
projectFilename: PropTypes.string,
|
||||
saveProjectSb3: PropTypes.func,
|
||||
saveProjectSb3Stream: PropTypes.func,
|
||||
canSaveProject: PropTypes.bool,
|
||||
onSetFileHandle: PropTypes.func,
|
||||
onSetProjectTitle: PropTypes.func,
|
||||
onShowSavingAlert: PropTypes.func,
|
||||
onShowSaveSuccessAlert: PropTypes.func,
|
||||
onShowSaveErrorAlert: PropTypes.func,
|
||||
onProjectUnchanged: PropTypes.func,
|
||||
showSaveFilePicker: PropTypes.func
|
||||
};
|
||||
SB3Downloader.defaultProps = {
|
||||
className: '',
|
||||
showSaveFilePicker: typeof showSaveFilePicker === 'function' ? window.showSaveFilePicker.bind(window) : null
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
fileHandle: state.scratchGui.tw.fileHandle,
|
||||
saveProjectSb3: state.scratchGui.vm.saveProjectSb3.bind(state.scratchGui.vm),
|
||||
saveProjectSb3Stream: state.scratchGui.vm.saveProjectSb3Stream.bind(state.scratchGui.vm),
|
||||
canSaveProject: getIsShowingProject(state.scratchGui.projectState.loadingState),
|
||||
projectFilename: getProjectFilename(state.scratchGui.projectTitle, projectTitleInitialState)
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSetFileHandle: fileHandle => dispatch(setFileHandle(fileHandle)),
|
||||
onSetProjectTitle: title => dispatch(setProjectTitle(title)),
|
||||
onShowSavingAlert: () => showAlertWithTimeout(dispatch, 'saving'),
|
||||
onShowSaveSuccessAlert: () => showAlertWithTimeout(dispatch, 'twSaveToDiskSuccess'),
|
||||
onShowSaveErrorAlert: () => dispatch(showStandardAlert('savingError')),
|
||||
onProjectUnchanged: () => dispatch(setProjectUnchanged())
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SB3Downloader);
|
||||
80
scratch-gui/src/containers/scanning-step.jsx
Normal file
80
scratch-gui/src/containers/scanning-step.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import ScanningStepComponent from '../components/connection-modal/scanning-step.jsx';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
class ScanningStep extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handlePeripheralListUpdate',
|
||||
'handlePeripheralScanTimeout',
|
||||
'handleRefresh'
|
||||
]);
|
||||
this.state = {
|
||||
scanning: true,
|
||||
peripheralList: []
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
this.props.vm.scanForPeripheral(this.props.extensionId);
|
||||
this.props.vm.on(
|
||||
'PERIPHERAL_LIST_UPDATE', this.handlePeripheralListUpdate);
|
||||
this.props.vm.on(
|
||||
'PERIPHERAL_SCAN_TIMEOUT', this.handlePeripheralScanTimeout);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
// @todo: stop the peripheral scan here
|
||||
this.props.vm.removeListener(
|
||||
'PERIPHERAL_LIST_UPDATE', this.handlePeripheralListUpdate);
|
||||
this.props.vm.removeListener(
|
||||
'PERIPHERAL_SCAN_TIMEOUT', this.handlePeripheralScanTimeout);
|
||||
}
|
||||
handlePeripheralScanTimeout () {
|
||||
this.setState({
|
||||
scanning: false,
|
||||
peripheralList: []
|
||||
});
|
||||
}
|
||||
handlePeripheralListUpdate (newList) {
|
||||
// TODO: sort peripherals by signal strength? so they don't jump around
|
||||
const peripheralArray = Object.keys(newList).map(id =>
|
||||
newList[id]
|
||||
);
|
||||
this.setState({peripheralList: peripheralArray});
|
||||
}
|
||||
handleRefresh () {
|
||||
this.props.vm.scanForPeripheral(this.props.extensionId);
|
||||
this.setState({
|
||||
scanning: true,
|
||||
peripheralList: []
|
||||
});
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<ScanningStepComponent
|
||||
connectionSmallIconURL={this.props.connectionSmallIconURL}
|
||||
peripheralList={this.state.peripheralList}
|
||||
phase={this.state.phase}
|
||||
scanning={this.state.scanning}
|
||||
title={this.props.extensionId}
|
||||
onConnected={this.props.onConnected}
|
||||
onConnecting={this.props.onConnecting}
|
||||
onRefresh={this.handleRefresh}
|
||||
onUpdatePeripheral={this.props.onUpdatePeripheral}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ScanningStep.propTypes = {
|
||||
connectionSmallIconURL: PropTypes.string,
|
||||
extensionId: PropTypes.string.isRequired,
|
||||
onConnected: PropTypes.func.isRequired,
|
||||
onConnecting: PropTypes.func.isRequired,
|
||||
onUpdatePeripheral: PropTypes.func,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
export default ScanningStep;
|
||||
59
scratch-gui/src/containers/slider-monitor.jsx
Normal file
59
scratch-gui/src/containers/slider-monitor.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import VM from 'scratch-vm';
|
||||
import {setVariableValue} from '../lib/variable-utils';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import SliderMonitorComponent from '../components/monitor/slider-monitor.jsx';
|
||||
|
||||
class SliderMonitor extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleSliderUpdate'
|
||||
]);
|
||||
|
||||
this.state = {
|
||||
value: props.value
|
||||
};
|
||||
}
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (this.state.value !== nextProps.value) {
|
||||
this.setState({value: nextProps.value});
|
||||
}
|
||||
}
|
||||
handleSliderUpdate (e) {
|
||||
this.setState({value: Number(e.target.value)});
|
||||
const {vm, targetId, id: variableId} = this.props;
|
||||
setVariableValue(vm, targetId, variableId, Number(e.target.value));
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
vm, // eslint-disable-line no-unused-vars
|
||||
value, // eslint-disable-line no-unused-vars
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<SliderMonitorComponent
|
||||
{...props}
|
||||
value={this.state.value}
|
||||
onSliderUpdate={this.handleSliderUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SliderMonitor.propTypes = {
|
||||
id: PropTypes.string,
|
||||
targetId: PropTypes.string,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.number,
|
||||
PropTypes.string
|
||||
]),
|
||||
vm: PropTypes.instanceOf(VM)
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({vm: state.scratchGui.vm});
|
||||
|
||||
export default connect(mapStateToProps)(SliderMonitor);
|
||||
85
scratch-gui/src/containers/slider-prompt.jsx
Normal file
85
scratch-gui/src/containers/slider-prompt.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import SliderPromptComponent from '../components/slider-prompt/slider-prompt.jsx';
|
||||
|
||||
class SliderPrompt extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleOk',
|
||||
'handleCancel',
|
||||
'handleChangeMin',
|
||||
'handleChangeMax',
|
||||
'handleKeyPress',
|
||||
'validates',
|
||||
'shouldBeDiscrete'
|
||||
]);
|
||||
|
||||
const {isDiscrete, minValue, maxValue} = this.props;
|
||||
this.state = {
|
||||
// For internal use, convert values to strings based on isDiscrete
|
||||
// This is because `<input />` always returns values as strings.
|
||||
minValue: isDiscrete ? minValue.toFixed(0) : minValue.toFixed(2),
|
||||
maxValue: isDiscrete ? maxValue.toFixed(0) : maxValue.toFixed(2)
|
||||
};
|
||||
}
|
||||
handleKeyPress (event) {
|
||||
if (event.key === 'Enter') this.handleOk();
|
||||
}
|
||||
handleOk () {
|
||||
const {minValue, maxValue} = this.state;
|
||||
if (!this.validates(minValue, maxValue)) {
|
||||
this.props.onCancel();
|
||||
return;
|
||||
}
|
||||
this.props.onOk(
|
||||
parseFloat(minValue),
|
||||
parseFloat(maxValue),
|
||||
this.shouldBeDiscrete(minValue, maxValue));
|
||||
}
|
||||
handleCancel () {
|
||||
this.props.onCancel();
|
||||
}
|
||||
handleChangeMin (e) {
|
||||
this.setState({minValue: e.target.value});
|
||||
}
|
||||
handleChangeMax (e) {
|
||||
this.setState({maxValue: e.target.value});
|
||||
}
|
||||
shouldBeDiscrete (min, max) {
|
||||
return min.indexOf('.') + max.indexOf('.') === -2; // Both -1
|
||||
}
|
||||
validates (min, max) {
|
||||
return isFinite(min) && isFinite(max);
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<SliderPromptComponent
|
||||
maxValue={this.state.maxValue}
|
||||
minValue={this.state.minValue}
|
||||
onCancel={this.handleCancel}
|
||||
onChangeMax={this.handleChangeMax}
|
||||
onChangeMin={this.handleChangeMin}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onOk={this.handleOk}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SliderPrompt.propTypes = {
|
||||
isDiscrete: PropTypes.bool,
|
||||
maxValue: PropTypes.number,
|
||||
minValue: PropTypes.number,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onOk: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
SliderPrompt.defaultProps = {
|
||||
maxValue: 100,
|
||||
minValue: 0,
|
||||
isDiscrete: true
|
||||
};
|
||||
|
||||
export default SliderPrompt;
|
||||
515
scratch-gui/src/containers/sound-editor.jsx
Normal file
515
scratch-gui/src/containers/sound-editor.jsx
Normal file
@@ -0,0 +1,515 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import WavEncoder from 'wav-encoder';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {
|
||||
computeChunkedRMS,
|
||||
encodeAndAddSoundToVM,
|
||||
downsampleIfNeeded,
|
||||
dropEveryOtherSample
|
||||
} 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;
|
||||
|
||||
const MAX_RMS = 1.2;
|
||||
|
||||
class SoundEditor extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'copy',
|
||||
'copyCurrentBuffer',
|
||||
'handleCopyToNew',
|
||||
'handleStoppedPlaying',
|
||||
'handleChangeName',
|
||||
'handlePlay',
|
||||
'handleStopPlaying',
|
||||
'handleUpdatePlayhead',
|
||||
'handleDelete',
|
||||
'handleUpdateTrim',
|
||||
'handleEffect',
|
||||
'handleUndo',
|
||||
'handleRedo',
|
||||
'submitNewSamples',
|
||||
'handleCopy',
|
||||
'handlePaste',
|
||||
'paste',
|
||||
'handleKeyPress',
|
||||
'handleContainerClick',
|
||||
'setRef',
|
||||
'resampleBufferToRate'
|
||||
]);
|
||||
this.state = {
|
||||
copyBuffer: null,
|
||||
chunkLevels: computeChunkedRMS(this.props.samples),
|
||||
playhead: null, // null is not playing, [0 -> 1] is playing percent
|
||||
trimStart: null,
|
||||
trimEnd: null
|
||||
};
|
||||
|
||||
this.redoStack = [];
|
||||
this.undoStack = [];
|
||||
|
||||
this.ref = null;
|
||||
}
|
||||
componentDidMount () {
|
||||
this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate);
|
||||
|
||||
document.addEventListener('keydown', this.handleKeyPress);
|
||||
}
|
||||
componentWillReceiveProps (newProps) {
|
||||
if (newProps.soundId !== this.props.soundId) { // A different sound has been selected
|
||||
this.redoStack = [];
|
||||
this.undoStack = [];
|
||||
this.resetState(newProps.samples, newProps.sampleRate);
|
||||
this.setState({
|
||||
trimStart: null,
|
||||
trimEnd: null
|
||||
});
|
||||
}
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.audioBufferPlayer.stop();
|
||||
|
||||
document.removeEventListener('keydown', this.handleKeyPress);
|
||||
}
|
||||
handleKeyPress (event) {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
// Ignore keyboard shortcuts if a text input field is focused
|
||||
return;
|
||||
}
|
||||
if (this.props.isFullScreen) {
|
||||
// Ignore keyboard shortcuts if the stage is fullscreen mode
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (this.state.playhead) {
|
||||
this.handleStopPlaying();
|
||||
} else {
|
||||
this.handlePlay();
|
||||
}
|
||||
}
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
this.handleDeleteInverse();
|
||||
} else {
|
||||
this.handleDelete();
|
||||
}
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.handleUpdateTrim(null, null);
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
if (event.shiftKey && event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault();
|
||||
if (this.redoStack.length > 0) {
|
||||
this.handleRedo();
|
||||
}
|
||||
} else if (event.key === 'z') {
|
||||
if (this.undoStack.length > 0) {
|
||||
event.preventDefault();
|
||||
this.handleUndo();
|
||||
}
|
||||
} else if (event.key === 'c') {
|
||||
event.preventDefault();
|
||||
this.handleCopy();
|
||||
} else if (event.key === 'v') {
|
||||
event.preventDefault();
|
||||
this.handlePaste();
|
||||
} else if (event.key === 'a') {
|
||||
event.preventDefault();
|
||||
this.handleUpdateTrim(0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
resetState (samples, sampleRate) {
|
||||
this.audioBufferPlayer.stop();
|
||||
this.audioBufferPlayer = new AudioBufferPlayer(samples, sampleRate);
|
||||
this.setState({
|
||||
chunkLevels: computeChunkedRMS(samples),
|
||||
playhead: null
|
||||
});
|
||||
}
|
||||
submitNewSamples (samples, sampleRate, skipUndo) {
|
||||
return downsampleIfNeeded({samples, sampleRate}, this.resampleBufferToRate)
|
||||
.then(({samples: newSamples, sampleRate: newSampleRate}) =>
|
||||
WavEncoder.encode({
|
||||
sampleRate: newSampleRate,
|
||||
channelData: [newSamples]
|
||||
}).then(wavBuffer => {
|
||||
if (!skipUndo) {
|
||||
this.redoStack = [];
|
||||
if (this.undoStack.length >= UNDO_STACK_SIZE) {
|
||||
this.undoStack.shift(); // Drop the first element off the array
|
||||
}
|
||||
this.undoStack.push(this.getUndoItem());
|
||||
}
|
||||
this.resetState(newSamples, newSampleRate);
|
||||
this.props.vm.updateSoundBuffer(
|
||||
this.props.soundIndex,
|
||||
this.audioBufferPlayer.buffer,
|
||||
new Uint8Array(wavBuffer));
|
||||
return true; // Edit was successful
|
||||
})
|
||||
)
|
||||
.catch(e => {
|
||||
// Encoding failed, or the sound was too large to save so edit is rejected
|
||||
log.error(`Encountered error while trying to encode sound update: ${e.message}`);
|
||||
return false; // Edit was not applied
|
||||
});
|
||||
}
|
||||
handlePlay () {
|
||||
this.audioBufferPlayer.stop();
|
||||
this.audioBufferPlayer.play(
|
||||
this.state.trimStart || 0,
|
||||
this.state.trimEnd || 1,
|
||||
this.handleUpdatePlayhead,
|
||||
this.handleStoppedPlaying);
|
||||
}
|
||||
handleStopPlaying () {
|
||||
this.audioBufferPlayer.stop();
|
||||
this.handleStoppedPlaying();
|
||||
}
|
||||
handleStoppedPlaying () {
|
||||
this.setState({playhead: null});
|
||||
}
|
||||
handleUpdatePlayhead (playhead) {
|
||||
this.setState({playhead});
|
||||
}
|
||||
handleChangeName (name) {
|
||||
this.props.vm.renameSound(this.props.soundIndex, name);
|
||||
}
|
||||
handleDelete () {
|
||||
const {samples, sampleRate} = this.copyCurrentBuffer();
|
||||
const sampleCount = samples.length;
|
||||
const startIndex = Math.floor(this.state.trimStart * sampleCount);
|
||||
const endIndex = Math.floor(this.state.trimEnd * sampleCount);
|
||||
const firstPart = samples.slice(0, startIndex);
|
||||
const secondPart = samples.slice(endIndex, sampleCount);
|
||||
const newLength = firstPart.length + secondPart.length;
|
||||
let newSamples;
|
||||
if (newLength === 0) {
|
||||
newSamples = new Float32Array(1);
|
||||
} else {
|
||||
newSamples = new Float32Array(newLength);
|
||||
newSamples.set(firstPart, 0);
|
||||
newSamples.set(secondPart, firstPart.length);
|
||||
}
|
||||
this.submitNewSamples(newSamples, sampleRate).then(() => {
|
||||
this.setState({
|
||||
trimStart: null,
|
||||
trimEnd: null
|
||||
});
|
||||
});
|
||||
}
|
||||
handleDeleteInverse () {
|
||||
// Delete everything outside of the trimmers
|
||||
const {samples, sampleRate} = this.copyCurrentBuffer();
|
||||
const sampleCount = samples.length;
|
||||
const startIndex = Math.floor(this.state.trimStart * sampleCount);
|
||||
const endIndex = Math.floor(this.state.trimEnd * sampleCount);
|
||||
let clippedSamples = samples.slice(startIndex, endIndex);
|
||||
if (clippedSamples.length === 0) {
|
||||
clippedSamples = new Float32Array(1);
|
||||
}
|
||||
this.submitNewSamples(clippedSamples, sampleRate).then(success => {
|
||||
if (success) {
|
||||
this.setState({
|
||||
trimStart: null,
|
||||
trimEnd: null
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
handleUpdateTrim (trimStart, trimEnd) {
|
||||
this.setState({trimStart, trimEnd});
|
||||
this.handleStopPlaying();
|
||||
}
|
||||
effectFactory (name) {
|
||||
return () => this.handleEffect(name);
|
||||
}
|
||||
copyCurrentBuffer () {
|
||||
// Cannot reliably use props.samples because it gets detached by Firefox
|
||||
return {
|
||||
samples: this.audioBufferPlayer.buffer.getChannelData(0),
|
||||
sampleRate: this.audioBufferPlayer.buffer.sampleRate
|
||||
};
|
||||
}
|
||||
handleEffect (name) {
|
||||
const trimStart = this.state.trimStart === null ? 0.0 : this.state.trimStart;
|
||||
const trimEnd = this.state.trimEnd === null ? 1.0 : this.state.trimEnd;
|
||||
|
||||
// Offline audio context needs at least 2 samples
|
||||
if (this.audioBufferPlayer.buffer.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effects = new AudioEffects(this.audioBufferPlayer.buffer, name, trimStart, trimEnd);
|
||||
effects.process((renderedBuffer, adjustedTrimStart, adjustedTrimEnd) => {
|
||||
const samples = renderedBuffer.getChannelData(0);
|
||||
const sampleRate = renderedBuffer.sampleRate;
|
||||
this.submitNewSamples(samples, sampleRate).then(success => {
|
||||
if (success) {
|
||||
if (this.state.trimStart === null) {
|
||||
this.handlePlay();
|
||||
} else {
|
||||
this.setState({trimStart: adjustedTrimStart, trimEnd: adjustedTrimEnd}, this.handlePlay);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
tooLoud () {
|
||||
const numChunks = this.state.chunkLevels.length;
|
||||
const startIndex = this.state.trimStart === null ?
|
||||
0 : Math.floor(this.state.trimStart * numChunks);
|
||||
const endIndex = this.state.trimEnd === null ?
|
||||
numChunks - 1 : Math.ceil(this.state.trimEnd * numChunks);
|
||||
const trimChunks = this.state.chunkLevels.slice(startIndex, endIndex);
|
||||
let max = 0;
|
||||
for (const i of trimChunks) {
|
||||
if (i > max) {
|
||||
max = i;
|
||||
}
|
||||
}
|
||||
return max > MAX_RMS;
|
||||
}
|
||||
getUndoItem () {
|
||||
return {
|
||||
...this.copyCurrentBuffer(),
|
||||
trimStart: this.state.trimStart,
|
||||
trimEnd: this.state.trimEnd
|
||||
};
|
||||
}
|
||||
handleUndo () {
|
||||
this.redoStack.push(this.getUndoItem());
|
||||
const {samples, sampleRate, trimStart, trimEnd} = this.undoStack.pop();
|
||||
if (samples) {
|
||||
return this.submitNewSamples(samples, sampleRate, true).then(success => {
|
||||
if (success) {
|
||||
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
handleRedo () {
|
||||
const {samples, sampleRate, trimStart, trimEnd} = this.redoStack.pop();
|
||||
if (samples) {
|
||||
this.undoStack.push(this.getUndoItem());
|
||||
return this.submitNewSamples(samples, sampleRate, true).then(success => {
|
||||
if (success) {
|
||||
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
handleCopy () {
|
||||
this.copy();
|
||||
}
|
||||
copy (callback) {
|
||||
const trimStart = this.state.trimStart === null ? 0.0 : this.state.trimStart;
|
||||
const trimEnd = this.state.trimEnd === null ? 1.0 : this.state.trimEnd;
|
||||
|
||||
const newCopyBuffer = this.copyCurrentBuffer();
|
||||
const trimStartSamples = trimStart * newCopyBuffer.samples.length;
|
||||
const trimEndSamples = trimEnd * newCopyBuffer.samples.length;
|
||||
newCopyBuffer.samples = newCopyBuffer.samples.slice(trimStartSamples, trimEndSamples);
|
||||
|
||||
this.setState({
|
||||
copyBuffer: newCopyBuffer
|
||||
}, callback);
|
||||
}
|
||||
handleCopyToNew () {
|
||||
this.copy(() => {
|
||||
encodeAndAddSoundToVM(this.props.vm, this.state.copyBuffer.samples,
|
||||
this.state.copyBuffer.sampleRate, this.props.name);
|
||||
});
|
||||
}
|
||||
resampleBufferToRate (buffer, newRate) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sampleRateRatio = newRate / buffer.sampleRate;
|
||||
const newLength = sampleRateRatio * buffer.samples.length;
|
||||
let offlineContext;
|
||||
// Try to use either OfflineAudioContext or webkitOfflineAudioContext to resample
|
||||
// The constructors will throw if trying to resample at an unsupported rate
|
||||
// (e.g. Safari/webkitOAC does not support lower than 44khz).
|
||||
try {
|
||||
if (window.OfflineAudioContext) {
|
||||
offlineContext = new window.OfflineAudioContext(1, newLength, newRate);
|
||||
} else if (window.webkitOfflineAudioContext) {
|
||||
offlineContext = new window.webkitOfflineAudioContext(1, newLength, newRate);
|
||||
}
|
||||
} catch {
|
||||
// If no OAC available and downsampling by 2, downsample by dropping every other sample.
|
||||
if (newRate === buffer.sampleRate / 2) {
|
||||
return resolve(dropEveryOtherSample(buffer));
|
||||
}
|
||||
return reject(new Error('Could not resample'));
|
||||
}
|
||||
const source = offlineContext.createBufferSource();
|
||||
const audioBuffer = offlineContext.createBuffer(1, buffer.samples.length, buffer.sampleRate);
|
||||
audioBuffer.getChannelData(0).set(buffer.samples);
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(offlineContext.destination);
|
||||
source.start();
|
||||
offlineContext.startRendering();
|
||||
offlineContext.oncomplete = ({renderedBuffer}) => {
|
||||
resolve({
|
||||
samples: renderedBuffer.getChannelData(0),
|
||||
sampleRate: newRate
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
paste () {
|
||||
// If there's no selection, paste at the end of the sound
|
||||
const {samples} = this.copyCurrentBuffer();
|
||||
if (this.state.trimStart === null) {
|
||||
const newLength = samples.length + this.state.copyBuffer.samples.length;
|
||||
const newSamples = new Float32Array(newLength);
|
||||
newSamples.set(samples, 0);
|
||||
newSamples.set(this.state.copyBuffer.samples, samples.length);
|
||||
this.submitNewSamples(newSamples, this.props.sampleRate, false).then(success => {
|
||||
if (success) {
|
||||
this.handlePlay();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// else replace the selection with the pasted sound
|
||||
const trimStartSamples = this.state.trimStart * samples.length;
|
||||
const trimEndSamples = this.state.trimEnd * samples.length;
|
||||
const firstPart = samples.slice(0, trimStartSamples);
|
||||
const lastPart = samples.slice(trimEndSamples);
|
||||
const newLength = firstPart.length + this.state.copyBuffer.samples.length + lastPart.length;
|
||||
const newSamples = new Float32Array(newLength);
|
||||
newSamples.set(firstPart, 0);
|
||||
newSamples.set(this.state.copyBuffer.samples, firstPart.length);
|
||||
newSamples.set(lastPart, firstPart.length + this.state.copyBuffer.samples.length);
|
||||
|
||||
const trimStartSeconds = trimStartSamples / this.props.sampleRate;
|
||||
const trimEndSeconds = trimStartSeconds +
|
||||
(this.state.copyBuffer.samples.length / this.state.copyBuffer.sampleRate);
|
||||
const newDurationSeconds = newSamples.length / this.state.copyBuffer.sampleRate;
|
||||
const adjustedTrimStart = trimStartSeconds / newDurationSeconds;
|
||||
const adjustedTrimEnd = trimEndSeconds / newDurationSeconds;
|
||||
this.submitNewSamples(newSamples, this.props.sampleRate, false).then(success => {
|
||||
if (success) {
|
||||
this.setState({
|
||||
trimStart: adjustedTrimStart,
|
||||
trimEnd: adjustedTrimEnd
|
||||
}, this.handlePlay);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
handlePaste () {
|
||||
if (!this.state.copyBuffer) return;
|
||||
if (this.state.copyBuffer.sampleRate === this.props.sampleRate) {
|
||||
this.paste();
|
||||
} else {
|
||||
this.resampleBufferToRate(this.state.copyBuffer, this.props.sampleRate).then(buffer => {
|
||||
this.setState({
|
||||
copyBuffer: buffer
|
||||
}, this.paste);
|
||||
});
|
||||
}
|
||||
}
|
||||
setRef (element) {
|
||||
this.ref = element;
|
||||
}
|
||||
handleContainerClick (e) {
|
||||
// If the click is on the sound editor's div (and not any other element), delesect
|
||||
if (e.target === this.ref && this.state.trimStart !== null) {
|
||||
this.handleUpdateTrim(null, null);
|
||||
}
|
||||
}
|
||||
render () {
|
||||
const {effectTypes} = AudioEffects;
|
||||
return (
|
||||
<SoundEditorComponent
|
||||
isStereo={this.props.isStereo}
|
||||
duration={this.props.duration}
|
||||
size={this.props.size}
|
||||
sampleRate={this.props.sampleRate}
|
||||
canPaste={this.state.copyBuffer !== null}
|
||||
canRedo={this.redoStack.length > 0}
|
||||
canUndo={this.undoStack.length > 0}
|
||||
chunkLevels={this.state.chunkLevels}
|
||||
name={this.props.name}
|
||||
playhead={this.state.playhead}
|
||||
setRef={this.setRef}
|
||||
tooLoud={this.tooLoud()}
|
||||
trimEnd={this.state.trimEnd}
|
||||
trimStart={this.state.trimStart}
|
||||
onChangeName={this.handleChangeName}
|
||||
onContainerClick={this.handleContainerClick}
|
||||
onCopy={this.handleCopy}
|
||||
onCopyToNew={this.handleCopyToNew}
|
||||
onDelete={this.handleDelete}
|
||||
onEcho={this.effectFactory(effectTypes.ECHO)}
|
||||
onFadeIn={this.effectFactory(effectTypes.FADEIN)}
|
||||
onFadeOut={this.effectFactory(effectTypes.FADEOUT)}
|
||||
onFaster={this.effectFactory(effectTypes.FASTER)}
|
||||
onLouder={this.effectFactory(effectTypes.LOUDER)}
|
||||
onMute={this.effectFactory(effectTypes.MUTE)}
|
||||
onPaste={this.handlePaste}
|
||||
onPlay={this.handlePlay}
|
||||
onRedo={this.handleRedo}
|
||||
onReverse={this.effectFactory(effectTypes.REVERSE)}
|
||||
onRobot={this.effectFactory(effectTypes.ROBOT)}
|
||||
onSetTrim={this.handleUpdateTrim}
|
||||
onSlower={this.effectFactory(effectTypes.SLOWER)}
|
||||
onSofter={this.effectFactory(effectTypes.SOFTER)}
|
||||
onStop={this.handleStopPlaying}
|
||||
onUndo={this.handleUndo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SoundEditor.propTypes = {
|
||||
isStereo: PropTypes.bool,
|
||||
duration: PropTypes.number,
|
||||
size: PropTypes.number,
|
||||
isFullScreen: PropTypes.bool,
|
||||
name: PropTypes.string.isRequired,
|
||||
sampleRate: PropTypes.number,
|
||||
samples: PropTypes.instanceOf(Float32Array),
|
||||
soundId: PropTypes.string,
|
||||
soundIndex: PropTypes.number,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, {soundIndex}) => {
|
||||
const sprite = state.scratchGui.vm.editingTarget.sprite;
|
||||
// Make sure the sound index doesn't go out of range.
|
||||
const index = soundIndex < sprite.sounds.length ? soundIndex : sprite.sounds.length - 1;
|
||||
const sound = state.scratchGui.vm.editingTarget.sprite.sounds[index];
|
||||
const audioBuffer = state.scratchGui.vm.getSoundBuffer(index);
|
||||
return {
|
||||
isStereo: audioBuffer.numberOfChannels !== 1,
|
||||
duration: sound.sampleCount / sound.rate,
|
||||
size: sound.asset ? sound.asset.data.byteLength : 0,
|
||||
soundId: sound.soundId,
|
||||
sampleRate: audioBuffer.sampleRate,
|
||||
samples: audioBuffer.getChannelData(0),
|
||||
isFullScreen: state.scratchGui.mode.isFullScreen,
|
||||
name: sound.name,
|
||||
vm: state.scratchGui.vm
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps
|
||||
)(SoundEditor);
|
||||
216
scratch-gui/src/containers/sound-library.jsx
Normal file
216
scratch-gui/src/containers/sound-library.jsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
import VM from 'scratch-vm';
|
||||
import AudioEngine from 'scratch-audio';
|
||||
import SharedAudioContext from '../lib/audio/shared-audio-context';
|
||||
|
||||
import LibraryComponent from '../components/library/library.jsx';
|
||||
|
||||
import soundIcon from '../components/library-item/lib-icon--sound.svg';
|
||||
import soundIconRtl from '../components/library-item/lib-icon--sound-rtl.svg';
|
||||
|
||||
import {getSoundLibrary} from '../lib/libraries/tw-async-libraries';
|
||||
import soundTags from '../lib/libraries/sound-tags';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
const messages = defineMessages({
|
||||
libraryTitle: {
|
||||
defaultMessage: 'Choose a Sound',
|
||||
description: 'Heading for the sound library',
|
||||
id: 'gui.soundLibrary.chooseASound'
|
||||
}
|
||||
});
|
||||
|
||||
// @todo need to use this hack to avoid library using md5 for image
|
||||
const getSoundLibraryThumbnailData = (soundLibraryContent, isRtl) => soundLibraryContent.map(sound => {
|
||||
const {
|
||||
md5ext,
|
||||
...otherData
|
||||
} = sound;
|
||||
return {
|
||||
_md5: md5ext,
|
||||
rawURL: isRtl ? soundIconRtl : soundIcon,
|
||||
...otherData
|
||||
};
|
||||
});
|
||||
|
||||
class SoundLibrary extends React.PureComponent {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleItemSelected',
|
||||
'handleItemMouseEnter',
|
||||
'handleItemMouseLeave',
|
||||
'onStop',
|
||||
'setStopHandler'
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* function to call when the sound ends
|
||||
*/
|
||||
this.handleStop = null;
|
||||
|
||||
this.state = {
|
||||
data: null
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
const soundLibrary = getSoundLibrary();
|
||||
if (soundLibrary.then) {
|
||||
soundLibrary.then(data => this.setState({
|
||||
data: getSoundLibraryThumbnailData(data, this.props.isRtl)
|
||||
}));
|
||||
} else {
|
||||
this.setState({
|
||||
data: getSoundLibraryThumbnailData(soundLibrary, this.props.isRtl)
|
||||
});
|
||||
}
|
||||
|
||||
this.audioEngine = new AudioEngine(new SharedAudioContext());
|
||||
this.playingSoundPromise = null;
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.stopPlayingSound();
|
||||
}
|
||||
onStop () {
|
||||
if (this.playingSoundPromise !== null) {
|
||||
this.playingSoundPromise.then(soundPlayer =>
|
||||
soundPlayer && soundPlayer.removeListener('stop', this.onStop));
|
||||
if (this.handleStop) this.handleStop();
|
||||
}
|
||||
|
||||
}
|
||||
setStopHandler (func) {
|
||||
this.handleStop = func;
|
||||
}
|
||||
stopPlayingSound () {
|
||||
// Playback is queued, playing, or has played recently and finished
|
||||
// normally.
|
||||
if (this.playingSoundPromise !== null) {
|
||||
// Forcing sound to stop, so stop listening for sound ending:
|
||||
this.playingSoundPromise.then(soundPlayer =>
|
||||
soundPlayer && soundPlayer.removeListener('stop', this.onStop));
|
||||
// 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 => {
|
||||
if (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;
|
||||
|
||||
// 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 => {
|
||||
if (soundAsset) {
|
||||
const sound = {
|
||||
md5: md5ext,
|
||||
name: soundItem.name,
|
||||
format: soundItem.format,
|
||||
data: soundAsset.data
|
||||
};
|
||||
return this.audioEngine.decodeSoundPlayer(sound)
|
||||
.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();
|
||||
soundPlayer.addListener('stop', this.onStop);
|
||||
// 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.stopPlayingSound();
|
||||
}
|
||||
handleItemSelected (soundItem) {
|
||||
const vmSound = {
|
||||
format: soundItem.format,
|
||||
md5: soundItem._md5,
|
||||
rate: soundItem.rate,
|
||||
sampleCount: soundItem.sampleCount,
|
||||
name: soundItem.name
|
||||
};
|
||||
this.props.vm.addSound(vmSound).then(() => {
|
||||
this.props.onNewSound();
|
||||
});
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<LibraryComponent
|
||||
showPlayButton
|
||||
data={this.state.data}
|
||||
id="soundLibrary"
|
||||
setStopHandler={this.setStopHandler}
|
||||
tags={soundTags}
|
||||
title={this.props.intl.formatMessage(messages.libraryTitle)}
|
||||
onItemMouseEnter={this.handleItemMouseEnter}
|
||||
onItemMouseLeave={this.handleItemMouseLeave}
|
||||
onItemSelected={this.handleItemSelected}
|
||||
onRequestClose={this.props.onRequestClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SoundLibrary.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isRtl: PropTypes.bool,
|
||||
onNewSound: PropTypes.func.isRequired,
|
||||
onRequestClose: PropTypes.func,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isRtl: state.locales.isRtl
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SoundLibrary));
|
||||
349
scratch-gui/src/containers/sound-tab.jsx
Normal file
349
scratch-gui/src/containers/sound-tab.jsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {defineMessages, intlShape, injectIntl} from 'react-intl';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import AssetPanel from '../components/asset-panel/asset-panel.jsx';
|
||||
import soundIcon from '../components/asset-panel/icon--sound.svg';
|
||||
import soundIconRtl from '../components/asset-panel/icon--sound-rtl.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 searchIcon from '../components/action-menu/icon--search.svg';
|
||||
|
||||
import RecordModal from './record-modal.jsx';
|
||||
import SoundEditor from './sound-editor.jsx';
|
||||
import SoundLibrary from './sound-library.jsx';
|
||||
import SoundEditorNotSupported from '../components/tw-sound-editor-not-supported/sound-editor-not-supported.jsx';
|
||||
|
||||
import {getSoundLibrary} from '../lib/libraries/tw-async-libraries';
|
||||
import {handleFileUpload, soundUpload} from '../lib/file-uploader.js';
|
||||
import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx';
|
||||
import DragConstants from '../lib/drag-constants';
|
||||
import downloadBlob from '../lib/download-blob';
|
||||
import SharedAudioContext from '../lib/audio/shared-audio-context.js';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {
|
||||
closeSoundLibrary,
|
||||
openSoundLibrary,
|
||||
openSoundRecorder
|
||||
} from '../reducers/modals';
|
||||
|
||||
import {
|
||||
activateTab,
|
||||
COSTUMES_TAB_INDEX
|
||||
} from '../reducers/editor-tab';
|
||||
|
||||
import {setRestore} from '../reducers/restore-deletion';
|
||||
import {showStandardAlert, closeAlertWithId} from '../reducers/alerts';
|
||||
|
||||
class SoundTab extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleSelectSound',
|
||||
'handleDeleteSound',
|
||||
'handleDuplicateSound',
|
||||
'handleExportSound',
|
||||
'handleNewSound',
|
||||
'handleSurpriseSound',
|
||||
'handleFileUploadClick',
|
||||
'handleSoundUpload',
|
||||
'handleDrop',
|
||||
'setFileInput'
|
||||
]);
|
||||
this.state = {selectedSoundIndex: 0};
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const {
|
||||
editingTarget,
|
||||
sprites,
|
||||
stage
|
||||
} = nextProps;
|
||||
|
||||
const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage;
|
||||
if (!target || !target.sounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If switching editing targets, reset the sound index
|
||||
if (this.props.editingTarget !== editingTarget) {
|
||||
this.setState({selectedSoundIndex: 0});
|
||||
} else if (this.state.selectedSoundIndex > target.sounds.length - 1) {
|
||||
this.setState({selectedSoundIndex: Math.max(target.sounds.length - 1, 0)});
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectSound (soundIndex) {
|
||||
this.setState({selectedSoundIndex: soundIndex});
|
||||
}
|
||||
|
||||
handleDeleteSound (soundIndex) {
|
||||
const restoreFun = this.props.vm.deleteSound(soundIndex);
|
||||
if (soundIndex >= this.state.selectedSoundIndex) {
|
||||
this.setState({selectedSoundIndex: Math.max(0, soundIndex - 1)});
|
||||
}
|
||||
this.props.dispatchUpdateRestore({restoreFun, deletedItem: 'Sound'});
|
||||
}
|
||||
|
||||
handleExportSound (soundIndex) {
|
||||
const item = this.props.vm.editingTarget.sprite.sounds[soundIndex];
|
||||
const blob = new Blob([item.asset.data], {type: item.asset.assetType.contentType});
|
||||
downloadBlob(`${item.name}.${item.asset.dataFormat}`, blob);
|
||||
}
|
||||
|
||||
handleDuplicateSound (soundIndex) {
|
||||
this.props.vm.duplicateSound(soundIndex).then(() => {
|
||||
this.setState({selectedSoundIndex: soundIndex + 1});
|
||||
});
|
||||
}
|
||||
|
||||
handleNewSound () {
|
||||
if (!this.props.vm.editingTarget) {
|
||||
return null;
|
||||
}
|
||||
const sprite = this.props.vm.editingTarget.sprite;
|
||||
const sounds = sprite.sounds ? sprite.sounds : [];
|
||||
this.setState({selectedSoundIndex: Math.max(sounds.length - 1, 0)});
|
||||
}
|
||||
|
||||
async handleSurpriseSound () {
|
||||
const soundLibraryContent = await getSoundLibrary();
|
||||
const soundItem = soundLibraryContent[Math.floor(Math.random() * soundLibraryContent.length)];
|
||||
const vmSound = {
|
||||
format: soundItem.dataFormat,
|
||||
md5: soundItem.md5ext,
|
||||
rate: soundItem.rate,
|
||||
sampleCount: soundItem.sampleCount,
|
||||
name: soundItem.name
|
||||
};
|
||||
this.props.vm.addSound(vmSound).then(() => {
|
||||
this.handleNewSound();
|
||||
});
|
||||
}
|
||||
|
||||
handleFileUploadClick () {
|
||||
this.fileInput.click();
|
||||
}
|
||||
|
||||
handleSoundUpload (e) {
|
||||
const storage = this.props.vm.runtime.storage;
|
||||
const targetId = this.props.vm.editingTarget.id;
|
||||
this.props.onShowImporting();
|
||||
handleFileUpload(e.target, (buffer, fileType, fileName, fileIndex, fileCount) => {
|
||||
soundUpload(buffer, fileType, storage, newSound => {
|
||||
newSound.name = fileName;
|
||||
this.props.vm.addSound(newSound, targetId).then(() => {
|
||||
this.handleNewSound();
|
||||
if (fileIndex === fileCount - 1) {
|
||||
this.props.onCloseImporting();
|
||||
}
|
||||
});
|
||||
}, this.props.onCloseImporting);
|
||||
}, this.props.onCloseImporting);
|
||||
}
|
||||
|
||||
handleDrop (dropInfo) {
|
||||
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)});
|
||||
} else if (dropInfo.dragType === DragConstants.BACKPACK_COSTUME) {
|
||||
this.props.onActivateCostumesTab();
|
||||
this.props.vm.addCostume(dropInfo.payload.body, {
|
||||
name: dropInfo.payload.name
|
||||
});
|
||||
} else if (dropInfo.dragType === DragConstants.BACKPACK_SOUND) {
|
||||
this.props.vm.addSound({
|
||||
md5: dropInfo.payload.body,
|
||||
name: dropInfo.payload.name
|
||||
}).then(this.handleNewSound);
|
||||
}
|
||||
}
|
||||
|
||||
setFileInput (input) {
|
||||
this.fileInput = input;
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
dispatchUpdateRestore, // eslint-disable-line no-unused-vars
|
||||
intl,
|
||||
isRtl,
|
||||
vm,
|
||||
onNewSoundFromLibraryClick,
|
||||
onNewSoundFromRecordingClick
|
||||
} = this.props;
|
||||
|
||||
if (!vm.editingTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSupported = !!(vm.runtime.audioEngine && new SharedAudioContext());
|
||||
|
||||
const sprite = vm.editingTarget.sprite;
|
||||
|
||||
const sounds = sprite.sounds ? sprite.sounds.map(sound => (
|
||||
{
|
||||
url: isRtl ? soundIconRtl : soundIcon,
|
||||
name: sound.name,
|
||||
details: (sound.sampleCount / sound.rate).toFixed(2),
|
||||
dragPayload: sound
|
||||
}
|
||||
)) : [];
|
||||
|
||||
const messages = defineMessages({
|
||||
fileUploadSound: {
|
||||
defaultMessage: 'Upload Sound',
|
||||
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',
|
||||
description: 'Button to record a sound in the editor tab',
|
||||
id: 'gui.soundTab.recordSound'
|
||||
},
|
||||
addSound: {
|
||||
defaultMessage: 'Choose a Sound',
|
||||
description: 'Button to add a sound in the editor tab',
|
||||
id: 'gui.soundTab.addSoundFromLibrary'
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<AssetPanel
|
||||
buttons={isSupported ? [{
|
||||
title: intl.formatMessage(messages.addSound),
|
||||
img: addSoundFromLibraryIcon,
|
||||
onClick: onNewSoundFromLibraryClick
|
||||
}, {
|
||||
title: intl.formatMessage(messages.fileUploadSound),
|
||||
img: fileUploadIcon,
|
||||
onClick: this.handleFileUploadClick,
|
||||
fileAccept: '.wav, .mp3, .ogg, .flac, .aac, .m4a',
|
||||
fileChange: this.handleSoundUpload,
|
||||
fileInput: this.setFileInput,
|
||||
fileMultiple: true
|
||||
}, {
|
||||
title: intl.formatMessage(messages.surpriseSound),
|
||||
img: surpriseIcon,
|
||||
onClick: this.handleSurpriseSound
|
||||
}, {
|
||||
title: intl.formatMessage(messages.recordSound),
|
||||
img: addSoundFromRecordingIcon,
|
||||
onClick: onNewSoundFromRecordingClick
|
||||
}, {
|
||||
title: intl.formatMessage(messages.addSound),
|
||||
img: searchIcon,
|
||||
onClick: onNewSoundFromLibraryClick
|
||||
}] : []}
|
||||
dragType={DragConstants.SOUND}
|
||||
isRtl={isRtl}
|
||||
items={sounds}
|
||||
selectedItemIndex={this.state.selectedSoundIndex}
|
||||
onDeleteClick={this.handleDeleteSound}
|
||||
onDrop={this.handleDrop}
|
||||
onDuplicateClick={this.handleDuplicateSound}
|
||||
onExportClick={this.handleExportSound}
|
||||
onItemClick={this.handleSelectSound}
|
||||
>
|
||||
{sprite.sounds && sprite.sounds[this.state.selectedSoundIndex] ? (
|
||||
isSupported ? (
|
||||
<SoundEditor soundIndex={this.state.selectedSoundIndex} />
|
||||
) : (
|
||||
<SoundEditorNotSupported />
|
||||
)
|
||||
) : null}
|
||||
{this.props.soundRecorderVisible ? (
|
||||
<RecordModal
|
||||
onNewSound={this.handleNewSound}
|
||||
/>
|
||||
) : null}
|
||||
{this.props.soundLibraryVisible ? (
|
||||
<SoundLibrary
|
||||
vm={this.props.vm}
|
||||
onNewSound={this.handleNewSound}
|
||||
onRequestClose={this.props.onRequestCloseSoundLibrary}
|
||||
/>
|
||||
) : null}
|
||||
</AssetPanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SoundTab.propTypes = {
|
||||
dispatchUpdateRestore: PropTypes.func,
|
||||
editingTarget: PropTypes.string,
|
||||
intl: intlShape,
|
||||
isRtl: PropTypes.bool,
|
||||
onActivateCostumesTab: PropTypes.func.isRequired,
|
||||
onCloseImporting: PropTypes.func.isRequired,
|
||||
onNewSoundFromLibraryClick: PropTypes.func.isRequired,
|
||||
onNewSoundFromRecordingClick: PropTypes.func.isRequired,
|
||||
onRequestCloseSoundLibrary: PropTypes.func.isRequired,
|
||||
onShowImporting: PropTypes.func.isRequired,
|
||||
soundLibraryVisible: PropTypes.bool,
|
||||
soundRecorderVisible: PropTypes.bool,
|
||||
sprites: PropTypes.shape({
|
||||
id: PropTypes.shape({
|
||||
sounds: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string.isRequired
|
||||
}))
|
||||
})
|
||||
}),
|
||||
stage: PropTypes.shape({
|
||||
sounds: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string.isRequired
|
||||
}))
|
||||
}),
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
editingTarget: state.scratchGui.targets.editingTarget,
|
||||
isRtl: state.locales.isRtl,
|
||||
sprites: state.scratchGui.targets.sprites,
|
||||
stage: state.scratchGui.targets.stage,
|
||||
soundLibraryVisible: state.scratchGui.modals.soundLibrary,
|
||||
soundRecorderVisible: state.scratchGui.modals.soundRecorder
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onActivateCostumesTab: () => dispatch(activateTab(COSTUMES_TAB_INDEX)),
|
||||
onNewSoundFromLibraryClick: e => {
|
||||
e.preventDefault();
|
||||
dispatch(openSoundLibrary());
|
||||
},
|
||||
onNewSoundFromRecordingClick: () => {
|
||||
dispatch(openSoundRecorder());
|
||||
},
|
||||
onRequestCloseSoundLibrary: () => {
|
||||
dispatch(closeSoundLibrary());
|
||||
},
|
||||
dispatchUpdateRestore: restoreState => {
|
||||
dispatch(setRestore(restoreState));
|
||||
},
|
||||
onCloseImporting: () => dispatch(closeAlertWithId('importingAsset')),
|
||||
onShowImporting: () => dispatch(showStandardAlert('importingAsset'))
|
||||
});
|
||||
|
||||
export default errorBoundaryHOC('Sound Tab')(
|
||||
injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SoundTab))
|
||||
);
|
||||
46
scratch-gui/src/containers/sprite-info.jsx
Normal file
46
scratch-gui/src/containers/sprite-info.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import SpriteInfoComponent from '../components/sprite-info/sprite-info.jsx';
|
||||
|
||||
class SpriteInfo extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleClickVisible',
|
||||
'handleClickNotVisible'
|
||||
]);
|
||||
}
|
||||
handleClickVisible (e) {
|
||||
e.preventDefault();
|
||||
this.props.onChangeVisibility(true);
|
||||
}
|
||||
handleClickNotVisible (e) {
|
||||
e.preventDefault();
|
||||
this.props.onChangeVisibility(false);
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<SpriteInfoComponent
|
||||
{...this.props}
|
||||
onClickNotVisible={this.handleClickNotVisible}
|
||||
onClickVisible={this.handleClickVisible}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SpriteInfo.propTypes = {
|
||||
...SpriteInfoComponent.propTypes,
|
||||
onChangeDirection: PropTypes.func,
|
||||
onChangeName: PropTypes.func,
|
||||
onChangeSize: PropTypes.func,
|
||||
onChangeVisibility: PropTypes.func,
|
||||
onChangeX: PropTypes.func,
|
||||
onChangeY: PropTypes.func,
|
||||
x: PropTypes.number,
|
||||
y: PropTypes.number
|
||||
};
|
||||
|
||||
export default SpriteInfo;
|
||||
67
scratch-gui/src/containers/sprite-library.jsx
Normal file
67
scratch-gui/src/containers/sprite-library.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {injectIntl, intlShape, defineMessages} from 'react-intl';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import {getSpriteLibrary} from '../lib/libraries/tw-async-libraries';
|
||||
import randomizeSpritePosition from '../lib/randomize-sprite-position';
|
||||
import spriteTags from '../lib/libraries/sprite-tags';
|
||||
|
||||
import LibraryComponent from '../components/library/library.jsx';
|
||||
|
||||
const messages = defineMessages({
|
||||
libraryTitle: {
|
||||
defaultMessage: 'Choose a Sprite',
|
||||
description: 'Heading for the sprite library',
|
||||
id: 'gui.spriteLibrary.chooseASprite'
|
||||
}
|
||||
});
|
||||
|
||||
class SpriteLibrary extends React.PureComponent {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleItemSelect'
|
||||
]);
|
||||
this.state = {
|
||||
data: getSpriteLibrary()
|
||||
};
|
||||
}
|
||||
componentDidMount () {
|
||||
if (this.state.data.then) {
|
||||
this.state.data.then(data => this.setState({
|
||||
data
|
||||
}));
|
||||
}
|
||||
}
|
||||
handleItemSelect (item) {
|
||||
// Randomize position of library sprite
|
||||
randomizeSpritePosition(item);
|
||||
this.props.vm.addSprite(JSON.stringify(item)).then(() => {
|
||||
this.props.onActivateBlocksTab();
|
||||
});
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<LibraryComponent
|
||||
data={this.state.data.then ? null : this.state.data}
|
||||
id="spriteLibrary"
|
||||
tags={spriteTags}
|
||||
title={this.props.intl.formatMessage(messages.libraryTitle)}
|
||||
removedTrademarks
|
||||
onItemSelected={this.handleItemSelect}
|
||||
onRequestClose={this.props.onRequestClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SpriteLibrary.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onActivateBlocksTab: PropTypes.func.isRequired,
|
||||
onRequestClose: PropTypes.func,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
export default injectIntl(SpriteLibrary);
|
||||
199
scratch-gui/src/containers/sprite-selector-item.jsx
Normal file
199
scratch-gui/src/containers/sprite-selector-item.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
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 {updateAssetDrag} from '../reducers/asset-drag';
|
||||
import VM from 'scratch-vm';
|
||||
import getCostumeUrl from '../lib/get-costume-url';
|
||||
import DragRecognizer from '../lib/drag-recognizer';
|
||||
import {getEventXY} from '../lib/touch-utils';
|
||||
|
||||
import SpriteSelectorItemComponent from '../components/sprite-selector-item/sprite-selector-item.jsx';
|
||||
|
||||
class SpriteSelectorItem extends React.PureComponent {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'getCostumeData',
|
||||
'setRef',
|
||||
'handleClick',
|
||||
'handleDelete',
|
||||
'handleDuplicate',
|
||||
'handleExport',
|
||||
'handleRename',
|
||||
'handleMouseEnter',
|
||||
'handleMouseLeave',
|
||||
'handleMouseDown',
|
||||
'handleDragEnd',
|
||||
'handleDrag',
|
||||
'handleTouchEnd'
|
||||
]);
|
||||
|
||||
this.dragRecognizer = new DragRecognizer({
|
||||
onDrag: this.handleDrag,
|
||||
onDragEnd: this.handleDragEnd
|
||||
});
|
||||
}
|
||||
componentDidMount () {
|
||||
document.addEventListener('touchend', this.handleTouchEnd);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('touchend', this.handleTouchEnd);
|
||||
this.dragRecognizer.reset();
|
||||
}
|
||||
getCostumeData () {
|
||||
if (this.props.costumeURL) return this.props.costumeURL;
|
||||
if (!this.props.asset) return null;
|
||||
|
||||
return getCostumeUrl(this.props.asset);
|
||||
}
|
||||
handleDragEnd () {
|
||||
if (this.props.dragging) {
|
||||
this.props.onDrag({
|
||||
img: null,
|
||||
currentOffset: null,
|
||||
dragging: false,
|
||||
dragType: null,
|
||||
index: null
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.noClick = false;
|
||||
});
|
||||
}
|
||||
handleDrag (currentOffset) {
|
||||
this.props.onDrag({
|
||||
img: this.getCostumeData(),
|
||||
currentOffset: currentOffset,
|
||||
dragging: true,
|
||||
dragType: this.props.dragType,
|
||||
index: this.props.index,
|
||||
payload: this.props.dragPayload
|
||||
});
|
||||
this.noClick = true;
|
||||
}
|
||||
handleTouchEnd (e) {
|
||||
const {x, y} = getEventXY(e);
|
||||
const {top, left, bottom, right} = this.ref.getBoundingClientRect();
|
||||
if (x >= left && x <= right && y >= top && y <= bottom) {
|
||||
this.handleMouseEnter();
|
||||
}
|
||||
}
|
||||
handleMouseDown (e) {
|
||||
this.dragRecognizer.start(e);
|
||||
}
|
||||
handleClick (e) {
|
||||
e.preventDefault();
|
||||
if (!this.noClick) {
|
||||
this.props.onClick(this.props.id);
|
||||
}
|
||||
}
|
||||
handleDelete (e) {
|
||||
e.stopPropagation(); // To prevent from bubbling back to handleClick
|
||||
this.props.onDeleteButtonClick(this.props.id);
|
||||
}
|
||||
handleDuplicate (e) {
|
||||
e.stopPropagation(); // To prevent from bubbling back to handleClick
|
||||
this.props.onDuplicateButtonClick(this.props.id);
|
||||
}
|
||||
handleExport (e) {
|
||||
e.stopPropagation();
|
||||
this.props.onExportButtonClick(this.props.id);
|
||||
}
|
||||
handleRename (e) {
|
||||
e.stopPropagation();
|
||||
this.props.onRenameButtonClick(this.props.id);
|
||||
}
|
||||
handleMouseLeave () {
|
||||
this.props.dispatchSetHoveredSprite(null);
|
||||
}
|
||||
handleMouseEnter () {
|
||||
this.props.dispatchSetHoveredSprite(this.props.id);
|
||||
}
|
||||
setRef (component) {
|
||||
// Access the DOM node using .elem because it is going through ContextMenuTrigger
|
||||
this.ref = component && component.elem;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
asset,
|
||||
id,
|
||||
index,
|
||||
onClick,
|
||||
onDeleteButtonClick,
|
||||
onDuplicateButtonClick,
|
||||
onExportButtonClick,
|
||||
onRenameButtonClick,
|
||||
dragPayload,
|
||||
receivedBlocks,
|
||||
costumeURL,
|
||||
vm,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<SpriteSelectorItemComponent
|
||||
componentRef={this.setRef}
|
||||
costumeURL={this.getCostumeData()}
|
||||
preventContextMenu={this.dragRecognizer.gestureInProgress()}
|
||||
onClick={this.handleClick}
|
||||
onDeleteButtonClick={onDeleteButtonClick ? this.handleDelete : null}
|
||||
onDuplicateButtonClick={onDuplicateButtonClick ? this.handleDuplicate : null}
|
||||
onExportButtonClick={onExportButtonClick ? this.handleExport : null}
|
||||
onRenameButtonClick={onRenameButtonClick ? this.handleRename : null}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SpriteSelectorItem.propTypes = {
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
asset: PropTypes.any,
|
||||
costumeURL: PropTypes.string,
|
||||
dispatchSetHoveredSprite: PropTypes.func.isRequired,
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
dragPayload: PropTypes.any,
|
||||
dragType: PropTypes.string,
|
||||
dragging: PropTypes.bool,
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
id: PropTypes.any,
|
||||
index: PropTypes.number,
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
name: PropTypes.any,
|
||||
onClick: PropTypes.func,
|
||||
onDeleteButtonClick: PropTypes.func,
|
||||
onRenameButtonClick: PropTypes.func,
|
||||
onDrag: PropTypes.func.isRequired,
|
||||
onDuplicateButtonClick: PropTypes.func,
|
||||
onExportButtonClick: PropTypes.func,
|
||||
receivedBlocks: PropTypes.bool.isRequired,
|
||||
selected: PropTypes.bool,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, {id}) => ({
|
||||
dragging: state.scratchGui.assetDrag.dragging,
|
||||
receivedBlocks: state.scratchGui.hoveredTarget.receivedBlocks &&
|
||||
state.scratchGui.hoveredTarget.sprite === id,
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
dispatchSetHoveredSprite: spriteId => {
|
||||
dispatch(setHoveredSprite(spriteId));
|
||||
},
|
||||
onDrag: data => dispatch(updateAssetDrag(data))
|
||||
});
|
||||
|
||||
const ConnectedComponent = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SpriteSelectorItem);
|
||||
|
||||
export default ConnectedComponent;
|
||||
13
scratch-gui/src/containers/stage-cpp-unity.jsx
Normal file
13
scratch-gui/src/containers/stage-cpp-unity.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import StageWrapperCppUnityCPLT from '../components/stage-cpp-unity/stage-unity.jsx';
|
||||
|
||||
const StageWrapperCppUnity = props => <StageWrapperCppUnityCPLT {...props} />;
|
||||
|
||||
// StageWrapperUnity.propTypes = {
|
||||
// isRendererSupported: PropTypes.bool.isRequired,
|
||||
// stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired,
|
||||
// vm: PropTypes.instanceOf(VM).isRequired
|
||||
// };
|
||||
|
||||
export default StageWrapperCppUnity;
|
||||
6
scratch-gui/src/containers/stage-cpp.jsx
Normal file
6
scratch-gui/src/containers/stage-cpp.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import StageWrapperCppClang from '../components/stage-cpp/stage-cpp-clang.jsx';
|
||||
|
||||
const StageWrapperCpp = props => <StageWrapperCppClang {...props} />;
|
||||
|
||||
export default StageWrapperCpp;
|
||||
109
scratch-gui/src/containers/stage-header.jsx
Normal file
109
scratch-gui/src/containers/stage-header.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import VM from 'scratch-vm';
|
||||
import {STAGE_DISPLAY_SCALE_METADATA, STAGE_DISPLAY_SIZES, STAGE_SIZE_MODES} from '../lib/layout-constants';
|
||||
import {setStageSize} from '../reducers/stage-size';
|
||||
import {setFullScreen} from '../reducers/mode';
|
||||
import {openSettingsModal} from '../reducers/modals';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import StageHeaderComponent from '../components/stage-header/stage-header.jsx';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
class StageHeader extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleKeyPress'
|
||||
]);
|
||||
this.checkInvalidStageSizeMode();
|
||||
}
|
||||
componentDidMount () {
|
||||
document.addEventListener('keydown', this.handleKeyPress);
|
||||
}
|
||||
componentDidUpdate () {
|
||||
this.checkInvalidStageSizeMode();
|
||||
}
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('keydown', this.handleKeyPress);
|
||||
}
|
||||
handleKeyPress (event) {
|
||||
if (event.key === 'Escape' && this.props.isFullScreen) {
|
||||
this.props.onSetStageUnFullScreen();
|
||||
}
|
||||
}
|
||||
checkInvalidStageSizeMode () {
|
||||
// Switch from "large" to "full" when the large option isn't even displayed in the interface
|
||||
if (this.props.stageSizeMode === STAGE_SIZE_MODES.large && !this.showFixedLargeSize()) {
|
||||
this.props.onSetStageFull();
|
||||
}
|
||||
}
|
||||
showFixedLargeSize () {
|
||||
// Fixed width "large" mode should only be available when it would be smaller than the constrained
|
||||
// full stage, otherwise there are some sizes where switching to the smaller size would make it
|
||||
// larger instead of smaller.
|
||||
const constrainedScale = STAGE_DISPLAY_SCALE_METADATA[STAGE_DISPLAY_SIZES.constrained].scale;
|
||||
const constrainedWidth = this.props.customStageSize.width * constrainedScale;
|
||||
const largeWidth = STAGE_DISPLAY_SCALE_METADATA[STAGE_DISPLAY_SIZES.large].width;
|
||||
return constrainedWidth > largeWidth;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<StageHeaderComponent
|
||||
{...props}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
showFixedLargeSize={this.showFixedLargeSize()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StageHeader.propTypes = {
|
||||
isFullScreen: PropTypes.bool.isRequired,
|
||||
// tw: update when dimensions or isWindowFullScreen changes
|
||||
isWindowFullScreen: PropTypes.bool.isRequired,
|
||||
customStageSize: PropTypes.shape({
|
||||
width: PropTypes.number.isRequired,
|
||||
height: PropTypes.number.isRequired
|
||||
}).isRequired,
|
||||
dimensions: PropTypes.arrayOf(PropTypes.number),
|
||||
isPlayerOnly: PropTypes.bool,
|
||||
onSetStageUnFullScreen: PropTypes.func.isRequired,
|
||||
onSetStageFull: PropTypes.func.isRequired,
|
||||
onOpenSettings: PropTypes.func.isRequired,
|
||||
// tw: replace showBranding
|
||||
isEmbedded: PropTypes.bool.isRequired,
|
||||
stageSizeMode: PropTypes.oneOf(Object.keys(STAGE_SIZE_MODES)).isRequired,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
customStageSize: state.scratchGui.customStageSize,
|
||||
stageSizeMode: state.scratchGui.stageSize.stageSize,
|
||||
// tw: replace showBranding
|
||||
isEmbedded: state.scratchGui.mode.isEmbedded,
|
||||
isFullScreen: state.scratchGui.mode.isFullScreen,
|
||||
// tw: update when dimensions or isWindowFullScreen changes
|
||||
isWindowFullScreen: state.scratchGui.tw.isWindowFullScreen,
|
||||
dimensions: state.scratchGui.tw.dimensions,
|
||||
isPlayerOnly: state.scratchGui.mode.isPlayerOnly
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSetStageLarge: () => dispatch(setStageSize(STAGE_SIZE_MODES.large)),
|
||||
onSetStageSmall: () => dispatch(setStageSize(STAGE_SIZE_MODES.small)),
|
||||
onSetStageFull: () => dispatch(setStageSize(STAGE_SIZE_MODES.full)),
|
||||
onSetStageFullScreen: () => dispatch(setFullScreen(true)),
|
||||
onSetStageUnFullScreen: () => dispatch(setFullScreen(false)),
|
||||
onOpenSettings: () => dispatch(openSettingsModal())
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(StageHeader);
|
||||
13
scratch-gui/src/containers/stage-python-unity.jsx
Normal file
13
scratch-gui/src/containers/stage-python-unity.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import StageWrapperPythonUnityCPLT from '../components/stage-python-unity/stage-unity.jsx';
|
||||
|
||||
const StageWrapperPythonUnity = props => <StageWrapperPythonUnityCPLT {...props} />;
|
||||
|
||||
// StageWrapperUnity.propTypes = {
|
||||
// isRendererSupported: PropTypes.bool.isRequired,
|
||||
// stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired,
|
||||
// vm: PropTypes.instanceOf(VM).isRequired
|
||||
// };
|
||||
|
||||
export default StageWrapperPythonUnity;
|
||||
7
scratch-gui/src/containers/stage-python.jsx
Normal file
7
scratch-gui/src/containers/stage-python.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import StageWrapperPythonCPLT from '../components/stage-python/stage-python.jsx';
|
||||
|
||||
const StageWrapperPython = props => <StageWrapperPythonCPLT {...props} />;
|
||||
|
||||
export default StageWrapperPython;
|
||||
230
scratch-gui/src/containers/stage-selector.jsx
Normal file
230
scratch-gui/src/containers/stage-selector.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import omit from 'lodash.omit';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {intlShape, injectIntl} from 'react-intl';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {openBackdropLibrary} from '../reducers/modals';
|
||||
import {activateTab, COSTUMES_TAB_INDEX} from '../reducers/editor-tab';
|
||||
import {showStandardAlert, closeAlertWithId} from '../reducers/alerts';
|
||||
import {setHoveredSprite} from '../reducers/hovered-target';
|
||||
import DragConstants from '../lib/drag-constants';
|
||||
import DropAreaHOC from '../lib/drop-area-hoc.jsx';
|
||||
import ThrottledPropertyHOC from '../lib/throttled-property-hoc.jsx';
|
||||
import {emptyCostume} from '../lib/empty-assets';
|
||||
import sharedMessages from '../lib/shared-messages';
|
||||
import {fetchCode} from '../lib/backpack-api';
|
||||
import {getEventXY} from '../lib/touch-utils';
|
||||
|
||||
import StageSelectorComponent from '../components/stage-selector/stage-selector.jsx';
|
||||
|
||||
import {getBackdropLibrary} from '../lib/libraries/tw-async-libraries';
|
||||
import {handleFileUpload, costumeUpload} from '../lib/file-uploader.js';
|
||||
import {placeInViewport} from '../lib/backpack/code-payload.js';
|
||||
|
||||
const dragTypes = [
|
||||
DragConstants.COSTUME,
|
||||
DragConstants.SOUND,
|
||||
DragConstants.BACKPACK_COSTUME,
|
||||
DragConstants.BACKPACK_SOUND,
|
||||
DragConstants.BACKPACK_CODE
|
||||
];
|
||||
|
||||
const DroppableThrottledStage = DropAreaHOC(dragTypes)(
|
||||
ThrottledPropertyHOC('url', 500)(StageSelectorComponent)
|
||||
);
|
||||
|
||||
class StageSelector extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleClick',
|
||||
'handleNewBackdrop',
|
||||
'handleSurpriseBackdrop',
|
||||
'handleEmptyBackdrop',
|
||||
'addBackdropFromLibraryItem',
|
||||
'handleFileUploadClick',
|
||||
'handleBackdropUpload',
|
||||
'handleMouseEnter',
|
||||
'handleMouseLeave',
|
||||
'handleTouchEnd',
|
||||
'handleDrop',
|
||||
'setFileInput',
|
||||
'setRef'
|
||||
]);
|
||||
}
|
||||
componentDidMount () {
|
||||
document.addEventListener('touchend', this.handleTouchEnd);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('touchend', this.handleTouchEnd);
|
||||
}
|
||||
handleTouchEnd (e) {
|
||||
const {x, y} = getEventXY(e);
|
||||
const {top, left, bottom, right} = this.ref.getBoundingClientRect();
|
||||
if (x >= left && x <= right && y >= top && y <= bottom) {
|
||||
this.handleMouseEnter();
|
||||
}
|
||||
}
|
||||
addBackdropFromLibraryItem (item, shouldActivateTab = true) {
|
||||
const vmBackdrop = {
|
||||
name: item.name,
|
||||
md5: item.md5ext,
|
||||
rotationCenterX: item.rotationCenterX,
|
||||
rotationCenterY: item.rotationCenterY,
|
||||
bitmapResolution: item.bitmapResolution,
|
||||
skinId: null
|
||||
};
|
||||
this.handleNewBackdrop(vmBackdrop, shouldActivateTab);
|
||||
}
|
||||
handleClick () {
|
||||
this.props.onSelect(this.props.id);
|
||||
}
|
||||
handleNewBackdrop (backdrops_, shouldActivateTab = true) {
|
||||
const backdrops = Array.isArray(backdrops_) ? backdrops_ : [backdrops_];
|
||||
return Promise.all(backdrops.map(backdrop =>
|
||||
this.props.vm.addBackdrop(backdrop.md5, backdrop)
|
||||
)).then(() => {
|
||||
if (shouldActivateTab) {
|
||||
return this.props.onActivateTab(COSTUMES_TAB_INDEX);
|
||||
}
|
||||
});
|
||||
}
|
||||
async handleSurpriseBackdrop (e) {
|
||||
e.stopPropagation(); // Prevent click from falling through to selecting stage.
|
||||
const backdropLibraryContent = await getBackdropLibrary();
|
||||
// @todo should this not add a backdrop you already have?
|
||||
const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
|
||||
this.addBackdropFromLibraryItem(item, false);
|
||||
}
|
||||
handleEmptyBackdrop (e) {
|
||||
e.stopPropagation(); // Prevent click from falling through to stage selector, select it manually below
|
||||
this.props.vm.setEditingTarget(this.props.id);
|
||||
this.handleNewBackdrop(emptyCostume(this.props.intl.formatMessage(sharedMessages.backdrop, {index: 1})));
|
||||
}
|
||||
handleBackdropUpload (e) {
|
||||
const vm = this.props.vm;
|
||||
this.props.onShowImporting();
|
||||
handleFileUpload(e.target, (buffer, fileType, fileName, fileIndex, fileCount) => {
|
||||
costumeUpload(buffer, fileType, vm, vmCostumes => {
|
||||
this.props.vm.setEditingTarget(this.props.id);
|
||||
vmCostumes.forEach((costume, i) => {
|
||||
costume.name = `${fileName}${i ? i + 1 : ''}`;
|
||||
});
|
||||
this.handleNewBackdrop(vmCostumes).then(() => {
|
||||
if (fileIndex === fileCount - 1) {
|
||||
this.props.onCloseImporting();
|
||||
}
|
||||
});
|
||||
}, this.props.onCloseImporting);
|
||||
}, this.props.onCloseImporting);
|
||||
}
|
||||
handleFileUploadClick (e) {
|
||||
e.stopPropagation(); // Prevent click from selecting the stage, that is handled manually in backdrop upload
|
||||
this.fileInput.click();
|
||||
}
|
||||
handleMouseEnter () {
|
||||
this.props.dispatchSetHoveredSprite(this.props.id);
|
||||
}
|
||||
handleMouseLeave () {
|
||||
this.props.dispatchSetHoveredSprite(null);
|
||||
}
|
||||
handleDrop (dragInfo) {
|
||||
if (dragInfo.dragType === DragConstants.COSTUME) {
|
||||
this.props.vm.shareCostumeToTarget(dragInfo.index, this.props.id);
|
||||
} else if (dragInfo.dragType === DragConstants.SOUND) {
|
||||
this.props.vm.shareSoundToTarget(dragInfo.index, this.props.id);
|
||||
} else if (dragInfo.dragType === DragConstants.BACKPACK_COSTUME) {
|
||||
this.props.vm.addCostume(dragInfo.payload.body, {
|
||||
name: dragInfo.payload.name
|
||||
}, this.props.id);
|
||||
} else if (dragInfo.dragType === DragConstants.BACKPACK_SOUND) {
|
||||
this.props.vm.addSound({
|
||||
md5: dragInfo.payload.body,
|
||||
name: dragInfo.payload.name
|
||||
}, this.props.id);
|
||||
} else if (dragInfo.dragType === DragConstants.BACKPACK_CODE) {
|
||||
fetchCode(dragInfo.payload.bodyUrl)
|
||||
.then(payload => {
|
||||
const centered = placeInViewport(
|
||||
payload,
|
||||
this.props.workspaceMetrics.targets[this.props.id],
|
||||
this.props.isRtl
|
||||
);
|
||||
this.props.vm.shareBlocksToTarget(centered, this.props.id);
|
||||
this.props.vm.refreshWorkspace();
|
||||
});
|
||||
}
|
||||
}
|
||||
setFileInput (input) {
|
||||
this.fileInput = input;
|
||||
}
|
||||
setRef (ref) {
|
||||
this.ref = ref;
|
||||
}
|
||||
render () {
|
||||
const componentProps = omit(this.props, [
|
||||
'asset', 'dispatchSetHoveredSprite', 'id', 'intl',
|
||||
'onActivateTab', 'onSelect', 'onShowImporting', 'onCloseImporting',
|
||||
'isRtl', 'workspaceMetrics'
|
||||
]);
|
||||
return (
|
||||
<DroppableThrottledStage
|
||||
componentRef={this.setRef}
|
||||
fileInputRef={this.setFileInput}
|
||||
onBackdropFileUpload={this.handleBackdropUpload}
|
||||
onBackdropFileUploadClick={this.handleFileUploadClick}
|
||||
onClick={this.handleClick}
|
||||
onDrop={this.handleDrop}
|
||||
onEmptyBackdropClick={this.handleEmptyBackdrop}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onSurpriseBackdropClick={this.handleSurpriseBackdrop}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
StageSelector.propTypes = {
|
||||
...StageSelectorComponent.propTypes,
|
||||
id: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
isRtl: PropTypes.bool,
|
||||
onCloseImporting: PropTypes.func,
|
||||
onSelect: PropTypes.func,
|
||||
onShowImporting: PropTypes.func,
|
||||
workspaceMetrics: PropTypes.shape({
|
||||
targets: PropTypes.object
|
||||
})
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, {asset, id}) => ({
|
||||
isRtl: state.locales.isRtl,
|
||||
url: asset && asset.encodeDataURI(),
|
||||
vm: state.scratchGui.vm,
|
||||
receivedBlocks: state.scratchGui.hoveredTarget.receivedBlocks &&
|
||||
state.scratchGui.hoveredTarget.sprite === id,
|
||||
raised: state.scratchGui.blockDrag,
|
||||
workspaceMetrics: state.scratchGui.workspaceMetrics
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onNewBackdropClick: e => {
|
||||
e.stopPropagation();
|
||||
dispatch(openBackdropLibrary());
|
||||
},
|
||||
onActivateTab: tabIndex => {
|
||||
dispatch(activateTab(tabIndex));
|
||||
},
|
||||
dispatchSetHoveredSprite: spriteId => {
|
||||
dispatch(setHoveredSprite(spriteId));
|
||||
},
|
||||
onCloseImporting: () => dispatch(closeAlertWithId('importingAsset')),
|
||||
onShowImporting: () => dispatch(showStandardAlert('importingAsset'))
|
||||
});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(StageSelector));
|
||||
13
scratch-gui/src/containers/stage-unity.jsx
Normal file
13
scratch-gui/src/containers/stage-unity.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import StageWrapperUnityCPLT from '../components/stage-unity/stage-unity.jsx';
|
||||
|
||||
const StageWrapperUnity = props => <StageWrapperUnityCPLT {...props} />;
|
||||
|
||||
// StageWrapperUnity.propTypes = {
|
||||
// isRendererSupported: PropTypes.bool.isRequired,
|
||||
// stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired,
|
||||
// vm: PropTypes.instanceOf(VM).isRequired
|
||||
// };
|
||||
|
||||
export default StageWrapperUnity;
|
||||
15
scratch-gui/src/containers/stage-wrapper.jsx
Normal file
15
scratch-gui/src/containers/stage-wrapper.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
};
|
||||
|
||||
export default StageWrapper;
|
||||
522
scratch-gui/src/containers/stage.jsx
Normal file
522
scratch-gui/src/containers/stage.jsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
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 {BitmapAdapter as V2BitmapAdapter} from '@turbowarp/scratch-svg-renderer';
|
||||
|
||||
import StageComponent from '../components/stage/stage.jsx';
|
||||
|
||||
import {
|
||||
activateColorPicker,
|
||||
deactivateColorPicker
|
||||
} from '../reducers/color-picker';
|
||||
|
||||
import {setHighQualityPenState} from '../reducers/tw';
|
||||
|
||||
const colorPickerRadius = 20;
|
||||
const dragThreshold = 3; // Same as the block drag threshold
|
||||
|
||||
class Stage extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'attachMouseEvents',
|
||||
'cancelMouseDownTimeout',
|
||||
'detachMouseEvents',
|
||||
'handleDoubleClick',
|
||||
'handleQuestionAnswered',
|
||||
'onMouseUp',
|
||||
'onMouseMove',
|
||||
'onMouseDown',
|
||||
'onStartDrag',
|
||||
'onStopDrag',
|
||||
'onWheel',
|
||||
'onContextMenu',
|
||||
'updateRect',
|
||||
'questionListener',
|
||||
'setDragCanvas',
|
||||
'clearDragCanvas',
|
||||
'drawDragCanvas',
|
||||
'positionDragCanvas'
|
||||
]);
|
||||
this.state = {
|
||||
mouseDownTimeoutId: null,
|
||||
mouseDownPosition: null,
|
||||
isDragging: false,
|
||||
dragOffset: null,
|
||||
dragId: null,
|
||||
colorInfo: null,
|
||||
question: null
|
||||
};
|
||||
if (this.props.vm.renderer) {
|
||||
this.renderer = this.props.vm.renderer;
|
||||
this.canvas = this.renderer.canvas;
|
||||
} else {
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.renderer = new Renderer(
|
||||
this.canvas,
|
||||
-this.props.customStageSize.width / 2,
|
||||
this.props.customStageSize.width / 2,
|
||||
-this.props.customStageSize.height / 2,
|
||||
this.props.customStageSize.height / 2
|
||||
);
|
||||
this.props.vm.setStageSize(
|
||||
this.props.customStageSize.width,
|
||||
this.props.customStageSize.height
|
||||
);
|
||||
this.props.vm.attachRenderer(this.renderer);
|
||||
|
||||
// Only attach a video provider once because it is stateful
|
||||
this.props.vm.setVideoProvider(new VideoProvider());
|
||||
|
||||
// Calling draw a single time before any project is loaded just makes
|
||||
// the canvas white instead of solid black–needed because it is not
|
||||
// possible to use CSS to style the canvas to have a different
|
||||
// default color
|
||||
// this.props.vm.renderer.draw();
|
||||
|
||||
// tw: handle changes to high quality pen
|
||||
this.props.vm.renderer.on('UseHighQualityRenderChanged', this.props.onHighQualityPenChanged);
|
||||
}
|
||||
this.props.vm.attachV2BitmapAdapter(new V2BitmapAdapter());
|
||||
}
|
||||
componentDidMount () {
|
||||
this.attachRectEvents();
|
||||
this.attachMouseEvents(this.canvas);
|
||||
this.updateRect();
|
||||
this.props.vm.runtime.addListener('QUESTION', this.questionListener);
|
||||
}
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
return this.props.stageSize !== nextProps.stageSize ||
|
||||
this.props.isColorPicking !== nextProps.isColorPicking ||
|
||||
this.state.colorInfo !== nextState.colorInfo ||
|
||||
this.props.isFullScreen !== nextProps.isFullScreen ||
|
||||
this.props.isWindowFullScreen !== nextProps.isWindowFullScreen ||
|
||||
this.props.dimensions !== nextProps.dimensions ||
|
||||
this.state.question !== nextState.question ||
|
||||
this.props.micIndicator !== nextProps.micIndicator ||
|
||||
this.props.isStarted !== nextProps.isStarted ||
|
||||
this.props.customStageSize !== nextProps.customStageSize;
|
||||
}
|
||||
componentDidUpdate (prevProps) {
|
||||
if (this.props.isColorPicking && !prevProps.isColorPicking) {
|
||||
this.startColorPickingLoop();
|
||||
} else if (!this.props.isColorPicking && prevProps.isColorPicking) {
|
||||
this.stopColorPickingLoop();
|
||||
}
|
||||
this.updateRect();
|
||||
this.renderer.resize(this.rect.width, this.rect.height);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.detachMouseEvents(this.canvas);
|
||||
this.detachRectEvents();
|
||||
this.stopColorPickingLoop();
|
||||
this.props.vm.runtime.removeListener('QUESTION', this.questionListener);
|
||||
}
|
||||
questionListener (question) {
|
||||
this.setState({question: question});
|
||||
}
|
||||
handleQuestionAnswered (answer) {
|
||||
this.setState({question: null}, () => {
|
||||
this.props.vm.runtime.emit('ANSWER', answer);
|
||||
});
|
||||
}
|
||||
startColorPickingLoop () {
|
||||
const callback = () => {
|
||||
this.animationFrameId = requestAnimationFrame(callback);
|
||||
if (typeof this.pickX === 'number') {
|
||||
this.setState({colorInfo: this.getColorInfo(this.pickX, this.pickY)});
|
||||
}
|
||||
};
|
||||
this.animationFrameId = requestAnimationFrame(callback);
|
||||
}
|
||||
stopColorPickingLoop () {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
attachMouseEvents (canvas) {
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
document.addEventListener('touchmove', this.onMouseMove);
|
||||
document.addEventListener('touchend', this.onMouseUp);
|
||||
canvas.addEventListener('mousedown', this.onMouseDown);
|
||||
canvas.addEventListener('touchstart', this.onMouseDown);
|
||||
canvas.addEventListener('wheel', this.onWheel);
|
||||
canvas.addEventListener('contextmenu', this.onContextMenu);
|
||||
}
|
||||
detachMouseEvents (canvas) {
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
document.removeEventListener('touchmove', this.onMouseMove);
|
||||
document.removeEventListener('touchend', this.onMouseUp);
|
||||
canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||
canvas.removeEventListener('touchstart', this.onMouseDown);
|
||||
canvas.removeEventListener('wheel', this.onWheel);
|
||||
canvas.removeEventListener('contextmenu', this.onContextMenu);
|
||||
}
|
||||
attachRectEvents () {
|
||||
window.addEventListener('resize', this.updateRect);
|
||||
window.addEventListener('scroll', this.updateRect);
|
||||
}
|
||||
detachRectEvents () {
|
||||
window.removeEventListener('resize', this.updateRect);
|
||||
window.removeEventListener('scroll', this.updateRect);
|
||||
}
|
||||
updateRect () {
|
||||
this.rect = this.canvas.getBoundingClientRect();
|
||||
}
|
||||
getScratchCoords (x, y) {
|
||||
const nativeSize = this.renderer.getNativeSize();
|
||||
return [
|
||||
(nativeSize[0] / this.rect.width) * (x - (this.rect.width / 2)),
|
||||
(nativeSize[1] / this.rect.height) * (y - (this.rect.height / 2))
|
||||
];
|
||||
}
|
||||
getColorInfo (x, y) {
|
||||
return {
|
||||
x: x,
|
||||
y: y,
|
||||
...this.renderer.extractColor(x, y, colorPickerRadius)
|
||||
};
|
||||
}
|
||||
handleDoubleClick (e) {
|
||||
// tw: Disable editing target changing in certain circumstances to avoid lag
|
||||
if (this.props.disableEditingTargetChange) {
|
||||
return;
|
||||
}
|
||||
const {x, y} = getEventXY(e);
|
||||
// Set editing target from cursor position, if clicking on a sprite.
|
||||
const mousePosition = [x - this.rect.left, y - this.rect.top];
|
||||
const drawableId = this.renderer.pick(mousePosition[0], mousePosition[1]);
|
||||
if (drawableId === null) return;
|
||||
const targetId = this.props.vm.getTargetIdForDrawableId(drawableId);
|
||||
if (targetId === null) return;
|
||||
this.props.vm.setEditingTarget(targetId);
|
||||
}
|
||||
onMouseMove (e) {
|
||||
const {x, y} = getEventXY(e);
|
||||
const mousePosition = [x - this.rect.left, y - this.rect.top];
|
||||
|
||||
if (this.props.isColorPicking) {
|
||||
// Set the pickX/Y for the color picker loop to pick up
|
||||
this.pickX = mousePosition[0];
|
||||
this.pickY = mousePosition[1];
|
||||
}
|
||||
|
||||
if (this.state.mouseDown && !this.state.isDragging) {
|
||||
const distanceFromMouseDown = Math.sqrt(
|
||||
Math.pow(mousePosition[0] - this.state.mouseDownPosition[0], 2) +
|
||||
Math.pow(mousePosition[1] - this.state.mouseDownPosition[1], 2)
|
||||
);
|
||||
if (distanceFromMouseDown > dragThreshold) {
|
||||
this.cancelMouseDownTimeout();
|
||||
this.onStartDrag(...this.state.mouseDownPosition);
|
||||
}
|
||||
}
|
||||
if (this.state.mouseDown && this.state.isDragging) {
|
||||
// Editor drag style only updates the drag canvas, does full update at the end of drag
|
||||
// Non-editor drag style just updates the sprite continuously.
|
||||
if (this.props.useEditorDragStyle) {
|
||||
this.positionDragCanvas(mousePosition[0], mousePosition[1]);
|
||||
} else {
|
||||
const spritePosition = this.getScratchCoords(mousePosition[0], mousePosition[1]);
|
||||
this.props.vm.postSpriteInfo({
|
||||
x: spritePosition[0] + this.state.dragOffset[0],
|
||||
y: -(spritePosition[1] + this.state.dragOffset[1]),
|
||||
force: true
|
||||
});
|
||||
}
|
||||
}
|
||||
const coordinates = {
|
||||
x: mousePosition[0],
|
||||
y: mousePosition[1],
|
||||
canvasWidth: this.rect.width,
|
||||
canvasHeight: this.rect.height
|
||||
};
|
||||
this.props.vm.postIOData('mouse', coordinates);
|
||||
}
|
||||
onMouseUp (e) {
|
||||
const {x, y} = getEventXY(e);
|
||||
const mousePosition = [x - this.rect.left, y - this.rect.top];
|
||||
this.cancelMouseDownTimeout();
|
||||
this.setState({
|
||||
mouseDown: false,
|
||||
mouseDownPosition: null
|
||||
});
|
||||
const data = {
|
||||
isDown: false,
|
||||
button: e.button,
|
||||
x: x - this.rect.left,
|
||||
y: y - this.rect.top,
|
||||
canvasWidth: this.rect.width,
|
||||
canvasHeight: this.rect.height,
|
||||
wasDragged: this.state.isDragging
|
||||
};
|
||||
if (this.state.isDragging) {
|
||||
this.onStopDrag(mousePosition[0], mousePosition[1]);
|
||||
}
|
||||
this.props.vm.postIOData('mouse', data);
|
||||
|
||||
if (this.props.isColorPicking &&
|
||||
mousePosition[0] > 0 && mousePosition[0] < this.rect.width &&
|
||||
mousePosition[1] > 0 && mousePosition[1] < this.rect.height
|
||||
) {
|
||||
const {r, g, b} = this.state.colorInfo.color;
|
||||
const componentToString = c => {
|
||||
const hex = c.toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
const colorString = `#${componentToString(r)}${componentToString(g)}${componentToString(b)}`;
|
||||
this.props.onDeactivateColorPicker(colorString);
|
||||
this.setState({colorInfo: null});
|
||||
this.pickX = null;
|
||||
this.pickY = null;
|
||||
}
|
||||
}
|
||||
onMouseDown (e) {
|
||||
this.updateRect();
|
||||
const {x, y} = getEventXY(e);
|
||||
const mousePosition = [x - this.rect.left, y - this.rect.top];
|
||||
if (this.props.isColorPicking) {
|
||||
// Set the pickX/Y for the color picker loop to pick up
|
||||
this.pickX = mousePosition[0];
|
||||
this.pickY = mousePosition[1];
|
||||
// Immediately update the color picker info
|
||||
this.setState({colorInfo: this.getColorInfo(this.pickX, this.pickY)});
|
||||
} else {
|
||||
const isTouchEvent = window.TouchEvent && e instanceof TouchEvent;
|
||||
if (e.button === 0 || isTouchEvent) {
|
||||
this.setState({
|
||||
mouseDown: true,
|
||||
mouseDownPosition: mousePosition,
|
||||
mouseDownTimeoutId: setTimeout(
|
||||
this.onStartDrag.bind(this, mousePosition[0], mousePosition[1]),
|
||||
400
|
||||
)
|
||||
});
|
||||
}
|
||||
const data = {
|
||||
isDown: true,
|
||||
button: e.button,
|
||||
x: mousePosition[0],
|
||||
y: mousePosition[1],
|
||||
canvasWidth: this.rect.width,
|
||||
canvasHeight: this.rect.height
|
||||
};
|
||||
this.props.vm.postIOData('mouse', data);
|
||||
if (isTouchEvent && e.preventDefault) {
|
||||
// Prevent default to prevent touch from dragging page
|
||||
e.preventDefault();
|
||||
// But we do want any active input to be blurred
|
||||
if (document.activeElement && document.activeElement.blur) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onWheel (e) {
|
||||
const data = {
|
||||
deltaX: e.deltaX,
|
||||
deltaY: e.deltaY
|
||||
};
|
||||
this.props.vm.postIOData('mouseWheel', data);
|
||||
}
|
||||
onContextMenu (e) {
|
||||
if (this.props.vm.runtime.ioDevices.mouse.usesRightClickDown) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
cancelMouseDownTimeout () {
|
||||
if (this.state.mouseDownTimeoutId !== null) {
|
||||
clearTimeout(this.state.mouseDownTimeoutId);
|
||||
}
|
||||
this.setState({mouseDownTimeoutId: null});
|
||||
}
|
||||
/**
|
||||
* Initialize the position of the "dragged sprite" canvas
|
||||
* @param {DrawableExtraction} drawableData The data returned from renderer.extractDrawableScreenSpace
|
||||
* @param {number} x The x position of the initial drag event
|
||||
* @param {number} y The y position of the initial drag event
|
||||
*/
|
||||
drawDragCanvas (drawableData, x, y) {
|
||||
const {
|
||||
imageData,
|
||||
x: boundsX,
|
||||
y: boundsY,
|
||||
width: boundsWidth,
|
||||
height: boundsHeight
|
||||
} = drawableData;
|
||||
this.dragCanvas.width = imageData.width;
|
||||
this.dragCanvas.height = imageData.height;
|
||||
// On high-DPI devices, the canvas size in layout-pixels is not equal to the size of the extracted data.
|
||||
this.dragCanvas.style.width = `${boundsWidth}px`;
|
||||
this.dragCanvas.style.height = `${boundsHeight}px`;
|
||||
|
||||
this.dragCanvas.getContext('2d').putImageData(imageData, 0, 0);
|
||||
// Position so that pick location is at (0, 0) so that positionDragCanvas()
|
||||
// can use translation to move to mouse position smoothly.
|
||||
this.dragCanvas.style.left = `${boundsX - x}px`;
|
||||
this.dragCanvas.style.top = `${boundsY - y}px`;
|
||||
this.dragCanvas.style.display = 'block';
|
||||
}
|
||||
clearDragCanvas () {
|
||||
this.dragCanvas.width = this.dragCanvas.height = 0;
|
||||
this.dragCanvas.style.display = 'none';
|
||||
}
|
||||
positionDragCanvas (mouseX, mouseY) {
|
||||
// mouseX/Y are relative to stage top/left, and dragCanvas is already
|
||||
// positioned so that the pick location is at (0,0).
|
||||
this.dragCanvas.style.transform = `translate(${mouseX}px, ${mouseY}px)`;
|
||||
}
|
||||
onStartDrag (x, y) {
|
||||
if (this.state.dragId) return;
|
||||
const drawableId = this.renderer.pick(x, y);
|
||||
if (drawableId === null) return;
|
||||
const targetId = this.props.vm.getTargetIdForDrawableId(drawableId);
|
||||
if (targetId === null) return;
|
||||
|
||||
const target = this.props.vm.runtime.getTargetById(targetId);
|
||||
|
||||
// Do not start drag unless in editor drag mode or target is draggable
|
||||
if (!(this.props.useEditorDragStyle || target.draggable)) return;
|
||||
|
||||
// Dragging always brings the target to the front
|
||||
target.goToFront();
|
||||
|
||||
const [scratchMouseX, scratchMouseY] = this.getScratchCoords(x, y);
|
||||
const offsetX = target.x - scratchMouseX;
|
||||
const offsetY = -(target.y + scratchMouseY);
|
||||
|
||||
this.props.vm.startDrag(targetId);
|
||||
this.setState({
|
||||
isDragging: true,
|
||||
dragId: targetId,
|
||||
dragOffset: [offsetX, offsetY]
|
||||
});
|
||||
if (this.props.useEditorDragStyle) {
|
||||
// Extract the drawable art
|
||||
const drawableData = this.renderer.extractDrawableScreenSpace(drawableId);
|
||||
this.drawDragCanvas(drawableData, x, y);
|
||||
this.positionDragCanvas(x, y);
|
||||
this.props.vm.postSpriteInfo({visible: false});
|
||||
// this.props.vm.renderer.draw();
|
||||
}
|
||||
}
|
||||
onStopDrag (mouseX, mouseY) {
|
||||
const dragId = this.state.dragId;
|
||||
const commonStopDragActions = () => {
|
||||
this.props.vm.stopDrag(dragId);
|
||||
this.setState({
|
||||
isDragging: false,
|
||||
dragOffset: null,
|
||||
dragId: null
|
||||
});
|
||||
};
|
||||
if (this.props.useEditorDragStyle) {
|
||||
// Need to sequence these actions to prevent flickering.
|
||||
const spriteInfo = {visible: true};
|
||||
// First update the sprite position if dropped in the stage.
|
||||
if (mouseX > 0 && mouseX < this.rect.width &&
|
||||
mouseY > 0 && mouseY < this.rect.height) {
|
||||
const spritePosition = this.getScratchCoords(mouseX, mouseY);
|
||||
spriteInfo.x = spritePosition[0] + this.state.dragOffset[0];
|
||||
spriteInfo.y = -(spritePosition[1] + this.state.dragOffset[1]);
|
||||
spriteInfo.force = true;
|
||||
}
|
||||
this.props.vm.postSpriteInfo(spriteInfo);
|
||||
// Then clear the dragging canvas and stop drag (potentially slow if selecting sprite)
|
||||
this.clearDragCanvas();
|
||||
commonStopDragActions();
|
||||
// this.props.vm.renderer.draw();
|
||||
} else {
|
||||
commonStopDragActions();
|
||||
}
|
||||
}
|
||||
setDragCanvas (canvas) {
|
||||
this.dragCanvas = canvas;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
vm, // eslint-disable-line no-unused-vars
|
||||
onActivateColorPicker, // eslint-disable-line no-unused-vars
|
||||
disableEditingTargetChange, // eslint-disable-line no-unused-vars
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<StageComponent
|
||||
canvas={this.canvas}
|
||||
overlay={this.props.vm.runtime.renderer.overlayContainer}
|
||||
colorInfo={this.state.colorInfo}
|
||||
dragRef={this.setDragCanvas}
|
||||
question={this.state.question}
|
||||
onDoubleClick={this.handleDoubleClick}
|
||||
onQuestionAnswered={this.handleQuestionAnswered}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Stage.propTypes = {
|
||||
onHighQualityPenChanged: PropTypes.func,
|
||||
highQualityPen: PropTypes.bool,
|
||||
customStageSize: PropTypes.shape({
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number
|
||||
}),
|
||||
disableEditingTargetChange: PropTypes.bool,
|
||||
isColorPicking: PropTypes.bool,
|
||||
isFullScreen: PropTypes.bool.isRequired,
|
||||
isPlayerOnly: PropTypes.bool,
|
||||
isRtl: PropTypes.bool,
|
||||
isWindowFullScreen: PropTypes.bool,
|
||||
dimensions: PropTypes.arrayOf(PropTypes.number),
|
||||
isStarted: PropTypes.bool,
|
||||
micIndicator: PropTypes.bool,
|
||||
onActivateColorPicker: PropTypes.func,
|
||||
onDeactivateColorPicker: PropTypes.func,
|
||||
stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired,
|
||||
useEditorDragStyle: PropTypes.bool,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
Stage.defaultProps = {
|
||||
useEditorDragStyle: true
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
highQualityPen: state.scratchGui.tw.highQualityPen,
|
||||
customStageSize: state.scratchGui.customStageSize,
|
||||
disableEditingTargetChange: (
|
||||
state.scratchGui.mode.isFullScreen ||
|
||||
state.scratchGui.mode.isEmbedded ||
|
||||
state.scratchGui.mode.isPlayerOnly
|
||||
),
|
||||
isColorPicking: state.scratchGui.colorPicker.active,
|
||||
isFullScreen: state.scratchGui.mode.isFullScreen || state.scratchGui.mode.isEmbedded,
|
||||
isPlayerOnly: state.scratchGui.mode.isPlayerOnly,
|
||||
isRtl: state.locales.isRtl,
|
||||
isWindowFullScreen: state.scratchGui.tw.isWindowFullScreen,
|
||||
dimensions: state.scratchGui.tw.dimensions,
|
||||
isStarted: state.scratchGui.vmStatus.started,
|
||||
micIndicator: state.scratchGui.micIndicator,
|
||||
// Do not use editor drag style in fullscreen or player mode.
|
||||
useEditorDragStyle: !(state.scratchGui.mode.isFullScreen || state.scratchGui.mode.isPlayerOnly)
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
// tw: handler for syncing high quality pen option changes
|
||||
onHighQualityPenChanged: enabled => dispatch(setHighQualityPenState(enabled)),
|
||||
onActivateColorPicker: () => dispatch(activateColorPicker()),
|
||||
onDeactivateColorPicker: color => dispatch(deactivateColorPicker(color))
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Stage);
|
||||
32
scratch-gui/src/containers/tag-button.jsx
Normal file
32
scratch-gui/src/containers/tag-button.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import TagButtonComponent from '../components/tag-button/tag-button.jsx';
|
||||
|
||||
class TagButton extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleClick'
|
||||
]);
|
||||
}
|
||||
handleClick () {
|
||||
this.props.onClick(this.props.tag);
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<TagButtonComponent
|
||||
{...this.props}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TagButton.propTypes = {
|
||||
...TagButtonComponent.propTypes,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
export default TagButton;
|
||||
82
scratch-gui/src/containers/target-highlight.jsx
Normal file
82
scratch-gui/src/containers/target-highlight.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
class TargetHighlight extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'getPageCoords'
|
||||
]);
|
||||
}
|
||||
|
||||
// Transform scratch coordinates into page coordinates
|
||||
getPageCoords (x, y) {
|
||||
const {stageWidth, stageHeight, vm} = this.props;
|
||||
// The renderers "nativeSize" is the [width, height] of the stage in scratch-units
|
||||
const nativeSize = vm.renderer.getNativeSize();
|
||||
return [
|
||||
((stageWidth / nativeSize[0]) * x) + (stageWidth / 2),
|
||||
-((stageHeight / nativeSize[1]) * y) + (stageHeight / 2)
|
||||
];
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
className,
|
||||
highlightedTargetId,
|
||||
highlightedTargetTime,
|
||||
vm
|
||||
} = this.props;
|
||||
|
||||
if (!(highlightedTargetId && vm && vm.renderer &&
|
||||
vm.runtime.getTargetById(highlightedTargetId))) return null;
|
||||
|
||||
const target = vm.runtime.getTargetById(highlightedTargetId);
|
||||
const bounds = vm.renderer.getBounds(target.drawableID);
|
||||
const [left, top] = this.getPageCoords(bounds.left, bounds.top);
|
||||
const [right, bottom] = this.getPageCoords(bounds.right, bounds.bottom);
|
||||
|
||||
const pad = 2; // px
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
// Ensure new DOM element each update to restart animation
|
||||
key={highlightedTargetTime}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${top - pad}px`,
|
||||
left: `${left - pad}px`,
|
||||
width: `${(right - left) + (2 * pad)}px`,
|
||||
height: `${(bottom - top) + (2 * pad)}px`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TargetHighlight.propTypes = {
|
||||
className: PropTypes.string,
|
||||
highlightedTargetId: PropTypes.string,
|
||||
highlightedTargetTime: PropTypes.number,
|
||||
stageHeight: PropTypes.number,
|
||||
stageWidth: PropTypes.number,
|
||||
vm: PropTypes.instanceOf(VM)
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
highlightedTargetTime: state.scratchGui.targets.highlightedTargetTime,
|
||||
highlightedTargetId: state.scratchGui.targets.highlightedTargetId,
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TargetHighlight);
|
||||
305
scratch-gui/src/containers/target-pane.jsx
Normal file
305
scratch-gui/src/containers/target-pane.jsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
import {intlShape, injectIntl} from 'react-intl';
|
||||
|
||||
import {
|
||||
openSpriteLibrary,
|
||||
closeSpriteLibrary
|
||||
} from '../reducers/modals';
|
||||
import {activateTab, COSTUMES_TAB_INDEX, BLOCKS_TAB_INDEX} from '../reducers/editor-tab';
|
||||
import {setReceivedBlocks} from '../reducers/hovered-target';
|
||||
import {showStandardAlert, closeAlertWithId} from '../reducers/alerts';
|
||||
import {setRestore} from '../reducers/restore-deletion';
|
||||
import DragConstants from '../lib/drag-constants';
|
||||
import TargetPaneComponent from '../components/target-pane/target-pane.jsx';
|
||||
import {getSpriteLibrary} from '../lib/libraries/tw-async-libraries';
|
||||
import {handleFileUpload, spriteUpload} from '../lib/file-uploader.js';
|
||||
import sharedMessages from '../lib/shared-messages';
|
||||
import {emptySprite} from '../lib/empty-assets';
|
||||
import {highlightTarget} from '../reducers/targets';
|
||||
import {fetchSprite, fetchCode} from '../lib/backpack-api';
|
||||
import randomizeSpritePosition from '../lib/randomize-sprite-position';
|
||||
import downloadBlob from '../lib/download-blob';
|
||||
import log from '../lib/log';
|
||||
import {placeInViewport} from '../lib/backpack/code-payload.js';
|
||||
|
||||
class TargetPane extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleActivateBlocksTab',
|
||||
'handleBlockDragEnd',
|
||||
'handleChangeSpriteRotationStyle',
|
||||
'handleChangeSpriteDirection',
|
||||
'handleChangeSpriteName',
|
||||
'handleChangeSpriteSize',
|
||||
'handleChangeSpriteVisibility',
|
||||
'handleChangeSpriteX',
|
||||
'handleChangeSpriteY',
|
||||
'handleDeleteSprite',
|
||||
'handleDrop',
|
||||
'handleDuplicateSprite',
|
||||
'handleExportSprite',
|
||||
'handleNewSprite',
|
||||
'handleSelectSprite',
|
||||
'handleSurpriseSpriteClick',
|
||||
'handlePaintSpriteClick',
|
||||
'handleFileUploadClick',
|
||||
'handleSpriteUpload',
|
||||
'setFileInput'
|
||||
]);
|
||||
}
|
||||
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});
|
||||
}
|
||||
handleChangeSpriteRotationStyle (rotationStyle) {
|
||||
this.props.vm.postSpriteInfo({rotationStyle});
|
||||
}
|
||||
handleChangeSpriteName (name) {
|
||||
this.props.vm.renameSprite(this.props.editingTarget, name);
|
||||
}
|
||||
handleChangeSpriteSize (size) {
|
||||
this.props.vm.postSpriteInfo({size});
|
||||
}
|
||||
handleChangeSpriteVisibility (visible) {
|
||||
this.props.vm.postSpriteInfo({visible});
|
||||
}
|
||||
handleChangeSpriteX (x) {
|
||||
this.props.vm.postSpriteInfo({x});
|
||||
}
|
||||
handleChangeSpriteY (y) {
|
||||
this.props.vm.postSpriteInfo({y});
|
||||
}
|
||||
handleDeleteSprite (id) {
|
||||
const restoreSprite = this.props.vm.deleteSprite(id);
|
||||
const restoreFun = () => restoreSprite().then(this.handleActivateBlocksTab);
|
||||
|
||||
this.props.dispatchUpdateRestore({
|
||||
restoreFun: restoreFun,
|
||||
deletedItem: 'Sprite'
|
||||
});
|
||||
|
||||
}
|
||||
handleDuplicateSprite (id) {
|
||||
this.props.vm.duplicateSprite(id);
|
||||
}
|
||||
handleExportSprite (id) {
|
||||
const spriteName = this.props.vm.runtime.getTargetById(id).getName();
|
||||
const saveLink = document.createElement('a');
|
||||
document.body.appendChild(saveLink);
|
||||
|
||||
this.props.vm.exportSprite(id).then(content => {
|
||||
downloadBlob(`${spriteName}.sprite3`, content);
|
||||
});
|
||||
}
|
||||
handleSelectSprite (id) {
|
||||
this.props.vm.setEditingTarget(id);
|
||||
if (this.props.stage && id !== this.props.stage.id) {
|
||||
this.props.onHighlightTarget(id);
|
||||
}
|
||||
}
|
||||
async handleSurpriseSpriteClick () {
|
||||
const spriteLibraryContent = await getSpriteLibrary();
|
||||
const surpriseSprites = spriteLibraryContent.filter(sprite =>
|
||||
(sprite.tags.indexOf('letters') === -1) && (sprite.tags.indexOf('numbers') === -1)
|
||||
);
|
||||
const item = surpriseSprites[Math.floor(Math.random() * surpriseSprites.length)];
|
||||
randomizeSpritePosition(item);
|
||||
this.props.vm.addSprite(JSON.stringify(item))
|
||||
.then(this.handleActivateBlocksTab);
|
||||
}
|
||||
handlePaintSpriteClick () {
|
||||
const formatMessage = this.props.intl.formatMessage;
|
||||
const emptyItem = emptySprite(
|
||||
formatMessage(sharedMessages.sprite, {index: 1}),
|
||||
formatMessage(sharedMessages.pop),
|
||||
formatMessage(sharedMessages.costume, {index: 1})
|
||||
);
|
||||
this.props.vm.addSprite(JSON.stringify(emptyItem)).then(() => {
|
||||
setTimeout(() => { // Wait for targets update to propagate before tab switching
|
||||
this.props.onActivateTab(COSTUMES_TAB_INDEX);
|
||||
});
|
||||
});
|
||||
}
|
||||
handleActivateBlocksTab () {
|
||||
this.props.onActivateTab(BLOCKS_TAB_INDEX);
|
||||
}
|
||||
handleNewSprite (spriteJSONString) {
|
||||
return this.props.vm.addSprite(spriteJSONString)
|
||||
.then(this.handleActivateBlocksTab)
|
||||
.catch(err => {
|
||||
log.error(err);
|
||||
});
|
||||
}
|
||||
handleFileUploadClick () {
|
||||
this.fileInput.click();
|
||||
}
|
||||
handleSpriteUpload (e) {
|
||||
const vm = this.props.vm;
|
||||
this.props.onShowImporting();
|
||||
handleFileUpload(e.target, (buffer, fileType, fileName, fileIndex, fileCount) => {
|
||||
spriteUpload(buffer, fileType, fileName, vm, newSprite => {
|
||||
this.handleNewSprite(newSprite)
|
||||
.then(() => {
|
||||
if (fileIndex === fileCount - 1) {
|
||||
this.props.onCloseImporting();
|
||||
}
|
||||
})
|
||||
.catch(this.props.onCloseImporting);
|
||||
}, this.props.onCloseImporting);
|
||||
}, this.props.onCloseImporting);
|
||||
}
|
||||
setFileInput (input) {
|
||||
this.fileInput = input;
|
||||
}
|
||||
handleBlockDragEnd (blocks) {
|
||||
if (this.props.hoveredTarget.sprite && this.props.hoveredTarget.sprite !== this.props.editingTarget) {
|
||||
this.shareBlocks(blocks, this.props.hoveredTarget.sprite, this.props.editingTarget);
|
||||
this.props.onReceivedBlocks(true);
|
||||
}
|
||||
}
|
||||
shareBlocks (payload, targetId, optFromTargetId) {
|
||||
// Position the top-level block based on the scroll position.
|
||||
const centered = placeInViewport(payload, this.props.workspaceMetrics.targets[targetId], this.props.isRtl);
|
||||
return this.props.vm.shareBlocksToTarget(centered, targetId, optFromTargetId);
|
||||
}
|
||||
handleDrop (dragInfo) {
|
||||
const {sprite: targetId} = this.props.hoveredTarget;
|
||||
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);
|
||||
} else if (dragInfo.dragType === DragConstants.BACKPACK_SPRITE) {
|
||||
// TODO storage does not have a way of loading zips right now, and may never need it.
|
||||
// So for now just grab the zip manually.
|
||||
fetchSprite(dragInfo.payload.bodyUrl)
|
||||
.then(sprite3Zip => this.props.vm.addSprite(sprite3Zip));
|
||||
} else if (targetId) {
|
||||
// Something is being dragged over one of the sprite tiles or the backdrop.
|
||||
// Dropping assets like sounds and costumes duplicate the asset on the
|
||||
// hovered target. Shared costumes also become the current costume on that target.
|
||||
// However, dropping does not switch the editing target or activate that editor tab.
|
||||
// This is based on 2.0 behavior, but seems like it keeps confusing switching to a minimum.
|
||||
// it allows the user to share multiple things without switching back and forth.
|
||||
if (dragInfo.dragType === DragConstants.COSTUME) {
|
||||
this.props.vm.shareCostumeToTarget(dragInfo.index, targetId);
|
||||
} else if (targetId && dragInfo.dragType === DragConstants.SOUND) {
|
||||
this.props.vm.shareSoundToTarget(dragInfo.index, targetId);
|
||||
} else if (dragInfo.dragType === DragConstants.BACKPACK_COSTUME) {
|
||||
// In scratch 2, this only creates a new sprite from the costume.
|
||||
// We may be able to handle both kinds of drops, depending on where
|
||||
// the drop happens. For now, just add the costume.
|
||||
this.props.vm.addCostume(dragInfo.payload.body, {
|
||||
name: dragInfo.payload.name
|
||||
}, targetId);
|
||||
} else if (dragInfo.dragType === DragConstants.BACKPACK_SOUND) {
|
||||
this.props.vm.addSound({
|
||||
md5: dragInfo.payload.body,
|
||||
name: dragInfo.payload.name
|
||||
}, targetId);
|
||||
} else if (dragInfo.dragType === DragConstants.BACKPACK_CODE) {
|
||||
fetchCode(dragInfo.payload.bodyUrl)
|
||||
.then(blocks => this.shareBlocks(blocks, targetId))
|
||||
.then(() => this.props.vm.refreshWorkspace());
|
||||
}
|
||||
}
|
||||
}
|
||||
render () {
|
||||
/* eslint-disable no-unused-vars */
|
||||
const {
|
||||
dispatchUpdateRestore,
|
||||
isRtl,
|
||||
onActivateTab,
|
||||
onCloseImporting,
|
||||
onHighlightTarget,
|
||||
onReceivedBlocks,
|
||||
onShowImporting,
|
||||
workspaceMetrics,
|
||||
...componentProps
|
||||
} = this.props;
|
||||
/* eslint-enable no-unused-vars */
|
||||
return (
|
||||
<TargetPaneComponent
|
||||
{...componentProps}
|
||||
fileInputRef={this.setFileInput}
|
||||
onActivateBlocksTab={this.handleActivateBlocksTab}
|
||||
onChangeSpriteDirection={this.handleChangeSpriteDirection}
|
||||
onChangeSpriteName={this.handleChangeSpriteName}
|
||||
onChangeSpriteRotationStyle={this.handleChangeSpriteRotationStyle}
|
||||
onChangeSpriteSize={this.handleChangeSpriteSize}
|
||||
onChangeSpriteVisibility={this.handleChangeSpriteVisibility}
|
||||
onChangeSpriteX={this.handleChangeSpriteX}
|
||||
onChangeSpriteY={this.handleChangeSpriteY}
|
||||
onDeleteSprite={this.handleDeleteSprite}
|
||||
onDrop={this.handleDrop}
|
||||
onDuplicateSprite={this.handleDuplicateSprite}
|
||||
onExportSprite={this.handleExportSprite}
|
||||
onFileUploadClick={this.handleFileUploadClick}
|
||||
onPaintSpriteClick={this.handlePaintSpriteClick}
|
||||
onSelectSprite={this.handleSelectSprite}
|
||||
onSpriteUpload={this.handleSpriteUpload}
|
||||
onSurpriseSpriteClick={this.handleSurpriseSpriteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
onSelectSprite, // eslint-disable-line no-unused-vars
|
||||
onActivateBlocksTab, // eslint-disable-line no-unused-vars
|
||||
...targetPaneProps
|
||||
} = TargetPaneComponent.propTypes;
|
||||
|
||||
TargetPane.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onCloseImporting: PropTypes.func,
|
||||
onShowImporting: PropTypes.func,
|
||||
...targetPaneProps
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
editingTarget: state.scratchGui.targets.editingTarget,
|
||||
hoveredTarget: state.scratchGui.hoveredTarget,
|
||||
isRtl: state.locales.isRtl,
|
||||
spriteLibraryVisible: state.scratchGui.modals.spriteLibrary,
|
||||
sprites: state.scratchGui.targets.sprites,
|
||||
stage: state.scratchGui.targets.stage,
|
||||
raiseSprites: state.scratchGui.blockDrag,
|
||||
workspaceMetrics: state.scratchGui.workspaceMetrics
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onNewSpriteClick: e => {
|
||||
e.preventDefault();
|
||||
dispatch(openSpriteLibrary());
|
||||
},
|
||||
onRequestCloseSpriteLibrary: () => {
|
||||
dispatch(closeSpriteLibrary());
|
||||
},
|
||||
onActivateTab: tabIndex => {
|
||||
dispatch(activateTab(tabIndex));
|
||||
},
|
||||
onReceivedBlocks: receivedBlocks => {
|
||||
dispatch(setReceivedBlocks(receivedBlocks));
|
||||
},
|
||||
dispatchUpdateRestore: restoreState => {
|
||||
dispatch(setRestore(restoreState));
|
||||
},
|
||||
onHighlightTarget: id => {
|
||||
dispatch(highlightTarget(id));
|
||||
},
|
||||
onCloseImporting: () => dispatch(closeAlertWithId('importingAsset')),
|
||||
onShowImporting: () => dispatch(showStandardAlert('importingAsset'))
|
||||
});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TargetPane));
|
||||
123
scratch-gui/src/containers/tips-library.jsx
Normal file
123
scratch-gui/src/containers/tips-library.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {injectIntl, intlShape, defineMessages} from 'react-intl';
|
||||
|
||||
import decksLibraryContent from '../lib/libraries/decks/index.jsx';
|
||||
import tutorialTags from '../lib/libraries/tutorial-tags';
|
||||
|
||||
import analytics from '../lib/analytics';
|
||||
import {notScratchDesktop} from '../lib/isScratchDesktop';
|
||||
|
||||
import LibraryComponent from '../components/library/library.jsx';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {
|
||||
closeTipsLibrary
|
||||
} from '../reducers/modals';
|
||||
|
||||
import {
|
||||
activateDeck
|
||||
} from '../reducers/cards';
|
||||
|
||||
const messages = defineMessages({
|
||||
tipsLibraryTitle: {
|
||||
defaultMessage: 'Choose a Tutorial',
|
||||
description: 'Heading for the help/tutorials library',
|
||||
id: 'gui.tipsLibrary.tutorials'
|
||||
}
|
||||
});
|
||||
|
||||
class TipsLibrary extends React.PureComponent {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleItemSelect'
|
||||
]);
|
||||
}
|
||||
handleItemSelect (item) {
|
||||
analytics.event({
|
||||
category: 'library',
|
||||
action: 'Select How-to',
|
||||
label: item.id
|
||||
});
|
||||
|
||||
/*
|
||||
Support tutorials that require specific starter projects.
|
||||
If a tutorial declares "requiredProjectId", check that the URL contains
|
||||
it. If it is not, open a new page with this tutorial and project id.
|
||||
|
||||
TODO remove this at first opportunity. If this is still here after HOC2018,
|
||||
blame Eric R. Andrew is also on record saying "this is temporary".
|
||||
UPDATE well now Paul is wrapped into this as well. Sigh...
|
||||
eventually we will find a solution that doesn't involve loading a whole project
|
||||
*/
|
||||
if (item.requiredProjectId && (item.requiredProjectId !== this.props.projectId)) {
|
||||
const urlParams = `/projects/${item.requiredProjectId}/editor?tutorial=${item.urlId}`;
|
||||
return window.open(window.location.origin + urlParams, '_blank');
|
||||
}
|
||||
|
||||
this.props.onActivateDeck(item.id);
|
||||
}
|
||||
render () {
|
||||
const decksLibraryThumbnailData = Object.keys(decksLibraryContent)
|
||||
.filter(id => {
|
||||
if (notScratchDesktop()) return true; // Do not filter anything in online editor
|
||||
const deck = decksLibraryContent[id];
|
||||
// Scratch Desktop doesn't want tutorials with `requiredProjectId`
|
||||
if (Object.prototype.hasOwnProperty.call(deck, 'requiredProjectId')) return false;
|
||||
// Scratch Desktop should not load tutorials that are _only_ videos
|
||||
if (deck.steps.filter(s => s.title).length === 0) return false;
|
||||
// Allow any other tutorials
|
||||
return true;
|
||||
})
|
||||
.map(id => ({
|
||||
rawURL: decksLibraryContent[id].img,
|
||||
id: id,
|
||||
name: decksLibraryContent[id].name,
|
||||
featured: true,
|
||||
tags: decksLibraryContent[id].tags,
|
||||
urlId: decksLibraryContent[id].urlId,
|
||||
requiredProjectId: decksLibraryContent[id].requiredProjectId,
|
||||
hidden: decksLibraryContent[id].hidden || false
|
||||
}));
|
||||
|
||||
if (!this.props.visible) return null;
|
||||
return (
|
||||
<LibraryComponent
|
||||
filterable
|
||||
data={decksLibraryThumbnailData}
|
||||
id="tipsLibrary"
|
||||
tags={tutorialTags}
|
||||
title={this.props.intl.formatMessage(messages.tipsLibraryTitle)}
|
||||
visible={this.props.visible}
|
||||
onItemSelected={this.handleItemSelect}
|
||||
onRequestClose={this.props.onRequestClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TipsLibrary.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onActivateDeck: PropTypes.func.isRequired,
|
||||
onRequestClose: PropTypes.func,
|
||||
projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
visible: PropTypes.bool
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
visible: state.scratchGui.modals.tipsLibrary,
|
||||
projectId: state.scratchGui.projectState.projectId
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onActivateDeck: id => dispatch(activateDeck(id)),
|
||||
onRequestClose: () => dispatch(closeTipsLibrary())
|
||||
});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TipsLibrary));
|
||||
60
scratch-gui/src/containers/turbo-mode.jsx
Normal file
60
scratch-gui/src/containers/turbo-mode.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
/**
|
||||
* Turbo Mode component passes toggleTurboMode function to its child.
|
||||
* It also includes `turboMode` in the props passed to the children.
|
||||
* It expects this child to be a function with the signature
|
||||
* function (toggleTurboMode, {turboMode, ...props}) {}
|
||||
* The component can then be used to attach turbo mode setting functionality
|
||||
* to any other component:
|
||||
*
|
||||
* <TurboMode>{(toggleTurboMode, props) => (
|
||||
* <MyCoolComponent
|
||||
* turboEnabled={props.turboMode}
|
||||
* onClick={toggleTurboMode}
|
||||
* {...props}
|
||||
* />
|
||||
* )}</TurboMode>
|
||||
*/
|
||||
class TurboMode extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'toggleTurboMode'
|
||||
]);
|
||||
}
|
||||
toggleTurboMode () {
|
||||
this.props.vm.setTurboMode(!this.props.turboMode);
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
children,
|
||||
vm,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...props
|
||||
} = this.props;
|
||||
return this.props.children(this.toggleTurboMode, props);
|
||||
}
|
||||
}
|
||||
|
||||
TurboMode.propTypes = {
|
||||
children: PropTypes.func,
|
||||
turboMode: PropTypes.bool,
|
||||
vm: PropTypes.shape({
|
||||
setTurboMode: PropTypes.func
|
||||
})
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
vm: state.scratchGui.vm,
|
||||
turboMode: state.scratchGui.vmStatus.turbo
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
() => ({}) // omit dispatch prop
|
||||
)(TurboMode);
|
||||
59
scratch-gui/src/containers/tw-change-username.jsx
Normal file
59
scratch-gui/src/containers/tw-change-username.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
import {openUsernameModal} from '../reducers/modals';
|
||||
import {closeEditMenu} from '../reducers/menus';
|
||||
import isScratchDesktop from '../lib/isScratchDesktop';
|
||||
|
||||
const messages = defineMessages({
|
||||
cannotChangeWhileRunning: {
|
||||
defaultMessage: 'Username cannot be changed while the project is running.',
|
||||
description: 'Alert that appears when trying to change username while project is running',
|
||||
id: 'tw.changeUsername.cannotChangeWhileRunning'
|
||||
}
|
||||
});
|
||||
|
||||
class ChangeUsername extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'changeUsername'
|
||||
]);
|
||||
}
|
||||
changeUsername () {
|
||||
if (this.props.running && !isScratchDesktop()) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(this.props.intl.formatMessage(messages.cannotChangeWhileRunning));
|
||||
return;
|
||||
}
|
||||
this.props.onOpenUsernameModal();
|
||||
}
|
||||
render () {
|
||||
return this.props.children(this.changeUsername);
|
||||
}
|
||||
}
|
||||
|
||||
ChangeUsername.propTypes = {
|
||||
children: PropTypes.func,
|
||||
onOpenUsernameModal: PropTypes.func,
|
||||
running: PropTypes.bool,
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
running: state.scratchGui.vmStatus.running
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onOpenUsernameModal: () => {
|
||||
dispatch(openUsernameModal());
|
||||
dispatch(closeEditMenu());
|
||||
}
|
||||
});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(ChangeUsername));
|
||||
67
scratch-gui/src/containers/tw-cloud-toggler.jsx
Normal file
67
scratch-gui/src/containers/tw-cloud-toggler.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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 {setCloud} from '../reducers/tw';
|
||||
import isScratchDesktop from '../lib/isScratchDesktop';
|
||||
|
||||
const messages = defineMessages({
|
||||
cloudUnavailableAlert: {
|
||||
defaultMessage: 'Cannot use cloud variables, most likely because you opened the editor.',
|
||||
// eslint-disable-next-line max-len
|
||||
description: 'Message displayed when clicking on the option to toggle cloud variables when cloud variables are not available',
|
||||
id: 'tw.menuBar.cloudUnavailableAlert'
|
||||
}
|
||||
});
|
||||
|
||||
class CloudVariablesToggler extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'toggleCloudVariables'
|
||||
]);
|
||||
}
|
||||
toggleCloudVariables () {
|
||||
if (!this.props.canUseCloudVariables) {
|
||||
const message = this.props.intl.formatMessage(messages.cloudUnavailableAlert);
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
this.props.onCloudChange(!this.props.enabled);
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
children,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...props
|
||||
} = this.props;
|
||||
return this.props.children(this.toggleCloudVariables, props);
|
||||
}
|
||||
}
|
||||
|
||||
CloudVariablesToggler.propTypes = {
|
||||
intl: intlShape,
|
||||
children: PropTypes.func,
|
||||
enabled: PropTypes.bool,
|
||||
username: PropTypes.string,
|
||||
onCloudChange: PropTypes.func,
|
||||
canUseCloudVariables: PropTypes.bool
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
username: state.scratchGui.tw.username,
|
||||
enabled: state.scratchGui.tw.cloud,
|
||||
canUseCloudVariables: isScratchDesktop() || !state.scratchGui.mode.hasEverEnteredEditor
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onCloudChange: enabled => dispatch(setCloud(enabled))
|
||||
});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(CloudVariablesToggler));
|
||||
50
scratch-gui/src/containers/tw-cloud-variable-badge.jsx
Normal file
50
scratch-gui/src/containers/tw-cloud-variable-badge.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import {setCloudHost} from '../reducers/tw';
|
||||
import CloudVariableBadge from '../components/tw-cloud-variable-badge/cloud-variable-badge.jsx';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {openUsernameModal} from '../reducers/modals';
|
||||
|
||||
class TWCloudVariableBadge extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleChangeCloudHost'
|
||||
]);
|
||||
}
|
||||
|
||||
handleChangeCloudHost (cloudHost) {
|
||||
this.props.onSetCloudHost(cloudHost);
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<CloudVariableBadge
|
||||
cloudHost={this.props.cloudHost}
|
||||
onSetCloudHost={this.handleChangeCloudHost}
|
||||
onOpenChangeUsername={this.props.onOpenChangeUsername}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TWCloudVariableBadge.propTypes = {
|
||||
cloudHost: PropTypes.string,
|
||||
onSetCloudHost: PropTypes.func,
|
||||
onOpenChangeUsername: PropTypes.func
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
cloudHost: state.scratchGui.tw.cloudHost
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSetCloudHost: cloudHost => dispatch(setCloudHost(cloudHost)),
|
||||
onOpenChangeUsername: () => dispatch(openUsernameModal())
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TWCloudVariableBadge);
|
||||
257
scratch-gui/src/containers/tw-custom-extension-modal.jsx
Normal file
257
scratch-gui/src/containers/tw-custom-extension-modal.jsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {connect} from 'react-redux';
|
||||
import log from '../lib/log';
|
||||
import CustomExtensionModalComponent from '../components/tw-custom-extension-modal/custom-extension-modal.jsx';
|
||||
import {closeCustomExtensionModal} from '../reducers/modals';
|
||||
import {manuallyTrustExtension, isTrustedExtension} from './tw-security-manager.jsx';
|
||||
import {getPersistedUnsandboxed, setPersistedUnsandboxed} from '../lib/tw-persisted-unsandboxed.js';
|
||||
|
||||
/**
|
||||
* @param {Blob} blob Blob
|
||||
* @returns {Promise<string>} data: uri
|
||||
*/
|
||||
const readAsDataURL = blob => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(new Error(`Could not read extension as data URL: ${reader.error}`));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
class CustomExtensionModal extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
bindAll(this, [
|
||||
'handleChangeFiles',
|
||||
'handleChangeURL',
|
||||
'handleClose',
|
||||
'handleKeyDown',
|
||||
'handleLoadExtension',
|
||||
'handleSwitchToFile',
|
||||
'handleSwitchToURL',
|
||||
'handleSwitchToText',
|
||||
'handleChangeText',
|
||||
'handleDragOver',
|
||||
'handleDragLeave',
|
||||
'handleDrop',
|
||||
'handleChangeUnsandboxed'
|
||||
]);
|
||||
|
||||
this.state = {
|
||||
type: 'url',
|
||||
url: '',
|
||||
files: null,
|
||||
text: '',
|
||||
unsandboxed: getPersistedUnsandboxed()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>} List of extension URLs to load.
|
||||
*/
|
||||
getExtensionURLs () {
|
||||
if (this.state.type === 'url') {
|
||||
return Promise.resolve([
|
||||
this.state.url
|
||||
]);
|
||||
}
|
||||
|
||||
if (this.state.type === 'file') {
|
||||
const files = Array.from(this.state.files);
|
||||
return Promise.all(files.map(readAsDataURL));
|
||||
}
|
||||
|
||||
if (this.state.type === 'text') {
|
||||
return Promise.resolve([
|
||||
`data:application/javascript,${encodeURIComponent(this.state.text)}`
|
||||
]);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Unknown type'));
|
||||
}
|
||||
|
||||
hasValidInput () {
|
||||
if (this.state.type === 'url') {
|
||||
try {
|
||||
const parsed = new URL(this.state.url);
|
||||
return (
|
||||
parsed.protocol === 'https:' ||
|
||||
parsed.protocol === 'http:' ||
|
||||
parsed.protocol === 'data:'
|
||||
);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.type === 'file') {
|
||||
return !!this.state.files;
|
||||
}
|
||||
|
||||
if (this.state.type === 'text') {
|
||||
return !!this.state.text;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
handleChangeFiles (files) {
|
||||
this.setState({
|
||||
files
|
||||
});
|
||||
}
|
||||
|
||||
handleChangeURL (e) {
|
||||
this.setState({
|
||||
url: e.target.value
|
||||
});
|
||||
}
|
||||
|
||||
handleClose () {
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
handleKeyDown (e) {
|
||||
if (e.key === 'Enter' && this.hasValidInput()) {
|
||||
e.preventDefault();
|
||||
this.handleLoadExtension();
|
||||
}
|
||||
}
|
||||
|
||||
async handleLoadExtension () {
|
||||
this.handleClose();
|
||||
try {
|
||||
const urls = await this.getExtensionURLs();
|
||||
|
||||
if (this.state.type !== 'url') {
|
||||
setPersistedUnsandboxed(this.state.unsandboxed);
|
||||
if (this.state.unsandboxed) {
|
||||
for (const url of urls) {
|
||||
manuallyTrustExtension(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of urls) {
|
||||
await this.props.vm.extensionManager.loadExtensionURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(err);
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(err);
|
||||
}
|
||||
}
|
||||
|
||||
handleSwitchToFile () {
|
||||
this.setState({
|
||||
type: 'file'
|
||||
});
|
||||
}
|
||||
|
||||
handleSwitchToURL () {
|
||||
this.setState({
|
||||
type: 'url'
|
||||
});
|
||||
}
|
||||
|
||||
handleSwitchToText () {
|
||||
this.setState({
|
||||
type: 'text'
|
||||
});
|
||||
}
|
||||
|
||||
handleChangeText (e) {
|
||||
this.setState({
|
||||
text: e.target.value
|
||||
});
|
||||
}
|
||||
|
||||
handleDragOver (e) {
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
|
||||
handleDragLeave () {
|
||||
|
||||
}
|
||||
|
||||
handleDrop (e) {
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
e.preventDefault();
|
||||
this.setState({
|
||||
type: 'file',
|
||||
files
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isUnsandboxed () {
|
||||
if (this.state.type === 'url') {
|
||||
return isTrustedExtension(this.state.url);
|
||||
}
|
||||
return this.state.unsandboxed;
|
||||
}
|
||||
|
||||
canChangeUnsandboxed () {
|
||||
return this.state.type !== 'url';
|
||||
}
|
||||
|
||||
handleChangeUnsandboxed (e) {
|
||||
this.setState({
|
||||
unsandboxed: e.target.checked
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<CustomExtensionModalComponent
|
||||
canLoadExtension={this.hasValidInput()}
|
||||
type={this.state.type}
|
||||
onSwitchToFile={this.handleSwitchToFile}
|
||||
onSwitchToURL={this.handleSwitchToURL}
|
||||
onSwitchToText={this.handleSwitchToText}
|
||||
files={this.state.files}
|
||||
onChangeFiles={this.handleChangeFiles}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDragLeave={this.handleDragLeave}
|
||||
onDrop={this.handleDrop}
|
||||
url={this.state.url}
|
||||
onChangeURL={this.handleChangeURL}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
text={this.state.text}
|
||||
onChangeText={this.handleChangeText}
|
||||
unsandboxed={this.isUnsandboxed()}
|
||||
onChangeUnsandboxed={this.canChangeUnsandboxed() ? this.handleChangeUnsandboxed : null}
|
||||
onLoadExtension={this.handleLoadExtension}
|
||||
onClose={this.handleClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CustomExtensionModal.propTypes = {
|
||||
onClose: PropTypes.func,
|
||||
vm: PropTypes.shape({
|
||||
extensionManager: PropTypes.shape({
|
||||
loadExtensionURL: PropTypes.func
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onClose: () => dispatch(closeCustomExtensionModal())
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(CustomExtensionModal);
|
||||
114
scratch-gui/src/containers/tw-fonts-modal.jsx
Normal file
114
scratch-gui/src/containers/tw-fonts-modal.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {closeFontsModal} from '../reducers/modals';
|
||||
import FontsModalComponent from '../components/tw-fonts-modal/fonts-modal.jsx';
|
||||
|
||||
class TWFontsModal extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleClose',
|
||||
'handleCustomFontsChanged',
|
||||
'handleCancelAddFont',
|
||||
'handleOpenSystemFonts',
|
||||
'handleOpenLibaryFonts',
|
||||
'handleOpenCustomFonts'
|
||||
]);
|
||||
this.state = {
|
||||
fonts: this.props.vm.runtime.fontManager.getFonts(),
|
||||
screen: ''
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.props.vm.runtime.fontManager.on('change', this.handleCustomFontsChanged);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.props.vm.runtime.fontManager.off('change', this.handleCustomFontsChanged);
|
||||
}
|
||||
|
||||
handleClose () {
|
||||
if (this.state.screen) {
|
||||
this.setState({
|
||||
screen: ''
|
||||
});
|
||||
} else {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
handleCustomFontsChanged () {
|
||||
this.setState({
|
||||
fonts: this.props.vm.runtime.fontManager.getFonts()
|
||||
});
|
||||
}
|
||||
|
||||
handleCancelAddFont () {
|
||||
this.setState({
|
||||
screen: ''
|
||||
});
|
||||
}
|
||||
|
||||
handleOpenSystemFonts () {
|
||||
this.setState({
|
||||
screen: 'system'
|
||||
});
|
||||
}
|
||||
|
||||
handleOpenLibaryFonts () {
|
||||
this.setState({
|
||||
screen: 'library'
|
||||
});
|
||||
}
|
||||
|
||||
handleOpenCustomFonts () {
|
||||
this.setState({
|
||||
screen: 'custom'
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<FontsModalComponent
|
||||
onClose={this.handleClose}
|
||||
screen={this.state.screen}
|
||||
fonts={this.state.fonts}
|
||||
fontManager={this.props.vm.runtime.fontManager}
|
||||
onCancelAddFont={this.handleCancelAddFont}
|
||||
onOpenSystemFonts={this.handleOpenSystemFonts}
|
||||
onOpenLibraryFonts={this.handleOpenLibaryFonts}
|
||||
onOpenCustomFonts={this.handleOpenCustomFonts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TWFontsModal.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
vm: PropTypes.shape({
|
||||
runtime: PropTypes.shape({
|
||||
fontManager: PropTypes.shape({
|
||||
getFonts: PropTypes.func,
|
||||
addSystemFont: PropTypes.func,
|
||||
on: PropTypes.func,
|
||||
off: PropTypes.func
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onClose: () => dispatch(closeFontsModal())
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TWFontsModal);
|
||||
69
scratch-gui/src/containers/tw-framerate-changer.jsx
Normal file
69
scratch-gui/src/containers/tw-framerate-changer.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
const messages = defineMessages({
|
||||
newFramerate: {
|
||||
defaultMessage: 'New framerate:',
|
||||
description: 'Prompt shown to choose a new framerate',
|
||||
id: 'tw.menuBar.newFramerate'
|
||||
}
|
||||
});
|
||||
|
||||
class FramerateChanger extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'changeFramerate'
|
||||
]);
|
||||
}
|
||||
async changeFramerate (e) {
|
||||
if (e && (e.ctrlKey || e.shiftKey)) {
|
||||
// prompt() returns Promise in desktop app
|
||||
// eslint-disable-next-line no-alert
|
||||
const newFPS = await prompt(this.props.intl.formatMessage(messages.newFramerate), this.props.framerate);
|
||||
if (newFPS === null) {
|
||||
return;
|
||||
}
|
||||
const fps = +newFPS;
|
||||
if (isFinite(fps) && fps > 0) {
|
||||
this.props.vm.setFramerate(fps);
|
||||
}
|
||||
} else if (this.props.framerate === 60) {
|
||||
this.props.vm.setFramerate(30);
|
||||
} else {
|
||||
this.props.vm.setFramerate(60);
|
||||
}
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
intl,
|
||||
children,
|
||||
vm,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...props
|
||||
} = this.props;
|
||||
return this.props.children(this.changeFramerate, props);
|
||||
}
|
||||
}
|
||||
|
||||
FramerateChanger.propTypes = {
|
||||
intl: intlShape,
|
||||
children: PropTypes.func,
|
||||
framerate: PropTypes.number,
|
||||
vm: PropTypes.instanceOf(VM)
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
framerate: state.scratchGui.tw.framerate,
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
() => ({}) // omit dispatch prop
|
||||
)(FramerateChanger));
|
||||
32
scratch-gui/src/containers/tw-invalid-project-modal.jsx
Normal file
32
scratch-gui/src/containers/tw-invalid-project-modal.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import InvalidProjectModal from '../components/tw-invalid-project-modal/invalid-project-modal.jsx';
|
||||
import {closeInvalidProjectModal, openRestorePointModal} from '../reducers/modals';
|
||||
|
||||
const TWInvalidProjectModal = props => (
|
||||
<InvalidProjectModal {...props} />
|
||||
);
|
||||
|
||||
TWInvalidProjectModal.propTypes = {
|
||||
onClickRestorePoints: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
error: PropTypes.any
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
error: state.scratchGui.tw.projectError
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onClickRestorePoints: () => {
|
||||
dispatch(closeInvalidProjectModal());
|
||||
dispatch(openRestorePointModal());
|
||||
},
|
||||
onClose: () => dispatch(closeInvalidProjectModal())
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TWInvalidProjectModal);
|
||||
342
scratch-gui/src/containers/tw-restore-point-manager.jsx
Normal file
342
scratch-gui/src/containers/tw-restore-point-manager.jsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {intlShape, injectIntl, defineMessages} from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {showAlertWithTimeout, showStandardAlert} from '../reducers/alerts';
|
||||
import {closeLoadingProject, closeRestorePointModal, openLoadingProject} from '../reducers/modals';
|
||||
import {LoadingStates, getIsShowingProject, onLoadedProject, requestProjectUpload} from '../reducers/project-state';
|
||||
import {setFileHandle} from '../reducers/tw';
|
||||
import TWRestorePointModal from '../components/tw-restore-point-modal/restore-point-modal.jsx';
|
||||
import RestorePointAPI from '../lib/tw-restore-point-api';
|
||||
import log from '../lib/log';
|
||||
|
||||
/* eslint-disable no-alert */
|
||||
|
||||
const SAVE_DELAY = 250;
|
||||
const MINIMUM_SAVE_TIME = 1000;
|
||||
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const messages = defineMessages({
|
||||
confirmLoad: {
|
||||
defaultMessage: 'You have unsaved changes. Replace existing project?',
|
||||
description: 'Confirmation that appears when loading a restore point to confirm overwriting unsaved changes.',
|
||||
id: 'tw.restorePoints.confirmLoad'
|
||||
},
|
||||
confirmDelete: {
|
||||
defaultMessage: 'Are you sure you want to delete "{projectTitle}"? This cannot be undone.',
|
||||
description: 'Confirmation that appears when deleting a restore poinnt',
|
||||
id: 'tw.restorePoints.confirmDelete'
|
||||
},
|
||||
confirmDeleteAll: {
|
||||
defaultMessage: 'Are you sure you want to delete ALL restore points? This cannot be undone.',
|
||||
description: 'Confirmation that appears when deleting ALL restore points.',
|
||||
id: 'tw.restorePoints.confirmDeleteAll'
|
||||
},
|
||||
loadError: {
|
||||
defaultMessage: 'Error loading restore point: {error}',
|
||||
description: 'Error message when a restore point could not be loaded',
|
||||
id: 'tw.restorePoints.error'
|
||||
}
|
||||
});
|
||||
|
||||
class TWRestorePointManager extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleProjectChanged',
|
||||
'handleClickCreate',
|
||||
'handleClickDelete',
|
||||
'handleClickDeleteAll',
|
||||
'handleChangeInterval',
|
||||
'handleClickLoad'
|
||||
]);
|
||||
this.state = {
|
||||
loading: true,
|
||||
totalSize: 0,
|
||||
restorePoints: [],
|
||||
error: null,
|
||||
interval: RestorePointAPI.readInterval()
|
||||
};
|
||||
this.timeout = null;
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
// This helps reduce problems when people constantly enter and leave the editor which
|
||||
// causes this component to re-mount. Still not perfect though, ideally we would
|
||||
// compensate for time already passed.
|
||||
if (this.props.projectChanged && this.props.hasEverEnteredEditor) {
|
||||
this.queueRestorePoint();
|
||||
}
|
||||
|
||||
RestorePointAPI.deleteLegacyRestorePoint();
|
||||
this.props.vm.on('PROJECT_CHANGED', this.handleProjectChanged);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.isModalVisible && !this.props.isModalVisible) {
|
||||
this.refreshState();
|
||||
} else if (!nextProps.isModalVisible && this.props.isModalVisible) {
|
||||
this.setState({
|
||||
restorePoints: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.cancelQueuedRestorePoint();
|
||||
this.props.vm.off('PROJECT_CHANGED', this.handleProjectChanged);
|
||||
}
|
||||
|
||||
handleProjectChanged () {
|
||||
if (this.props.hasEverEnteredEditor && !this.timeout) {
|
||||
this.queueRestorePoint();
|
||||
}
|
||||
}
|
||||
|
||||
handleClickCreate () {
|
||||
this.createRestorePoint(RestorePointAPI.TYPE_MANUAL)
|
||||
.catch(error => {
|
||||
this.handleModalError(error);
|
||||
});
|
||||
}
|
||||
|
||||
handleClickDelete (id) {
|
||||
const projectTitle = this.state.restorePoints.find(i => i.id === id).title;
|
||||
if (!confirm(this.props.intl.formatMessage(messages.confirmDelete, {projectTitle}))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: true
|
||||
});
|
||||
RestorePointAPI.deleteRestorePoint(id)
|
||||
.then(() => {
|
||||
this.refreshState();
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleModalError(error);
|
||||
});
|
||||
}
|
||||
|
||||
handleClickDeleteAll () {
|
||||
if (!confirm(this.props.intl.formatMessage(messages.confirmDeleteAll))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: true
|
||||
});
|
||||
RestorePointAPI.deleteAllRestorePoints()
|
||||
.then(() => {
|
||||
this.refreshState();
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleModalError(error);
|
||||
});
|
||||
}
|
||||
|
||||
canLoadProject () {
|
||||
if (!this.props.isShowingProject) {
|
||||
// Loading a project now will break the state machine
|
||||
return false;
|
||||
}
|
||||
if (this.props.projectChanged && !confirm(this.props.intl.formatMessage(messages.confirmLoad))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
handleClickLoad (id) {
|
||||
if (!this.canLoadProject()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onCloseModal();
|
||||
this.props.onStartLoadingRestorePoint(this.props.loadingState);
|
||||
|
||||
RestorePointAPI.loadRestorePoint(this.props.vm, id)
|
||||
.then(() => {
|
||||
this.props.onFinishLoadingRestorePoint(true, this.props.loadingState);
|
||||
setTimeout(() => {
|
||||
// this.props.vm.renderer.draw();
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
log.error(error);
|
||||
alert(this.props.intl.formatMessage(messages.loadError, {
|
||||
error
|
||||
}));
|
||||
this.props.onFinishLoadingRestorePoint(false, this.props.loadingState);
|
||||
});
|
||||
}
|
||||
|
||||
handleChangeInterval (e) {
|
||||
const interval = +e.target.value;
|
||||
RestorePointAPI.setInterval(interval);
|
||||
this.setState({
|
||||
interval
|
||||
}, () => {
|
||||
if (this.timeout) {
|
||||
this.cancelQueuedRestorePoint();
|
||||
this.queueRestorePoint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
queueRestorePoint () {
|
||||
if (this.timeout || this.state.interval < 0) {
|
||||
return;
|
||||
}
|
||||
this.timeout = setTimeout(() => {
|
||||
this.createRestorePoint(RestorePointAPI.TYPE_AUTOMATIC).then(() => {
|
||||
this.timeout = null;
|
||||
});
|
||||
}, this.state.interval);
|
||||
}
|
||||
|
||||
cancelQueuedRestorePoint () {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// createRestorePoint (type) {
|
||||
// if (this.props.isModalVisible) {
|
||||
// this.setState({
|
||||
// loading: true
|
||||
// });
|
||||
// }
|
||||
|
||||
// this.props.onStartCreatingRestorePoint();
|
||||
// return Promise.all([
|
||||
// // Wait a little bit before saving so UI can update before saving, which can cause stutter
|
||||
// sleep(SAVE_DELAY)
|
||||
// .then(() => RestorePointAPI.createRestorePoint(this.props.vm, this.props.projectTitle, type))
|
||||
// .then(() => RestorePointAPI.removeExtraneousRestorePoints()),
|
||||
|
||||
// // Force saves to not be instant so people can see that we're making a restore point
|
||||
// // It also makes refreshes less likely to cause accidental clicks in the modal
|
||||
// sleep(MINIMUM_SAVE_TIME)
|
||||
// ])
|
||||
// .then(() => {
|
||||
// this.props.onFinishCreatingRestorePoint();
|
||||
// if (this.props.isModalVisible) {
|
||||
// this.refreshState();
|
||||
// }
|
||||
// })
|
||||
// .catch(error => {
|
||||
// log.error(error);
|
||||
// this.props.onErrorCreatingRestorePoint();
|
||||
// if (this.props.isModalVisible) {
|
||||
// this.refreshState();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
refreshState () {
|
||||
this.setState({
|
||||
loading: true,
|
||||
error: null,
|
||||
restorePoints: []
|
||||
});
|
||||
RestorePointAPI.getAllRestorePoints()
|
||||
.then(data => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
totalSize: data.totalSize,
|
||||
restorePoints: data.restorePoints
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleModalError(error);
|
||||
});
|
||||
}
|
||||
|
||||
handleModalError (error) {
|
||||
log.error('Restore point error', error);
|
||||
this.setState({
|
||||
error: `${error}`,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this.props.isModalVisible) {
|
||||
return (
|
||||
<TWRestorePointModal
|
||||
onClose={this.props.onCloseModal}
|
||||
onClickCreate={this.handleClickCreate}
|
||||
onClickDelete={this.handleClickDelete}
|
||||
onClickDeleteAll={this.handleClickDeleteAll}
|
||||
onClickLoad={this.handleClickLoad}
|
||||
interval={this.state.interval}
|
||||
onChangeInterval={this.handleChangeInterval}
|
||||
isLoading={this.state.loading}
|
||||
totalSize={this.state.totalSize}
|
||||
restorePoints={this.state.restorePoints}
|
||||
error={this.state.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
TWRestorePointManager.propTypes = {
|
||||
intl: intlShape,
|
||||
projectChanged: PropTypes.bool.isRequired,
|
||||
projectTitle: PropTypes.string.isRequired,
|
||||
onStartCreatingRestorePoint: PropTypes.func.isRequired,
|
||||
onFinishCreatingRestorePoint: PropTypes.func.isRequired,
|
||||
onErrorCreatingRestorePoint: PropTypes.func.isRequired,
|
||||
onStartLoadingRestorePoint: PropTypes.func.isRequired,
|
||||
onFinishLoadingRestorePoint: PropTypes.func.isRequired,
|
||||
onCloseModal: PropTypes.func.isRequired,
|
||||
loadingState: PropTypes.oneOf(LoadingStates).isRequired,
|
||||
isShowingProject: PropTypes.bool.isRequired,
|
||||
isModalVisible: PropTypes.bool.isRequired,
|
||||
hasEverEnteredEditor: PropTypes.bool.isRequired,
|
||||
vm: PropTypes.shape({
|
||||
on: PropTypes.func.isRequired,
|
||||
off: PropTypes.func.isRequired,
|
||||
loadProject: PropTypes.func.isRequired,
|
||||
stop: PropTypes.func.isRequired,
|
||||
renderer: PropTypes.shape({
|
||||
draw: PropTypes.func.isRequired
|
||||
})
|
||||
}).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
projectChanged: state.scratchGui.projectChanged,
|
||||
projectTitle: state.scratchGui.projectTitle,
|
||||
loadingState: state.scratchGui.projectState.loadingState,
|
||||
isShowingProject: getIsShowingProject(state.scratchGui.projectState.loadingState),
|
||||
isModalVisible: state.scratchGui.modals.restorePointModal,
|
||||
hasEverEnteredEditor: state.scratchGui.mode.hasEverEnteredEditor,
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onStartCreatingRestorePoint: () => dispatch(showStandardAlert('twCreatingRestorePoint')),
|
||||
onFinishCreatingRestorePoint: () => showAlertWithTimeout(dispatch, 'twRestorePointSuccess'),
|
||||
onErrorCreatingRestorePoint: () => showAlertWithTimeout(dispatch, 'twRestorePointError'),
|
||||
onStartLoadingRestorePoint: loadingState => {
|
||||
dispatch(openLoadingProject());
|
||||
dispatch(requestProjectUpload(loadingState));
|
||||
},
|
||||
onFinishLoadingRestorePoint: (success, loadingState) => {
|
||||
dispatch(onLoadedProject(loadingState, false, success));
|
||||
dispatch(closeLoadingProject());
|
||||
dispatch(setFileHandle(null));
|
||||
},
|
||||
onCloseModal: () => dispatch(closeRestorePointModal())
|
||||
});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TWRestorePointManager));
|
||||
454
scratch-gui/src/containers/tw-security-manager.jsx
Normal file
454
scratch-gui/src/containers/tw-security-manager.jsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
import log from '../lib/log';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import SecurityManagerModal from '../components/tw-security-manager-modal/security-manager-modal.jsx';
|
||||
import SecurityModals from '../lib/tw-security-manager-constants';
|
||||
import {getPersistedUnsandboxed, setPersistedUnsandboxed} from '../lib/tw-persisted-unsandboxed.js';
|
||||
|
||||
/* eslint-disable require-atomic-updates */
|
||||
|
||||
/**
|
||||
* Set of extension URLs that the user has manually trusted to load unsandboxed.
|
||||
*/
|
||||
const extensionsTrustedByUser = new Set();
|
||||
|
||||
const manuallyTrustExtension = url => {
|
||||
extensionsTrustedByUser.add(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Trusted extensions are loaded automatically and without a sandbox.
|
||||
* @param {string} url URL as a string.
|
||||
* @returns {boolean} True if the extension can is trusted
|
||||
*/
|
||||
const isTrustedExtension = url => (
|
||||
// Always trust our official extension repostiory.
|
||||
url.startsWith('https://extensions.turbowarp.org/') ||
|
||||
|
||||
// For development.
|
||||
url.startsWith('http://localhost:8000/') ||
|
||||
|
||||
extensionsTrustedByUser.has(url)
|
||||
);
|
||||
|
||||
/**
|
||||
* Set of fetch resource origins that were manually trusted by the user.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const fetchOriginsTrustedByUser = new Set();
|
||||
|
||||
/**
|
||||
* Set of origins manually trusted by the user for embedding.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const embedOriginsTrustedByUser = new Set();
|
||||
|
||||
/**
|
||||
* @param {URL} parsed Parsed URL object
|
||||
* @returns {boolean} True if the URL is part of the builtin set of URLs to always trust fetching from.
|
||||
*/
|
||||
const isAlwaysTrustedForFetching = parsed => (
|
||||
// If we would trust loading an extension from here, we can trust loading resources too.
|
||||
isTrustedExtension(parsed.href) ||
|
||||
|
||||
// Any TurboWarp service such as trampoline
|
||||
parsed.origin === 'https://turbowarp.org' ||
|
||||
parsed.origin.endsWith('.turbowarp.org') ||
|
||||
parsed.origin.endsWith('.turbowarp.xyz') ||
|
||||
|
||||
// GitHub API
|
||||
// GitHub Pages allows redirects, so not included here.
|
||||
parsed.origin === 'https://raw.githubusercontent.com' ||
|
||||
parsed.origin === 'https://api.github.com' ||
|
||||
|
||||
// GitLab API
|
||||
// GitLab Pages allows redirects, so not included here.
|
||||
parsed.origin === 'https://gitlab.com' ||
|
||||
|
||||
// Sourcehut Pages
|
||||
parsed.origin.endsWith('.srht.site') ||
|
||||
|
||||
// Itch
|
||||
parsed.origin.endsWith('.itch.io') ||
|
||||
|
||||
// GameJolt
|
||||
parsed.origin === 'https://api.gamejolt.com' ||
|
||||
|
||||
// httpbin
|
||||
parsed.origin === 'https://httpbin.org' ||
|
||||
|
||||
// ScratchDB
|
||||
parsed.origin === 'https://scratchdb.lefty.one'
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {string} url Original URL string
|
||||
* @returns {URL|null} A URL object if it is valid and of a known protocol, otherwise null.
|
||||
*/
|
||||
const parseURL = url => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
const protocols = [
|
||||
// The important one we want to exclude is javascript:
|
||||
'http:',
|
||||
'https:',
|
||||
'ws:',
|
||||
'wss:',
|
||||
'data:',
|
||||
'blob:',
|
||||
'mailto:',
|
||||
'steam:',
|
||||
'calculator:'
|
||||
];
|
||||
if (!protocols.includes(parsed.protocol)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
let allowedAudio = false;
|
||||
let allowedVideo = false;
|
||||
let allowedReadClipboard = false;
|
||||
let allowedNotify = false;
|
||||
let allowedGeolocation = false;
|
||||
|
||||
const SECURITY_MANAGER_METHODS = [
|
||||
'getSandboxMode',
|
||||
'canLoadExtensionFromProject',
|
||||
'canFetch',
|
||||
'canOpenWindow',
|
||||
'canRedirect',
|
||||
'canRecordAudio',
|
||||
'canRecordVideo',
|
||||
'canReadClipboard',
|
||||
'canNotify',
|
||||
'canGeolocate',
|
||||
'canEmbed'
|
||||
];
|
||||
|
||||
class TWSecurityManagerComponent extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleAllowed',
|
||||
'handleDenied'
|
||||
]);
|
||||
bindAll(this, SECURITY_MANAGER_METHODS);
|
||||
this.nextModalCallbacks = [];
|
||||
this.modalLocked = false;
|
||||
this.state = {
|
||||
type: null,
|
||||
data: null,
|
||||
callback: null,
|
||||
modalCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const vmSecurityManager = this.props.vm.extensionManager.securityManager;
|
||||
const propsSecurityManager = this.props.securityManager;
|
||||
for (const method of SECURITY_MANAGER_METHODS) {
|
||||
vmSecurityManager[method] = propsSecurityManager[method] || this[method];
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line valid-jsdoc
|
||||
/**
|
||||
* @returns {Promise<() => Promise<boolean>>} Resolves with a function that you can call to show the modal.
|
||||
* The resolved function returns a promise that resolves with true if the request was approved.
|
||||
*/
|
||||
async acquireModalLock () {
|
||||
// We need a two-step process for showing a modal so that we don't overwrite or overlap modals,
|
||||
// and so that multiple attempts to fetch resources from the same origin will all be allowed
|
||||
// with just one click. This means that some places have to wait until previous modals are
|
||||
// closed before it knows if it needs to display another modal.
|
||||
|
||||
if (this.modalLocked) {
|
||||
await new Promise(resolve => {
|
||||
this.nextModalCallbacks.push(resolve);
|
||||
});
|
||||
} else {
|
||||
this.modalLocked = true;
|
||||
}
|
||||
|
||||
const releaseLock = () => {
|
||||
if (this.nextModalCallbacks.length) {
|
||||
const nextModalCallback = this.nextModalCallbacks.shift();
|
||||
nextModalCallback();
|
||||
} else {
|
||||
this.modalLocked = false;
|
||||
this.setState({
|
||||
// only clear type in case other data needs to be accessed
|
||||
type: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showModal = async (type, data) => {
|
||||
const result = await new Promise(resolve => {
|
||||
this.setState(oldState => ({
|
||||
type,
|
||||
data,
|
||||
callback: resolve,
|
||||
modalCount: oldState.modalCount + 1
|
||||
}));
|
||||
});
|
||||
releaseLock();
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
showModal,
|
||||
releaseLock
|
||||
};
|
||||
}
|
||||
|
||||
handleAllowed () {
|
||||
this.state.callback(true);
|
||||
}
|
||||
|
||||
handleDenied () {
|
||||
this.state.callback(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url The extension's URL
|
||||
* @returns {string} The VM worker mode to use
|
||||
*/
|
||||
getSandboxMode (url) {
|
||||
if (isTrustedExtension(url)) {
|
||||
log.info(`Loading extension ${url} unsandboxed`);
|
||||
return 'unsandboxed';
|
||||
}
|
||||
return 'iframe';
|
||||
}
|
||||
|
||||
handleChangeUnsandboxed (e) {
|
||||
const checked = e.target.checked;
|
||||
this.setState(oldState => ({
|
||||
data: {
|
||||
...oldState.data,
|
||||
unsandboxed: checked
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url The extension's URL
|
||||
* @returns {Promise<boolean>} Whether the extension can be loaded
|
||||
*/
|
||||
async canLoadExtensionFromProject (url) {
|
||||
if (isTrustedExtension(url)) {
|
||||
log.info(`Loading extension ${url} automatically`);
|
||||
return true;
|
||||
}
|
||||
const {showModal} = await this.acquireModalLock();
|
||||
if (url.startsWith('data:')) {
|
||||
const allowed = await showModal(SecurityModals.LoadExtension, {
|
||||
url,
|
||||
unsandboxed: getPersistedUnsandboxed(),
|
||||
onChangeUnsandboxed: this.handleChangeUnsandboxed.bind(this)
|
||||
});
|
||||
if (allowed) {
|
||||
setPersistedUnsandboxed(this.state.data.unsandboxed);
|
||||
}
|
||||
if (allowed && this.state.data.unsandboxed) {
|
||||
manuallyTrustExtension(url);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
return showModal(SecurityModals.LoadExtension, {
|
||||
url,
|
||||
unsandboxed: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url The resource to fetch
|
||||
* @returns {Promise<boolean>} True if the resource is allowed to be fetched
|
||||
*/
|
||||
async canFetch (url) {
|
||||
const parsed = parseURL(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
if (isAlwaysTrustedForFetching(parsed)) {
|
||||
return true;
|
||||
}
|
||||
const {showModal, releaseLock} = await this.acquireModalLock();
|
||||
if (fetchOriginsTrustedByUser.has(origin)) {
|
||||
releaseLock();
|
||||
return true;
|
||||
}
|
||||
const allowed = await showModal(SecurityModals.Fetch, {
|
||||
url
|
||||
});
|
||||
if (allowed) {
|
||||
fetchOriginsTrustedByUser.add(origin);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url The website to open
|
||||
* @returns {Promise<boolean>} True if the website can be opened
|
||||
*/
|
||||
async canOpenWindow (url) {
|
||||
const parsed = parseURL(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const {showModal} = await this.acquireModalLock();
|
||||
return showModal(SecurityModals.OpenWindow, {
|
||||
url
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url The website to redirect to
|
||||
* @returns {Promise<boolean>} True if the website can be redirected to
|
||||
*/
|
||||
async canRedirect (url) {
|
||||
const parsed = parseURL(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const {showModal} = await this.acquireModalLock();
|
||||
return showModal(SecurityModals.Redirect, {
|
||||
url
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>} True if audio can be recorded
|
||||
*/
|
||||
async canRecordAudio () {
|
||||
if (!allowedAudio) {
|
||||
const {showModal} = await this.acquireModalLock();
|
||||
allowedAudio = await showModal(SecurityModals.RecordAudio);
|
||||
}
|
||||
return allowedAudio;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>} True if video can be recorded
|
||||
*/
|
||||
async canRecordVideo () {
|
||||
if (!allowedVideo) {
|
||||
const {showModal} = await this.acquireModalLock();
|
||||
allowedVideo = await showModal(SecurityModals.RecordVideo);
|
||||
}
|
||||
return allowedVideo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>} True if the clipboard can be read
|
||||
*/
|
||||
async canReadClipboard () {
|
||||
if (!allowedReadClipboard) {
|
||||
const {showModal} = await this.acquireModalLock();
|
||||
allowedReadClipboard = await showModal(SecurityModals.ReadClipboard);
|
||||
}
|
||||
return allowedReadClipboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>} True if the notifications are allowed
|
||||
*/
|
||||
async canNotify () {
|
||||
if (!allowedNotify) {
|
||||
const {showModal} = await this.acquireModalLock();
|
||||
allowedNotify = await showModal(SecurityModals.Notify);
|
||||
}
|
||||
return allowedNotify;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>} True if geolocation is allowed.
|
||||
*/
|
||||
async canGeolocate () {
|
||||
if (!allowedGeolocation) {
|
||||
const {showModal} = await this.acquireModalLock();
|
||||
allowedGeolocation = await showModal(SecurityModals.Geolocate);
|
||||
}
|
||||
return allowedGeolocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url Frame URL
|
||||
* @returns {Promise<boolean>} True if embed is allowed.
|
||||
*/
|
||||
async canEmbed (url) {
|
||||
const parsed = parseURL(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const origin = (parsed.protocol === 'http:' || parsed.protocol === 'https:') ? parsed.origin : null;
|
||||
const {showModal, releaseLock} = await this.acquireModalLock();
|
||||
if (origin && embedOriginsTrustedByUser.has(origin)) {
|
||||
releaseLock();
|
||||
return true;
|
||||
}
|
||||
const allowed = await showModal(SecurityModals.Embed, {url});
|
||||
if (origin && allowed) {
|
||||
embedOriginsTrustedByUser.add(origin);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this.state.type) {
|
||||
return (
|
||||
<SecurityManagerModal
|
||||
type={this.state.type}
|
||||
data={this.state.data}
|
||||
onAllowed={this.handleAllowed}
|
||||
onDenied={this.handleDenied}
|
||||
key={this.state.modalCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
TWSecurityManagerComponent.propTypes = {
|
||||
vm: PropTypes.shape({
|
||||
extensionManager: PropTypes.shape({
|
||||
securityManager: PropTypes.shape(
|
||||
SECURITY_MANAGER_METHODS.reduce((obj, method) => {
|
||||
obj[method] = PropTypes.func.isRequired;
|
||||
return obj;
|
||||
}, {})
|
||||
).isRequired
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
securityManager: PropTypes.shape(Object.fromEntries(SECURITY_MANAGER_METHODS.map(i => [i, PropTypes.func])))
|
||||
};
|
||||
|
||||
TWSecurityManagerComponent.defaultProps = {
|
||||
securityManager: {}
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
vm: state.scratchGui.vm
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
const ConnectedSecurityManagerComponent = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TWSecurityManagerComponent);
|
||||
|
||||
export {
|
||||
ConnectedSecurityManagerComponent as default,
|
||||
manuallyTrustExtension,
|
||||
isTrustedExtension
|
||||
};
|
||||
173
scratch-gui/src/containers/tw-settings-modal.jsx
Normal file
173
scratch-gui/src/containers/tw-settings-modal.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {connect} from 'react-redux';
|
||||
import {closeSettingsModal} from '../reducers/modals';
|
||||
import SettingsModalComponent from '../components/tw-settings-modal/settings-modal.jsx';
|
||||
import {defaultStageSize} from '../reducers/custom-stage-size';
|
||||
|
||||
const messages = defineMessages({
|
||||
newFramerate: {
|
||||
defaultMessage: 'New framerate:',
|
||||
description: 'Prompt shown to choose a new framerate',
|
||||
id: 'tw.menuBar.newFramerate'
|
||||
}
|
||||
});
|
||||
|
||||
class UsernameModal extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleFramerateChange',
|
||||
'handleCustomizeFramerate',
|
||||
'handleHighQualityPenChange',
|
||||
'handleInterpolationChange',
|
||||
'handleInfiniteClonesChange',
|
||||
'handleRemoveFencingChange',
|
||||
'handleRemoveLimitsChange',
|
||||
'handleWarpTimerChange',
|
||||
'handleStageWidthChange',
|
||||
'handleStageHeightChange',
|
||||
'handleDisableCompilerChange',
|
||||
'handleStoreProjectOptions'
|
||||
]);
|
||||
}
|
||||
handleFramerateChange (e) {
|
||||
this.props.vm.setFramerate(e.target.checked ? 60 : 30);
|
||||
}
|
||||
async handleCustomizeFramerate () {
|
||||
// prompt() returns Promise in desktop app
|
||||
// eslint-disable-next-line no-alert
|
||||
const newFramerate = await prompt(this.props.intl.formatMessage(messages.newFramerate), this.props.framerate);
|
||||
const parsed = parseFloat(newFramerate);
|
||||
if (isFinite(parsed)) {
|
||||
this.props.vm.setFramerate(parsed);
|
||||
}
|
||||
}
|
||||
handleHighQualityPenChange (e) {
|
||||
this.props.vm.renderer.setUseHighQualityRender(e.target.checked);
|
||||
}
|
||||
handleInterpolationChange (e) {
|
||||
this.props.vm.setInterpolation(e.target.checked);
|
||||
}
|
||||
handleInfiniteClonesChange (e) {
|
||||
this.props.vm.setRuntimeOptions({
|
||||
maxClones: e.target.checked ? Infinity : 300
|
||||
});
|
||||
}
|
||||
handleRemoveFencingChange (e) {
|
||||
this.props.vm.setRuntimeOptions({
|
||||
fencing: !e.target.checked
|
||||
});
|
||||
}
|
||||
handleRemoveLimitsChange (e) {
|
||||
this.props.vm.setRuntimeOptions({
|
||||
miscLimits: !e.target.checked
|
||||
});
|
||||
}
|
||||
handleWarpTimerChange (e) {
|
||||
this.props.vm.setCompilerOptions({
|
||||
warpTimer: e.target.checked
|
||||
});
|
||||
}
|
||||
handleDisableCompilerChange (e) {
|
||||
this.props.vm.setCompilerOptions({
|
||||
enabled: !e.target.checked
|
||||
});
|
||||
}
|
||||
handleStageWidthChange (value) {
|
||||
this.props.vm.setStageSize(value, this.props.customStageSize.height);
|
||||
}
|
||||
handleStageHeightChange (value) {
|
||||
this.props.vm.setStageSize(this.props.customStageSize.width, value);
|
||||
}
|
||||
handleStoreProjectOptions () {
|
||||
this.props.vm.storeProjectOptions();
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
onClose,
|
||||
vm,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<SettingsModalComponent
|
||||
onClose={this.props.onClose}
|
||||
onFramerateChange={this.handleFramerateChange}
|
||||
onCustomizeFramerate={this.handleCustomizeFramerate}
|
||||
onHighQualityPenChange={this.handleHighQualityPenChange}
|
||||
onInterpolationChange={this.handleInterpolationChange}
|
||||
onInfiniteClonesChange={this.handleInfiniteClonesChange}
|
||||
onRemoveFencingChange={this.handleRemoveFencingChange}
|
||||
onRemoveLimitsChange={this.handleRemoveLimitsChange}
|
||||
onWarpTimerChange={this.handleWarpTimerChange}
|
||||
onStageWidthChange={this.handleStageWidthChange}
|
||||
onStageHeightChange={this.handleStageHeightChange}
|
||||
onDisableCompilerChange={this.handleDisableCompilerChange}
|
||||
stageWidth={this.props.customStageSize.width}
|
||||
stageHeight={this.props.customStageSize.height}
|
||||
customStageSizeEnabled={
|
||||
this.props.customStageSize.width !== defaultStageSize.width ||
|
||||
this.props.customStageSize.height !== defaultStageSize.height
|
||||
}
|
||||
onStoreProjectOptions={this.handleStoreProjectOptions}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UsernameModal.propTypes = {
|
||||
intl: intlShape,
|
||||
onClose: PropTypes.func,
|
||||
vm: PropTypes.shape({
|
||||
renderer: PropTypes.shape({
|
||||
setUseHighQualityRender: PropTypes.func
|
||||
}),
|
||||
setFramerate: PropTypes.func,
|
||||
setCompilerOptions: PropTypes.func,
|
||||
setInterpolation: PropTypes.func,
|
||||
setRuntimeOptions: PropTypes.func,
|
||||
setStageSize: PropTypes.func,
|
||||
storeProjectOptions: PropTypes.func
|
||||
}),
|
||||
isEmbedded: PropTypes.bool,
|
||||
framerate: PropTypes.number,
|
||||
highQualityPen: PropTypes.bool,
|
||||
interpolation: PropTypes.bool,
|
||||
infiniteClones: PropTypes.bool,
|
||||
removeFencing: PropTypes.bool,
|
||||
removeLimits: PropTypes.bool,
|
||||
warpTimer: PropTypes.bool,
|
||||
customStageSize: PropTypes.shape({
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number
|
||||
}),
|
||||
disableCompiler: PropTypes.bool
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
vm: state.scratchGui.vm,
|
||||
isEmbedded: state.scratchGui.mode.isEmbedded,
|
||||
framerate: state.scratchGui.tw.framerate,
|
||||
highQualityPen: state.scratchGui.tw.highQualityPen,
|
||||
interpolation: state.scratchGui.tw.interpolation,
|
||||
infiniteClones: state.scratchGui.tw.runtimeOptions.maxClones === Infinity,
|
||||
removeFencing: !state.scratchGui.tw.runtimeOptions.fencing,
|
||||
removeLimits: !state.scratchGui.tw.runtimeOptions.miscLimits,
|
||||
warpTimer: state.scratchGui.tw.compilerOptions.warpTimer,
|
||||
customStageSize: state.scratchGui.customStageSize,
|
||||
disableCompiler: !state.scratchGui.tw.compilerOptions.enabled
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onClose: () => dispatch(closeSettingsModal())
|
||||
});
|
||||
|
||||
export default injectIntl(connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(UsernameModal));
|
||||
73
scratch-gui/src/containers/tw-theme-manager-hoc.jsx
Normal file
73
scratch-gui/src/containers/tw-theme-manager-hoc.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {applyGuiColors} from '../lib/themes/guiHelpers';
|
||||
import {BLOCKS_CUSTOM, Theme} from '../lib/themes';
|
||||
import {detectTheme, onSystemPreferenceChange} from '../lib/themes/themePersistance';
|
||||
import {setTheme} from '../reducers/theme';
|
||||
|
||||
const TWThemeManagerHOC = function (WrappedComponent) {
|
||||
class TWThemeManagerComponent extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleSystemThemeChange'
|
||||
]);
|
||||
applyGuiColors(props.reduxTheme);
|
||||
}
|
||||
componentDidMount () {
|
||||
this.removeListeners = onSystemPreferenceChange(this.handleSystemThemeChange);
|
||||
}
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.reduxTheme !== this.props.reduxTheme) {
|
||||
applyGuiColors(this.props.reduxTheme);
|
||||
}
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.removeListeners();
|
||||
}
|
||||
handleSystemThemeChange () {
|
||||
let newTheme = detectTheme();
|
||||
if (this.props.reduxTheme.blocks === BLOCKS_CUSTOM) {
|
||||
newTheme = newTheme.set('blocks', BLOCKS_CUSTOM);
|
||||
}
|
||||
this.props.onChangeTheme(newTheme);
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
reduxTheme,
|
||||
onChangeTheme,
|
||||
/* eslint-enable no-unused-vars */
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<WrappedComponent
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TWThemeManagerComponent.propTypes = {
|
||||
reduxTheme: PropTypes.instanceOf(Theme),
|
||||
onChangeTheme: PropTypes.func
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, ownProps) => ({
|
||||
// Allow embed page to override theme
|
||||
reduxTheme: ownProps.theme || state.scratchGui.theme.theme
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChangeTheme: theme => dispatch(setTheme(theme))
|
||||
});
|
||||
|
||||
return connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TWThemeManagerComponent);
|
||||
};
|
||||
|
||||
export default TWThemeManagerHOC;
|
||||
68
scratch-gui/src/containers/tw-unknown-platform-modal.jsx
Normal file
68
scratch-gui/src/containers/tw-unknown-platform-modal.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {closeUnknownPlatformModal} from '../reducers/modals';
|
||||
import UnknownPlatformModalComponent from '../components/tw-unknown-platform-modal/unknown-platform-modal.jsx';
|
||||
|
||||
class TWUnknownPlatformModal extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleClose'
|
||||
]);
|
||||
this.state = {
|
||||
canClose: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
// Make it harder to accidentally dismiss without reading
|
||||
setTimeout(() => {
|
||||
this.setState({
|
||||
canClose: true
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
handleClose () {
|
||||
if (this.state.canClose) {
|
||||
this.props.callback();
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<UnknownPlatformModalComponent
|
||||
onClose={this.handleClose}
|
||||
platform={this.props.platform}
|
||||
canClose={this.state.canClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TWUnknownPlatformModal.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
platform: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
url: PropTypes.string
|
||||
}),
|
||||
callback: PropTypes.func
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
vm: state.scratchGui.vm,
|
||||
callback: state.scratchGui.tw.platformMismatchDetails.callback,
|
||||
platform: state.scratchGui.tw.platformMismatchDetails.platform
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onClose: () => dispatch(closeUnknownPlatformModal())
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TWUnknownPlatformModal);
|
||||
93
scratch-gui/src/containers/tw-username-modal.jsx
Normal file
93
scratch-gui/src/containers/tw-username-modal.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import {connect} from 'react-redux';
|
||||
import {setUsername, setUsernameInvalid} from '../reducers/tw';
|
||||
import UsernameModalComponent from '../components/tw-username-modal/username-modal.jsx';
|
||||
import {closeUsernameModal} from '../reducers/modals';
|
||||
import {generateRandomUsername} from '../lib/tw-username';
|
||||
import isScratchDesktop from '../lib/isScratchDesktop';
|
||||
|
||||
class UsernameModal extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleKeyPress',
|
||||
'handleFocus',
|
||||
'handleOk',
|
||||
'handleCancel',
|
||||
'handleChange',
|
||||
'handleReset'
|
||||
]);
|
||||
this.state = {
|
||||
value: this.props.username,
|
||||
valueValid: !this.props.usernameInvalid
|
||||
};
|
||||
}
|
||||
handleKeyPress (event) {
|
||||
if (event.key === 'Enter' && this.state.valueValid) {
|
||||
this.handleOk();
|
||||
}
|
||||
}
|
||||
handleFocus (event) {
|
||||
event.target.select();
|
||||
}
|
||||
handleOk () {
|
||||
this.props.onSetUsername(this.state.value);
|
||||
this.props.onCloseUsernameModal();
|
||||
}
|
||||
handleCancel () {
|
||||
this.props.onCloseUsernameModal();
|
||||
}
|
||||
handleChange (e) {
|
||||
this.setState({
|
||||
value: e.target.value,
|
||||
valueValid: e.target.checkValidity()
|
||||
});
|
||||
}
|
||||
handleReset () {
|
||||
const randomUsername = isScratchDesktop() ? 'player' : generateRandomUsername();
|
||||
this.props.onCloseUsernameModal();
|
||||
this.props.onSetUsername(randomUsername);
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<UsernameModalComponent
|
||||
mustChangeUsername={this.props.usernameInvalid}
|
||||
value={this.state.value}
|
||||
valueValid={this.state.valueValid}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onFocus={this.handleFocus}
|
||||
onOk={this.handleOk}
|
||||
onCancel={this.handleCancel}
|
||||
onChange={this.handleChange}
|
||||
onReset={this.handleReset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UsernameModal.propTypes = {
|
||||
onCloseUsernameModal: PropTypes.func,
|
||||
onSetUsername: PropTypes.func,
|
||||
username: PropTypes.string,
|
||||
usernameInvalid: PropTypes.bool
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
username: state.scratchGui.tw.username,
|
||||
usernameInvalid: state.scratchGui.tw.usernameInvalid
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onCloseUsernameModal: () => dispatch(closeUsernameModal()),
|
||||
onSetUsername: username => {
|
||||
dispatch(setUsername(username));
|
||||
dispatch(setUsernameInvalid(false));
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(UsernameModal);
|
||||
71
scratch-gui/src/containers/watermark.jsx
Normal file
71
scratch-gui/src/containers/watermark.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import omit from 'lodash.omit';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import ThrottledPropertyHOC from '../lib/throttled-property-hoc.jsx';
|
||||
|
||||
import VM from 'scratch-vm';
|
||||
import storage from '../lib/storage';
|
||||
import getCostumeUrl from '../lib/get-costume-url';
|
||||
|
||||
import WatermarkComponent from '../components/watermark/watermark.jsx';
|
||||
|
||||
class Watermark extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'getCostumeData'
|
||||
]);
|
||||
}
|
||||
|
||||
getCostumeData () {
|
||||
if (!this.props.asset) return null;
|
||||
|
||||
return getCostumeUrl(this.props.asset);
|
||||
}
|
||||
|
||||
render () {
|
||||
const componentProps = omit(this.props, ['asset', 'vm']);
|
||||
return (
|
||||
<WatermarkComponent
|
||||
costumeURL={this.getCostumeData()}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Watermark.propTypes = {
|
||||
asset: PropTypes.instanceOf(storage.Asset),
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const targets = state.scratchGui.targets;
|
||||
const currentTargetId = targets.editingTarget;
|
||||
|
||||
let asset;
|
||||
if (currentTargetId) {
|
||||
if (targets.stage.id === currentTargetId) {
|
||||
asset = targets.stage.costume.asset;
|
||||
} else if (Object.prototype.hasOwnProperty.call(targets.sprites, currentTargetId)) {
|
||||
const currentSprite = targets.sprites[currentTargetId];
|
||||
asset = currentSprite.costume.asset;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
vm: state.scratchGui.vm,
|
||||
asset: asset
|
||||
};
|
||||
};
|
||||
|
||||
const ConnectedComponent = connect(
|
||||
mapStateToProps
|
||||
)(
|
||||
ThrottledPropertyHOC('asset', 500)(Watermark)
|
||||
);
|
||||
|
||||
export default ConnectedComponent;
|
||||
24
scratch-gui/src/containers/webgl-modal.jsx
Normal file
24
scratch-gui/src/containers/webgl-modal.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import WebGlModalComponent from '../components/webgl-modal/webgl-modal.jsx';
|
||||
|
||||
class WebGlModal extends React.Component {
|
||||
handleCancel () {
|
||||
window.history.back();
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<WebGlModalComponent
|
||||
isRtl={this.props.isRtl}
|
||||
onBack={this.handleCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
WebGlModal.propTypes = {
|
||||
isRtl: PropTypes.bool
|
||||
};
|
||||
|
||||
export default WebGlModal;
|
||||
Reference in New Issue
Block a user