Merge pull request #1 from LLK/develop

Get my fork up to date
This commit is contained in:
Jacco Kulman 2018-04-02 09:20:00 +02:00 committed by GitHub
commit 3a86ffe642
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 813 additions and 123 deletions

13
.github/CONTRIBUTING.md vendored Normal file
View file

@ -0,0 +1,13 @@
## Contributing
The development of Scratch is an ongoing process, and we love to have people in the Scratch and open source communities help us along the way.
If you're interested in contributing, please take a look at the [issues](https://github.com/LLK/scratch-vm/issues) on this repository.
Two great ways of helping are by identifying bugs and documenting them as issues, or fixing issues and creating pull requests. When looking for bugs to fix, please look for the ["Help Wanted" label](https://github.com/LLK/scratch-vm/issues?q=label%3A%22help+wanted%22). Bugs with this label have been specifically set aside for Open Source contributors. Issues without the label can also be worked on but we ask that you comment on the issue prior to starting work. When submitting pull requests please be patient -- it can take a while to find time to review them. The organization and class structures can't be radically changed without significant coordination and collaboration from the Scratch Team, so these types of changes should be avoided.
It's been said that the Scratch Team spends about one hour of design discussion for every pixel in Scratch, but some think that estimate is a little low. While we welcome suggestions for new features in our [suggestions forum](https://scratch.mit.edu/discuss/1/) (especially ones that come with mockups), we are unlikely to accept PRs with new features that haven't been thought through and discussed as a group. Why? Because we have a strong belief in the value of keeping things simple for new users. To learn more about our design philosophy, see [the Scratch Developers page](https://scratch.mit.edu/developers), or [this paper](http://web.media.mit.edu/~mres/papers/Scratch-CACM-final.pdf).
Beyond this repo, there are also some other resources that you might want to take a look at:
* [Community Guidelines](https://github.com/LLK/scratch-www/wiki/Community-Guidelines) (we find it important to maintain a constructive and welcoming community, just like on Scratch)
* [Open Source forum](https://scratch.mit.edu/discuss/49/) on Scratch
* [Suggestions forum](https://scratch.mit.edu/discuss/1/) on Scratch
* [Bugs & Glitches forum](https://scratch.mit.edu/discuss/3/) on Scratch

15
.github/ISSUE_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,15 @@
### Expected Behavior
_Please describe what should happen_
### Actual Behavior
_Describe what actually happens_
### Steps to Reproduce
_Explain what someone needs to do in order to see what's described in *Actual behavior* above_
### Operating System and Browser
_e.g. Mac OS 10.11.6 Safari 10.0_

15
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,15 @@
### Resolves
_What Github issue does this resolve (please include link)?_
### Proposed Changes
_Describe what this Pull Request does_
### Reason for Changes
_Explain why these changes should be made_
### Test Coverage
_Please show how you have added tests to cover your changes_

View file

@ -26,7 +26,7 @@
"react-dom": "^16"
},
"devDependencies": {
"@scratch/paper": "~0.11.6",
"@scratch/paper": "0.11.20180329192534",
"autoprefixer": "8.1.0",
"babel-cli": "6.26.0",
"babel-core": "^6.23.1",

View file

@ -203,3 +203,20 @@ $border-radius: 0.25rem;
justify-content: flex-start;
}
}
.text-area {
background: transparent;
border: none;
display: none;
font-family: Helvetica;
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,11 +366,10 @@ const PaintEditorComponent = props => {
<RectMode
onUpdateSvg={props.onUpdateSvg}
/>
{/* text tool, coming soon */}
<TextModeComponent />
</div>
) : null}
<div>
{/* Canvas */}
<div
className={classNames(
@ -383,6 +385,11 @@ const PaintEditorComponent = props => {
svgId={props.svgId}
onUpdateSvg={props.onUpdateSvg}
/>
<textarea
className={styles.textArea}
ref={props.setTextArea}
spellCheck={false}
/>
{props.isEyeDropping &&
props.colorInfo !== null &&
!props.colorInfo.hideLoupe ? (
@ -394,6 +401,7 @@ const PaintEditorComponent = props => {
</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"
>
const TextModeComponent = props => (
<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
isSelected={props.isSelected}
onMouseDown={props.onMouseDown}
/>
</ComingSoonTooltip>
);
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';
@ -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));
}
});

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;
@ -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(/<svg [^>]*>/);
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));
}
});

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

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,56 +16,51 @@ 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) {
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)';
} else if (item.parent instanceof paper.CompoundPath) {
item = item.parent;
}
if (!_colorMatch(item.fillColor, colorString)) {
changed = true;
item.fillColor = colorString;
}
}
}
return changed;
};
/**
* 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: '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;

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