UNPKG

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