Merge pull request #14 from fsih/select

Select
This commit is contained in:
DD Liu 2017-10-06 13:18:53 -04:00 committed by GitHub
commit 7c51db0353
31 changed files with 2000 additions and 12 deletions

View file

@ -3,6 +3,7 @@ import React from 'react';
import PaperCanvas from '../containers/paper-canvas.jsx';
import BrushMode from '../containers/brush-mode.jsx';
import EraserMode from '../containers/eraser-mode.jsx';
import SelectMode from '../containers/select-mode.jsx';
import PropTypes from 'prop-types';
import LineMode from '../containers/line-mode.jsx';
import FillColorIndicatorComponent from '../containers/fill-color-indicator.jsx';
@ -126,6 +127,9 @@ class PaintEditorComponent extends React.Component {
canvas={this.state.canvas}
onUpdateSvg={this.props.onUpdateSvg}
/>
<SelectMode
onUpdateSvg={this.props.onUpdateSvg}
/>
</div>
) : null}

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
const SelectModeComponent = props => (
<button onClick={props.onMouseDown}>
<FormattedMessage
defaultMessage="Select"
description="Label for the select tool, which allows selecting, moving, and resizing shapes"
id="paint.selectMode.select"
/>
</button>
);
SelectModeComponent.propTypes = {
onMouseDown: PropTypes.func.isRequired
};
export default SelectModeComponent;

View file

@ -3,6 +3,7 @@ import log from '../../log/log';
import BroadBrushHelper from './broad-brush-helper';
import SegmentBrushHelper from './segment-brush-helper';
import {styleCursorPreview} from './style-path';
import {clearSelection} from '../../helper/selection';
/**
* Shared code for the brush and eraser mode. Adds functions on the paper tool object
@ -232,8 +233,7 @@ class Blobbiness {
// Eraser didn't hit anything selected, so assume they meant to erase from all instead of from subset
// and deselect the selection
if (items.length === 0) {
// TODO: Add back selection handling
// pg.selection.clearSelection();
clearSelection();
items = paper.project.getItems({
match: function (item) {
return blob.isMergeable(lastPath, item) && blob.touches(lastPath, item);

View file

@ -2,8 +2,6 @@ const stylePath = function (path, options) {
if (options.isEraser) {
path.fillColor = 'white';
} else {
// TODO: Add back brush styling. Keep a separate active toolbar style for brush vs pen.
// path = pg.stylebar.applyActiveToolbarStyle(path);
path.fillColor = options.fillColor;
}
};
@ -14,8 +12,6 @@ const styleCursorPreview = function (path, options) {
path.strokeColor = 'cornflowerblue';
path.strokeWidth = 1;
} else {
// TODO: Add back brush styling. Keep a separate active toolbar style for brush vs pen.
// path = pg.stylebar.applyActiveToolbarStyle(path);
path.fillColor = options.fillColor;
}
};

View file

@ -6,6 +6,7 @@ import Modes from '../modes/modes';
import Blobbiness from './blob/blob';
import {changeBrushSize} from '../reducers/brush-mode';
import {changeMode} from '../reducers/modes';
import {clearSelection} from '../helper/selection';
import BrushModeComponent from '../components/brush-mode.jsx';
class BrushMode extends React.Component {
@ -42,7 +43,7 @@ class BrushMode extends React.Component {
activateTool () {
// TODO: Instead of clearing selection, consider a kind of "draw inside"
// analogous to how selection works with eraser
// pg.selection.clearSelection();
clearSelection();
// TODO: This is temporary until a component that provides the brush size is hooked up
this.props.canvas.addEventListener('mousewheel', this.onScroll);

View file

@ -4,6 +4,7 @@ import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
import Modes from '../modes/modes';
import {changeStrokeWidth} from '../reducers/stroke-width';
import {clearSelection} from '../helper/selection';
import LineModeComponent from '../components/line-mode.jsx';
import {changeMode} from '../reducers/modes';
import paper from 'paper';
@ -42,8 +43,7 @@ class LineMode extends React.Component {
return false; // Static component, for now
}
activateTool () {
// TODO add back selection
// pg.selection.clearSelection();
clearSelection();
this.props.canvas.addEventListener('mousewheel', this.onScroll);
this.tool = new paper.Tool();

View file

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import PaintEditorComponent from '../components/paint-editor.jsx';
import {changeMode} from '../reducers/modes';
import {getGuideLayer} from '../helper/layer';
import Modes from '../modes/modes';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
@ -21,6 +22,8 @@ class PaintEditor extends React.Component {
document.removeEventListener('keydown', this.props.onKeyPress);
}
handleUpdateSvg () {
// Hide bounding box
getGuideLayer().visible = false;
const bounds = paper.project.activeLayer.bounds;
this.props.onUpdateSvg(
paper.project.exportSVG({
@ -29,6 +32,7 @@ class PaintEditor extends React.Component {
}),
paper.project.view.center.x - bounds.x,
paper.project.view.center.y - bounds.y);
getGuideLayer().visible = true;
}
render () {
return (
@ -58,6 +62,8 @@ const mapDispatchToProps = dispatch => ({
dispatch(changeMode(Modes.BRUSH));
} else if (event.key === 'l') {
dispatch(changeMode(Modes.LINE));
} else if (event.key === 's') {
dispatch(changeMode(Modes.SELECT));
}
}
});

View file

@ -4,4 +4,7 @@
margin: auto;
position: relative;
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 */
image-rendering: pixelated;
}

View file

@ -33,7 +33,15 @@ class PaperCanvas extends React.Component {
onLoad: function (item) {
// Remove viewbox
if (item.clipped) {
let mask;
for (const child of item.children) {
if (child.isClipMask()) {
mask = child;
break;
}
}
item.clipped = false;
mask.remove();
// Consider removing clip mask here?
}
while (item.reduce() !== item) {

View file

@ -0,0 +1,84 @@
import PropTypes from 'prop-types';
import React from 'react';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
import Modes from '../modes/modes';
import {changeMode} from '../reducers/modes';
import {setHoveredItem, clearHoveredItem} from '../reducers/hover';
import SelectTool from '../helper/selection-tools/select-tool';
import SelectModeComponent from '../components/select-mode.jsx';
class SelectMode extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'activateTool',
'deactivateTool'
]);
}
componentDidMount () {
if (this.props.isSelectModeActive) {
this.activateTool(this.props);
}
}
componentWillReceiveProps (nextProps) {
if (this.tool && nextProps.hoveredItemId !== this.props.hoveredItemId) {
this.tool.setPrevHoveredItemId(nextProps.hoveredItemId);
}
if (nextProps.isSelectModeActive && !this.props.isSelectModeActive) {
this.activateTool();
} else if (!nextProps.isSelectModeActive && this.props.isSelectModeActive) {
this.deactivateTool();
}
}
shouldComponentUpdate () {
return false; // Static component, for now
}
activateTool () {
this.tool = new SelectTool(this.props.setHoveredItem, this.props.clearHoveredItem, this.props.onUpdateSvg);
this.tool.activate();
}
deactivateTool () {
this.tool.deactivateTool();
this.tool.remove();
this.tool = null;
}
render () {
return (
<SelectModeComponent onMouseDown={this.props.handleMouseDown} />
);
}
}
SelectMode.propTypes = {
clearHoveredItem: PropTypes.func.isRequired,
handleMouseDown: PropTypes.func.isRequired,
hoveredItemId: PropTypes.number,
isSelectModeActive: PropTypes.bool.isRequired,
onUpdateSvg: PropTypes.func.isRequired,
setHoveredItem: PropTypes.func.isRequired
};
const mapStateToProps = state => ({
isSelectModeActive: state.scratchPaint.mode === Modes.SELECT,
hoveredItemId: state.scratchPaint.hoveredItemId
});
const mapDispatchToProps = dispatch => ({
setHoveredItem: hoveredItemId => {
dispatch(setHoveredItem(hoveredItemId));
},
clearHoveredItem: () => {
dispatch(clearHoveredItem());
},
handleMouseDown: () => {
dispatch(changeMode(Modes.SELECT));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(SelectMode);

View file

@ -0,0 +1,60 @@
import PropTypes from 'prop-types';
import React from 'react';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
import paper from 'paper';
const SelectionHOC = function (WrappedComponent) {
class SelectionComponent extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'removeItemById'
]);
}
componentDidMount () {
if (this.props.hoveredItemId) {
paper.view.update();
}
}
componentDidUpdate (prevProps) {
// Hovered item has changed
if ((this.props.hoveredItemId && this.props.hoveredItemId !== prevProps.hoveredItemId) ||
(!this.props.hoveredItemId && prevProps.hoveredItemId)) {
// Remove the old hover item if any
this.removeItemById(prevProps.hoveredItemId);
}
}
removeItemById (itemId) {
if (itemId) {
const match = paper.project.getItem({
match: item => (item.id === itemId)
});
if (match) {
match.remove();
}
}
}
render () {
const {
hoveredItemId, // eslint-disable-line no-unused-vars
...props
} = this.props;
return (
<WrappedComponent {...props} />
);
}
}
SelectionComponent.propTypes = {
hoveredItemId: PropTypes.number
};
const mapStateToProps = state => ({
hoveredItemId: state.scratchPaint.hoveredItemId
});
return connect(
mapStateToProps
)(SelectionComponent);
};
export default SelectionHOC;

