diff --git a/package.json b/package.json index 5985c099..65add8dd 100644 --- a/package.json +++ b/package.json @@ -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", 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..49b79881 --- /dev/null +++ b/src/components/color-picker.css @@ -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); +} diff --git a/src/components/color-picker.jsx b/src/components/color-picker.jsx new file mode 100644 index 00000000..e3af2d56 --- /dev/null +++ b/src/components/color-picker.jsx @@ -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 ( +
+
+
+ + + + + {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..6f9af076 100644 --- a/src/reducers/fill-color.js +++ b/src/reducers/fill-color.js @@ -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; } 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; }