js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
185 lines (184 loc) • 7.41 kB
JavaScript
import { InputEvtType, isPointerEvt, } from '../../inputEvents.mjs';
import InputMapper from './InputMapper.mjs';
import { Vec2 } from '@js-draw/math';
import untilNextAnimationFrame from '../../util/untilNextAnimationFrame.mjs';
var StabilizerType;
(function (StabilizerType) {
StabilizerType[StabilizerType["IntertialStabilizer"] = 0] = "IntertialStabilizer";
})(StabilizerType || (StabilizerType = {}));
const defaultOptions = {
kind: StabilizerType.IntertialStabilizer,
mass: 0.4, // kg
springConstant: 100.0, // N/m
frictionCoefficient: 0.28,
maxPointDist: 10, // screen units
inertiaFraction: 0.75,
minSimilarityToFinalize: 0.0,
velocityDecayFactor: 0.1,
};
// Stabilizes input for a single cursor
class StylusInputStabilizer {
constructor(
// The initial starting point of the pointer.
start,
// Emits a pointer motion event, returns true if the event was handled.
updatePointer, options) {
this.updatePointer = updatePointer;
this.options = options;
this.runLoop = true;
this.lastUpdateTime = 0;
this.velocity = Vec2.zero;
this.strokePoint = start;
this.targetPoint = start;
this.targetInterval = 10; // ms
void this.loop();
}
async loop() {
this.lastUpdateTime = performance.now();
while (this.runLoop) {
this.update(false);
await untilNextAnimationFrame();
}
}
setTarget(point) {
this.targetPoint = point;
}
getNextVelocity(deltaTimeMs) {
const toTarget = this.targetPoint.minus(this.strokePoint);
const springForce = toTarget.times(this.options.springConstant);
const gravityAccel = 10;
const normalForceMagnitude = this.options.mass * gravityAccel;
const frictionForce = this.velocity
.normalizedOrZero()
.times(-this.options.frictionCoefficient * normalForceMagnitude);
const acceleration = springForce.plus(frictionForce).times(1 / this.options.mass);
const decayFactor = this.options.velocityDecayFactor;
const springVelocity = this.velocity
.times(1 - decayFactor)
.plus(acceleration.times(deltaTimeMs / 1000));
// An alternate velocity that goes directly towards the target.
const toTargetVelocity = toTarget.normalizedOrZero().times(springVelocity.length());
return toTargetVelocity.lerp(springVelocity, this.options.inertiaFraction);
}
update(force) {
const nowTime = performance.now();
const deltaTime = nowTime - this.lastUpdateTime;
const reachedTarget = this.strokePoint.eq(this.targetPoint);
if (deltaTime > this.targetInterval || force) {
if (!reachedTarget) {
let velocity;
let deltaX;
let parts = 1;
do {
velocity = this.getNextVelocity(deltaTime / parts);
deltaX = velocity.times(deltaTime / 1000);
parts++;
} while (deltaX.magnitude() > this.options.maxPointDist && parts < 10);
for (let i = 0; i < parts; i++) {
this.velocity = this.getNextVelocity(deltaTime / parts);
deltaX = this.velocity.times(deltaTime / 1000);
this.strokePoint = this.strokePoint.plus(deltaX);
// Allows the last updatePointer to be returned.
if (i < parts - 1) {
this.updatePointer(this.strokePoint, nowTime);
}
}
}
// Even if we have reached the target, ensure that lastUpdateTime is updated
// (prevent large deltaTime).
this.lastUpdateTime = nowTime;
if (force || !reachedTarget) {
return this.updatePointer(this.strokePoint, nowTime);
}
}
return false;
}
/** Finalizes the current stroke. */
finish() {
this.runLoop = false;
const toTarget = this.targetPoint.minus(this.strokePoint);
if (this.velocity.dot(toTarget) > this.options.minSimilarityToFinalize) {
// Connect the stroke to its end point
this.updatePointer(this.targetPoint, performance.now());
}
}
cancel() {
this.runLoop = false;
}
}
export default class InputStabilizer extends InputMapper {
constructor(viewport, options = defaultOptions) {
super();
this.viewport = viewport;
this.options = options;
this.stabilizer = null;
this.lastPointerEvent = null;
}
mapPointerEvent(event) {
// Don't store the last pointer event for use with pressure/button data --
// this information can be very different for a pointerup event.
if (isPointerEvt(event) && event.kind !== InputEvtType.PointerUpEvt) {
this.lastPointerEvent = event;
}
// Only apply smoothing if there is a single pointer.
if (event.kind === InputEvtType.GestureCancelEvt ||
event.allPointers.length > 1 ||
this.stabilizer === null) {
return this.emit(event);
}
this.stabilizer.setTarget(event.current.screenPos);
if (event.kind === InputEvtType.PointerMoveEvt) {
return this.stabilizer.update(true);
}
else if (event.kind === InputEvtType.PointerUpEvt) {
this.stabilizer.finish();
return this.emit(event);
}
else {
return this.emit(event);
}
}
// Assumes that there is exactly one pointer that is currently down.
emitPointerMove(screenPoint, timeStamp) {
if (!this.lastPointerEvent) {
return false;
}
const pointer = this.lastPointerEvent.current
.withScreenPosition(screenPoint, this.viewport)
.withTimestamp(timeStamp);
const event = {
kind: InputEvtType.PointerMoveEvt,
current: pointer,
allPointers: [pointer],
};
const handled = this.emit(event);
return handled;
}
onEvent(event) {
if (isPointerEvt(event) || event.kind === InputEvtType.GestureCancelEvt) {
if (event.kind === InputEvtType.PointerDownEvt) {
if (event.allPointers.length > 1) {
// Do not attempt to stabilize multiple pointers.
this.stabilizer?.cancel();
this.stabilizer = null;
}
else {
// Create a new stabilizer for the new stroke.
this.stabilizer?.cancel();
this.stabilizer = new StylusInputStabilizer(event.current.screenPos, (screenPoint, timeStamp) => this.emitPointerMove(screenPoint, timeStamp), this.options);
}
}
const handled = this.mapPointerEvent(event);
if (event.kind === InputEvtType.PointerUpEvt ||
event.kind === InputEvtType.GestureCancelEvt) {
this.stabilizer?.cancel();
this.stabilizer = null;
}
return handled;
}
return this.emit(event);
}
static fromEditor(editor) {
return new InputStabilizer(editor.viewport);
}
}