mirror of
https://github.com/scratchfoundation/scratch-paint.git
synced 2024-12-24 06:22:23 -05:00
405 lines
17 KiB
JavaScript
405 lines
17 KiB
JavaScript
import paper from '@scratch/paper';
|
|
import log from '../../log/log';
|
|
import BroadBrushHelper from './broad-brush-helper';
|
|
import SegmentBrushHelper from './segment-brush-helper';
|
|
import {MIXED, styleCursorPreview} from '../../helper/style-path';
|
|
import {clearSelection} from '../../helper/selection';
|
|
import {getGuideLayer} from '../../helper/layer';
|
|
|
|
/**
|
|
* Shared code for the brush and eraser mode. Adds functions on the paper tool object
|
|
* to handle mouse events, which are delegated to broad-brush-helper and segment-brush-helper
|
|
* based on the brushSize in the state.
|
|
*/
|
|
class Blobbiness {
|
|
static get BROAD () {
|
|
return 'broadbrush';
|
|
}
|
|
static get SEGMENT () {
|
|
return 'segmentbrush';
|
|
}
|
|
|
|
// If brush size >= threshold use segment brush, else use broadbrush
|
|
// Segment brush has performance issues at low threshold, but broad brush has weird corners
|
|
// which get more obvious the bigger it is
|
|
static get THRESHOLD () {
|
|
return 9;
|
|
}
|
|
|
|
/**
|
|
* @param {function} onUpdateSvg call when the drawing has changed to let listeners know
|
|
* @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state
|
|
*/
|
|
constructor (onUpdateSvg, clearSelectedItems) {
|
|
this.broadBrushHelper = new BroadBrushHelper();
|
|
this.segmentBrushHelper = new SegmentBrushHelper();
|
|
this.onUpdateSvg = onUpdateSvg;
|
|
this.clearSelectedItems = clearSelectedItems;
|
|
|
|
// The following are stored to check whether these have changed and the cursor preview needs to be redrawn.
|
|
this.strokeColor = null;
|
|
this.brushSize = null;
|
|
this.fillColor = null;
|
|
}
|
|
|
|
/**
|
|
* Set configuration options for a blob
|
|
* @param {!object} options Configuration
|
|
* @param {!number} options.brushSize Width of blob marking made by mouse
|
|
* @param {!boolean} options.isEraser Whether the stroke should be treated as an erase path. If false,
|
|
* the stroke is an additive path.
|
|
* @param {?string} options.fillColor Color of the brush stroke.
|
|
* @param {?string} options.strokeColor Color of the brush outline.
|
|
* @param {?number} options.strokeWidth Width of the brush outline.
|
|
*/
|
|
setOptions (options) {
|
|
const oldFillColor = this.options ? this.options.fillColor : 'black';
|
|
const oldStrokeColor = this.options ? this.options.strokeColor : null;
|
|
const oldStrokeWidth = this.options ? this.options.strokeWidth : null;
|
|
// If values are mixed, it means the color was set by a selection contained multiple values.
|
|
// In this case keep drawing with the previous values if any. (For stroke width, null indicates
|
|
// mixed, because stroke width is required to be a number)
|
|
this.options = {
|
|
...options,
|
|
fillColor: options.fillColor === MIXED ? oldFillColor : options.fillColor,
|
|
strokeColor: options.strokeColor === MIXED ? oldStrokeColor : options.strokeColor,
|
|
strokeWidth: options.strokeWidth === null ? oldStrokeWidth : options.strokeWidth
|
|
};
|
|
this.resizeCursorIfNeeded();
|
|
}
|
|
|
|
/**
|
|
* Adds handlers on the mouse tool to draw blobs. Initialize with configuration options for a blob.
|
|
* @param {!object} options Configuration
|
|
* @param {!number} options.brushSize Width of blob marking made by mouse
|
|
* @param {!boolean} options.isEraser Whether the stroke should be treated as an erase path. If false,
|
|
* the stroke is an additive path.
|
|
* @param {?string} options.fillColor Color of the brush stroke.
|
|
* @param {?string} options.strokeColor Color of the brush outline.
|
|
* @param {?number} options.strokeWidth Width of the brush outline.
|
|
*/
|
|
activateTool (options) {
|
|
this.tool = new paper.Tool();
|
|
this.cursorPreviewLastPoint = new paper.Point(-10000, -10000);
|
|
this.setOptions(options);
|
|
this.tool.fixedDistance = 1;
|
|
|
|
const blob = this;
|
|
this.tool.onMouseMove = function (event) {
|
|
blob.resizeCursorIfNeeded(event.point);
|
|
styleCursorPreview(blob.cursorPreview, blob.options);
|
|
blob.cursorPreview.bringToFront();
|
|
blob.cursorPreview.position = event.point;
|
|
};
|
|
|
|
this.tool.onMouseDown = function (event) {
|
|
blob.resizeCursorIfNeeded(event.point);
|
|
if (event.event.button > 0) return; // only first mouse button
|
|
|
|
if (blob.options.brushSize < Blobbiness.THRESHOLD) {
|
|
blob.brush = Blobbiness.BROAD;
|
|
blob.broadBrushHelper.onBroadMouseDown(event, blob.tool, blob.options);
|
|
} else {
|
|
blob.brush = Blobbiness.SEGMENT;
|
|
blob.segmentBrushHelper.onSegmentMouseDown(event, blob.tool, blob.options);
|
|
}
|
|
blob.cursorPreview.bringToFront();
|
|
blob.cursorPreview.position = event.point;
|
|
paper.view.draw();
|
|
};
|
|
|
|
this.tool.onMouseDrag = function (event) {
|
|
blob.resizeCursorIfNeeded(event.point);
|
|
if (event.event.button > 0) return; // only first mouse button
|
|
if (blob.brush === Blobbiness.BROAD) {
|
|
blob.broadBrushHelper.onBroadMouseDrag(event, blob.tool, blob.options);
|
|
} else if (blob.brush === Blobbiness.SEGMENT) {
|
|
blob.segmentBrushHelper.onSegmentMouseDrag(event, blob.tool, blob.options);
|
|
} else {
|
|
log.warn(`Brush type does not exist: ${blob.brush}`);
|
|
}
|
|
|
|
blob.cursorPreview.bringToFront();
|
|
blob.cursorPreview.position = event.point;
|
|
paper.view.draw();
|
|
};
|
|
|
|
this.tool.onMouseUp = function (event) {
|
|
blob.resizeCursorIfNeeded(event.point);
|
|
if (event.event.button > 0) return; // only first mouse button
|
|
|
|
let lastPath;
|
|
if (blob.brush === Blobbiness.BROAD) {
|
|
lastPath = blob.broadBrushHelper.onBroadMouseUp(event, blob.tool, blob.options);
|
|
} else if (blob.brush === Blobbiness.SEGMENT) {
|
|
lastPath = blob.segmentBrushHelper.onSegmentMouseUp(event, blob.tool, blob.options);
|
|
} else {
|
|
log.warn(`Brush type does not exist: ${blob.brush}`);
|
|
}
|
|
|
|
if (blob.options.isEraser) {
|
|
blob.mergeEraser(lastPath);
|
|
} else {
|
|
blob.mergeBrush(lastPath);
|
|
}
|
|
|
|
blob.cursorPreview.visible = false;
|
|
blob.onUpdateSvg();
|
|
blob.cursorPreview.visible = true;
|
|
blob.cursorPreview.bringToFront();
|
|
blob.cursorPreview.position = event.point;
|
|
|
|
// Reset
|
|
blob.brush = null;
|
|
this.fixedDistance = 1;
|
|
};
|
|
this.tool.activate();
|
|
}
|
|
|
|
resizeCursorIfNeeded (point) {
|
|
if (!this.options) {
|
|
return;
|
|
}
|
|
|
|
if (typeof point === 'undefined') {
|
|
point = this.cursorPreviewLastPoint;
|
|
} else {
|
|
this.cursorPreviewLastPoint = point;
|
|
}
|
|
|
|
if (this.cursorPreview && this.cursorPreview.parent &&
|
|
this.brushSize === this.options.brushSize &&
|
|
this.fillColor === this.options.fillColor &&
|
|
this.strokeColor === this.options.strokeColor) {
|
|
return;
|
|
}
|
|
const newPreview = new paper.Path.Circle({
|
|
center: point,
|
|
radius: this.options.brushSize / 2
|
|
});
|
|
newPreview.parent = getGuideLayer();
|
|
newPreview.data.isHelperItem = true;
|
|
if (this.cursorPreview) {
|
|
this.cursorPreview.remove();
|
|
}
|
|
this.brushSize = this.options.brushSize;
|
|
this.fillColor = this.options.fillColor;
|
|
this.strokeColor = this.options.strokeColor;
|
|
this.cursorPreview = newPreview;
|
|
styleCursorPreview(this.cursorPreview, this.options);
|
|
}
|
|
|
|
mergeBrush (lastPath) {
|
|
const blob = this;
|
|
|
|
// Get all path items to merge with
|
|
const paths = paper.project.getItems({
|
|
match: function (item) {
|
|
return blob.isMergeable(lastPath, item) &&
|
|
item.parent instanceof paper.Layer; // don't merge with nested in group
|
|
}
|
|
});
|
|
|
|
let mergedPath = lastPath;
|
|
let i;
|
|
// Move down z order to first overlapping item
|
|
for (i = paths.length - 1; i >= 0 && !this.touches(paths[i], lastPath); i--) {
|
|
continue;
|
|
}
|
|
let mergedPathIndex = i;
|
|
for (; i >= 0; i--) {
|
|
if (!this.touches(paths[i], lastPath)) {
|
|
continue;
|
|
}
|
|
if (!paths[i].getFillColor()) {
|
|
// Ignore for merge. Paths without fill need to be in paths though,
|
|
// since they can visibly change if z order changes
|
|
} else if (this.colorMatch(paths[i], lastPath)) {
|
|
// Make sure the new shape isn't overlapped by anything that would
|
|
// visibly change if we change its z order
|
|
for (let j = mergedPathIndex; j > i; j--) {
|
|
if (this.touches(paths[j], paths[i])) {
|
|
continue;
|
|
}
|
|
}
|
|
// Merge same fill color
|
|
const tempPath = mergedPath.unite(paths[i]);
|
|
tempPath.strokeColor = paths[i].strokeColor;
|
|
tempPath.strokeWidth = paths[i].strokeWidth;
|
|
if (mergedPath === lastPath) {
|
|
tempPath.insertAbove(paths[i]); // First intersected path determines z position of the new path
|
|
} else {
|
|
tempPath.insertAbove(mergedPath); // Rest of merges join z index of merged path
|
|
mergedPathIndex--; // Removed an item, so the merged path index decreases
|
|
}
|
|
mergedPath.remove();
|
|
mergedPath = tempPath;
|
|
paths[i].remove();
|
|
paths.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
mergeEraser (lastPath) {
|
|
const blob = this;
|
|
|
|
// Get all path items to merge with
|
|
// If there are selected items, try to erase from amongst those.
|
|
let items = paper.project.getItems({
|
|
match: function (item) {
|
|
return item.selected && blob.isMergeable(lastPath, item) && blob.touches(lastPath, item);
|
|
}
|
|
});
|
|
// 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) {
|
|
clearSelection(this.clearSelectedItems);
|
|
items = paper.project.getItems({
|
|
match: function (item) {
|
|
return blob.isMergeable(lastPath, item) && blob.touches(lastPath, item);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
// TODO handle compound paths
|
|
if (items[i] instanceof paper.Path && (!items[i].fillColor || items[i].fillColor._alpha === 0)) {
|
|
// Gather path segments
|
|
const subpaths = [];
|
|
const firstSeg = items[i];
|
|
const intersections = firstSeg.getIntersections(lastPath);
|
|
for (let j = intersections.length - 1; j >= 0; j--) {
|
|
const split = firstSeg.splitAt(intersections[j]);
|
|
if (split) {
|
|
split.insertAbove(firstSeg);
|
|
subpaths.push(split);
|
|
}
|
|
}
|
|
subpaths.push(firstSeg);
|
|
|
|
// Remove the ones that are within the eraser stroke boundary
|
|
for (let k = subpaths.length - 1; k >= 0; k--) {
|
|
const segMidpoint = subpaths[k].getLocationAt(subpaths[k].length / 2).point;
|
|
if (lastPath.contains(segMidpoint)) {
|
|
subpaths[k].remove();
|
|
subpaths.splice(k, 1);
|
|
}
|
|
}
|
|
lastPath.remove();
|
|
continue;
|
|
}
|
|
// Erase
|
|
const newPath = items[i].subtract(lastPath);
|
|
newPath.insertBelow(items[i]);
|
|
|
|
// Gather path segments
|
|
const subpaths = [];
|
|
// TODO: Handle compound path
|
|
if (items[i] instanceof paper.Path && !items[i].closed) {
|
|
const firstSeg = items[i].clone();
|
|
const intersections = firstSeg.getIntersections(lastPath);
|
|
// keep first and last segments
|
|
for (let j = intersections.length - 1; j >= 0; j--) {
|
|
const split = firstSeg.splitAt(intersections[j]);
|
|
split.insertAbove(firstSeg);
|
|
subpaths.push(split);
|
|
}
|
|
subpaths.push(firstSeg);
|
|
}
|
|
|
|
// Remove the ones that are within the eraser stroke boundary, or are already part of new path.
|
|
// This way subpaths only remain if they didn't get turned into a shape by subtract.
|
|
for (let k = subpaths.length - 1; k >= 0; k--) {
|
|
const segMidpoint = subpaths[k].getLocationAt(subpaths[k].length / 2).point;
|
|
if (lastPath.contains(segMidpoint) || newPath.contains(segMidpoint)) {
|
|
subpaths[k].remove();
|
|
subpaths.splice(k, 1);
|
|
}
|
|
}
|
|
|
|
// Divide topologically separate shapes into their own compound paths, instead of
|
|
// everything being stuck together.
|
|
// Assume that result of erase operation returns clockwise paths for positive shapes
|
|
const clockwiseChildren = [];
|
|
const ccwChildren = [];
|
|
if (newPath.children) {
|
|
for (let j = newPath.children.length - 1; j >= 0; j--) {
|
|
const child = newPath.children[j];
|
|
if (child.isClockwise()) {
|
|
clockwiseChildren.push(child);
|
|
} else {
|
|
ccwChildren.push(child);
|
|
}
|
|
}
|
|
for (let j = 0; j < clockwiseChildren.length; j++) {
|
|
const cw = clockwiseChildren[j];
|
|
cw.copyAttributes(newPath);
|
|
cw.fillColor = newPath.fillColor;
|
|
cw.strokeColor = newPath.strokeColor;
|
|
cw.strokeWidth = newPath.strokeWidth;
|
|
cw.insertAbove(items[i]);
|
|
|
|
// Go backward since we are deleting elements
|
|
let newCw = cw;
|
|
for (let k = ccwChildren.length - 1; k >= 0; k--) {
|
|
const ccw = ccwChildren[k];
|
|
if (this.firstEnclosesSecond(ccw, cw) || this.firstEnclosesSecond(cw, ccw)) {
|
|
const temp = newCw.subtract(ccw);
|
|
temp.insertAbove(newCw);
|
|
newCw.remove();
|
|
newCw = temp;
|
|
ccw.remove();
|
|
ccwChildren.splice(k, 1);
|
|
}
|
|
}
|
|
}
|
|
newPath.remove();
|
|
}
|
|
items[i].remove();
|
|
}
|
|
lastPath.remove();
|
|
}
|
|
|
|
colorMatch (existingPath, addedPath) {
|
|
// Note: transparent fill colors do notdetect as touching
|
|
return existingPath.getFillColor().equals(addedPath.getFillColor()) &&
|
|
(addedPath.getStrokeColor() === existingPath.getStrokeColor() || // both null
|
|
(addedPath.getStrokeColor() &&
|
|
addedPath.getStrokeColor().equals(existingPath.getStrokeColor()))) &&
|
|
addedPath.getStrokeWidth() === existingPath.getStrokeWidth() &&
|
|
this.touches(existingPath, addedPath);
|
|
}
|
|
|
|
touches (path1, path2) {
|
|
// Two shapes are touching if their paths intersect
|
|
if (path1 && path2 && path1.intersects(path2)) {
|
|
return true;
|
|
}
|
|
return this.firstEnclosesSecond(path1, path2) || this.firstEnclosesSecond(path2, path1);
|
|
}
|
|
|
|
firstEnclosesSecond (path1, path2) {
|
|
// Two shapes are also touching if one is completely inside the other
|
|
if (path1 && path2 && path2.firstSegment && path2.firstSegment.point &&
|
|
path1.hitTest(path2.firstSegment.point)) {
|
|
return true;
|
|
}
|
|
// TODO: clean up these no point paths
|
|
return false;
|
|
}
|
|
|
|
isMergeable (newPath, existingPath) {
|
|
return existingPath instanceof paper.PathItem && // path or compound path
|
|
existingPath !== this.cursorPreview && // don't merge with the mouse preview
|
|
existingPath !== newPath; // don't merge with self
|
|
}
|
|
|
|
deactivateTool () {
|
|
this.cursorPreview.remove();
|
|
this.cursorPreview = null;
|
|
this.tool.remove();
|
|
this.tool = null;
|
|
}
|
|
}
|
|
|
|
export default Blobbiness;
|