View file

@ -0,0 +1,27 @@
const isCompoundPath = function (item) {
return item && item.className === 'CompoundPath';
};
const isCompoundPathChild = function (item) {
if (item.parent) {
return item.parent.className === 'CompoundPath';
}
return false;
};
const getItemsCompoundPath = function (item) {
const itemParent = item.parent;
if (isCompoundPath(itemParent)) {
return itemParent;
}
return null;
};
export {
isCompoundPath,
isCompoundPathChild,
getItemsCompoundPath
};

130
src/helper/group.js Normal file
View file

@ -0,0 +1,130 @@
import paper from 'paper';
import {getRootItem, isGroupItem} from './item';
import {clearSelection, getSelectedItems, setItemSelection} from './selection';
const isGroup = function (item) {
return isGroupItem(item);
};
const groupSelection = function () {
const items = getSelectedItems();
if (items.length > 0) {
const group = new paper.Group(items);
clearSelection();
setItemSelection(group, true);
for (let i = 0; i < group.children.length; i++) {
group.children[i].selected = true;
}
// @todo: Set selection bounds; enable/disable grouping icons
// @todo add back undo
// pg.undo.snapshot('groupSelection');
return group;
}
return false;
};
const ungroupLoop = function (group, recursive) {
// don't ungroup items that are not groups
if (!group || !group.children || !isGroup(group)) return;
group.applyMatrix = true;
// iterate over group children recursively
for (let i = 0; i < group.children.length; i++) {
const groupChild = group.children[i];
if (groupChild.hasChildren()) {
// recursion (groups can contain groups, ie. from SVG import)
if (recursive) {
ungroupLoop(groupChild, true /* recursive */);
continue;
}
}
groupChild.applyMatrix = true;
// move items from the group to the activeLayer (ungrouping)
groupChild.insertBelow(group);
groupChild.selected = true;
i--;
}
};
// ungroup items (only top hierarchy)
const ungroupItems = function (items) {
clearSelection();
const emptyGroups = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (isGroup(item) && !item.data.isPGTextItem) {
ungroupLoop(item, false /* recursive */);
if (!item.hasChildren()) {
emptyGroups.push(item);
}
}
}
// remove all empty groups after ungrouping
for (let j = 0; j < emptyGroups.length; j++) {
emptyGroups[j].remove();
}
// @todo: Set selection bounds; enable/disable grouping icons
// @todo add back undo
// pg.undo.snapshot('ungroupItems');
};
const ungroupSelection = function () {
const items = getSelectedItems();
ungroupItems(items);
};
const groupItems = function (items) {
if (items.length > 0) {
const group = new paper.Group(items);
// @todo: Set selection bounds; enable/disable grouping icons
// @todo add back undo
// pg.undo.snapshot('groupItems');
return group;
}
return false;
};
const getItemsGroup = function (item) {
const itemParent = item.parent;
if (isGroup(itemParent)) {
return itemParent;
}
return null;
};
const isGroupChild = function (item) {
const rootItem = getRootItem(item);
return isGroup(rootItem);
};
const shouldShowGroup = function () {
const items = getSelectedItems();
return items.length > 1;
};
const shouldShowUngroup = function () {
const items = getSelectedItems();
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (isGroup(item) && !item.data.isPGTextItem && item.children && item.children.length > 0) {
return true;
}
}
return false;
};
export {
groupSelection,
ungroupSelection,
groupItems,
ungroupItems,
getItemsGroup,
isGroup,
isGroupChild,
shouldShowGroup,
shouldShowUngroup
};

106
src/helper/guides.js Normal file
View file

@ -0,0 +1,106 @@
import paper from 'paper';
import {getGuideLayer} from './layer';
import {getAllPaperItems} from './selection';
const GUIDE_BLUE = '#009dec';
const GUIDE_GREY = '#aaaaaa';
const setDefaultGuideStyle = function (item) {
item.strokeWidth = 1 / paper.view.zoom;
item.opacity = 1;
item.blendMode = 'normal';
item.guide = true;
};
const hoverItem = function (hitResult) {
const segments = hitResult.item.segments;
const clone = new paper.Path(segments);
setDefaultGuideStyle(clone);
if (hitResult.item.closed) {
clone.closed = true;
}
clone.parent = getGuideLayer();
clone.strokeColor = GUIDE_BLUE;
clone.fillColor = null;
clone.data.isHelperItem = true;
clone.bringToFront();
return clone;
};
const hoverBounds = function (item) {
const rect = new paper.Path.Rectangle(item.internalBounds);
rect.matrix = item.matrix;
setDefaultGuideStyle(rect);
rect.parent = getGuideLayer();
rect.strokeColor = GUIDE_BLUE;
rect.fillColor = null;
rect.data.isHelperItem = true;
rect.bringToFront();
return rect;
};
const rectSelect = function (event, color) {
const half = new paper.Point(0.5 / paper.view.zoom, 0.5 / paper.view.zoom);
const start = event.downPoint.add(half);
const end = event.point.add(half);
const rect = new paper.Path.Rectangle(start, end);
const zoom = 1.0 / paper.view.zoom;
setDefaultGuideStyle(rect);
if (!color) color = GUIDE_GREY;
rect.parent = getGuideLayer();
rect.strokeColor = color;
rect.data.isRectSelect = true;
rect.data.isHelperItem = true;
rect.dashArray = [3.0 * zoom, 3.0 * zoom];
return rect;
};
const getGuideColor = function (colorName) {
if (colorName === 'blue') {
return GUIDE_BLUE;
} else if (colorName === 'grey') {
return GUIDE_GREY;
}
};
const _removePaperItemsByDataTags = function (tags) {
const allItems = getAllPaperItems(true);
for (const item of allItems) {
for (const tag of tags) {
if (item.data && item.data[tag]) {
item.remove();
}
}
}
};
const _removePaperItemsByTags = function (tags) {
const allItems = getAllPaperItems(true);
for (const item of allItems) {
for (const tag of tags) {
if (item[tag]) {
item.remove();
}
}
}
};
const removeHelperItems = function () {
_removePaperItemsByDataTags(['isHelperItem']);
};
const removeAllGuides = function () {
_removePaperItemsByTags(['guide']);
};
export {
hoverItem,
hoverBounds,
rectSelect,
removeAllGuides,
removeHelperItems,
getGuideColor,
setDefaultGuideStyle
};

