UNPKG

js-draw

Version:

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

381 lines (380 loc) 15 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const math_1 = require("@js-draw/math"); const EditorImage_1 = __importDefault(require("../image/EditorImage")); const Pointer_1 = require("../Pointer"); const FreehandLineBuilder_1 = require("../components/builders/FreehandLineBuilder"); const types_1 = require("../types"); const BaseTool_1 = __importDefault(require("./BaseTool")); const keybindings_1 = require("./keybindings"); const keybindings_2 = require("./keybindings"); const InputStabilizer_1 = __importDefault(require("./InputFilter/InputStabilizer")); const ReactiveValue_1 = require("../util/ReactiveValue"); const StationaryPenDetector_1 = __importStar(require("./util/StationaryPenDetector")); /** * 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}. */ class Pen extends BaseTool_1.default { 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_1.ReactiveValue.fromInitialValue({ factory: FreehandLineBuilder_1.makeFreehandLineBuilder, color: math_1.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 === Pointer_1.PointerDevice.Eraser; const isPen = current.device === Pointer_1.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_1.default(current, StationaryPenDetector_1.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 === Pointer_1.PointerDevice.Pen; const isTouchEvent = event.current.device === Pointer_1.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_1.default.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(types_1.EditorEventType.ToolUpdated, { kind: types_1.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_1.default(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(keybindings_1.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(keybindings_2.decreaseSizeKeyboardShortcutId, event)) { newThickness = (this.getThickness() * 2) / 3; } else if (shortcuts.matchesShortcut(keybindings_2.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; } } exports.default = Pen;