Merge pull request #59 from paulkaplan/color-picker

Color picker
This commit is contained in:
Paul Kaplan 2017-10-16 08:52:09 -04:00 committed by GitHub
commit fb11d5dd5e
15 changed files with 551 additions and 35 deletions

View file

@ -56,6 +56,7 @@
"minilog": "3.1.0", "minilog": "3.1.0",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"paper": "0.11.4", "paper": "0.11.4",
"parse-color": "1.0.0",
"postcss-import": "^10.0.0", "postcss-import": "^10.0.0",
"postcss-loader": "^2.0.5", "postcss-loader": "^2.0.5",
"postcss-simple-vars": "^4.0.0", "postcss-simple-vars": "^4.0.0",
@ -65,6 +66,7 @@
"react-dom": "16.0.0", "react-dom": "16.0.0",
"react-intl": "2.4.0", "react-intl": "2.4.0",
"react-intl-redux": "0.6.0", "react-intl-redux": "0.6.0",
"react-popover": "0.5.4",
"react-redux": "5.0.5", "react-redux": "5.0.5",
"react-test-renderer": "^16.0.0", "react-test-renderer": "^16.0.0",
"redux": "3.7.0", "redux": "3.7.0",

View file

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

View file

@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './color-button.css';
const ColorButtonComponent = props => (
<div
className={styles.colorButton}
onClick={props.onClick}
>
<div
className={styles.colorButtonSwatch}
style={{
background: props.color
}}
/>
<div className={styles.colorButtonArrow}></div>
</div>
);
ColorButtonComponent.propTypes = {
color: PropTypes.string,
onClick: PropTypes.func.isRequired
};
export default ColorButtonComponent;

View file

@ -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);
}

View file

@ -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 (
<div className={styles.colorPickerContainer}>
<div className={styles.row}>
<div className={styles.rowHeader}>
<span className={styles.labelName}>
<FormattedMessage
defaultMessage="Hue"
description="Label for the hue component in the color picker"
id="paint.paintEditor.hue"
/>
</span>
<span className={styles.labelReadout}>
{Math.round(this.state.hue)}
</span>
</div>
<div className={styles.rowSlider}>
<Slider
background={this._makeBackground('hue')}
value={this.state.hue}
onChange={this.handleHueChange}
/>
</div>
</div>
<div className={styles.row}>
<div className={styles.rowHeader}>
<span className={styles.labelName}>
<FormattedMessage
defaultMessage="Saturation"
description="Label for the saturation component in the color picker"
id="paint.paintEditor.saturation"
/>
</span>
<span className={styles.labelReadout}>
{Math.round(this.state.saturation)}
</span>
</div>
<div className={styles.rowSlider}>
<Slider
background={this._makeBackground('saturation')}
value={this.state.saturation}
onChange={this.handleSaturationChange}
/>
</div>
</div>
<div className={styles.row}>
<div className={styles.rowHeader}>
<span className={styles.labelName}>
<FormattedMessage
defaultMessage="Brightness"
description="Label for the brightness component in the color picker"
id="paint.paintEditor.brightness"
/>
</span>
<span className={styles.labelReadout}>
{Math.round(this.state.brightness)}
</span>
</div>
<div className={styles.rowSlider}>
<Slider
background={this._makeBackground('brightness')}
value={this.state.brightness}
onChange={this.handleBrightnessChange}
/>
</div>
</div>
<div className={styles.divider} />
<div className={styles.row}>
<div className={styles.swatches}>
<div
className={classNames({
[styles.swatch]: true,
[styles.activeSwatch]: this.props.color === null
})}
onClick={this.handleTransparent}
/>
</div>
</div>
</div>
);
}
}
ColorPickerComponent.propTypes = {
color: PropTypes.string,
onChangeColor: PropTypes.func.isRequired
};
export default ColorPickerComponent;

View file

