From 8a2c52da526c93d3c5d9780532743a2d30e358a2 Mon Sep 17 00:00:00 2001 From: Paul Kaplan <pkaplan@media.mit.edu> Date: Wed, 8 Nov 2017 12:13:28 -0500 Subject: [PATCH] Custom procedures reducer, modal and block workspace. --- .../custom-procedures/custom-procedures.css | 91 ++++++++++++ .../custom-procedures/custom-procedures.jsx | 133 ++++++++++++++++++ .../custom-procedures/icon--boolean-input.svg | Bin 0 -> 523 bytes .../custom-procedures/icon--label.svg | Bin 0 -> 559 bytes .../custom-procedures/icon--text-input.svg | Bin 0 -> 534 bytes src/containers/blocks.jsx | 31 +++- src/containers/custom-procedures.jsx | 125 ++++++++++++++++ src/reducers/custom-procedures.js | 65 +++++++++ src/reducers/gui.js | 2 + test/integration/test.js | 18 +++ 10 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 src/components/custom-procedures/custom-procedures.css create mode 100644 src/components/custom-procedures/custom-procedures.jsx create mode 100644 src/components/custom-procedures/icon--boolean-input.svg create mode 100644 src/components/custom-procedures/icon--label.svg create mode 100644 src/components/custom-procedures/icon--text-input.svg create mode 100644 src/containers/custom-procedures.jsx create mode 100644 src/reducers/custom-procedures.js diff --git a/src/components/custom-procedures/custom-procedures.css b/src/components/custom-procedures/custom-procedures.css new file mode 100644 index 000000000..f60fab88e --- /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 000000000..e8e0e18a7 --- /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 GIT binary patch literal 523 zcmYk3&2GaW5QJZ4MURaQj(_Sp*oRg<^iZklv6qM#Y||2B0f`euefN^si6VhU3?HN2 zh1z`!hpp;ui8|+o0^@NU!!ZuEY4MT}f<1v?FU9zx4<#Z%Abo=58C8^v##Yq2qY_nE zD;q>7J+NB3V?n}r^>^`>cV0Am@u`tY6%S35WjR?{XY{WKw$cfsq?aNhS8{O)Nlp{v zpTy~WmE1xSp0R6)lD(+eP#C@I`Mvq0Wk0)r6i-D{JhkAY^DTTuYj_PmjjjdX83Aux zx{VU>-VZxxDV`|Wg5)LIXsrb6*H@Jn2mxn~1wGq}8v>6oLoh=XHKKM<D`x%J&x4B* zoM#)R2#&LklDn=%X@;N^vh7@nk{p5YT;j!J3BnZ}nCtG7{GG4vBVLB-BJqJ;kgFid W{OX9BH21nVpTExmxb@rrarF<yKB=kz literal 0 HcmV?d00001 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 GIT binary patch literal 559 zcmY+C!EWOq5Qbl6L@%wjHaK>Y*2X?`wNm%8TI~x|)E+yi*hDaila2cB#g1F;9+-ij zkAWYM{@B5(H-j(Pj%c5AK8|BNrm=M$UkM?2AQ&7qj!*VfG64eCTgV<+B^zvgB|V)D zR&i~7lw9my)tb(^h|}VI)>i*$$)cr~RvVMwwC!f|APP^;zF`=q>!ly{n$Q^?Ql5xq zRd#9ZK#|vfF;7<eQ}J1(E#`?<_1c{CXEaA7U8`Ss!05@E<{rK@n8YhSqV9H-Z-pof zQrIr?)8U6M7uXl?l3$HUezi%Og3Uk43w)1%J6mIOa0ovdO|J&w=deGJm;6e}4s=tp zS8ENb;pH>y8Dh{VQqiD#{6gRxED$VEB^}lTwNXAi&2+gbgX#X`HG}h#N8Qk_Wa|xs z-R!1h*#iR?xGbcAWsFyn@bq7$Fh@=ezO}9|+1xdX@HW}T;b~VP-mz+06#Rr(N#2DP H{z3Ht<I=j0 literal 0 HcmV?d00001 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 GIT binary patch literal 534 zcmZXR(Qd*Z6oy~rgk5eHYO!;b_F~H}cDL*W3>Mn5VQC6ljq%-wEjp83kU#nP_yPxJ z_ca{0y0tlKoa-`-hhd0@Br<)C=Y$aK2?Tp3hgWmR5di{=XP_^rU_w^5VAdVAETU4| zS$r}bFO@rHBuZB2v76s@ap;m$rL@i-swz#FWMy4%e&lScob0vok;UXnK8+H3nizj1 zS%fWmlTeZJt3<0-mb}Y)vunkD`E67i+B3t`r5T={;FR-E_=?x?7Jc@nlw#LQco)iT zw1khg+d0edM6nu_$kE0aEqS}XrWzpxf;&EI`BvT#c!U5!fC|>j(m|=Y^<%#bE<tda zZL~me3^^#_nj9@s1dUX6<8nlo2zt*^A0DF!|2O$PPb`CJ%wVGA&y>t3Q|#>*l~3=& JwO{_bi(f<UtvCPx literal 0 HcmV?d00001 diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 47d12661c..378dccf6d 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 000000000..d594aba2b --- /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 000000000..2f4f63d98 --- /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 c9e5e4192..1dfa4ea76 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 4096c2d90..ad3659180 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([]); }); -- GitLab