generated from corrad-software/corrad-af-2024
449 lines
12 KiB
JavaScript
449 lines
12 KiB
JavaScript
// Touch Interactions Composable for Mobile DMS
|
|
// Provides swipe gestures, touch controls, and mobile enhancements
|
|
|
|
import { ref, onMounted, onUnmounted, nextTick, readonly } from 'vue';
|
|
|
|
export const useTouchInteractions = (options = {}) => {
|
|
// Default configuration
|
|
const defaultOptions = {
|
|
threshold: 50, // Minimum distance for swipe
|
|
velocity: 0.3, // Minimum velocity for swipe
|
|
timeThreshold: 500, // Maximum time for swipe
|
|
preventScroll: false, // Prevent scroll during swipe
|
|
enablePinch: false, // Enable pinch-to-zoom
|
|
enableRotation: false, // Enable rotation
|
|
...options
|
|
};
|
|
|
|
// Touch state
|
|
const touchState = ref({
|
|
isSupported: false,
|
|
isSwiping: false,
|
|
startX: 0,
|
|
startY: 0,
|
|
currentX: 0,
|
|
currentY: 0,
|
|
startTime: 0,
|
|
direction: null,
|
|
distance: 0,
|
|
velocity: 0
|
|
});
|
|
|
|
// Multi-touch state
|
|
const multiTouchState = ref({
|
|
isMultiTouch: false,
|
|
initialDistance: 0,
|
|
currentDistance: 0,
|
|
scale: 1,
|
|
rotation: 0
|
|
});
|
|
|
|
// Gesture detection
|
|
const gestures = ref({
|
|
swipeLeft: false,
|
|
swipeRight: false,
|
|
swipeUp: false,
|
|
swipeDown: false,
|
|
pinch: false,
|
|
spread: false,
|
|
rotate: false
|
|
});
|
|
|
|
// Event callbacks
|
|
const callbacks = ref({
|
|
onSwipeStart: null,
|
|
onSwipeMove: null,
|
|
onSwipeEnd: null,
|
|
onSwipeLeft: null,
|
|
onSwipeRight: null,
|
|
onSwipeUp: null,
|
|
onSwipeDown: null,
|
|
onPinchStart: null,
|
|
onPinchMove: null,
|
|
onPinchEnd: null,
|
|
onRotateStart: null,
|
|
onRotateMove: null,
|
|
onRotateEnd: null,
|
|
onTap: null,
|
|
onDoubleTap: null,
|
|
onLongPress: null
|
|
});
|
|
|
|
// Utility functions
|
|
const getDistance = (touches) => {
|
|
if (touches.length < 2) return 0;
|
|
const dx = touches[0].clientX - touches[1].clientX;
|
|
const dy = touches[0].clientY - touches[1].clientY;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
};
|
|
|
|
const getAngle = (touches) => {
|
|
if (touches.length < 2) return 0;
|
|
const dx = touches[1].clientX - touches[0].clientX;
|
|
const dy = touches[1].clientY - touches[0].clientY;
|
|
return Math.atan2(dy, dx) * 180 / Math.PI;
|
|
};
|
|
|
|
const getDirection = (startX, startY, endX, endY) => {
|
|
const dx = endX - startX;
|
|
const dy = endY - startY;
|
|
|
|
if (Math.abs(dx) > Math.abs(dy)) {
|
|
return dx > 0 ? 'right' : 'left';
|
|
} else {
|
|
return dy > 0 ? 'down' : 'up';
|
|
}
|
|
};
|
|
|
|
const getVelocity = (distance, time) => {
|
|
return time > 0 ? distance / time : 0;
|
|
};
|
|
|
|
// Touch event handlers
|
|
const handleTouchStart = (event) => {
|
|
const touch = event.touches[0];
|
|
const now = Date.now();
|
|
|
|
touchState.value = {
|
|
...touchState.value,
|
|
isSwiping: true,
|
|
startX: touch.clientX,
|
|
startY: touch.clientY,
|
|
currentX: touch.clientX,
|
|
currentY: touch.clientY,
|
|
startTime: now,
|
|
direction: null,
|
|
distance: 0,
|
|
velocity: 0
|
|
};
|
|
|
|
// Multi-touch handling
|
|
if (event.touches.length > 1 && defaultOptions.enablePinch) {
|
|
multiTouchState.value = {
|
|
isMultiTouch: true,
|
|
initialDistance: getDistance(event.touches),
|
|
currentDistance: getDistance(event.touches),
|
|
scale: 1,
|
|
rotation: getAngle(event.touches)
|
|
};
|
|
|
|
callbacks.value.onPinchStart?.(multiTouchState.value);
|
|
}
|
|
|
|
callbacks.value.onSwipeStart?.(touchState.value);
|
|
|
|
if (defaultOptions.preventScroll) {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (event) => {
|
|
if (!touchState.value.isSwiping) return;
|
|
|
|
const touch = event.touches[0];
|
|
const currentX = touch.clientX;
|
|
const currentY = touch.clientY;
|
|
|
|
const deltaX = currentX - touchState.value.startX;
|
|
const deltaY = currentY - touchState.value.startY;
|
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
|
|
touchState.value = {
|
|
...touchState.value,
|
|
currentX,
|
|
currentY,
|
|
distance,
|
|
direction: getDirection(
|
|
touchState.value.startX,
|
|
touchState.value.startY,
|
|
currentX,
|
|
currentY
|
|
)
|
|
};
|
|
|
|
// Multi-touch handling
|
|
if (event.touches.length > 1 && multiTouchState.value.isMultiTouch) {
|
|
const currentDistance = getDistance(event.touches);
|
|
const currentRotation = getAngle(event.touches);
|
|
|
|
multiTouchState.value = {
|
|
...multiTouchState.value,
|
|
currentDistance,
|
|
scale: currentDistance / multiTouchState.value.initialDistance,
|
|
rotation: currentRotation - multiTouchState.value.rotation
|
|
};
|
|
|
|
callbacks.value.onPinchMove?.(multiTouchState.value);
|
|
}
|
|
|
|
callbacks.value.onSwipeMove?.(touchState.value);
|
|
|
|
if (defaultOptions.preventScroll) {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = (event) => {
|
|
if (!touchState.value.isSwiping) return;
|
|
|
|
const endTime = Date.now();
|
|
const duration = endTime - touchState.value.startTime;
|
|
const velocity = getVelocity(touchState.value.distance, duration);
|
|
|
|
touchState.value = {
|
|
...touchState.value,
|
|
isSwiping: false,
|
|
velocity
|
|
};
|
|
|
|
// Determine if it's a valid swipe
|
|
const isValidSwipe =
|
|
touchState.value.distance >= defaultOptions.threshold &&
|
|
velocity >= defaultOptions.velocity &&
|
|
duration <= defaultOptions.timeThreshold;
|
|
|
|
if (isValidSwipe) {
|
|
// Reset gesture state
|
|
Object.keys(gestures.value).forEach(key => {
|
|
gestures.value[key] = false;
|
|
});
|
|
|
|
// Set current gesture
|
|
switch (touchState.value.direction) {
|
|
case 'left':
|
|
gestures.value.swipeLeft = true;
|
|
callbacks.value.onSwipeLeft?.(touchState.value);
|
|
break;
|
|
case 'right':
|
|
gestures.value.swipeRight = true;
|
|
callbacks.value.onSwipeRight?.(touchState.value);
|
|
break;
|
|
case 'up':
|
|
gestures.value.swipeUp = true;
|
|
callbacks.value.onSwipeUp?.(touchState.value);
|
|
break;
|
|
case 'down':
|
|
gestures.value.swipeDown = true;
|
|
callbacks.value.onSwipeDown?.(touchState.value);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Multi-touch end
|
|
if (multiTouchState.value.isMultiTouch) {
|
|
callbacks.value.onPinchEnd?.(multiTouchState.value);
|
|
multiTouchState.value.isMultiTouch = false;
|
|
}
|
|
|
|
callbacks.value.onSwipeEnd?.(touchState.value);
|
|
};
|
|
|
|
// Tap detection
|
|
let tapTimeout = null;
|
|
let tapCount = 0;
|
|
let longPressTimeout = null;
|
|
|
|
const handleTap = (event) => {
|
|
const touch = event.touches[0] || event.changedTouches[0];
|
|
|
|
// Long press detection
|
|
longPressTimeout = setTimeout(() => {
|
|
callbacks.value.onLongPress?.({
|
|
x: touch.clientX,
|
|
y: touch.clientY,
|
|
target: event.target
|
|
});
|
|
}, 500);
|
|
|
|
// Double tap detection
|
|
tapCount++;
|
|
|
|
if (tapCount === 1) {
|
|
tapTimeout = setTimeout(() => {
|
|
if (tapCount === 1) {
|
|
callbacks.value.onTap?.({
|
|
x: touch.clientX,
|
|
y: touch.clientY,
|
|
target: event.target
|
|
});
|
|
}
|
|
tapCount = 0;
|
|
}, 300);
|
|
} else if (tapCount === 2) {
|
|
clearTimeout(tapTimeout);
|
|
clearTimeout(longPressTimeout);
|
|
callbacks.value.onDoubleTap?.({
|
|
x: touch.clientX,
|
|
y: touch.clientY,
|
|
target: event.target
|
|
});
|
|
tapCount = 0;
|
|
}
|
|
};
|
|
|
|
const handleTouchCancel = () => {
|
|
touchState.value.isSwiping = false;
|
|
multiTouchState.value.isMultiTouch = false;
|
|
clearTimeout(tapTimeout);
|
|
clearTimeout(longPressTimeout);
|
|
};
|
|
|
|
// Setup touch interactions
|
|
const setupTouchInteractions = (element) => {
|
|
if (!element) return;
|
|
|
|
// Check for touch support
|
|
touchState.value.isSupported = 'ontouchstart' in window;
|
|
|
|
if (touchState.value.isSupported) {
|
|
element.addEventListener('touchstart', handleTouchStart, { passive: !defaultOptions.preventScroll });
|
|
element.addEventListener('touchmove', handleTouchMove, { passive: !defaultOptions.preventScroll });
|
|
element.addEventListener('touchend', handleTouchEnd, { passive: true });
|
|
element.addEventListener('touchcancel', handleTouchCancel, { passive: true });
|
|
|
|
// Tap events
|
|
element.addEventListener('touchstart', handleTap, { passive: true });
|
|
}
|
|
|
|
return () => {
|
|
if (element && touchState.value.isSupported) {
|
|
element.removeEventListener('touchstart', handleTouchStart);
|
|
element.removeEventListener('touchmove', handleTouchMove);
|
|
element.removeEventListener('touchend', handleTouchEnd);
|
|
element.removeEventListener('touchcancel', handleTouchCancel);
|
|
element.removeEventListener('touchstart', handleTap);
|
|
}
|
|
};
|
|
};
|
|
|
|
// Register callback functions
|
|
const onSwipeStart = (callback) => { callbacks.value.onSwipeStart = callback; };
|
|
const onSwipeMove = (callback) => { callbacks.value.onSwipeMove = callback; };
|
|
const onSwipeEnd = (callback) => { callbacks.value.onSwipeEnd = callback; };
|
|
const onSwipeLeft = (callback) => { callbacks.value.onSwipeLeft = callback; };
|
|
const onSwipeRight = (callback) => { callbacks.value.onSwipeRight = callback; };
|
|
const onSwipeUp = (callback) => { callbacks.value.onSwipeUp = callback; };
|
|
const onSwipeDown = (callback) => { callbacks.value.onSwipeDown = callback; };
|
|
const onPinchStart = (callback) => { callbacks.value.onPinchStart = callback; };
|
|
const onPinchMove = (callback) => { callbacks.value.onPinchMove = callback; };
|
|
const onPinchEnd = (callback) => { callbacks.value.onPinchEnd = callback; };
|
|
const onTap = (callback) => { callbacks.value.onTap = callback; };
|
|
const onDoubleTap = (callback) => { callbacks.value.onDoubleTap = callback; };
|
|
const onLongPress = (callback) => { callbacks.value.onLongPress = callback; };
|
|
|
|
// DMS-specific touch interactions
|
|
const dmsInteractions = {
|
|
// Swipe to delete
|
|
setupSwipeToDelete: (element, onDelete) => {
|
|
const cleanup = setupTouchInteractions(element);
|
|
onSwipeLeft(() => {
|
|
element.classList.add('swipe-delete-active');
|
|
onDelete?.();
|
|
});
|
|
return cleanup;
|
|
},
|
|
|
|
// Pull to refresh
|
|
setupPullToRefresh: (element, onRefresh) => {
|
|
let refreshTriggered = false;
|
|
const cleanup = setupTouchInteractions(element);
|
|
|
|
onSwipeDown((state) => {
|
|
if (state.distance > 100 && !refreshTriggered) {
|
|
refreshTriggered = true;
|
|
onRefresh?.();
|
|
setTimeout(() => {
|
|
refreshTriggered = false;
|
|
}, 2000);
|
|
}
|
|
});
|
|
|
|
return cleanup;
|
|
},
|
|
|
|
// Pinch to zoom for document preview
|
|
setupPinchZoom: (element, onZoom) => {
|
|
const cleanup = setupTouchInteractions(element);
|
|
onPinchMove((state) => {
|
|
onZoom?.(state.scale);
|
|
});
|
|
return cleanup;
|
|
},
|
|
|
|
// Long press for context menu
|
|
setupContextMenu: (element, onContext) => {
|
|
const cleanup = setupTouchInteractions(element);
|
|
onLongPress((state) => {
|
|
onContext?.(state);
|
|
});
|
|
return cleanup;
|
|
}
|
|
};
|
|
|
|
// Haptic feedback (if supported)
|
|
const hapticFeedback = {
|
|
light: () => {
|
|
if (navigator.vibrate) {
|
|
navigator.vibrate(10);
|
|
}
|
|
},
|
|
|
|
medium: () => {
|
|
if (navigator.vibrate) {
|
|
navigator.vibrate(20);
|
|
}
|
|
},
|
|
|
|
heavy: () => {
|
|
if (navigator.vibrate) {
|
|
navigator.vibrate([30, 10, 30]);
|
|
}
|
|
},
|
|
|
|
success: () => {
|
|
if (navigator.vibrate) {
|
|
navigator.vibrate([10, 5, 10]);
|
|
}
|
|
},
|
|
|
|
error: () => {
|
|
if (navigator.vibrate) {
|
|
navigator.vibrate([50, 25, 50]);
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
// State
|
|
touchState: readonly(touchState),
|
|
multiTouchState: readonly(multiTouchState),
|
|
gestures: readonly(gestures),
|
|
|
|
// Setup
|
|
setupTouchInteractions,
|
|
|
|
// Event registration
|
|
onSwipeStart,
|
|
onSwipeMove,
|
|
onSwipeEnd,
|
|
onSwipeLeft,
|
|
onSwipeRight,
|
|
onSwipeUp,
|
|
onSwipeDown,
|
|
onPinchStart,
|
|
onPinchMove,
|
|
onPinchEnd,
|
|
onTap,
|
|
onDoubleTap,
|
|
onLongPress,
|
|
|
|
// DMS-specific
|
|
dmsInteractions,
|
|
|
|
// Utilities
|
|
hapticFeedback,
|
|
|
|
// Configuration
|
|
options: defaultOptions
|
|
};
|
|
};
|