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