From 2183dc759f0ce65a728ccff25dbcf4845ace6730 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 8 Dec 2017 16:52:37 -0500 Subject: [PATCH] Implement eye dropper for paint editor --- package.json | 1 + src/components/box/box.css | 2 + src/components/box/box.jsx | 143 +++++++++++++++++++ src/components/color-picker/color-picker.css | 6 + src/components/color-picker/color-picker.jsx | 125 +++++----------- src/components/color-picker/eye-dropper.svg | 12 ++ src/components/fill-color-indicator.jsx | 4 +- src/components/loupe/loupe.css | 5 + src/components/loupe/loupe.jsx | 108 ++++++++++++++ src/components/paint-editor/paint-editor.css | 10 ++ src/components/paint-editor/paint-editor.jsx | 17 ++- src/components/stroke-color-indicator.jsx | 4 +- src/containers/color-picker.jsx | 132 +++++++++++++++++ src/containers/paint-editor.jsx | 93 +++++++++++- src/helper/eye-dropper.js | 71 +++++++++ src/lib/modes.js | 1 + src/reducers/color.js | 2 + src/reducers/eye-dropper.js | 55 +++++++ 18 files changed, 695 insertions(+), 96 deletions(-) create mode 100644 src/components/box/box.css create mode 100644 src/components/box/box.jsx create mode 100644 src/components/color-picker/eye-dropper.svg create mode 100644 src/components/loupe/loupe.css create mode 100644 src/components/loupe/loupe.jsx create mode 100644 src/containers/color-picker.jsx create mode 100644 src/helper/eye-dropper.js create mode 100644 src/reducers/eye-dropper.js diff --git a/package.json b/package.json index 40249d8b..d94ad504 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "react-popover": "0.5.4", "react-redux": "5.0.5", "react-responsive": "3.0.0", + "react-style-proptype": "3.1.0", "react-test-renderer": "^16.0.0", "redux": "3.7.0", "redux-mock-store": "^1.2.3", diff --git a/src/components/box/box.css b/src/components/box/box.css new file mode 100644 index 00000000..893c22f4 --- /dev/null +++ b/src/components/box/box.css @@ -0,0 +1,2 @@ +.box { +} diff --git a/src/components/box/box.jsx b/src/components/box/box.jsx new file mode 100644 index 00000000..581fa326 --- /dev/null +++ b/src/components/box/box.jsx @@ -0,0 +1,143 @@ +/* DO NOT EDIT +@todo This file is copied from GUI and should be pulled out into a shared library. +See https://github.com/LLK/scratch-paint/issues/13 */ + +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import stylePropType from 'react-style-proptype'; +import styles from './box.css'; + +const getRandomColor = (function () { + // In "DEBUG" mode this is used to output a random background color for each + // box. The function gives the same "random" set for each seed, allowing re- + // renders of the same content to give the same random display. + const random = (function (seed) { + let mW = seed; + let mZ = 987654321; + const mask = 0xffffffff; + return function () { + mZ = ((36969 * (mZ & 65535)) + (mZ >> 16)) & mask; + mW = ((18000 * (mW & 65535)) + (mW >> 16)) & mask; + let result = ((mZ << 16) + mW) & mask; + result /= 4294967296; + return result + 1; + }; + }(601)); + return function () { + const r = Math.max(parseInt(random() * 100, 10) % 256, 1); + const g = Math.max(parseInt(random() * 100, 10) % 256, 1); + const b = Math.max(parseInt(random() * 100, 10) % 256, 1); + return `rgb(${r},${g},${b})`; + }; +}()); + +const Box = props => { + const { + alignContent, + alignItems, + alignSelf, + basis, + children, + className, + componentRef, + direction, + element, + grow, + height, + justifyContent, + width, + wrap, + shrink, + style, + ...componentProps + } = props; + return React.createElement(element, { + className: classNames(className, styles.box), + ref: componentRef, + style: Object.assign( + { + alignContent: alignContent, + alignItems: alignItems, + alignSelf: alignSelf, + flexBasis: basis, + flexDirection: direction, + flexGrow: grow, + flexShrink: shrink, + flexWrap: wrap, + justifyContent: justifyContent, + width: width, + height: height + }, + process.env.DEBUG ? { + backgroundColor: getRandomColor(), + outline: `1px solid black` + } : {}, + style + ), + ...componentProps + }, children); +}; +Box.propTypes = { + /** Defines how the browser distributes space between and around content items vertically within this box. */ + alignContent: PropTypes.oneOf([ + 'flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'stretch' + ]), + /** Defines how the browser distributes space between and around flex items horizontally within this box. */ + alignItems: PropTypes.oneOf([ + 'flex-start', 'flex-end', 'center', 'baseline', 'stretch' + ]), + /** Specifies how this box should be aligned inside of its container (requires the container to be flexable). */ + alignSelf: PropTypes.oneOf([ + 'auto', 'flex-start', 'flex-end', 'center', 'baseline', 'stretch' + ]), + /** Specifies the initial length of this box */ + basis: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.oneOf(['auto']) + ]), + /** Specifies the the HTML nodes which will be child elements of this box. */ + children: PropTypes.node, + /** Specifies the class name that will be set on this box */ + className: PropTypes.string, + /** + * A callback function whose first parameter is the underlying dom elements. + * This call back will be executed immediately after the component is mounted or unmounted + */ + componentRef: PropTypes.func, + /** https://developer.mozilla.org/en-US/docs/Web/CSS/flex-direction */ + direction: PropTypes.oneOf([ + 'row', 'row-reverse', 'column', 'column-reverse' + ]), + /** Specifies the type of HTML element of this box. Defaults to div. */ + element: PropTypes.string, + /** Specifies the flex grow factor of a flex item. */ + grow: PropTypes.number, + /** The height in pixels (if specified as a number) or a string if different units are required. */ + height: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string + ]), + /** https://developer.mozilla.org/en-US/docs/Web/CSS/justify-content */ + justifyContent: PropTypes.oneOf([ + 'flex-start', 'flex-end', 'center', 'space-between', 'space-around' + ]), + /** Specifies the flex shrink factor of a flex item. */ + shrink: PropTypes.number, + /** An object whose keys are css property names and whose values correspond the the css property. */ + style: stylePropType, + /** The width in pixels (if specified as a number) or a string if different units are required. */ + width: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string + ]), + /** How whitespace should wrap within this block. */ + wrap: PropTypes.oneOf([ + 'nowrap', 'wrap', 'wrap-reverse' + ]) +}; +Box.defaultProps = { + element: 'div', + style: {} +}; +export default Box; diff --git a/src/components/color-picker/color-picker.css b/src/components/color-picker/color-picker.css index 3a796cff..8618c323 100644 --- a/src/components/color-picker/color-picker.css +++ b/src/components/color-picker/color-picker.css @@ -13,6 +13,12 @@ stroke: #ddd; } +.swatch-row { + display: flex; + flex-direction: row; + justify-content: space-between; +} + .row-header { font-family: "Helvetica Neue", Helvetica, sans-serif; font-size: 0.65rem; diff --git a/src/components/color-picker/color-picker.jsx b/src/components/color-picker/color-picker.jsx index 1317a510..c732eb0c 100644 --- a/src/components/color-picker/color-picker.jsx +++ b/src/components/color-picker/color-picker.jsx @@ -3,86 +3,20 @@ import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; import classNames from 'classnames'; import parseColor from 'parse-color'; -import bindAll from 'lodash.bindall'; - -import {MIXED} from '../../helper/style-path'; import Slider from '../forms/slider.jsx'; -import styles from './color-picker.css'; -import noFillIcon from '../color-button/no-fill.svg'; -const colorStringToHsv = hexString => { - const hsv = parseColor(hexString).hsv; - // Hue comes out in [0, 360], limit to [0, 100] - hsv[0] = hsv[0] / 3.6; - // Black is parsed as {0, 0, 0}, but turn saturation up to 100 - // to make it easier to see slider values. - if (hsv[1] === 0 && hsv[2] === 0) { - hsv[1] = 100; - } - return hsv; -}; +import styles from './color-picker.css'; + +import eyeDropperIcon from './eye-dropper.svg'; +import noFillIcon from '../color-button/no-fill.svg'; const hsvToHex = (h, s, v) => // Scale hue back up to [0, 360] from [0, 100] parseColor(`hsv(${3.6 * h}, ${s}, ${v})`).hex ; -// Important! This component ignores new color props and cannot be updated -// This is to make the HSV <=> RGB conversion stable. Because of this, the -// component MUST be unmounted in order to change the props externally. class ColorPickerComponent extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'handleHueChange', - 'handleSaturationChange', - 'handleBrightnessChange', - 'handleTransparent' - ]); - const isTransparent = this.props.color === null; - const isMixed = this.props.color === MIXED; - const hsv = isTransparent || isMixed ? - [50, 100, 100] : colorStringToHsv(props.color); - - this.state = { - hue: hsv[0], - saturation: hsv[1], - brightness: hsv[2] - }; - } - - componentWillReceiveProps () { - // Just a reminder, new props do not update the hsv state - } - - handleHueChange (hue) { - this.setState({hue: hue}); - this.handleColorChange(); - } - - handleSaturationChange (saturation) { - this.setState({saturation: saturation}); - this.handleColorChange(); - } - - handleBrightnessChange (brightness) { - this.setState({brightness: brightness}); - this.handleColorChange(); - } - - handleColorChange () { - this.props.onChangeColor(hsvToHex( - this.state.hue, - this.state.saturation, - this.state.brightness - )); - } - - handleTransparent () { - this.props.onChangeColor(null); - } - _makeBackground (channel) { const stops = []; // Generate the color slider background CSS gradients by adding @@ -90,13 +24,13 @@ class ColorPickerComponent extends React.Component { for (let n = 100; n >= 0; n -= 10) { switch (channel) { case 'hue': - stops.push(hsvToHex(n, this.state.saturation, this.state.brightness)); + stops.push(hsvToHex(n, this.props.saturation, this.props.brightness)); break; case 'saturation': - stops.push(hsvToHex(this.state.hue, n, this.state.brightness)); + stops.push(hsvToHex(this.props.hue, n, this.props.brightness)); break; case 'brightness': - stops.push(hsvToHex(this.state.hue, this.state.saturation, n)); + stops.push(hsvToHex(this.props.hue, this.props.saturation, n)); break; default: throw new Error(`Unknown channel for color sliders: ${channel}`); @@ -104,7 +38,6 @@ class ColorPickerComponent extends React.Component { } return `linear-gradient(to left, ${stops.join(',')})`; } - render () { return (
@@ -118,14 +51,14 @@ class ColorPickerComponent extends React.Component { /> - {Math.round(this.state.hue)} + {Math.round(this.props.hue)}
@@ -139,14 +72,14 @@ class ColorPickerComponent extends React.Component { /> - {Math.round(this.state.saturation)} + {Math.round(this.props.saturation)}
@@ -160,40 +93,58 @@ class ColorPickerComponent extends React.Component { /> - {Math.round(this.state.brightness)} + {Math.round(this.props.brightness)}
-
+
+
+
+ +
+
); } } - ColorPickerComponent.propTypes = { + brightness: PropTypes.number.isRequired, color: PropTypes.string, - onChangeColor: PropTypes.func.isRequired + hue: PropTypes.number.isRequired, + isEyeDropping: PropTypes.bool.isRequired, + onActivateEyeDropper: PropTypes.func.isRequired, + onBrightnessChange: PropTypes.func.isRequired, + onHueChange: PropTypes.func.isRequired, + onSaturationChange: PropTypes.func.isRequired, + onTransparent: PropTypes.func.isRequired, + saturation: PropTypes.number.isRequired }; export default ColorPickerComponent; diff --git a/src/components/color-picker/eye-dropper.svg b/src/components/color-picker/eye-dropper.svg new file mode 100644 index 00000000..0489c30f --- /dev/null +++ b/src/components/color-picker/eye-dropper.svg @@ -0,0 +1,12 @@ + + + + eye-dropper + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/src/components/fill-color-indicator.jsx b/src/components/fill-color-indicator.jsx index 4e49c145..c6891cc9 100644 --- a/src/components/fill-color-indicator.jsx +++ b/src/components/fill-color-indicator.jsx @@ -1,10 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import Popover from 'react-popover'; - import {defineMessages, injectIntl, intlShape} from 'react-intl'; -import ColorPicker from './color-picker/color-picker.jsx'; + import ColorButton from './color-button/color-button.jsx'; +import ColorPicker from '../containers/color-picker.jsx'; import InputGroup from './input-group/input-group.jsx'; import Label from './forms/label.jsx'; diff --git a/src/components/loupe/loupe.css b/src/components/loupe/loupe.css new file mode 100644 index 00000000..61d25a6d --- /dev/null +++ b/src/components/loupe/loupe.css @@ -0,0 +1,5 @@ +.eye-dropper { + 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 00000000..de5ffea5 --- /dev/null +++ b/src/components/loupe/loupe.jsx @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import bindAll from 'lodash.bindall'; + +import Box from '../box/box.jsx'; + +import {LOUPE_RADIUS, CANVAS_SCALE} from '../../helper/eye-dropper'; + +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 color = this.props.colorInfo.color; + + const ctx = this.canvas.getContext('2d'); + this.canvas.width = zoomScale * (LOUPE_RADIUS * 2); + this.canvas.height = zoomScale * (LOUPE_RADIUS * 2); + + // In order to scale the image data, must draw to a tmp canvas first + const tmpCanvas = document.createElement('canvas'); + tmpCanvas.width = LOUPE_RADIUS * 2; + tmpCanvas.height = LOUPE_RADIUS * 2; + const tmpCtx = tmpCanvas.getContext('2d'); + const imageData = tmpCtx.createImageData( + LOUPE_RADIUS * 2, LOUPE_RADIUS * 2 + ); + imageData.data.set(this.props.colorInfo.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, LOUPE_RADIUS * 2, LOUPE_RADIUS * 2); + + // 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((20) - (boxSize / 2), (20) - (boxSize / 2), boxSize, boxSize); + ctx.fill(); + ctx.stroke(); + + // Draw a thick ring around the loupe showing the current color + ctx.strokeStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3]})`; + ctx.lineWidth = colorRingWidth; + ctx.beginPath(); + ctx.moveTo(LOUPE_RADIUS * 2, LOUPE_RADIUS); + ctx.arc(LOUPE_RADIUS, LOUPE_RADIUS, LOUPE_RADIUS, 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 + }), + x: PropTypes.number, + y: PropTypes.number, + data: PropTypes.instanceOf(Uint8ClampedArray) + }) +}; + +export default LoupeComponent; diff --git a/src/components/paint-editor/paint-editor.css b/src/components/paint-editor/paint-editor.css index 9e28b9f9..6e8614f0 100644 --- a/src/components/paint-editor/paint-editor.css +++ b/src/components/paint-editor/paint-editor.css @@ -143,6 +143,16 @@ $border-radius: 0.25rem; flex-direction: row-reverse; } +.color-picker-wrapper { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: hidden; +} + @media only screen and (max-width: $full-size-paint) { .editor-container { padding: calc(3 * $grid-unit) $grid-unit; diff --git a/src/components/paint-editor/paint-editor.jsx b/src/components/paint-editor/paint-editor.jsx index d68c3c2c..40dce34b 100644 --- a/src/components/paint-editor/paint-editor.jsx +++ b/src/components/paint-editor/paint-editor.jsx @@ -10,6 +10,7 @@ import PaperCanvas from '../../containers/paper-canvas.jsx'; import {shouldShowGroup, shouldShowUngroup} from '../../helper/group'; import {shouldShowBringForward, shouldShowSendBackward} from '../../helper/order'; +import Box from '../box/box.jsx'; import Button from '../button/button.jsx'; import ButtonGroup from '../button-group/button-group.jsx'; import BrushMode from '../../containers/brush-mode.jsx'; @@ -22,6 +23,7 @@ import InputGroup from '../input-group/input-group.jsx'; import Label from '../forms/label.jsx'; import LabeledIconButton from '../labeled-icon-button/labeled-icon-button.jsx'; import LineMode from '../../containers/line-mode.jsx'; +import Loupe from '../loupe/loupe.jsx'; import ModeToolsComponent from '../mode-tools/mode-tools.jsx'; import OvalMode from '../../containers/oval-mode.jsx'; import RectMode from '../../containers/rect-mode.jsx'; @@ -109,6 +111,7 @@ class PaintEditorComponent extends React.Component { } setCanvas (canvas) { this.setState({canvas: canvas}); + this.canvas = canvas; } render () { const redoDisabled = !this.props.canRedo(); @@ -368,6 +371,16 @@ class PaintEditorComponent extends React.Component { svgId={this.props.svgId} onUpdateSvg={this.props.onUpdateSvg} /> + {( + this.props.isEyeDropping && + this.props.colorInfo !== null && + !this.props.colorInfo.hideLoupe + ) ? ( + + + + ) : null + } {/* Zoom controls */} @@ -413,7 +426,9 @@ class PaintEditorComponent extends React.Component { PaintEditorComponent.propTypes = { canRedo: PropTypes.func.isRequired, canUndo: PropTypes.func.isRequired, + colorInfo: Loupe.propTypes.colorInfo, intl: intlShape, + isEyeDropping: PropTypes.bool, name: PropTypes.string, onCopyToClipboard: PropTypes.func.isRequired, onGroup: PropTypes.func.isRequired, @@ -436,4 +451,4 @@ PaintEditorComponent.propTypes = { svgId: PropTypes.string }; -export default injectIntl(PaintEditorComponent); +export default injectIntl(PaintEditorComponent, {withRef: true}); diff --git a/src/components/stroke-color-indicator.jsx b/src/components/stroke-color-indicator.jsx index 5759ddba..170582bb 100644 --- a/src/components/stroke-color-indicator.jsx +++ b/src/components/stroke-color-indicator.jsx @@ -1,10 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import Popover from 'react-popover'; - import {defineMessages, injectIntl, intlShape} from 'react-intl'; -import ColorPicker from './color-picker/color-picker.jsx'; + import ColorButton from './color-button/color-button.jsx'; +import ColorPicker from '../containers/color-picker.jsx'; import InputGroup from './input-group/input-group.jsx'; import Label from './forms/label.jsx'; diff --git a/src/containers/color-picker.jsx b/src/containers/color-picker.jsx new file mode 100644 index 00000000..1da7e898 --- /dev/null +++ b/src/containers/color-picker.jsx @@ -0,0 +1,132 @@ +import bindAll from 'lodash.bindall'; +import {connect} from 'react-redux'; +import parseColor from 'parse-color'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import {clearSelectedItems} from '../reducers/selected-items'; +import {activateEyeDropper} from '../reducers/eye-dropper'; +import {changeMode} from '../reducers/modes'; + +import ColorPickerComponent from '../components/color-picker/color-picker.jsx'; +import {MIXED} from '../helper/style-path'; +import Modes from '../lib/modes'; + +const colorStringToHsv = hexString => { + const hsv = parseColor(hexString).hsv; + // Hue comes out in [0, 360], limit to [0, 100] + hsv[0] = hsv[0] / 3.6; + // Black is parsed as {0, 0, 0}, but turn saturation up to 100 + // to make it easier to see slider values. + if (hsv[1] === 0 && hsv[2] === 0) { + hsv[1] = 100; + } + return hsv; +}; + +const hsvToHex = (h, s, v) => + // Scale hue back up to [0, 360] from [0, 100] + parseColor(`hsv(${3.6 * h}, ${s}, ${v})`).hex +; + +// Important! This component ignores new color props and cannot be updated +// This is to make the HSV <=> RGB conversion stable. Because of this, the +// component MUST be unmounted in order to change the props externally. +class ColorPicker extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleHueChange', + 'handleSaturationChange', + 'handleBrightnessChange', + 'handleTransparent', + 'handleActivateEyeDropper' + ]); + const isTransparent = this.props.color === null; + const isMixed = this.props.color === MIXED; + const hsv = isTransparent || isMixed ? + [50, 100, 100] : colorStringToHsv(props.color); + + this.state = { + hue: hsv[0], + saturation: hsv[1], + brightness: hsv[2] + }; + } + componentWillReceiveProps () { + // Just a reminder, new props do not update the hsv state + } + handleHueChange (hue) { + this.setState({hue: hue}); + this.handleColorChange(); + } + handleSaturationChange (saturation) { + this.setState({saturation: saturation}); + this.handleColorChange(); + } + handleBrightnessChange (brightness) { + this.setState({brightness: brightness}); + this.handleColorChange(); + } + handleColorChange () { + this.props.onChangeColor(hsvToHex( + this.state.hue, + this.state.saturation, + this.state.brightness + )); + } + handleTransparent () { + this.props.onChangeColor(null); + } + handleActivateEyeDropper () { + this.props.onActivateEyeDropper( + this.props.currentMode, + this.props.onChangeColor + ); + } + render () { + return ( + + ); + } +} + +ColorPicker.propTypes = { + color: PropTypes.string, + currentMode: PropTypes.string, + isEyeDropping: PropTypes.bool.isRequired, + onActivateEyeDropper: PropTypes.func.isRequired, + onChangeColor: PropTypes.func.isRequired +}; + +const mapStateToProps = state => ({ + currentMode: state.scratchPaint.mode, + isEyeDropping: state.scratchPaint.color.eyeDropper.active, +}); + +const mapDispatchToProps = dispatch => ({ + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, + onActivateEyeDropper: (currentMode, callback) => { + dispatch(changeMode(Modes.EYE_DROPPER)); + dispatch(activateEyeDropper(currentMode, callback)); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ColorPicker); diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx index 7fc5fe0b..bb500ea9 100644 --- a/src/containers/paint-editor.jsx +++ b/src/containers/paint-editor.jsx @@ -6,6 +6,7 @@ import {changeMode} from '../reducers/modes'; import {undo, redo, undoSnapshot} from '../reducers/undo'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; import {incrementPasteOffset, setClipboardItems} from '../reducers/clipboard'; +import {deactivateEyeDropper} from '../reducers/eye-dropper'; import {hideGuideLayers, showGuideLayers} from '../helper/layer'; import {performUndo, performRedo, performSnapshot, shouldShowUndo, shouldShowRedo} from '../helper/undo'; @@ -13,6 +14,7 @@ import {bringToFront, sendBackward, sendToBack, bringForward} from '../helper/or import {groupSelection, ungroupSelection} from '../helper/group'; import {clearSelection, getSelectedLeafItems, getSelectedRootItems} from '../helper/selection'; import {resetZoom, zoomOnSelection} from '../helper/view'; +import EyeDropperTool from '../helper/eye-dropper'; import Modes from '../lib/modes'; import {connect} from 'react-redux'; @@ -38,14 +40,37 @@ class PaintEditor extends React.Component { 'canRedo', 'canUndo', 'handleCopyToClipboard', - 'handlePasteFromClipboard' + 'handlePasteFromClipboard', + 'setPaintEditor', + 'onMouseDown', + 'startEyeDroppingLoop', + 'stopEyeDroppingLoop' ]); + this.state = { + colorInfo: null + }; } componentDidMount () { document.addEventListener('keydown', this.props.onKeyPress); } + shouldComponentUpdate (nextProps, nextState) { + return this.props.isEyeDropping !== nextProps.isEyeDropping || + this.state.colorInfo !== nextState.colorInfo || + this.props.clipboardItems !== nextProps.clipboardItems || + this.props.pasteOffset !== nextProps.pasteOffset || + this.props.selectedItems !== nextProps.selectedItems || + this.props.undoState !== nextProps.undoState; + } + componentDidUpdate (prevProps) { + if (this.props.isEyeDropping && !prevProps.isEyeDropping) { + this.startEyeDroppingLoop(); + } else if (!this.props.isEyeDropping && prevProps.isEyeDropping) { + this.stopEyeDroppingLoop(); + } + } componentWillUnmount () { document.removeEventListener('keydown', this.props.onKeyPress); + this.stopEyeDroppingLoop(); } handleUpdateSvg (skipSnapshot) { // Store the zoom/pan and restore it after snapshotting @@ -145,12 +170,58 @@ class PaintEditor extends React.Component { handleZoomReset () { resetZoom(); } + setPaintEditor (paintEditor) { + this.paintEditor = paintEditor; + } + onMouseDown () { + if (this.props.isEyeDropping) { + const colorString = this.eyeDropper.colorString; + const callback = this.props.changeColorToEyeDropper; + + this.props.onDeactivateEyeDropper(this.props.previousMode); + this.stopEyeDroppingLoop(); + if (!this.eyeDropper.hideLoupe) { + // If not hide loupe, that means the click is inside the canvas, + // so apply the new color + callback(colorString); + } + this.setState({colorInfo: null}); + } + } + startEyeDroppingLoop () { + const canvas = this.paintEditor.getWrappedInstance().canvas; + this.eyeDropper = new EyeDropperTool(canvas); + this.eyeDropper.activate(); + + // document listeners used to detect if a mouse is down outside of the + // canvas, and should therefore stop the eye dropper + document.addEventListener('mousedown', this.onMouseDown); + document.addEventListener('touchstart', this.onMouseDown); + + this.intervalId = setInterval(() => { + this.setState({ + colorInfo: this.eyeDropper.getColorInfo( + this.eyeDropper.pickX, + this.eyeDropper.pickY, + this.eyeDropper.hideLoupe + ) + }); + }, 30); + } + stopEyeDroppingLoop () { + clearInterval(this.intervalId); + document.removeEventListener('mousedown', this.onMouseDown); + document.removeEventListener('touchstart', this.onMouseDown); + } render () { return ( ({ - selectedItems: state.scratchPaint.selectedItems, - undoState: state.scratchPaint.undo, + changeColorToEyeDropper: state.scratchPaint.color.eyeDropper.callback, clipboardItems: state.scratchPaint.clipboard.items, - pasteOffset: state.scratchPaint.clipboard.pasteOffset + isEyeDropping: state.scratchPaint.color.eyeDropper.active, + pasteOffset: state.scratchPaint.clipboard.pasteOffset, + previousItems: state.scratchPaint.color.eyeDropper.previousItems, + previousMode: state.scratchPaint.color.eyeDropper.previousMode, + selectedItems: state.scratchPaint.selectedItems, + undoState: state.scratchPaint.undo }); const mapDispatchToProps = dispatch => ({ onKeyPress: event => { @@ -237,6 +317,11 @@ const mapDispatchToProps = dispatch => ({ }, incrementPasteOffset: () => { dispatch(incrementPasteOffset()); + }, + onDeactivateEyeDropper: previousMode => { + // deactivate the eye dropper, reset to previously selected mode + dispatch(deactivateEyeDropper()); + dispatch(changeMode(previousMode)); } }); diff --git a/src/helper/eye-dropper.js b/src/helper/eye-dropper.js new file mode 100644 index 00000000..64c9ba81 --- /dev/null +++ b/src/helper/eye-dropper.js @@ -0,0 +1,71 @@ +import paper from '@scratch/paper'; + +const PAPER_WIDTH = 864; +const PAPER_HEIGHT = 648; +const LOUPE_RADIUS = 20; +const CANVAS_SCALE = 1.8; + +class EyeDropperTool extends paper.Tool { + constructor (canvas) { + super(); + + this.onMouseDown = this.handleMouseDown; + this.onMouseMove = this.handleMouseMove; + + this.active = false; + this.canvas = canvas; + this.colorInfo = null; + this.rect = canvas.getBoundingClientRect(); + this.colorString = ''; + } + handleMouseMove (event) { + // Set the pickX/Y for the color picker loop to pick up + this.pickX = event.point.x * CANVAS_SCALE; + this.pickY = event.point.y * CANVAS_SCALE; + + // check if the x/y are outside of the canvas + this.hideLoupe = this.pickX > PAPER_WIDTH || + this.pickX < 0 || + this.pickY > PAPER_HEIGHT || + this.pickY < 0; + } + handleMouseDown () { + if (!this.hideLoupe) { + const colorInfo = this.getColorInfo(this.pickX, this.pickY, this.hideLoupe); + const r = colorInfo.color[0]; + const g = colorInfo.color[1]; + const b = colorInfo.color[2]; + + const componentToString = c => { + const hex = c.toString(16); + return hex.length === 1 ? `0${hex}` : hex; + }; + this.colorString = `#${componentToString(r)}${componentToString(g)}${componentToString(b)}`; + } + } + getColorInfo (x, y, hideLoupe) { + const c = this.canvas.getContext('2d'); + const colors = c.getImageData(x, y, 1, 1); + + return { + x: x, + y: y, + color: colors.data, + data: c.getImageData( + x - LOUPE_RADIUS, + y - LOUPE_RADIUS, + LOUPE_RADIUS * 2, + LOUPE_RADIUS * 2 + ).data, + hideLoupe: hideLoupe + }; + } +} + +export { + EyeDropperTool as default, + PAPER_HEIGHT, + PAPER_WIDTH, + LOUPE_RADIUS, + CANVAS_SCALE +}; diff --git a/src/lib/modes.js b/src/lib/modes.js index 21d90b1b..d639a9ff 100644 --- a/src/lib/modes.js +++ b/src/lib/modes.js @@ -2,6 +2,7 @@ import keyMirror from 'keymirror'; const Modes = keyMirror({ BRUSH: null, + EYE_DROPPER: null, ERASER: null, LINE: null, SELECT: null, diff --git a/src/reducers/color.js b/src/reducers/color.js index 016b17ce..e1412748 100644 --- a/src/reducers/color.js +++ b/src/reducers/color.js @@ -1,9 +1,11 @@ import {combineReducers} from 'redux'; +import eyeDropperReducer from './eye-dropper'; import fillColorReducer from './fill-color'; import strokeColorReducer from './stroke-color'; import strokeWidthReducer from './stroke-width'; export default combineReducers({ + eyeDropper: eyeDropperReducer, fillColor: fillColorReducer, strokeColor: strokeColorReducer, strokeWidth: strokeWidthReducer diff --git a/src/reducers/eye-dropper.js b/src/reducers/eye-dropper.js new file mode 100644 index 00000000..b5984f9d --- /dev/null +++ b/src/reducers/eye-dropper.js @@ -0,0 +1,55 @@ +const ACTIVATE_EYE_DROPPER = 'scratch-paint/eye-dropper/ACTIVATE_COLOR_PICKER'; +const DEACTIVATE_EYE_DROPPER = 'scratch-paint/eye-dropper/DEACTIVATE_COLOR_PICKER'; + +const initialState = { + active: false, + callback: () => {}, // this will either be `onChangeFillColor` or `onChangeOutlineColor` + previousMode: null // the previous mode that was active to go back to +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case ACTIVATE_EYE_DROPPER: + return Object.assign( + {}, + state, + { + active: true, + callback: action.callback, + previousMode: action.previousMode + } + ); + case DEACTIVATE_EYE_DROPPER: + return Object.assign( + {}, + state, + { + active: false, + callback: () => {}, + previousMode: null + } + ); + default: + return state; + } +}; + +const activateEyeDropper = function (currentMode, callback) { + return { + type: ACTIVATE_EYE_DROPPER, + callback: callback, + previousMode: currentMode + }; +}; +const deactivateEyeDropper = function () { + return { + type: DEACTIVATE_EYE_DROPPER + }; +}; + +export { + reducer as default, + activateEyeDropper, + deactivateEyeDropper +};