diff --git a/src/components/paint-editor.jsx b/src/components/paint-editor.jsx
index ec653731..b565eff8 100644
--- a/src/components/paint-editor.jsx
+++ b/src/components/paint-editor.jsx
@@ -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}
/>
+
) : null}
diff --git a/src/components/select-mode.jsx b/src/components/select-mode.jsx
new file mode 100644
index 00000000..78e976f7
--- /dev/null
+++ b/src/components/select-mode.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {FormattedMessage} from 'react-intl';
+
+const SelectModeComponent = props => (
+
+);
+
+SelectModeComponent.propTypes = {
+ onMouseDown: PropTypes.func.isRequired
+};
+
+export default SelectModeComponent;
diff --git a/src/containers/blob/blob.js b/src/containers/blob/blob.js
index 30bbdc95..a10565c2 100644
--- a/src/containers/blob/blob.js
+++ b/src/containers/blob/blob.js
@@ -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);
diff --git a/src/containers/blob/style-path.js b/src/containers/blob/style-path.js
index 658d2f77..26a2527b 100644
--- a/src/containers/blob/style-path.js
+++ b/src/containers/blob/style-path.js
@@ -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;
}
};
diff --git a/src/containers/brush-mode.jsx b/src/containers/brush-mode.jsx
index bf1b372a..bbf36f11 100644
--- a/src/containers/brush-mode.jsx
+++ b/src/containers/brush-mode.jsx
@@ -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);
diff --git a/src/containers/line-mode.jsx b/src/containers/line-mode.jsx
index 35e47b53..0af6f939 100644
--- a/src/containers/line-mode.jsx
+++ b/src/containers/line-mode.jsx
@@ -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();
diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx
index ba260313..9d48fa2d 100644
--- a/src/containers/paint-editor.jsx
+++ b/src/containers/paint-editor.jsx
@@ -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));
}
}
});
diff --git a/src/containers/paper-canvas.css b/src/containers/paper-canvas.css
index d2f149fa..82e8e028 100644
--- a/src/containers/paper-canvas.css
+++ b/src/containers/paper-canvas.css
@@ -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;
}
diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx
index 50e95b51..7539bb0d 100644
--- a/src/containers/paper-canvas.jsx
+++ b/src/containers/paper-canvas.jsx
@@ -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) {
diff --git a/src/containers/select-mode.jsx b/src/containers/select-mode.jsx
new file mode 100644
index 00000000..3992d3f9
--- /dev/null
+++ b/src/containers/select-mode.jsx
@@ -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 (
+
+ );
+ }
+}
+
+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);
diff --git a/src/containers/selection-hoc.jsx b/src/containers/selection-hoc.jsx
new file mode 100644
index 00000000..e468a179
--- /dev/null
+++ b/src/containers/selection-hoc.jsx
@@ -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 (
+
+ );
+ }
+ }
+ SelectionComponent.propTypes = {
+ hoveredItemId: PropTypes.number
+ };
+
+ const mapStateToProps = state => ({
+ hoveredItemId: state.scratchPaint.hoveredItemId
+ });
+ return connect(
+ mapStateToProps
+ )(SelectionComponent);
+};
+
+export default SelectionHOC;
diff --git a/src/helper/compound-path.js b/src/helper/compound-path.js
new file mode 100644
index 00000000..24c808b2
--- /dev/null
+++ b/src/helper/compound-path.js
@@ -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
+};
diff --git a/src/helper/group.js b/src/helper/group.js
new file mode 100644
index 00000000..e6d5e63e
--- /dev/null
+++ b/src/helper/group.js
@@ -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
+};
diff --git a/src/helper/guides.js b/src/helper/guides.js
new file mode 100644
index 00000000..c6f917b8
--- /dev/null
+++ b/src/helper/guides.js
@@ -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
+};
diff --git a/src/helper/hover.js b/src/helper/hover.js
new file mode 100644
index 00000000..b4c0bc1f
--- /dev/null
+++ b/src/helper/hover.js
@@ -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
+};
diff --git a/src/helper/item.js b/src/helper/item.js
new file mode 100644
index 00000000..3083dbf7
--- /dev/null
+++ b/src/helper/item.js
@@ -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
+};
diff --git a/src/helper/layer.js b/src/helper/layer.js
new file mode 100644
index 00000000..0e33649a
--- /dev/null
+++ b/src/helper/layer.js
@@ -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};
diff --git a/src/helper/math.js b/src/helper/math.js
new file mode 100644
index 00000000..a769aa67
--- /dev/null
+++ b/src/helper/math.js
@@ -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
+};
diff --git a/src/helper/selection-tools/bounding-box-tool.js b/src/helper/selection-tools/bounding-box-tool.js
new file mode 100644
index 00000000..dca535fd
--- /dev/null
+++ b/src/helper/selection-tools/bounding-box-tool.js
@@ -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;
diff --git a/src/helper/selection-tools/move-tool.js b/src/helper/selection-tools/move-tool.js
new file mode 100644
index 00000000..8853e631
--- /dev/null
+++ b/src/helper/selection-tools/move-tool.js
@@ -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;
diff --git a/src/helper/selection-tools/rotate-tool.js b/src/helper/selection-tools/rotate-tool.js
new file mode 100644
index 00000000..2006cebf
--- /dev/null
+++ b/src/helper/selection-tools/rotate-tool.js
@@ -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.} 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;
diff --git a/src/helper/selection-tools/scale-tool.js b/src/helper/selection-tools/scale-tool.js
new file mode 100644
index 00000000..8744ab72
--- /dev/null
+++ b/src/helper/selection-tools/scale-tool.js
@@ -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.} 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;
diff --git a/src/helper/selection-tools/select-tool.js b/src/helper/selection-tools/select-tool.js
new file mode 100644
index 00000000..22320a23
--- /dev/null
+++ b/src/helper/selection-tools/select-tool.js
@@ -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;
diff --git a/src/helper/selection-tools/selection-box-tool.js b/src/helper/selection-tools/selection-box-tool.js
new file mode 100644
index 00000000..bc787c51
--- /dev/null
+++ b/src/helper/selection-tools/selection-box-tool.js
@@ -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;
diff --git a/src/helper/selection.js b/src/helper/selection.js
new file mode 100644
index 00000000..4abd8b31
--- /dev/null
+++ b/src/helper/selection.js
@@ -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} 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} 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
+};
diff --git a/src/index.js b/src/index.js
index b7d91a70..bbcbba68 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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
};
diff --git a/src/modes/modes.js b/src/modes/modes.js
index c1b64738..a12446e3 100644
--- a/src/modes/modes.js
+++ b/src/modes/modes.js
@@ -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;
diff --git a/src/reducers/hover.js b/src/reducers/hover.js
new file mode 100644
index 00000000..fe5d5cab
--- /dev/null
+++ b/src/reducers/hover.js
@@ -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
+};
diff --git a/src/reducers/scratch-paint-reducer.js b/src/reducers/scratch-paint-reducer.js
index 6e637526..0d44903b 100644
--- a/src/reducers/scratch-paint-reducer.js
+++ b/src/reducers/scratch-paint-reducer.js
@@ -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
});
diff --git a/test/unit/components/select-mode.test.jsx b/test/unit/components/select-mode.test.jsx
new file mode 100644
index 00000000..3ec1ba29
--- /dev/null
+++ b/test/unit/components/select-mode.test.jsx
@@ -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(
+
+ );
+ componentShallowWrapper.simulate('click');
+ expect(onClick).toHaveBeenCalled();
+ });
+});
diff --git a/test/unit/hover-reducer.test.js b/test/unit/hover-reducer.test.js
new file mode 100644
index 00000000..58f469b8
--- /dev/null
+++ b/test/unit/hover-reducer.test.js
@@ -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);
+});