/* ============================================================ mobile-gestures.jsx Détecteur de gestes nommés pour smartphone. Chaque geste a un NOM SYSTÈME, et un composant de TEST. ============================================================ */ const { useState: uG, useRef: rG, useEffect: eG } = React; /* ============================================================ useGesture — hook bas niveau qui détecte les gestes Renvoie : { onTouchStart, onTouchMove, onTouchEnd } à attacher au composant qui doit recevoir les gestes. Callbacks supportés : onTap tap simple (< 200ms, ne bouge pas) onDoubleTap double-tap (deux tap rapides) onLongPress long press (≥ 500ms sans bouger) onSwipeLeft swipe vers la gauche onSwipeRight swipe vers la droite onSwipeUp swipe vers le haut onSwipeDown swipe vers le bas onPanStart début de glisser onPan cours de glisser ({dx, dy}) onPanEnd fin de glisser onPinch pincement ({scale, dx, dy}) ============================================================ */ function useGesture(handlers = {}) { const state = rG({ sx: 0, sy: 0, st: 0, lx: 0, ly: 0, lt: 0, moved: false, longPressTimer: null, lastTap: 0, lastTapPos: null, pinching: false, startDist: 0, }); const reset = () => { if (state.current.longPressTimer) clearTimeout(state.current.longPressTimer); }; const onTouchStart = (e) => { const t = e.touches[0]; state.current.sx = t.clientX; state.current.sy = t.clientY; state.current.lx = t.clientX; state.current.ly = t.clientY; state.current.st = Date.now(); state.current.lt = Date.now(); state.current.moved = false; // Pinch detection if (e.touches.length === 2) { const dx = e.touches[1].clientX - t.clientX; const dy = e.touches[1].clientY - t.clientY; state.current.startDist = Math.hypot(dx, dy); state.current.pinching = true; return; } // Long press if (handlers.onLongPress) { state.current.longPressTimer = setTimeout(() => { if (!state.current.moved) { handlers.onLongPress({ x: t.clientX, y: t.clientY }); state.current.moved = true; // empêche d'autres détections } }, 500); } handlers.onPanStart && handlers.onPanStart({ x: t.clientX, y: t.clientY }); }; const onTouchMove = (e) => { const t = e.touches[0]; const dx = t.clientX - state.current.sx; const dy = t.clientY - state.current.sy; if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { state.current.moved = true; reset(); } if (state.current.pinching && e.touches.length === 2) { const px = e.touches[1].clientX - t.clientX; const py = e.touches[1].clientY - t.clientY; const dist = Math.hypot(px, py); const scale = dist / state.current.startDist; handlers.onPinch && handlers.onPinch({ scale, dx, dy }); return; } handlers.onPan && handlers.onPan({ dx, dy, x: t.clientX, y: t.clientY }); state.current.lx = t.clientX; state.current.ly = t.clientY; state.current.lt = Date.now(); }; const onTouchEnd = (e) => { reset(); const dx = state.current.lx - state.current.sx; const dy = state.current.ly - state.current.sy; const dt = Date.now() - state.current.st; handlers.onPanEnd && handlers.onPanEnd({ dx, dy }); if (state.current.pinching) { state.current.pinching = false; return; } if (state.current.moved && dt < 500) { const absX = Math.abs(dx), absY = Math.abs(dy); if (absX > 50 || absY > 50) { if (absX > absY) { if (dx > 0) handlers.onSwipeRight && handlers.onSwipeRight({ dx, dt }); else handlers.onSwipeLeft && handlers.onSwipeLeft({ dx, dt }); } else { if (dy > 0) handlers.onSwipeDown && handlers.onSwipeDown({ dy, dt }); else handlers.onSwipeUp && handlers.onSwipeUp({ dy, dt }); } } } else if (!state.current.moved && dt < 200) { // Tap / DoubleTap const now = Date.now(); const pos = { x: state.current.lx, y: state.current.ly }; const lp = state.current.lastTapPos; if (now - state.current.lastTap < 300 && lp && Math.hypot(pos.x - lp.x, pos.y - lp.y) < 30) { handlers.onDoubleTap && handlers.onDoubleTap(pos); state.current.lastTap = 0; } else { handlers.onTap && handlers.onTap(pos); state.current.lastTap = now; state.current.lastTapPos = pos; } } }; return { onTouchStart, onTouchMove, onTouchEnd }; } /* ============================================================ GestureZone — zone tactile de test Affiche le dernier geste détecté + un journal des gestes. Toutes les actions sont nommées explicitement. ============================================================ */ function GestureZone({ label, accept = [] }) { const [last, setLast] = uG(null); const [log, setLog] = uG([]); const [count, setCount] = uG({}); const [trail, setTrail] = uG(null); const fire = (name, data) => { setLast({ name, data, time: Date.now() }); setLog((l) => [{ name, t: new Date().toLocaleTimeString('fr-FR', { hour12: false }) }, ...l].slice(0, 5)); setCount((c) => ({ ...c, [name]: (c[name] || 0) + 1 })); }; const hAll = { onTap: () => fire('Tap'), onDoubleTap: () => fire('DoubleTap'), onLongPress: () => fire('LongPress'), onSwipeLeft: () => fire('SwipeLeft'), onSwipeRight: () => fire('SwipeRight'), onSwipeUp: () => fire('SwipeUp'), onSwipeDown: () => fire('SwipeDown'), onPan: ({ dx, dy }) => setTrail({ dx, dy }), onPanEnd: () => setTrail(null), onPinch: ({ scale }) => fire('Pinch', { scale: scale.toFixed(2) }), }; // Filtre uniquement les handlers demandés const h = accept.length === 0 ? hAll : Object.fromEntries( Object.entries(hAll).filter(([k]) => accept.some((n) => k.toLowerCase().includes(n.toLowerCase()))) ); const gesture = useGesture(h); return (
{label && (
{label}
)}
{/* indicateur central */}
{last ? (
{last.name} {last.data && (
{Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
)}
) : ( essaie un geste ici )}
{/* trail visuel pendant le pan */} {trail && (
)}
{/* Journal */} {log.length > 0 && (
journal {log.length} dernier{log.length > 1 ? 's' : ''}
{log.map((l, i) => (
{l.t}{' '} {l.name}
))}
)}
); } /* Catalogue des gestes — utile pour affichage + onglet d'aide */ const GESTURE_CATALOG = [ { name: 'Tap', icon: 'play', desc: 'Pression rapide sur l\'écran.', usage: 'Action principale (équiv. clic).' }, { name: 'DoubleTap', icon: 'plus', desc: 'Deux Tap rapprochés (<300 ms).', usage: 'Zoomer une image, liker (style Instagram).' }, { name: 'LongPress', icon: 'clock', desc: 'Pression maintenue ≥ 500 ms.', usage: 'Ouvrir un menu contextuel, sélectionner.' }, { name: 'SwipeLeft', icon: 'chevL', desc: 'Glisser le doigt vers la gauche.', usage: 'Naviguer à l\'écran suivant, supprimer une ligne.' }, { name: 'SwipeRight', icon: 'chevR', desc: 'Glisser le doigt vers la droite.', usage: 'Retour à l\'écran précédent, archiver.' }, { name: 'SwipeUp', icon: 'chevU', desc: 'Glisser vers le haut.', usage: 'Voir plus de détails, fermer une popup.' }, { name: 'SwipeDown', icon: 'chevD', desc: 'Glisser vers le bas.', usage: 'Rafraîchir (PullToRefresh), fermer une BottomSheet.' }, { name: 'Pan', icon: 'grid', desc: 'Glisser en continu (drag).', usage: 'Déplacer un élément, scroll horizontal.' }, { name: 'Pinch', icon: 'search', desc: 'Écarter / rapprocher 2 doigts.', usage: 'Zoomer une carte, une image.' }, ]; Object.assign(window, { useGesture, GestureZone, GESTURE_CATALOG });