From 65e01398783a1e53626964c4af7bbbf26b24ba5c Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Mon, 28 Aug 2017 10:41:37 -0400 Subject: [PATCH 1/2] WIP initial prototype of eye dropper --- src/components/loupe/loupe.css | 5 ++ src/components/loupe/loupe.jsx | 104 +++++++++++++++++++++++++++++++++ src/components/stage/stage.css | 19 +++++- src/components/stage/stage.jsx | 47 +++++++++++---- src/containers/blocks.jsx | 18 ++++-- src/containers/stage.jsx | 59 +++++++++++++++++-- src/reducers/color-picker.js | 40 +++++++++++++ src/reducers/gui.js | 3 +- 8 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 src/components/loupe/loupe.css create mode 100644 src/components/loupe/loupe.jsx create mode 100644 src/reducers/color-picker.js 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/stage.css b/src/components/stage/stage.css index 4cc5d3092..f13554a02 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -13,15 +13,32 @@ 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; } -.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 92ef79f21..7a23c099d 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -1,7 +1,9 @@ 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,27 +12,50 @@ const StageComponent = props => { canvasRef, width, height, + colorInfo, + onDeactivateColorPicker, + isColorPicking, ...boxProps } = props; return ( - +
- - + 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, + 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 1b5b2ec9c..f45a6628b 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -3,10 +3,19 @@ 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 {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); @@ -28,7 +37,8 @@ class Stage extends React.Component { mouseDownPosition: null, isDragging: false, dragOffset: null, - dragId: null + dragId: null, + colorInfo: null }; } componentDidMount () { @@ -38,8 +48,11 @@ class Stage extends React.Component { this.renderer = new Renderer(this.canvas); this.props.vm.attachRenderer(this.renderer); } - shouldComponentUpdate (nextProps) { - return this.props.width !== nextProps.width || this.props.height !== nextProps.height; + shouldComponentUpdate (nextProps, nextState) { + return this.props.width !== nextProps.width || + this.props.height !== nextProps.height || + this.props.isColorPicking !== nextProps.isColorPicking || + this.state.colorInfo !== nextState.colorInfo; } componentWillUnmount () { this.detachMouseEvents(this.canvas); @@ -79,6 +92,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. @@ -113,6 +133,10 @@ class Stage extends React.Component { canvasHeight: this.rect.height }; this.props.vm.postIOData('mouse', coordinates); + + if (this.props.isColorPicking) { + this.setState({colorInfo: this.getColorInfo(mousePosition[0], mousePosition[1])}); + } } onMouseUp (e) { const {x, y} = getEventXY(e); @@ -157,6 +181,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) { @@ -191,11 +225,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 ( @@ -205,8 +241,23 @@ class Stage extends React.Component { Stage.propTypes = { height: PropTypes.number, + isColorPicking: PropTypes.bool, + onActivateColorPicker: PropTypes.func, + onDeactivateColorPicker: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired, width: PropTypes.number }; -export default Stage; +const mapStateToProps = state => ({ + isColorPicking: state.colorPicker.active +}); + +const mapDispatchToProps = dispatch => ({ + onActivateColorPicker: () => dispatch(activateColorPicker()), + onDeactivateColorPicker: color => dispatch(deactivateColorPicker(color)) +}); + +export default connect( + 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 dfe05dd84..fef037d92 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'; @@ -6,8 +7,8 @@ import targetReducer from './targets'; import toolboxReducer from './toolbox'; import vmReducer from './vm'; - export default combineReducers({ + colorPicker: colorPickerReducer, intl: intlReducer, modals: modalReducer, monitors: monitorReducer, From 77f99a5c05fa194a6eee92acb38be7f922ed735d Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Wed, 20 Sep 2017 15:44:03 -0400 Subject: [PATCH 2/2] Run the color picker in an interval loop --- src/containers/stage.jsx | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index f45a6628b..6d3d58211 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -54,9 +54,25 @@ class Stage extends React.Component { this.props.isColorPicking !== nextProps.isColorPicking || this.state.colorInfo !== nextState.colorInfo; } + componentDidUpdate (prevProps) { + if (this.props.isColorPicking && !prevProps.isColorPicking) { + this.startColorPickingLoop(); + } else if (!this.props.isColorPicking && prevProps.isColorPicking) { + this.stopColorPickingLoop(); + } + } 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); @@ -112,6 +128,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) { @@ -133,10 +154,6 @@ class Stage extends React.Component { canvasHeight: this.rect.height }; this.props.vm.postIOData('mouse', coordinates); - - if (this.props.isColorPicking) { - this.setState({colorInfo: this.getColorInfo(mousePosition[0], mousePosition[1])}); - } } onMouseUp (e) { const {x, y} = getEventXY(e);