the-finger
Version:
JavaScript library to detect touch gestures: tap, double tap, press, long press, drag, flick, rotate, pinch, spread, pan, two-finger.
813 lines (691 loc) • 27.6 kB
JavaScript
const CONSTANTS = {
PRESS_TIME: 350, // ms
DOUBLE_TAP_INTERVAL: 250, // ms
FLICK_THRESHOLD: 0.75, // drag speed
DOUBLE_TAP_DRAG_THRESHOLD: 5, // px
};
const ELEMENT_STATE = new WeakMap();
class TheFinger {
#element;
#settings;
#areaBox;
#gestureType = null;
#moving = false;
#pressTimer;
#currentTouch = {};
#startTime;
#tapReleaseTime;
#startX;
#startY;
#endX;
#endY;
#rotationAngleStart;
#totalAngleStart;
#previousAngle = null;
#angleRelative = null;
#revs = 0;
#negativeRev = false;
#distanceStart;
#watching = {};
#preventDefaults = {};
#touchHistory = new Map();
#touchSequence = [];
#initialDirection;
#doubleTapDragActive = false;
#doubleTapDragStart = null;
gestures = {
'press': {
start: () => {
this.#gestureType = 'press';
return {
type: 'press',
data: { x: this.#startX, y: this.#startY }
};
},
move: () => { },
end: () => { }
},
'tap': {
start: () => { },
move: () => {
clearTimeout(this.#pressTimer);
},
end: (touches, timestamp) => {
if (!this.#moving && timestamp - this.#startTime < CONSTANTS.PRESS_TIME) {
clearTimeout(this.#pressTimer);
if (timestamp < this.#tapReleaseTime + CONSTANTS.DOUBLE_TAP_INTERVAL + CONSTANTS.PRESS_TIME) {
this.#tapReleaseTime = null;
return {
type: 'double-tap',
data: { x: this.#startX, y: this.#startY }
};
} else {
this.#tapReleaseTime = timestamp;
return {
type: 'tap',
data: { x: this.#startX, y: this.#startY }
};
}
}
return null;
}
},
'two-finger-tap': {
start: () => { },
move: () => { },
end: (touches, timestamp) => {
if (this.#touchSequence.length === 2 && !this.#moving &&
timestamp - this.#startTime < CONSTANTS.PRESS_TIME) {
// Get the positions of both touch points
const touchPositions = [...this.#touchHistory.values()].map(h => ({
x: h.x[0],
y: h.y[0]
}));
if (touchPositions.length === 2) {
// Calculate center point between the two touches
const x = (touchPositions[0].x + touchPositions[1].x) / 2;
const y = (touchPositions[0].y + touchPositions[1].y) / 2;
return {
type: 'two-finger-tap',
data: {
x,
y,
touches: touchPositions
}
};
}
}
return null;
}
},
'long-press': {
start: (touches, timestamp) => {
if (touches.length !== 1) return;
this.#pressTimer = setTimeout(() => {
this.#gestureType = 'long-press';
this.#currentTouch = { x: this.#startX, y: this.#startY };
this.#executeCallback('long-press', [this.#currentTouch]);
}, CONSTANTS.PRESS_TIME);
},
move: () => {
clearTimeout(this.#pressTimer);
},
end: () => {
clearTimeout(this.#pressTimer);
}
},
'drag': {
start: () => { },
move: (touches) => {
if (touches.length !== 1) return null;
const touch = touches[0];
const x = touch.clientX - this.#areaBox.left;
const y = touch.clientY - this.#areaBox.top;
// Get previous x, y coordinates using helper method
const { prevX, prevY } = this.#getPreviousCoordinates(touches, this.#startX, this.#startY);
this.#currentTouch = {
x,
y,
startX: this.#startX,
startY: this.#startY,
step: this.#getStepSpeed(),
speed: this.#getSpeed(),
angle: this.#getAngle(prevX, prevY, x, y)
};
if (!this.#initialDirection) {
this.#initialDirection = this.#getDirection(this.#startX, this.#startY, x, y);
this.#currentTouch.initial_direction = this.#initialDirection;
}
return {
type: 'drag',
data: this.#currentTouch
};
},
end: () => {
if (!this.#moving || this.#touchHistory.size === 0 || this.#gestureType !== 'drag') return null;
const history = this.#touchHistory.values().next().value;
if (!history?.x?.length || !history?.y?.length) return null;
const x_arr = history.x;
const y_arr = history.y;
this.#endX = x_arr[x_arr.length - 1];
this.#endY = y_arr[y_arr.length - 1];
this.#currentTouch.endX = this.#endX;
this.#currentTouch.endY = this.#endY;
this.#currentTouch.speed = this.#getSpeed();
this.#currentTouch.initial_direction = this.#initialDirection;
if (x_arr.length > 1 && y_arr.length > 1) {
this.#currentTouch.final_direction = this.#getDirection(
x_arr[x_arr.length - 2],
y_arr[y_arr.length - 2],
this.#currentTouch.endX,
this.#currentTouch.endY
);
}
this.#currentTouch.flick = this.#currentTouch.speed >= CONSTANTS.FLICK_THRESHOLD;
return {
type: 'drag',
data: this.#currentTouch
};
}
},
'pan': {
start: (touches) => {
if (touches.length < 2) return null;
const { x, y } = this.#getAveragePosition(touches);
this.#startX = x;
this.#startY = y;
},
move: (touches) => {
if (touches.length < 2) return null;
const { x, y, touchesArr } = this.#getAveragePosition(touches);
// Get previous x, y coordinates using helper method
const { prevX, prevY } = this.#getPreviousCoordinates(touches, this.#startX, this.#startY);
this.#currentTouch = {
touches: touchesArr,
x,
y,
startX: this.#startX,
startY: this.#startY,
step: this.#getStepSpeed(),
speed: this.#getSpeed(),
angle: this.#getAngle(prevX, prevY, x, y)
};
if (!this.#initialDirection) {
this.#initialDirection = this.#getDirection(this.#startX, this.#startY, x, y);
this.#currentTouch.initial_direction = this.#initialDirection;
}
return { type: 'pan', data: this.#currentTouch };
},
end: () => {
if (
this.#touchSequence.length < 2 ||
!this.#moving ||
this.#touchHistory.size === 0
) return null;
// build touches[] from the last point of every finger's history
const touchesFinal = [...this.#touchHistory.values()].map(h => ({
x: h.x[h.x.length - 1],
y: h.y[h.y.length - 1]
}));
const len = touchesFinal.length;
const x = touchesFinal.reduce((s, t) => s + t.x, 0) / len;
const y = touchesFinal.reduce((s, t) => s + t.y, 0) / len;
// Get previous position for angle calculation
let prevX = this.#currentTouch.x || this.#startX;
let prevY = this.#currentTouch.y || this.#startY;
// Get average of 5th-to-last positions if available
let sumPrevX = 0, sumPrevY = 0;
let count = 0;
for (const [_, history] of this.#touchHistory.entries()) {
if (history.x.length >= 5 && history.y.length >= 5) {
sumPrevX += history.x[history.x.length - 5];
sumPrevY += history.y[history.y.length - 5];
count++;
}
}
if (count > 0) {
prevX = sumPrevX / count;
prevY = sumPrevY / count;
}
const speed = this.#getSpeed();
this.#currentTouch = {
touches: touchesFinal,
x,
y,
startX: this.#startX,
startY: this.#startY,
step: this.#getStepSpeed(),
speed,
angle: this.#getAngle(prevX, prevY, x, y),
endX: x,
endY: y,
initial_direction: this.#initialDirection,
final_direction: this.#getDirection(this.#startX, this.#startY, x, y),
flick: speed >= CONSTANTS.FLICK_THRESHOLD
};
return { type: 'pan', data: this.#currentTouch };
}
},
'rotate': {
start: (touches) => {
if (touches.length !== 2) return;
const [touch1, touch2] = touches;
const x1 = touch1.clientX;
const y1 = touch1.clientY;
const x2 = touch2.clientX;
const y2 = touch2.clientY;
this.#rotationAngleStart = this.#getAngle(x1, y1, x2, y2);
this.#totalAngleStart = this.#rotationAngleStart > 180
? (360 * this.#revs + this.#rotationAngleStart) - 360
: 360 * this.#revs + this.#rotationAngleStart;
},
move: (touches) => {
if (touches.length !== 2) return null;
const [touch1, touch2] = touches;
const x1 = touch1.clientX;
const y1 = touch1.clientY;
const x2 = touch2.clientX;
const y2 = touch2.clientY;
const angleAbsolute = this.#getAngle(x1, y1, x2, y2);
this.#calculateRotation(angleAbsolute);
const data = {
touches: [
{ x: x1, y: y1 },
{ x: x2, y: y2 }
],
rotation: this.#angleRelative - this.#totalAngleStart,
angleAbsolute,
angleRelative: this.#angleRelative
};
this.#currentTouch = data;
return {
type: 'rotate',
data
};
},
end: () => { }
},
'pinch-spread': {
start: (touches) => {
if (touches.length !== 2) return;
const [touch1, touch2] = touches;
const x1 = touch1.clientX;
const y1 = touch1.clientY;
const x2 = touch2.clientX;
const y2 = touch2.clientY;
this.#distanceStart = this.#getDistance(x1, y1, x2, y2);
},
move: (touches) => {
if (touches.length !== 2 || !this.#watching['pinch-spread']) return null;
const [touch1, touch2] = touches;
const x1 = touch1.clientX;
const y1 = touch1.clientY;
const x2 = touch2.clientX;
const y2 = touch2.clientY;
const distance = this.#getDistance(x1, y1, x2, y2);
const scale = this.#getScale(this.#distanceStart, distance);
const data = {
touches: [
{ x: x1, y: y1 },
{ x: x2, y: y2 }
],
distance,
scale
};
this.#currentTouch = data;
return {
type: 'pinch-spread',
data
};
},
end: () => {
if (this.#gestureType !== 'pinch-spread' ||
!this.#moving ||
this.#touchHistory.size === 0) return null;
this.#currentTouch.end = true;
return {
type: 'pinch-spread',
data: this.#currentTouch
};
}
},
'double-tap-and-drag': {
start: (touches, timestamp) => {
if (touches.length !== 1) return;
if (
this.#tapReleaseTime &&
timestamp < this.#tapReleaseTime + CONSTANTS.DOUBLE_TAP_INTERVAL + CONSTANTS.PRESS_TIME
) {
this.#doubleTapDragActive = true;
const touch = touches[0];
this.#doubleTapDragStart = {
x: touch.clientX - this.#areaBox.left,
y: touch.clientY - this.#areaBox.top
};
} else {
this.#doubleTapDragActive = false;
}
},
move: (touches, timestamp) => {
if (!this.#doubleTapDragActive || touches.length !== 1) return null;
const touch = touches[0];
const x = touch.clientX - this.#areaBox.left;
const y = touch.clientY - this.#areaBox.top;
const dx = x - this.#doubleTapDragStart.x;
const dy = y - this.#doubleTapDragStart.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > CONSTANTS.DOUBLE_TAP_DRAG_THRESHOLD) {
this.#gestureType = 'double-tap-and-drag';
this.#currentTouch = {
x,
y,
startX: this.#doubleTapDragStart.x,
startY: this.#doubleTapDragStart.y,
dx,
dy,
dist
};
return {
type: 'double-tap-and-drag',
data: this.#currentTouch
};
}
return null;
},
end: () => {
this.#doubleTapDragActive = false;
this.#doubleTapDragStart = null;
}
},
};
constructor(element, settings = {}) {
this.#element = element;
this.#settings = settings;
if (element) this.on(element);
}
// Public API methods
on(element) {
this.#element = element;
element.addEventListener('touchstart', this.#detectGesture);
element.addEventListener('touchmove', this.#detectGesture);
element.addEventListener('touchend', this.#detectGesture);
ELEMENT_STATE.set(element, {});
}
off(element) {
element = element || this.#element;
element.removeEventListener('touchstart', this.#detectGesture);
element.removeEventListener('touchmove', this.#detectGesture);
element.removeEventListener('touchend', this.#detectGesture);
ELEMENT_STATE.delete(element);
}
track(gesture, callback, settings) {
this.#watching[gesture] = callback;
if (settings) {
if (settings.preventDefault === true) {
this.#preventDefaults[gesture] = true;
}
if (settings.preventDefault === 'horizontal') {
this.#preventDefaults[gesture] = 'horizontal';
}
if (settings.preventDefault === 'vertical') {
this.#preventDefaults[gesture] = 'vertical';
}
}
}
untrack(gesture) {
delete this.#watching[gesture];
}
// Private methods
#detectGesture = (e) => {
if (this.#settings?.preventDefault) e.preventDefault();
const { touches, type, timeStamp: timestamp } = e;
switch (type) {
case 'touchstart':
this.#handleTouchStart(touches, timestamp);
break;
case 'touchmove':
this.#handleTouchMove(touches, timestamp);
break;
case 'touchend':
this.#handleTouchEnd(touches, timestamp);
break;
}
this.#handlePreventDefault(e);
if (this.#settings?.visualize) visualize(touches);
};
#handleTouchStart(touches, timestamp) {
this.#startTime = timestamp;
this.#previousAngle = null;
this.#angleRelative = null;
this.#revs = 0;
this.#negativeRev = false;
this.#gestureType = null;
this.#createTouches(touches, timestamp);
// Run all gesture start handlers
const gestureValues = Object.values(this.gestures);
for (let i = 0; i < gestureValues.length; i++) {
const gesture = gestureValues[i];
if (gesture.start) {
const result = gesture.start(touches, timestamp);
if (result) {
this.#gestureType = result.type;
this.#executeCallback(result.type, [result.data, this.#touchHistory]);
}
}
}
}
#handleTouchMove(touches, timestamp) {
this.#moving = true;
this.#saveToHistory(touches, timestamp);
// Run all gesture move handlers
const gestureValues = Object.values(this.gestures);
for (let i = 0; i < gestureValues.length; i++) {
const gesture = gestureValues[i];
if (gesture.move) {
const result = gesture.move(touches, timestamp);
if (result) {
this.#gestureType = result.type;
this.#executeCallback(result.type, [result.data, this.#touchHistory]);
}
}
}
}
#handleTouchEnd(touches, timestamp) {
// Run all gesture end handlers
const gestureValues = Object.values(this.gestures);
for (let i = 0; i < gestureValues.length; i++) {
const gesture = gestureValues[i];
if (gesture.end) {
const result = gesture.end(touches, timestamp);
if (result) {
this.#gestureType = result.type;
this.#executeCallback(result.type, [result.data, this.#touchHistory]);
}
}
}
if (touches.length === 0) {
this.#touchHistory = new Map();
this.#touchSequence = [];
}
this.#moving = false;
this.#initialDirection = null;
}
#handlePreventDefault(e) {
if (this.#preventDefaults[this.#gestureType] === true) {
e.preventDefault();
return;
}
const angle = this.#currentTouch.angle;
if (angle == null || Number.isNaN(angle)) return;
if (this.#preventDefaults[this.#gestureType] === 'horizontal') {
if ((angle > 45 && angle < 135) || (angle > 225 && angle < 315)) {
e.preventDefault();
}
} else if (this.#preventDefaults[this.#gestureType] === 'vertical') {
if ((angle > 315 || angle < 45) || (angle > 135 && angle < 225)) {
e.preventDefault();
}
}
}
#createTouches(touches, timestamp) {
this.#areaBox = this.#element.getBoundingClientRect();
for (const touch of touches) {
const id = touch.identifier;
if (!this.#touchHistory.has(id)) {
const startX = touch.clientX - this.#areaBox.left;
const startY = touch.clientY - this.#areaBox.top;
this.#startX = startX;
this.#startY = startY;
this.#touchHistory.set(id, {
x: [startX],
y: [startY],
t: [timestamp]
});
this.#touchSequence.push(id); // <── NEW
}
}
}
#saveToHistory(touches, timestamp) {
if (this.#touchHistory.size === 0) return;
for (const touch of touches) {
const history = this.#touchHistory.get(touch.identifier);
if (history) {
history.x.push(touch.clientX - this.#areaBox.left);
history.y.push(touch.clientY - this.#areaBox.top);
history.t.push(timestamp);
}
}
}
#getStepSpeed() {
let x_delta = 0;
const length = this.#touchSequence.length;
for (let i = 0; i < length; i++) {
const id = this.#touchSequence[i];
const history = this.#touchHistory.get(id);
if (history && history.x.length > 1) {
const xs = history.x;
x_delta = Math.abs(xs[xs.length - 1] - xs[xs.length - 2]);
break; // Exit after first valid touch for step speed
}
}
return x_delta;
}
#getSpeed() {
const firstId = this.#touchSequence[0]; // earliest-started finger
const history = this.#touchHistory.get(firstId);
if (!history?.x?.length || !history?.y?.length || !history?.t?.length) return 0;
const n = Math.min(5, history.x.length);
const xs = history.x.slice(-n);
const ys = history.y.slice(-n);
const ts = history.t.slice(-n);
const time = ts[ts.length - 1] - ts[0];
if (time === 0) return 0;
const dist = this.#getDistance(xs[0], ys[0], xs[xs.length - 1], ys[ys.length - 1]);
return dist / time;
}
#getDistance(x1, y1, x2, y2) {
return Math.hypot(x2 - x1, y2 - y1);
}
#getAveragePosition(touches) {
const len = touches.length;
let sumX = 0, sumY = 0;
for (let i = 0; i < len; i++) {
sumX += touches[i].clientX - this.#areaBox.left;
sumY += touches[i].clientY - this.#areaBox.top;
}
const x = sumX / len;
const y = sumY / len;
return {
x,
y,
touchesArr: Array.from(touches).map(t => ({
x: t.clientX - this.#areaBox.left,
y: t.clientY - this.#areaBox.top
}))
};
}
#getScale(distanceStart, distance) {
return distance / distanceStart;
}
#getAngle(prevX, prevY, currX, currY) {
const dX = currX - prevX;
const dY = currY - prevY;
const radians = Math.atan2(dY, dX);
let angle = radians * 180 / Math.PI + 90;
if (angle < 0) angle += 360;
if (angle > 360) angle -= 360;
return angle;
}
#getDirection(prevX, prevY, x, y) {
const angle = this.#getAngle(prevX, prevY, x, y);
if (angle >= 315 || angle < 45) return 'top';
if (angle >= 45 && angle < 135) return 'right';
if (angle >= 135 && angle < 225) return 'bottom';
if (angle >= 225 && angle < 315) return 'left';
}
#calculateRotation(angleAbsolute) {
if (this.#previousAngle === null) {
this.#previousAngle = angleAbsolute;
return;
}
if (angleAbsolute - this.#previousAngle <= -180) {
if (this.#negativeRev) {
this.#revs = 0;
this.#negativeRev = false;
} else {
this.#revs++;
}
} else if (angleAbsolute - this.#previousAngle >= 180) {
if (this.#revs === 0 && !this.#negativeRev) {
this.#negativeRev = true;
} else {
this.#revs--;
}
}
this.#angleRelative = (this.#negativeRev || this.#revs < 0)
? (360 * this.#revs + angleAbsolute) - 360
: 360 * this.#revs + angleAbsolute;
this.#previousAngle = angleAbsolute;
}
#getPreviousCoordinates(touches, defaultX, defaultY) {
// Default to start coordinates if no better option
let prevX = defaultX;
let prevY = defaultY;
if (touches.length > 1) { // If we have multiple touches
let sumPrevX = 0, sumPrevY = 0;
let count = 0;
// Check if we have enough history (5 points)
for (const touch of touches) {
const h = this.#touchHistory.get(touch.identifier);
if (h && h.x.length >= 5 && h.y.length >= 5) {
sumPrevX += h.x[h.x.length - 5];
sumPrevY += h.y[h.y.length - 5];
count++;
}
}
// If we have multiple touches with enough history, use their average
if (count > 0) {
prevX = sumPrevX / count;
prevY = sumPrevY / count;
return { prevX, prevY };
}
// If not enough history, use earliest records
sumPrevX = 0;
sumPrevY = 0;
count = 0;
for (const touch of touches) {
const h = this.#touchHistory.get(touch.identifier);
if (h && h.x.length > 0 && h.y.length > 0) {
sumPrevX += h.x[0];
sumPrevY += h.y[0];
count++;
}
}
if (count > 0) {
prevX = sumPrevX / count;
prevY = sumPrevY / count;
}
} else if (touches.length === 1) { // If we have a single touch
const touchId = touches[0].identifier;
const history = this.#touchHistory.get(touchId);
if (history && history.x.length > 1 && history.y.length > 1) {
if (history.x.length >= 5) {
prevX = history.x[history.x.length - 5];
prevY = history.y[history.y.length - 5];
} else {
// If not enough history, use earliest record
prevX = history.x[0];
prevY = history.y[0];
}
}
}
return { prevX, prevY };
}
#executeCallback(gestureType, params) {
if (this.#touchHistory.size > 0 && this.#watching[gestureType]) {
this.#watching[gestureType].apply(this, params);
}
}
}
// Add default export for easier importing
export default TheFinger;