// retoor 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 = `
Pull to refresh `; 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 ? `${item.icon}` : ''} ${item.label} `; 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; }