import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import Renderer from 'scratch-render'; import VM from 'scratch-vm'; import {connect} from 'react-redux'; import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants'; import {getEventXY} from '../lib/touch-utils'; import VideoProvider from '../lib/video/video-provider'; import {SVGRenderer as V2SVGAdapter} from 'scratch-svg-renderer'; import {BitmapAdapter as V2BitmapAdapter} from 'scratch-svg-renderer'; import StageComponent from '../components/stage/stage.jsx'; import { activateColorPicker, deactivateColorPicker } from '../reducers/color-picker'; const colorPickerRadius = 20; const dragThreshold = 3; // Same as the block drag threshold class Stage extends React.Component { constructor (props) { super(props); bindAll(this, [ 'attachMouseEvents', 'cancelMouseDownTimeout', 'detachMouseEvents', 'handleDoubleClick', 'handleQuestionAnswered', 'onMouseUp', 'onMouseMove', 'onMouseDown', 'onStartDrag', 'onStopDrag', 'onWheel', 'updateRect', 'questionListener', 'setDragCanvas', 'clearDragCanvas', 'drawDragCanvas', 'positionDragCanvas' ]); this.state = { mouseDownTimeoutId: null, mouseDownPosition: null, isDragging: false, dragOffset: null, dragId: null, colorInfo: null, question: null }; if (this.props.vm.renderer) { this.renderer = this.props.vm.renderer; this.canvas = this.renderer.canvas; } else { this.canvas = document.createElement('canvas'); this.renderer = new Renderer(this.canvas); this.props.vm.attachRenderer(this.renderer); // Calling draw a single time before any project is loaded just makes // the canvas white instead of solid black–needed because it is not // possible to use CSS to style the canvas to have a different // default color this.props.vm.renderer.draw(); } this.props.vm.attachV2SVGAdapter(new V2SVGAdapter()); this.props.vm.attachV2BitmapAdapter(new V2BitmapAdapter()); this.props.vm.setVideoProvider(new VideoProvider()); } componentDidMount () { this.attachRectEvents(); this.attachMouseEvents(this.canvas); this.updateRect(); this.props.vm.runtime.addListener('QUESTION', this.questionListener); } shouldComponentUpdate (nextProps, nextState) { return this.props.stageSize !== nextProps.stageSize || this.props.isColorPicking !== nextProps.isColorPicking || this.state.colorInfo !== nextState.colorInfo || this.props.isFullScreen !== nextProps.isFullScreen || this.state.question !== nextState.question || this.props.micIndicator !== nextProps.micIndicator || this.props.isStarted !== nextProps.isStarted; } componentDidUpdate (prevProps) { if (this.props.isColorPicking && !prevProps.isColorPicking) { this.startColorPickingLoop(); } else if (!this.props.isColorPicking && prevProps.isColorPicking) { this.stopColorPickingLoop(); } this.updateRect(); this.renderer.resize(this.rect.width, this.rect.height); } componentWillUnmount () { this.detachMouseEvents(this.canvas); this.detachRectEvents(); this.stopColorPickingLoop(); this.props.vm.runtime.removeListener('QUESTION', this.questionListener); } questionListener (question) { this.setState({question: question}); } handleQuestionAnswered (answer) { this.setState({question: null}, () => { this.props.vm.runtime.emit('ANSWER', answer); }); } startColorPickingLoop () { this.intervalId = setInterval(() => { this.setState({colorInfo: this.getColorInfo(this.pickX, this.pickY)}); }, 30); } stopColorPickingLoop () { clearInterval(this.intervalId); } attachMouseEvents (canvas) { document.addEventListener('mousemove', this.onMouseMove); document.addEventListener('mouseup', this.onMouseUp); document.addEventListener('touchmove', this.onMouseMove); document.addEventListener('touchend', this.onMouseUp); canvas.addEventListener('mousedown', this.onMouseDown); canvas.addEventListener('touchstart', this.onMouseDown); canvas.addEventListener('wheel', this.onWheel); } detachMouseEvents (canvas) { document.removeEventListener('mousemove', this.onMouseMove); document.removeEventListener('mouseup', this.onMouseUp); document.removeEventListener('touchmove', this.onMouseMove); document.removeEventListener('touchend', this.onMouseUp); canvas.removeEventListener('mousedown', this.onMouseDown); canvas.removeEventListener('touchstart', this.onMouseDown); canvas.removeEventListener('wheel', this.onWheel); } attachRectEvents () { window.addEventListener('resize', this.updateRect); window.addEventListener('scroll', this.updateRect); } detachRectEvents () { window.removeEventListener('resize', this.updateRect); window.removeEventListener('scroll', this.updateRect); } updateRect () { this.rect = this.canvas.getBoundingClientRect(); } getScratchCoords (x, y) { const nativeSize = this.renderer.getNativeSize(); return [ (nativeSize[0] / this.rect.width) * (x - (this.rect.width / 2)), (nativeSize[1] / this.rect.height) * (y - (this.rect.height / 2)) ]; } getColorInfo (x, y) { return { x: x, y: y, ...this.renderer.extractColor(x, y, colorPickerRadius) }; } handleDoubleClick (e) { const {x, y} = getEventXY(e); // Set editing target from cursor position, if clicking on a sprite. const mousePosition = [x - this.rect.left, y - this.rect.top]; const drawableId = this.renderer.pick(mousePosition[0], mousePosition[1]); if (drawableId === null) return; const targetId = this.props.vm.getTargetIdForDrawableId(drawableId); if (targetId === null) return; this.props.vm.setEditingTarget(targetId); } onMouseMove (e) { const {x, y} = getEventXY(e); const mousePosition = [x - this.rect.left, y - this.rect.top]; // Set the pickX/Y for the color picker loop to pick up this.pickX = mousePosition[0]; this.pickY = mousePosition[1]; if (this.state.mouseDown && !this.state.isDragging) { const distanceFromMouseDown = Math.sqrt( Math.pow(mousePosition[0] - this.state.mouseDownPosition[0], 2) + Math.pow(mousePosition[1] - this.state.mouseDownPosition[1], 2) ); if (distanceFromMouseDown > dragThreshold) { this.cancelMouseDownTimeout(); this.onStartDrag(...this.state.mouseDownPosition); } } if (this.state.mouseDown && this.state.isDragging) { // Editor drag style only updates the drag canvas, does full update at the end of drag // Non-editor drag style just updates the sprite continuously. if (this.props.useEditorDragStyle) { this.positionDragCanvas(mousePosition[0], mousePosition[1]); } else { const spritePosition = this.getScratchCoords(mousePosition[0], mousePosition[1]); this.props.vm.postSpriteInfo({ x: spritePosition[0] + this.state.dragOffset[0], y: -(spritePosition[1] + this.state.dragOffset[1]), force: true }); } } const coordinates = { x: mousePosition[0], y: mousePosition[1], canvasWidth: this.rect.width, canvasHeight: this.rect.height }; this.props.vm.postIOData('mouse', coordinates); } onMouseUp (e) { const {x, y} = getEventXY(e); const mousePosition = [x - this.rect.left, y - this.rect.top]; this.cancelMouseDownTimeout(); this.setState({ mouseDown: false, mouseDownPosition: null }); const data = { isDown: false, x: x - this.rect.left, y: y - this.rect.top, canvasWidth: this.rect.width, canvasHeight: this.rect.height, wasDragged: this.state.isDragging }; if (this.state.isDragging) { this.onStopDrag(mousePosition[0], mousePosition[1]); } this.props.vm.postIOData('mouse', data); } onMouseDown (e) { this.updateRect(); const {x, y} = getEventXY(e); const mousePosition = [x - this.rect.left, y - this.rect.top]; if (e.button === 0 || e instanceof TouchEvent) { this.setState({ mouseDown: true, mouseDownPosition: mousePosition, mouseDownTimeoutId: setTimeout( this.onStartDrag.bind(this, mousePosition[0], mousePosition[1]), 400 ) }); } const data = { isDown: true, x: mousePosition[0], y: mousePosition[1], canvasWidth: this.rect.width, canvasHeight: this.rect.height }; this.props.vm.postIOData('mouse', data); if (e.preventDefault) { e.preventDefault(); } if (this.props.isColorPicking) { const {r, g, b} = this.state.colorInfo.color; const componentToString = c => { const hex = c.toString(16); return hex.length === 1 ? `0${hex}` : hex; }; const colorString = `#${componentToString(r)}${componentToString(g)}${componentToString(b)}`; this.props.onDeactivateColorPicker(colorString); this.setState({colorInfo: null}); } } onWheel (e) { const data = { deltaX: e.deltaX, deltaY: e.deltaY }; this.props.vm.postIOData('mouseWheel', data); } cancelMouseDownTimeout () { if (this.state.mouseDownTimeoutId !== null) { clearTimeout(this.state.mouseDownTimeoutId); } this.setState({mouseDownTimeoutId: null}); } drawDragCanvas (drawableData) { const { data, width, height, x, y } = drawableData; this.dragCanvas.width = width; this.dragCanvas.height = height; // Need to convert uint8array from WebGL readPixels into Uint8ClampedArray // for ImageData constructor. Shares underlying buffer, so it is fast. const imageData = new ImageData( new Uint8ClampedArray(data.buffer), width, height); this.dragCanvas.getContext('2d').putImageData(imageData, 0, 0); // Position so that pick location is at (0, 0) so that positionDragCanvas() // can use translation to move to mouse position smoothly. this.dragCanvas.style.left = `${-x}px`; this.dragCanvas.style.top = `${-y}px`; this.dragCanvas.style.display = 'block'; } clearDragCanvas () { this.dragCanvas.width = this.dragCanvas.height = 0; this.dragCanvas.style.display = 'none'; } positionDragCanvas (mouseX, mouseY) { // mouseX/Y are relative to stage top/left, and dragCanvas is already // positioned so that the pick location is at (0,0). this.dragCanvas.style.transform = `translate(${mouseX}px, ${mouseY}px)`; } onStartDrag (x, y) { if (this.state.dragId) return; const drawableId = this.renderer.pick(x, y); if (drawableId === null) return; const drawableData = this.renderer.extractDrawable(drawableId, x, y); const targetId = this.props.vm.getTargetIdForDrawableId(drawableId); if (targetId === null) return; const target = this.props.vm.runtime.getTargetById(targetId); // Do not start drag unless in editor drag mode or target is draggable if (!(this.props.useEditorDragStyle || target.draggable)) return; // Dragging always brings the target to the front target.goToFront(); this.props.vm.startDrag(targetId); this.setState({ isDragging: true, dragId: targetId, dragOffset: drawableData.scratchOffset }); if (this.props.useEditorDragStyle) { this.drawDragCanvas(drawableData); this.positionDragCanvas(x, y); this.props.vm.postSpriteInfo({visible: false}); } } onStopDrag (mouseX, mouseY) { const dragId = this.state.dragId; const commonStopDragActions = () => { this.props.vm.stopDrag(dragId); this.setState({ isDragging: false, dragOffset: null, dragId: null }); }; if (this.props.useEditorDragStyle) { // Need to sequence these actions to prevent flickering. const spriteInfo = {visible: true}; // First update the sprite position if dropped in the stage. if (mouseX > 0 && mouseX < this.rect.width && mouseY > 0 && mouseY < this.rect.height) { const spritePosition = this.getScratchCoords(mouseX, mouseY); spriteInfo.x = spritePosition[0] + this.state.dragOffset[0]; spriteInfo.y = -(spritePosition[1] + this.state.dragOffset[1]); spriteInfo.force = true; } this.props.vm.postSpriteInfo(spriteInfo); // Then clear the dragging canvas and stop drag (potentially slow if selecting sprite) setTimeout(() => { this.clearDragCanvas(); setTimeout(() => { commonStopDragActions(); }, 30); }, 30); } else { commonStopDragActions(); } } setDragCanvas (canvas) { this.dragCanvas = canvas; } render () { const { vm, // eslint-disable-line no-unused-vars onActivateColorPicker, // eslint-disable-line no-unused-vars ...props } = this.props; return ( <StageComponent canvas={this.canvas} colorInfo={this.state.colorInfo} dragRef={this.setDragCanvas} question={this.state.question} onDoubleClick={this.handleDoubleClick} onQuestionAnswered={this.handleQuestionAnswered} {...props} /> ); } } Stage.propTypes = { isColorPicking: PropTypes.bool, isFullScreen: PropTypes.bool.isRequired, micIndicator: PropTypes.bool, onActivateColorPicker: PropTypes.func, onDeactivateColorPicker: PropTypes.func, stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, useEditorDragStyle: PropTypes.bool, vm: PropTypes.instanceOf(VM).isRequired }; Stage.defaultProps = { useEditorDragStyle: true }; const mapStateToProps = state => ({ isColorPicking: state.scratchGui.colorPicker.active, isFullScreen: state.scratchGui.mode.isFullScreen, isStarted: state.scratchGui.vmStatus.started, micIndicator: state.scratchGui.micIndicator, // Do not use editor drag style in fullscreen or player mode. useEditorDragStyle: !(state.scratchGui.mode.isFullScreen || state.scratchGui.mode.isPlayerOnly) }); const mapDispatchToProps = dispatch => ({ onActivateColorPicker: () => dispatch(activateColorPicker()), onDeactivateColorPicker: color => dispatch(deactivateColorPicker(color)) }); export default connect( mapStateToProps, mapDispatchToProps )(Stage);