2017-10-12 18:35:30 -04:00
|
|
|
import paper from '@scratch/paper';
|
2017-09-21 10:36:26 -04:00
|
|
|
import log from '../../log/log';
|
|
|
|
import keyMirror from 'keymirror';
|
|
|
|
|
2017-11-07 14:02:39 -05:00
|
|
|
import Modes from '../../lib/modes';
|
2018-09-26 11:19:46 -04:00
|
|
|
import {isBoundsItem} from '../item';
|
|
|
|
import {hoverBounds, hoverItem} from '../guides';
|
|
|
|
import {sortItemsByZIndex} from '../math';
|
2018-01-25 15:56:50 -05:00
|
|
|
import {getSelectedLeafItems, getSelectedSegments} from '../selection';
|
2017-09-21 10:36:26 -04:00
|
|
|
import MoveTool from './move-tool';
|
|
|
|
import PointTool from './point-tool';
|
|
|
|
import HandleTool from './handle-tool';
|
|
|
|
import SelectionBoxTool from './selection-box-tool';
|
|
|
|
|
|
|
|
/** Modes of the reshape tool, which can do many things depending on how it's used. */
|
|
|
|
const ReshapeModes = keyMirror({
|
|
|
|
FILL: null,
|
|
|
|
POINT: null,
|
|
|
|
HANDLE: null,
|
|
|
|
SELECTION_BOX: null
|
|
|
|
});
|
|
|
|
|
2017-09-22 14:14:48 -04:00
|
|
|
/**
|
|
|
|
* paper.Tool to handle reshape mode, which allows manipulation of control points and
|
|
|
|
* handles of path items. Can be used to select items within groups and points within items.
|
|
|
|
* Reshape is made up of 4 tools:
|
|
|
|
* - Selection box tool, which is activated by clicking an empty area. Draws a box and selects
|
|
|
|
* points and curves inside it
|
|
|
|
* - Move tool, which translates items
|
|
|
|
* - Point tool, which translates, adds and removes points
|
|
|
|
* - Handle tool, which translates handles, changing the shape of curves
|
|
|
|
*/
|
2017-09-21 10:36:26 -04:00
|
|
|
class ReshapeTool extends paper.Tool {
|
2017-09-22 14:14:48 -04:00
|
|
|
/** Distance within which mouse is considered to be hitting an item */
|
2017-09-21 10:36:26 -04:00
|
|
|
static get TOLERANCE () {
|
2018-08-16 13:09:26 -04:00
|
|
|
return 4;
|
2017-09-21 10:36:26 -04:00
|
|
|
}
|
2017-09-22 14:14:48 -04:00
|
|
|
/** Clicks registered within this amount of time are registered as double clicks */
|
2017-09-21 10:36:26 -04:00
|
|
|
static get DOUBLE_CLICK_MILLIS () {
|
|
|
|
return 250;
|
|
|
|
}
|
2017-09-22 14:14:48 -04:00
|
|
|
/**
|
|
|
|
* @param {function} setHoveredItem Callback to set the hovered item
|
|
|
|
* @param {function} clearHoveredItem Callback to clear the hovered item
|
2017-10-02 15:25:04 -04:00
|
|
|
* @param {function} setSelectedItems Callback to set the set of selected items in the Redux state
|
|
|
|
* @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state
|
2018-04-26 18:45:50 -04:00
|
|
|
* @param {!function} onUpdateImage A callback to call when the image visibly changes
|
2018-08-09 10:55:44 -04:00
|
|
|
* @param {!function} switchToTextTool A callback to call to switch to the text tool
|
2017-09-22 14:14:48 -04:00
|
|
|
*/
|
2018-08-09 10:55:44 -04:00
|
|
|
constructor (setHoveredItem, clearHoveredItem, setSelectedItems, clearSelectedItems, onUpdateImage,
|
|
|
|
switchToTextTool) {
|
2017-09-21 10:36:26 -04:00
|
|
|
super();
|
|
|
|
this.setHoveredItem = setHoveredItem;
|
|
|
|
this.clearHoveredItem = clearHoveredItem;
|
2018-04-26 18:45:50 -04:00
|
|
|
this.onUpdateImage = onUpdateImage;
|
2017-09-22 13:56:58 -04:00
|
|
|
this.prevHoveredItemId = null;
|
2017-09-21 10:36:26 -04:00
|
|
|
this.lastEvent = null;
|
2017-11-06 11:13:52 -05:00
|
|
|
this.active = false;
|
2017-09-21 10:36:26 -04:00
|
|
|
this.mode = ReshapeModes.SELECTION_BOX;
|
|
|
|
this._modeMap = {};
|
2017-10-11 10:35:10 -04:00
|
|
|
this._modeMap[ReshapeModes.FILL] =
|
2018-08-09 10:55:44 -04:00
|
|
|
new MoveTool(Modes.RESHAPE, setSelectedItems, clearSelectedItems, onUpdateImage, switchToTextTool);
|
2018-04-26 18:45:50 -04:00
|
|
|
this._modeMap[ReshapeModes.POINT] = new PointTool(setSelectedItems, clearSelectedItems, onUpdateImage);
|
|
|
|
this._modeMap[ReshapeModes.HANDLE] = new HandleTool(setSelectedItems, clearSelectedItems, onUpdateImage);
|
2017-10-03 13:45:19 -04:00
|
|
|
this._modeMap[ReshapeModes.SELECTION_BOX] =
|
|
|
|
new SelectionBoxTool(Modes.RESHAPE, setSelectedItems, clearSelectedItems);
|
2017-09-21 10:36:26 -04:00
|
|
|
|
|
|
|
// 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;
|
2018-01-25 15:56:50 -05:00
|
|
|
this.onKeyUp = this.handleKeyUp;
|
|
|
|
this.onKeyDown = this.handleKeyDown;
|
2017-09-21 18:20:44 -04:00
|
|
|
|
|
|
|
paper.settings.handleSize = 8;
|
2017-09-21 10:36:26 -04:00
|
|
|
}
|
2017-09-22 14:14:48 -04:00
|
|
|
/**
|
2018-09-26 11:19:46 -04:00
|
|
|
* Returns the hit options for segments to use when conducting hit tests. Segments are only visible
|
|
|
|
* when the shape is selected. Segments take precedence, since they are always over curves and need
|
|
|
|
* to be grabbable. (Segments are the little circles)
|
2017-09-22 14:14:48 -04:00
|
|
|
* @return {object} See paper.Item.hitTest for definition of options
|
|
|
|
*/
|
2018-09-26 11:19:46 -04:00
|
|
|
getSelectedSegmentHitOptions () {
|
2017-09-22 12:31:39 -04:00
|
|
|
const hitOptions = {
|
|
|
|
segments: true,
|
2018-09-26 11:19:46 -04:00
|
|
|
tolerance: ReshapeTool.TOLERANCE / paper.view.zoom,
|
|
|
|
match: hitResult => {
|
2018-11-05 15:58:42 -05:00
|
|
|
if (hitResult.type !== 'segment') return false;
|
2018-09-26 11:19:46 -04:00
|
|
|
if (hitResult.item.data && hitResult.item.data.noHover) return false;
|
|
|
|
if (!hitResult.item.selected) return false;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
return hitOptions;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Returns the hit options for handles to use when conducting hit tests. Handles need to be done
|
|
|
|
* separately because we want to ignore hidden handles, but we don't want hidden handles to negate
|
|
|
|
* legitimate hits on other things (like if the handle is over part of the fill). (Handles are the diamonds)
|
|
|
|
* @return {object} See paper.Item.hitTest for definition of options
|
|
|
|
*/
|
|
|
|
getHandleHitOptions () {
|
|
|
|
const hitOptions = {
|
|
|
|
handles: true,
|
|
|
|
tolerance: ReshapeTool.TOLERANCE / paper.view.zoom,
|
|
|
|
match: hitResult => {
|
|
|
|
if (hitResult.item.data && hitResult.item.data.noHover) return false;
|
|
|
|
// Only hit test against handles that are visible, that is,
|
|
|
|
// their segment is selected
|
2018-11-05 15:58:42 -05:00
|
|
|
if (!hitResult.segment || !hitResult.segment.selected) return false;
|
2018-09-26 11:19:46 -04:00
|
|
|
// If the entire shape is selected, handles are hidden
|
|
|
|
if (hitResult.item.fullySelected) return false;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
return hitOptions;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Returns the hit options for strokes and curves of selected objects, which take precedence over
|
|
|
|
* unselected things and fills.
|
|
|
|
* @return {object} See paper.Item.hitTest for definition of options
|
|
|
|
*/
|
|
|
|
getSelectedStrokeHitOptions () {
|
|
|
|
const hitOptions = {
|
|
|
|
segments: false,
|
2017-09-22 12:31:39 -04:00
|
|
|
stroke: true,
|
|
|
|
curves: true,
|
2018-09-26 11:19:46 -04:00
|
|
|
handles: false,
|
|
|
|
fill: false,
|
2017-09-22 14:08:19 -04:00
|
|
|
guide: false,
|
2018-09-26 11:19:46 -04:00
|
|
|
tolerance: ReshapeTool.TOLERANCE / paper.view.zoom,
|
|
|
|
match: hitResult => {
|
2018-11-05 15:58:42 -05:00
|
|
|
if (hitResult.type !== 'stroke' || hitResult.type !== 'curve') return false;
|
2018-09-26 11:19:46 -04:00
|
|
|
if (!hitResult.item.selected) return false;
|
|
|
|
if (hitResult.item.data && hitResult.item.data.noHover) return false;
|
2017-09-22 12:31:39 -04:00
|
|
|
return true;
|
2018-09-26 11:19:46 -04:00
|
|
|
}
|
|
|
|
};
|
|
|
|
return hitOptions;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Returns the hit options for fills and unselected strokes/curves 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
|
|
|
|
*/
|
|
|
|
getUnselectedAndFillHitOptions () {
|
|
|
|
const hitOptions = {
|
|
|
|
fill: true,
|
|
|
|
stroke: true,
|
|
|
|
curves: true,
|
|
|
|
tolerance: ReshapeTool.TOLERANCE / paper.view.zoom,
|
|
|
|
match: hitResult => {
|
|
|
|
if (hitResult.item.data && hitResult.item.data.noHover) return false;
|
2017-09-22 12:31:39 -04:00
|
|
|
return true;
|
2018-09-26 11:19:46 -04:00
|
|
|
}
|
|
|
|
};
|
2017-09-22 12:31:39 -04:00
|
|
|
return hitOptions;
|
2017-09-21 10:36:26 -04:00
|
|
|
}
|
2017-09-22 14:14:48 -04:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
2017-09-22 13:56:58 -04:00
|
|
|
setPrevHoveredItemId (prevHoveredItemId) {
|
|
|
|
this.prevHoveredItemId = prevHoveredItemId;
|
2017-09-21 10:36:26 -04:00
|
|
|
}
|
2018-09-26 11:19:46 -04:00
|
|
|
/**
|
|
|
|
* Given the point at which the mouse is, return the prioritized hit result, or null if nothing was hit.
|
|
|
|
* @param {paper.Point} point Point to hit test on canvas
|
|
|
|
* @return {?paper.HitResult} hitResult
|
|
|
|
*/
|
|
|
|
getHitResult (point) {
|
|
|
|
// Prefer hits on segments to other types of hits, since segments always overlap curves.
|
|
|
|
let hitResults =
|
|
|
|
paper.project.hitTestAll(point, this.getSelectedSegmentHitOptions());
|
|
|
|
if (!hitResults.length) {
|
|
|
|
hitResults = paper.project.hitTestAll(point, this.getHandleHitOptions());
|
|
|
|
}
|
|
|
|
if (!hitResults.length) {
|
|
|
|
hitResults = paper.project.hitTestAll(point, this.getSelectedStrokeHitOptions());
|
|
|
|
}
|
|
|
|
if (!hitResults.length) {
|
|
|
|
hitResults = paper.project.hitTestAll(point, this.getUnselectedAndFillHitOptions());
|
|
|
|
}
|
|
|
|
if (!hitResults.length) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get highest z-index result
|
|
|
|
let hitResult;
|
|
|
|
for (const result of hitResults) {
|
|
|
|
if (!hitResult || sortItemsByZIndex(hitResult.item, result.item) < 0) {
|
|
|
|
hitResult = result;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return hitResult;
|
|
|
|
}
|
2017-09-21 10:36:26 -04:00
|
|
|
handleMouseDown (event) {
|
|
|
|
if (event.event.button > 0) return; // only first mouse button
|
2017-11-06 11:13:52 -05:00
|
|
|
this.active = true;
|
2017-09-21 10:36:26 -04:00
|
|
|
this.clearHoveredItem();
|
|
|
|
|
|
|
|
// Check if double clicked
|
|
|
|
let doubleClicked = false;
|
|
|
|
if (this.lastEvent) {
|
|
|
|
if ((event.event.timeStamp - this.lastEvent.event.timeStamp) < ReshapeTool.DOUBLE_CLICK_MILLIS) {
|
|
|
|
doubleClicked = true;
|
|
|
|
} else {
|
|
|
|
doubleClicked = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.lastEvent = event;
|
|
|
|
|
2018-09-26 11:19:46 -04:00
|
|
|
const hitResult = this.getHitResult(event.point);
|
|
|
|
if (!hitResult) {
|
2017-09-21 10:36:26 -04:00
|
|
|
this._modeMap[ReshapeModes.SELECTION_BOX].onMouseDown(event.modifiers.shift);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const hitProperties = {
|
|
|
|
hitResult: hitResult,
|
|
|
|
clone: event.modifiers.alt,
|
|
|
|
multiselect: event.modifiers.shift,
|
|
|
|
doubleClicked: doubleClicked,
|
|
|
|
subselect: true
|
|
|
|
};
|
|
|
|
|
|
|
|
// If item is not yet selected, don't behave differently depending on if they clicked a segment
|
|
|
|
// or stroke (since those were invisible), just select the whole thing as if they clicked the fill.
|
|
|
|
if (!hitResult.item.selected ||
|
|
|
|
hitResult.type === 'fill' ||
|
|
|
|
(hitResult.type !== 'segment' && doubleClicked)) {
|
|
|
|
this.mode = ReshapeModes.FILL;
|
|
|
|
this._modeMap[this.mode].onMouseDown(hitProperties);
|
|
|
|
} else if (hitResult.type === 'segment') {
|
|
|
|
this.mode = ReshapeModes.POINT;
|
|
|
|
this._modeMap[this.mode].onMouseDown(hitProperties);
|
|
|
|
} else if (
|
|
|
|
hitResult.type === 'stroke' ||
|
|
|
|
hitResult.type === 'curve') {
|
|
|
|
this.mode = ReshapeModes.POINT;
|
|
|
|
this._modeMap[this.mode].addPoint(hitProperties);
|
2018-09-24 13:47:57 -04:00
|
|
|
this.onUpdateImage();
|
2017-09-21 10:36:26 -04:00
|
|
|
this._modeMap[this.mode].onMouseDown(hitProperties);
|
|
|
|
} else if (
|
|
|
|
hitResult.type === 'handle-in' ||
|
|
|
|
hitResult.type === 'handle-out') {
|
|
|
|
this.mode = ReshapeModes.HANDLE;
|
|
|
|
this._modeMap[this.mode].onMouseDown(hitProperties);
|
|
|
|
} else {
|
|
|
|
log.warn(`Unhandled hit result type: ${hitResult.type}`);
|
|
|
|
this.mode = ReshapeModes.FILL;
|
|
|
|
this._modeMap[this.mode].onMouseDown(hitProperties);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
handleMouseMove (event) {
|
2018-09-26 11:19:46 -04:00
|
|
|
const hitResult = this.getHitResult(event.point);
|
|
|
|
let hoveredItem;
|
|
|
|
|
|
|
|
if (hitResult) {
|
|
|
|
const item = hitResult.item;
|
|
|
|
if (item.selected) {
|
|
|
|
hoveredItem = null;
|
|
|
|
} else if (isBoundsItem(item)) {
|
|
|
|
hoveredItem = hoverBounds(item);
|
|
|
|
} else {
|
|
|
|
hoveredItem = hoverItem(item);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-09-22 13:56:58 -04:00
|
|
|
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);
|
2017-09-21 10:36:26 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
handleMouseDrag (event) {
|
2017-11-06 11:13:52 -05:00
|
|
|
if (event.event.button > 0 || !this.active) return; // only first mouse button
|
2017-09-21 10:36:26 -04:00
|
|
|
this._modeMap[this.mode].onMouseDrag(event);
|
|
|
|
}
|
|
|
|
handleMouseUp (event) {
|
2017-11-06 11:13:52 -05:00
|
|
|
if (event.event.button > 0 || !this.active) return; // only first mouse button
|
2018-07-06 11:29:06 -04:00
|
|
|
if (this.mode === ReshapeModes.SELECTION_BOX) {
|
|
|
|
this._modeMap[this.mode].onMouseUpVector(event);
|
|
|
|
} else {
|
|
|
|
this._modeMap[this.mode].onMouseUp(event);
|
|
|
|
}
|
2017-09-21 10:36:26 -04:00
|
|
|
this.mode = ReshapeModes.SELECTION_BOX;
|
2017-11-06 11:13:52 -05:00
|
|
|
this.active = false;
|
2017-09-21 10:36:26 -04:00
|
|
|
}
|
2018-01-25 15:56:50 -05:00
|
|
|
handleKeyDown (event) {
|
2018-03-15 13:20:07 -04:00
|
|
|
if (event.event.target instanceof HTMLInputElement) {
|
|
|
|
// Ignore nudge if a text input field is focused
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-01-25 15:56:50 -05:00
|
|
|
const nudgeAmount = 1 / paper.view.zoom;
|
|
|
|
const selected = getSelectedLeafItems();
|
|
|
|
if (selected.length === 0) return;
|
|
|
|
|
|
|
|
let translation;
|
|
|
|
if (event.key === 'up') {
|
|
|
|
translation = new paper.Point(0, -nudgeAmount);
|
|
|
|
} else if (event.key === 'down') {
|
|
|
|
translation = new paper.Point(0, nudgeAmount);
|
|
|
|
} else if (event.key === 'left') {
|
|
|
|
translation = new paper.Point(-nudgeAmount, 0);
|
|
|
|
} else if (event.key === 'right') {
|
|
|
|
translation = new paper.Point(nudgeAmount, 0);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (translation) {
|
|
|
|
const segments = getSelectedSegments();
|
|
|
|
// If no segments are selected, translate selected paths
|
|
|
|
if (segments.length === 0) {
|
|
|
|
for (const item of selected) {
|
|
|
|
item.translate(translation);
|
|
|
|
}
|
|
|
|
} else { // Translate segments
|
|
|
|
for (const seg of segments) {
|
|
|
|
seg.point = seg.point.add(translation);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
handleKeyUp (event) {
|
|
|
|
const selected = getSelectedLeafItems();
|
|
|
|
if (selected.length === 0) return;
|
|
|
|
|
|
|
|
if (event.key === 'up' || event.key === 'down' || event.key === 'left' || event.key === 'right') {
|
2018-04-26 18:45:50 -04:00
|
|
|
this.onUpdateImage();
|
2018-01-25 15:56:50 -05:00
|
|
|
}
|
|
|
|
}
|
2017-09-22 13:56:58 -04:00
|
|
|
deactivateTool () {
|
2017-09-21 18:20:44 -04:00
|
|
|
paper.settings.handleSize = 0;
|
|
|
|
this.clearHoveredItem();
|
2017-09-22 13:56:58 -04:00
|
|
|
this.setHoveredItem = null;
|
|
|
|
this.clearHoveredItem = null;
|
2018-04-26 18:45:50 -04:00
|
|
|
this.onUpdateImage = null;
|
2017-09-22 13:56:58 -04:00
|
|
|
this.lastEvent = null;
|
2017-09-21 18:20:44 -04:00
|
|
|
}
|
2017-09-21 10:36:26 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
export default ReshapeTool;
|