38
src/helper/hover.js Normal file
View file

@ -0,0 +1,38 @@
import paper from 'paper';
import {isBoundsItem, getRootItem} from './item';
import {hoverBounds, hoverItem} from './guides';
import {isGroupChild} from './group';
/**
* @param {!MouseEvent} event mouse event
* @param {?object} hitOptions hit options to use
* @return {paper.Item} the hovered item or null if there is none
*/
const getHoveredItem = function (event, hitOptions) {
const hitResults = paper.project.hitTestAll(event.point, hitOptions);
if (hitResults.length === 0) {
return null;
}
let hitResult;
for (const result of hitResults) {
if (!(result.item.data && result.item.data.noHover) && !result.item.selected) {
hitResult = result;
break;
}
}
if (!hitResult) {
return null;
}
if (isBoundsItem(hitResult.item)) {
return hoverBounds(hitResult.item);
} else if (isGroupChild(hitResult.item)) {
return hoverBounds(getRootItem(hitResult.item));
}
return hoverItem(hitResult);
};
export {
getHoveredItem
};

78
src/helper/item.js Normal file
View file

@ -0,0 +1,78 @@
import paper from 'paper';
const getRootItem = function (item) {
if (item.parent.className === 'Layer') {
return item;
}
return getRootItem(item.parent);
};
const isBoundsItem = function (item) {
if (item.className === 'PointText' ||
item.className === 'Shape' ||
item.className === 'PlacedSymbol' ||
item.className === 'Raster') {
return true;
}
return false;
};
const isPathItem = function (item) {
return item.className === 'Path';
};
const isCompoundPathItem = function (item) {
return item.className === 'CompoundPath';
};
const isGroupItem = function (item) {
return item && item.className && item.className === 'Group';
};
const isPointTextItem = function (item) {
return item.className === 'PointText';
};
const isPGTextItem = function (item) {
return getRootItem(item).data.isPGTextItem;
};
const setPivot = function (item, point) {
if (isBoundsItem(item)) {
item.pivot = item.globalToLocal(point);
} else {
item.pivot = point;
}
};
const getPositionInView = function (item) {
const itemPos = new paper.Point();
itemPos.x = item.position.x - paper.view.bounds.x;
itemPos.y = item.position.y - paper.view.bounds.y;
return itemPos;
};
const setPositionInView = function (item, pos) {
item.position.x = paper.view.bounds.x + pos.x;
item.position.y = paper.view.bounds.y + pos.y;
};
export {
isBoundsItem,
isPathItem,
isCompoundPathItem,
isGroupItem,
isPointTextItem,
isPGTextItem,
setPivot,
getPositionInView,
setPositionInView,
getRootItem
};

18
src/helper/layer.js Normal file
View file

@ -0,0 +1,18 @@
import paper from 'paper';
const getGuideLayer = function () {
for (let i = 0; i < paper.project.layers.length; i++) {
const layer = paper.project.layers[i];
if (layer.data && layer.data.isGuideLayer) {
return layer;
}
}
// Create if it doesn't exist
const guideLayer = new paper.Layer();
guideLayer.data.isGuideLayer = true;
guideLayer.bringToFront();
return guideLayer;
};
export {getGuideLayer};

35
src/helper/math.js Normal file
View file

@ -0,0 +1,35 @@
import paper from 'paper';
const checkPointsClose = function (startPos, eventPoint, threshold) {
const xOff = Math.abs(startPos.x - eventPoint.x);
const yOff = Math.abs(startPos.y - eventPoint.y);
if (xOff < threshold && yOff < threshold) {
return true;
}
return false;
};
const getRandomInt = function (min, max) {
return Math.floor(Math.random() * (max - min)) + min;
};
const getRandomBoolean = function () {
return getRandomInt(0, 2) === 1;
};
// Thanks Mikko Mononen! https://github.com/memononen/stylii
const snapDeltaToAngle = function (delta, snapAngle) {
let angle = Math.atan2(delta.y, delta.x);
angle = Math.round(angle / snapAngle) * snapAngle;
const dirx = Math.cos(angle);
const diry = Math.sin(angle);
const d = (dirx * delta.x) + (diry * delta.y);
return new paper.Point(dirx * d, diry * d);
};
export {
checkPointsClose,
getRandomInt,
getRandomBoolean,
snapDeltaToAngle
};

View file

