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 {updateTargets} from '../reducers/targets'; import {updateBlockDrag} from '../reducers/block-drag'; import {updateMonitors} from '../reducers/monitors'; import {setProjectChanged, setProjectUnchanged} from '../reducers/project-changed'; import {setRunningState, setTurboState, setStartedState} from '../reducers/vm-status'; import {showExtensionAlert} from '../reducers/alerts'; import {updateMicIndicator} from '../reducers/mic-indicator'; import { setFramerateState, setCompilerOptionsState, addCompileError, clearCompileErrors, setRuntimeOptionsState, setInterpolationState, setHasCloudVariables, setPlatformMismatchDetails } from '../reducers/tw'; import {setCustomStageSize} from '../reducers/custom-stage-size'; import {openUnknownPlatformModal} from '../reducers/modals'; import implementGuiAPI from './tw-extension-gui-api'; import {BLOCKS_TAB_INDEX} from '../reducers/editor-tab'; let compileErrorCounter = 0; /* * Higher Order Component to manage events emitted by the VM * @param {React.Component} WrappedComponent component to manage VM events for * @returns {React.Component} connected component with vm events bound to redux */ const vmListenerHOC = function (WrappedComponent) { class VMListener extends React.Component { constructor (props) { super(props); bindAll(this, [ 'handleKeyDown', 'handleKeyUp', 'handleProjectChanged', 'handleTargetsUpdate', 'handleCloudDataUpdate', 'handleCompileError' ]); // We have to start listening to the vm here rather than in // componentDidMount because the HOC mounts the wrapped component, // so the HOC componentDidMount triggers after the wrapped component // mounts. // If the wrapped component uses the vm in componentDidMount, then // we need to start listening before mounting the wrapped component. this.props.vm.on('targetsUpdate', this.handleTargetsUpdate); this.props.vm.on('MONITORS_UPDATE', this.props.onMonitorsUpdate); this.props.vm.on('BLOCK_DRAG_UPDATE', this.props.onBlockDragUpdate); this.props.vm.on('TURBO_MODE_ON', this.props.onTurboModeOn); this.props.vm.on('TURBO_MODE_OFF', this.props.onTurboModeOff); this.props.vm.on('PROJECT_RUN_START', this.props.onProjectRunStart); this.props.vm.on('PROJECT_RUN_STOP', this.props.onProjectRunStop); this.props.vm.on('PROJECT_CHANGED', this.handleProjectChanged); this.props.vm.on('RUNTIME_STARTED', this.props.onRuntimeStarted); this.props.vm.on('RUNTIME_STOPPED', this.props.onRuntimeStopped); this.props.vm.on('PROJECT_START', this.props.onGreenFlag); this.props.vm.on('PERIPHERAL_CONNECTION_LOST_ERROR', this.props.onShowExtensionAlert); this.props.vm.on('MIC_LISTENING', this.props.onMicListeningUpdate); this.props.vm.on('MIC_LISTENING', this.props.onMicListeningUpdate); // tw: add handlers for our events this.props.vm.on('HAS_CLOUD_DATA_UPDATE', this.handleCloudDataUpdate); this.props.vm.on('COMPILER_OPTIONS_CHANGED', this.props.onCompilerOptionsChanged); this.props.vm.on('RUNTIME_OPTIONS_CHANGED', this.props.onRuntimeOptionsChanged); this.props.vm.on('FRAMERATE_CHANGED', this.props.onFramerateChanged); this.props.vm.on('INTERPOLATION_CHANGED', this.props.onInterpolationChanged); this.props.vm.on('COMPILE_ERROR', this.handleCompileError); this.props.vm.on('RUNTIME_STARTED', this.props.onClearCompileErrors); this.props.vm.on('STAGE_SIZE_CHANGED', this.props.onStageSizeChanged); this.props.vm.on('CREATE_UNSANDBOXED_EXTENSION_API', implementGuiAPI); this.props.vm.runtime.on('PLATFORM_MISMATCH', this.props.onPlatformMismatch); } componentDidMount () { if (this.props.attachKeyboardEvents) { document.addEventListener('keydown', this.handleKeyDown); document.addEventListener('keyup', this.handleKeyUp); } this.props.vm.postIOData('userData', {username: this.props.username}); } componentDidUpdate (prevProps) { if (prevProps.username !== this.props.username) { this.props.vm.postIOData('userData', {username: this.props.username}); } // Re-request a targets update when the shouldUpdateTargets state changes to true // i.e. when the editor transitions out of fullscreen/player only modes if (this.props.shouldUpdateTargets && !prevProps.shouldUpdateTargets) { this.props.vm.emitTargetsUpdate(false /* Emit the event, but do not trigger project change */); } } componentWillUnmount () { if (this.props.attachKeyboardEvents) { document.removeEventListener('keydown', this.handleKeyDown); document.removeEventListener('keyup', this.handleKeyUp); } this.props.vm.off('targetsUpdate', this.handleTargetsUpdate); this.props.vm.off('MONITORS_UPDATE', this.props.onMonitorsUpdate); this.props.vm.off('BLOCK_DRAG_UPDATE', this.props.onBlockDragUpdate); this.props.vm.off('TURBO_MODE_ON', this.props.onTurboModeOn); this.props.vm.off('TURBO_MODE_OFF', this.props.onTurboModeOff); this.props.vm.off('PROJECT_RUN_START', this.props.onProjectRunStart); this.props.vm.off('PROJECT_RUN_STOP', this.props.onProjectRunStop); this.props.vm.off('PROJECT_CHANGED', this.handleProjectChanged); this.props.vm.off('RUNTIME_STARTED', this.props.onRuntimeStarted); this.props.vm.off('RUNTIME_STOPPED', this.props.onRuntimeStopped); this.props.vm.off('PROJECT_START', this.props.onGreenFlag); this.props.vm.off('PERIPHERAL_CONNECTION_LOST_ERROR', this.props.onShowExtensionAlert); this.props.vm.off('MIC_LISTENING', this.props.onMicListeningUpdate); this.props.vm.off('MIC_LISTENING', this.props.onMicListeningUpdate); this.props.vm.off('HAS_CLOUD_DATA_UPDATE', this.handleCloudDataUpdate); this.props.vm.off('COMPILER_OPTIONS_CHANGED', this.props.onCompilerOptionsChanged); this.props.vm.off('RUNTIME_OPTIONS_CHANGED', this.props.onRuntimeOptionsChanged); this.props.vm.off('FRAMERATE_CHANGED', this.props.onFramerateChanged); this.props.vm.off('INTERPOLATION_CHANGED', this.props.onInterpolationChanged); this.props.vm.off('COMPILE_ERROR', this.handleCompileError); this.props.vm.off('RUNTIME_STARTED', this.props.onClearCompileErrors); this.props.vm.off('STAGE_SIZE_CHANGED', this.props.onStageSizeChanged); this.props.vm.off('CREATE_UNSANDBOXED_EXTENSION_API', implementGuiAPI); this.props.vm.runtime.off('PLATFORM_MISMATCH', this.props.onPlatformMismatch); } handleCloudDataUpdate (hasCloudVariables) { if (this.props.hasCloudVariables !== hasCloudVariables) { this.props.onHasCloudVariablesChanged(hasCloudVariables); } } // tw: handling for compile errors handleCompileError (target, error) { const errorMessage = `${error}`; // Ignore intentonal errors if (errorMessage.includes('Script explicitly disables compilation')) { return; } this.props.onCompileError({ sprite: target.getName(), error: errorMessage, id: compileErrorCounter++ }); } handleProjectChanged () { if (this.props.shouldUpdateProjectChanged && !this.props.projectChanged) { this.props.onProjectChanged(); } } handleTargetsUpdate (data) { if (this.props.shouldUpdateTargets) { this.props.onTargetsUpdate(data); } } handleKeyDown (e) { // Don't capture keys intended for Blockly inputs. if (e.target !== document && e.target !== document.body) return; const key = (!e.key || e.key === 'Dead') ? e.keyCode : e.key; this.props.vm.postIOData('keyboard', { key: key, keyCode: e.keyCode, isDown: true }); // Prevent space/arrow key from scrolling the page. if (e.keyCode === 32 || // 32=space (e.keyCode >= 37 && e.keyCode <= 40)) { // 37, 38, 39, 40 are arrows e.preventDefault(); } // tw: prevent backspace from going back if (e.keyCode === 8) { e.preventDefault(); } // TW: prevent delete and backspace from deleting blocks in the editor while in fullscreen // or in the editor too if the project has been using these keys for something const blockEditorDeleteOperation = scratchKey => ( this.props.isEditorObscured || ( this.props.isEditorUsable && this.props.vm.runtime.ioDevices.keyboard.hasUsedKey(scratchKey) ) ); if ( (e.keyCode === 8 && blockEditorDeleteOperation('backspace')) || (e.keyCode === 46 && blockEditorDeleteOperation('delete')) ) { e.stopPropagation(); e.stopImmediatePropagation(); } // tw: prevent ' and / from opening quick find in Firefox if (e.keyCode === 222 || e.keyCode === 191) { e.preventDefault(); } } handleKeyUp (e) { // Always capture up events, // even those that have switched to other targets. const key = (!e.key || e.key === 'Dead') ? e.keyCode : e.key; this.props.vm.postIOData('keyboard', { key: key, keyCode: e.keyCode, isDown: false }); // E.g., prevent scroll. if (e.target !== document && e.target !== document.body) { e.preventDefault(); } } render () { const { /* eslint-disable no-unused-vars */ attachKeyboardEvents, isEditorObscured, isEditorUsable, projectChanged, shouldUpdateTargets, shouldUpdateProjectChanged, onBlockDragUpdate, onGreenFlag, onKeyDown, onKeyUp, onMicListeningUpdate, onMonitorsUpdate, onTargetsUpdate, onProjectChanged, onProjectRunStart, onProjectRunStop, onProjectSaved, onRuntimeStarted, onRuntimeStopped, onTurboModeOff, onTurboModeOn, hasCloudVariables, onHasCloudVariablesChanged, onFramerateChanged, onInterpolationChanged, onCompilerOptionsChanged, onPlatformMismatch, onRuntimeOptionsChanged, onStageSizeChanged, onCompileError, onClearCompileErrors, onShowExtensionAlert, /* eslint-enable no-unused-vars */ ...props } = this.props; return ; } } VMListener.propTypes = { attachKeyboardEvents: PropTypes.bool, isEditorObscured: PropTypes.bool.isRequired, isEditorUsable: PropTypes.bool.isRequired, onBlockDragUpdate: PropTypes.func.isRequired, onGreenFlag: PropTypes.func, onKeyDown: PropTypes.func, onKeyUp: PropTypes.func, onMicListeningUpdate: PropTypes.func.isRequired, onMonitorsUpdate: PropTypes.func.isRequired, onProjectChanged: PropTypes.func.isRequired, onProjectRunStart: PropTypes.func.isRequired, onProjectRunStop: PropTypes.func.isRequired, onProjectSaved: PropTypes.func.isRequired, onRuntimeStarted: PropTypes.func.isRequired, onRuntimeStopped: PropTypes.func.isRequired, onShowExtensionAlert: PropTypes.func.isRequired, onTargetsUpdate: PropTypes.func.isRequired, onTurboModeOff: PropTypes.func.isRequired, onTurboModeOn: PropTypes.func.isRequired, hasCloudVariables: PropTypes.bool, onHasCloudVariablesChanged: PropTypes.func.isRequired, onFramerateChanged: PropTypes.func.isRequired, onInterpolationChanged: PropTypes.func.isRequired, onCompilerOptionsChanged: PropTypes.func.isRequired, onPlatformMismatch: PropTypes.func.isRequired, onRuntimeOptionsChanged: PropTypes.func.isRequired, onStageSizeChanged: PropTypes.func, onCompileError: PropTypes.func, onClearCompileErrors: PropTypes.func, projectChanged: PropTypes.bool, shouldUpdateTargets: PropTypes.bool, shouldUpdateProjectChanged: PropTypes.bool, username: PropTypes.string, vm: PropTypes.instanceOf(VM).isRequired }; VMListener.defaultProps = { attachKeyboardEvents: true, onGreenFlag: () => ({}) }; const mapStateToProps = state => ({ hasCloudVariables: state.scratchGui.tw.hasCloudVariables, isEditorObscured: ( !state.scratchGui.mode.isPlayerOnly && state.scratchGui.mode.isFullScreen ), isEditorUsable: ( !state.scratchGui.mode.isPlayerOnly && !state.scratchGui.mode.isFullScreen && state.scratchGui.editorTab.activeTabIndex === BLOCKS_TAB_INDEX ), projectChanged: state.scratchGui.projectChanged, // Do not emit target or project updates in fullscreen or player only mode // or when recording sounds (it leads to garbled recordings on low-power machines) shouldUpdateTargets: !state.scratchGui.mode.isFullScreen && !state.scratchGui.mode.isPlayerOnly && !state.scratchGui.modals.soundRecorder, // Do not update the projectChanged state in fullscreen or player only mode shouldUpdateProjectChanged: !state.scratchGui.mode.isFullScreen && !state.scratchGui.mode.isPlayerOnly, vm: state.scratchGui.vm, username: state.session && state.session.session && state.session.session.user ? state.session.session.user.username : state.scratchGui.tw ? state.scratchGui.tw.username : '' }); const mapDispatchToProps = dispatch => ({ onTargetsUpdate: data => { dispatch(updateTargets(data.targetList, data.editingTarget)); }, onMonitorsUpdate: monitorList => { dispatch(updateMonitors(monitorList)); }, onBlockDragUpdate: areBlocksOverGui => { dispatch(updateBlockDrag(areBlocksOverGui)); }, onProjectRunStart: () => dispatch(setRunningState(true)), onProjectRunStop: () => dispatch(setRunningState(false)), onProjectChanged: () => dispatch(setProjectChanged()), onProjectSaved: () => dispatch(setProjectUnchanged()), onRuntimeStarted: () => dispatch(setStartedState(true)), onRuntimeStopped: () => dispatch(setStartedState(false)), onTurboModeOn: () => dispatch(setTurboState(true)), onTurboModeOff: () => dispatch(setTurboState(false)), onHasCloudVariablesChanged: hasCloudVariables => dispatch(setHasCloudVariables(hasCloudVariables)), onFramerateChanged: framerate => dispatch(setFramerateState(framerate)), onInterpolationChanged: interpolation => dispatch(setInterpolationState(interpolation)), onCompilerOptionsChanged: options => dispatch(setCompilerOptionsState(options)), onPlatformMismatch: (platform, callback) => { dispatch(setPlatformMismatchDetails(platform, callback)); dispatch(openUnknownPlatformModal()); }, onRuntimeOptionsChanged: options => dispatch(setRuntimeOptionsState(options)), onStageSizeChanged: (width, height) => dispatch(setCustomStageSize(width, height)), onCompileError: errors => dispatch(addCompileError(errors)), onClearCompileErrors: () => dispatch(clearCompileErrors()), onShowExtensionAlert: data => { dispatch(showExtensionAlert(data)); }, onMicListeningUpdate: listening => { dispatch(updateMicIndicator(listening)); } }); return connect( mapStateToProps, mapDispatchToProps )(VMListener); }; export default vmListenerHOC;