@ -1,16 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Popover from 'react-popover';
import {defineMessages, injectIntl, intlShape} from 'react-intl'; import {defineMessages, injectIntl, intlShape} from 'react-intl';
import BufferedInputHOC from './forms/buffered-input-hoc.jsx';
import Label from './forms/label.jsx'; import Label from './forms/label.jsx';
import Input from './forms/input.jsx'; import ColorPicker from './color-picker.jsx';
import ColorButton from './color-button.jsx';
import {MIXED} from '../helper/style-path';
import styles from './paint-editor.css'; import styles from './paint-editor.css';
const BufferedInput = BufferedInputHOC(Input);
const messages = defineMessages({ const messages = defineMessages({
fill: { fill: {
id: 'paint.paintEditor.fill', id: 'paint.paintEditor.fill',
@ -18,23 +16,37 @@ const messages = defineMessages({
defaultMessage: 'Fill' defaultMessage: 'Fill'
} }
}); });
const FillColorIndicatorComponent = props => ( const FillColorIndicatorComponent = props => (
<div className={styles.inputGroup}> <div className={styles.inputGroup}>
<Popover
body={
<ColorPicker
color={props.fillColor}
onChangeColor={props.onChangeFillColor}
/>
}
isOpen={props.fillColorModalVisible}
preferPlace="below"
onOuterAction={props.onCloseFillColor}
>
<Label text={props.intl.formatMessage(messages.fill)}> <Label text={props.intl.formatMessage(messages.fill)}>
<BufferedInput <ColorButton
type="text" color={props.fillColor}
value={props.fillColor === MIXED ? 'mixed' : onClick={props.onOpenFillColor}
props.fillColor === null ? 'transparent' : props.fillColor} // @todo Don't use text
onSubmit={props.onChangeFillColor}
/> />
</Label> </Label>
</Popover>
</div> </div>
); );
FillColorIndicatorComponent.propTypes = { FillColorIndicatorComponent.propTypes = {
fillColor: PropTypes.string, fillColor: PropTypes.string,
fillColorModalVisible: PropTypes.bool.isRequired,
intl: intlShape, intl: intlShape,
onChangeFillColor: PropTypes.func.isRequired onChangeFillColor: PropTypes.func.isRequired,
onCloseFillColor: PropTypes.func.isRequired,
onOpenFillColor: PropTypes.func.isRequired
}; };
export default injectIntl(FillColorIndicatorComponent); export default injectIntl(FillColorIndicatorComponent);

View file

@ -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);
}

View file

@ -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 (
<div
className={styles.container}
ref={this.setBackground}
style={{
backgroundImage: this.props.background
}}
>
<div
className={styles.handle}
style={{
left: `${handleOffset}px`
}}
onMouseDown={this.handleMouseDown}
/>
</div>
);
}
}
SliderComponent.propTypes = {
background: PropTypes.string,
onChange: PropTypes.func.isRequired,
value: PropTypes.number.isRequired
};
SliderComponent.defaultProps = {
background: 'yellow'
};
export default SliderComponent;

View file