@ -0,0 +1,197 @@
import paper from 'paper';
import keyMirror from 'keymirror';
import {clearSelection, getSelectedItems} from '../selection';
import {getGuideColor, removeHelperItems} from '../guides';
import {getGuideLayer} from '../layer';
import ScaleTool from './scale-tool';
import RotateTool from './rotate-tool';
import MoveTool from './move-tool';
/** SVG for the rotation icon on the bounding box */
const ARROW_PATH = 'M19.28,1.09C19.28.28,19,0,18.2,0c-1.67,0-3.34,0-5,0-.34,0-.88.24-1,.47a1.4,1.4,' +
'0,0,0,.36,1.08,15.27,15.27,0,0,0,1.46,1.36A6.4,6.4,0,0,1,6.52,4,5.85,5.85,0,0,1,5.24,3,15.27,15.27,' +
'0,0,0,6.7,1.61,1.4,1.4,0,0,0,7.06.54C7,.3,6.44.07,6.1.06c-1.67,0-3.34,0-5,0C.28,0,0,.31,0,1.12c0,1.67,' +
'0,3.34,0,5a1.23,1.23,0,0,0,.49,1,1.22,1.22,0,0,0,1-.31A14.38,14.38,0,0,0,2.84,5.26l.73.62a9.45,9.45,' +
'0,0,0,7.34,2,9.45,9.45,0,0,0,4.82-2.05l.73-.62a14.38,14.38,0,0,0,1.29,1.51,1.22,1.22,' +
'0,0,0,1,.31,1.23,1.23,0,0,0,.49-1C19.31,4.43,19.29,2.76,19.28,1.09Z';
/** Modes of the bounding box tool, which can do many things depending on how it's used. */
const Modes = keyMirror({
SCALE: null,
ROTATE: null,
MOVE: null
});
/**
* Tool that handles transforming the selection and drawing a bounding box with handles.
* On mouse down, the type of function (move, scale, rotate) is determined based on what is clicked
* (scale handle, rotate handle, the object itself). This determines the mode of the tool, which then
* delegates actions to the MoveTool, RotateTool or ScaleTool accordingly.
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
*/
class BoundingBoxTool {
constructor (onUpdateSvg) {
this.onUpdateSvg = onUpdateSvg;
this.mode = null;
this.boundsPath = null;
this.boundsScaleHandles = [];
this.boundsRotHandles = [];
this._modeMap = {};
this._modeMap[Modes.SCALE] = new ScaleTool(onUpdateSvg);
this._modeMap[Modes.ROTATE] = new RotateTool(onUpdateSvg);
this._modeMap[Modes.MOVE] = new MoveTool(onUpdateSvg);
}
/**
* @param {!MouseEvent} event The mouse event
* @param {boolean} clone Whether to clone on mouse down (e.g. alt key held)
* @param {boolean} multiselect Whether to multiselect on mouse down (e.g. shift key held)
* @param {paper.hitOptions} hitOptions The options with which to detect whether mouse down has hit
* anything editable
* @return {boolean} True if there was a hit, false otherwise
*/
onMouseDown (event, clone, multiselect, hitOptions) {
const hitResults = paper.project.hitTestAll(event.point, hitOptions);
if (!hitResults || hitResults.length === 0) {
if (!multiselect) {
this.removeBoundsPath();
clearSelection();
}
return false;
}
// Prefer scale to trigger over rotate, and scale and rotate to trigger over other hits
let hitResult = hitResults[0];
for (let i = 0; i < hitResults.length; i++) {
if (hitResults[i].item.data && hitResults[i].item.data.isScaleHandle) {
hitResult = hitResults[i];
this.mode = Modes.SCALE;
break;
} else if (hitResults[i].item.data && hitResults[i].item.data.isRotHandle) {
hitResult = hitResults[i];
this.mode = Modes.ROTATE;
}
}
if (!this.mode) {
this.mode = Modes.MOVE;
}
const hitProperties = {
hitResult: hitResult,
clone: event.modifiers.alt,
multiselect: event.modifiers.shift
};
if (this.mode === Modes.MOVE) {
this._modeMap[this.mode].onMouseDown(hitProperties);
} else if (this.mode === Modes.SCALE) {
this._modeMap[this.mode].onMouseDown(
hitResult, this.boundsPath, this.boundsScaleHandles, this.boundsRotHandles, getSelectedItems());
} else if (this.mode === Modes.ROTATE) {
this._modeMap[this.mode].onMouseDown(hitResult, this.boundsPath, getSelectedItems());
}
// while transforming object, never show the bounds stuff
this.removeBoundsPath();
return true;
}
onMouseDrag (event) {
if (event.event.button > 0) return; // only first mouse button
this._modeMap[this.mode].onMouseDrag(event);
}
onMouseUp (event) {
if (event.event.button > 0) return; // only first mouse button
this._modeMap[this.mode].onMouseUp(event);
this.mode = null;
this.setSelectionBounds();
}
setSelectionBounds () {
this.removeBoundsPath();
const items = getSelectedItems(true /* recursive */);
if (items.length <= 0) return;
let rect = null;
for (const item of items) {
if (rect) {
rect = rect.unite(item.bounds);
} else {
rect = item.bounds;
}
}
if (!this.boundsPath) {
this.boundsPath = new paper.Path.Rectangle(rect);
this.boundsPath.curves[0].divideAtTime(0.5);
this.boundsPath.curves[2].divideAtTime(0.5);
this.boundsPath.curves[4].divideAtTime(0.5);
this.boundsPath.curves[6].divideAtTime(0.5);
}
this.boundsPath.guide = true;
this.boundsPath.data.isSelectionBound = true;
this.boundsPath.data.isHelperItem = true;
this.boundsPath.fillColor = null;
this.boundsPath.strokeScaling = false;
this.boundsPath.fullySelected = true;
this.boundsPath.parent = getGuideLayer();
for (let index = 0; index < this.boundsPath.segments.length; index++) {
const segment = this.boundsPath.segments[index];
let size = 4;
if (index % 2 === 0) {
size = 6;
}
if (index === 7) {
const offset = new paper.Point(0, 20);
const arrows = new paper.Path(ARROW_PATH);
arrows.translate(segment.point.add(offset).add(-10.5, -5));
const line = new paper.Path.Rectangle(
segment.point.add(offset).subtract(1, 0),
segment.point);
const rotHandle = arrows.unite(line);
line.remove();
arrows.remove();
rotHandle.scale(1 / paper.view.zoom, segment.point);
rotHandle.data = {
offset: offset,
isRotHandle: true,
isHelperItem: true,
noSelect: true,
noHover: true
};
rotHandle.fillColor = getGuideColor('blue');
rotHandle.parent = getGuideLayer();
this.boundsRotHandles[index] = rotHandle;
}
this.boundsScaleHandles[index] =
new paper.Path.Rectangle({
center: segment.point,
data: {
index: index,
isScaleHandle: true,
isHelperItem: true,
noSelect: true,
noHover: true
},
size: [size / paper.view.zoom, size / paper.view.zoom],
fillColor: getGuideColor('blue'),
parent: getGuideLayer()
});
}
}
removeBoundsPath () {
removeHelperItems();
this.boundsPath = null;
this.boundsScaleHandles.length = 0;
this.boundsRotHandles.length = 0;
}
}
export default BoundingBoxTool;

View file

@ -0,0 +1,104 @@
import {isGroup} from '../group';
import {isCompoundPathItem, getRootItem} from '../item';
import {snapDeltaToAngle} from '../math';
import {clearSelection, cloneSelection, getSelectedItems, setItemSelection} from '../selection';
/**
* Tool to handle dragging an item to reposition it in a selection mode.
*/
class MoveTool {
/**
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
*/
constructor (onUpdateSvg) {
this.selectedItems = null;
this.onUpdateSvg = onUpdateSvg;
}
/**
* @param {!object} hitProperties Describes the mouse event
* @param {!paper.HitResult} hitProperties.hitResult Data about the location of the mouse click
* @param {?boolean} hitProperties.clone Whether to clone on mouse down (e.g. alt key held)
* @param {?boolean} hitProperties.multiselect Whether to multiselect on mouse down (e.g. shift key held)
* @param {?boolean} hitProperties.doubleClicked True if this is the second click in a short amout of time
* @param {?boolean} hitProperties.subselect True if we allow selection of subgroups, false if we should
* select the whole group.
*/
onMouseDown (hitProperties) {
let item = hitProperties.hitResult.item;
if (!hitProperties.subselect) {
const root = getRootItem(hitProperties.hitResult.item);
item = isCompoundPathItem(root) || isGroup(root) ? root : hitProperties.hitResult.item;
}
if (item.selected) {
// Double click causes all points to be selected in subselect mode.
if (hitProperties.doubleClicked) {
if (!hitProperties.multiselect) {
clearSelection();
}
this._select(item, true /* state */, hitProperties.subselect, true /* fullySelect */);
} else if (hitProperties.multiselect) {
this._select(item, false /* state */, hitProperties.subselect);
}
} else {
// deselect all by default if multiselect isn't on
if (!hitProperties.multiselect) {
clearSelection();
}
this._select(item, true, hitProperties.subselect);
}
if (hitProperties.clone) cloneSelection(hitProperties.subselect);
this.selectedItems = getSelectedItems(true /* subselect */);
}
/**
* Sets the selection state of an item.
* @param {!paper.Item} item Item to select or deselect
* @param {?boolean} state True if item should be selected, false if deselected
* @param {?boolean} subselect True if a subset of all points in an item are allowed to be
* selected, false if items must be selected all or nothing.
* @param {?boolean} fullySelect True if in addition to the item being selected, all of its
* control points should be selected. False if the item should be selected but not its
* points. Only relevant when subselect is true.
*/
_select (item, state, subselect, fullySelect) {
if (subselect) {
item.selected = false;
if (fullySelect) {
item.fullySelected = state;
} else {
item.selected = state;
}
} else {
setItemSelection(item, state);
}
}
onMouseDrag (event) {
const dragVector = event.point.subtract(event.downPoint);
for (const item of this.selectedItems) {
// add the position of the item before the drag started
// for later use in the snap calculation
if (!item.data.origPos) {
item.data.origPos = item.position;
}
if (event.modifiers.shift) {
item.position = item.data.origPos.add(snapDeltaToAngle(dragVector, Math.PI / 4));
} else {
item.position = item.data.origPos.add(dragVector);
}
}
}
onMouseUp () {
// resetting the items origin point for the next usage
for (const item of this.selectedItems) {
item.data.origPos = null;
}
this.selectedItems = null;
// @todo add back undo
// pg.undo.snapshot('moveSelection');
this.onUpdateSvg();
}
}
export default MoveTool;

View file

