4518ed8311
Contient les tokens, composants et exemples adaptés au mobile, à utiliser comme référence lors du développement des vues smartphone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
287 lines
11 KiB
React
287 lines
11 KiB
React
/* ============================================================
|
|
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 (
|
|
<div style={{
|
|
background: 'var(--bg-3)',
|
|
border: '1px solid var(--border-2)',
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
boxShadow: 'var(--tile-3d)',
|
|
marginBottom: 12,
|
|
}}>
|
|
{label && (
|
|
<div style={{
|
|
padding: '10px 14px',
|
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
|
letterSpacing: '0.08em', textTransform: 'uppercase',
|
|
color: 'var(--ink-3)',
|
|
background: 'var(--bg-2)',
|
|
borderBottom: '1px solid var(--border-1)',
|
|
}}>{label}</div>
|
|
)}
|
|
<div {...gesture}
|
|
style={{
|
|
height: 200,
|
|
position: 'relative',
|
|
background: `repeating-linear-gradient(45deg, var(--bg-3) 0 14px, var(--bg-4) 14px 15px)`,
|
|
touchAction: 'none',
|
|
userSelect: 'none',
|
|
WebkitUserSelect: 'none',
|
|
cursor: 'grab',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
}}>
|
|
{/* indicateur central */}
|
|
<div style={{
|
|
fontFamily: 'var(--font-mono)', fontSize: 13,
|
|
color: 'var(--ink-3)', textAlign: 'center',
|
|
padding: 16, pointerEvents: 'none',
|
|
}}>
|
|
{last ? (
|
|
<div style={{
|
|
animation: 'gp-pop .3s cubic-bezier(.3,.7,.3,1.2)',
|
|
fontSize: 22, fontWeight: 700, color: 'var(--accent)',
|
|
fontFamily: 'var(--font-ui)',
|
|
}}>
|
|
{last.name}
|
|
{last.data && (
|
|
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 4 }}>
|
|
{Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span>essaie un geste ici</span>
|
|
)}
|
|
<style>{`@keyframes gp-pop { from { opacity: 0; transform: scale(.85) } to { opacity: 1; transform: scale(1) } }`}</style>
|
|
</div>
|
|
{/* trail visuel pendant le pan */}
|
|
{trail && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '50%', left: '50%',
|
|
width: 14, height: 14,
|
|
borderRadius: '50%',
|
|
background: 'var(--accent)',
|
|
boxShadow: '0 0 12px var(--accent-glow)',
|
|
transform: `translate(calc(-50% + ${trail.dx}px), calc(-50% + ${trail.dy}px))`,
|
|
pointerEvents: 'none',
|
|
}} />
|
|
)}
|
|
</div>
|
|
{/* Journal */}
|
|
{log.length > 0 && (
|
|
<div style={{
|
|
padding: '8px 14px 10px',
|
|
background: 'var(--bg-2)',
|
|
borderTop: '1px solid var(--border-1)',
|
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
|
color: 'var(--ink-3)',
|
|
display: 'flex', flexDirection: 'column', gap: 2,
|
|
}}>
|
|
<div style={{
|
|
display: 'flex', justifyContent: 'space-between',
|
|
fontSize: 9, letterSpacing: '0.08em', textTransform: 'uppercase',
|
|
color: 'var(--ink-4)', marginBottom: 4,
|
|
}}>
|
|
<span>journal</span>
|
|
<span>{log.length} dernier{log.length > 1 ? 's' : ''}</span>
|
|
</div>
|
|
{log.map((l, i) => (
|
|
<div key={i} style={{ opacity: 1 - i * 0.15 }}>
|
|
<span style={{ color: 'var(--ink-4)' }}>{l.t}</span>{' '}
|
|
<span style={{ color: 'var(--accent)' }}>{l.name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* 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 });
|