fidget-pincher
Version:
- [jsDelivr CDN](https://cdn.jsdelivr.net/npm/fidget-pincher/): `<script src="https://cdn.jsdelivr.net/npm/fidget-pincher/dist/bundle.min.js"></script>` - [unpkg CDN](https://unpkg.com/fidget-pincher/): `<script src="https://unpkg.com/fidget-pincher/dist
743 lines (737 loc) • 27.5 kB
JavaScript
function multiplyMatrices(a, b) {
const [a0, a1, a2, a3, a4, a5, a6, a7, a8] = a;
const [b0, b1, b2, b3, b4, b5, b6, b7, b8] = b;
return [
a0 * b0 + a1 * b3 + a2 * b6,
a0 * b1 + a1 * b4 + a2 * b7,
a0 * b2 + a1 * b5 + a2 * b8,
a3 * b0 + a4 * b3 + a5 * b6,
a3 * b1 + a4 * b4 + a5 * b7,
a3 * b2 + a4 * b5 + a5 * b8,
a6 * b0 + a7 * b3 + a8 * b6,
a6 * b1 + a7 * b4 + a8 * b7,
a6 * b2 + a7 * b5 + a8 * b8,
];
}
// solve linear system of equations with 2 unknowns
function solveLinearSystem(a, b, c, d, e, f) {
const det = a * e - b * d;
if (det === 0) {
return null;
}
return [
(c * e - b * f) / det,
(a * f - c * d) / det,
];
}
class TransformationMatrix {
static identity() {
return new TransformationMatrix(1, 0, 0, 1, 0, 0);
}
static translation(x, y) {
return new TransformationMatrix(1, 0, 0, 1, x, y);
}
static rotation(angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return new TransformationMatrix(c, s, -s, c, 0, 0);
}
static scale(x, y) {
return new TransformationMatrix(x, 0, 0, y, 0, 0);
}
/**
* The transformation matrix is described by: [
* [a, c, e],
* [b, d, f],
* [0, 0, 1]
* ]
*/
constructor(a, b, c, d, e, f) {
this.a = a;
this.b = b;
this.c = c;
this.d = d;
this.e = e;
this.f = f;
}
multiplyMatrix(other) {
const a = [this.a, this.c, this.e, this.b, this.d, this.f, 0, 0, 1];
const b = [other.a, other.c, other.e, other.b, other.d, other.f, 0, 0, 1];
const [c0, c1, c2, c3, c4, c5, c6, c7, c8] = multiplyMatrices(a, b);
return new TransformationMatrix(c0, c3, c1, c4, c2, c5);
}
toCSSMatrix() {
const { a, b, c, d, e, f } = this;
return `matrix(${a}, ${b}, ${c}, ${d}, ${e}, ${f})`;
}
decompose() {
// decompose the matrix into translate, rotate, scale
const { a, b, c, d, e, f } = this;
const translateX = e;
const translateY = f;
// ignore skew
const scale = Math.hypot(a, b);
const rotate = Math.atan2(b, a);
return { translateX, translateY, scale, rotate };
}
toCSSDecomposed() {
const { translateX, translateY, scale, rotate } = this.decompose();
return `translate(${translateX}px, ${translateY}px) rotate(${rotate}rad) scale(${scale})`;
}
/**
* solve [ax + cy + e] = [x]
* [bx + dy + f] = [y]
*/
calculateTransformOrigin() {
const { a, b, c, d, e, f } = this;
const result = solveLinearSystem(a - 1, c, -e, b, d - 1, -f);
if (result === null) {
return null;
}
const [x, y] = result;
return { x, y };
}
}
function defaultOptions$1() {
return {
minimumTime: 100,
minimumSnapshots: 3,
brakingTime: 1000,
maximumPinchReleaseTime: 300
};
}
function completeOptions(options) {
return Object.assign(Object.assign({}, defaultOptions$1()), options);
}
function calculateInertia(history, options) {
if (history.length < options.minimumSnapshots) {
throw new Error('Not enough snapshots');
}
let dt = 0;
let deltas = Array(history[0].deltas.length).fill(0);
let count = 0;
for (let i = history.length - 1; i >= 0; i--) {
const snapshot = history[i];
dt += snapshot.dt;
for (let j = 0; j < snapshot.deltas.length; j++) {
deltas[j] += snapshot.deltas[j];
}
count++;
if (dt >= options.minimumTime && count >= options.minimumSnapshots) {
break;
}
}
return {
velocities: deltas.map(d => d / dt),
time: dt,
count
};
}
function applyInertia(options, callback) {
const { brakingTime } = options;
const start = performance.now();
let running = true;
let task = requestAnimationFrame(animate);
function animate(now) {
if (!running) {
return;
}
const elapsed = now - start;
const dt = Math.min(elapsed, brakingTime);
const mappedTime = dt * (1 - dt / (2 * brakingTime));
callback({ mappedTime });
if (elapsed < brakingTime) {
task = requestAnimationFrame(animate);
}
else {
running = false;
}
}
return {
isRunning: () => running,
stop: () => {
running = false;
cancelAnimationFrame(task);
}
};
}
function calculateTranslateInertia(translationHistory, options) {
options = completeOptions(options);
if (translationHistory.length < options.minimumSnapshots) {
return null;
}
const inertia = calculateInertia(translationHistory.map(s => ({ deltas: [s.dx, s.dy], dt: s.dt })), options);
const [vx, vy] = inertia.velocities;
return { vx, vy };
}
function applyTranslateInertia(inertia, callback, options) {
options = completeOptions(options);
let sx = 0;
let sy = 0;
return applyInertia(options, ({ mappedTime }) => {
const dx = inertia.vx * mappedTime - sx;
const dy = inertia.vy * mappedTime - sy;
sx += dx;
sy += dy;
callback(dx, dy);
});
}
function calculateFidgetSpinInertia(pinchHistory, options) {
options = completeOptions(options);
if (pinchHistory.length < options.minimumSnapshots) {
return null;
}
const inertia = calculateInertia(pinchHistory.map(s => ({ deltas: [s.pinch.rotation], dt: s.dt })), options);
const [angularVelocity] = inertia.velocities;
// calculate product of last [count] scales
const scaleProduct = pinchHistory
.slice(-inertia.count)
.map(s => s.pinch.scale)
.reduce((a, b) => a * b, 1);
// calculate scaling constant
// scale(t) = scalingConstant ^ t
const scalingConstant = Math.pow(scaleProduct, 1 / inertia.time);
return { angularVelocity, scalingConstant };
}
function applyFidgetSpinInertia(inertia, callback, options) {
options = completeOptions(options);
let r = 0;
let s = 1;
return applyInertia(options, ({ mappedTime }) => {
const rotation = inertia.angularVelocity * mappedTime - r;
const scale = Math.pow(inertia.scalingConstant, mappedTime) / s;
r += rotation;
s *= scale;
callback(rotation, scale);
});
}
function calculatePinchInertia(pinchHistory, options) {
options = completeOptions(options);
if (pinchHistory.length < options.minimumSnapshots) {
return null;
}
const inertia = calculateInertia(pinchHistory.map(s => ({ deltas: [], dt: s.dt })), options);
// calculate transform of last [count] pinch
let transform = TransformationMatrix.identity();
for (const snapshot of pinchHistory.slice(-inertia.count)) {
const { dx, dy, scale, rotation, prevCentroid } = snapshot.pinch;
const actions = [
TransformationMatrix.translation(-prevCentroid.x, -prevCentroid.y),
TransformationMatrix.rotation(rotation),
TransformationMatrix.scale(scale, scale),
TransformationMatrix.translation(dx, dy),
TransformationMatrix.translation(prevCentroid.x, prevCentroid.y)
];
for (const action of actions) {
transform = action.multiplyMatrix(transform);
}
}
const { translateX, translateY, scale, rotate } = transform.decompose();
const angularVelocity = rotate / inertia.time;
const scalingConstant = Math.pow(scale, 1 / inertia.time);
const transformOrigin = transform.calculateTransformOrigin();
const velocityX = translateX / inertia.time;
const velocityY = translateY / inertia.time;
return {
angularVelocity,
scalingConstant,
transformOrigin,
velocityX,
velocityY
};
}
function applyPinchInertia(inertia, callback, options) {
if (inertia.transformOrigin === null) {
return applyTranslateInertia({ vx: inertia.velocityX, vy: inertia.velocityY }, (dx, dy) => {
callback(TransformationMatrix.translation(dx, dy));
}, options);
}
options = completeOptions(options);
const { x, y } = inertia.transformOrigin;
let r = 0;
let s = 1;
return applyInertia(options, ({ mappedTime }) => {
const rotation = inertia.angularVelocity * mappedTime - r;
const scale = Math.pow(inertia.scalingConstant, mappedTime) / s;
r += rotation;
s *= scale;
const actions = [
TransformationMatrix.translation(-x, -y),
TransformationMatrix.rotation(rotation),
TransformationMatrix.scale(scale, scale),
TransformationMatrix.translation(x, y),
];
let transform = TransformationMatrix.identity();
for (const action of actions) {
transform = action.multiplyMatrix(transform);
}
callback(transform);
});
}
function isPinchRelease(time, options) {
options = completeOptions(options);
return time < options.maximumPinchReleaseTime;
}
function calculateCentroid(points) {
let [xs, ys] = [0, 0];
for (const point of points) {
xs += point.x;
ys += point.y;
}
return {
x: xs / points.length,
y: ys / points.length
};
}
function calculateAverageDistance(points) {
const { x: xs, y: ys } = calculateCentroid(points);
let d = 0;
for (const point of points) {
d += Math.hypot(point.x - xs, point.y - ys);
}
return d / points.length;
}
function positiveMod(a, b) {
return (a % b + b) % b;
}
// maps angle to [-pi, pi)
function normalizeRelativeAngle(angle) {
return positiveMod(angle + Math.PI, 2 * Math.PI) - Math.PI;
}
function calculateAverageAngleDisplacement(prevPoints, nextPoints) {
if (prevPoints.length !== nextPoints.length) {
throw new Error('prevPoints and nextPoints must have the same length');
}
const prevCentroid = calculateCentroid(prevPoints);
const nextCentroid = calculateCentroid(nextPoints);
let d = 0;
for (let i = 0; i < prevPoints.length; i++) {
const prevPoint = prevPoints[i];
const nextPoint = nextPoints[i];
const prevAngle = Math.atan2(prevPoint.y - prevCentroid.y, prevPoint.x - prevCentroid.x);
const nextAngle = Math.atan2(nextPoint.y - nextCentroid.y, nextPoint.x - nextCentroid.x);
d += normalizeRelativeAngle(nextAngle - prevAngle);
}
return d / prevPoints.length;
}
function calculatePinch(prevPoints, nextPoints) {
const prevCentroid = calculateCentroid(prevPoints);
const nextCentroid = calculateCentroid(nextPoints);
const prevDistance = calculateAverageDistance(prevPoints);
const nextDistance = calculateAverageDistance(nextPoints);
const rotation = calculateAverageAngleDisplacement(prevPoints, nextPoints);
const scale = nextDistance / prevDistance;
const dx = nextCentroid.x - prevCentroid.x;
const dy = nextCentroid.y - prevCentroid.y;
return {
dx,
dy,
scale,
rotation,
prevCentroid,
nextCentroid
};
}
class ImplInertia {
get options() {
return this.optionsGetter.get();
}
constructor(optionsGetter, transform, __owner) {
this.optionsGetter = optionsGetter;
this.transform = transform;
this.__owner = __owner;
this.t = 0;
this.translations = [];
this.translationApplyResult = null;
this.pinches = [];
this.fidgetSpinApplyResult = null;
this.fidgetSpinPivot = { x: 0, y: 0 };
this.pinchApplyResult = null;
this.pinchReleaseTimestamp = 0;
}
onStart(touches, t) {
var _a, _b, _c, _d, _e;
this.translations = [];
if (touches === 1) {
if (this.options.stopTranslateInertiaOnTouch) {
(_a = this.translationApplyResult) === null || _a === void 0 ? void 0 : _a.stop();
this.translationApplyResult = null;
}
if (this.options.stopFidgetSpinInertiaOnTouch) {
(_b = this.fidgetSpinApplyResult) === null || _b === void 0 ? void 0 : _b.stop();
this.fidgetSpinApplyResult = null;
}
if (this.options.stopPinchInertiaOnTouch) {
(_c = this.pinchApplyResult) === null || _c === void 0 ? void 0 : _c.stop();
this.pinchApplyResult = null;
}
}
else if (touches === 2) {
this.pinches = [];
if (this.options.stopFidgetSpinInertiaOnPinch) {
(_d = this.fidgetSpinApplyResult) === null || _d === void 0 ? void 0 : _d.stop();
this.fidgetSpinApplyResult = null;
}
if (this.options.stopPinchInertiaOnPinch) {
(_e = this.pinchApplyResult) === null || _e === void 0 ? void 0 : _e.stop();
this.pinchApplyResult = null;
}
}
this.t = t;
}
onTranslate(dx, dy, t) {
/* update fidget spin pivot */
this.fidgetSpinPivot.x = this.__owner.pointers[0].x;
this.fidgetSpinPivot.y = this.__owner.pointers[0].y;
/* update fidget spin pivot end */
const dt = t - this.t;
this.translations.push({ dx, dy, dt });
this.t = t;
}
onPinch(pinch, t) {
const dt = t - this.t;
this.pinches.push({ pinch, dt });
this.t = t;
}
onEnd(touches) {
var _a;
if (!this.options.enableInertia) {
return;
}
if (touches === 1) {
this.pinchReleaseTimestamp = this.t;
}
if (touches === 0 && this.options.enablePinchInertia) {
if (isPinchRelease(this.t - this.pinchReleaseTimestamp)) {
this.pinchReleaseTimestamp = 0;
if (this.options.stopFidgetSpinInertiaOnPinchInertia) {
(_a = this.fidgetSpinApplyResult) === null || _a === void 0 ? void 0 : _a.stop();
this.fidgetSpinApplyResult = null;
}
const inertia = calculatePinchInertia(this.pinches);
if (inertia) {
const result = applyPinchInertia(inertia, (action) => {
let transform = this.transform.get();
transform = action.multiplyMatrix(transform);
this.transform.set(transform);
});
this.pinchApplyResult = result;
}
return;
}
}
if (touches === 0 && this.options.enableTranslateInertia) {
const inertia = calculateTranslateInertia(this.translations);
if (inertia) {
const result = applyTranslateInertia(inertia, (dx, dy) => {
/* update fidget spin pivot */
this.fidgetSpinPivot.x += dx;
this.fidgetSpinPivot.y += dy;
/* update fidget spin pivot end */
let transform = this.transform.get();
transform = TransformationMatrix.translation(dx, dy).multiplyMatrix(transform);
this.transform.set(transform);
});
this.translationApplyResult = result;
}
}
if (touches === 1 && this.options.enableFidgetSpinInertia) {
const inertia = calculateFidgetSpinInertia(this.pinches);
if (inertia) {
const result = applyFidgetSpinInertia(inertia, (rotation, scale) => {
const { x, y } = this.fidgetSpinPivot;
let transform = this.transform.get();
const actions = [
TransformationMatrix.translation(-x, -y),
TransformationMatrix.rotation(rotation),
TransformationMatrix.scale(scale, scale),
TransformationMatrix.translation(x, y),
];
for (const action of actions) {
transform = action.multiplyMatrix(transform);
}
this.transform.set(transform);
});
this.fidgetSpinApplyResult = result;
}
}
}
}
class ImplPointer {
constructor(owner, x, y) {
this.owner = owner;
this.x = x;
this.y = y;
}
// corresponding to mousemove or touchmove
move(x, y, t) {
if (this.owner.pointers.length === 1) {
const dx = x - this.x;
const dy = y - this.y;
this.owner.transform = TransformationMatrix.translation(dx, dy).multiplyMatrix(this.owner.transform);
this.x = x;
this.y = y;
this.owner.inertia.onTranslate(dx, dy, t);
this.owner.notifyTransformed();
}
else if (this.owner.pointers.length >= 2) {
const prevPoints = this.owner.pointers.map(p => ({ x: p.x, y: p.y }));
this.x = x;
this.y = y;
const nextPoints = this.owner.pointers.map(p => ({ x: p.x, y: p.y }));
const pinch = calculatePinch(prevPoints, nextPoints);
const { dx, dy, scale, rotation, prevCentroid } = pinch;
const actions = [
TransformationMatrix.translation(-prevCentroid.x, -prevCentroid.y),
TransformationMatrix.rotation(rotation),
TransformationMatrix.scale(scale, scale),
TransformationMatrix.translation(dx, dy),
TransformationMatrix.translation(prevCentroid.x, prevCentroid.y)
];
for (const action of actions) {
this.owner.transform = action.multiplyMatrix(this.owner.transform);
}
this.owner.inertia.onPinch(pinch, t);
this.owner.notifyTransformed();
}
}
// corresponding to mouseup or touchend
remove() {
const index = this.owner.pointers.indexOf(this);
if (index >= 0) {
this.owner.pointers.splice(index, 1);
}
this.owner.inertia.onEnd(this.owner.pointers.length);
}
}
class Impl {
constructor(options) {
this.options = options;
this.transformedCallbacks = [];
this.pointers = [];
this.transform = TransformationMatrix.identity();
this.inertia = new ImplInertia({
get: () => this.options,
}, {
get: () => this.transform,
set: (value) => {
this.transform = value;
this.notifyTransformed();
}
}, this);
}
setOptions(options) {
this.options = options;
}
// corresponding to mousedown or touchstart
addPointer(x, y, t) {
const pointer = new ImplPointer(this, x, y);
this.pointers.push(pointer);
this.inertia.onStart(this.pointers.length, t);
return pointer;
}
addTransformedCallback(callback) {
this.transformedCallbacks.push(callback);
}
removeTransformedCallback(callback) {
const index = this.transformedCallbacks.indexOf(callback);
if (index >= 0) {
this.transformedCallbacks.splice(index, 1);
}
}
notifyTransformed() {
for (const callback of this.transformedCallbacks) {
try {
callback(this.transform);
}
catch (e) {
console.error(e);
}
}
}
}
function defaultOptions() {
return {
enableInertia: true,
enableTranslateInertia: true,
enableFidgetSpinInertia: true,
enablePinchInertia: true,
stopTranslateInertiaOnTouch: true,
stopFidgetSpinInertiaOnPinch: true,
stopFidgetSpinInertiaOnTouch: true,
stopPinchInertiaOnPinch: true,
stopPinchInertiaOnTouch: true,
stopFidgetSpinInertiaOnPinchInertia: true,
};
}
class FidgetPincher {
constructor(options) {
this.impl = new Impl(Object.assign(Object.assign({}, defaultOptions()), options));
this.pointerMap = new Map();
}
setOptions(options) {
this.impl.setOptions(Object.assign(Object.assign({}, defaultOptions()), options));
}
addPointer(id, x, y, t) {
const pointer = this.impl.addPointer(x, y, t);
this.pointerMap.set(id, pointer);
}
movePointer(id, x, y, t) {
const pointer = this.pointerMap.get(id);
if (pointer) {
pointer.move(x, y, t);
}
}
removePointer(id) {
const pointer = this.pointerMap.get(id);
if (pointer) {
pointer.remove();
}
}
setTouchElement(element, options) {
const events = this.createEvents(element);
element.addEventListener('mousedown', events.mousedown);
element.addEventListener('touchstart', events.touchstart);
element.addEventListener('touchmove', events.touchmove);
element.addEventListener('touchend', events.touchend);
const { onTransformed } = options;
if (onTransformed !== undefined) {
this.impl.addTransformedCallback(onTransformed);
}
return () => {
element.removeEventListener('mousedown', events.mousedown);
element.removeEventListener('touchstart', events.touchstart);
element.removeEventListener('touchmove', events.touchmove);
element.removeEventListener('touchend', events.touchend);
if (onTransformed !== undefined) {
this.impl.removeTransformedCallback(onTransformed);
}
};
}
getTransform() {
return this.impl.transform;
}
static parseTransform(transform) {
if (transform instanceof TransformationMatrix) {
return transform;
}
if (Array.isArray(transform)) {
const [a, b, c, d, e, f] = transform;
return new TransformationMatrix(a, b, c, d, e, f);
}
if (typeof transform === 'string') {
// match 'matrix(a, b, c, d, e, f)'
const match = transform.match(/^matrix\((.+)\)$/);
if (match) {
const [a, b, c, d, e, f] = match[1].split(',').map(parseFloat);
return new TransformationMatrix(a, b, c, d, e, f);
}
}
if (typeof transform === 'object') {
const { a, b, c, d, e, f } = transform;
return new TransformationMatrix(a, b, c, d, e, f);
}
throw new Error(`Invalid transform: ${transform}`);
}
// won't trigger onTransformed callback
setTransform(transform) {
// support [a, b, c, d, e, f] and { a, b, c, d, e, f }
if (!(transform instanceof TransformationMatrix)) {
transform = FidgetPincher.parseTransform(transform);
}
this.impl.transform = transform;
}
// call this function when browser default touch events should interrupt the operation
// e.g. page zoom, scroll, text selection, etc.
clearTouchPointers() {
this.pointerMap.clear();
this.impl.pointers = [];
}
createEvents(element) {
const events = {
mousedown: (event) => {
// register mousemove and mouseup
window.addEventListener('mousemove', events.mousemove);
window.addEventListener('mouseup', events.mouseup);
// map cursor x y relative to element center
const { clientX: x, clientY: y } = event;
const { left, top, width, height } = element.getBoundingClientRect();
const cx = left + width / 2;
const cy = top + height / 2;
const dx = x - cx;
const dy = y - cy;
const t = performance.now();
this.addPointer('mouse', dx, dy, t);
},
mousemove: (event) => {
// map cursor x y relative to element center
const { clientX: x, clientY: y } = event;
const { left, top, width, height } = element.getBoundingClientRect();
const cx = left + width / 2;
const cy = top + height / 2;
const dx = x - cx;
const dy = y - cy;
const t = performance.now();
this.movePointer('mouse', dx, dy, t);
},
mouseup: (event) => {
// unregister mousemove and mouseup
window.removeEventListener('mousemove', events.mousemove);
window.removeEventListener('mouseup', events.mouseup);
// map cursor x y relative to element center
const { clientX: x, clientY: y } = event;
const { left, top, width, height } = element.getBoundingClientRect();
const cx = left + width / 2;
const cy = top + height / 2;
const dx = x - cx;
const dy = y - cy;
const t = performance.now();
this.movePointer('mouse', dx, dy, t);
this.removePointer('mouse');
},
touchstart: (event) => {
// map touches x y relative to element center
const { left, top, width, height } = element.getBoundingClientRect();
const cx = left + width / 2;
const cy = top + height / 2;
const t = performance.now();
for (const touch of event.changedTouches) {
const { identifier, clientX: x, clientY: y } = touch;
const dx = x - cx;
const dy = y - cy;
this.addPointer(identifier, dx, dy, t);
}
},
touchmove: (event) => {
// map touches x y relative to element center
const { left, top, width, height } = element.getBoundingClientRect();
const cx = left + width / 2;
const cy = top + height / 2;
const t = performance.now();
for (const touch of event.changedTouches) {
const { identifier, clientX: x, clientY: y } = touch;
const dx = x - cx;
const dy = y - cy;
this.movePointer(identifier, dx, dy, t);
}
},
touchend: (event) => {
// map touches x y relative to element center
const { left, top, width, height } = element.getBoundingClientRect();
const cx = left + width / 2;
const cy = top + height / 2;
const t = performance.now();
for (const touch of event.changedTouches) {
const { identifier, clientX: x, clientY: y } = touch;
const dx = x - cx;
const dy = y - cy;
this.movePointer(identifier, dx, dy, t);
this.removePointer(identifier);
}
}
};
return events;
}
}
FidgetPincher.TransformationMatrix = TransformationMatrix;
export { FidgetPincher, TransformationMatrix };