-
- {props.isEyeDropping &&
- props.colorInfo !== null &&
- !props.colorInfo.hideLoupe ? (
-
-
-
- ) : null
- }
+
+
+ {/* Canvas */}
+
+
+
+ {props.isEyeDropping &&
+ props.colorInfo !== null &&
+ !props.colorInfo.hideLoupe ? (
+
+
+
+ ) : null
+ }
+
{
PaintEditorComponent.propTypes = {
canRedo: PropTypes.func.isRequired,
canUndo: PropTypes.func.isRequired,
- canvas: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+ canvas: PropTypes.instanceOf(Element),
colorInfo: Loupe.propTypes.colorInfo,
intl: intlShape,
isEyeDropping: PropTypes.bool,
@@ -480,8 +488,10 @@ PaintEditorComponent.propTypes = {
rotationCenterX: PropTypes.number,
rotationCenterY: PropTypes.number,
setCanvas: PropTypes.func.isRequired,
+ setTextArea: PropTypes.func.isRequired,
svg: PropTypes.string,
- svgId: PropTypes.string
+ svgId: PropTypes.string,
+ textArea: PropTypes.instanceOf(Element)
};
export default injectIntl(PaintEditorComponent);
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/fill-color-indicator.jsx b/src/containers/fill-color-indicator.jsx
index 30a0690b..4af2befa 100644
--- a/src/containers/fill-color-indicator.jsx
+++ b/src/containers/fill-color-indicator.jsx
@@ -30,7 +30,7 @@ class FillColorIndicator extends React.Component {
}
handleChangeFillColor (newColor) {
// Apply color and update redux, but do not update svg until picker closes.
- const isDifferent = applyFillColorToSelection(newColor);
+ const isDifferent = applyFillColorToSelection(newColor, this.props.textEditTarget);
this._hasChanged = this._hasChanged || isDifferent;
this.props.onChangeFillColor(newColor);
}
@@ -54,7 +54,8 @@ const mapStateToProps = state => ({
disabled: state.scratchPaint.mode === Modes.LINE,
fillColor: state.scratchPaint.color.fillColor,
fillColorModalVisible: state.scratchPaint.modals.fillColor,
- isEyeDropping: state.scratchPaint.color.eyeDropper.active
+ isEyeDropping: state.scratchPaint.color.eyeDropper.active,
+ textEditTarget: state.scratchPaint.textEditTarget
});
const mapDispatchToProps = dispatch => ({
@@ -76,7 +77,8 @@ FillColorIndicator.propTypes = {
isEyeDropping: PropTypes.bool.isRequired,
onChangeFillColor: PropTypes.func.isRequired,
onCloseFillColor: PropTypes.func.isRequired,
- onUpdateSvg: PropTypes.func.isRequired
+ onUpdateSvg: PropTypes.func.isRequired,
+ textEditTarget: PropTypes.number
};
export default connect(
diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx
index a41616ea..11f9e267 100644
--- a/src/containers/paint-editor.jsx
+++ b/src/containers/paint-editor.jsx
@@ -7,6 +7,8 @@ import {changeMode} from '../reducers/modes';
import {undo, redo, undoSnapshot} from '../reducers/undo';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {deactivateEyeDropper} from '../reducers/eye-dropper';
+import {setTextEditTarget} from '../reducers/text-edit-target';
+import {updateViewBounds} from '../reducers/view-bounds';
import {hideGuideLayers, showGuideLayers} from '../helper/layer';
import {performUndo, performRedo, performSnapshot, shouldShowUndo, shouldShowRedo} from '../helper/undo';
@@ -36,10 +38,14 @@ class PaintEditor extends React.Component {
'handleSendToFront',
'handleGroup',
'handleUngroup',
+ 'handleZoomIn',
+ 'handleZoomOut',
+ 'handleZoomReset',
'canRedo',
'canUndo',
'onMouseDown',
'setCanvas',
+ 'setTextArea',
'startEyeDroppingLoop',
'stopEyeDroppingLoop'
]);
@@ -49,7 +55,14 @@ class PaintEditor extends React.Component {
};
}
componentDidMount () {
- document.addEventListener('keydown', this.props.onKeyPress);
+ document.addEventListener('keydown', (/* event */) => {
+ // Don't activate keyboard shortcuts during text editing
+ if (!this.props.textEditing) {
+ // @todo disabling keyboard shortcuts because there is a bug
+ // that is interfering with text editing.
+ // this.props.onKeyPress(event);
+ }
+ });
// document listeners used to detect if a mouse is down outside of the
// canvas, and should therefore stop the eye dropper
document.addEventListener('mousedown', this.onMouseDown);
@@ -129,23 +142,37 @@ class PaintEditor extends React.Component {
}
handleZoomIn () {
zoomOnSelection(PaintEditor.ZOOM_INCREMENT);
+ this.props.updateViewBounds(paper.view.matrix);
+ this.props.setSelectedItems();
}
handleZoomOut () {
zoomOnSelection(-PaintEditor.ZOOM_INCREMENT);
+ this.props.updateViewBounds(paper.view.matrix);
+ this.props.setSelectedItems();
}
handleZoomReset () {
resetZoom();
+ this.props.updateViewBounds(paper.view.matrix);
+ this.props.setSelectedItems();
}
setCanvas (canvas) {
this.setState({canvas: canvas});
this.canvas = canvas;
}
+ setTextArea (element) {
+ this.setState({textArea: element});
+ }
onMouseDown (event) {
if (event.target === paper.view.element &&
document.activeElement instanceof HTMLInputElement) {
document.activeElement.blur();
}
+ if (event.target !== paper.view.element && event.target !== this.state.textArea) {
+ // Exit text edit mode if you click anywhere outside of canvas
+ this.props.removeTextEditTarget();
+ }
+
if (this.props.isEyeDropping) {
const colorString = this.eyeDropper.colorString;
const callback = this.props.changeColorToEyeDropper;
@@ -209,8 +236,10 @@ class PaintEditor extends React.Component {
rotationCenterX={this.props.rotationCenterX}
rotationCenterY={this.props.rotationCenterY}
setCanvas={this.setCanvas}
+ setTextArea={this.setTextArea}
svg={this.props.svg}
svgId={this.props.svgId}
+ textArea={this.state.textArea}
onGroup={this.handleGroup}
onRedo={this.handleRedo}
onSendBackward={this.handleSendBackward}
@@ -244,16 +273,19 @@ PaintEditor.propTypes = {
activate: PropTypes.func.isRequired,
remove: PropTypes.func.isRequired
}),
+ removeTextEditTarget: PropTypes.func.isRequired,
rotationCenterX: PropTypes.number,
rotationCenterY: PropTypes.number,
setSelectedItems: PropTypes.func.isRequired,
svg: PropTypes.string,
svgId: PropTypes.string,
+ textEditing: PropTypes.bool.isRequired,
undoSnapshot: PropTypes.func.isRequired,
undoState: PropTypes.shape({
stack: PropTypes.arrayOf(PropTypes.object).isRequired,
pointer: PropTypes.number.isRequired
- })
+ }),
+ updateViewBounds: PropTypes.func.isRequired
};
const mapStateToProps = state => ({
@@ -263,6 +295,7 @@ const mapStateToProps = state => ({
pasteOffset: state.scratchPaint.clipboard.pasteOffset,
previousTool: state.scratchPaint.color.eyeDropper.previousTool,
selectedItems: state.scratchPaint.selectedItems,
+ textEditing: state.scratchPaint.textEditTarget !== null,
undoState: state.scratchPaint.undo
});
const mapDispatchToProps = dispatch => ({
@@ -275,14 +308,31 @@ const mapDispatchToProps = dispatch => ({
dispatch(changeMode(Modes.LINE));
} else if (event.key === 's') {
dispatch(changeMode(Modes.SELECT));
+ } else if (event.key === 'w') {
+ dispatch(changeMode(Modes.RESHAPE));
+ } else if (event.key === 'f') {
+ dispatch(changeMode(Modes.FILL));
+ } else if (event.key === 't') {
+ dispatch(changeMode(Modes.TEXT));
+ } else if (event.key === 'c') {
+ dispatch(changeMode(Modes.OVAL));
+ } else if (event.key === 'r') {
+ dispatch(changeMode(Modes.RECT));
}
},
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},
+ removeTextEditTarget: () => {
+ dispatch(setTextEditTarget());
+ },
setSelectedItems: () => {
dispatch(setSelectedItems(getSelectedLeafItems()));
},
+ onDeactivateEyeDropper: () => {
+ // set redux values to default for eye dropper reducer
+ dispatch(deactivateEyeDropper());
+ },
onUndo: () => {
dispatch(undo());
},
@@ -292,9 +342,8 @@ const mapDispatchToProps = dispatch => ({
undoSnapshot: snapshot => {
dispatch(undoSnapshot(snapshot));
},
- onDeactivateEyeDropper: () => {
- // set redux values to default for eye dropper reducer
- dispatch(deactivateEyeDropper());
+ updateViewBounds: matrix => {
+ dispatch(updateViewBounds(matrix));
}
});
diff --git a/src/containers/paper-canvas.css b/src/containers/paper-canvas.css
index ef983c99..65a26ef0 100644
--- a/src/containers/paper-canvas.css
+++ b/src/containers/paper-canvas.css
@@ -2,7 +2,7 @@
width: 480px;
height: 360px;
margin: auto;
- position: relative;
+ position: absolute;
background-color: #fff;
/* Turn off anti-aliasing for the drawing canvas. Each time it's updated it switches
back and forth from aliased to unaliased and that looks bad */
diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx
index 2a3f176a..bac7ed42 100644
--- a/src/containers/paper-canvas.jsx
+++ b/src/containers/paper-canvas.jsx
@@ -16,7 +16,7 @@ import {pan, resetZoom, zoomOnFixedPoint} from '../helper/view';
import {ensureClockwise} from '../helper/math';
import {clearHoveredItem} from '../reducers/hover';
import {clearPasteOffset} from '../reducers/clipboard';
-
+import {updateViewBounds} from '../reducers/view-bounds';
import styles from './paper-canvas.css';
@@ -59,6 +59,7 @@ class PaperCanvas extends React.Component {
const oldZoom = paper.project.view.zoom;
const oldCenter = paper.project.view.center.clone();
resetZoom();
+ this.props.updateViewBounds(paper.view.matrix);
this.importSvg(newProps.svg, newProps.rotationCenterX, newProps.rotationCenterY);
paper.project.view.zoom = oldZoom;
paper.project.view.center = oldCenter;
@@ -85,13 +86,10 @@ class PaperCanvas extends React.Component {
importSvg (svg, rotationCenterX, rotationCenterY) {
const paperCanvas = this;
// Pre-process SVG to prevent parsing errors (discussion from #213)
- // 1. Remove newlines and tab characters, chrome will not load urls with them.
- // https://www.chromestatus.com/feature/5735596811091968
- svg = svg.split(/[\n|\r|\t]/).join('');
- // 2. Remove svg: namespace on elements.
+ // 1. Remove svg: namespace on elements.
svg = svg.split(/<\s*svg:/).join('<');
svg = svg.split(/<\/\s*svg:/).join('');
- // 3. Add root svg namespace if it does not exist.
+ // 2. Add root svg namespace if it does not exist.
const svgAttrs = svg.match(/]*>/);
if (svgAttrs && svgAttrs[0].indexOf('xmlns=') === -1) {
svg = svg.replace(
@@ -179,16 +177,20 @@ class PaperCanvas extends React.Component {
new paper.Point(offsetX, offsetY)
);
zoomOnFixedPoint(-event.deltaY / 100, fixedPoint);
+ this.props.updateViewBounds(paper.view.matrix);
+ this.props.setSelectedItems();
} else if (event.shiftKey && event.deltaX === 0) {
// Scroll horizontally (based on vertical scroll delta)
// This is needed as for some browser/system combinations which do not set deltaX.
// See #156.
const dx = event.deltaY / paper.project.view.zoom;
pan(dx, 0);
+ this.props.updateViewBounds(paper.view.matrix);
} else {
const dx = event.deltaX / paper.project.view.zoom;
const dy = event.deltaY / paper.project.view.zoom;
pan(dx, dy);
+ this.props.updateViewBounds(paper.view.matrix);
}
event.preventDefault();
}
@@ -218,7 +220,8 @@ PaperCanvas.propTypes = {
setSelectedItems: PropTypes.func.isRequired,
svg: PropTypes.string,
svgId: PropTypes.string,
- undoSnapshot: PropTypes.func.isRequired
+ undoSnapshot: PropTypes.func.isRequired,
+ updateViewBounds: PropTypes.func.isRequired
};
const mapStateToProps = state => ({
mode: state.scratchPaint.mode
@@ -241,6 +244,9 @@ const mapDispatchToProps = dispatch => ({
},
clearPasteOffset: () => {
dispatch(clearPasteOffset());
+ },
+ updateViewBounds: matrix => {
+ dispatch(updateViewBounds(matrix));
}
});
diff --git a/src/containers/stroke-color-indicator.jsx b/src/containers/stroke-color-indicator.jsx
index ba675086..d88bedfe 100644
--- a/src/containers/stroke-color-indicator.jsx
+++ b/src/containers/stroke-color-indicator.jsx
@@ -30,7 +30,7 @@ class StrokeColorIndicator extends React.Component {
}
handleChangeStrokeColor (newColor) {
// Apply color and update redux, but do not update svg until picker closes.
- const isDifferent = applyStrokeColorToSelection(newColor);
+ const isDifferent = applyStrokeColorToSelection(newColor, this.props.textEditTarget);
this._hasChanged = this._hasChanged || isDifferent;
this.props.onChangeStrokeColor(newColor);
}
@@ -51,10 +51,12 @@ class StrokeColorIndicator extends React.Component {
}
const mapStateToProps = state => ({
- disabled: state.scratchPaint.mode === Modes.BRUSH,
+ disabled: state.scratchPaint.mode === Modes.BRUSH ||
+ state.scratchPaint.mode === Modes.TEXT,
isEyeDropping: state.scratchPaint.color.eyeDropper.active,
strokeColor: state.scratchPaint.color.strokeColor,
- strokeColorModalVisible: state.scratchPaint.modals.strokeColor
+ strokeColorModalVisible: state.scratchPaint.modals.strokeColor,
+ textEditTarget: state.scratchPaint.textEditTarget
});
const mapDispatchToProps = dispatch => ({
@@ -76,7 +78,8 @@ StrokeColorIndicator.propTypes = {
onCloseStrokeColor: PropTypes.func.isRequired,
onUpdateSvg: PropTypes.func.isRequired,
strokeColor: PropTypes.string,
- strokeColorModalVisible: PropTypes.bool.isRequired
+ strokeColorModalVisible: PropTypes.bool.isRequired,
+ textEditTarget: PropTypes.number
};
export default connect(
diff --git a/src/containers/stroke-width-indicator.jsx b/src/containers/stroke-width-indicator.jsx
index e462a02d..663049a2 100644
--- a/src/containers/stroke-width-indicator.jsx
+++ b/src/containers/stroke-width-indicator.jsx
@@ -15,7 +15,9 @@ class StrokeWidthIndicator extends React.Component {
]);
}
handleChangeStrokeWidth (newWidth) {
- applyStrokeWidthToSelection(newWidth, this.props.onUpdateSvg);
+ if (applyStrokeWidthToSelection(newWidth, this.props.textEditTarget)) {
+ this.props.onUpdateSvg();
+ }
this.props.onChangeStrokeWidth(newWidth);
}
render () {
@@ -30,8 +32,10 @@ class StrokeWidthIndicator extends React.Component {
}
const mapStateToProps = state => ({
- disabled: state.scratchPaint.mode === Modes.BRUSH,
- strokeWidth: state.scratchPaint.color.strokeWidth
+ disabled: state.scratchPaint.mode === Modes.BRUSH ||
+ state.scratchPaint.mode === Modes.TEXT,
+ strokeWidth: state.scratchPaint.color.strokeWidth,
+ textEditTarget: state.scratchPaint.textEditTarget
});
const mapDispatchToProps = dispatch => ({
onChangeStrokeWidth: strokeWidth => {
@@ -43,7 +47,8 @@ StrokeWidthIndicator.propTypes = {
disabled: PropTypes.bool.isRequired,
onChangeStrokeWidth: PropTypes.func.isRequired,
onUpdateSvg: PropTypes.func.isRequired,
- strokeWidth: PropTypes.number
+ strokeWidth: PropTypes.number,
+ textEditTarget: PropTypes.number
};
export default connect(
diff --git a/src/containers/text-mode.jsx b/src/containers/text-mode.jsx
new file mode 100644
index 00000000..039a6304
--- /dev/null
+++ b/src/containers/text-mode.jsx
@@ -0,0 +1,148 @@
+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 {setTextEditTarget} from '../reducers/text-edit-target';
+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 (this.tool && !nextProps.textEditTarget && this.props.textEditTarget) {
+ this.tool.onTextEditCancelled();
+ }
+ if (this.tool && !nextProps.viewBounds.equals(this.props.viewBounds)) {
+ this.tool.onViewBoundsChanged(nextProps.viewBounds);
+ }
+
+ 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.textArea,
+ this.props.setSelectedItems,
+ this.props.clearSelectedItems,
+ this.props.onUpdateSvg,
+ this.props.setTextEditTarget,
+ );
+ 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,
+ setTextEditTarget: PropTypes.func.isRequired,
+ textArea: PropTypes.instanceOf(Element),
+ textEditTarget: PropTypes.number,
+ viewBounds: PropTypes.instanceOf(paper.Matrix).isRequired
+};
+
+const mapStateToProps = state => ({
+ colorState: state.scratchPaint.color,
+ isTextModeActive: state.scratchPaint.mode === Modes.TEXT,
+ selectedItems: state.scratchPaint.selectedItems,
+ textEditTarget: state.scratchPaint.textEditTarget,
+ viewBounds: state.scratchPaint.viewBounds
+});
+const mapDispatchToProps = dispatch => ({
+ clearSelectedItems: () => {
+ dispatch(clearSelectedItems());
+ },
+ setSelectedItems: () => {
+ dispatch(setSelectedItems(getSelectedLeafItems()));
+ },
+ setTextEditTarget: targetId => {
+ dispatch(setTextEditTarget(targetId));
+ },
+ 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/guides.js b/src/helper/guides.js
index 2dfd266c..e91e25b5 100644
--- a/src/helper/guides.js
+++ b/src/helper/guides.js
@@ -30,14 +30,19 @@ const hoverItem = function (item) {
return clone;
};
-const hoverBounds = function (item) {
- const rect = new paper.Path.Rectangle(item.internalBounds);
+const hoverBounds = function (item, expandBy) {
+ let bounds = item.internalBounds;
+ if (expandBy) {
+ bounds = bounds.expand(expandBy);
+ }
+ const rect = new paper.Path.Rectangle(bounds);
rect.matrix = item.matrix;
setDefaultGuideStyle(rect);
rect.parent = getGuideLayer();
rect.strokeColor = GUIDE_BLUE;
rect.fillColor = null;
rect.data.isHelperItem = true;
+ rect.data.origItem = item;
rect.bringToFront();
return rect;
@@ -104,7 +109,8 @@ const removeHitPoint = function () {
const drawHitPoint = function (point) {
removeHitPoint();
if (point) {
- const hitPoint = paper.Path.Circle(point, 4 /* radius */);
+ const hitPoint = paper.Path.Circle(point, 4 / paper.view.zoom /* radius */);
+ hitPoint.strokeWidth = 1 / paper.view.zoom;
hitPoint.strokeColor = GUIDE_BLUE;
hitPoint.fillColor = new paper.Color(1, 1, 1, 0.5);
hitPoint.parent = getGuideLayer();
diff --git a/src/helper/style-path.js b/src/helper/style-path.js
index f7b50916..eda8085a 100644
--- a/src/helper/style-path.js
+++ b/src/helper/style-path.js
@@ -2,6 +2,7 @@ import paper from '@scratch/paper';
import {getSelectedLeafItems} from './selection';
import {isPGTextItem, isPointTextItem} from './item';
import {isGroup} from './group';
+import {getItems} from './selection';
const MIXED = 'scratch-paint/style-path/mixed';
@@ -15,44 +16,38 @@ const _colorMatch = function (itemColor, incomingColor) {
(itemColor && incomingColor && itemColor.toCSS() === new paper.Color(incomingColor).toCSS());
};
+// Selected items and currently active text edit items respond to color changes.
+const _getColorStateListeners = function (textEditTargetId) {
+ const items = getSelectedLeafItems();
+ if (textEditTargetId) {
+ const matches = getItems({
+ match: item => item.id === textEditTargetId
+ });
+ if (matches.length) {
+ items.push(matches[0]);
+ }
+ }
+ return items;
+};
+
/**
* Called when setting fill color
* @param {string} colorString New color, css format
+ * @param {?string} textEditTargetId paper.Item.id of text editing target, if any
* @return {boolean} Whether the color application actually changed visibly.
*/
-const applyFillColorToSelection = function (colorString) {
- const items = getSelectedLeafItems();
+const applyFillColorToSelection = function (colorString, textEditTargetId) {
+ const items = _getColorStateListeners(textEditTargetId);
let changed = false;
for (let item of items) {
- if (item.parent instanceof paper.CompoundPath) {
+ if (isPointTextItem(item) && !colorString) {
+ colorString = 'rgba(0,0,0,0)';
+ } else if (item.parent instanceof paper.CompoundPath) {
item = item.parent;
}
- if (isPGTextItem(item)) {
- for (const child of item.children) {
- if (child.children) {
- for (const path of child.children) {
- if (!path.data.isPGGlyphRect) {
- if (!_colorMatch(path.fillColor, colorString)) {
- changed = true;
- path.fillColor = colorString;
- }
- }
- }
- } else if (!child.data.isPGGlyphRect) {
- if (!_colorMatch(child.fillColor, colorString)) {
- changed = true;
- child.fillColor = colorString;
- }
- }
- }
- } else {
- if (isPointTextItem(item) && !colorString) {
- colorString = 'rgba(0,0,0,0)';
- }
- if (!_colorMatch(item.fillColor, colorString)) {
- changed = true;
- item.fillColor = colorString;
- }
+ if (!_colorMatch(item.fillColor, colorString)) {
+ changed = true;
+ item.fillColor = colorString;
}
}
return changed;
@@ -61,10 +56,11 @@ const applyFillColorToSelection = function (colorString) {
/**
* Called when setting stroke color
* @param {string} colorString New color, css format
+ * @param {?string} textEditTargetId paper.Item.id of text editing target, if any
* @return {boolean} Whether the color application actually changed visibly.
*/
-const applyStrokeColorToSelection = function (colorString) {
- const items = getSelectedLeafItems();
+const applyStrokeColorToSelection = function (colorString, textEditTargetId) {
+ const items = _getColorStateListeners(textEditTargetId);
let changed = false;
for (let item of items) {
if (item.parent instanceof paper.CompoundPath) {
@@ -106,11 +102,12 @@ const applyStrokeColorToSelection = function (colorString) {
/**
* Called when setting stroke width
* @param {number} value New stroke width
- * @param {!function} onUpdateSvg A callback to call when the image visibly changes
+ * @param {?string} textEditTargetId paper.Item.id of text editing target, if any
+ * @return {boolean} Whether the color application actually changed visibly.
*/
-const applyStrokeWidthToSelection = function (value, onUpdateSvg) {
+const applyStrokeWidthToSelection = function (value, textEditTargetId) {
let changed = false;
- const items = getSelectedLeafItems();
+ const items = _getColorStateListeners(textEditTargetId);
for (let item of items) {
if (item.parent instanceof paper.CompoundPath) {
item = item.parent;
@@ -122,9 +119,7 @@ const applyStrokeWidthToSelection = function (value, onUpdateSvg) {
changed = true;
}
}
- if (changed) {
- onUpdateSvg();
- }
+ return changed;
};
/**
diff --git a/src/helper/tools/fill-tool.js b/src/helper/tools/fill-tool.js
index 8360c161..572c6a23 100644
--- a/src/helper/tools/fill-tool.js
+++ b/src/helper/tools/fill-tool.js
@@ -38,14 +38,14 @@ class FillTool extends paper.Tool {
item.lastSegment.point.getDistance(item.firstSegment.point) < 8;
};
return {
- class: paper.Path,
segments: true,
stroke: true,
curves: true,
fill: true,
guide: false,
match: function (hitResult) {
- return (hitResult.item.hasFill() || hitResult.item.closed || isAlmostClosedPath(hitResult.item));
+ return (hitResult.item instanceof paper.Path || hitResult.item instanceof paper.PointText) &&
+ (hitResult.item.hasFill() || hitResult.item.closed || isAlmostClosedPath(hitResult.item));
},
hitUnfilledPaths: true,
tolerance: FillTool.TOLERANCE / paper.view.zoom
diff --git a/src/helper/tools/text-tool.js b/src/helper/tools/text-tool.js
new file mode 100644
index 00000000..24b3d064
--- /dev/null
+++ b/src/helper/tools/text-tool.js
@@ -0,0 +1,326 @@
+import paper from '@scratch/paper';
+import Modes from '../../lib/modes';
+import {clearSelection} from '../selection';
+import BoundingBoxTool from '../selection-tools/bounding-box-tool';
+import NudgeTool from '../selection-tools/nudge-tool';
+import {hoverBounds} from '../guides';
+
+/**
+ * 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;
+ }
+ static get TEXT_EDIT_MODE () {
+ return 'TEXT_EDIT_MODE';
+ }
+ static get SELECT_MODE () {
+ return 'SELECT_MODE';
+ }
+ /** Clicks registered within this amount of time are registered as double clicks */
+ static get DOUBLE_CLICK_MILLIS () {
+ return 250;
+ }
+ /** Typing with no pauses longer than this amount of type will count as 1 action */
+ static get TYPING_TIMEOUT_MILLIS () {
+ return 1000;
+ }
+ static get TEXT_PADDING () {
+ return 8;
+ }
+ /**
+ * @param {HTMLTextAreaElement} textAreaElement dom element for the editable text field
+ * @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
+ * @param {!function} setTextEditTarget Call to set text editing target whenever text editing is active
+ */
+ constructor (textAreaElement, setSelectedItems, clearSelectedItems, onUpdateSvg, setTextEditTarget) {
+ super();
+ this.element = textAreaElement;
+ this.setSelectedItems = setSelectedItems;
+ this.clearSelectedItems = clearSelectedItems;
+ this.onUpdateSvg = onUpdateSvg;
+ this.setTextEditTarget = setTextEditTarget;
+ this.boundingBoxTool = new BoundingBoxTool(Modes.TEXT, setSelectedItems, clearSelectedItems, onUpdateSvg);
+ this.nudgeTool = new NudgeTool(this.boundingBoxTool, onUpdateSvg);
+ this.lastEvent = null;
+
+ // 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.onMouseMove = this.handleMouseMove;
+ this.onKeyUp = this.handleKeyUp;
+ this.onKeyDown = this.handleKeyDown;
+
+ this.textBox = null;
+ this.guide = null;
+ this.colorState = null;
+ this.mode = null;
+ this.active = false;
+ this.lastTypeEvent = null;
+
+ // If text selected and then activate this tool, switch to text edit mode for that text
+ // If double click on text while in select mode, does mode change to text mode? Text fully selected by default
+ }
+ getBoundingBoxHitOptions () {
+ 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
+ };
+ }
+ getTextEditHitOptions () {
+ return {
+ class: paper.PointText,
+ segments: true,
+ stroke: true,
+ curves: true,
+ fill: true,
+ guide: false,
+ match: hitResult => hitResult.item && !hitResult.item.selected, // Unselected only
+ tolerance: TextTool.TOLERANCE / paper.view.zoom
+ };
+ }
+ /**
+ * Called when the selection changes to update the bounds of the bounding box.
+ * @param {Array} selectedItems Array of selected items.
+ */
+ onSelectionChanged (selectedItems) {
+ this.boundingBoxTool.onSelectionChanged(selectedItems);
+ }
+ // Allow other tools to cancel text edit mode
+ onTextEditCancelled () {
+ this.endTextEdit();
+ if (this.textBox) {
+ this.mode = TextTool.SELECT_MODE;
+ this.textBox.selected = true;
+ this.setSelectedItems();
+ }
+ }
+ /**
+ * Called when the view matrix changes
+ * @param {paper.Matrix} viewMtx applied to paper.view
+ */
+ onViewBoundsChanged (viewMtx) {
+ if (this.mode !== TextTool.TEXT_EDIT_MODE) {
+ return;
+ }
+ const matrix = this.textBox.matrix;
+ this.element.style.transform =
+ `translate(0px, ${this.textBox.internalBounds.y}px)
+ matrix(${viewMtx.a}, ${viewMtx.b}, ${viewMtx.c}, ${viewMtx.d},
+ ${viewMtx.tx}, ${viewMtx.ty})
+ matrix(${matrix.a}, ${matrix.b}, ${matrix.c}, ${matrix.d},
+ ${matrix.tx}, ${matrix.ty})`;
+ }
+ setColorState (colorState) {
+ this.colorState = colorState;
+ }
+ handleMouseMove (event) {
+ const hitResults = paper.project.hitTestAll(event.point, this.getTextEditHitOptions());
+ if (hitResults.length) {
+ document.body.style.cursor = 'text';
+ } else {
+ document.body.style.cursor = 'auto';
+ }
+ }
+ handleMouseDown (event) {
+ if (event.event.button > 0) return; // only first mouse button
+ this.active = true;
+
+ const lastMode = this.mode;
+
+ // Check if double clicked
+ let doubleClicked = false;
+ if (this.lastEvent) {
+ if ((event.event.timeStamp - this.lastEvent.event.timeStamp) < TextTool.DOUBLE_CLICK_MILLIS) {
+ doubleClicked = true;
+ } else {
+ doubleClicked = false;
+ }
+ }
+ this.lastEvent = event;
+
+ const doubleClickHitTest = paper.project.hitTest(event.point, this.getBoundingBoxHitOptions());
+ if (doubleClicked &&
+ this.mode === TextTool.SELECT_MODE &&
+ doubleClickHitTest) {
+ // Double click in select mode moves you to text edit mode
+ clearSelection(this.clearSelectedItems);
+ this.textBox = doubleClickHitTest.item;
+ this.beginTextEdit(this.textBox.content, this.textBox.matrix);
+ } else if (
+ this.boundingBoxTool.onMouseDown(
+ event, false /* clone */, false /* multiselect */, this.getBoundingBoxHitOptions())) {
+ // In select mode staying in select mode
+ return;
+ }
+
+ // We clicked away from the item, so end the current mode
+ if (lastMode === TextTool.SELECT_MODE) {
+ clearSelection(this.clearSelectedItems);
+ this.mode = null;
+ } else if (lastMode === TextTool.TEXT_EDIT_MODE) {
+ this.endTextEdit();
+ }
+
+ const hitResults = paper.project.hitTestAll(event.point, this.getTextEditHitOptions());
+ if (hitResults.length) {
+ // Clicking a different text item to begin text edit mode on that item
+ clearSelection(this.clearSelectedItems);
+ this.textBox = hitResults[0].item;
+ this.beginTextEdit(this.textBox.content, this.textBox.matrix);
+ } else if (lastMode === TextTool.TEXT_EDIT_MODE) {
+ // In text mode clicking away to begin select mode
+ if (this.textBox) {
+ this.mode = TextTool.SELECT_MODE;
+ this.textBox.selected = true;
+ this.setSelectedItems();
+ }
+ } else {
+ // In no mode or select mode clicking away to begin text edit mode
+ this.textBox = new paper.PointText({
+ point: event.point,
+ content: '',
+ font: 'Helvetica',
+ fontSize: 30,
+ fillColor: this.colorState.fillColor,
+ // Default leading for both the HTML text area and paper.PointText
+ // is 120%, but for some reason they are slightly off from each other.
+ // This value was obtained experimentally.
+ // (Don't round to 34.6, the text area will start to scroll.)
+ leading: 34.61
+ });
+ this.beginTextEdit(this.textBox.content, this.textBox.matrix);
+ }
+ }
+ handleMouseDrag (event) {
+ if (event.event.button > 0 || !this.active) return; // only first mouse button
+
+ if (this.mode === TextTool.SELECT_MODE) {
+ this.boundingBoxTool.onMouseDrag(event);
+ return;
+ }
+ }
+ handleMouseUp (event) {
+ if (event.event.button > 0 || !this.active) return; // only first mouse button
+
+ if (this.mode === TextTool.SELECT_MODE) {
+ this.boundingBoxTool.onMouseUp(event);
+ this.isBoundingBoxMode = null;
+ return;
+ }
+
+ this.active = false;
+ }
+ handleKeyUp (event) {
+ if (this.mode === TextTool.SELECT_MODE) {
+ this.nudgeTool.onKeyUp(event);
+ }
+ }
+ handleKeyDown (event) {
+ if (event.event.target instanceof HTMLInputElement) {
+ // Ignore nudge if a text input field is focused
+ return;
+ }
+
+ if (this.mode === TextTool.SELECT_MODE) {
+ this.nudgeTool.onKeyUp(event);
+ }
+ }
+ handleTextInput (event) {
+ // Save undo state if you paused typing for long enough.
+ if (this.lastTypeEvent && event.timeStamp - this.lastTypeEvent.timeStamp > TextTool.TYPING_TIMEOUT_MILLIS) {
+ this.onUpdateSvg();
+ }
+ this.lastTypeEvent = event;
+ if (this.mode === TextTool.TEXT_EDIT_MODE) {
+ this.textBox.content = this.element.value;
+ }
+ this.resizeGuide();
+ }
+ resizeGuide () {
+ if (this.guide) this.guide.remove();
+ this.guide = hoverBounds(this.textBox, TextTool.TEXT_PADDING);
+ this.guide.dashArray = [4, 4];
+ this.element.style.width = `${this.textBox.internalBounds.width}px`;
+ this.element.style.height = `${this.textBox.internalBounds.height}px`;
+ }
+ /**
+ * @param {string} initialText Text to initialize the text area with
+ * @param {paper.Matrix} matrix Transform matrix for the element. Defaults
+ * to the identity matrix.
+ */
+ beginTextEdit (initialText, matrix) {
+ this.mode = TextTool.TEXT_EDIT_MODE;
+ this.setTextEditTarget(this.textBox.id);
+
+ const viewMtx = paper.view.matrix;
+
+ this.element.style.display = 'initial';
+ this.element.value = initialText ? initialText : '';
+ this.element.style.transformOrigin =
+ `${-this.textBox.internalBounds.x}px ${-this.textBox.internalBounds.y}px`;
+ this.element.style.transform =
+ `translate(0px, ${this.textBox.internalBounds.y}px)
+ matrix(${viewMtx.a}, ${viewMtx.b}, ${viewMtx.c}, ${viewMtx.d},
+ ${viewMtx.tx}, ${viewMtx.ty})
+ matrix(${matrix.a}, ${matrix.b}, ${matrix.c}, ${matrix.d},
+ ${matrix.tx}, ${matrix.ty})`;
+ this.element.focus({preventScroll: true});
+ this.eventListener = this.handleTextInput.bind(this);
+ this.element.addEventListener('input', this.eventListener);
+ this.resizeGuide();
+ }
+ endTextEdit () {
+ if (this.mode !== TextTool.TEXT_EDIT_MODE) {
+ return;
+ }
+ this.mode = null;
+
+ // Remove invisible textboxes
+ if (this.textBox && this.textBox.content.trim() === '') {
+ this.textBox.remove();
+ this.textBox = null;
+ }
+
+ // Remove guide
+ if (this.guide) {
+ this.guide.remove();
+ this.guide = null;
+ this.setTextEditTarget();
+ }
+ this.element.style.display = 'none';
+ if (this.eventListener) {
+ this.element.removeEventListener('input', this.eventListener);
+ this.eventListener = null;
+ }
+ this.lastTypeEvent = null;
+
+ // If you finished editing a textbox, save undo state
+ if (this.textBox && this.textBox.content.trim().length) {
+ this.onUpdateSvg();
+ }
+ }
+ deactivateTool () {
+ if (this.textBox && this.textBox.content.trim() === '') {
+ this.textBox.remove();
+ this.textBox = null;
+ }
+ this.endTextEdit();
+ 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;
diff --git a/src/reducers/scratch-paint-reducer.js b/src/reducers/scratch-paint-reducer.js
index 7bbe8c6f..258dea9e 100644
--- a/src/reducers/scratch-paint-reducer.js
+++ b/src/reducers/scratch-paint-reducer.js
@@ -7,6 +7,8 @@ import clipboardReducer from './clipboard';
import hoverReducer from './hover';
import modalsReducer from './modals';
import selectedItemReducer from './selected-items';
+import textEditTargetReducer from './text-edit-target';
+import viewBoundsReducer from './view-bounds';
import undoReducer from './undo';
export default combineReducers({
@@ -18,5 +20,7 @@ export default combineReducers({
hoveredItemId: hoverReducer,
modals: modalsReducer,
selectedItems: selectedItemReducer,
- undo: undoReducer
+ textEditTarget: textEditTargetReducer,
+ undo: undoReducer,
+ viewBounds: viewBoundsReducer
});
diff --git a/src/reducers/text-edit-target.js b/src/reducers/text-edit-target.js
new file mode 100644
index 00000000..45380bd8
--- /dev/null
+++ b/src/reducers/text-edit-target.js
@@ -0,0 +1,40 @@
+import log from '../log/log';
+
+const CHANGE_TEXT_EDIT_TARGET = 'scratch-paint/text-tool/CHANGE_TEXT_EDIT_TARGET';
+const initialState = null;
+
+const reducer = function (state, action) {
+ if (typeof state === 'undefined') state = initialState;
+ switch (action.type) {
+ case CHANGE_TEXT_EDIT_TARGET:
+ if (typeof action.textEditTargetId === 'undefined') {
+ log.warn(`Text edit target should not be set to undefined. Use null.`);
+ return state;
+ } else if (typeof action.textEditTargetId === 'undefined' || isNaN(action.textEditTargetId)) {
+ log.warn(`Text edit target should be an item ID number. Got: ${action.textEditTargetId}`);
+ return state;
+ }
+ return action.textEditTargetId;
+ default:
+ return state;
+ }
+};
+
+// Action creators ==================================
+/**
+ * Set the currently-being-edited text field to the given item ID
+ * @param {?number} textEditTargetId The paper.Item ID of the active text field.
+ * Leave empty if there is no text editing target.
+ * @return {object} Redux action to change the text edit target.
+ */
+const setTextEditTarget = function (textEditTargetId) {
+ return {
+ type: CHANGE_TEXT_EDIT_TARGET,
+ textEditTargetId: textEditTargetId ? textEditTargetId : null
+ };
+};
+
+export {
+ reducer as default,
+ setTextEditTarget
+};
diff --git a/src/reducers/view-bounds.js b/src/reducers/view-bounds.js
new file mode 100644
index 00000000..1fc0c6b5
--- /dev/null
+++ b/src/reducers/view-bounds.js
@@ -0,0 +1,37 @@
+import paper from '@scratch/paper';
+import log from '../log/log';
+
+const UPDATE_VIEW_BOUNDS = 'scratch-paint/view/UPDATE_VIEW_BOUNDS';
+const initialState = new paper.Matrix(); // Identity
+
+const reducer = function (state, action) {
+ if (typeof state === 'undefined') state = initialState;
+ switch (action.type) {
+ case UPDATE_VIEW_BOUNDS:
+ if (!(action.viewBounds instanceof paper.Matrix)) {
+ log.warn(`View bounds should be a paper.Matrix.`);
+ return state;
+ }
+ return action.viewBounds;
+ default:
+ return state;
+ }
+};
+
+// Action creators ==================================
+/**
+ * Set the view bounds, which defines the zoom and scroll of the paper canvas.
+ * @param {paper.Matrix} matrix The matrix applied to the view
+ * @return {object} Redux action to set the view bounds
+ */
+const updateViewBounds = function (matrix) {
+ return {
+ type: UPDATE_VIEW_BOUNDS,
+ viewBounds: matrix.clone()
+ };
+};
+
+export {
+ reducer as default,
+ updateViewBounds
+};