@ -0,0 +1,71 @@
import paper from 'paper';
/**
* Tool to handle rotation when dragging the rotation handle in the bounding box tool.
*/
class RotateTool {
/**
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
*/
constructor (onUpdateSvg) {
this.rotItems = [];
this.rotGroupPivot = null;
this.prevRot = [];
this.onUpdateSvg = onUpdateSvg;
}
/**
* @param {!paper.HitResult} hitResult Data about the location of the mouse click
* @param {!object} boundsPath Where the boundaries of the hit item are
* @param {!Array.<paper.Item>} selectedItems Set of selected paper.Items
*/
onMouseDown (hitResult, boundsPath, selectedItems) {
this.rotGroupPivot = boundsPath.bounds.center;
for (const item of selectedItems) {
// Rotate only root items
if (item.parent instanceof paper.Layer) {
this.rotItems.push(item);
}
}
for (let i = 0; i < this.rotItems.length; i++) {
this.prevRot[i] = 90;
}
}
onMouseDrag (event) {
let rotAngle = (event.point.subtract(this.rotGroupPivot)).angle;
for (let i = 0; i < this.rotItems.length; i++) {
const item = this.rotItems[i];
if (!item.data.origRot) {
item.data.origRot = item.rotation;
}
if (event.modifiers.shift) {
rotAngle = Math.round(rotAngle / 45) * 45;
item.applyMatrix = false;
item.pivot = this.rotGroupPivot;
item.rotation = rotAngle;
} else {
item.rotate(rotAngle - this.prevRot[i], this.rotGroupPivot);
}
this.prevRot[i] = rotAngle;
}
}
onMouseUp (event) {
if (event.event.button > 0) return; // only first mouse button
for (const item of this.rotItems) {
item.applyMatrix = true;
}
this.rotItems.length = 0;
this.rotGroupPivot = null;
this.prevRot = [];
// @todo add back undo
this.onUpdateSvg();
}
}
export default RotateTool;

View file

@ -0,0 +1,205 @@
import paper from 'paper';
/**
* Tool to handle scaling items by pulling on the handles around the edges of the bounding
* box when in the bounding box tool.
*/
class ScaleTool {
/**
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
*/
constructor (onUpdateSvg) {
this.pivot = null;
this.origPivot = null;
this.corner = null;
this.origSize = null;
this.origCenter = null;
this.itemGroup = null;
this.boundsPath = null;
// Lowest item above all scale items in z index
this.itemToInsertBelow = null;
this.scaleItems = [];
this.boundsScaleHandles = [];
this.boundsRotHandles = [];
this.onUpdateSvg = onUpdateSvg;
}
/**
* @param {!paper.HitResult} hitResult Data about the location of the mouse click
* @param {!object} boundsPath Where the boundaries of the hit item are
* @param {!object} boundsScaleHandles Bounding box scale handles
* @param {!object} boundsRotHandles Bounding box rotation handle
* @param {!Array.<paper.Item>} selectedItems Set of selected paper.Items
* @param {boolean} clone Whether to clone on mouse down (e.g. alt key held)
* @param {boolean} multiselect Whether to multiselect on mouse down (e.g. shift key held)
*/
onMouseDown (hitResult, boundsPath, boundsScaleHandles, boundsRotHandles, selectedItems) {
const index = hitResult.item.data.index;
this.boundsPath = boundsPath;
this.boundsScaleHandles = boundsScaleHandles;
this.boundsRotHandles = boundsRotHandles;
this.pivot = this.boundsPath.bounds[this._getOpposingRectCornerNameByIndex(index)].clone();
this.origPivot = this.boundsPath.bounds[this._getOpposingRectCornerNameByIndex(index)].clone();
this.corner = this.boundsPath.bounds[this._getRectCornerNameByIndex(index)].clone();
this.origSize = this.corner.subtract(this.pivot);
this.origCenter = this.boundsPath.bounds.center;
for (const item of selectedItems) {
// Scale only root items
if (item.parent instanceof paper.Layer) {
this.scaleItems.push(item);
}
}
}
onMouseDrag (event) {
const scaleTool = this;
const modOrigSize = this.origSize;
// get item to insert below so that scaled items stay in same z position
const items = paper.project.getItems({
match: function (item) {
if (item instanceof paper.Layer || item.data.isHelperItem) {
return false;
}
for (const scaleItem of scaleTool.scaleItems) {
if (!scaleItem.isBelow(item)) {
return false;
}
}
return true;
}
});
if (items.length > 0) {
this.itemToInsertBelow = items[0];
}
this.itemGroup = new paper.Group(this.scaleItems);
this.itemGroup.insertBelow(this.itemToInsertBelow);
this.itemGroup.addChild(this.boundsPath);
this.itemGroup.data.isHelperItem = true;
this.itemGroup.strokeScaling = false;
this.itemGroup.applyMatrix = false;
if (event.modifiers.alt) {
this.pivot = this.origCenter;
this.modOrigSize = this.origSize * 0.5;
} else {
this.pivot = this.origPivot;
}
this.corner = this.corner.add(event.delta);
const size = this.corner.subtract(this.pivot);
let sx = 1.0;
let sy = 1.0;
if (Math.abs(modOrigSize.x) > 0.0000001) {
sx = size.x / modOrigSize.x;
}
if (Math.abs(modOrigSize.y) > 0.0000001) {
sy = size.y / modOrigSize.y;
}
if (event.modifiers.shift) {
const signx = sx > 0 ? 1 : -1;
const signy = sy > 0 ? 1 : -1;
sx = sy = Math.max(Math.abs(sx), Math.abs(sy));
sx *= signx;
sy *= signy;
}
this.itemGroup.scale(sx, sy, this.pivot);
for (let i = 0; i < this.boundsScaleHandles.length; i++) {
const handle = this.boundsScaleHandles[i];
handle.position = this.itemGroup.bounds[this._getRectCornerNameByIndex(i)];
handle.bringToFront();
}
for (let i = 0; i < this.boundsRotHandles.length; i++) {
const handle = this.boundsRotHandles[i];
if (handle) {
handle.position = this.itemGroup.bounds[this._getRectCornerNameByIndex(i)] + handle.data.offset;
handle.bringToFront();
}
}
}
onMouseUp () {
this.pivot = null;
this.origPivot = null;
this.corner = null;
this.origSize = null;
this.origCenter = null;
this.scaleItems.length = 0;
this.boundsPath = null;
this.boundsScaleHandles = [];
this.boundsRotHandles = [];
if (!this.itemGroup) {
return;
}
this.itemGroup.applyMatrix = true;
// mark text items as scaled (for later use on font size calc)
for (let i = 0; i < this.itemGroup.children.length; i++) {
const child = this.itemGroup.children[i];
if (child.data.isPGTextItem) {
child.data.wasScaled = true;
}
}
if (this.itemToInsertBelow) {
// No increment step because itemGroup.children is getting depleted
for (const i = 0; i < this.itemGroup.children.length;) {
this.itemGroup.children[i].insertBelow(this.itemToInsertBelow);
}
this.itemToInsertBelow = null;
} else if (this.itemGroup.layer) {
this.itemGroup.layer.addChildren(this.itemGroup.children);
}
this.itemGroup.remove();
// @todo add back undo
this.onUpdateSvg();
}
_getRectCornerNameByIndex (index) {
switch (index) {
case 0:
return 'bottomLeft';
case 1:
return 'leftCenter';
case 2:
return 'topLeft';
case 3:
return 'topCenter';
case 4:
return 'topRight';
case 5:
return 'rightCenter';
case 6:
return 'bottomRight';
case 7:
return 'bottomCenter';
}
}
_getOpposingRectCornerNameByIndex (index) {
switch (index) {
case 0:
return 'topRight';
case 1:
return 'rightCenter';
case 2:
return 'bottomRight';
case 3:
return 'bottomCenter';
case 4:
return 'bottomLeft';
case 5:
return 'leftCenter';
case 6:
return 'topLeft';
case 7:
return 'topCenter';
}
}
}
export default ScaleTool;

