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