Merge branch 'develop' into issue/219

This commit is contained in:
Paul Kaplan 2018-03-28 08:51:54 -04:00 committed by GitHub
commit d9587ba8d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 759 additions and 125 deletions

View file

@ -203,3 +203,20 @@ $border-radius: 0.25rem;
justify-content: flex-start;
}
}
.text-area {
background: transparent;
border: none;
display: none;
font-family: Times;
font-size: 30px;
outline: none;
overflow: hidden;
padding: 0px;
position: absolute;
resize: none;
white-space: nowrap;
margin: 0px;
-webkit-text-fill-color: transparent;
text-fill-color: transparent;
}

View file

@ -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,14 @@ const PaintEditorComponent = props => {
<EraserMode
onUpdateSvg={props.onUpdateSvg}
/>
{/* Text mode will go here */}
<LineMode
<FillMode
onUpdateSvg={props.onUpdateSvg}
/>
<FillMode
<TextMode
textArea={props.textArea}
onUpdateSvg={props.onUpdateSvg}
/>
<LineMode
onUpdateSvg={props.onUpdateSvg}
/>
<OvalMode
@ -363,37 +366,42 @@ const PaintEditorComponent = props => {
<RectMode
onUpdateSvg={props.onUpdateSvg}
/>
{/* text tool, coming soon */}
<TextModeComponent />
</div>
) : null}
{/* Canvas */}
<div
className={classNames(
styles.canvasContainer,
{[styles.withEyeDropper]: props.isEyeDropping}
)}
>
<PaperCanvas
canvasRef={props.setCanvas}
rotationCenterX={props.rotationCenterX}
rotationCenterY={props.rotationCenterY}
svg={props.svg}
svgId={props.svgId}
onUpdateSvg={props.onUpdateSvg}
/>
{props.isEyeDropping &&
props.colorInfo !== null &&
!props.colorInfo.hideLoupe ? (
<Box className={styles.colorPickerWrapper}>
<Loupe
colorInfo={props.colorInfo}
pixelRatio={paper.project.view.pixelRatio}
/>
</Box>
) : null
}
<div>
{/* Canvas */}
<div
className={classNames(
styles.canvasContainer,
{[styles.withEyeDropper]: props.isEyeDropping}
)}
>
<PaperCanvas
canvasRef={props.setCanvas}
rotationCenterX={props.rotationCenterX}
rotationCenterY={props.rotationCenterY}
svg={props.svg}
svgId={props.svgId}
onUpdateSvg={props.onUpdateSvg}
/>
<textarea
className={styles.textArea}
ref={props.setTextArea}
spellCheck={false}
/>
{props.isEyeDropping &&
props.colorInfo !== null &&
!props.colorInfo.hideLoupe ? (
<Box className={styles.colorPickerWrapper}>
<Loupe
colorInfo={props.colorInfo}
pixelRatio={paper.project.view.pixelRatio}
/>
</Box>
) : null
}
</div>
<div className={styles.canvasControls}>
<ComingSoonTooltip
className={styles.bitmapTooltip}
@ -459,7 +467,7 @@ const PaintEditorComponent = props => {
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);

View file

@ -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 = () => (
<ComingSoonTooltip
place="right"
tooltipId="text-mode-select"
>
<ToolSelectComponent
disabled
imgDescriptor={{
defaultMessage: 'Text',
description: 'Label for the text tool',
id: 'paint.textMode.text'
}}
imgSrc={textIcon}
isSelected={false}
onMouseDown={function () {}} // eslint-disable-line react/jsx-no-bind
/>
</ComingSoonTooltip>
const TextModeComponent = props => (
<ToolSelectComponent
imgDescriptor={{
defaultMessage: 'Text',
description: 'Label for the text tool',
id: 'paint.textMode.text'
}}
imgSrc={textIcon}
isSelected={props.isSelected}
onMouseDown={props.onMouseDown}
/>
);
TextModeComponent.propTypes = {
isSelected: PropTypes.bool.isRequired,
onMouseDown: PropTypes.func.isRequired
};
export default TextModeComponent;

View file

@ -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(

View file

@ -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';
@ -43,6 +45,7 @@ class PaintEditor extends React.Component {
'canUndo',
'onMouseDown',
'setCanvas',
'setTextArea',
'startEyeDroppingLoop',
'stopEyeDroppingLoop'
]);
@ -52,7 +55,12 @@ 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) {
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);
@ -132,32 +140,37 @@ class PaintEditor extends React.Component {
}
handleZoomIn () {
zoomOnSelection(PaintEditor.ZOOM_INCREMENT);
if (this.props.setSelectedItems) {
this.props.setSelectedItems();
}
this.props.updateViewBounds(paper.view.matrix);
this.props.setSelectedItems();
}
handleZoomOut () {
zoomOnSelection(-PaintEditor.ZOOM_INCREMENT);
if (this.props.setSelectedItems) {
this.props.setSelectedItems();
}
this.props.updateViewBounds(paper.view.matrix);
this.props.setSelectedItems();
}
handleZoomReset () {
resetZoom();
if (this.props.setSelectedItems) {
this.props.setSelectedItems();
}
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;
@ -221,8 +234,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}
@ -256,16 +271,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 => ({
@ -275,6 +293,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 => ({
@ -287,14 +306,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());
},
@ -304,9 +340,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));
}
});

View file

@ -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 */

View file

@ -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;
@ -179,6 +180,7 @@ 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)
@ -186,10 +188,12 @@ class PaperCanvas extends React.Component {
// 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();
}
@ -219,7 +223,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
@ -242,6 +247,9 @@ const mapDispatchToProps = dispatch => ({
},
clearPasteOffset: () => {
dispatch(clearPasteOffset());
},
updateViewBounds: matrix => {
dispatch(updateViewBounds(matrix));
}
});

View file

@ -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(

View file

@ -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(

View file

@ -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 (
<TextModeComponent
isSelected={this.props.isTextModeActive}
onMouseDown={this.props.handleMouseDown}
/>
);
}
}
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);

View file

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

View file

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

View file

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

View file

@ -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<paper.Item>} 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: 'Times',
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;

View file

@ -9,7 +9,8 @@ const Modes = keyMirror({
RESHAPE: null,
OVAL: null,
RECT: null,
ROUNDED_RECT: null
ROUNDED_RECT: null,
TEXT: null
});
export default Modes;

View file

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

View file

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

View file

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