View file

@ -0,0 +1,136 @@
import Modes from '../../modes/modes';
import {getHoveredItem} from '../hover';
import {deleteSelection, selectRootItem} from '../selection';
import BoundingBoxTool from './bounding-box-tool';
import SelectionBoxTool from './selection-box-tool';
import paper from 'paper';
/**
* paper.Tool that handles select mode. This is made up of 2 subtools.
* - The selection box tool is active when the user clicks an empty space and drags.
* It selects all items in the rectangle.
* - The bounding box tool is active if the user clicks on a non-empty space. It handles
* reshaping the item that was clicked.
*/
class SelectTool extends paper.Tool {
/** The distance within which mouse events count as a hit against an item */
static get TOLERANCE () {
return 6;
}
/**
* @param {function} setHoveredItem Callback to set the hovered item
* @param {function} clearHoveredItem Callback to clear the hovered item
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
*/
constructor (setHoveredItem, clearHoveredItem, onUpdateSvg) {
super();
this.setHoveredItem = setHoveredItem;
this.clearHoveredItem = clearHoveredItem;
this.onUpdateSvg = onUpdateSvg;
this.boundingBoxTool = new BoundingBoxTool(onUpdateSvg);
this.selectionBoxTool = new SelectionBoxTool(Modes.SELECT);
this.selectionBoxMode = false;
// 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.onMouseMove = this.handleMouseMove;
this.onMouseDrag = this.handleMouseDrag;
this.onMouseUp = this.handleMouseUp;
this.onKeyUp = this.handleKeyUp;
selectRootItem();
this.boundingBoxTool.setSelectionBounds();
}
/**
* To be called when the hovered item changes. When the select tool hovers over a
* new item, it compares against this to see if a hover item change event needs to
* be fired.
* @param {paper.Item} prevHoveredItemId ID of the highlight item that indicates the mouse is
* over a given item currently
*/
setPrevHoveredItemId (prevHoveredItemId) {
this.prevHoveredItemId = prevHoveredItemId;
}
/**
* Returns the hit options to use when conducting hit tests.
* @param {boolean} preselectedOnly True if we should only return results that are already
* selected.
* @return {object} See paper.Item.hitTest for definition of options
*/
getHitOptions (preselectedOnly) {
// Tolerance needs to be scaled when the view is zoomed in in order to represent the same
// distance for the user to move the mouse.
const hitOptions = {
segments: true,
stroke: true,
curves: true,
fill: true,
guide: false,
tolerance: SelectTool.TOLERANCE / paper.view.zoom
};
if (preselectedOnly) {
hitOptions.selected = true;
}
return hitOptions;
}
handleMouseDown (event) {
if (event.event.button > 0) return; // only first mouse button
// If bounding box tool does not find an item that was hit, use selection box tool.
this.clearHoveredItem();
if (!this.boundingBoxTool
.onMouseDown(
event,
event.modifiers.alt,
event.modifiers.shift,
this.getHitOptions(false /* preseelectedOnly */))) {
this.selectionBoxMode = true;
this.selectionBoxTool.onMouseDown(event.modifiers.shift);
}
}
handleMouseMove (event) {
const hoveredItem = getHoveredItem(event, this.getHitOptions());
if ((!hoveredItem && this.prevHoveredItemId) || // There is no longer a hovered item
(hoveredItem && !this.prevHoveredItemId) || // There is now a hovered item
(hoveredItem && this.prevHoveredItemId &&
hoveredItem.id !== this.prevHoveredItemId)) { // hovered item changed
this.setHoveredItem(hoveredItem ? hoveredItem.id : null);
}
}
handleMouseDrag (event) {
if (event.event.button > 0) return; // only first mouse button
if (this.selectionBoxMode) {
this.selectionBoxTool.onMouseDrag(event);
} else {
this.boundingBoxTool.onMouseDrag(event);
}
}
handleMouseUp (event) {
if (event.event.button > 0) return; // only first mouse button
if (this.selectionBoxMode) {
this.selectionBoxTool.onMouseUp(event);
this.boundingBoxTool.setSelectionBounds();
} else {
this.boundingBoxTool.onMouseUp(event);
}
this.selectionBoxMode = false;
}
handleKeyUp (event) {
// Backspace, delete
if (event.key === 'delete' || event.key === 'backspace') {
deleteSelection(Modes.SELECT);
this.boundingBoxTool.removeBoundsPath();
this.onUpdateSvg();
}
}
deactivateTool () {
this.clearHoveredItem();
this.boundingBoxTool.removeBoundsPath();
}
}
export default SelectTool;

View file

@ -0,0 +1,32 @@
import {rectSelect} from '../guides';
import {clearSelection, processRectangularSelection} from '../selection';
/** Tool to handle drag selection. A dotted line box appears and everything enclosed is selected. */
class SelectionBoxTool {
constructor (mode) {
this.selectionRect = null;
this.mode = mode;
}
/**
* @param {boolean} multiselect Whether to multiselect on mouse down (e.g. shift key held)
*/
onMouseDown (multiselect) {
if (!multiselect) {
clearSelection();
}
}
onMouseDrag (event) {
this.selectionRect = rectSelect(event);
// Remove this rect on the next drag and up event
this.selectionRect.removeOnDrag();
}
onMouseUp (event) {
if (this.selectionRect) {
processRectangularSelection(event, this.selectionRect, this.mode);
this.selectionRect.remove();
this.selectionRect = null;
}
}
}
export default SelectionBoxTool;

526
src/helper/selection.js Normal file
View file

