Files
scratch-gui/src/containers/stage.jsx
T

431 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// Only attach a video provider once because it is stateful
this.props.vm.setVideoProvider(new VideoProvider());
// 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());
}
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);