UNPKG

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
"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;