UNPKG

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
"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