circuit-bricks
Version:
A modular, Lego-style SVG circuit component system for React (ALPHA - Not for production use)
340 lines (338 loc) • 9.98 kB
JavaScript
"use client";
import { isBrowser } from "./ssrUtils-E6vWOUns.mjs";
import { useCallback, useEffect, useRef, useState } from "react";
//#region src/utils/touchUtils.ts
/**
* Default touch configuration
*/
const defaultTouchConfig = {
tapThreshold: 10,
longPressDelay: 500,
doubleTapDelay: 300,
swipeThreshold: 50,
swipeVelocityThreshold: .5,
pinchThreshold: .1,
preventDefaultOnTouch: true
};
/**
* Calculate distance between two points
*/
const getDistance = (p1, p2) => {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
return Math.sqrt(dx * dx + dy * dy);
};
/**
* Calculate distance between two touches
*/
const getTouchDistance = (touch1, touch2) => {
return getDistance({
x: touch1.clientX,
y: touch1.clientY
}, {
x: touch2.clientX,
y: touch2.clientY
});
};
/**
* Get touch point from touch event
*/
const getTouchPoint = (touch) => ({
x: touch.clientX,
y: touch.clientY
});
/**
* Hook for handling touch gestures
*/
const useTouchGestures = (onGesture, config = {}) => {
const touchConfig = {
...defaultTouchConfig,
...config
};
const stateRef = useRef({
isActive: false,
startTime: 0,
startPoint: {
x: 0,
y: 0
},
currentPoint: {
x: 0,
y: 0
},
lastTapTime: 0,
touches: [],
initialScale: 1
});
const longPressTimerRef = useRef(void 0);
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = void 0;
}
}, []);
const handleTouchStart = useCallback((event) => {
if (touchConfig.preventDefaultOnTouch) event.preventDefault();
const touch = event.touches[0];
const currentTime = Date.now();
const touchPoint = getTouchPoint(touch);
stateRef.current = {
isActive: true,
startTime: currentTime,
startPoint: touchPoint,
currentPoint: touchPoint,
lastTapTime: stateRef.current.lastTapTime,
touches: Array.from(event.touches),
initialScale: 1
};
longPressTimerRef.current = setTimeout(() => {
if (stateRef.current.isActive) {
const distance = getDistance(stateRef.current.startPoint, stateRef.current.currentPoint);
if (distance < touchConfig.tapThreshold) onGesture({
type: "long-press",
startPoint: stateRef.current.startPoint,
currentPoint: stateRef.current.currentPoint,
deltaX: 0,
deltaY: 0,
distance: 0,
duration: currentTime - stateRef.current.startTime
});
}
}, touchConfig.longPressDelay);
if (event.touches.length === 2) stateRef.current.initialDistance = getTouchDistance(event.touches[0], event.touches[1]);
}, [onGesture, touchConfig]);
const handleTouchMove = useCallback((event) => {
if (!stateRef.current.isActive) return;
if (touchConfig.preventDefaultOnTouch) event.preventDefault();
const touch = event.touches[0];
const currentPoint = getTouchPoint(touch);
const currentTime = Date.now();
stateRef.current.currentPoint = currentPoint;
stateRef.current.touches = Array.from(event.touches);
const deltaX = currentPoint.x - stateRef.current.startPoint.x;
const deltaY = currentPoint.y - stateRef.current.startPoint.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const duration = currentTime - stateRef.current.startTime;
if (distance > touchConfig.tapThreshold) clearLongPressTimer();
if (event.touches.length === 2 && stateRef.current.initialDistance) {
const currentDistance = getTouchDistance(event.touches[0], event.touches[1]);
const scale = currentDistance / stateRef.current.initialDistance;
if (Math.abs(scale - stateRef.current.initialScale) > touchConfig.pinchThreshold) {
onGesture({
type: "pinch",
startPoint: stateRef.current.startPoint,
currentPoint,
deltaX,
deltaY,
distance,
scale,
duration
});
stateRef.current.initialScale = scale;
}
} else if (event.touches.length === 1 && distance > touchConfig.tapThreshold) {
const velocity = {
x: deltaX / Math.max(duration, 1),
y: deltaY / Math.max(duration, 1)
};
onGesture({
type: "pan",
startPoint: stateRef.current.startPoint,
currentPoint,
deltaX,
deltaY,
distance,
velocity,
duration
});
}
}, [
onGesture,
touchConfig,
clearLongPressTimer
]);
const handleTouchEnd = useCallback((event) => {
if (!stateRef.current.isActive) return;
if (touchConfig.preventDefaultOnTouch) event.preventDefault();
clearLongPressTimer();
const currentTime = Date.now();
const deltaX = stateRef.current.currentPoint.x - stateRef.current.startPoint.x;
const deltaY = stateRef.current.currentPoint.y - stateRef.current.startPoint.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const duration = currentTime - stateRef.current.startTime;
if (distance < touchConfig.tapThreshold && duration < touchConfig.longPressDelay) {
const timeSinceLastTap = currentTime - stateRef.current.lastTapTime;
if (timeSinceLastTap < touchConfig.doubleTapDelay) {
onGesture({
type: "double-tap",
startPoint: stateRef.current.startPoint,
currentPoint: stateRef.current.currentPoint,
deltaX: 0,
deltaY: 0,
distance: 0,
duration
});
stateRef.current.lastTapTime = 0;
} else {
onGesture({
type: "tap",
startPoint: stateRef.current.startPoint,
currentPoint: stateRef.current.currentPoint,
deltaX: 0,
deltaY: 0,
distance: 0,
duration
});
stateRef.current.lastTapTime = currentTime;
}
} else if (distance > touchConfig.swipeThreshold) {
const velocity = {
x: deltaX / Math.max(duration, 1),
y: deltaY / Math.max(duration, 1)
};
const velocityMagnitude = Math.sqrt(velocity.x * velocity.x + velocity.y * velocity.y);
if (velocityMagnitude > touchConfig.swipeVelocityThreshold) onGesture({
type: "swipe",
startPoint: stateRef.current.startPoint,
currentPoint: stateRef.current.currentPoint,
deltaX,
deltaY,
distance,
velocity,
duration
});
}
stateRef.current.isActive = false;
}, [
onGesture,
touchConfig,
clearLongPressTimer
]);
const touchHandlers = {
onTouchStart: handleTouchStart,
onTouchMove: handleTouchMove,
onTouchEnd: handleTouchEnd,
onTouchCancel: handleTouchEnd
};
return touchHandlers;
};
/**
* Hook for detecting device orientation
*/
const useDeviceOrientation = () => {
const [orientation, setOrientation] = useState("portrait");
useEffect(() => {
if (!isBrowser()) return;
const updateOrientation = () => {
setOrientation(window.innerHeight > window.innerWidth ? "portrait" : "landscape");
};
updateOrientation();
window.addEventListener("resize", updateOrientation);
window.addEventListener("orientationchange", updateOrientation);
return () => {
window.removeEventListener("resize", updateOrientation);
window.removeEventListener("orientationchange", updateOrientation);
};
}, []);
return orientation;
};
/**
* Hook for safe area insets (for devices with notches)
*/
const useSafeAreaInsets = () => {
const [insets, setInsets] = useState({
top: 0,
right: 0,
bottom: 0,
left: 0
});
useEffect(() => {
if (!isBrowser()) return;
const updateInsets = () => {
const computedStyle = getComputedStyle(document.documentElement);
setInsets({
top: parseInt(computedStyle.getPropertyValue("env(safe-area-inset-top)") || "0"),
right: parseInt(computedStyle.getPropertyValue("env(safe-area-inset-right)") || "0"),
bottom: parseInt(computedStyle.getPropertyValue("env(safe-area-inset-bottom)") || "0"),
left: parseInt(computedStyle.getPropertyValue("env(safe-area-inset-left)") || "0")
});
};
updateInsets();
window.addEventListener("resize", updateInsets);
return () => {
window.removeEventListener("resize", updateInsets);
};
}, []);
return insets;
};
/**
* Prevent zoom on double tap (iOS Safari)
*/
const usePreventZoom = (enabled = true) => {
useEffect(() => {
if (!isBrowser() || !enabled) return;
const preventDefault = (e) => {
if (e.touches.length > 1) e.preventDefault();
};
const preventZoom = (e) => {
e.preventDefault();
};
document.addEventListener("touchstart", preventDefault, { passive: false });
document.addEventListener("gesturestart", preventZoom, { passive: false });
return () => {
document.removeEventListener("touchstart", preventDefault);
document.removeEventListener("gesturestart", preventZoom);
};
}, [enabled]);
};
/**
* SSR-safe user agent detection (internal)
*/
const useUserAgentInternal = () => {
const [userAgent, setUserAgent] = useState("");
useEffect(() => {
if (isBrowser()) setUserAgent(navigator.userAgent);
}, []);
return userAgent;
};
/**
* SSR-safe media query hook (internal)
*/
const useMediaQueryInternal = (query) => {
const [matches, setMatches] = useState(false);
useEffect(() => {
if (!isBrowser()) return;
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handler = (event) => {
setMatches(event.matches);
};
mediaQuery.addEventListener("change", handler);
return () => {
mediaQuery.removeEventListener("change", handler);
};
}, [query]);
return matches;
};
/**
* Detect mobile devices
*/
const useIsMobileTouch = () => {
const userAgent = useUserAgentInternal();
const isSmallScreen = useMediaQueryInternal("(max-width: 768px)");
const isMobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
return isMobileUA || isSmallScreen;
};
/**
* Detect touch support
*/
const useHasTouchSupportTouch = () => {
const [hasTouch, setHasTouch] = useState(false);
useEffect(() => {
if (isBrowser()) setHasTouch("ontouchstart" in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0);
}, []);
return hasTouch;
};
//#endregion
export { useDeviceOrientation, useHasTouchSupportTouch, useIsMobileTouch, usePreventZoom, useSafeAreaInsets, useTouchGestures };
//# sourceMappingURL=touchUtils-C7Ut3mIb.mjs.map