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