diff --git a/src/components/paint-editor/paint-editor.jsx b/src/components/paint-editor/paint-editor.jsx index 413a2449..f69de1ed 100644 --- a/src/components/paint-editor/paint-editor.jsx +++ b/src/components/paint-editor/paint-editor.jsx @@ -33,7 +33,7 @@ import ReshapeMode from '../../containers/reshape-mode.jsx'; import SelectMode from '../../containers/select-mode.jsx'; import StrokeColorIndicatorComponent from '../../containers/stroke-color-indicator.jsx'; import StrokeWidthIndicatorComponent from '../../containers/stroke-width-indicator.jsx'; -import TextModeComponent from '../text-mode/text-mode.jsx'; +import TextMode from '../../containers/text-mode.jsx'; import layout from '../../lib/layout-constants'; import styles from './paint-editor.css'; @@ -350,11 +350,13 @@ const PaintEditorComponent = props => { - {/* Text mode will go here */} - - + { - {/* text tool, coming soon */} - ) : null} diff --git a/src/components/text-mode/text-mode.jsx b/src/components/text-mode/text-mode.jsx index 1ab29d09..747f68b9 100644 --- a/src/components/text-mode/text-mode.jsx +++ b/src/components/text-mode/text-mode.jsx @@ -1,27 +1,25 @@ import React from 'react'; - -import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx'; +import PropTypes from 'prop-types'; import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; import textIcon from './text.svg'; -const TextModeComponent = () => ( - - - +const TextModeComponent = props => ( + ); +TextModeComponent.propTypes = { + isSelected: PropTypes.bool.isRequired, + onMouseDown: PropTypes.func.isRequired +}; + export default TextModeComponent; diff --git a/src/containers/text-mode.jsx b/src/containers/text-mode.jsx new file mode 100644 index 00000000..257c465f --- /dev/null +++ b/src/containers/text-mode.jsx @@ -0,0 +1,130 @@ +import paper from '@scratch/paper'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; +import bindAll from 'lodash.bindall'; +import Modes from '../lib/modes'; +import {MIXED} from '../helper/style-path'; + +import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-color'; +import {changeStrokeColor} from '../reducers/stroke-color'; +import {changeMode} from '../reducers/modes'; +import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; + +import {clearSelection, getSelectedLeafItems} from '../helper/selection'; +import TextTool from '../helper/tools/text-tool'; +import TextModeComponent from '../components/text-mode/text-mode.jsx'; + +class TextMode extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'activateTool', + 'deactivateTool' + ]); + } + componentDidMount () { + if (this.props.isTextModeActive) { + this.activateTool(this.props); + } + } + componentWillReceiveProps (nextProps) { + if (this.tool && nextProps.colorState !== this.props.colorState) { + this.tool.setColorState(nextProps.colorState); + } + if (this.tool && nextProps.selectedItems !== this.props.selectedItems) { + this.tool.onSelectionChanged(nextProps.selectedItems); + } + + if (nextProps.isTextModeActive && !this.props.isTextModeActive) { + this.activateTool(); + } else if (!nextProps.isTextModeActive && this.props.isTextModeActive) { + this.deactivateTool(); + } + } + shouldComponentUpdate (nextProps) { + return nextProps.isTextModeActive !== this.props.isTextModeActive; + } + activateTool () { + clearSelection(this.props.clearSelectedItems); + // If fill and stroke color are both mixed/transparent/absent, set fill to default and stroke to transparent. + // If exactly one of fill or stroke color is set, set the other one to transparent. + // This way the tool won't draw an invisible state, or be unclear about what will be drawn. + const {fillColor, strokeColor, strokeWidth} = this.props.colorState; + const fillColorPresent = fillColor !== MIXED && fillColor !== null; + const strokeColorPresent = + strokeColor !== MIXED && strokeColor !== null && strokeWidth !== null && strokeWidth !== 0; + if (!fillColorPresent && !strokeColorPresent) { + this.props.onChangeFillColor(DEFAULT_COLOR); + this.props.onChangeStrokeColor(null); + } else if (!fillColorPresent && strokeColorPresent) { + this.props.onChangeFillColor(null); + } else if (fillColorPresent && !strokeColorPresent) { + this.props.onChangeStrokeColor(null); + } + this.tool = new TextTool( + this.props.setSelectedItems, + this.props.clearSelectedItems, + this.props.onUpdateSvg + ); + this.tool.setColorState(this.props.colorState); + this.tool.activate(); + } + deactivateTool () { + this.tool.deactivateTool(); + this.tool.remove(); + this.tool = null; + } + render () { + return ( + + ); + } +} + +TextMode.propTypes = { + clearSelectedItems: PropTypes.func.isRequired, + colorState: PropTypes.shape({ + fillColor: PropTypes.string, + strokeColor: PropTypes.string, + strokeWidth: PropTypes.number + }).isRequired, + handleMouseDown: PropTypes.func.isRequired, + isTextModeActive: PropTypes.bool.isRequired, + onChangeFillColor: PropTypes.func.isRequired, + onChangeStrokeColor: PropTypes.func.isRequired, + onUpdateSvg: PropTypes.func.isRequired, + selectedItems: PropTypes.arrayOf(PropTypes.instanceOf(paper.Item)), + setSelectedItems: PropTypes.func.isRequired +}; + +const mapStateToProps = state => ({ + colorState: state.scratchPaint.color, + isTextModeActive: state.scratchPaint.mode === Modes.TEXT, + selectedItems: state.scratchPaint.selectedItems +}); +const mapDispatchToProps = dispatch => ({ + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, + setSelectedItems: () => { + dispatch(setSelectedItems(getSelectedLeafItems())); + }, + handleMouseDown: () => { + dispatch(changeMode(Modes.TEXT)); + }, + onChangeFillColor: fillColor => { + dispatch(changeFillColor(fillColor)); + }, + onChangeStrokeColor: strokeColor => { + dispatch(changeStrokeColor(strokeColor)); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(TextMode); diff --git a/src/helper/tools/text-tool.js b/src/helper/tools/text-tool.js new file mode 100644 index 00000000..73a1224e --- /dev/null +++ b/src/helper/tools/text-tool.js @@ -0,0 +1,134 @@ +import paper from '@scratch/paper'; +import Modes from '../../lib/modes'; +import {styleShape} from '../style-path'; +import {clearSelection} from '../selection'; +import BoundingBoxTool from '../selection-tools/bounding-box-tool'; +import NudgeTool from '../selection-tools/nudge-tool'; + +/** + * Tool for adding text. Text elements have limited editability; they can't be reshaped, + * drawn on or erased. This way they can preserve their ability to have the text edited. + */ +class TextTool extends paper.Tool { + static get TOLERANCE () { + return 6; + } + /** + * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state + * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state + * @param {!function} onUpdateSvg A callback to call when the image visibly changes + */ + constructor (setSelectedItems, clearSelectedItems, onUpdateSvg) { + super(); + this.setSelectedItems = setSelectedItems; + this.clearSelectedItems = clearSelectedItems; + this.onUpdateSvg = onUpdateSvg; + this.boundingBoxTool = new BoundingBoxTool(Modes.TEXT, setSelectedItems, clearSelectedItems, onUpdateSvg); + const nudgeTool = new NudgeTool(this.boundingBoxTool, onUpdateSvg); + + // We have to set these functions instead of just declaring them because + // paper.js tools hook up the listeners in the setter functions. + this.onMouseDown = this.handleMouseDown; + this.onMouseDrag = this.handleMouseDrag; + this.onMouseUp = this.handleMouseUp; + this.onKeyUp = nudgeTool.onKeyUp; + this.onKeyDown = nudgeTool.onKeyDown; + + this.oval = null; + this.colorState = null; + this.isBoundingBoxMode = null; + this.active = false; + } + getHitOptions () { + return { + segments: true, + stroke: true, + curves: true, + fill: true, + guide: false, + match: hitResult => + (hitResult.item.data && hitResult.item.data.isHelperItem) || + hitResult.item.selected, // Allow hits on bounding box and selected only + tolerance: TextTool.TOLERANCE / paper.view.zoom + }; + } + /** + * Should be called if the selection changes to update the bounds of the bounding box. + * @param {Array} selectedItems Array of selected items. + */ + onSelectionChanged (selectedItems) { + this.boundingBoxTool.onSelectionChanged(selectedItems); + } + setColorState (colorState) { + this.colorState = colorState; + } + handleMouseDown (event) { + if (event.event.button > 0) return; // only first mouse button + this.active = true; + + if (this.boundingBoxTool.onMouseDown(event, false /* clone */, false /* multiselect */, this.getHitOptions())) { + this.isBoundingBoxMode = true; + } else { + this.isBoundingBoxMode = false; + clearSelection(this.clearSelectedItems); + this.oval = new paper.Shape.Ellipse({ + point: event.downPoint, + size: 0 + }); + styleShape(this.oval, this.colorState); + } + } + handleMouseDrag (event) { + if (event.event.button > 0 || !this.active) return; // only first mouse button + + if (this.isBoundingBoxMode) { + this.boundingBoxTool.onMouseDrag(event); + return; + } + + const downPoint = new paper.Point(event.downPoint.x, event.downPoint.y); + const point = new paper.Point(event.point.x, event.point.y); + if (event.modifiers.shift) { + this.oval.size = new paper.Point(event.downPoint.x - event.point.x, event.downPoint.x - event.point.x); + } else { + this.oval.size = downPoint.subtract(point); + } + if (event.modifiers.alt) { + this.oval.position = downPoint; + } else { + this.oval.position = downPoint.subtract(this.oval.size.multiply(0.5)); + } + + } + handleMouseUp (event) { + if (event.event.button > 0 || !this.active) return; // only first mouse button + + if (this.isBoundingBoxMode) { + this.boundingBoxTool.onMouseUp(event); + this.isBoundingBoxMode = null; + return; + } + + if (this.oval) { + if (Math.abs(this.oval.size.width * this.oval.size.height) < TextTool.TOLERANCE / paper.view.zoom) { + // Tiny oval created unintentionally? + this.oval.remove(); + this.oval = null; + } else { + const ovalPath = this.oval.toPath(true /* insert */); + this.oval.remove(); + this.oval = null; + + ovalPath.selected = true; + this.setSelectedItems(); + this.onUpdateSvg(); + } + } + this.active = false; + } + deactivateTool () { + this.boundingBoxTool.removeBoundsPath(); + } +} + +export default TextTool; diff --git a/src/lib/modes.js b/src/lib/modes.js index 6736f742..7fcbbe57 100644 --- a/src/lib/modes.js +++ b/src/lib/modes.js @@ -9,7 +9,8 @@ const Modes = keyMirror({ RESHAPE: null, OVAL: null, RECT: null, - ROUNDED_RECT: null + ROUNDED_RECT: null, + TEXT: null }); export default Modes;