diff --git a/src/components/custom-procedures/custom-procedures.css b/src/components/custom-procedures/custom-procedures.css new file mode 100644 index 0000000000000000000000000000000000000000..f60fab88e2b2b1baec32b563d3c774ef679f0d9c --- /dev/null +++ b/src/components/custom-procedures/custom-procedures.css @@ -0,0 +1,91 @@ +@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 { + padding: 0 1rem 1rem; + width: 100%; +} + +.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; +} diff --git a/src/components/custom-procedures/custom-procedures.jsx b/src/components/custom-procedures/custom-procedures.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e8e0e18a7b9c62b9f0c2445c7176e3f6ad742bf6 --- /dev/null +++ b/src/components/custom-procedures/custom-procedures.jsx @@ -0,0 +1,133 @@ +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 numeric" + 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 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; diff --git a/src/components/custom-procedures/icon--boolean-input.svg b/src/components/custom-procedures/icon--boolean-input.svg new file mode 100644 index 0000000000000000000000000000000000000000..49a4d919fb321786383d313d6b529c64a9033ca9 Binary files /dev/null and b/src/components/custom-procedures/icon--boolean-input.svg differ diff --git a/src/components/custom-procedures/icon--label.svg b/src/components/custom-procedures/icon--label.svg new file mode 100644 index 0000000000000000000000000000000000000000..2c2d52a66da350abe5947ffdc139a1eacb2dbbf8 Binary files /dev/null and b/src/components/custom-procedures/icon--label.svg differ diff --git a/src/components/custom-procedures/icon--text-input.svg b/src/components/custom-procedures/icon--text-input.svg new file mode 100644 index 0000000000000000000000000000000000000000..a6dfbe5ebb8bed01a8cc9482e69f50c14fef62be Binary files /dev/null and b/src/components/custom-procedures/icon--text-input.svg differ diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 47d12661cfebe65ee23e33efa6efd8d7332ad388..378dccf6daa8f9d47798a4c72ee9a531fd912127 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -9,11 +9,13 @@ import VM from 'scratch-vm'; import Prompt from './prompt.jsx'; import BlocksComponent from '../components/blocks/blocks.jsx'; import ExtensionLibrary from './extension-library.jsx'; +import CustomProcedures from './custom-procedures.jsx'; import {connect} from 'react-redux'; import {updateToolbox} from '../reducers/toolbox'; import {activateColorPicker} from '../reducers/color-picker'; import {closeExtensionLibrary} from '../reducers/modals'; +import {activateCustomProcedures, deactivateCustomProcedures} from '../reducers/custom-procedures'; const addFunctionListener = (object, property, callback) => { const oldFn = object[property]; @@ -35,6 +37,7 @@ class Blocks extends React.Component { 'handlePromptStart', 'handlePromptCallback', 'handlePromptClose', + 'handleCustomProceduresClose', 'onScriptGlowOn', 'onScriptGlowOff', 'onBlockGlowOn', @@ -55,6 +58,7 @@ class Blocks extends React.Component { } componentDidMount () { this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker; + this.ScratchBlocks.Procedures.externalProcedureDefCallback = this.props.onActivateCustomProcedures; const workspaceConfig = defaultsDeep({}, Blocks.defaultOptions, @@ -74,7 +78,8 @@ class Blocks extends React.Component { this.state.prompt !== nextState.prompt || this.props.isVisible !== nextProps.isVisible || this.props.toolboxXML !== nextProps.toolboxXML || - this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible + this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible || + this.props.customProceduresVisible !== nextProps.customProceduresVisible ); } componentDidUpdate (prevProps) { @@ -221,9 +226,14 @@ class Blocks extends React.Component { handlePromptClose () { this.setState({prompt: null}); } + handleCustomProceduresClose (data) { + this.props.onRequestCloseCustomProcedures(data); + this.workspace.refreshToolboxSelection_(); + } render () { /* eslint-disable no-unused-vars */ const { + customProceduresVisible, extensionLibraryVisible, options, vm, @@ -231,6 +241,7 @@ class Blocks extends React.Component { onActivateColorPicker, updateToolboxState, onRequestCloseExtensionLibrary, + onRequestCloseCustomProcedures, toolboxXML, ...props } = this.props; @@ -257,15 +268,26 @@ class Blocks extends React.Component { onRequestClose={onRequestCloseExtensionLibrary} /> ) : null} + {customProceduresVisible ? ( + <CustomProcedures + options={{ + media: options.media + }} + onRequestClose={this.handleCustomProceduresClose} + /> + ) : null} </div> ); } } Blocks.propTypes = { + customProceduresVisible: PropTypes.bool, extensionLibraryVisible: PropTypes.bool, isVisible: PropTypes.bool, onActivateColorPicker: PropTypes.func, + onActivateCustomProcedures: PropTypes.func, + onRequestCloseCustomProcedures: PropTypes.func, onRequestCloseExtensionLibrary: PropTypes.func, options: PropTypes.shape({ media: PropTypes.string, @@ -326,14 +348,19 @@ Blocks.defaultProps = { const mapStateToProps = state => ({ extensionLibraryVisible: state.modals.extensionLibrary, - toolboxXML: state.toolbox.toolboxXML + toolboxXML: state.toolbox.toolboxXML, + customProceduresVisible: state.customProcedures.active }); const mapDispatchToProps = dispatch => ({ onActivateColorPicker: callback => dispatch(activateColorPicker(callback)), + onActivateCustomProcedures: (data, callback) => dispatch(activateCustomProcedures(data, callback)), onRequestCloseExtensionLibrary: () => { dispatch(closeExtensionLibrary()); }, + onRequestCloseCustomProcedures: data => { + dispatch(deactivateCustomProcedures(data)); + }, updateToolboxState: toolboxXML => { dispatch(updateToolbox(toolboxXML)); } diff --git a/src/containers/custom-procedures.jsx b/src/containers/custom-procedures.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d594aba2b227163e2ec7802cce3dc284c2362bc4 --- /dev/null +++ b/src/containers/custom-procedures.jsx @@ -0,0 +1,125 @@ +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'); + this.workspace.addChangeListener(() => this.mutationRoot.onChangeFn()); + this.mutationRoot.domToMutation(this.props.mutator); + this.mutationRoot.initSvg(); + this.mutationRoot.render(); + + // Center the procedure declaration block. + const metrics = this.workspace.getMetrics(); + const dx = (metrics.viewWidth / 2) - (this.mutationRoot.width / 2); + const dy = (metrics.viewHeight / 2) - (this.mutationRoot.height / 2); + this.mutationRoot.moveBy(dx, dy); + } + 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 +}; + +CustomProcedures.defaultProps = { + options: CustomProcedures.defaultOptions +}; + +const mapStateToProps = state => ({ + mutator: state.customProcedures.mutator +}); + +const mapDispatchToProps = () => ({}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(CustomProcedures); diff --git a/src/reducers/custom-procedures.js b/src/reducers/custom-procedures.js new file mode 100644 index 0000000000000000000000000000000000000000..2f4f63d983b0a610ce4395b0610db48e24ea21ab --- /dev/null +++ b/src/reducers/custom-procedures.js @@ -0,0 +1,65 @@ +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 +}; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index c9e5e41928931dca615abf8de677f693adc36dfa..1dfa4ea76c3ddb04b5d3fb00ddbe2b81b14e6c2a 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -1,5 +1,6 @@ import {combineReducers} from 'redux'; import colorPickerReducer from './color-picker'; +import customProceduresReducer from './custom-procedures'; import intlReducer from './intl'; import modalReducer from './modals'; import monitorReducer from './monitors'; @@ -10,6 +11,7 @@ import {ScratchPaintReducer} from 'scratch-paint'; export default combineReducers({ colorPicker: colorPickerReducer, + customProcedures: customProceduresReducer, intl: intlReducer, modals: modalReducer, monitors: monitorReducer, diff --git a/test/integration/test.js b/test/integration/test.js index 4096c2d909b081756d8345df7d892a75013c7d5c..ad36591805c04f7ef8c51534a7e8467b0835f63d 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -157,6 +157,24 @@ describe('costumes, sounds and variables', () => { await clickText('stamp', blocksTabScope); // Would fail if didn't scroll back + const logs = await getLogs(errorWhitelist); + 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([]); });