Skip to content
Snippets Groups Projects
Unverified Commit b66f4cfd authored by Paul Kaplan's avatar Paul Kaplan Committed by GitHub
Browse files

Merge pull request #992 from paulkaplan/custom-procedures

First iteration of procedures modal with block workspace
parents 9678c21a b82ecb70
No related branches found
No related tags found
No related merge requests found
@import "../../css/colors.css";
@import "../../css/units.css";
.modal-content {
width: 700px;
}
.body {
background: $ui-pane-gray;
padding: 1.5rem 2.25rem;
}
/* Blocks workspace for custom procedure declaration editor */
.workspace {
min-height: 200px;
position: relative;
}
.workspace :global(.injectionDiv){
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.workspace :global(.blocklySvg) {
background-color: #E7EDF2;
}
/* Row of "card" buttons for modifying custom procedures */
.options-row {
display: flex;
justify-content: space-between;
}
.option-card {
background: white;
border: 2px solid $form-border;
border-radius: $space;
padding: calc($space * 2);
text-align: center;
flex-grow: 1;
cursor: pointer;
transition: all 0.2s;
flex-basis: 100px;
}
.option-card:hover {
border: 2px solid $motion-primary;
box-shadow: 0px 0px 0px 3px $motion-transparent;
}
.option-card + .option-card {
margin-left: 1rem;
}
.option-icon {
max-height: 48px;
margin-bottom: 0.5rem;
}
.option-title {
font-weight: bold;
}
/* Confirmation buttons at the bottom of the modal */
.button-row {
margin-top: 1rem;
font-weight: bolder;
text-align: right;
}
.button-row button {
border: 1px solid $ui-pane-border;
border-radius: 0.25rem;
padding: 0.75rem 1rem;
background: white;
font-weight: bold;
font-size: 0.85rem;
}
.button-row button.ok-button {
background: $motion-primary;
border: $motion-primary;
color: white;
}
.button-row button + button {
margin-left: 0.5rem;
}
import PropTypes from 'prop-types';
import React from 'react';
import Modal from '../modal/modal.jsx';
import Box from '../box/box.jsx';
import {FormattedMessage} from 'react-intl';
import booleanInputIcon from './icon--boolean-input.svg';
import textInputIcon from './icon--text-input.svg';
import labelIcon from './icon--label.svg';
import styles from './custom-procedures.css';
const CustomProcedures = props => (
<Modal
className={styles.modalContent}
contentLabel="Create new block"
onRequestClose={props.onCancel}
>
<Box
className={styles.workspace}
componentRef={props.componentRef}
/>
<Box className={styles.body}>
<div className={styles.optionsRow}>
<div
className={styles.optionCard}
role="button"
tabIndex="0"
onClick={props.onAddTextNumber}
>
<img
className={styles.optionIcon}
src={textInputIcon}
/>
<div className={styles.optionTitle}>
<FormattedMessage
defaultMessage="Add an input"
description="Label for button to add a number/text input"
id="gui.customProcedures.addAnInputNumberText"
/>
</div>
<div className={styles.optionDescription}>
<FormattedMessage
defaultMessage="number or text"
description="Description of the number/text input type"
id="gui.customProcedures.numberTextType"
/>
</div>
</div>
<div
className={styles.optionCard}
role="button"
tabIndex="0"
onClick={props.onAddBoolean}
>
<img
className={styles.optionIcon}
src={booleanInputIcon}
/>
<div className={styles.optionTitle}>
<FormattedMessage
defaultMessage="Add an input"
description="Label for button to add a boolean input"
id="gui.customProcedures.addAnInputBoolean"
/>
</div>
<div className={styles.optionDescription}>
<FormattedMessage
defaultMessage="boolean"
description="Description of the boolean input type"
id="gui.customProcedures.booleanType"
/>
</div>
</div>
<div
className={styles.optionCard}
role="button"
tabIndex="0"
onClick={props.onAddLabel}
>
<img
className={styles.optionIcon}
src={labelIcon}
/>
<div className={styles.optionTitle}>
<FormattedMessage
defaultMessage="Add a label"
description="Label for button to add a label"
id="gui.customProcedures.addALabel"
/>
</div>
</div>
</div>
<div className={styles.checkboxRow}>
{/* @todo (#993) Implement "run without screen refresh" */}
{/* <label><input type="checkbox" />Run without screen refresh</label> */}
</div>
<Box className={styles.buttonRow}>
<button
className={styles.cancelButton}
onClick={props.onCancel}
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for button to cancel custom procedure edits"
id="gui.customProcedures.cancel"
/>
</button>
<button
className={styles.okButton}
onClick={props.onOk}
>
<FormattedMessage
defaultMessage="OK"
description="Label for button to save new custom procedure"
id="gui.customProcedures.ok"
/>
</button>
</Box>
</Box>
</Modal>
);
CustomProcedures.propTypes = {
componentRef: PropTypes.func.isRequired,
onAddBoolean: PropTypes.func.isRequired,
onAddLabel: PropTypes.func.isRequired,
onAddTextNumber: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onOk: PropTypes.func.isRequired
};
export default CustomProcedures;
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
...@@ -9,11 +9,13 @@ import VM from 'scratch-vm'; ...@@ -9,11 +9,13 @@ import VM from 'scratch-vm';
import Prompt from './prompt.jsx'; import Prompt from './prompt.jsx';
import BlocksComponent from '../components/blocks/blocks.jsx'; import BlocksComponent from '../components/blocks/blocks.jsx';
import ExtensionLibrary from './extension-library.jsx'; import ExtensionLibrary from './extension-library.jsx';
import CustomProcedures from './custom-procedures.jsx';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import {updateToolbox} from '../reducers/toolbox'; import {updateToolbox} from '../reducers/toolbox';
import {activateColorPicker} from '../reducers/color-picker'; import {activateColorPicker} from '../reducers/color-picker';
import {closeExtensionLibrary} from '../reducers/modals'; import {closeExtensionLibrary} from '../reducers/modals';
import {activateCustomProcedures, deactivateCustomProcedures} from '../reducers/custom-procedures';
const addFunctionListener = (object, property, callback) => { const addFunctionListener = (object, property, callback) => {
const oldFn = object[property]; const oldFn = object[property];
...@@ -35,6 +37,7 @@ class Blocks extends React.Component { ...@@ -35,6 +37,7 @@ class Blocks extends React.Component {
'handlePromptStart', 'handlePromptStart',
'handlePromptCallback', 'handlePromptCallback',
'handlePromptClose', 'handlePromptClose',
'handleCustomProceduresClose',
'onScriptGlowOn', 'onScriptGlowOn',
'onScriptGlowOff', 'onScriptGlowOff',
'onBlockGlowOn', 'onBlockGlowOn',
...@@ -55,6 +58,7 @@ class Blocks extends React.Component { ...@@ -55,6 +58,7 @@ class Blocks extends React.Component {
} }
componentDidMount () { componentDidMount () {
this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker; this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker;
this.ScratchBlocks.Procedures.externalProcedureDefCallback = this.props.onActivateCustomProcedures;
const workspaceConfig = defaultsDeep({}, const workspaceConfig = defaultsDeep({},
Blocks.defaultOptions, Blocks.defaultOptions,
...@@ -74,7 +78,8 @@ class Blocks extends React.Component { ...@@ -74,7 +78,8 @@ class Blocks extends React.Component {
this.state.prompt !== nextState.prompt || this.state.prompt !== nextState.prompt ||
this.props.isVisible !== nextProps.isVisible || this.props.isVisible !== nextProps.isVisible ||
this.props.toolboxXML !== nextProps.toolboxXML || this.props.toolboxXML !== nextProps.toolboxXML ||
this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible ||
this.props.customProceduresVisible !== nextProps.customProceduresVisible
); );
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
...@@ -223,16 +228,23 @@ class Blocks extends React.Component { ...@@ -223,16 +228,23 @@ class Blocks extends React.Component {
handlePromptClose () { handlePromptClose () {
this.setState({prompt: null}); this.setState({prompt: null});
} }
handleCustomProceduresClose (data) {
this.props.onRequestCloseCustomProcedures(data);
this.workspace.refreshToolboxSelection_();
}
render () { render () {
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
const { const {
customProceduresVisible,
extensionLibraryVisible, extensionLibraryVisible,
options, options,
vm, vm,
isVisible, isVisible,
onActivateColorPicker, onActivateColorPicker,
updateToolboxState, updateToolboxState,
onActivateCustomProcedures,
onRequestCloseExtensionLibrary, onRequestCloseExtensionLibrary,
onRequestCloseCustomProcedures,
toolboxXML, toolboxXML,
...props ...props
} = this.props; } = this.props;
...@@ -259,15 +271,26 @@ class Blocks extends React.Component { ...@@ -259,15 +271,26 @@ class Blocks extends React.Component {
onRequestClose={onRequestCloseExtensionLibrary} onRequestClose={onRequestCloseExtensionLibrary}
/> />
) : null} ) : null}
{customProceduresVisible ? (
<CustomProcedures
options={{
media: options.media
}}
onRequestClose={this.handleCustomProceduresClose}
/>
) : null}
</div> </div>
); );
} }
} }
Blocks.propTypes = { Blocks.propTypes = {
customProceduresVisible: PropTypes.bool,
extensionLibraryVisible: PropTypes.bool, extensionLibraryVisible: PropTypes.bool,
isVisible: PropTypes.bool, isVisible: PropTypes.bool,
onActivateColorPicker: PropTypes.func, onActivateColorPicker: PropTypes.func,
onActivateCustomProcedures: PropTypes.func,
onRequestCloseCustomProcedures: PropTypes.func,
onRequestCloseExtensionLibrary: PropTypes.func, onRequestCloseExtensionLibrary: PropTypes.func,
options: PropTypes.shape({ options: PropTypes.shape({
media: PropTypes.string, media: PropTypes.string,
...@@ -328,14 +351,19 @@ Blocks.defaultProps = { ...@@ -328,14 +351,19 @@ Blocks.defaultProps = {
const mapStateToProps = state => ({ const mapStateToProps = state => ({
extensionLibraryVisible: state.modals.extensionLibrary, extensionLibraryVisible: state.modals.extensionLibrary,
toolboxXML: state.toolbox.toolboxXML toolboxXML: state.toolbox.toolboxXML,
customProceduresVisible: state.customProcedures.active
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
onActivateColorPicker: callback => dispatch(activateColorPicker(callback)), onActivateColorPicker: callback => dispatch(activateColorPicker(callback)),
onActivateCustomProcedures: (data, callback) => dispatch(activateCustomProcedures(data, callback)),
onRequestCloseExtensionLibrary: () => { onRequestCloseExtensionLibrary: () => {
dispatch(closeExtensionLibrary()); dispatch(closeExtensionLibrary());
}, },
onRequestCloseCustomProcedures: data => {
dispatch(deactivateCustomProcedures(data));
},
updateToolboxState: toolboxXML => { updateToolboxState: toolboxXML => {
dispatch(updateToolbox(toolboxXML)); dispatch(updateToolbox(toolboxXML));
} }
......
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 ScratchBlocks from 'scratch-blocks';
import {connect} from 'react-redux';
class CustomProcedures extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleAddLabel',
'handleAddBoolean',
'handleAddTextNumber',
'handleCancel',
'handleOk',
'setBlocks'
]);
}
componentWillUnmount () {
if (this.workspace) {
this.workspace.dispose();
}
}
setBlocks (blocksRef) {
if (!blocksRef) return;
this.blocks = blocksRef;
const workspaceConfig = defaultsDeep({},
CustomProcedures.defaultOptions,
this.props.options
);
// @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 dx = (metrics.viewWidth / 2) - (this.mutationRoot.width / 2) - x;
const dy = (metrics.viewHeight / 2) - (this.mutationRoot.height / 2) - y;
this.mutationRoot.moveBy(dx, dy);
});
this.mutationRoot.domToMutation(this.props.mutator);
this.mutationRoot.initSvg();
this.mutationRoot.render();
}
handleCancel () {
this.props.onRequestClose();
}
handleOk () {
const newMutation = this.mutationRoot ? this.mutationRoot.mutationToDom() : 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();
}
}
render () {
return (
<CustomProceduresComponent
componentRef={this.setBlocks}
onAddBoolean={this.handleAddBoolean}
onAddLabel={this.handleAddLabel}
onAddTextNumber={this.handleAddTextNumber}
onCancel={this.handleCancel}
onOk={this.handleOk}
/>
);
}
}
CustomProcedures.propTypes = {
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
})
};
CustomProcedures.defaultOptions = {
zoom: {
controls: false,
wheel: false,
startScale: 0.9
},
comments: false,
scrollbars: true
};
CustomProcedures.defaultProps = {
options: CustomProcedures.defaultOptions
};
const mapStateToProps = state => ({
mutator: state.customProcedures.mutator
});
export default connect(
mapStateToProps
)(CustomProcedures);
...@@ -648,6 +648,13 @@ const data = function () { ...@@ -648,6 +648,13 @@ const data = function () {
`; `;
}; };
const more = function () {
return `
<category name="More" colour="#FF6680" secondaryColour="#FF4D6A" custom="PROCEDURE">
</category>
`;
};
const xmlOpen = '<xml style="display: none">'; const xmlOpen = '<xml style="display: none">';
const xmlClose = '</xml>'; const xmlClose = '</xml>';
...@@ -669,7 +676,8 @@ const makeToolboxXML = function (isStage, targetId, categoriesXML) { ...@@ -669,7 +676,8 @@ const makeToolboxXML = function (isStage, targetId, categoriesXML) {
control(isStage, targetId), gap, control(isStage, targetId), gap,
sensing(isStage, targetId), gap, sensing(isStage, targetId), gap,
operators(isStage, targetId), gap, operators(isStage, targetId), gap,
data(isStage, targetId) data(isStage, targetId), gap,
more(isStage, targetId)
]; ];
if (categoriesXML) { if (categoriesXML) {
......
const ACTIVATE_CUSTOM_PROCEDURES = 'scratch-gui/custom-procedures/ACTIVATE_CUSTOM_PROCEDURES';
const DEACTIVATE_CUSTOM_PROCEDURES = 'scratch-gui/custom-procedures/DEACTIVATE_CUSTOM_PROCEDURES';
const SET_CALLBACK = 'scratch-gui/custom-procedures/SET_CALLBACK';
const initialState = {
active: false,
mutator: null,
callback: null
};
const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState;
switch (action.type) {
case ACTIVATE_CUSTOM_PROCEDURES:
return Object.assign({}, state, {
active: true,
mutator: action.mutator,
callback: action.callback
});
case DEACTIVATE_CUSTOM_PROCEDURES:
// Can be called without a mutator to deactivate without new procedure
// i.e. when clicking on the modal background
if (action.mutator) {
state.callback(action.mutator);
}
return Object.assign({}, state, {
active: false,
mutator: null,
callback: null
});
case SET_CALLBACK:
return Object.assign({}, state, {callback: action.callback});
default:
return state;
}
};
/**
* Action creator to open the custom procedures modal.
* @param {!Element} mutator The XML node of the mutator for the procedure.
* @param {!function(!Element)} callback The function to call when done editing procedure.
* Expect the callback to be a function that takes a new XML mutator node.
* @returns {object} An action object with type ACTIVATE_CUSTOM_PROCEDURES.
*/
const activateCustomProcedures = (mutator, callback) => ({
type: ACTIVATE_CUSTOM_PROCEDURES,
mutator: mutator,
callback: callback
});
/**
* Action creator to close the custom procedures modal.
* @param {?Element} mutator The new mutator, or null if the callback should not be called.
* @returns {object} An action object with type ACTIVATE_CUSTOM_PROCEDURES.
*/
const deactivateCustomProcedures = mutator => ({
type: DEACTIVATE_CUSTOM_PROCEDURES,
mutator: mutator
});
export {
reducer as default,
activateCustomProcedures,
deactivateCustomProcedures
};
import {combineReducers} from 'redux'; import {combineReducers} from 'redux';
import colorPickerReducer from './color-picker'; import colorPickerReducer from './color-picker';
import customProceduresReducer from './custom-procedures';
import intlReducer from './intl'; import intlReducer from './intl';
import modalReducer from './modals'; import modalReducer from './modals';
import monitorReducer from './monitors'; import monitorReducer from './monitors';
...@@ -10,6 +11,7 @@ import {ScratchPaintReducer} from 'scratch-paint'; ...@@ -10,6 +11,7 @@ import {ScratchPaintReducer} from 'scratch-paint';
export default combineReducers({ export default combineReducers({
colorPicker: colorPickerReducer, colorPicker: colorPickerReducer,
customProcedures: customProceduresReducer,
intl: intlReducer, intl: intlReducer,
modals: modalReducer, modals: modalReducer,
monitors: monitorReducer, monitors: monitorReducer,
......
...@@ -177,4 +177,22 @@ describe('costumes, sounds and variables', () => { ...@@ -177,4 +177,22 @@ describe('costumes, sounds and variables', () => {
const logs = await getLogs(errorWhitelist); const logs = await getLogs(errorWhitelist);
await expect(logs).toEqual([]); await expect(logs).toEqual([]);
}); });
test('Custom procedures', async () => {
await loadUri(uri);
await clickText('More');
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
await clickText('Make a Block...');
// Click on the "add an input" buttons
await clickText('number or text', modalScope);
await clickText('boolean', modalScope);
await clickText('Add a label', modalScope);
await clickText('OK', modalScope);
// Make sure a "define" block has been added to the workspace
await findByText('define', blocksTabScope);
const logs = await getLogs(errorWhitelist);
await expect(logs).toEqual([]);
});
}); });
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment