mirror of
https://github.com/scratchfoundation/scratch-paint.git
synced 2024-12-22 13:32:28 -05:00
commit
7c51db0353
31 changed files with 2000 additions and 12 deletions
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import PaperCanvas from '../containers/paper-canvas.jsx';
|
||||
import BrushMode from '../containers/brush-mode.jsx';
|
||||
import EraserMode from '../containers/eraser-mode.jsx';
|
||||
import SelectMode from '../containers/select-mode.jsx';
|
||||
import PropTypes from 'prop-types';
|
||||
import LineMode from '../containers/line-mode.jsx';
|
||||
import FillColorIndicatorComponent from '../containers/fill-color-indicator.jsx';
|
||||
|
@ -126,6 +127,9 @@ class PaintEditorComponent extends React.Component {
|
|||
canvas={this.state.canvas}
|
||||
onUpdateSvg={this.props.onUpdateSvg}
|
||||
/>
|
||||
<SelectMode
|
||||
onUpdateSvg={this.props.onUpdateSvg}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
19
src/components/select-mode.jsx
Normal file
19
src/components/select-mode.jsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
const SelectModeComponent = props => (
|
||||
<button onClick={props.onMouseDown}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Select"
|
||||
description="Label for the select tool, which allows selecting, moving, and resizing shapes"
|
||||
id="paint.selectMode.select"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
SelectModeComponent.propTypes = {
|
||||
onMouseDown: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default SelectModeComponent;
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
84
src/containers/select-mode.jsx
Normal file
84
src/containers/select-mode.jsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import Modes from '../modes/modes';
|
||||
|
||||
import {changeMode} from '../reducers/modes';
|
||||
import {setHoveredItem, clearHoveredItem} from '../reducers/hover';
|
||||
|
||||
import SelectTool from '../helper/selection-tools/select-tool';
|
||||
import SelectModeComponent from '../components/select-mode.jsx';
|
||||
|
||||
class SelectMode extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'activateTool',
|
||||
'deactivateTool'
|
||||
]);
|
||||
}
|
||||
componentDidMount () {
|
||||
if (this.props.isSelectModeActive) {
|
||||
this.activateTool(this.props);
|
||||
}
|
||||
}
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (this.tool && nextProps.hoveredItemId !== this.props.hoveredItemId) {
|
||||
this.tool.setPrevHoveredItemId(nextProps.hoveredItemId);
|
||||
}
|
||||
|
||||
if (nextProps.isSelectModeActive && !this.props.isSelectModeActive) {
|
||||
this.activateTool();
|
||||
} else if (!nextProps.isSelectModeActive && this.props.isSelectModeActive) {
|
||||
this.deactivateTool();
|
||||
}
|
||||
}
|
||||
shouldComponentUpdate () {
|
||||
return false; // Static component, for now
|
||||
}
|
||||
activateTool () {
|
||||
this.tool = new SelectTool(this.props.setHoveredItem, this.props.clearHoveredItem, this.props.onUpdateSvg);
|
||||
this.tool.activate();
|
||||
}
|
||||
deactivateTool () {
|
||||
this.tool.deactivateTool();
|
||||
this.tool.remove();
|
||||
this.tool = null;
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<SelectModeComponent onMouseDown={this.props.handleMouseDown} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectMode.propTypes = {
|
||||
clearHoveredItem: PropTypes.func.isRequired,
|
||||
handleMouseDown: PropTypes.func.isRequired,
|
||||
hoveredItemId: PropTypes.number,
|
||||
isSelectModeActive: PropTypes.bool.isRequired,
|
||||
onUpdateSvg: PropTypes.func.isRequired,
|
||||
setHoveredItem: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isSelectModeActive: state.scratchPaint.mode === Modes.SELECT,
|
||||
hoveredItemId: state.scratchPaint.hoveredItemId
|
||||
});
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
setHoveredItem: hoveredItemId => {
|
||||
dispatch(setHoveredItem(hoveredItemId));
|
||||
},
|
||||
clearHoveredItem: () => {
|
||||
dispatch(clearHoveredItem());
|
||||
},
|
||||
handleMouseDown: () => {
|
||||
dispatch(changeMode(Modes.SELECT));
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SelectMode);
|
60
src/containers/selection-hoc.jsx
Normal file
60
src/containers/selection-hoc.jsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import bindAll from 'lodash.bindall';
|
||||
import paper from 'paper';
|
||||
|
||||
const SelectionHOC = function (WrappedComponent) {
|
||||
class SelectionComponent extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'removeItemById'
|
||||
]);
|
||||
}
|
||||
componentDidMount () {
|
||||
if (this.props.hoveredItemId) {
|
||||
paper.view.update();
|
||||
}
|
||||
}
|
||||
componentDidUpdate (prevProps) {
|
||||
// Hovered item has changed
|
||||
if ((this.props.hoveredItemId && this.props.hoveredItemId !== prevProps.hoveredItemId) ||
|
||||
(!this.props.hoveredItemId && prevProps.hoveredItemId)) {
|
||||
// Remove the old hover item if any
|
||||
this.removeItemById(prevProps.hoveredItemId);
|
||||
}
|
||||
}
|
||||
removeItemById (itemId) {
|
||||
if (itemId) {
|
||||
const match = paper.project.getItem({
|
||||
match: item => (item.id === itemId)
|
||||
});
|
||||
if (match) {
|
||||
match.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
hoveredItemId, // eslint-disable-line no-unused-vars
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<WrappedComponent {...props} />
|
||||
);
|
||||
}
|
||||
}
|
||||
SelectionComponent.propTypes = {
|
||||
hoveredItemId: PropTypes.number
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
hoveredItemId: state.scratchPaint.hoveredItemId
|
||||
});
|
||||
return connect(
|
||||
mapStateToProps
|
||||
)(SelectionComponent);
|
||||
};
|
||||
|
||||
export default SelectionHOC;
|
27
src/helper/compound-path.js
Normal file
27
src/helper/compound-path.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
const isCompoundPath = function (item) {
|
||||
return item && item.className === 'CompoundPath';
|
||||
};
|
||||
|
||||
const isCompoundPathChild = function (item) {
|
||||
if (item.parent) {
|
||||
return item.parent.className === 'CompoundPath';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
const getItemsCompoundPath = function (item) {
|
||||
const itemParent = item.parent;
|
||||
|
||||
if (isCompoundPath(itemParent)) {
|
||||
return itemParent;
|
||||
}
|
||||
return null;
|
||||
|
||||
};
|
||||
|
||||
export {
|
||||
isCompoundPath,
|
||||
isCompoundPathChild,
|
||||
getItemsCompoundPath
|
||||
};
|
130
src/helper/group.js
Normal file
130
src/helper/group.js
Normal file
|
@ -0,0 +1,130 @@
|
|||
import paper from 'paper';
|
||||
import {getRootItem, isGroupItem} from './item';
|
||||
import {clearSelection, getSelectedItems, setItemSelection} from './selection';
|
||||
|
||||
const isGroup = function (item) {
|
||||
return isGroupItem(item);
|
||||
};
|
||||
|
||||
const groupSelection = function () {
|
||||
const items = getSelectedItems();
|
||||
if (items.length > 0) {
|
||||
const group = new paper.Group(items);
|
||||
clearSelection();
|
||||
setItemSelection(group, true);
|
||||
for (let i = 0; i < group.children.length; i++) {
|
||||
group.children[i].selected = true;
|
||||
}
|
||||
// @todo: Set selection bounds; enable/disable grouping icons
|
||||
// @todo add back undo
|
||||
// pg.undo.snapshot('groupSelection');
|
||||
return group;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const ungroupLoop = function (group, recursive) {
|
||||
// don't ungroup items that are not groups
|
||||
if (!group || !group.children || !isGroup(group)) return;
|
||||
|
||||
group.applyMatrix = true;
|
||||
// iterate over group children recursively
|
||||
for (let i = 0; i < group.children.length; i++) {
|
||||
const groupChild = group.children[i];
|
||||
if (groupChild.hasChildren()) {
|
||||
// recursion (groups can contain groups, ie. from SVG import)
|
||||
if (recursive) {
|
||||
ungroupLoop(groupChild, true /* recursive */);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
groupChild.applyMatrix = true;
|
||||
// move items from the group to the activeLayer (ungrouping)
|
||||
groupChild.insertBelow(group);
|
||||
groupChild.selected = true;
|
||||
i--;
|
||||
}
|
||||
};
|
||||
|
||||
// ungroup items (only top hierarchy)
|
||||
const ungroupItems = function (items) {
|
||||
clearSelection();
|
||||
const emptyGroups = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (isGroup(item) && !item.data.isPGTextItem) {
|
||||
ungroupLoop(item, false /* recursive */);
|
||||
|
||||
if (!item.hasChildren()) {
|
||||
emptyGroups.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove all empty groups after ungrouping
|
||||
for (let j = 0; j < emptyGroups.length; j++) {
|
||||
emptyGroups[j].remove();
|
||||
}
|
||||
// @todo: Set selection bounds; enable/disable grouping icons
|
||||
// @todo add back undo
|
||||
// pg.undo.snapshot('ungroupItems');
|
||||
};
|
||||
|
||||
const ungroupSelection = function () {
|
||||
const items = getSelectedItems();
|
||||
ungroupItems(items);
|
||||
};
|
||||
|
||||
|
||||
const groupItems = function (items) {
|
||||
if (items.length > 0) {
|
||||
const group = new paper.Group(items);
|
||||
// @todo: Set selection bounds; enable/disable grouping icons
|
||||
// @todo add back undo
|
||||
// pg.undo.snapshot('groupItems');
|
||||
return group;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getItemsGroup = function (item) {
|
||||
const itemParent = item.parent;
|
||||
|
||||
if (isGroup(itemParent)) {
|
||||
return itemParent;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isGroupChild = function (item) {
|
||||
const rootItem = getRootItem(item);
|
||||
return isGroup(rootItem);
|
||||
};
|
||||
|
||||
const shouldShowGroup = function () {
|
||||
const items = getSelectedItems();
|
||||
return items.length > 1;
|
||||
};
|
||||
|
||||
const shouldShowUngroup = function () {
|
||||
const items = getSelectedItems();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (isGroup(item) && !item.data.isPGTextItem && item.children && item.children.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export {
|
||||
groupSelection,
|
||||
ungroupSelection,
|
||||
groupItems,
|
||||
ungroupItems,
|
||||
getItemsGroup,
|
||||
isGroup,
|
||||
isGroupChild,
|
||||
shouldShowGroup,
|
||||
shouldShowUngroup
|
||||
};
|
106
src/helper/guides.js
Normal file
106
src/helper/guides.js
Normal file
|
@ -0,0 +1,106 @@
|
|||
import paper from 'paper';
|
||||
import {getGuideLayer} from './layer';
|
||||
import {getAllPaperItems} from './selection';
|
||||
|
||||
const GUIDE_BLUE = '#009dec';
|
||||
const GUIDE_GREY = '#aaaaaa';
|
||||
|
||||
const setDefaultGuideStyle = function (item) {
|
||||
item.strokeWidth = 1 / paper.view.zoom;
|
||||
item.opacity = 1;
|
||||
item.blendMode = 'normal';
|
||||
item.guide = true;
|
||||
};
|
||||
|
||||
const hoverItem = function (hitResult) {
|
||||
const segments = hitResult.item.segments;
|
||||
const clone = new paper.Path(segments);
|
||||
setDefaultGuideStyle(clone);
|
||||
if (hitResult.item.closed) {
|
||||
clone.closed = true;
|
||||
}
|
||||
clone.parent = getGuideLayer();
|
||||
clone.strokeColor = GUIDE_BLUE;
|
||||
clone.fillColor = null;
|
||||
clone.data.isHelperItem = true;
|
||||
clone.bringToFront();
|
||||
|
||||
return clone;
|
||||
};
|
||||
|
||||
const hoverBounds = function (item) {
|
||||
const rect = new paper.Path.Rectangle(item.internalBounds);
|
||||
rect.matrix = item.matrix;
|
||||
setDefaultGuideStyle(rect);
|
||||
rect.parent = getGuideLayer();
|
||||
rect.strokeColor = GUIDE_BLUE;
|
||||
rect.fillColor = null;
|
||||
rect.data.isHelperItem = true;
|
||||
rect.bringToFront();
|
||||
|
||||
return rect;
|
||||
};
|
||||
|
||||
const rectSelect = function (event, color) {
|
||||
const half = new paper.Point(0.5 / paper.view.zoom, 0.5 / paper.view.zoom);
|
||||
const start = event.downPoint.add(half);
|
||||
const end = event.point.add(half);
|
||||
const rect = new paper.Path.Rectangle(start, end);
|
||||
const zoom = 1.0 / paper.view.zoom;
|
||||
setDefaultGuideStyle(rect);
|
||||
if (!color) color = GUIDE_GREY;
|
||||
rect.parent = getGuideLayer();
|
||||
rect.strokeColor = color;
|
||||
rect.data.isRectSelect = true;
|
||||
rect.data.isHelperItem = true;
|
||||
rect.dashArray = [3.0 * zoom, 3.0 * zoom];
|
||||
return rect;
|
||||
};
|
||||
|
||||
const getGuideColor = function (colorName) {
|
||||
if (colorName === 'blue') {
|
||||
return GUIDE_BLUE;
|
||||
} else if (colorName === 'grey') {
|
||||
return GUIDE_GREY;
|
||||
}
|
||||
};
|
||||
|
||||
const _removePaperItemsByDataTags = function (tags) {
|
||||
const allItems = getAllPaperItems(true);
|
||||
for (const item of allItems) {
|
||||
for (const tag of tags) {
|
||||
if (item.data && item.data[tag]) {
|
||||
item.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const _removePaperItemsByTags = function (tags) {
|
||||
const allItems = getAllPaperItems(true);
|
||||
for (const item of allItems) {
|
||||
for (const tag of tags) {
|
||||
if (item[tag]) {
|
||||
item.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeHelperItems = function () {
|
||||
_removePaperItemsByDataTags(['isHelperItem']);
|
||||
};
|
||||
|
||||
const removeAllGuides = function () {
|
||||
_removePaperItemsByTags(['guide']);
|
||||
};
|
||||
|
||||
export {
|
||||
hoverItem,
|
||||
hoverBounds,
|
||||
rectSelect,
|
||||
removeAllGuides,
|
||||
removeHelperItems,
|
||||
getGuideColor,
|
||||
setDefaultGuideStyle
|
||||
};
|
38
src/helper/hover.js
Normal file
38
src/helper/hover.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
import paper from 'paper';
|
||||
import {isBoundsItem, getRootItem} from './item';
|
||||
import {hoverBounds, hoverItem} from './guides';
|
||||
import {isGroupChild} from './group';
|
||||
|
||||
/**
|
||||
* @param {!MouseEvent} event mouse event
|
||||
* @param {?object} hitOptions hit options to use
|
||||
* @return {paper.Item} the hovered item or null if there is none
|
||||
*/
|
||||
const getHoveredItem = function (event, hitOptions) {
|
||||
const hitResults = paper.project.hitTestAll(event.point, hitOptions);
|
||||
if (hitResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let hitResult;
|
||||
for (const result of hitResults) {
|
||||
if (!(result.item.data && result.item.data.noHover) && !result.item.selected) {
|
||||
hitResult = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hitResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isBoundsItem(hitResult.item)) {
|
||||
return hoverBounds(hitResult.item);
|
||||
} else if (isGroupChild(hitResult.item)) {
|
||||
return hoverBounds(getRootItem(hitResult.item));
|
||||
}
|
||||
return hoverItem(hitResult);
|
||||
};
|
||||
|
||||
export {
|
||||
getHoveredItem
|
||||
};
|
78
src/helper/item.js
Normal file
78
src/helper/item.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
import paper from 'paper';
|
||||
|
||||
const getRootItem = function (item) {
|
||||
if (item.parent.className === 'Layer') {
|
||||
return item;
|
||||
}
|
||||
return getRootItem(item.parent);
|
||||
};
|
||||
|
||||
const isBoundsItem = function (item) {
|
||||
if (item.className === 'PointText' ||
|
||||
item.className === 'Shape' ||
|
||||
item.className === 'PlacedSymbol' ||
|
||||
item.className === 'Raster') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
const isPathItem = function (item) {
|
||||
return item.className === 'Path';
|
||||
};
|
||||
|
||||
|
||||
const isCompoundPathItem = function (item) {
|
||||
return item.className === 'CompoundPath';
|
||||
};
|
||||
|
||||
|
||||
const isGroupItem = function (item) {
|
||||
return item && item.className && item.className === 'Group';
|
||||
};
|
||||
|
||||
|
||||
const isPointTextItem = function (item) {
|
||||
return item.className === 'PointText';
|
||||
};
|
||||
|
||||
|
||||
const isPGTextItem = function (item) {
|
||||
return getRootItem(item).data.isPGTextItem;
|
||||
};
|
||||
|
||||
const setPivot = function (item, point) {
|
||||
if (isBoundsItem(item)) {
|
||||
item.pivot = item.globalToLocal(point);
|
||||
} else {
|
||||
item.pivot = point;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getPositionInView = function (item) {
|
||||
const itemPos = new paper.Point();
|
||||
itemPos.x = item.position.x - paper.view.bounds.x;
|
||||
itemPos.y = item.position.y - paper.view.bounds.y;
|
||||
return itemPos;
|
||||
};
|
||||
|
||||
|
||||
const setPositionInView = function (item, pos) {
|
||||
item.position.x = paper.view.bounds.x + pos.x;
|
||||
item.position.y = paper.view.bounds.y + pos.y;
|
||||
};
|
||||
|
||||
export {
|
||||
isBoundsItem,
|
||||
isPathItem,
|
||||
isCompoundPathItem,
|
||||
isGroupItem,
|
||||
isPointTextItem,
|
||||
isPGTextItem,
|
||||
setPivot,
|
||||
getPositionInView,
|
||||
setPositionInView,
|
||||
getRootItem
|
||||
};
|
18
src/helper/layer.js
Normal file
18
src/helper/layer.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import paper from 'paper';
|
||||
|
||||
const getGuideLayer = function () {
|
||||
for (let i = 0; i < paper.project.layers.length; i++) {
|
||||
const layer = paper.project.layers[i];
|
||||
if (layer.data && layer.data.isGuideLayer) {
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
// Create if it doesn't exist
|
||||
const guideLayer = new paper.Layer();
|
||||
guideLayer.data.isGuideLayer = true;
|
||||
guideLayer.bringToFront();
|
||||
return guideLayer;
|
||||
};
|
||||
|
||||
export {getGuideLayer};
|
35
src/helper/math.js
Normal file
35
src/helper/math.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
import paper from 'paper';
|
||||
|
||||
const checkPointsClose = function (startPos, eventPoint, threshold) {
|
||||
const xOff = Math.abs(startPos.x - eventPoint.x);
|
||||
const yOff = Math.abs(startPos.y - eventPoint.y);
|
||||
if (xOff < threshold && yOff < threshold) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getRandomInt = function (min, max) {
|
||||
return Math.floor(Math.random() * (max - min)) + min;
|
||||
};
|
||||
|
||||
const getRandomBoolean = function () {
|
||||
return getRandomInt(0, 2) === 1;
|
||||
};
|
||||
|
||||
// Thanks Mikko Mononen! https://github.com/memononen/stylii
|
||||
const snapDeltaToAngle = function (delta, snapAngle) {
|
||||
let angle = Math.atan2(delta.y, delta.x);
|
||||
angle = Math.round(angle / snapAngle) * snapAngle;
|
||||
const dirx = Math.cos(angle);
|
||||
const diry = Math.sin(angle);
|
||||
const d = (dirx * delta.x) + (diry * delta.y);
|
||||
return new paper.Point(dirx * d, diry * d);
|
||||
};
|
||||
|
||||
export {
|
||||
checkPointsClose,
|
||||
getRandomInt,
|
||||
getRandomBoolean,
|
||||
snapDeltaToAngle
|
||||
};
|
197
src/helper/selection-tools/bounding-box-tool.js
Normal file
197
src/helper/selection-tools/bounding-box-tool.js
Normal file
|
@ -0,0 +1,197 @@
|
|||
import paper from 'paper';
|
||||
import keyMirror from 'keymirror';
|
||||
|
||||
import {clearSelection, getSelectedItems} from '../selection';
|
||||
import {getGuideColor, removeHelperItems} from '../guides';
|
||||
import {getGuideLayer} from '../layer';
|
||||
|
||||
import ScaleTool from './scale-tool';
|
||||
import RotateTool from './rotate-tool';
|
||||
import MoveTool from './move-tool';
|
||||
|
||||
/** SVG for the rotation icon on the bounding box */
|
||||
const ARROW_PATH = 'M19.28,1.09C19.28.28,19,0,18.2,0c-1.67,0-3.34,0-5,0-.34,0-.88.24-1,.47a1.4,1.4,' +
|
||||
'0,0,0,.36,1.08,15.27,15.27,0,0,0,1.46,1.36A6.4,6.4,0,0,1,6.52,4,5.85,5.85,0,0,1,5.24,3,15.27,15.27,' +
|
||||
'0,0,0,6.7,1.61,1.4,1.4,0,0,0,7.06.54C7,.3,6.44.07,6.1.06c-1.67,0-3.34,0-5,0C.28,0,0,.31,0,1.12c0,1.67,' +
|
||||
'0,3.34,0,5a1.23,1.23,0,0,0,.49,1,1.22,1.22,0,0,0,1-.31A14.38,14.38,0,0,0,2.84,5.26l.73.62a9.45,9.45,' +
|
||||
'0,0,0,7.34,2,9.45,9.45,0,0,0,4.82-2.05l.73-.62a14.38,14.38,0,0,0,1.29,1.51,1.22,1.22,' +
|
||||
'0,0,0,1,.31,1.23,1.23,0,0,0,.49-1C19.31,4.43,19.29,2.76,19.28,1.09Z';
|
||||
/** Modes of the bounding box tool, which can do many things depending on how it's used. */
|
||||
const Modes = keyMirror({
|
||||
SCALE: null,
|
||||
ROTATE: null,
|
||||
MOVE: null
|
||||
});
|
||||
|
||||
/**
|
||||
* Tool that handles transforming the selection and drawing a bounding box with handles.
|
||||
* On mouse down, the type of function (move, scale, rotate) is determined based on what is clicked
|
||||
* (scale handle, rotate handle, the object itself). This determines the mode of the tool, which then
|
||||
* delegates actions to the MoveTool, RotateTool or ScaleTool accordingly.
|
||||
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
|
||||
*/
|
||||
class BoundingBoxTool {
|
||||
constructor (onUpdateSvg) {
|
||||
this.onUpdateSvg = onUpdateSvg;
|
||||
this.mode = null;
|
||||
this.boundsPath = null;
|
||||
this.boundsScaleHandles = [];
|
||||
this.boundsRotHandles = [];
|
||||
this._modeMap = {};
|
||||
this._modeMap[Modes.SCALE] = new ScaleTool(onUpdateSvg);
|
||||
this._modeMap[Modes.ROTATE] = new RotateTool(onUpdateSvg);
|
||||
this._modeMap[Modes.MOVE] = new MoveTool(onUpdateSvg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!MouseEvent} event The mouse event
|
||||
* @param {boolean} clone Whether to clone on mouse down (e.g. alt key held)
|
||||
* @param {boolean} multiselect Whether to multiselect on mouse down (e.g. shift key held)
|
||||
* @param {paper.hitOptions} hitOptions The options with which to detect whether mouse down has hit
|
||||
* anything editable
|
||||
* @return {boolean} True if there was a hit, false otherwise
|
||||
*/
|
||||
onMouseDown (event, clone, multiselect, hitOptions) {
|
||||
const hitResults = paper.project.hitTestAll(event.point, hitOptions);
|
||||
if (!hitResults || hitResults.length === 0) {
|
||||
if (!multiselect) {
|
||||
this.removeBoundsPath();
|
||||
clearSelection();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prefer scale to trigger over rotate, and scale and rotate to trigger over other hits
|
||||
let hitResult = hitResults[0];
|
||||
for (let i = 0; i < hitResults.length; i++) {
|
||||
if (hitResults[i].item.data && hitResults[i].item.data.isScaleHandle) {
|
||||
hitResult = hitResults[i];
|
||||
this.mode = Modes.SCALE;
|
||||
break;
|
||||
} else if (hitResults[i].item.data && hitResults[i].item.data.isRotHandle) {
|
||||
hitResult = hitResults[i];
|
||||
this.mode = Modes.ROTATE;
|
||||
}
|
||||
}
|
||||
if (!this.mode) {
|
||||
this.mode = Modes.MOVE;
|
||||
}
|
||||
|
||||
const hitProperties = {
|
||||
hitResult: hitResult,
|
||||
clone: event.modifiers.alt,
|
||||
multiselect: event.modifiers.shift
|
||||
};
|
||||
if (this.mode === Modes.MOVE) {
|
||||
this._modeMap[this.mode].onMouseDown(hitProperties);
|
||||
} else if (this.mode === Modes.SCALE) {
|
||||
this._modeMap[this.mode].onMouseDown(
|
||||
hitResult, this.boundsPath, this.boundsScaleHandles, this.boundsRotHandles, getSelectedItems());
|
||||
} else if (this.mode === Modes.ROTATE) {
|
||||
this._modeMap[this.mode].onMouseDown(hitResult, this.boundsPath, getSelectedItems());
|
||||
}
|
||||
|
||||
// while transforming object, never show the bounds stuff
|
||||
this.removeBoundsPath();
|
||||
return true;
|
||||
}
|
||||
onMouseDrag (event) {
|
||||
if (event.event.button > 0) return; // only first mouse button
|
||||
this._modeMap[this.mode].onMouseDrag(event);
|
||||
}
|
||||
onMouseUp (event) {
|
||||
if (event.event.button > 0) return; // only first mouse button
|
||||
this._modeMap[this.mode].onMouseUp(event);
|
||||
|
||||
this.mode = null;
|
||||
this.setSelectionBounds();
|
||||
}
|
||||
setSelectionBounds () {
|
||||
this.removeBoundsPath();
|
||||
|
||||
const items = getSelectedItems(true /* recursive */);
|
||||
if (items.length <= 0) return;
|
||||
|
||||
let rect = null;
|
||||
for (const item of items) {
|
||||
if (rect) {
|
||||
rect = rect.unite(item.bounds);
|
||||
} else {
|
||||
rect = item.bounds;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.boundsPath) {
|
||||
this.boundsPath = new paper.Path.Rectangle(rect);
|
||||
this.boundsPath.curves[0].divideAtTime(0.5);
|
||||
this.boundsPath.curves[2].divideAtTime(0.5);
|
||||
this.boundsPath.curves[4].divideAtTime(0.5);
|
||||
this.boundsPath.curves[6].divideAtTime(0.5);
|
||||
}
|
||||
this.boundsPath.guide = true;
|
||||
this.boundsPath.data.isSelectionBound = true;
|
||||
this.boundsPath.data.isHelperItem = true;
|
||||
this.boundsPath.fillColor = null;
|
||||
this.boundsPath.strokeScaling = false;
|
||||
this.boundsPath.fullySelected = true;
|
||||
this.boundsPath.parent = getGuideLayer();
|
||||
|
||||
for (let index = 0; index < this.boundsPath.segments.length; index++) {
|
||||
const segment = this.boundsPath.segments[index];
|
||||
let size = 4;
|
||||
|
||||
if (index % 2 === 0) {
|
||||
size = 6;
|
||||
}
|
||||
|
||||
if (index === 7) {
|
||||
const offset = new paper.Point(0, 20);
|
||||
|
||||
const arrows = new paper.Path(ARROW_PATH);
|
||||
arrows.translate(segment.point.add(offset).add(-10.5, -5));
|
||||
|
||||
const line = new paper.Path.Rectangle(
|
||||
segment.point.add(offset).subtract(1, 0),
|
||||
segment.point);
|
||||
|
||||
const rotHandle = arrows.unite(line);
|
||||
line.remove();
|
||||
arrows.remove();
|
||||
rotHandle.scale(1 / paper.view.zoom, segment.point);
|
||||
rotHandle.data = {
|
||||
offset: offset,
|
||||
isRotHandle: true,
|
||||
isHelperItem: true,
|
||||
noSelect: true,
|
||||
noHover: true
|
||||
};
|
||||
rotHandle.fillColor = getGuideColor('blue');
|
||||
rotHandle.parent = getGuideLayer();
|
||||
this.boundsRotHandles[index] = rotHandle;
|
||||
}
|
||||
|
||||
this.boundsScaleHandles[index] =
|
||||
new paper.Path.Rectangle({
|
||||
center: segment.point,
|
||||
data: {
|
||||
index: index,
|
||||
isScaleHandle: true,
|
||||
isHelperItem: true,
|
||||
noSelect: true,
|
||||
noHover: true
|
||||
},
|
||||
size: [size / paper.view.zoom, size / paper.view.zoom],
|
||||
fillColor: getGuideColor('blue'),
|
||||
parent: getGuideLayer()
|
||||
});
|
||||
}
|
||||
}
|
||||
removeBoundsPath () {
|
||||
removeHelperItems();
|
||||
this.boundsPath = null;
|
||||
this.boundsScaleHandles.length = 0;
|
||||
this.boundsRotHandles.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default BoundingBoxTool;
|
104
src/helper/selection-tools/move-tool.js
Normal file
104
src/helper/selection-tools/move-tool.js
Normal file
|
@ -0,0 +1,104 @@
|
|||
import {isGroup} from '../group';
|
||||
import {isCompoundPathItem, getRootItem} from '../item';
|
||||
import {snapDeltaToAngle} from '../math';
|
||||
import {clearSelection, cloneSelection, getSelectedItems, setItemSelection} from '../selection';
|
||||
|
||||
/**
|
||||
* Tool to handle dragging an item to reposition it in a selection mode.
|
||||
*/
|
||||
class MoveTool {
|
||||
/**
|
||||
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
|
||||
*/
|
||||
constructor (onUpdateSvg) {
|
||||
this.selectedItems = null;
|
||||
this.onUpdateSvg = onUpdateSvg;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!object} hitProperties Describes the mouse event
|
||||
* @param {!paper.HitResult} hitProperties.hitResult Data about the location of the mouse click
|
||||
* @param {?boolean} hitProperties.clone Whether to clone on mouse down (e.g. alt key held)
|
||||
* @param {?boolean} hitProperties.multiselect Whether to multiselect on mouse down (e.g. shift key held)
|
||||
* @param {?boolean} hitProperties.doubleClicked True if this is the second click in a short amout of time
|
||||
* @param {?boolean} hitProperties.subselect True if we allow selection of subgroups, false if we should
|
||||
* select the whole group.
|
||||
*/
|
||||
onMouseDown (hitProperties) {
|
||||
let item = hitProperties.hitResult.item;
|
||||
if (!hitProperties.subselect) {
|
||||
const root = getRootItem(hitProperties.hitResult.item);
|
||||
item = isCompoundPathItem(root) || isGroup(root) ? root : hitProperties.hitResult.item;
|
||||
}
|
||||
if (item.selected) {
|
||||
// Double click causes all points to be selected in subselect mode.
|
||||
if (hitProperties.doubleClicked) {
|
||||
if (!hitProperties.multiselect) {
|
||||
clearSelection();
|
||||
}
|
||||
this._select(item, true /* state */, hitProperties.subselect, true /* fullySelect */);
|
||||
} else if (hitProperties.multiselect) {
|
||||
this._select(item, false /* state */, hitProperties.subselect);
|
||||
}
|
||||
} else {
|
||||
// deselect all by default if multiselect isn't on
|
||||
if (!hitProperties.multiselect) {
|
||||
clearSelection();
|
||||
}
|
||||
this._select(item, true, hitProperties.subselect);
|
||||
}
|
||||
if (hitProperties.clone) cloneSelection(hitProperties.subselect);
|
||||
this.selectedItems = getSelectedItems(true /* subselect */);
|
||||
}
|
||||
/**
|
||||
* Sets the selection state of an item.
|
||||
* @param {!paper.Item} item Item to select or deselect
|
||||
* @param {?boolean} state True if item should be selected, false if deselected
|
||||
* @param {?boolean} subselect True if a subset of all points in an item are allowed to be
|
||||
* selected, false if items must be selected all or nothing.
|
||||
* @param {?boolean} fullySelect True if in addition to the item being selected, all of its
|
||||
* control points should be selected. False if the item should be selected but not its
|
||||
* points. Only relevant when subselect is true.
|
||||
*/
|
||||
_select (item, state, subselect, fullySelect) {
|
||||
if (subselect) {
|
||||
item.selected = false;
|
||||
if (fullySelect) {
|
||||
item.fullySelected = state;
|
||||
} else {
|
||||
item.selected = state;
|
||||
}
|
||||
} else {
|
||||
setItemSelection(item, state);
|
||||
}
|
||||
}
|
||||
onMouseDrag (event) {
|
||||
const dragVector = event.point.subtract(event.downPoint);
|
||||
for (const item of this.selectedItems) {
|
||||
// add the position of the item before the drag started
|
||||
// for later use in the snap calculation
|
||||
if (!item.data.origPos) {
|
||||
item.data.origPos = item.position;
|
||||
}
|
||||
|
||||
if (event.modifiers.shift) {
|
||||
item.position = item.data.origPos.add(snapDeltaToAngle(dragVector, Math.PI / 4));
|
||||
} else {
|
||||
item.position = item.data.origPos.add(dragVector);
|
||||
}
|
||||
}
|
||||
}
|
||||
onMouseUp () {
|
||||
// resetting the items origin point for the next usage
|
||||
for (const item of this.selectedItems) {
|
||||
item.data.origPos = null;
|
||||
}
|
||||
this.selectedItems = null;
|
||||
|
||||
// @todo add back undo
|
||||
// pg.undo.snapshot('moveSelection');
|
||||
this.onUpdateSvg();
|
||||
}
|
||||
}
|
||||
|
||||
export default MoveTool;
|
71
src/helper/selection-tools/rotate-tool.js
Normal file
71
src/helper/selection-tools/rotate-tool.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
import paper from 'paper';
|
||||
|
||||
/**
|
||||
* Tool to handle rotation when dragging the rotation handle in the bounding box tool.
|
||||
*/
|
||||
class RotateTool {
|
||||
/**
|
||||
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
|
||||
*/
|
||||
constructor (onUpdateSvg) {
|
||||
this.rotItems = [];
|
||||
this.rotGroupPivot = null;
|
||||
this.prevRot = [];
|
||||
this.onUpdateSvg = onUpdateSvg;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!paper.HitResult} hitResult Data about the location of the mouse click
|
||||
* @param {!object} boundsPath Where the boundaries of the hit item are
|
||||
* @param {!Array.<paper.Item>} selectedItems Set of selected paper.Items
|
||||
*/
|
||||
onMouseDown (hitResult, boundsPath, selectedItems) {
|
||||
this.rotGroupPivot = boundsPath.bounds.center;
|
||||
for (const item of selectedItems) {
|
||||
// Rotate only root items
|
||||
if (item.parent instanceof paper.Layer) {
|
||||
this.rotItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.rotItems.length; i++) {
|
||||
this.prevRot[i] = 90;
|
||||
}
|
||||
}
|
||||
onMouseDrag (event) {
|
||||
let rotAngle = (event.point.subtract(this.rotGroupPivot)).angle;
|
||||
|
||||
for (let i = 0; i < this.rotItems.length; i++) {
|
||||
const item = this.rotItems[i];
|
||||
|
||||
if (!item.data.origRot) {
|
||||
item.data.origRot = item.rotation;
|
||||
}
|
||||
|
||||
if (event.modifiers.shift) {
|
||||
rotAngle = Math.round(rotAngle / 45) * 45;
|
||||
item.applyMatrix = false;
|
||||
item.pivot = this.rotGroupPivot;
|
||||
item.rotation = rotAngle;
|
||||
} else {
|
||||
item.rotate(rotAngle - this.prevRot[i], this.rotGroupPivot);
|
||||
}
|
||||
this.prevRot[i] = rotAngle;
|
||||
}
|
||||
}
|
||||
onMouseUp (event) {
|
||||
if (event.event.button > 0) return; // only first mouse button
|
||||
for (const item of this.rotItems) {
|
||||
item.applyMatrix = true;
|
||||
}
|
||||
|
||||
this.rotItems.length = 0;
|
||||
this.rotGroupPivot = null;
|
||||
this.prevRot = [];
|
||||
|
||||
// @todo add back undo
|
||||
this.onUpdateSvg();
|
||||
}
|
||||
}
|
||||
|
||||
export default RotateTool;
|
205
src/helper/selection-tools/scale-tool.js
Normal file
205
src/helper/selection-tools/scale-tool.js
Normal file
|
@ -0,0 +1,205 @@
|
|||
import paper from 'paper';
|
||||
|
||||
/**
|
||||
* Tool to handle scaling items by pulling on the handles around the edges of the bounding
|
||||
* box when in the bounding box tool.
|
||||
*/
|
||||
class ScaleTool {
|
||||
/**
|
||||
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
|
||||
*/
|
||||
constructor (onUpdateSvg) {
|
||||
this.pivot = null;
|
||||
this.origPivot = null;
|
||||
this.corner = null;
|
||||
this.origSize = null;
|
||||
this.origCenter = null;
|
||||
this.itemGroup = null;
|
||||
this.boundsPath = null;
|
||||
// Lowest item above all scale items in z index
|
||||
this.itemToInsertBelow = null;
|
||||
this.scaleItems = [];
|
||||
this.boundsScaleHandles = [];
|
||||
this.boundsRotHandles = [];
|
||||
this.onUpdateSvg = onUpdateSvg;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!paper.HitResult} hitResult Data about the location of the mouse click
|
||||
* @param {!object} boundsPath Where the boundaries of the hit item are
|
||||
* @param {!object} boundsScaleHandles Bounding box scale handles
|
||||
* @param {!object} boundsRotHandles Bounding box rotation handle
|
||||
* @param {!Array.<paper.Item>} selectedItems Set of selected paper.Items
|
||||
* @param {boolean} clone Whether to clone on mouse down (e.g. alt key held)
|
||||
* @param {boolean} multiselect Whether to multiselect on mouse down (e.g. shift key held)
|
||||
*/
|
||||
onMouseDown (hitResult, boundsPath, boundsScaleHandles, boundsRotHandles, selectedItems) {
|
||||
const index = hitResult.item.data.index;
|
||||
this.boundsPath = boundsPath;
|
||||
this.boundsScaleHandles = boundsScaleHandles;
|
||||
this.boundsRotHandles = boundsRotHandles;
|
||||
this.pivot = this.boundsPath.bounds[this._getOpposingRectCornerNameByIndex(index)].clone();
|
||||
this.origPivot = this.boundsPath.bounds[this._getOpposingRectCornerNameByIndex(index)].clone();
|
||||
this.corner = this.boundsPath.bounds[this._getRectCornerNameByIndex(index)].clone();
|
||||
this.origSize = this.corner.subtract(this.pivot);
|
||||
this.origCenter = this.boundsPath.bounds.center;
|
||||
for (const item of selectedItems) {
|
||||
// Scale only root items
|
||||
if (item.parent instanceof paper.Layer) {
|
||||
this.scaleItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
onMouseDrag (event) {
|
||||
const scaleTool = this;
|
||||
const modOrigSize = this.origSize;
|
||||
|
||||
// get item to insert below so that scaled items stay in same z position
|
||||
const items = paper.project.getItems({
|
||||
match: function (item) {
|
||||
if (item instanceof paper.Layer || item.data.isHelperItem) {
|
||||
return false;
|
||||
}
|
||||
for (const scaleItem of scaleTool.scaleItems) {
|
||||
if (!scaleItem.isBelow(item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (items.length > 0) {
|
||||
this.itemToInsertBelow = items[0];
|
||||
}
|
||||
|
||||
this.itemGroup = new paper.Group(this.scaleItems);
|
||||
this.itemGroup.insertBelow(this.itemToInsertBelow);
|
||||
this.itemGroup.addChild(this.boundsPath);
|
||||
this.itemGroup.data.isHelperItem = true;
|
||||
this.itemGroup.strokeScaling = false;
|
||||
this.itemGroup.applyMatrix = false;
|
||||
|
||||
if (event.modifiers.alt) {
|
||||
this.pivot = this.origCenter;
|
||||
this.modOrigSize = this.origSize * 0.5;
|
||||
} else {
|
||||
this.pivot = this.origPivot;
|
||||
}
|
||||
|
||||
this.corner = this.corner.add(event.delta);
|
||||
const size = this.corner.subtract(this.pivot);
|
||||
let sx = 1.0;
|
||||
let sy = 1.0;
|
||||
if (Math.abs(modOrigSize.x) > 0.0000001) {
|
||||
sx = size.x / modOrigSize.x;
|
||||
}
|
||||
if (Math.abs(modOrigSize.y) > 0.0000001) {
|
||||
sy = size.y / modOrigSize.y;
|
||||
}
|
||||
|
||||
if (event.modifiers.shift) {
|
||||
const signx = sx > 0 ? 1 : -1;
|
||||
const signy = sy > 0 ? 1 : -1;
|
||||
sx = sy = Math.max(Math.abs(sx), Math.abs(sy));
|
||||
sx *= signx;
|
||||
sy *= signy;
|
||||
}
|
||||
|
||||
this.itemGroup.scale(sx, sy, this.pivot);
|
||||
|
||||
for (let i = 0; i < this.boundsScaleHandles.length; i++) {
|
||||
const handle = this.boundsScaleHandles[i];
|
||||
handle.position = this.itemGroup.bounds[this._getRectCornerNameByIndex(i)];
|
||||
handle.bringToFront();
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.boundsRotHandles.length; i++) {
|
||||
const handle = this.boundsRotHandles[i];
|
||||
if (handle) {
|
||||
handle.position = this.itemGroup.bounds[this._getRectCornerNameByIndex(i)] + handle.data.offset;
|
||||
handle.bringToFront();
|
||||
}
|
||||
}
|
||||
}
|
||||
onMouseUp () {
|
||||
this.pivot = null;
|
||||
this.origPivot = null;
|
||||
this.corner = null;
|
||||
this.origSize = null;
|
||||
this.origCenter = null;
|
||||
this.scaleItems.length = 0;
|
||||
this.boundsPath = null;
|
||||
this.boundsScaleHandles = [];
|
||||
this.boundsRotHandles = [];
|
||||
|
||||
if (!this.itemGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.itemGroup.applyMatrix = true;
|
||||
|
||||
// mark text items as scaled (for later use on font size calc)
|
||||
for (let i = 0; i < this.itemGroup.children.length; i++) {
|
||||
const child = this.itemGroup.children[i];
|
||||
if (child.data.isPGTextItem) {
|
||||
child.data.wasScaled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.itemToInsertBelow) {
|
||||
// No increment step because itemGroup.children is getting depleted
|
||||
for (const i = 0; i < this.itemGroup.children.length;) {
|
||||
this.itemGroup.children[i].insertBelow(this.itemToInsertBelow);
|
||||
}
|
||||
this.itemToInsertBelow = null;
|
||||
} else if (this.itemGroup.layer) {
|
||||
this.itemGroup.layer.addChildren(this.itemGroup.children);
|
||||
}
|
||||
this.itemGroup.remove();
|
||||
|
||||
// @todo add back undo
|
||||
this.onUpdateSvg();
|
||||
}
|
||||
_getRectCornerNameByIndex (index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return 'bottomLeft';
|
||||
case 1:
|
||||
return 'leftCenter';
|
||||
case 2:
|
||||
return 'topLeft';
|
||||
case 3:
|
||||
return 'topCenter';
|
||||
case 4:
|
||||
return 'topRight';
|
||||
case 5:
|
||||
return 'rightCenter';
|
||||
case 6:
|
||||
return 'bottomRight';
|
||||
case 7:
|
||||
return 'bottomCenter';
|
||||
}
|
||||
}
|
||||
_getOpposingRectCornerNameByIndex (index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return 'topRight';
|
||||
case 1:
|
||||
return 'rightCenter';
|
||||
case 2:
|
||||
return 'bottomRight';
|
||||
case 3:
|
||||
return 'bottomCenter';
|
||||
case 4:
|
||||
return 'bottomLeft';
|
||||
case 5:
|
||||
return 'leftCenter';
|
||||
case 6:
|
||||
return 'topLeft';
|
||||
case 7:
|
||||
return 'topCenter';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ScaleTool;
|
136
src/helper/selection-tools/select-tool.js
Normal file
136
src/helper/selection-tools/select-tool.js
Normal file
|
@ -0,0 +1,136 @@
|
|||
import Modes from '../../modes/modes';
|
||||
|
||||
import {getHoveredItem} from '../hover';
|
||||
import {deleteSelection, selectRootItem} from '../selection';
|
||||
import BoundingBoxTool from './bounding-box-tool';
|
||||
import SelectionBoxTool from './selection-box-tool';
|
||||
import paper from 'paper';
|
||||
|
||||
/**
|
||||
* paper.Tool that handles select mode. This is made up of 2 subtools.
|
||||
* - The selection box tool is active when the user clicks an empty space and drags.
|
||||
* It selects all items in the rectangle.
|
||||
* - The bounding box tool is active if the user clicks on a non-empty space. It handles
|
||||
* reshaping the item that was clicked.
|
||||
*/
|
||||
class SelectTool extends paper.Tool {
|
||||
/** The distance within which mouse events count as a hit against an item */
|
||||
static get TOLERANCE () {
|
||||
return 6;
|
||||
}
|
||||
/**
|
||||
* @param {function} setHoveredItem Callback to set the hovered item
|
||||
* @param {function} clearHoveredItem Callback to clear the hovered item
|
||||
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
|
||||
*/
|
||||
constructor (setHoveredItem, clearHoveredItem, onUpdateSvg) {
|
||||
super();
|
||||
this.setHoveredItem = setHoveredItem;
|
||||
this.clearHoveredItem = clearHoveredItem;
|
||||
this.onUpdateSvg = onUpdateSvg;
|
||||
this.boundingBoxTool = new BoundingBoxTool(onUpdateSvg);
|
||||
this.selectionBoxTool = new SelectionBoxTool(Modes.SELECT);
|
||||
this.selectionBoxMode = false;
|
||||
|
||||
// We have to set these functions instead of just declaring them because
|
||||
// paper.js tools hook up the listeners in the setter functions.
|
||||
this.onMouseDown = this.handleMouseDown;
|
||||
this.onMouseMove = this.handleMouseMove;
|
||||
this.onMouseDrag = this.handleMouseDrag;
|
||||
this.onMouseUp = this.handleMouseUp;
|
||||
this.onKeyUp = this.handleKeyUp;
|
||||
|
||||
selectRootItem();
|
||||
this.boundingBoxTool.setSelectionBounds();
|
||||
}
|
||||
/**
|
||||
* To be called when the hovered item changes. When the select tool hovers over a
|
||||
* new item, it compares against this to see if a hover item change event needs to
|
||||
* be fired.
|
||||
* @param {paper.Item} prevHoveredItemId ID of the highlight item that indicates the mouse is
|
||||
* over a given item currently
|
||||
*/
|
||||
setPrevHoveredItemId (prevHoveredItemId) {
|
||||
this.prevHoveredItemId = prevHoveredItemId;
|
||||
}
|
||||
/**
|
||||
* Returns the hit options to use when conducting hit tests.
|
||||
* @param {boolean} preselectedOnly True if we should only return results that are already
|
||||
* selected.
|
||||
* @return {object} See paper.Item.hitTest for definition of options
|
||||
*/
|
||||
getHitOptions (preselectedOnly) {
|
||||
// Tolerance needs to be scaled when the view is zoomed in in order to represent the same
|
||||
// distance for the user to move the mouse.
|
||||
const hitOptions = {
|
||||
segments: true,
|
||||
stroke: true,
|
||||
curves: true,
|
||||
fill: true,
|
||||
guide: false,
|
||||
tolerance: SelectTool.TOLERANCE / paper.view.zoom
|
||||
};
|
||||
if (preselectedOnly) {
|
||||
hitOptions.selected = true;
|
||||
}
|
||||
return hitOptions;
|
||||
}
|
||||
handleMouseDown (event) {
|
||||
if (event.event.button > 0) return; // only first mouse button
|
||||
|
||||
// If bounding box tool does not find an item that was hit, use selection box tool.
|
||||
this.clearHoveredItem();
|
||||
if (!this.boundingBoxTool
|
||||
.onMouseDown(
|
||||
event,
|
||||
event.modifiers.alt,
|
||||
event.modifiers.shift,
|
||||
this.getHitOptions(false /* preseelectedOnly */))) {
|
||||
this.selectionBoxMode = true;
|
||||
this.selectionBoxTool.onMouseDown(event.modifiers.shift);
|
||||
}
|
||||
}
|
||||
handleMouseMove (event) {
|
||||
const hoveredItem = getHoveredItem(event, this.getHitOptions());
|
||||
if ((!hoveredItem && this.prevHoveredItemId) || // There is no longer a hovered item
|
||||
(hoveredItem && !this.prevHoveredItemId) || // There is now a hovered item
|
||||
(hoveredItem && this.prevHoveredItemId &&
|
||||
hoveredItem.id !== this.prevHoveredItemId)) { // hovered item changed
|
||||
this.setHoveredItem(hoveredItem ? hoveredItem.id : null);
|
||||
}
|
||||
}
|
||||
handleMouseDrag (event) {
|
||||
if (event.event.button > 0) return; // only first mouse button
|
||||
|
||||
if (this.selectionBoxMode) {
|
||||
this.selectionBoxTool.onMouseDrag(event);
|
||||
} else {
|
||||
this.boundingBoxTool.onMouseDrag(event);
|
||||
}
|
||||
}
|
||||
handleMouseUp (event) {
|
||||
if (event.event.button > 0) return; // only first mouse button
|
||||
|
||||
if (this.selectionBoxMode) {
|
||||
this.selectionBoxTool.onMouseUp(event);
|
||||
this.boundingBoxTool.setSelectionBounds();
|
||||
} else {
|
||||
this.boundingBoxTool.onMouseUp(event);
|
||||
}
|
||||
this.selectionBoxMode = false;
|
||||
}
|
||||
handleKeyUp (event) {
|
||||
// Backspace, delete
|
||||
if (event.key === 'delete' || event.key === 'backspace') {
|
||||
deleteSelection(Modes.SELECT);
|
||||
this.boundingBoxTool.removeBoundsPath();
|
||||
this.onUpdateSvg();
|
||||
}
|
||||
}
|
||||
deactivateTool () {
|
||||
this.clearHoveredItem();
|
||||
this.boundingBoxTool.removeBoundsPath();
|
||||
}
|
||||
}
|
||||
|
||||
export default SelectTool;
|
32
src/helper/selection-tools/selection-box-tool.js
Normal file
32
src/helper/selection-tools/selection-box-tool.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {rectSelect} from '../guides';
|
||||
import {clearSelection, processRectangularSelection} from '../selection';
|
||||
|
||||
/** Tool to handle drag selection. A dotted line box appears and everything enclosed is selected. */
|
||||
class SelectionBoxTool {
|
||||
constructor (mode) {
|
||||
this.selectionRect = null;
|
||||
this.mode = mode;
|
||||
}
|
||||
/**
|
||||
* @param {boolean} multiselect Whether to multiselect on mouse down (e.g. shift key held)
|
||||
*/
|
||||
onMouseDown (multiselect) {
|
||||
if (!multiselect) {
|
||||
clearSelection();
|
||||
}
|
||||
}
|
||||
onMouseDrag (event) {
|
||||
this.selectionRect = rectSelect(event);
|
||||
// Remove this rect on the next drag and up event
|
||||
this.selectionRect.removeOnDrag();
|
||||
}
|
||||
onMouseUp (event) {
|
||||
if (this.selectionRect) {
|
||||
processRectangularSelection(event, this.selectionRect, this.mode);
|
||||
this.selectionRect.remove();
|
||||
this.selectionRect = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SelectionBoxTool;
|
526
src/helper/selection.js
Normal file
526
src/helper/selection.js
Normal file
|
@ -0,0 +1,526 @@
|
|||
import paper from 'paper';
|
||||
import Modes from '../modes/modes';
|
||||
|
||||
import {getItemsGroup, isGroup} from './group';
|
||||
import {getRootItem, isBoundsItem, isCompoundPathItem, isPathItem, isPGTextItem} from './item';
|
||||
import {getItemsCompoundPath, isCompoundPath, isCompoundPathChild} from './compound-path';
|
||||
|
||||
/**
|
||||
* @param {boolean} includeGuides True if guide layer items like the bounding box should
|
||||
* be included in the returned items.
|
||||
* @return {Array<paper.item>} all top-level (direct descendants of a paper.Layer) items
|
||||
*/
|
||||
const getAllPaperItems = function (includeGuides) {
|
||||
includeGuides = includeGuides || false;
|
||||
const allItems = [];
|
||||
for (const layer of paper.project.layers) {
|
||||
for (const child of layer.children) {
|
||||
// don't give guides back
|
||||
if (!includeGuides && child.guide) {
|
||||
continue;
|
||||
}
|
||||
allItems.push(child);
|
||||
}
|
||||
}
|
||||
return allItems;
|
||||
};
|
||||
|
||||
/**
|
||||
* @return {Array<paper.item>} all top-level (direct descendants of a paper.Layer) items
|
||||
* that aren't guide items or helper items.
|
||||
*/
|
||||
const getAllSelectableItems = function () {
|
||||
const allItems = getAllPaperItems();
|
||||
const selectables = [];
|
||||
for (let i = 0; i < allItems.length; i++) {
|
||||
if (allItems[i].data && !allItems[i].data.isHelperItem) {
|
||||
selectables.push(allItems[i]);
|
||||
}
|
||||
}
|
||||
return selectables;
|
||||
};
|
||||
|
||||
const selectItemSegments = function (item, state) {
|
||||
if (item.children) {
|
||||
for (let i = 0; i < item.children.length; i++) {
|
||||
const child = item.children[i];
|
||||
if (child.children && child.children.length > 0) {
|
||||
selectItemSegments(child, state);
|
||||
} else {
|
||||
child.fullySelected = state;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < item.segments.length; i++) {
|
||||
item.segments[i].selected = state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setGroupSelection = function (root, selected, fullySelected) {
|
||||
root.fullySelected = fullySelected;
|
||||
root.selected = selected;
|
||||
// select children of compound-path or group
|
||||
if (isCompoundPath(root) || isGroup(root)) {
|
||||
const children = root.children;
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
if (isGroup(child)) {
|
||||
setGroupSelection(child, selected, fullySelected);
|
||||
} else {
|
||||
child.fullySelected = fullySelected;
|
||||
child.selected = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setItemSelection = function (item, state, fullySelected) {
|
||||
const parentGroup = getItemsGroup(item);
|
||||
const itemsCompoundPath = getItemsCompoundPath(item);
|
||||
|
||||
// if selection is in a group, select group not individual items
|
||||
if (parentGroup) {
|
||||
// do it recursive
|
||||
setItemSelection(parentGroup, state, fullySelected);
|
||||
} else if (itemsCompoundPath) {
|
||||
setGroupSelection(itemsCompoundPath, state, fullySelected);
|
||||
} else {
|
||||
if (item.data && item.data.noSelect) {
|
||||
return;
|
||||
}
|
||||
setGroupSelection(item, state, fullySelected);
|
||||
}
|
||||
// @todo: Update toolbar state on change
|
||||
|
||||
};
|
||||
|
||||
const selectAllItems = function () {
|
||||
const items = getAllSelectableItems();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
setItemSelection(items[i], true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAllSegments = function () {
|
||||
const items = getAllSelectableItems();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
selectItemSegments(items[i], true);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = function () {
|
||||
paper.project.deselectAll();
|
||||
// @todo: Update toolbar state on change
|
||||
};
|
||||
|
||||
// This gets all selected non-grouped items and groups
|
||||
// (alternative to paper.project.selectedItems, which includes
|
||||
// group children in addition to the group)
|
||||
// Returns in increasing Z order
|
||||
const getSelectedItems = function (recursive) {
|
||||
const allItems = paper.project.selectedItems;
|
||||
const itemsAndGroups = [];
|
||||
|
||||
if (recursive) {
|
||||
for (let i = 0; i < allItems.length; i++) {
|
||||
const item = allItems[i];
|
||||
if (item.data && !item.data.isSelectionBound) {
|
||||
itemsAndGroups.push(item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < allItems.length; i++) {
|
||||
const item = allItems[i];
|
||||
if ((isGroup(item) && !isGroup(item.parent)) ||
|
||||
!isGroup(item.parent)) {
|
||||
if (item.data && !item.data.isSelectionBound) {
|
||||
itemsAndGroups.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// sort items by index (0 at bottom)
|
||||
itemsAndGroups.sort((a, b) => parseFloat(a.index) - parseFloat(b.index));
|
||||
return itemsAndGroups;
|
||||
};
|
||||
|
||||
const deleteItemSelection = function () {
|
||||
const items = getSelectedItems();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
items[i].remove();
|
||||
}
|
||||
|
||||
// @todo: Update toolbar state on change
|
||||
paper.project.view.update();
|
||||
// @todo add back undo
|
||||
// pg.undo.snapshot('deleteItemSelection');
|
||||
};
|
||||
|
||||
const removeSelectedSegments = function () {
|
||||
// @todo add back undo
|
||||
// pg.undo.snapshot('removeSelectedSegments');
|
||||
|
||||
const items = getSelectedItems();
|
||||
const segmentsToRemove = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const segments = items[i].segments;
|
||||
for (let j = 0; j < segments.length; j++) {
|
||||
const seg = segments[j];
|
||||
if (seg.selected) {
|
||||
segmentsToRemove.push(seg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let removedSegments = false;
|
||||
for (let i = 0; i < segmentsToRemove.length; i++) {
|
||||
const seg = segmentsToRemove[i];
|
||||
seg.remove();
|
||||
removedSegments = true;
|
||||
}
|
||||
return removedSegments;
|
||||
};
|
||||
|
||||
const deleteSelection = function (mode) {
|
||||
if (mode === Modes.RESHAPE) {
|
||||
// If there are points selected remove them. If not delete the item selected.
|
||||
if (!removeSelectedSegments()) {
|
||||
deleteItemSelection();
|
||||
}
|
||||
} else {
|
||||
deleteItemSelection();
|
||||
}
|
||||
};
|
||||
|
||||
const splitPathRetainSelection = function (path, index, deselectSplitSegments) {
|
||||
const selectedPoints = [];
|
||||
|
||||
// collect points of selected segments, so we can reselect them
|
||||
// once the path is split.
|
||||
for (let i = 0; i < path.segments.length; i++) {
|
||||
const seg = path.segments[i];
|
||||
if (seg.selected) {
|
||||
if (deselectSplitSegments && i === index) {
|
||||
continue;
|
||||
}
|
||||
selectedPoints.push(seg.point);
|
||||
}
|
||||
}
|
||||
|
||||
const newPath = path.split(index, 0);
|
||||
if (!newPath) return;
|
||||
|
||||
// reselect all of the newPaths segments that are in the exact same location
|
||||
// as the ones that are stored in selectedPoints
|
||||
for (let i = 0; i < newPath.segments.length; i++) {
|
||||
const seg = newPath.segments[i];
|
||||
for (let j = 0; j < selectedPoints.length; j++) {
|
||||
const point = selectedPoints[j];
|
||||
if (point.x === seg.point.x && point.y === seg.point.y) {
|
||||
seg.selected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// only do this if path and newPath are different
|
||||
// (split at more than one point)
|
||||
if (path !== newPath) {
|
||||
for (let i = 0; i < path.segments.length; i++) {
|
||||
const seg = path.segments[i];
|
||||
for (let j = 0; j < selectedPoints.length; j++) {
|
||||
const point = selectedPoints[j];
|
||||
if (point.x === seg.point.x && point.y === seg.point.y) {
|
||||
seg.selected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const splitPathAtSelectedSegments = function () {
|
||||
const items = getSelectedItems();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const segments = item.segments;
|
||||
for (let j = 0; j < segments.length; j++) {
|
||||
const segment = segments[j];
|
||||
if (segment.selected) {
|
||||
if (item.closed ||
|
||||
(segment.next &&
|
||||
!segment.next.selected &&
|
||||
segment.previous &&
|
||||
!segment.previous.selected)) {
|
||||
splitPathRetainSelection(item, j, true);
|
||||
splitPathAtSelectedSegments();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSegments = function (item) {
|
||||
if (item.children) {
|
||||
for (let i = 0; i < item.children.length; i++) {
|
||||
const child = item.children[i];
|
||||
deleteSegments(child);
|
||||
}
|
||||
} else {
|
||||
const segments = item.segments;
|
||||
for (let j = 0; j < segments.length; j++) {
|
||||
const segment = segments[j];
|
||||
if (segment.selected) {
|
||||
if (item.closed ||
|
||||
(segment.next &&
|
||||
!segment.next.selected &&
|
||||
segment.previous &&
|
||||
!segment.previous.selected)) {
|
||||
|
||||
splitPathRetainSelection(item, j);
|
||||
deleteSelection();
|
||||
return;
|
||||
|
||||
} else if (!item.closed) {
|
||||
segment.remove();
|
||||
j--; // decrease counter if we removed one from the loop
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
// remove items with no segments left
|
||||
if (item.segments.length <= 0) {
|
||||
item.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSegmentSelection = function () {
|
||||
|
||||
const items = getSelectedItems();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
deleteSegments(items[i]);
|
||||
}
|
||||
|
||||
// @todo: Update toolbar state on change
|
||||
paper.project.view.update();
|
||||
// @todo add back undo
|
||||
// pg.undo.snapshot('deleteSegmentSelection');
|
||||
};
|
||||
|
||||
const cloneSelection = function () {
|
||||
const selectedItems = getSelectedItems();
|
||||
for (let i = 0; i < selectedItems.length; i++) {
|
||||
const item = selectedItems[i];
|
||||
item.clone();
|
||||
item.selected = false;
|
||||
}
|
||||
// @todo add back undo
|
||||
// pg.undo.snapshot('cloneSelection');
|
||||
};
|
||||
|
||||
// Only returns paths, no compound paths, groups or any other stuff
|
||||
const getSelectedPaths = function () {
|
||||
const allPaths = getSelectedItems();
|
||||
const paths = [];
|
||||
|
||||
for (let i = 0; i < allPaths.length; i++) {
|
||||
const path = allPaths[i];
|
||||
if (path.className === 'Path') {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
const checkBoundsItem = function (selectionRect, item, event) {
|
||||
const itemBounds = new paper.Path([
|
||||
item.localToGlobal(item.internalBounds.topLeft),
|
||||
item.localToGlobal(item.internalBounds.topRight),
|
||||
item.localToGlobal(item.internalBounds.bottomRight),
|
||||
item.localToGlobal(item.internalBounds.bottomLeft)
|
||||
]);
|
||||
itemBounds.closed = true;
|
||||
itemBounds.guide = true;
|
||||
|
||||
for (let i = 0; i < itemBounds.segments.length; i++) {
|
||||
const seg = itemBounds.segments[i];
|
||||
if (selectionRect.contains(seg.point) ||
|
||||
(i === 0 && selectionRect.getIntersections(itemBounds).length > 0)) {
|
||||
if (event.modifiers.shift && item.selected) {
|
||||
setItemSelection(item, false);
|
||||
|
||||
} else {
|
||||
setItemSelection(item, true);
|
||||
}
|
||||
itemBounds.remove();
|
||||
return true;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
itemBounds.remove();
|
||||
};
|
||||
|
||||
const _handleRectangularSelectionItems = function (item, event, rect, mode, root) {
|
||||
if (isPathItem(item)) {
|
||||
let segmentMode = false;
|
||||
|
||||
// first round checks for segments inside the selectionRect
|
||||
for (let j = 0; j < item.segments.length; j++) {
|
||||
const seg = item.segments[j];
|
||||
if (rect.contains(seg.point)) {
|
||||
if (mode === Modes.RESHAPE) {
|
||||
if (event.modifiers.shift && seg.selected) {
|
||||
seg.selected = false;
|
||||
} else {
|
||||
seg.selected = true;
|
||||
}
|
||||
segmentMode = true;
|
||||
} else {
|
||||
if (event.modifiers.shift && item.selected) {
|
||||
setItemSelection(root, false);
|
||||
} else {
|
||||
setItemSelection(root, true, true /* fullySelected */);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// second round checks for path intersections
|
||||
const intersections = item.getIntersections(rect);
|
||||
if (intersections.length > 0 && !segmentMode) {
|
||||
// if in reshape mode, select the curves that intersect
|
||||
// with the selectionRect
|
||||
if (mode === Modes.RESHAPE) {
|
||||
for (let k = 0; k < intersections.length; k++) {
|
||||
const curve = intersections[k].curve;
|
||||
// intersections contains every curve twice because
|
||||
// the selectionRect intersects a circle always at
|
||||
// two points. so we skip every other curve
|
||||
if (k % 2 === 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.modifiers.shift) {
|
||||
curve.selected = !curve.selected;
|
||||
} else {
|
||||
curve.selected = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (event.modifiers.shift && item.selected) {
|
||||
setItemSelection(item, false);
|
||||
|
||||
} else {
|
||||
setItemSelection(item, true);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// @todo: Update toolbar state on change
|
||||
|
||||
} else if (isBoundsItem(item)) {
|
||||
if (checkBoundsItem(rect, item, event)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// if the rectangular selection found a group, drill into it recursively
|
||||
const _rectangularSelectionGroupLoop = function (group, rect, root, event, mode) {
|
||||
for (let i = 0; i < group.children.length; i++) {
|
||||
const child = group.children[i];
|
||||
|
||||
if (isGroup(child) || isCompoundPathItem(child)) {
|
||||
_rectangularSelectionGroupLoop(child, rect, root, event, mode);
|
||||
} else {
|
||||
_handleRectangularSelectionItems(child, event, rect, mode, root);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Called after drawing a selection rectangle in a select mode. In reshape mode, this
|
||||
* selects all control points and curves within the rectangle. In select mode, this
|
||||
* selects all items and groups that intersect the rectangle
|
||||
* @param {!MouseEvent} event The mouse event to draw the rectangle
|
||||
* @param {!paper.Rect} rect The selection rectangle
|
||||
* @param {Modes} mode The mode of the paint editor when drawing the rectangle
|
||||
*/
|
||||
const processRectangularSelection = function (event, rect, mode) {
|
||||
const allItems = getAllSelectableItems();
|
||||
|
||||
for (let i = 0; i < allItems.length; i++) {
|
||||
const item = allItems[i];
|
||||
if (mode === Modes.RESHAPE && isPGTextItem(getRootItem(item))) {
|
||||
continue;
|
||||
}
|
||||
if (isGroup(item) || isCompoundPathItem(item)) {
|
||||
// check for item segment points inside
|
||||
_rectangularSelectionGroupLoop(item, rect, item, event, mode);
|
||||
} else {
|
||||
_handleRectangularSelectionItems(item, event, rect, mode, item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When switching to the select tool while having a child object of a
|
||||
* compound path selected, deselect the child and select the compound path
|
||||
* instead. (otherwise the compound path breaks because of scale-grouping)
|
||||
*/
|
||||
const selectRootItem = function () {
|
||||
const items = getSelectedItems();
|
||||
for (const item of items) {
|
||||
if (isCompoundPathChild(item)) {
|
||||
const cp = getItemsCompoundPath(item);
|
||||
setItemSelection(cp, true, true /* fullySelected */);
|
||||
}
|
||||
const rootItem = getRootItem(item);
|
||||
if (item !== rootItem) {
|
||||
setItemSelection(rootItem, true, true /* fullySelected */);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowIfSelection = function () {
|
||||
return getSelectedItems().length > 0;
|
||||
};
|
||||
|
||||
const shouldShowIfSelectionRecursive = function () {
|
||||
return getSelectedItems(true /* recursive */).length > 0;
|
||||
};
|
||||
|
||||
const shouldShowSelectAll = function () {
|
||||
return paper.project.getItems({class: paper.PathItem}).length > 0;
|
||||
};
|
||||
|
||||
export {
|
||||
getAllPaperItems,
|
||||
selectAllItems,
|
||||
selectAllSegments,
|
||||
clearSelection,
|
||||
deleteSelection,
|
||||
deleteItemSelection,
|
||||
deleteSegmentSelection,
|
||||
splitPathAtSelectedSegments,
|
||||
cloneSelection,
|
||||
setItemSelection,
|
||||
setGroupSelection,
|
||||
getSelectedItems,
|
||||
getSelectedPaths,
|
||||
removeSelectedSegments,
|
||||
processRectangularSelection,
|
||||
selectRootItem,
|
||||
shouldShowIfSelection,
|
||||
shouldShowIfSelectionRecursive,
|
||||
shouldShowSelectAll
|
||||
};
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -3,7 +3,9 @@ import keyMirror from 'keymirror';
|
|||
const Modes = keyMirror({
|
||||
BRUSH: null,
|
||||
ERASER: null,
|
||||
LINE: null
|
||||
LINE: null,
|
||||
SELECT: null,
|
||||
RESHAPE: null
|
||||
});
|
||||
|
||||
export default Modes;
|
||||
|
|
47
src/reducers/hover.js
Normal file
47
src/reducers/hover.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
import log from '../log/log';
|
||||
|
||||
const CHANGE_HOVERED = 'scratch-paint/hover/CHANGE_HOVERED';
|
||||
const initialState = null;
|
||||
|
||||
const reducer = function (state, action) {
|
||||
if (typeof state === 'undefined') state = initialState;
|
||||
switch (action.type) {
|
||||
case CHANGE_HOVERED:
|
||||
if (typeof action.hoveredItemId === 'undefined') {
|
||||
log.warn(`Hovered item should not be set to undefined. Use null.`);
|
||||
return state;
|
||||
} else if (typeof action.hoveredItemId === 'undefined' || isNaN(action.hoveredItemId)) {
|
||||
log.warn(`Hovered item should be an item ID number. Got: ${action.hoveredItemId}`);
|
||||
return state;
|
||||
}
|
||||
return action.hoveredItemId;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Action creators ==================================
|
||||
/**
|
||||
* Set the hovered item state to the given item ID
|
||||
* @param {number} hoveredItemId The paper.Item ID of the hover indicator item.
|
||||
* @return {object} Redux action to change the hovered item.
|
||||
*/
|
||||
const setHoveredItem = function (hoveredItemId) {
|
||||
return {
|
||||
type: CHANGE_HOVERED,
|
||||
hoveredItemId: hoveredItemId
|
||||
};
|
||||
};
|
||||
|
||||
const clearHoveredItem = function () {
|
||||
return {
|
||||
type: CHANGE_HOVERED,
|
||||
hoveredItemId: null
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
reducer as default,
|
||||
setHoveredItem,
|
||||
clearHoveredItem
|
||||
};
|
|
@ -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
|
||||
});
|
||||
|
|
15
test/unit/components/select-mode.test.jsx
Normal file
15
test/unit/components/select-mode.test.jsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
/* eslint-env jest */
|
||||
import React from 'react'; // eslint-disable-line no-unused-vars
|
||||
import {shallow} from 'enzyme';
|
||||
import SelectModeComponent from '../../../src/components/select-mode.jsx'; // eslint-disable-line no-unused-vars
|
||||
|
||||
describe('SelectModeComponent', () => {
|
||||
test('triggers callback when clicked', () => {
|
||||
const onClick = jest.fn();
|
||||
const componentShallowWrapper = shallow(
|
||||
<SelectModeComponent onMouseDown={onClick}/>
|
||||
);
|
||||
componentShallowWrapper.simulate('click');
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
35
test/unit/hover-reducer.test.js
Normal file
35
test/unit/hover-reducer.test.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
/* eslint-env jest */
|
||||
import reducer from '../../src/reducers/hover';
|
||||
import {clearHoveredItem, setHoveredItem} from '../../src/reducers/hover';
|
||||
|
||||
test('initialState', () => {
|
||||
let defaultState;
|
||||
expect(reducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeNull();
|
||||
});
|
||||
|
||||
test('setHoveredItem', () => {
|
||||
let defaultState;
|
||||
const item1 = 1;
|
||||
const item2 = 2;
|
||||
expect(reducer(defaultState /* state */, setHoveredItem(item1) /* action */)).toBe(item1);
|
||||
expect(reducer(item1 /* state */, setHoveredItem(item2) /* action */)).toBe(item2);
|
||||
});
|
||||
|
||||
test('clearHoveredItem', () => {
|
||||
let defaultState;
|
||||
const item = 1;
|
||||
expect(reducer(defaultState /* state */, clearHoveredItem() /* action */)).toBeNull();
|
||||
expect(reducer(item /* state */, clearHoveredItem() /* action */)).toBeNull();
|
||||
});
|
||||
|
||||
test('invalidSetHoveredItem', () => {
|
||||
let defaultState;
|
||||
const item = 1;
|
||||
const nonItem = {random: 'object'};
|
||||
let undef;
|
||||
expect(reducer(defaultState /* state */, setHoveredItem(nonItem) /* action */)).toBeNull();
|
||||
expect(reducer(item /* state */, setHoveredItem(nonItem) /* action */))
|
||||
.toBe(item);
|
||||
expect(reducer(item /* state */, setHoveredItem(undef) /* action */))
|
||||
.toBe(item);
|
||||
});
|
Loading…
Reference in a new issue