diff --git a/src/components/loupe/loupe.css b/src/components/loupe/loupe.css new file mode 100644 index 000000000..d0d708c0d --- /dev/null +++ b/src/components/loupe/loupe.css @@ -0,0 +1,5 @@ +.color-picker { + position: absolute; + border-radius: 100%; + border: 1px solid #222; +} diff --git a/src/components/loupe/loupe.jsx b/src/components/loupe/loupe.jsx new file mode 100644 index 000000000..4c2c1b2d6 --- /dev/null +++ b/src/components/loupe/loupe.jsx @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import bindAll from 'lodash.bindall'; + +import Box from '../box/box.jsx'; +import styles from './loupe.css'; + +const zoomScale = 3; + +class LoupeComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'setCanvas' + ]); + } + componentDidUpdate () { + this.draw(); + } + draw () { + const boxSize = 6 / zoomScale; + const boxLineWidth = 1 / zoomScale; + const colorRingWidth = 15 / zoomScale; + + const ctx = this.canvas.getContext('2d'); + const {color, data, width, height} = this.props.colorInfo; + this.canvas.width = zoomScale * width; + this.canvas.height = zoomScale * height; + + // In order to scale the image data, must draw to a tmp canvas first + const tmpCanvas = document.createElement('canvas'); + tmpCanvas.width = width; + tmpCanvas.height = height; + const tmpCtx = tmpCanvas.getContext('2d'); + const imageData = tmpCtx.createImageData(width, height); + imageData.data.set(data); + tmpCtx.putImageData(imageData, 0, 0); + + // Scale the loupe canvas and draw the zoomed image + ctx.save(); + ctx.scale(zoomScale, zoomScale); + ctx.drawImage(tmpCanvas, 0, 0, width, height); + + // Draw an outlined square at the cursor position (cursor is hidden) + ctx.lineWidth = boxLineWidth; + ctx.strokeStyle = 'black'; + ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`; + ctx.beginPath(); + ctx.rect((width / 2) - (boxSize / 2), (height / 2) - (boxSize / 2), boxSize, boxSize); + ctx.fill(); + ctx.stroke(); + + // Draw a thick ring around the loupe showing the current color + ctx.strokeStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`; + ctx.lineWidth = colorRingWidth; + ctx.beginPath(); + ctx.moveTo(width, height / 2); + ctx.arc(width / 2, height / 2, width / 2, 0, 2 * Math.PI); + ctx.stroke(); + ctx.restore(); + } + setCanvas (element) { + this.canvas = element; + } + render () { + const { + colorInfo, + ...boxProps + } = this.props; + return ( + + ); + } +} + +LoupeComponent.propTypes = { + colorInfo: PropTypes.shape({ + color: PropTypes.shape({ + r: PropTypes.number, + g: PropTypes.number, + b: PropTypes.number + }), + data: PropTypes.instanceOf(Uint8Array), + width: PropTypes.number, + height: PropTypes.number, + x: PropTypes.number, + y: PropTypes.number + }) +}; + +export default LoupeComponent; diff --git a/src/components/stage-header/stage-header.css b/src/components/stage-header/stage-header.css index 771e3e138..c491b82e9 100644 --- a/src/components/stage-header/stage-header.css +++ b/src/components/stage-header/stage-header.css @@ -11,7 +11,7 @@ top: 0; left: 0; right: 0; - z-index: 999; + z-index: 5000; } .stage-menu-wrapper { diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index a773f8980..9278b8984 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -13,6 +13,22 @@ background-color: transparent; } +.with-color-picker { + cursor: none; + z-index: 2001; +} + +.color-picker-background { + position: absolute;; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.55); + display: block; + z-index: 2000; + top: 0; + left: 0; +} + .stage-wrapper { position: relative; } @@ -23,7 +39,7 @@ left: 0; right: 0; bottom: 0; - z-index: 999; + z-index: 5000; background-color: rgba(255, 255, 255, 1); } @@ -40,11 +56,12 @@ justify-content: center; } -.monitor-wrapper { +.monitor-wrapper, .color-picker-wrapper { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; + overflow: hidden; } diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx index 4055db163..7a6b5537f 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -1,8 +1,9 @@ -import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; +import classNames from 'classnames'; import Box from '../box/box.jsx'; +import Loupe from '../loupe/loupe.jsx'; import MonitorList from '../../containers/monitor-list.jsx'; import styles from './stage.css'; @@ -10,8 +11,11 @@ const StageComponent = props => { const { canvasRef, height, + isColorPicking, isZoomed, width, + colorInfo, + onDeactivateColorPicker, ...boxProps } = props; let heightCorrectedAspect = window.innerHeight - 40; @@ -21,39 +25,61 @@ const StageComponent = props => { heightCorrectedAspect = widthCorrectedAspect * .75; } return isZoomed === false ? ( - +
- - + className={classNames(styles.stageWrapper, { + [styles.withColorPicker]: isColorPicking + })} + > + + + + + {isColorPicking && colorInfo ? ( + + + + ) : null} - + {isColorPicking ? ( + + ) : null} +
) : ( - - - - +
+ + + + + - +
); }; StageComponent.propTypes = { canvasRef: PropTypes.func, + colorInfo: Loupe.propTypes.colorInfo, height: PropTypes.number, + isColorPicking: PropTypes.bool, isZoomed: PropTypes.bool.isRequired, + onDeactivateColorPicker: PropTypes.func, width: PropTypes.number }; StageComponent.defaultProps = { diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index fbd4ecb6d..b15718483 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -11,6 +11,7 @@ import BlocksComponent from '../components/blocks/blocks.jsx'; import {connect} from 'react-redux'; import {updateToolbox} from '../reducers/toolbox'; +import {activateColorPicker} from '../reducers/color-picker'; const addFunctionListener = (object, property, callback) => { const oldFn = object[property]; @@ -50,6 +51,8 @@ class Blocks extends React.Component { this.onTargetsUpdate = debounce(this.onTargetsUpdate, 100); } componentDidMount () { + this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker; + const workspaceConfig = defaultsDeep({}, Blocks.defaultOptions, this.props.options); this.workspace = this.ScratchBlocks.inject(this.blocks, workspaceConfig); @@ -219,14 +222,17 @@ class Blocks extends React.Component { this.setState({prompt: null}); } render () { + /* eslint-disable no-unused-vars */ const { - options, // eslint-disable-line no-unused-vars - vm, // eslint-disable-line no-unused-vars - isVisible, // eslint-disable-line no-unused-vars - onExtensionAdded, // eslint-disable-line no-unused-vars - toolboxXML, // eslint-disable-line no-unused-vars + options, + vm, + isVisible, + onActivateColorPicker, + onExtensionAdded, + toolboxXML, ...props } = this.props; + /* eslint-enable no-unused-vars */ return (
({ }); const mapDispatchToProps = dispatch => ({ + onActivateColorPicker: callback => dispatch(activateColorPicker(callback)), onExtensionAdded: toolboxXML => { dispatch(updateToolbox(toolboxXML)); } diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index 51a60fda7..39e66a500 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -3,12 +3,19 @@ import PropTypes from 'prop-types'; import React from 'react'; import Renderer from 'scratch-render'; import VM from 'scratch-vm'; -import {getEventXY} from '../lib/touch-utils'; - import {connect} from 'react-redux'; +import {getEventXY} from '../lib/touch-utils'; + import StageComponent from '../components/stage/stage.jsx'; +import { + activateColorPicker, + deactivateColorPicker +} from '../reducers/color-picker'; + +const colorPickerRadius = 20; + class Stage extends React.Component { constructor (props) { super(props); @@ -29,9 +36,10 @@ class Stage extends React.Component { mouseDownTimeoutId: null, mouseDownPosition: null, isDragging: false, + isZoomed: false, dragOffset: null, dragId: null, - isZoomed: false + colorInfo: null }; } componentDidMount () { @@ -50,16 +58,32 @@ class Stage extends React.Component { } shouldComponentUpdate (nextProps, nextState) { return this.props.width !== nextProps.width || - this.props.height !== nextProps.height || - this.state.isZoomed !== nextState.isZoomed; + this.props.height !== nextProps.height || + this.props.isColorPicking !== nextProps.isColorPicking || + this.state.colorInfo !== nextState.colorInfo || + this.state.isZoomed !== nextState.isZoomed; } - componentDidUpdate () { + 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(); + } + 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); @@ -95,6 +119,13 @@ class Stage extends React.Component { (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. @@ -108,6 +139,11 @@ class Stage extends React.Component { 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.mouseDownTimeoutId !== null) { this.cancelMouseDownTimeout(); if (this.state.mouseDown && !this.state.isDragging) { @@ -173,6 +209,16 @@ class Stage extends React.Component { 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}); + } } cancelMouseDownTimeout () { if (this.state.mouseDownTimeoutId !== null) { @@ -207,11 +253,13 @@ class Stage extends React.Component { render () { const { vm, // eslint-disable-line no-unused-vars + onActivateColorPicker, // eslint-disable-line no-unused-vars ...props } = this.props; return ( ({ + isColorPicking: state.colorPicker.active, isZoomed: state.isZoomed }); +const mapDispatchToProps = dispatch => ({ + onActivateColorPicker: () => dispatch(activateColorPicker()), + onDeactivateColorPicker: color => dispatch(deactivateColorPicker(color)) +}); + export default connect( - mapStateToProps + mapStateToProps, + mapDispatchToProps )(Stage); diff --git a/src/reducers/color-picker.js b/src/reducers/color-picker.js new file mode 100644 index 000000000..fc16354a0 --- /dev/null +++ b/src/reducers/color-picker.js @@ -0,0 +1,40 @@ +const ACTIVATE_COLOR_PICKER = 'scratch-gui/color-picker/ACTIVATE_COLOR_PICKER'; +const DEACTIVATE_COLOR_PICKER = 'scratch-gui/color-picker/DEACTIVATE_COLOR_PICKER'; +const SET_CALLBACK = 'scratch-gui/color-picker/SET_CALLBACK'; + +const initialState = { + active: false, + callback: () => { + throw new Error('Color picker callback not initialized'); + } +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case ACTIVATE_COLOR_PICKER: + return Object.assign({}, state, {active: true, callback: action.callback}); + case DEACTIVATE_COLOR_PICKER: + // Can be called without a string to deactivate without setting color + // i.e. when clicking on the modal background + if (typeof action.color === 'string') { + state.callback(action.color); + } + return Object.assign({}, state, {active: false}); + case SET_CALLBACK: + return Object.assign({}, state, {callback: action.callback}); + default: + return state; + } +}; + +const activateColorPicker = callback => ({type: ACTIVATE_COLOR_PICKER, callback: callback}); +const deactivateColorPicker = color => ({type: DEACTIVATE_COLOR_PICKER, color: color}); +const setCallback = callback => ({type: SET_CALLBACK, callback: callback}); + +export { + reducer as default, + activateColorPicker, + deactivateColorPicker, + setCallback +}; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index f0b7eb6f5..9477566d4 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -1,4 +1,5 @@ import {combineReducers} from 'redux'; +import colorPickerReducer from './color-picker'; import intlReducer from './intl'; import modalReducer from './modals'; import monitorReducer from './monitors'; @@ -7,8 +8,8 @@ import toolboxReducer from './toolbox'; import vmReducer from './vm'; import zoomReducer from './zoom'; - export default combineReducers({ + colorPicker: colorPickerReducer, intl: intlReducer, isZoomed: zoomReducer, modals: modalReducer,