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 @@
+
+
\ 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
+};