UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

298 lines (297 loc) 12.2 kB
import { EditorEventType } from '../types.mjs'; import BaseTool from './BaseTool.mjs'; import { Vec2, LineSegment2, Color4, Rect2, Path } from '@js-draw/math'; import Erase from '../commands/Erase.mjs'; import { PointerDevice } from '../Pointer.mjs'; import { decreaseSizeKeyboardShortcutId, increaseSizeKeyboardShortcutId } from './keybindings.mjs'; import { ReactiveValue } from '../util/ReactiveValue.mjs'; import EditorImage from '../image/EditorImage.mjs'; import uniteCommands from '../commands/uniteCommands.mjs'; import { pathToRenderable } from '../rendering/RenderablePathSpec.mjs'; export var EraserMode; (function (EraserMode) { EraserMode["PartialStroke"] = "partial-stroke"; EraserMode["FullStroke"] = "full-stroke"; })(EraserMode || (EraserMode = {})); /** Handles switching from other primary tools to the eraser and back */ class EraserSwitcher extends BaseTool { 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 === 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. */ export default class Eraser extends BaseTool { 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.fromInitialValue(this.thickness); this.thicknessValue.onUpdate((value) => { this.thickness = value; this.editor.notifier.dispatch(EditorEventType.ToolUpdated, { kind: EditorEventType.ToolUpdated, tool: this, }); }); this.modeValue = ReactiveValue.fromInitialValue(options?.mode ?? EraserMode.FullStroke); this.modeValue.onUpdate((_value) => { this.editor.notifier.dispatch(EditorEventType.ToolUpdated, { kind: 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: Color4.transparent, stroke: { width: size / 10, color: Color4.gray }, }; renderer.drawPath(pathToRenderable(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 = Vec2.of(size / 2, size / 2); return 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 LineSegment2(this.lastPoint, currentPoint); const region = 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([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 = 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(toErase); const newAddCommands = toAdd.map((elem) => EditorImage.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(finalToErase)); this.addCommands.push(...newAddCommands); } this.drawPreviewAt(currentPoint); this.lastPoint = currentPoint; } onPointerDown(event) { if (event.allPointers.length === 1 || event.current.device === 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.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(this.toRemove); commands.push(command); } if (commands.length === 1) { this.editor.dispatch(commands[0]); // dispatch: Makes undo-able. } else { this.editor.dispatch(uniteCommands(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(decreaseSizeKeyboardShortcutId, event)) { newThickness = (this.getThickness() * 2) / 3; } else if (shortcuts.matchesShortcut(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; } }