diff --git a/src/components/monitor-list/monitor-list.jsx b/src/components/monitor-list/monitor-list.jsx index 50263220dd5bbbc164cee2c95bdfbbdf75a5c935..07b6a817be90020956891f340b73a344f5b21afd 100644 --- a/src/components/monitor-list/monitor-list.jsx +++ b/src/components/monitor-list/monitor-list.jsx @@ -24,6 +24,7 @@ const MonitorList = props => ( opcode={monitorData.opcode} params={monitorData.params} spriteName={monitorData.spriteName} + targetId={monitorData.targetId} value={monitorData.value} width={monitorData.width} x={monitorData.x} diff --git a/src/components/monitor/list-monitor.jsx b/src/components/monitor/list-monitor.jsx index c50cc5703cf806a294eb7aea39fc57c6f9a3fef7..e407260434a4546849189795b63ba92dfc648e8a 100644 --- a/src/components/monitor/list-monitor.jsx +++ b/src/components/monitor/list-monitor.jsx @@ -1,8 +1,73 @@ import React from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; import styles from './monitor.css'; -const ListMonitor = ({categoryColor, label, width, height, value}) => ( + +class ListMonitorRow extends React.Component { + constructor (props) { + super(props); + this.handleActivate = props.onActivate.bind(this, props.index); + } + render () { + return ( + <div + className={styles.listRow} + key={`label-${this.props.index}`} + > + <div className={styles.listIndex}>{this.props.index + 1 /* one indexed */}</div> + <div + className={styles.listValue} + style={{background: this.props.categoryColor}} + onClick={this.handleActivate} + > + {this.props.activeIndex === this.props.index ? ( + <div className={styles.inputWrapper}> + <input + autoFocus + autoComplete={false} + className={classNames(styles.listInput, 'no-drag')} + onBlur={this.props.onDeactivate} + onChange={this.props.onInput} + onFocus={this.props.onFocus} + onKeyDown={this.props.onKeyPress} // key down to get ahead of blur + spellCheck={false} + type="text" + value={this.props.activeValue} /* eslint-disable-line */ + /> + <div + className={styles.removeButton} + onMouseDown={this.props.onRemove} // mousedown to get ahead of blur + > + {'✖︎'} + </div> + </div> + + ) : ( + <div className={styles.valueInner}>{this.props.value}</div> + )} + </div> + </div> + ); + } +} + +ListMonitorRow.propTypes = { + index: PropTypes.number, + activeIndex: PropTypes.number, + activeValue: PropTypes.string, + value: PropTypes.string, + onRemove: PropTypes.func, + onKeyPress: PropTypes.func, + onFocus: PropTypes.func, + onInput: PropTypes.func, + onDeactivate: PropTypes.func, + categoryColor: PropTypes.string, + onActivate: PropTypes.func, + onKeyPress: PropTypes.func +}; + +const ListMonitor = ({label, width, height, value, onResizeMouseDown, onAdd, ...rowProps}) => ( <div className={styles.listMonitor} style={{ @@ -14,54 +79,58 @@ const ListMonitor = ({categoryColor, label, width, height, value}) => ( {label} </div> <div className={styles.listBody}> - {!value || value.length === 0 ? ( + {value.length === 0 ? ( <div className={styles.listEmpty}> - {'(empty)' /* @todo not translating, awaiting design */} + {'(empty)'} </div> ) : value.map((v, i) => ( - <div - className={styles.listRow} - key={`label-${i}`} - > - <div className={styles.listIndex}>{i + 1 /* one indexed */}</div> - <div - className={styles.listValue} - style={{background: categoryColor}} - > - <div className={styles.valueInner}>{v}</div> - </div> - </div> + <ListMonitorRow + {...rowProps} + index={i} + key={`${label}-row-${i}`} + value={v} + /> ))} </div> <div className={styles.listFooter}> - <div className={styles.footerButton}> - {/* @todo add button here */} + <div + className={styles.addButton} + onClick={onAdd} + > + {'+' /* TODO waiting on asset */} </div> <div className={styles.footerLength}> - <span className={styles.lengthNumber}> - {value.length} - </span> + {`length ${value.length}`} </div> - <div className={styles.resizeHandle}> - {/* @todo resize handle */} + <div + className={classNames(styles.resizeHandle, 'no-drag')} + onMouseDown={onResizeMouseDown} + > + {'=' /* TODO waiting on asset */} </div> </div> </div> ); ListMonitor.propTypes = { + activeIndex: PropTypes.number, categoryColor: PropTypes.string.isRequired, height: PropTypes.number, label: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.oneOfType([ + onActivate: PropTypes.func, + value: PropTypes.oneOfType([ PropTypes.string, - PropTypes.number - ])), + PropTypes.number, + PropTypes.arrayOf(PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ])) + ]), width: PropTypes.number }; ListMonitor.defaultProps = { - width: 80, + width: 110, height: 200 }; diff --git a/src/components/monitor/monitor.css b/src/components/monitor/monitor.css index 783cec7068671c87e8011fb21140929b64afacd1..078d24104eb8770c61e80a5b28362eb940982eac 100644 --- a/src/components/monitor/monitor.css +++ b/src/components/monitor/monitor.css @@ -109,6 +109,7 @@ border-radius: calc($space / 2); border: 1px solid $ui-black-transparent; flex-grow: 1; + height: 22px; } .list-footer { @@ -137,3 +138,25 @@ padding: 3px 5px; min-height: 22px; } + +.list-input { + padding: 3px 5px; + border: 0; + background: rgba(0, 0, 0, 0.1); + color: $ui-white; + outline: none; + font-size: 0.75rem; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +.remove-button { + position: absolute; + top: 4px; + right: 3px; + cursor: pointer; + color: $ui-white; +} + +.add-button { + cursor: pointer; +} diff --git a/src/components/monitor/monitor.jsx b/src/components/monitor/monitor.jsx index 95387a1ec78370be823f1bd05ca0d2b670147c39..a45fe52142a26090245023d4e558b720fd3d95cb 100644 --- a/src/components/monitor/monitor.jsx +++ b/src/components/monitor/monitor.jsx @@ -7,8 +7,8 @@ import {ContextMenu, MenuItem} from '../context-menu/context-menu.jsx'; import Box from '../box/box.jsx'; import DefaultMonitor from './default-monitor.jsx'; import LargeMonitor from './large-monitor.jsx'; -import SliderMonitor from './slider-monitor.jsx'; -import ListMonitor from './list-monitor.jsx'; +import SliderMonitor from '../../containers/slider-monitor.jsx'; +import ListMonitor from '../../containers/list-monitor.jsx'; import styles from './monitor.css'; @@ -29,7 +29,10 @@ const modes = { }; const MonitorComponent = props => ( - <ContextMenuTrigger id={`monitor-${props.label}`}> + <ContextMenuTrigger + holdToDisplay={props.mode === 'slider' ? -1 : 1000} + id={`monitor-${props.label}`} + > <Draggable bounds=".monitor-overlay" // Class for monitor container cancel=".no-drag" // Class used for slider input to prevent drag @@ -41,14 +44,9 @@ const MonitorComponent = props => ( componentRef={props.componentRef} onDoubleClick={props.mode === 'list' ? null : props.onNextMode} > - {(modes[props.mode] || modes.default)({ // Use default until other modes arrive + {React.createElement(modes[props.mode], { categoryColor: categories[props.category], - label: props.label, - value: props.value, - width: props.width, - height: props.height, - min: props.min, - max: props.max + ...props })} </Box> </Draggable> @@ -90,25 +88,13 @@ const monitorModes = Object.keys(modes); MonitorComponent.propTypes = { category: PropTypes.oneOf(Object.keys(categories)), componentRef: PropTypes.func.isRequired, - height: PropTypes.number, label: PropTypes.string.isRequired, - max: PropTypes.number, - min: PropTypes.number, mode: PropTypes.oneOf(monitorModes), onDragEnd: PropTypes.func.isRequired, onNextMode: PropTypes.func.isRequired, onSetModeToDefault: PropTypes.func.isRequired, onSetModeToLarge: PropTypes.func.isRequired, - onSetModeToSlider: PropTypes.func, - value: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - PropTypes.arrayOf(PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number - ])) - ]), - width: PropTypes.number + onSetModeToSlider: PropTypes.func }; MonitorComponent.defaultProps = { diff --git a/src/components/monitor/slider-monitor.jsx b/src/components/monitor/slider-monitor.jsx index a6c410898edc6325df5d3cfafd060a9935eee34c..f8187cfee6a9be6fe0811ddfb8751c00bf0eaa64 100644 --- a/src/components/monitor/slider-monitor.jsx +++ b/src/components/monitor/slider-monitor.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import styles from './monitor.css'; -const SliderMonitor = ({categoryColor, label, min, max, value}) => ( +const SliderMonitor = ({categoryColor, label, min, max, value, onSliderUpdate}) => ( <div className={styles.defaultMonitor}> <div className={styles.row}> <div className={styles.label}> @@ -22,6 +22,7 @@ const SliderMonitor = ({categoryColor, label, min, max, value}) => ( min={min} type="range" value={value} + onChange={onSliderUpdate} // @todo onChange callback /> </div> @@ -34,7 +35,7 @@ SliderMonitor.propTypes = { label: PropTypes.string.isRequired, max: PropTypes.number, min: PropTypes.number, - // @todo callback for change events + onSliderUpdate: PropTypes.func.isRequired, value: PropTypes.oneOfType([ PropTypes.string, PropTypes.number diff --git a/src/containers/list-monitor-row.jsx b/src/containers/list-monitor-row.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/containers/list-monitor.jsx b/src/containers/list-monitor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3b5384c7672c81efbe9e53fc8a6814d18c1e9b1e --- /dev/null +++ b/src/containers/list-monitor.jsx @@ -0,0 +1,182 @@ +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'; + +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, + // TODO These will need to be sent back to the VM for saving + width: props.width || 80, + height: props.height || 200 + }; + } + + handleActivate (index) { + 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 = (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 = (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. + 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); + // Selecting the next active is handled when event bubbles up to activate + } + + 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.min(this.initialWidth + dx, 480), + height: Math.max(Math.min(this.initialHeight + dy, 360), 60) + }); + }; + + const onMouseUp = ev => { + onMouseMove(ev); // Make sure width/height are up-to-date + // TODO send these new sizes to the VM for saving + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + + } + 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 = { + value: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string + ]), + vm: PropTypes.instanceOf(VM), + targetId: PropTypes.string, + id: PropTypes.string, + height: PropTypes.number, + width: PropTypes.number, + x: PropTypes.number, + y: PropTypes.number +}; + +const mapStateToProps = state => ({vm: state.vm}); + +export default connect(mapStateToProps)(ListMonitor); diff --git a/src/containers/monitor.jsx b/src/containers/monitor.jsx index cdb87f20eacea93fcafa446ee379ea4ba61f1ca8..c39b563026f78ced7181ba0e484221aec05c038f 100644 --- a/src/containers/monitor.jsx +++ b/src/containers/monitor.jsx @@ -107,12 +107,13 @@ class Monitor extends React.Component { max={this.props.max} min={this.props.min} mode={this.state.mode} - width={this.props.width} onDragEnd={this.handleDragEnd} onNextMode={this.handleNextMode} onSetModeToDefault={this.handleSetModeToDefault} onSetModeToLarge={this.handleSetModeToLarge} onSetModeToSlider={showSliderOption ? this.handleSetModeToSlider : null} + targetId={this.props.targetId} + width={this.props.width} /> ); } diff --git a/src/containers/slider-monitor.jsx b/src/containers/slider-monitor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8df6f92a9879c7aaa9ee364424ac0dd955b3e688 --- /dev/null +++ b/src/containers/slider-monitor.jsx @@ -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: Number(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.vm}); + +export default connect(mapStateToProps)(SliderMonitor); diff --git a/src/lib/variable-utils.js b/src/lib/variable-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..29eacd617938b9bf3fd8c521696cffc4c755a72a --- /dev/null +++ b/src/lib/variable-utils.js @@ -0,0 +1,24 @@ +// Utility functions for updating variables in the VM +// TODO these should be moved to top-level VM API +const getVariable = (vm, targetId, variableId) => { + const target = targetId ? + vm.runtime.getTargetById(targetId) : + vm.runtime.getTargetForStage(); + return target.variables[variableId]; +}; + +const getVariableValue = (vm, targetId, variableId) => { + const variable = getVariable(vm, targetId, variableId); + // If array, return a new copy for mutating, ensuring that updates stay immutable. + if (variable.value instanceof Array) return variable.value.slice(); + return variable.value; +}; + +const setVariableValue = (vm, targetId, variableId, value) => { + getVariable(vm, targetId, variableId).value = value; +}; + +export { + getVariableValue, + setVariableValue +};