Initial prototype of color picker

This commit is contained in:
Paul Kaplan 2017-10-11 09:05:34 -04:00
parent 8ffb95c02d
commit 3eaf8047aa
15 changed files with 550 additions and 36 deletions

View file

@ -55,6 +55,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",
@ -64,6 +65,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",

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 */
/* @TODO need to fix tip border issue */
: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,194 @@
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 = [];
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 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 => (
<div className={styles.inputGroup}>
<Label text={props.intl.formatMessage(messages.fill)}>
<BufferedInput
type="text"
value={props.fillColor === MIXED ? 'mixed' :
props.fillColor === null ? 'transparent' : props.fillColor} // @todo Don't use text
onSubmit={props.onChangeFillColor}
/>
</Label>
<Popover
body={
<ColorPicker
color={props.fillColor}
onChangeColor={props.onChangeFillColor}
/>
}
isOpen={props.fillColorModalVisible}
preferPlace="below"
onOuterAction={props.onCloseFillColor}
>
<Label text={props.intl.formatMessage(messages.fill)}>
<ColorButton
color={props.fillColor}
onClick={props.onOpenFillColor}
/>
</Label>
</Popover>
</div>
);
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);

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 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 => (
<div className={styles.inputGroup}>
<Label text={props.intl.formatMessage(messages.stroke)}>
<BufferedInput
type="text"
// @todo Don't use text
value={props.strokeColor === MIXED ? 'mixed' :
props.strokeColor === null ? 'transparent' : props.strokeColor}
onSubmit={props.onChangeStrokeColor}
/>
</Label>
<Popover
body={
<ColorPicker
color={props.strokeColor}
onChangeColor={props.onChangeStrokeColor}
/>
}
isOpen={props.strokeColorModalVisible}
preferPlace="below"
onOuterAction={props.onCloseStrokeColor}
>
<Label text={props.intl.formatMessage(messages.stroke)}>
<ColorButton
color={props.strokeColor}
onClick={props.onOpenStrokeColor}
/>
</Label>
</Popover>
</div>
);
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);

View file

@ -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 (
<FillColorIndicatorComponent
fillColor={this.props.fillColor}
{...this.props}
onChangeFillColor={this.handleChangeFillColor}
/>
);
@ -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());
}
});

View file

@ -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 (
<StrokeColorIndicatorComponent
strokeColor={this.props.strokeColor}
{...this.props}
onChangeStrokeColor={this.handleChangeStrokeColor}
/>
);
@ -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());
}
});

View file

@ -3,7 +3,7 @@ import {CHANGE_SELECTED_ITEMS} from './selected-items';
import {getColorsFromSelection} from '../helper/style-path';
const CHANGE_FILL_COLOR = 'scratch-paint/fill-color/CHANGE_FILL_COLOR';
const initialState = '#000';
const initialState = '#aa0551';
// Matches hex colors
const regExp = /^#([0-9a-f]{3}){1,2}$/i;
@ -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;
}

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

View file

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