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;