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 SliderPrompt from './slider-prompt.jsx'; import {connect} from 'react-redux'; import {Map} from 'immutable'; import VM from 'scratch-vm'; const availableModes = opcode => ( monitorModes.filter(mode => { if (opcode === 'data_variable') { return mode !== 'list'; } else if (opcode === 'data_listcontents') { return mode === 'list'; } else if (opcode === 'canvas_canvasGetter') { return mode === 'image'; } return mode !== 'slider' && mode !== '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)) { // skip all the other things to check custom monitors and see if they need an update if (key === 'value' && typeof nextProps[key] === 'object') { return !nextProps[key]._monitorUpToDate; } // 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'; const isImage = this.props.mode === 'image'; return ( {this.state.sliderPrompt && } ); } } 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, 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 }; const mapStateToProps = state => ({ monitorLayout: state.scratchGui.monitorLayout, 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));