js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
305 lines (304 loc) • 12.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.EraserMode = void 0;
const types_1 = require("../types");
const BaseTool_1 = __importDefault(require("./BaseTool"));
const math_1 = require("@js-draw/math");
const Erase_1 = __importDefault(require("../commands/Erase"));
const Pointer_1 = require("../Pointer");
const keybindings_1 = require("./keybindings");
const ReactiveValue_1 = require("../util/ReactiveValue");
const EditorImage_1 = __importDefault(require("../image/EditorImage"));
const uniteCommands_1 = __importDefault(require("../commands/uniteCommands"));
const RenderablePathSpec_1 = require("../rendering/RenderablePathSpec");
var EraserMode;
(function (EraserMode) {
EraserMode["PartialStroke"] = "partial-stroke";
EraserMode["FullStroke"] = "full-stroke";
})(EraserMode || (exports.EraserMode = EraserMode = {}));
/** Handles switching from other primary tools to the eraser and back */
class EraserSwitcher extends BaseTool_1.default {
constructor(editor, eraser) {
super(editor.notifier, editor.localization.changeTool);
this.editor = editor;
this.eraser = eraser;
}
onPointerDown(event) {
if (event.allPointers.length === 1 && event.current.device === Pointer_1.PointerDevice.Eraser) {
const toolController = this.editor.toolController;
const enabledPrimaryTools = toolController
.getPrimaryTools()
.filter((tool) => tool.isEnabled());
if (enabledPrimaryTools.length) {
this.previousEnabledTool = enabledPrimaryTools[0];
}
else {
this.previousEnabledTool = null;
}
this.previousEraserEnabledState = this.eraser.isEnabled();
this.eraser.setEnabled(true);
if (this.eraser.onPointerDown(event)) {
return true;
}
else {
this.restoreOriginalTool();
}
}
return false;
}
onPointerMove(event) {
this.eraser.onPointerMove(event);
}
restoreOriginalTool() {
this.eraser.setEnabled(this.previousEraserEnabledState);
if (this.previousEnabledTool) {
this.previousEnabledTool.setEnabled(true);
}
}
onPointerUp(event) {
this.eraser.onPointerUp(event);
this.restoreOriginalTool();
}
onGestureCancel(event) {
this.eraser.onGestureCancel(event);
this.restoreOriginalTool();
}
}
/**
* A tool that allows a user to erase parts of an image.
*/
class Eraser extends BaseTool_1.default {
constructor(editor, description, options) {
super(editor.notifier, description);
this.editor = editor;
this.lastPoint = null;
this.isFirstEraseEvt = true;
this.toAdd = new Set();
// Commands that each remove one element
this.eraseCommands = [];
this.addCommands = [];
this.thickness = options?.thickness ?? 10;
this.thicknessValue = ReactiveValue_1.ReactiveValue.fromInitialValue(this.thickness);
this.thicknessValue.onUpdate((value) => {
this.thickness = value;
this.editor.notifier.dispatch(types_1.EditorEventType.ToolUpdated, {
kind: types_1.EditorEventType.ToolUpdated,
tool: this,
});
});
this.modeValue = ReactiveValue_1.ReactiveValue.fromInitialValue(options?.mode ?? EraserMode.FullStroke);
this.modeValue.onUpdate((_value) => {
this.editor.notifier.dispatch(types_1.EditorEventType.ToolUpdated, {
kind: types_1.EditorEventType.ToolUpdated,
tool: this,
});
});
}
/**
* @returns a tool that briefly enables the eraser when a physical eraser is used.
* This tool should be added to the tool list after the primary tools.
*/
makeEraserSwitcherTool() {
return new EraserSwitcher(this.editor, this);
}
clearPreview() {
this.editor.clearWetInk();
}
getSizeOnCanvas() {
return this.thickness / this.editor.viewport.getScaleFactor();
}
drawPreviewAt(point) {
this.clearPreview();
const size = this.getSizeOnCanvas();
const renderer = this.editor.display.getWetInkRenderer();
const rect = this.getEraserRect(point);
const rect2 = this.getEraserRect(this.lastPoint ?? point);
const fill = {
fill: math_1.Color4.transparent,
stroke: { width: size / 10, color: math_1.Color4.gray },
};
renderer.drawPath((0, RenderablePathSpec_1.pathToRenderable)(math_1.Path.fromConvexHullOf([...rect.corners, ...rect2.corners]), fill));
}
/**
* @returns the eraser rectangle in canvas coordinates.
*
* For now, all erasers are rectangles or points.
*/
getEraserRect(centerPoint) {
const size = this.getSizeOnCanvas();
const halfSize = math_1.Vec2.of(size / 2, size / 2);
return math_1.Rect2.fromCorners(centerPoint.minus(halfSize), centerPoint.plus(halfSize));
}
/** Erases in a line from the last point to the current. */
eraseTo(currentPoint) {
if (!this.isFirstEraseEvt && currentPoint.distanceTo(this.lastPoint) === 0) {
return;
}
this.isFirstEraseEvt = false;
// Currently only objects within eraserRect or that intersect a straight line
// from the center of the current rect to the previous are erased. TODO: Erase
// all objects as if there were pointerMove events between the two points.
const eraserRect = this.getEraserRect(currentPoint);
const line = new math_1.LineSegment2(this.lastPoint, currentPoint);
const region = math_1.Rect2.union(line.bbox, eraserRect);
const intersectingElems = this.editor.image
.getComponentsIntersecting(region)
.filter((component) => {
return component.intersects(line) || component.intersectsRect(eraserRect);
});
// Only erase components that could be selected (and thus interacted with)
// by the user.
const eraseableElems = intersectingElems.filter((elem) => elem.isSelectable());
if (this.modeValue.get() === EraserMode.FullStroke) {
// Remove any intersecting elements.
this.toRemove.push(...eraseableElems);
// Create new Erase commands for the now-to-be-erased elements and apply them.
const newPartialCommands = eraseableElems.map((elem) => new Erase_1.default([elem]));
newPartialCommands.forEach((cmd) => cmd.apply(this.editor));
this.eraseCommands.push(...newPartialCommands);
}
else {
const toErase = [];
const toAdd = [];
for (const targetElem of eraseableElems) {
toErase.push(targetElem);
// Completely delete items that can't be divided.
if (!targetElem.withRegionErased) {
continue;
}
// Completely delete items that are completely or almost completely
// contained within the eraser.
const grownRect = eraserRect.grownBy(eraserRect.maxDimension / 3);
if (grownRect.containsRect(targetElem.getExactBBox())) {
continue;
}
// Join the current and previous rectangles so that points between events are also
// erased.
const erasePath = math_1.Path.fromConvexHullOf([
...eraserRect.corners,
...this.getEraserRect(this.lastPoint ?? currentPoint).corners,
].map((p) => this.editor.viewport.roundPoint(p)));
toAdd.push(...targetElem.withRegionErased(erasePath, this.editor.viewport));
}
const eraseCommand = new Erase_1.default(toErase);
const newAddCommands = toAdd.map((elem) => EditorImage_1.default.addComponent(elem));
eraseCommand.apply(this.editor);
newAddCommands.forEach((command) => command.apply(this.editor));
const finalToErase = [];
for (const item of toErase) {
if (this.toAdd.has(item)) {
this.toAdd.delete(item);
}
else {
finalToErase.push(item);
}
}
this.toRemove.push(...finalToErase);
for (const item of toAdd) {
this.toAdd.add(item);
}
this.eraseCommands.push(new Erase_1.default(finalToErase));
this.addCommands.push(...newAddCommands);
}
this.drawPreviewAt(currentPoint);
this.lastPoint = currentPoint;
}
onPointerDown(event) {
if (event.allPointers.length === 1 || event.current.device === Pointer_1.PointerDevice.Eraser) {
this.lastPoint = event.current.canvasPos;
this.toRemove = [];
this.toAdd.clear();
this.isFirstEraseEvt = true;
this.drawPreviewAt(event.current.canvasPos);
return true;
}
return false;
}
onPointerMove(event) {
const currentPoint = event.current.canvasPos;
this.eraseTo(currentPoint);
}
onPointerUp(event) {
this.eraseTo(event.current.canvasPos);
const commands = [];
if (this.addCommands.length > 0) {
this.addCommands.forEach((cmd) => cmd.unapply(this.editor));
// Remove items from toAdd that are also present in toRemove -- adding, then
// removing these does nothing, and can break undo/redo.
for (const item of this.toAdd) {
if (this.toRemove.includes(item)) {
this.toAdd.delete(item);
this.toRemove = this.toRemove.filter((other) => other !== item);
}
}
for (const item of this.toRemove) {
if (this.toAdd.has(item)) {
this.toAdd.delete(item);
this.toRemove = this.toRemove.filter((other) => other !== item);
}
}
commands.push(...[...this.toAdd].map((a) => EditorImage_1.default.addComponent(a)));
this.addCommands = [];
}
if (this.eraseCommands.length > 0) {
// Undo commands for each individual component and unite into a single command.
this.eraseCommands.forEach((cmd) => cmd.unapply(this.editor));
this.eraseCommands = [];
const command = new Erase_1.default(this.toRemove);
commands.push(command);
}
if (commands.length === 1) {
this.editor.dispatch(commands[0]); // dispatch: Makes undo-able.
}
else {
this.editor.dispatch((0, uniteCommands_1.default)(commands));
}
this.clearPreview();
}
onGestureCancel(_event) {
this.addCommands.forEach((cmd) => cmd.unapply(this.editor));
this.eraseCommands.forEach((cmd) => cmd.unapply(this.editor));
this.eraseCommands = [];
this.addCommands = [];
this.clearPreview();
}
onKeyPress(event) {
const shortcuts = this.editor.shortcuts;
let newThickness;
if (shortcuts.matchesShortcut(keybindings_1.decreaseSizeKeyboardShortcutId, event)) {
newThickness = (this.getThickness() * 2) / 3;
}
else if (shortcuts.matchesShortcut(keybindings_1.increaseSizeKeyboardShortcutId, event)) {
newThickness = (this.getThickness() * 3) / 2;
}
if (newThickness !== undefined) {
newThickness = Math.min(Math.max(1, newThickness), 200);
this.setThickness(newThickness);
return true;
}
return false;
}
/** Returns the side-length of the tip of this eraser. */
getThickness() {
return this.thickness;
}
/** Sets the side-length of this' tip. */
setThickness(thickness) {
this.thicknessValue.set(thickness);
}
/**
* Returns a {@link MutableReactiveValue} that can be used to watch
* this tool's thickness.
*/
getThicknessValue() {
return this.thicknessValue;
}
/** @returns An object that allows switching between a full stroke and a partial stroke eraser. */
getModeValue() {
return this.modeValue;
}
}
exports.default = Eraser;