@ -1,16 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Popover from 'react-popover';
import {defineMessages, injectIntl, intlShape} from 'react-intl'; import {defineMessages, injectIntl, intlShape} from 'react-intl';
import BufferedInputHOC from './forms/buffered-input-hoc.jsx';
import Label from './forms/label.jsx'; import Label from './forms/label.jsx';
import Input from './forms/input.jsx'; import ColorPicker from './color-picker.jsx';
import ColorButton from './color-button.jsx';
import {MIXED} from '../helper/style-path';
import styles from './paint-editor.css'; import styles from './paint-editor.css';
const BufferedInput = BufferedInputHOC(Input);
const messages = defineMessages({ const messages = defineMessages({
stroke: { stroke: {
id: 'paint.paintEditor.stroke', id: 'paint.paintEditor.stroke',
@ -18,24 +16,37 @@ const messages = defineMessages({
defaultMessage: 'Outline' defaultMessage: 'Outline'
} }
}); });
const StrokeColorIndicatorComponent = props => ( const StrokeColorIndicatorComponent = props => (
<div className={styles.inputGroup}> <div className={styles.inputGroup}>
<Popover
body={
<ColorPicker
color={props.strokeColor}
onChangeColor={props.onChangeStrokeColor}
/>
}
isOpen={props.strokeColorModalVisible}
preferPlace="below"
onOuterAction={props.onCloseStrokeColor}
>
<Label text={props.intl.formatMessage(messages.stroke)}> <Label text={props.intl.formatMessage(messages.stroke)}>
<BufferedInput <ColorButton
type="text" color={props.strokeColor}
// @todo Don't use text onClick={props.onOpenStrokeColor}
value={props.strokeColor === MIXED ? 'mixed' :
props.strokeColor === null ? 'transparent' : props.strokeColor}
onSubmit={props.onChangeStrokeColor}
/> />
</Label> </Label>
</Popover>
</div> </div>
); );
StrokeColorIndicatorComponent.propTypes = { StrokeColorIndicatorComponent.propTypes = {
intl: intlShape, intl: intlShape,
onChangeStrokeColor: PropTypes.func.isRequired, 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); export default injectIntl(StrokeColorIndicatorComponent);

View file

@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import bindAll from 'lodash.bindall'; import bindAll from 'lodash.bindall';
import {changeFillColor} from '../reducers/fill-color'; import {changeFillColor} from '../reducers/fill-color';
import {openFillColor, closeFillColor} from '../reducers/modals';
import FillColorIndicatorComponent from '../components/fill-color-indicator.jsx'; import FillColorIndicatorComponent from '../components/fill-color-indicator.jsx';
import {applyFillColorToSelection} from '../helper/style-path'; import {applyFillColorToSelection} from '../helper/style-path';
@ -20,7 +22,7 @@ class FillColorIndicator extends React.Component {
render () { render () {
return ( return (
<FillColorIndicatorComponent <FillColorIndicatorComponent
fillColor={this.props.fillColor} {...this.props}
onChangeFillColor={this.handleChangeFillColor} onChangeFillColor={this.handleChangeFillColor}
/> />
); );
@ -28,11 +30,19 @@ class FillColorIndicator extends React.Component {
} }
const mapStateToProps = state => ({ const mapStateToProps = state => ({
fillColor: state.scratchPaint.color.fillColor fillColor: state.scratchPaint.color.fillColor,
fillColorModalVisible: state.scratchPaint.modals.fillColor
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
onChangeFillColor: fillColor => { onChangeFillColor: fillColor => {
dispatch(changeFillColor(fillColor)); dispatch(changeFillColor(fillColor));
},
onOpenFillColor: () => {
dispatch(openFillColor());
},
onCloseFillColor: () => {
dispatch(closeFillColor());
} }
}); });

View file

@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import bindAll from 'lodash.bindall'; import bindAll from 'lodash.bindall';
import {changeStrokeColor} from '../reducers/stroke-color'; import {changeStrokeColor} from '../reducers/stroke-color';
import {openStrokeColor, closeStrokeColor} from '../reducers/modals';
import StrokeColorIndicatorComponent from '../components/stroke-color-indicator.jsx'; import StrokeColorIndicatorComponent from '../components/stroke-color-indicator.jsx';
import {applyStrokeColorToSelection} from '../helper/style-path'; import {applyStrokeColorToSelection} from '../helper/style-path';
@ -20,7 +22,7 @@ class StrokeColorIndicator extends React.Component {
render () { render () {
return ( return (
<StrokeColorIndicatorComponent <StrokeColorIndicatorComponent
strokeColor={this.props.strokeColor} {...this.props}
onChangeStrokeColor={this.handleChangeStrokeColor} onChangeStrokeColor={this.handleChangeStrokeColor}
/> />
); );
@ -28,11 +30,19 @@ class StrokeColorIndicator extends React.Component {
} }
const mapStateToProps = state => ({ const mapStateToProps = state => ({
strokeColor: state.scratchPaint.color.strokeColor strokeColor: state.scratchPaint.color.strokeColor,
strokeColorModalVisible: state.scratchPaint.modals.strokeColor
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
onChangeStrokeColor: strokeColor => { onChangeStrokeColor: strokeColor => {
dispatch(changeStrokeColor(strokeColor)); dispatch(changeStrokeColor(strokeColor));
},
onOpenStrokeColor: () => {
dispatch(openStrokeColor());
},
onCloseStrokeColor: () => {
dispatch(closeStrokeColor());
} }
}); });

View file

@ -11,7 +11,7 @@ const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState; if (typeof state === 'undefined') state = initialState;
switch (action.type) { switch (action.type) {
case CHANGE_FILL_COLOR: 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}`); log.warn(`Invalid hex color code: ${action.fillColor}`);
return state; return state;
} }

66
src/reducers/modals.js Normal file
View file

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

View file

@ -4,6 +4,7 @@ import brushModeReducer from './brush-mode';
import eraserModeReducer from './eraser-mode'; import eraserModeReducer from './eraser-mode';
import colorReducer from './color'; import colorReducer from './color';
import hoverReducer from './hover'; import hoverReducer from './hover';
import modalsReducer from './modals';
import selectedItemReducer from './selected-items'; import selectedItemReducer from './selected-items';
import undoReducer from './undo'; import undoReducer from './undo';
@ -13,6 +14,7 @@ export default combineReducers({
eraserMode: eraserModeReducer, eraserMode: eraserModeReducer,
color: colorReducer, color: colorReducer,
hoveredItemId: hoverReducer, hoveredItemId: hoverReducer,
modals: modalsReducer,
selectedItems: selectedItemReducer, selectedItems: selectedItemReducer,
undo: undoReducer undo: undoReducer
}); });

View file

@ -11,7 +11,7 @@ const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState; if (typeof state === 'undefined') state = initialState;
switch (action.type) { switch (action.type) {
case CHANGE_STROKE_COLOR: 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}`); log.warn(`Invalid hex color code: ${action.fillColor}`);
return state; return state;
} }