@ -0,0 +1,526 @@
import paper from 'paper';
import Modes from '../modes/modes';
import {getItemsGroup, isGroup} from './group';
import {getRootItem, isBoundsItem, isCompoundPathItem, isPathItem, isPGTextItem} from './item';
import {getItemsCompoundPath, isCompoundPath, isCompoundPathChild} from './compound-path';
/**
* @param {boolean} includeGuides True if guide layer items like the bounding box should
* be included in the returned items.
* @return {Array<paper.item>} all top-level (direct descendants of a paper.Layer) items
*/
const getAllPaperItems = function (includeGuides) {
includeGuides = includeGuides || false;
const allItems = [];
for (const layer of paper.project.layers) {
for (const child of layer.children) {
// don't give guides back
if (!includeGuides && child.guide) {
continue;
}
allItems.push(child);
}
}
return allItems;
};
/**
* @return {Array<paper.item>} all top-level (direct descendants of a paper.Layer) items
* that aren't guide items or helper items.
*/
const getAllSelectableItems = function () {
const allItems = getAllPaperItems();
const selectables = [];
for (let i = 0; i < allItems.length; i++) {
if (allItems[i].data && !allItems[i].data.isHelperItem) {
selectables.push(allItems[i]);
}
}
return selectables;
};
const selectItemSegments = function (item, state) {
if (item.children) {
for (let i = 0; i < item.children.length; i++) {
const child = item.children[i];
if (child.children && child.children.length > 0) {
selectItemSegments(child, state);
} else {
child.fullySelected = state;
}
}
} else {
for (let i = 0; i < item.segments.length; i++) {
item.segments[i].selected = state;
}
}
};
const setGroupSelection = function (root, selected, fullySelected) {
root.fullySelected = fullySelected;
root.selected = selected;
// select children of compound-path or group
if (isCompoundPath(root) || isGroup(root)) {
const children = root.children;
if (children) {
for (const child of children) {
if (isGroup(child)) {
setGroupSelection(child, selected, fullySelected);
} else {
child.fullySelected = fullySelected;
child.selected = selected;
}
}
}
}
};
const setItemSelection = function (item, state, fullySelected) {
const parentGroup = getItemsGroup(item);
const itemsCompoundPath = getItemsCompoundPath(item);
// if selection is in a group, select group not individual items
if (parentGroup) {
// do it recursive
setItemSelection(parentGroup, state, fullySelected);
} else if (itemsCompoundPath) {
setGroupSelection(itemsCompoundPath, state, fullySelected);
} else {
if (item.data && item.data.noSelect) {
return;
}
setGroupSelection(item, state, fullySelected);
}
// @todo: Update toolbar state on change
};
const selectAllItems = function () {
const items = getAllSelectableItems();
for (let i = 0; i < items.length; i++) {
setItemSelection(items[i], true);
}
};
const selectAllSegments = function () {
const items = getAllSelectableItems();
for (let i = 0; i < items.length; i++) {
selectItemSegments(items[i], true);
}
};
const clearSelection = function () {
paper.project.deselectAll();
// @todo: Update toolbar state on change
};
// This gets all selected non-grouped items and groups
// (alternative to paper.project.selectedItems, which includes
// group children in addition to the group)
// Returns in increasing Z order
const getSelectedItems = function (recursive) {
const allItems = paper.project.selectedItems;
const itemsAndGroups = [];
if (recursive) {
for (let i = 0; i < allItems.length; i++) {
const item = allItems[i];
if (item.data && !item.data.isSelectionBound) {
itemsAndGroups.push(item);
}
}
} else {
for (let i = 0; i < allItems.length; i++) {
const item = allItems[i];
if ((isGroup(item) && !isGroup(item.parent)) ||
!isGroup(item.parent)) {
if (item.data && !item.data.isSelectionBound) {
itemsAndGroups.push(item);
}
}
}
}
// sort items by index (0 at bottom)
itemsAndGroups.sort((a, b) => parseFloat(a.index) - parseFloat(b.index));
return itemsAndGroups;
};
const deleteItemSelection = function () {
const items = getSelectedItems();
for (let i = 0; i < items.length; i++) {
items[i].remove();
}
// @todo: Update toolbar state on change
paper.project.view.update();
// @todo add back undo
// pg.undo.snapshot('deleteItemSelection');
};
const removeSelectedSegments = function () {
// @todo add back undo
// pg.undo.snapshot('removeSelectedSegments');
const items = getSelectedItems();
const segmentsToRemove = [];
for (let i = 0; i < items.length; i++) {
const segments = items[i].segments;
for (let j = 0; j < segments.length; j++) {
const seg = segments[j];
if (seg.selected) {
segmentsToRemove.push(seg);
}
}
}
let removedSegments = false;
for (let i = 0; i < segmentsToRemove.length; i++) {
const seg = segmentsToRemove[i];
seg.remove();
removedSegments = true;
}
return removedSegments;
};
const deleteSelection = function (mode) {
if (mode === Modes.RESHAPE) {
// If there are points selected remove them. If not delete the item selected.
if (!removeSelectedSegments()) {
deleteItemSelection();
}
} else {
deleteItemSelection();
}
};
const splitPathRetainSelection = function (path, index, deselectSplitSegments) {
const selectedPoints = [];
// collect points of selected segments, so we can reselect them
// once the path is split.
for (let i = 0; i < path.segments.length; i++) {
const seg = path.segments[i];
if (seg.selected) {
if (deselectSplitSegments && i === index) {
continue;
}
selectedPoints.push(seg.point);
}
}
const newPath = path.split(index, 0);
if (!newPath) return;
// reselect all of the newPaths segments that are in the exact same location
// as the ones that are stored in selectedPoints
for (let i = 0; i < newPath.segments.length; i++) {
const seg = newPath.segments[i];
for (let j = 0; j < selectedPoints.length; j++) {
const point = selectedPoints[j];
if (point.x === seg.point.x && point.y === seg.point.y) {
seg.selected = true;
}
}
}
// only do this if path and newPath are different
// (split at more than one point)
if (path !== newPath) {
for (let i = 0; i < path.segments.length; i++) {
const seg = path.segments[i];
for (let j = 0; j < selectedPoints.length; j++) {
const point = selectedPoints[j];
if (point.x === seg.point.x && point.y === seg.point.y) {
seg.selected = true;
}
}
}
}
};
const splitPathAtSelectedSegments = function () {
const items = getSelectedItems();
for (let i = 0; i < items.length; i++) {
const item = items[i];
const segments = item.segments;
for (let j = 0; j < segments.length; j++) {
const segment = segments[j];
if (segment.selected) {
if (item.closed ||
(segment.next &&
!segment.next.selected &&
segment.previous &&
!segment.previous.selected)) {
splitPathRetainSelection(item, j, true);
splitPathAtSelectedSegments();
return;
}
}
}
}
};
const deleteSegments = function (item) {
if (item.children) {
for (let i = 0; i < item.children.length; i++) {
const child = item.children[i];
deleteSegments(child);
}
} else {
const segments = item.segments;
for (let j = 0; j < segments.length; j++) {
const segment = segments[j];
if (segment.selected) {
if (item.closed ||
(segment.next &&
!segment.next.selected &&
segment.previous &&
!segment.previous.selected)) {
splitPathRetainSelection(item, j);
deleteSelection();
return;
} else if (!item.closed) {
segment.remove();
j--; // decrease counter if we removed one from the loop
}
}
}
}
// remove items with no segments left
if (item.segments.length <= 0) {
item.remove();
}
};
const deleteSegmentSelection = function () {
const items = getSelectedItems();
for (let i = 0; i < items.length; i++) {
deleteSegments(items[i]);
}
// @todo: Update toolbar state on change
paper.project.view.update();
// @todo add back undo
// pg.undo.snapshot('deleteSegmentSelection');
};
const cloneSelection = function () {
const selectedItems = getSelectedItems();
for (let i = 0; i < selectedItems.length; i++) {
const item = selectedItems[i];
item.clone();
item.selected = false;
}
// @todo add back undo
// pg.undo.snapshot('cloneSelection');
};
// Only returns paths, no compound paths, groups or any other stuff
const getSelectedPaths = function () {
const allPaths = getSelectedItems();
const paths = [];
for (let i = 0; i < allPaths.length; i++) {
const path = allPaths[i];
if (path.className === 'Path') {
paths.push(path);
}
}
return paths;
};
const checkBoundsItem = function (selectionRect, item, event) {
const itemBounds = new paper.Path([
item.localToGlobal(item.internalBounds.topLeft),
item.localToGlobal(item.internalBounds.topRight),
item.localToGlobal(item.internalBounds.bottomRight),
item.localToGlobal(item.internalBounds.bottomLeft)
]);
itemBounds.closed = true;
itemBounds.guide = true;
for (let i = 0; i < itemBounds.segments.length; i++) {
const seg = itemBounds.segments[i];
if (selectionRect.contains(seg.point) ||
(i === 0 && selectionRect.getIntersections(itemBounds).length > 0)) {
if (event.modifiers.shift && item.selected) {
setItemSelection(item, false);
} else {
setItemSelection(item, true);
}
itemBounds.remove();
return true;
}
}
itemBounds.remove();
};
const _handleRectangularSelectionItems = function (item, event, rect, mode, root) {
if (isPathItem(item)) {
let segmentMode = false;
// first round checks for segments inside the selectionRect
for (let j = 0; j < item.segments.length; j++) {
const seg = item.segments[j];
if (rect.contains(seg.point)) {
if (mode === Modes.RESHAPE) {
if (event.modifiers.shift && seg.selected) {
seg.selected = false;
} else {
seg.selected = true;
}
segmentMode = true;
} else {
if (event.modifiers.shift && item.selected) {
setItemSelection(root, false);
} else {
setItemSelection(root, true, true /* fullySelected */);
}
return false;
}
}
}
// second round checks for path intersections
const intersections = item.getIntersections(rect);
if (intersections.length > 0 && !segmentMode) {
// if in reshape mode, select the curves that intersect
// with the selectionRect
if (mode === Modes.RESHAPE) {
for (let k = 0; k < intersections.length; k++) {
const curve = intersections[k].curve;
// intersections contains every curve twice because
// the selectionRect intersects a circle always at
// two points. so we skip every other curve
if (k % 2 === 1) {
continue;
}
if (event.modifiers.shift) {
curve.selected = !curve.selected;
} else {
curve.selected = true;
}
}
} else {
if (event.modifiers.shift && item.selected) {
setItemSelection(item, false);
} else {
setItemSelection(item, true);
}
return false;
}
}
// @todo: Update toolbar state on change
} else if (isBoundsItem(item)) {
if (checkBoundsItem(rect, item, event)) {
return false;
}
}
return true;
};
// if the rectangular selection found a group, drill into it recursively
const _rectangularSelectionGroupLoop = function (group, rect, root, event, mode) {
for (let i = 0; i < group.children.length; i++) {
const child = group.children[i];
if (isGroup(child) || isCompoundPathItem(child)) {
_rectangularSelectionGroupLoop(child, rect, root, event, mode);
} else {
_handleRectangularSelectionItems(child, event, rect, mode, root);
}
}
return true;
};
/**
* Called after drawing a selection rectangle in a select mode. In reshape mode, this
* selects all control points and curves within the rectangle. In select mode, this
* selects all items and groups that intersect the rectangle
* @param {!MouseEvent} event The mouse event to draw the rectangle
* @param {!paper.Rect} rect The selection rectangle
* @param {Modes} mode The mode of the paint editor when drawing the rectangle
*/
const processRectangularSelection = function (event, rect, mode) {
const allItems = getAllSelectableItems();
for (let i = 0; i < allItems.length; i++) {
const item = allItems[i];
if (mode === Modes.RESHAPE && isPGTextItem(getRootItem(item))) {
continue;
}
if (isGroup(item) || isCompoundPathItem(item)) {
// check for item segment points inside
_rectangularSelectionGroupLoop(item, rect, item, event, mode);
} else {
_handleRectangularSelectionItems(item, event, rect, mode, item);
}
}
};
/**
* When switching to the select tool while having a child object of a
* compound path selected, deselect the child and select the compound path
* instead. (otherwise the compound path breaks because of scale-grouping)
*/
const selectRootItem = function () {
const items = getSelectedItems();
for (const item of items) {
if (isCompoundPathChild(item)) {
const cp = getItemsCompoundPath(item);
setItemSelection(cp, true, true /* fullySelected */);
}
const rootItem = getRootItem(item);
if (item !== rootItem) {
setItemSelection(rootItem, true, true /* fullySelected */);
}
}
};
const shouldShowIfSelection = function () {
return getSelectedItems().length > 0;
};
const shouldShowIfSelectionRecursive = function () {
return getSelectedItems(true /* recursive */).length > 0;
};
const shouldShowSelectAll = function () {
return paper.project.getItems({class: paper.PathItem}).length > 0;
};
export {
getAllPaperItems,
selectAllItems,
selectAllSegments,
clearSelection,
deleteSelection,
deleteItemSelection,
deleteSegmentSelection,
splitPathAtSelectedSegments,
cloneSelection,
setItemSelection,
setGroupSelection,
getSelectedItems,
getSelectedPaths,
removeSelectedSegments,
processRectangularSelection,
selectRootItem,
shouldShowIfSelection,
shouldShowIfSelectionRecursive,
shouldShowSelectAll
};

