UNPKG

js-draw

Version:

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

342 lines (341 loc) 13.3 kB
import { Color4 } from '@js-draw/math'; import EditorImage from '../image/EditorImage.mjs'; import { PointerDevice } from '../Pointer.mjs'; import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuilder.mjs'; import { EditorEventType } from '../types.mjs'; import BaseTool from './BaseTool.mjs'; import { undoKeyboardShortcutId } from './keybindings.mjs'; import { decreaseSizeKeyboardShortcutId, increaseSizeKeyboardShortcutId } from './keybindings.mjs'; import InputStabilizer from './InputFilter/InputStabilizer.mjs'; import { ReactiveValue } from '../util/ReactiveValue.mjs'; import StationaryPenDetector, { defaultStationaryDetectionConfig, } from './util/StationaryPenDetector.mjs'; /** * A tool that allows drawing shapes and freehand lines. * * To change the type of shape drawn by the pen (e.g. to switch to the rectangle * pen type), see {@link setStrokeFactory}. */ export default class Pen extends BaseTool { constructor(editor, description, style) { super(editor.notifier, description); this.editor = editor; this.builder = null; this.lastPoint = null; this.startPoint = null; this.currentDeviceType = null; this.currentPointerId = null; this.shapeAutocompletionEnabled = false; this.pressureSensitivityEnabled = true; this.autocorrectedShape = null; this.lastAutocorrectedShape = null; this.removedAutocorrectedShapeTime = 0; this.stationaryDetector = null; this.styleValue = ReactiveValue.fromInitialValue({ factory: makeFreehandLineBuilder, color: Color4.blue, thickness: 4, ...style, }); this.styleValue.onUpdateAndNow((newValue) => { this.style = newValue; this.noteUpdated(); }); } getPressureMultiplier() { const thickness = this.style.thickness; return (1 / this.editor.viewport.getScaleFactor()) * thickness; } // Converts a `pointer` to a `StrokeDataPoint`. toStrokePoint(pointer) { const minPressure = 0.3; const defaultPressure = 0.5; // https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pressure#value let pressure = Math.max(pointer.pressure ?? 1.0, minPressure); if (!isFinite(pressure)) { console.warn('Non-finite pressure!', pointer); pressure = minPressure; } console.assert(isFinite(pointer.canvasPos.length()), 'Non-finite canvas position!'); console.assert(isFinite(pointer.screenPos.length()), 'Non-finite screen position!'); console.assert(isFinite(pointer.timeStamp), 'Non-finite timeStamp on pointer!'); const pos = pointer.canvasPos; if (!this.getPressureSensitivityEnabled()) { pressure = defaultPressure; } return { pos, width: pressure * this.getPressureMultiplier(), color: this.style.color, time: pointer.timeStamp, }; } // Displays the stroke that is currently being built with the display's `wetInkRenderer`. previewStroke() { this.editor.clearWetInk(); const wetInkRenderer = this.editor.display.getWetInkRenderer(); if (this.autocorrectedShape) { const visibleRect = this.editor.viewport.visibleRect; this.autocorrectedShape.render(wetInkRenderer, visibleRect); } else { this.builder?.preview(wetInkRenderer); } } // Throws if no stroke builder exists. addPointToStroke(point) { if (!this.builder) { throw new Error('No stroke is currently being generated.'); } this.builder.addPoint(point); this.lastPoint = point; this.previewStroke(); } onPointerDown(event) { // Avoid canceling an existing stroke if (this.builder && !this.eventCanCancelStroke(event)) { return true; } const { current, allPointers } = event; const isEraser = current.device === PointerDevice.Eraser; const isPen = current.device === PointerDevice.Pen; // Always start strokes if the current device is a pen. This is useful in the case // where an accidental touch gesture from a user's hand is ongoing. This gesture // should not prevent the user from drawing. if ((allPointers.length === 1 && !isEraser) || isPen) { this.startPoint = this.toStrokePoint(current); this.builder = this.style.factory(this.startPoint, this.editor.viewport); this.currentDeviceType = current.device; this.currentPointerId = current.id; if (this.shapeAutocompletionEnabled) { this.stationaryDetector = new StationaryPenDetector(current, defaultStationaryDetectionConfig, (pointer) => this.autocorrectShape(pointer)); } else { this.stationaryDetector = null; } this.lastAutocorrectedShape = null; this.removedAutocorrectedShapeTime = 0; return true; } return false; } eventCanCancelStroke(event) { // If there has been a delay since the last input event, // it's always okay to cancel const lastInputTime = this.lastPoint?.time ?? 0; if (event.current.timeStamp - lastInputTime > 1000) { return true; } const isPenStroke = this.currentDeviceType === PointerDevice.Pen; const isTouchEvent = event.current.device === PointerDevice.Touch; // Don't allow pen strokes to be cancelled by touch events. if (isPenStroke && isTouchEvent) { return false; } return true; } eventCanBeDeliveredToNonActiveTool(event) { return this.eventCanCancelStroke(event); } onPointerMove({ current }) { if (!this.builder) return; if (current.device !== this.currentDeviceType) return; if (current.id !== this.currentPointerId) return; const isStationary = this.stationaryDetector?.onPointerMove(current); if (!isStationary) { this.addPointToStroke(this.toStrokePoint(current)); if (this.autocorrectedShape) { this.removedAutocorrectedShapeTime = performance.now(); this.autocorrectedShape = null; this.editor.announceForAccessibility(this.editor.localization.autocorrectionCanceled); } } } onPointerUp({ current }) { if (!this.builder) return false; if (current.id !== this.currentPointerId) { // this.builder still exists, so we're handling events from another // device type. return true; } this.stationaryDetector?.onPointerUp(current); // onPointerUp events can have zero pressure. Use the last pressure instead. const currentPoint = this.toStrokePoint(current); const strokePoint = { ...currentPoint, width: this.lastPoint?.width ?? currentPoint.width, }; this.addPointToStroke(strokePoint); this.finalizeStroke(); return false; } onGestureCancel() { this.builder = null; this.editor.clearWetInk(); this.stationaryDetector?.destroy(); this.stationaryDetector = null; } removedAutocorrectedShapeRecently() { return this.removedAutocorrectedShapeTime > performance.now() - 320; } async autocorrectShape(_lastPointer) { if (!this.builder || !this.builder.autocorrectShape) return; if (!this.shapeAutocompletionEnabled) return; // If already corrected, do nothing if (this.autocorrectedShape) return; // Activate stroke fitting const correctedShape = await this.builder.autocorrectShape(); if (!this.builder || !correctedShape) { return; } // Don't complete to empty shapes. const bboxArea = correctedShape.getBBox().area; if (bboxArea === 0 || !isFinite(bboxArea)) { return; } const shapeDescription = correctedShape.description(this.editor.localization); this.editor.announceForAccessibility(this.editor.localization.autocorrectedTo(shapeDescription)); this.autocorrectedShape = correctedShape; this.lastAutocorrectedShape = correctedShape; this.previewStroke(); } finalizeStroke() { if (this.builder) { // If autocorrectedShape was cleared recently enough, it was // probably by mistake. Reset it. if (this.lastAutocorrectedShape && this.removedAutocorrectedShapeRecently()) { this.autocorrectedShape = this.lastAutocorrectedShape; } const stroke = this.autocorrectedShape ?? this.builder.build(); this.previewStroke(); if (stroke.getBBox().area > 0) { if (stroke === this.autocorrectedShape) { this.editor.announceForAccessibility(this.editor.localization.autocorrectedTo(stroke.description(this.editor.localization))); } const canFlatten = true; const action = EditorImage.addComponent(stroke, canFlatten); this.editor.dispatch(action); } else { console.warn('Pen: Not adding empty stroke', stroke, 'to the canvas.'); } } this.builder = null; this.lastPoint = null; this.autocorrectedShape = null; this.lastAutocorrectedShape = null; this.editor.clearWetInk(); this.stationaryDetector?.destroy(); this.stationaryDetector = null; } noteUpdated() { this.editor.notifier.dispatch(EditorEventType.ToolUpdated, { kind: EditorEventType.ToolUpdated, tool: this, }); } setColor(color) { if (color.toHexString() !== this.style.color.toHexString()) { this.styleValue.set({ ...this.style, color, }); } } setThickness(thickness) { if (thickness !== this.style.thickness) { this.styleValue.set({ ...this.style, thickness, }); } } /** * Changes the type of stroke created by the pen. The given `factory` can be one of the built-in * stroke factories (e.g. {@link makeFreehandLineBuilder}) or a custom stroke factory. * * Example: * [[include:doc-pages/inline-examples/changing-pen-types.md]] */ setStrokeFactory(factory) { if (factory !== this.style.factory) { this.styleValue.set({ ...this.style, factory, }); } } setHasStabilization(hasStabilization) { const hasInputMapper = !!this.getInputMapper(); // TODO: Currently, this assumes that there is no other input mapper. if (hasStabilization === hasInputMapper) { return; } if (hasInputMapper) { this.setInputMapper(null); } else { this.setInputMapper(new InputStabilizer(this.editor.viewport)); } this.noteUpdated(); } setStrokeAutocorrectEnabled(enabled) { if (enabled !== this.shapeAutocompletionEnabled) { this.shapeAutocompletionEnabled = enabled; this.noteUpdated(); } } getStrokeAutocorrectionEnabled() { return this.shapeAutocompletionEnabled; } setPressureSensitivityEnabled(enabled) { if (enabled !== this.pressureSensitivityEnabled) { this.pressureSensitivityEnabled = enabled; this.noteUpdated(); } } getPressureSensitivityEnabled() { return this.pressureSensitivityEnabled; } getThickness() { return this.style.thickness; } getColor() { return this.style.color; } getStrokeFactory() { return this.style.factory; } getStyleValue() { return this.styleValue; } onKeyPress(event) { const shortcuts = this.editor.shortcuts; // Ctrl+Z: End the stroke so that it can be undone/redone. const isCtrlZ = shortcuts.matchesShortcut(undoKeyboardShortcutId, event); if (this.builder && isCtrlZ) { this.finalizeStroke(); // Return false: Allow other listeners to handle the event (e.g. // undo/redo). return false; } 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), 256); this.setThickness(newThickness); return true; } return false; } }