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