View file

@ -1,7 +1,10 @@
import PaintEditor from './containers/paint-editor.jsx';
import SelectionHOV from './containers/selection-hoc.jsx';
import ScratchPaintReducer from './reducers/scratch-paint-reducer';
const Wrapped = SelectionHOV(PaintEditor);
export {
PaintEditor as default,
Wrapped as default,
ScratchPaintReducer
};

View file

@ -3,7 +3,9 @@ import keyMirror from 'keymirror';
const Modes = keyMirror({
BRUSH: null,
ERASER: null,
LINE: null
LINE: null,
SELECT: null,
RESHAPE: null
});
export default Modes;

47
src/reducers/hover.js Normal file
View file

@ -0,0 +1,47 @@
import log from '../log/log';
const CHANGE_HOVERED = 'scratch-paint/hover/CHANGE_HOVERED';
const initialState = null;
const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState;
switch (action.type) {
case CHANGE_HOVERED:
if (typeof action.hoveredItemId === 'undefined') {
log.warn(`Hovered item should not be set to undefined. Use null.`);
return state;
} else if (typeof action.hoveredItemId === 'undefined' || isNaN(action.hoveredItemId)) {
log.warn(`Hovered item should be an item ID number. Got: ${action.hoveredItemId}`);
return state;
}
return action.hoveredItemId;
default:
return state;
}
};
// Action creators ==================================
/**
* Set the hovered item state to the given item ID
* @param {number} hoveredItemId The paper.Item ID of the hover indicator item.
* @return {object} Redux action to change the hovered item.
*/
const setHoveredItem = function (hoveredItemId) {
return {
type: CHANGE_HOVERED,
hoveredItemId: hoveredItemId
};
};
const clearHoveredItem = function () {
return {
type: CHANGE_HOVERED,
hoveredItemId: null
};
};
export {
reducer as default,
setHoveredItem,
clearHoveredItem
};

View file

@ -3,10 +3,12 @@ import modeReducer from './modes';
import brushModeReducer from './brush-mode';
import eraserModeReducer from './eraser-mode';
import colorReducer from './color';
import hoverReducer from './hover';
export default combineReducers({
mode: modeReducer,
brushMode: brushModeReducer,
eraserMode: eraserModeReducer,
color: colorReducer
color: colorReducer,
hoveredItemId: hoverReducer
});

View file

@ -0,0 +1,15 @@
/* eslint-env jest */
import React from 'react'; // eslint-disable-line no-unused-vars
import {shallow} from 'enzyme';
import SelectModeComponent from '../../../src/components/select-mode.jsx'; // eslint-disable-line no-unused-vars
describe('SelectModeComponent', () => {
test('triggers callback when clicked', () => {
const onClick = jest.fn();
const componentShallowWrapper = shallow(
<SelectModeComponent onMouseDown={onClick}/>
);
componentShallowWrapper.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,35 @@
/* eslint-env jest */
import reducer from '../../src/reducers/hover';
import {clearHoveredItem, setHoveredItem} from '../../src/reducers/hover';
test('initialState', () => {
let defaultState;
expect(reducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeNull();
});
test('setHoveredItem', () => {
let defaultState;
const item1 = 1;
const item2 = 2;
expect(reducer(defaultState /* state */, setHoveredItem(item1) /* action */)).toBe(item1);
expect(reducer(item1 /* state */, setHoveredItem(item2) /* action */)).toBe(item2);
});
test('clearHoveredItem', () => {
let defaultState;
const item = 1;
expect(reducer(defaultState /* state */, clearHoveredItem() /* action */)).toBeNull();
expect(reducer(item /* state */, clearHoveredItem() /* action */)).toBeNull();
});
test('invalidSetHoveredItem', () => {
let defaultState;
const item = 1;
const nonItem = {random: 'object'};
let undef;
expect(reducer(defaultState /* state */, setHoveredItem(nonItem) /* action */)).toBeNull();
expect(reducer(item /* state */, setHoveredItem(nonItem) /* action */))
.toBe(item);
expect(reducer(item /* state */, setHoveredItem(undef) /* action */))
.toBe(item);
});