scratch-paint/src/helper/selection-tools/reshape-tool.js

293 lines
12 KiB
JavaScript

import paper from '@scratch/paper';
import log from '../../log/log';
import keyMirror from 'keymirror';
import Modes from '../../lib/modes';
import {getHoveredItem} from '../hover';
import {getRootItem, isPGTextItem} from '../item';
import {getSelectedLeafItems, getSelectedSegments} from '../selection';
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
});
/**
* 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
*/
class ReshapeTool extends paper.Tool {
/** Distance within which mouse is considered to be hitting an item */
static get TOLERANCE () {
return 8;
}
/** Clicks registered within this amount of time are registered as double clicks */
static get DOUBLE_CLICK_MILLIS () {
return 250;
}
/**
* @param {function} setHoveredItem Callback to set the hovered item
* @param {function} clearHoveredItem Callback to clear the hovered item
* @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
* @param {!function} onUpdateImage A callback to call when the image visibly changes
* @param {!function} switchToTextTool A callback to call to switch to the text tool
*/
constructor (setHoveredItem, clearHoveredItem, setSelectedItems, clearSelectedItems, onUpdateImage,
switchToTextTool) {
super();
this.setHoveredItem = setHoveredItem;
this.clearHoveredItem = clearHoveredItem;
this.onUpdateImage = onUpdateImage;
this.prevHoveredItemId = null;
this.lastEvent = null;
this.active = false;
this.mode = ReshapeModes.SELECTION_BOX;
this._modeMap = {};
this._modeMap[ReshapeModes.FILL] =
new MoveTool(Modes.RESHAPE, setSelectedItems, clearSelectedItems, onUpdateImage, switchToTextTool);
this._modeMap[ReshapeModes.POINT] = new PointTool(setSelectedItems, clearSelectedItems, onUpdateImage);
this._modeMap[ReshapeModes.HANDLE] = new HandleTool(setSelectedItems, clearSelectedItems, onUpdateImage);
this._modeMap[ReshapeModes.SELECTION_BOX] =
new SelectionBoxTool(Modes.RESHAPE, setSelectedItems, clearSelectedItems);
// 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;
this.onKeyDown = this.handleKeyDown;
paper.settings.handleSize = 8;
}
/**
* 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) {
const hitOptions = {
segments: true,
stroke: true,
curves: true,
handles: true,
fill: true,
guide: false,
tolerance: ReshapeTool.TOLERANCE / paper.view.zoom
};
if (preselectedOnly) {
hitOptions.match = item => {
if (!item.item || !item.item.selected) return;
if (item.type === 'handle-out' || item.type === 'handle-in') {
// Only hit test against handles that are visible, that is,
// their segment is selected
if (!item.segment.selected) {
return false;
}
// If the entire shape is selected, handles are hidden
if (item.item.fullySelected) {
return false;
}
}
return true;
};
} else {
hitOptions.match = item => {
if (item.type === 'handle-out' || item.type === 'handle-in') {
// Only hit test against handles that are visible, that is,
// their segment is selected
if (!item.segment.selected) {
return false;
}
// If the entire shape is selected, handles are hidden
if (item.item.fullySelected) {
return false;
}
}
return true;
};
}
return hitOptions;
}
/**
* 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;
}
handleMouseDown (event) {
if (event.event.button > 0) return; // only first mouse button
this.active = true;
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;
// Choose hit result to use ===========================================================
// Prefer hits on already selected items
let hitResults =
paper.project.hitTestAll(event.point, this.getHitOptions(true /* preselectedOnly */));
if (hitResults.length === 0) {
hitResults = paper.project.hitTestAll(event.point, this.getHitOptions());
}
if (hitResults.length === 0) {
this._modeMap[ReshapeModes.SELECTION_BOX].onMouseDown(event.modifiers.shift);
return;
}
// Prefer hits on segments to other types of hits, to make sure handles are movable.
let hitResult = hitResults[0];
for (let i = 0; i < hitResults.length; i++) {
if (hitResults[i].type === 'segment') {
hitResult = hitResults[i];
break;
}
}
// Don't allow detail-selection of PGTextItem
if (isPGTextItem(getRootItem(hitResult.item))) {
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);
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);
}
// @todo Trigger selection changed. Update styles based on selection.
}
handleMouseMove (event) {
const hoveredItem = getHoveredItem(event, this.getHitOptions(), true /* subselect */);
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 || !this.active) return; // only first mouse button
this._modeMap[this.mode].onMouseDrag(event);
}
handleMouseUp (event) {
if (event.event.button > 0 || !this.active) return; // only first mouse button
if (this.mode === ReshapeModes.SELECTION_BOX) {
this._modeMap[this.mode].onMouseUpVector(event);
} else {
this._modeMap[this.mode].onMouseUp(event);
}
this.mode = ReshapeModes.SELECTION_BOX;
this.active = false;
}
handleKeyDown (event) {
if (event.event.target instanceof HTMLInputElement) {
// Ignore nudge if a text input field is focused
return;
}
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') {
this.onUpdateImage();
}
}
deactivateTool () {
paper.settings.handleSize = 0;
this.clearHoveredItem();
this.setHoveredItem = null;
this.clearHoveredItem = null;
this.onUpdateImage = null;
this.lastEvent = null;
}
}
export default ReshapeTool;