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:
2026-06-16 15:37:45 +08:00
commit 6e0a1fbcbb
11350 changed files with 965674 additions and 0 deletions

View 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));

View 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);

View 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);

View 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;

View 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;

View 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;

View 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);

View 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));

View 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;

View 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;

View 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))
));

View 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);

View 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);

View 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);

View 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);

View 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))
);

View 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);

View 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);

View 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;

View 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;

View 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);

View 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;

View 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);

View 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);

View 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;

View 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);

View 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);

View 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);

View 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);

View 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;

View 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;

View 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;

View 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);

View 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))
);

View 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));

View 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));

View 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;

View 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;

View 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);

View 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;

View 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;

View 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);

View 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);

View 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);

View 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;

View 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);

View 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;

View 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);

View 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));

View 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))
);

View 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;

View 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);

View 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;

View 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;

View 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;

View 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);

View 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;

View 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;

View 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));

View 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;

View 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;

View 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 blackneeded 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);

View 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;

View 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);

View 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));

View 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));

View 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);

View 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));

View 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));

View 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);

View 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);

View 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);

View 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));

View 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);

View 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));

View 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
};

View 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));

View 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;

View 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);

View 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);

View 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;

View 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;