// retoor <retoor@molodetz.nl>
export class GestureHandler {
constructor(element, options = {}) {
this.element = element;
this.options = {
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
longPressDelay: 500,
pullToRefreshThreshold: 80,
edgeSwipeWidth: 20,
...options
};
this.touchStartX = 0;
this.touchStartY = 0;
this.touchStartTime = 0;
this.longPressTimer = null;
this.isPulling = false;
this.pullDistance = 0;
this.isLongPress = false;
this.callbacks = {
swipeLeft: [],
swipeRight: [],
swipeUp: [],
swipeDown: [],
longPress: [],
pullToRefresh: [],
edgeSwipeRight: []
};
this.boundHandlers = {
touchStart: this.handleTouchStart.bind(this),
touchMove: this.handleTouchMove.bind(this),
touchEnd: this.handleTouchEnd.bind(this),
touchCancel: this.handleTouchCancel.bind(this)
};
this.attach();
}
attach() {
this.element.addEventListener('touchstart', this.boundHandlers.touchStart, { passive: false });
this.element.addEventListener('touchmove', this.boundHandlers.touchMove, { passive: false });
this.element.addEventListener('touchend', this.boundHandlers.touchEnd, { passive: true });
this.element.addEventListener('touchcancel', this.boundHandlers.touchCancel, { passive: true });
}
destroy() {
this.element.removeEventListener('touchstart', this.boundHandlers.touchStart);
this.element.removeEventListener('touchmove', this.boundHandlers.touchMove);
this.element.removeEventListener('touchend', this.boundHandlers.touchEnd);
this.element.removeEventListener('touchcancel', this.boundHandlers.touchCancel);
this.clearLongPressTimer();
}
handleTouchStart(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.touchStartTime = Date.now();
this.isLongPress = false;
this.startLongPressTimer(e);
if (this.touchStartX <= this.options.edgeSwipeWidth) {
this.isEdgeSwipe = true;
} else {
this.isEdgeSwipe = false;
}
const scrollTop = this.element.scrollTop || 0;
if (scrollTop <= 0 && this.callbacks.pullToRefresh.length > 0) {
this.isPulling = true;
this.pullDistance = 0;
}
}
handleTouchMove(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaX = touch.clientX - this.touchStartX;
const deltaY = touch.clientY - this.touchStartY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance > 10) {
this.clearLongPressTimer();
}
if (this.isPulling && deltaY > 0) {
this.pullDistance = Math.min(deltaY, this.options.pullToRefreshThreshold * 1.5);
if (this.pullDistance > 0) {
e.preventDefault();
this.element.dispatchEvent(new CustomEvent('pull-progress', {
detail: {
progress: Math.min(this.pullDistance / this.options.pullToRefreshThreshold, 1),
distance: this.pullDistance
}
}));
}
}
}
handleTouchEnd(e) {
this.clearLongPressTimer();
if (this.isLongPress) {
this.isLongPress = false;
return;
}
const touch = e.changedTouches[0];
const deltaX = touch.clientX - this.touchStartX;
const deltaY = touch.clientY - this.touchStartY;
const deltaTime = Date.now() - this.touchStartTime;
const velocity = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / deltaTime;
if (this.isPulling && this.pullDistance >= this.options.pullToRefreshThreshold) {
this.emit('pullToRefresh');
}
this.isPulling = false;
this.pullDistance = 0;
this.element.dispatchEvent(new CustomEvent('pull-end'));
if (Math.abs(deltaX) < this.options.swipeThreshold &&
Math.abs(deltaY) < this.options.swipeThreshold) {
return;
}
if (velocity < this.options.swipeVelocityThreshold && deltaTime > 300) {
return;
}
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY);
if (isHorizontal) {
if (deltaX > this.options.swipeThreshold) {
if (this.isEdgeSwipe) {
this.emit('edgeSwipeRight');
} else {
this.emit('swipeRight');
}
} else if (deltaX < -this.options.swipeThreshold) {
this.emit('swipeLeft');
}
} else {
if (deltaY > this.options.swipeThreshold) {
this.emit('swipeDown');
} else if (deltaY < -this.options.swipeThreshold) {
this.emit('swipeUp');
}
}
}
handleTouchCancel() {
this.clearLongPressTimer();
this.isPulling = false;
this.pullDistance = 0;
this.isLongPress = false;
this.element.dispatchEvent(new CustomEvent('pull-end'));
}
startLongPressTimer(e) {
this.clearLongPressTimer();
if (this.callbacks.longPress.length === 0) return;
const touch = e.touches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY);
this.longPressTimer = setTimeout(() => {
this.isLongPress = true;
this.emit('longPress', {
x: touch.clientX,
y: touch.clientY,
target: target
});
if (navigator.vibrate) {
navigator.vibrate(50);
}
}, this.options.longPressDelay);
}
clearLongPressTimer() {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
on(event, callback) {
if (this.callbacks[event]) {
this.callbacks[event].push(callback);
}
return this;
}
off(event, callback) {
if (this.callbacks[event]) {
const index = this.callbacks[event].indexOf(callback);
if (index !== -1) {
this.callbacks[event].splice(index, 1);
}
}
return this;
}
emit(event, data = {}) {
if (this.callbacks[event]) {
this.callbacks[event].forEach(callback => callback(data));
}
}
}
export class PullToRefreshIndicator {
constructor(container) {
this.container = container;
this.indicator = null;
this.create();
}
create() {
this.indicator = document.createElement('div');
this.indicator.className = 'pull-to-refresh-indicator';
this.indicator.innerHTML = `
<div class="pull-spinner"></div>
<span class="pull-text">Pull to refresh</span>
`;
this.container.insertBefore(this.indicator, this.container.firstChild);
}
setProgress(progress) {
const height = Math.min(progress * 60, 60);
this.indicator.style.height = `${height}px`;
this.indicator.style.opacity = progress;
if (progress >= 1) {
this.indicator.querySelector('.pull-text').textContent = 'Release to refresh';
this.indicator.classList.add('ready');
} else {
this.indicator.querySelector('.pull-text').textContent = 'Pull to refresh';
this.indicator.classList.remove('ready');
}
}
showRefreshing() {
this.indicator.style.height = '60px';
this.indicator.style.opacity = 1;
this.indicator.querySelector('.pull-text').textContent = 'Refreshing...';
this.indicator.classList.add('refreshing');
}
hide() {
this.indicator.style.height = '0';
this.indicator.style.opacity = 0;
this.indicator.classList.remove('ready', 'refreshing');
}
destroy() {
if (this.indicator && this.indicator.parentNode) {
this.indicator.parentNode.removeChild(this.indicator);
}
}
}
export class ContextMenu {
constructor() {
this.menu = null;
this.isVisible = false;
this.boundClose = this.close.bind(this);
}
show(x, y, items) {
this.close();
this.menu = document.createElement('div');
this.menu.className = 'context-menu';
items.forEach(item => {
if (item.separator) {
const sep = document.createElement('div');
sep.className = 'context-menu-separator';
this.menu.appendChild(sep);
return;
}
const menuItem = document.createElement('button');
menuItem.className = 'context-menu-item';
if (item.destructive) {
menuItem.classList.add('destructive');
}
menuItem.innerHTML = `
${item.icon ? `<span class="context-menu-icon">${item.icon}</span>` : ''}
<span class="context-menu-label">${item.label}</span>
`;
menuItem.addEventListener('click', () => {
item.action();
this.close();
});
this.menu.appendChild(menuItem);
});
document.body.appendChild(this.menu);
const rect = this.menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let finalX = x;
let finalY = y;
if (x + rect.width > viewportWidth) {
finalX = viewportWidth - rect.width - 10;
}
if (y + rect.height > viewportHeight) {
finalY = viewportHeight - rect.height - 10;
}
this.menu.style.left = `${Math.max(10, finalX)}px`;
this.menu.style.top = `${Math.max(10, finalY)}px`;
this.isVisible = true;
requestAnimationFrame(() => {
this.menu.classList.add('visible');
});
setTimeout(() => {
document.addEventListener('touchstart', this.boundClose);
document.addEventListener('click', this.boundClose);
}, 100);
}
close() {
if (this.menu) {
this.menu.classList.remove('visible');
setTimeout(() => {
if (this.menu && this.menu.parentNode) {
this.menu.parentNode.removeChild(this.menu);
}
this.menu = null;
}, 200);
}
this.isVisible = false;
document.removeEventListener('touchstart', this.boundClose);
document.removeEventListener('click', this.boundClose);
}
}
export function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
export function isMobile() {
return window.innerWidth < 768;
}