diff --git a/package.json b/package.json
index 5b6084db..05d81460 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,7 @@
"minilog": "3.1.0",
"mkdirp": "^0.5.1",
"paper": "0.11.4",
+ "parse-color": "1.0.0",
"postcss-import": "^10.0.0",
"postcss-loader": "^2.0.5",
"postcss-simple-vars": "^4.0.0",
@@ -65,6 +66,7 @@
"react-dom": "16.0.0",
"react-intl": "2.4.0",
"react-intl-redux": "0.6.0",
+ "react-popover": "0.5.4",
"react-redux": "5.0.5",
"react-test-renderer": "^16.0.0",
"redux": "3.7.0",
diff --git a/src/components/color-button.css b/src/components/color-button.css
new file mode 100644
index 00000000..1fd2e0c0
--- /dev/null
+++ b/src/components/color-button.css
@@ -0,0 +1,32 @@
+.color-button {
+ height: 2rem;
+ width: 3rem;
+ display: flex;
+}
+
+.color-button-swatch {
+ display: flex;
+ flex-basis: 2rem;
+ flex-shrink: 0;
+ height: 100%;
+ border: 1px solid rgba(0, 0, 0, 0.25);
+ border-top-left-radius: 4px;
+ border-bottom-left-radius: 4px;
+}
+
+.color-button-arrow {
+ display: flex;
+ flex-basis: 1rem;
+ flex-shrink: 0;
+ height: 100%;
+
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border: 1px solid rgba(0, 0, 0, 0.25);
+ border-left: none;
+
+ align-items: center;
+ justify-content: center;
+ color: #575e75;
+ font-size: 0.75rem;
+}
diff --git a/src/components/color-button.jsx b/src/components/color-button.jsx
new file mode 100644
index 00000000..9a7087e5
--- /dev/null
+++ b/src/components/color-button.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import styles from './color-button.css';
+
+const ColorButtonComponent = props => (
+
+);
+
+ColorButtonComponent.propTypes = {
+ color: PropTypes.string,
+ onClick: PropTypes.func.isRequired
+};
+
+export default ColorButtonComponent;
diff --git a/src/components/color-picker.css b/src/components/color-picker.css
new file mode 100644
index 00000000..5ef9b537
--- /dev/null
+++ b/src/components/color-picker.css
@@ -0,0 +1,50 @@
+/* Popover styles */
+:global(.Popover-body) {
+ background: white;
+ border: 1px solid #ddd;
+ padding: 4px;
+ border-radius: 4px;
+ padding: 4px;
+ box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, .3);
+}
+
+:global(.Popover-tipShape) {
+ fill: white;
+ stroke: #ddd;
+}
+
+.row-header {
+ font-family: "Helvetica Neue", Helvetica, sans-serif;
+ font-size: 0.65rem;
+ color: #575E75;
+ margin: 8px;
+}
+
+.label-readout {
+ margin-left: 10px;
+}
+
+.label-name {
+ font-weight: bold;
+}
+
+.divider {
+ border-top: 1px solid #ddd;
+ margin: 8px;
+}
+
+.swatches {
+ margin: 8px;
+}
+
+.swatch {
+ width: 1rem;
+ height: 1rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+}
+
+.active-swatch {
+ border: 1px solid #4C97FF;
+ box-shadow: 0px 0px 0px 3px hsla(215, 100%, 65%, 0.2);
+}
diff --git a/src/components/color-picker.jsx b/src/components/color-picker.jsx
new file mode 100644
index 00000000..4e1217a0
--- /dev/null
+++ b/src/components/color-picker.jsx
@@ -0,0 +1,196 @@
+import React from 'react';
+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';
+
+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 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
+ // color stops depending on the slider.
+ for (let n = 100; n >= 0; n -= 10) {
+ switch (channel) {
+ case 'hue':
+ stops.push(hsvToHex(n, this.state.saturation, this.state.brightness));
+ break;
+ case 'saturation':
+ stops.push(hsvToHex(this.state.hue, n, this.state.brightness));
+ break;
+ case 'brightness':
+ stops.push(hsvToHex(this.state.hue, this.state.saturation, n));
+ break;
+ default:
+ throw new Error(`Unknown channel for color sliders: ${channel}`);
+ }
+ }
+ return `linear-gradient(to left, ${stops.join(',')})`;
+ }
+
+ render () {
+ return (
+
+
+
+
+
+
+
+ {Math.round(this.state.hue)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {Math.round(this.state.saturation)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {Math.round(this.state.brightness)}
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+
+ColorPickerComponent.propTypes = {
+ color: PropTypes.string,
+ onChangeColor: PropTypes.func.isRequired
+};
+
+export default ColorPickerComponent;
diff --git a/src/components/fill-color-indicator.jsx b/src/components/fill-color-indicator.jsx
index 5d2f65d2..3450890d 100644
--- a/src/components/fill-color-indicator.jsx
+++ b/src/components/fill-color-indicator.jsx
@@ -1,16 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
+import Popover from 'react-popover';
import {defineMessages, injectIntl, intlShape} from 'react-intl';
-import BufferedInputHOC from './forms/buffered-input-hoc.jsx';
import Label from './forms/label.jsx';
-import Input from './forms/input.jsx';
-
-import {MIXED} from '../helper/style-path';
+import ColorPicker from './color-picker.jsx';
+import ColorButton from './color-button.jsx';
import styles from './paint-editor.css';
-const BufferedInput = BufferedInputHOC(Input);
const messages = defineMessages({
fill: {
id: 'paint.paintEditor.fill',
@@ -18,23 +16,37 @@ const messages = defineMessages({
defaultMessage: 'Fill'
}
});
+
const FillColorIndicatorComponent = props => (
-
+
+ }
+ isOpen={props.fillColorModalVisible}
+ preferPlace="below"
+ onOuterAction={props.onCloseFillColor}
+ >
+
+
);
FillColorIndicatorComponent.propTypes = {
fillColor: PropTypes.string,
+ fillColorModalVisible: PropTypes.bool.isRequired,
intl: intlShape,
- onChangeFillColor: PropTypes.func.isRequired
+ onChangeFillColor: PropTypes.func.isRequired,
+ onCloseFillColor: PropTypes.func.isRequired,
+ onOpenFillColor: PropTypes.func.isRequired
};
export default injectIntl(FillColorIndicatorComponent);
diff --git a/src/components/forms/slider.css b/src/components/forms/slider.css
new file mode 100644
index 00000000..4374e16a
--- /dev/null
+++ b/src/components/forms/slider.css
@@ -0,0 +1,20 @@
+.container {
+ margin: 8px;
+ height: 22px;
+ width: 150px;
+ position: relative;
+ outline: none;
+ border-radius: 11px;
+ margin-bottom: 20px;
+}
+
+.handle {
+ left: 100px;
+ width: 26px;
+ height: 26px;
+ margin-top: -2px;
+ position: absolute;
+ background-color: white;
+ border-radius: 100%;
+ box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.15);
+}
diff --git a/src/components/forms/slider.jsx b/src/components/forms/slider.jsx
new file mode 100644
index 00000000..d49ba27c
--- /dev/null
+++ b/src/components/forms/slider.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import bindAll from 'lodash.bindall';
+
+import styles from './slider.css';
+
+const CONTAINER_WIDTH = 150;
+const HANDLE_WIDTH = 26;
+
+class SliderComponent extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'handleMouseDown',
+ 'handleMouseUp',
+ 'handleMouseMove',
+ 'setBackground'
+ ]);
+ }
+
+ handleMouseDown () {
+ document.addEventListener('mouseup', this.handleMouseUp);
+ document.addEventListener('mousemove', this.handleMouseMove);
+ }
+
+ handleMouseUp () {
+ document.removeEventListener('mouseup', this.handleMouseUp);
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ }
+
+ handleMouseMove (event) {
+ event.preventDefault();
+ const backgroundBBox = this.background.getBoundingClientRect();
+ const x = event.clientX - backgroundBBox.left;
+ this.props.onChange(Math.max(1, Math.min(99, 100 * x / backgroundBBox.width)));
+ }
+
+ setBackground (ref) {
+ this.background = ref;
+ }
+
+ render () {
+ const halfHandleWidth = HANDLE_WIDTH / 2;
+ const pixelMin = halfHandleWidth;
+ const pixelMax = CONTAINER_WIDTH - halfHandleWidth;
+ const handleOffset = pixelMin +
+ ((pixelMax - pixelMin) * (this.props.value / 100)) -
+ halfHandleWidth;
+ return (
+
+ );
+ }
+}
+
+SliderComponent.propTypes = {
+ background: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.number.isRequired
+};
+
+SliderComponent.defaultProps = {
+ background: 'yellow'
+};
+
+export default SliderComponent;
diff --git a/src/components/stroke-color-indicator.jsx b/src/components/stroke-color-indicator.jsx
index dcb7db5a..6911420b 100644
--- a/src/components/stroke-color-indicator.jsx
+++ b/src/components/stroke-color-indicator.jsx
@@ -1,16 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
+import Popover from 'react-popover';
import {defineMessages, injectIntl, intlShape} from 'react-intl';
-import BufferedInputHOC from './forms/buffered-input-hoc.jsx';
import Label from './forms/label.jsx';
-import Input from './forms/input.jsx';
-
-import {MIXED} from '../helper/style-path';
+import ColorPicker from './color-picker.jsx';
+import ColorButton from './color-button.jsx';
import styles from './paint-editor.css';
-const BufferedInput = BufferedInputHOC(Input);
const messages = defineMessages({
stroke: {
id: 'paint.paintEditor.stroke',
@@ -18,24 +16,37 @@ const messages = defineMessages({
defaultMessage: 'Outline'
}
});
+
const StrokeColorIndicatorComponent = props => (
-
+
+ }
+ isOpen={props.strokeColorModalVisible}
+ preferPlace="below"
+ onOuterAction={props.onCloseStrokeColor}
+ >
+
+
);
StrokeColorIndicatorComponent.propTypes = {
intl: intlShape,
onChangeStrokeColor: PropTypes.func.isRequired,
- strokeColor: PropTypes.string
+ onCloseStrokeColor: PropTypes.func.isRequired,
+ onOpenStrokeColor: PropTypes.func.isRequired,
+ strokeColor: PropTypes.string,
+ strokeColorModalVisible: PropTypes.bool.isRequired
};
export default injectIntl(StrokeColorIndicatorComponent);
diff --git a/src/containers/fill-color-indicator.jsx b/src/containers/fill-color-indicator.jsx
index 904071a8..ab7a5d8a 100644
--- a/src/containers/fill-color-indicator.jsx
+++ b/src/containers/fill-color-indicator.jsx
@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import React from 'react';
import bindAll from 'lodash.bindall';
import {changeFillColor} from '../reducers/fill-color';
+import {openFillColor, closeFillColor} from '../reducers/modals';
+
import FillColorIndicatorComponent from '../components/fill-color-indicator.jsx';
import {applyFillColorToSelection} from '../helper/style-path';
@@ -20,7 +22,7 @@ class FillColorIndicator extends React.Component {
render () {
return (
);
@@ -28,11 +30,19 @@ class FillColorIndicator extends React.Component {
}
const mapStateToProps = state => ({
- fillColor: state.scratchPaint.color.fillColor
+ fillColor: state.scratchPaint.color.fillColor,
+ fillColorModalVisible: state.scratchPaint.modals.fillColor
});
+
const mapDispatchToProps = dispatch => ({
onChangeFillColor: fillColor => {
dispatch(changeFillColor(fillColor));
+ },
+ onOpenFillColor: () => {
+ dispatch(openFillColor());
+ },
+ onCloseFillColor: () => {
+ dispatch(closeFillColor());
}
});
diff --git a/src/containers/stroke-color-indicator.jsx b/src/containers/stroke-color-indicator.jsx
index 74619b76..88d681c4 100644
--- a/src/containers/stroke-color-indicator.jsx
+++ b/src/containers/stroke-color-indicator.jsx
@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import React from 'react';
import bindAll from 'lodash.bindall';
import {changeStrokeColor} from '../reducers/stroke-color';
+import {openStrokeColor, closeStrokeColor} from '../reducers/modals';
+
import StrokeColorIndicatorComponent from '../components/stroke-color-indicator.jsx';
import {applyStrokeColorToSelection} from '../helper/style-path';
@@ -20,7 +22,7 @@ class StrokeColorIndicator extends React.Component {
render () {
return (
);
@@ -28,11 +30,19 @@ class StrokeColorIndicator extends React.Component {
}
const mapStateToProps = state => ({
- strokeColor: state.scratchPaint.color.strokeColor
+ strokeColor: state.scratchPaint.color.strokeColor,
+ strokeColorModalVisible: state.scratchPaint.modals.strokeColor
});
+
const mapDispatchToProps = dispatch => ({
onChangeStrokeColor: strokeColor => {
dispatch(changeStrokeColor(strokeColor));
+ },
+ onOpenStrokeColor: () => {
+ dispatch(openStrokeColor());
+ },
+ onCloseStrokeColor: () => {
+ dispatch(closeStrokeColor());
}
});
diff --git a/src/reducers/fill-color.js b/src/reducers/fill-color.js
index fafa3016..fb7d7642 100644
--- a/src/reducers/fill-color.js
+++ b/src/reducers/fill-color.js
@@ -11,7 +11,7 @@ const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState;
switch (action.type) {
case CHANGE_FILL_COLOR:
- if (!regExp.test(action.fillColor)) {
+ if (!regExp.test(action.fillColor) && action.fillColor !== null) {
log.warn(`Invalid hex color code: ${action.fillColor}`);
return state;
}
diff --git a/src/reducers/modals.js b/src/reducers/modals.js
new file mode 100644
index 00000000..1dda3a5d
--- /dev/null
+++ b/src/reducers/modals.js
@@ -0,0 +1,66 @@
+const OPEN_MODAL = 'scratch-paint/modals/OPEN_MODAL';
+const CLOSE_MODAL = 'scratch-paint/modals/CLOSE_MODAL';
+
+const MODAL_FILL_COLOR = 'fillColor';
+const MODAL_STROKE_COLOR = 'strokeColor';
+
+const initialState = {
+ [MODAL_FILL_COLOR]: false,
+ [MODAL_STROKE_COLOR]: false
+};
+
+const reducer = function (state, action) {
+ if (typeof state === 'undefined') state = initialState;
+ switch (action.type) {
+ case OPEN_MODAL:
+ return Object.assign({}, initialState, {
+ [action.modal]: true
+ });
+ case CLOSE_MODAL:
+ return Object.assign({}, initialState, {
+ [action.modal]: false
+ });
+ default:
+ return state;
+ }
+};
+
+const openModal = function (modal) {
+ return {
+ type: OPEN_MODAL,
+ modal: modal
+ };
+};
+
+const closeModal = function (modal) {
+ return {
+ type: CLOSE_MODAL,
+ modal: modal
+ };
+};
+
+// Action creators ==================================
+
+const openFillColor = function () {
+ return openModal(MODAL_FILL_COLOR);
+};
+
+const openStrokeColor = function () {
+ return openModal(MODAL_STROKE_COLOR);
+};
+
+const closeFillColor = function () {
+ return closeModal(MODAL_FILL_COLOR);
+};
+
+const closeStrokeColor = function () {
+ return closeModal(MODAL_STROKE_COLOR);
+};
+
+export {
+ reducer as default,
+ openFillColor,
+ openStrokeColor,
+ closeFillColor,
+ closeStrokeColor
+};
diff --git a/src/reducers/scratch-paint-reducer.js b/src/reducers/scratch-paint-reducer.js
index 12fb6d7f..ff0fcff3 100644
--- a/src/reducers/scratch-paint-reducer.js
+++ b/src/reducers/scratch-paint-reducer.js
@@ -4,6 +4,7 @@ import brushModeReducer from './brush-mode';
import eraserModeReducer from './eraser-mode';
import colorReducer from './color';
import hoverReducer from './hover';
+import modalsReducer from './modals';
import selectedItemReducer from './selected-items';
import undoReducer from './undo';
@@ -13,6 +14,7 @@ export default combineReducers({
eraserMode: eraserModeReducer,
color: colorReducer,
hoveredItemId: hoverReducer,
+ modals: modalsReducer,
selectedItems: selectedItemReducer,
undo: undoReducer
});
diff --git a/src/reducers/stroke-color.js b/src/reducers/stroke-color.js
index a7ecba9e..7bee304c 100644
--- a/src/reducers/stroke-color.js
+++ b/src/reducers/stroke-color.js
@@ -11,7 +11,7 @@ const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState;
switch (action.type) {
case CHANGE_STROKE_COLOR:
- if (!regExp.test(action.strokeColor)) {
+ if (!regExp.test(action.strokeColor) && action.strokeColor !== null) {
log.warn(`Invalid hex color code: ${action.fillColor}`);
return state;
}