aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
648 lines (645 loc) • 20.3 kB
JavaScript
'use client';
import { jsx, jsxs } from 'react/jsx-runtime';
import { forwardRef, useRef, useState, useCallback, useEffect } from 'react';
import '../../primitives/GlassCore.js';
import '../../primitives/glass/GlassAdvanced.js';
import { OptimizedGlassCore } from '../../primitives/OptimizedGlassCore.js';
import '../../primitives/glass/OptimizedGlassAdvanced.js';
import '../../primitives/MotionNative.js';
import { MotionFramer } from '../../primitives/motion/MotionFramer.js';
import { cn } from '../../lib/utilsComprehensive.js';
import { useA11yId } from '../../utils/a11y.js';
import { useMotionPreferenceContext } from '../../contexts/MotionPreferenceContext.js';
import { useGlassSound } from '../../utils/soundDesign.js';
const GlassGestureZone = /*#__PURE__*/forwardRef(({
width = 600,
height = 400,
active = true,
sensitivity = 0.8,
maxGestureDuration = 5000,
minGesturePoints = 3,
showTrail = true,
trailColor = "var(--glass-color-primary)",
trailFadeDuration = 2000,
showFeedback = true,
feedbackDuration = 1000,
gestureTemplates = [],
multiTouch = false,
maxTouches = 2,
recognitionTimeout = 1000,
debug = false,
onGestureStart,
onGestureMove,
onGestureEnd,
onGestureRecognized,
onMultiTouch,
showControls = true,
respectMotionPreference = true,
className,
...props
}, ref) => {
const {
prefersReducedMotion,
isMotionSafe
} = useMotionPreferenceContext();
const {
play
} = useGlassSound();
const canvasRef = useRef(null);
const animationRef = useRef();
const gestureZoneId = useA11yId("glass-gesture-zone");
const [currentStrokes, setCurrentStrokes] = useState(new Map());
const [completedStrokes, setCompletedStrokes] = useState([]);
const [recognizedGestures, setRecognizedGestures] = useState([]);
const [trailPoints, setTrailPoints] = useState([]);
const [feedbackGestures, setFeedbackGestures] = useState([]);
const [activePoints, setActivePoints] = useState([]);
const [isMultiTouchActive, setIsMultiTouchActive] = useState(false);
// Default gesture templates
const defaultTemplates = [{
name: "Circle",
type: "circle",
points: Array.from({
length: 16
}, (_, i) => ({
x: Math.cos(i / 16 * Math.PI * 2),
y: Math.sin(i / 16 * Math.PI * 2)
})),
threshold: 0.3
}, {
name: "Swipe Right",
type: "swipe_right",
points: [{
x: -1,
y: 0
}, {
x: -0.5,
y: 0
}, {
x: 0,
y: 0
}, {
x: 0.5,
y: 0
}, {
x: 1,
y: 0
}],
threshold: 0.4
}, {
name: "Swipe Left",
type: "swipe_left",
points: [{
x: 1,
y: 0
}, {
x: 0.5,
y: 0
}, {
x: 0,
y: 0
}, {
x: -0.5,
y: 0
}, {
x: -1,
y: 0
}],
threshold: 0.4
}, {
name: "Swipe Up",
type: "swipe_up",
points: [{
x: 0,
y: 1
}, {
x: 0,
y: 0.5
}, {
x: 0,
y: 0
}, {
x: 0,
y: -0.5
}, {
x: 0,
y: -1
}],
threshold: 0.4
}, {
name: "Swipe Down",
type: "swipe_down",
points: [{
x: 0,
y: -1
}, {
x: 0,
y: -0.5
}, {
x: 0,
y: 0
}, {
x: 0,
y: 0.5
}, {
x: 0,
y: 1
}],
threshold: 0.4
}, {
name: "Tap",
type: "tap",
points: [{
x: 0,
y: 0
}],
threshold: 0.1
}, {
name: "Double Tap",
type: "double_tap",
points: [{
x: 0,
y: 0
}],
threshold: 0.1
}, {
name: "Pinch",
type: "pinch",
points: [{
x: -0.5,
y: 0
}, {
x: 0,
y: 0
}, {
x: 0.5,
y: 0
}],
threshold: 0.3
}];
const allTemplates = [...defaultTemplates, ...gestureTemplates];
// Normalize points to [-1, 1] range
const normalizePoints = useCallback(points => {
if (points.length === 0) return [];
const minX = Math.min(...points.map(p => p.x));
const maxX = Math.max(...points.map(p => p.x));
const minY = Math.min(...points.map(p => p.y));
const maxY = Math.max(...points.map(p => p.y));
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const scaleX = Math.max(1, (maxX - minX) / 2);
const scaleY = Math.max(1, (maxY - minY) / 2);
const scale = Math.max(scaleX, scaleY);
return points.map(point => ({
x: (point.x - centerX) / scale,
y: (point.y - centerY) / scale
}));
}, []);
// Calculate gesture similarity using DTW (Dynamic Time Warping)
const calculateSimilarity = useCallback((gesturePoints, templatePoints) => {
if (gesturePoints.length === 0 || templatePoints.length === 0) return 0;
const m = gesturePoints.length;
const n = templatePoints.length;
const dtw = Array(m + 1).fill(null).map(() => Array(n + 1).fill(Infinity));
dtw[0][0] = 0;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = Math.sqrt(Math.pow(gesturePoints[i - 1].x - templatePoints[j - 1].x, 2) + Math.pow(gesturePoints[i - 1].y - templatePoints[j - 1].y, 2));
dtw[i][j] = cost + Math.min(dtw[i - 1][j],
// insertion
dtw[i][j - 1],
// deletion
dtw[i - 1][j - 1] // match
);
}
}
// Normalize by path length
const similarity = 1 - dtw[m][n] / Math.max(m, n);
return Math.max(0, similarity);
}, []);
// Recognize gesture
const recognizeGesture = useCallback(stroke => {
if (stroke.points.length < minGesturePoints) return;
const normalizedPoints = normalizePoints(stroke.points);
let bestMatch = null;
for (const template of allTemplates) {
const confidence = calculateSimilarity(normalizedPoints, template.points);
if (confidence >= template.threshold * sensitivity) {
if (!bestMatch || confidence > bestMatch.confidence) {
bestMatch = {
template,
confidence
};
}
}
}
if (bestMatch) {
const recognizedGesture = {
type: bestMatch.template.type,
confidence: bestMatch.confidence,
data: {
template: bestMatch.template.name,
strokeId: stroke.id,
duration: stroke.endTime - stroke.startTime,
velocity: stroke.velocity,
direction: stroke.direction,
distance: stroke.distance
},
timestamp: Date.now(),
stroke,
id: `gesture-${Date.now()}`
};
setRecognizedGestures(prev => [...prev.slice(-9), recognizedGesture]);
onGestureRecognized?.(recognizedGesture);
// Add feedback
if (showFeedback) {
setFeedbackGestures(prev => [...prev, {
gesture: recognizedGesture,
opacity: 1
}]);
}
play("success");
}
}, [minGesturePoints, normalizePoints, allTemplates, calculateSimilarity, sensitivity, onGestureRecognized, showFeedback, play]);
// Calculate stroke properties
const calculateStrokeProperties = useCallback(points => {
if (points.length < 2) {
return {
velocity: 0,
direction: 0,
distance: 0
};
}
let totalDistance = 0;
let totalTime = 0;
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x;
const dy = points[i].y - points[i - 1].y;
const dt = points[i].timestamp - points[i - 1].timestamp;
totalDistance += Math.sqrt(dx * dx + dy * dy);
totalTime += dt;
}
const velocity = totalTime > 0 ? totalDistance / totalTime : 0;
// Calculate overall direction
const startPoint = points[0];
const endPoint = points[points.length - 1];
const direction = Math.atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x);
return {
velocity,
direction,
distance: totalDistance
};
}, []);
// Get pointer position
const getPointerPosition = useCallback(event => {
const canvas = canvasRef.current;
if (!canvas) return {
x: 0,
y: 0,
timestamp: Date.now(),
id: ""
};
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
timestamp: Date.now(),
pressure: event.pressure || 1,
id: `point-${Date.now()}-${Math.random()}`
};
}, []);
// Handle pointer events
const handlePointerDown = useCallback(event => {
if (!active) return;
event.preventDefault();
const point = getPointerPosition(event);
const pointerId = event.pointerId.toString();
const newStroke = {
points: [point],
startTime: Date.now(),
endTime: Date.now(),
velocity: 0,
direction: 0,
distance: 0,
id: `stroke-${pointerId}-${Date.now()}`
};
setCurrentStrokes(prev => new Map(prev.set(pointerId, newStroke)));
setActivePoints(prev => [...prev, point]);
onGestureStart?.(point);
play("tap");
}, [active, getPointerPosition, onGestureStart, play]);
const handlePointerMove = useCallback(event => {
if (!active) return;
const point = getPointerPosition(event);
const pointerId = event.pointerId.toString();
const currentStroke = currentStrokes.get(pointerId);
if (!currentStroke) return;
const updatedStroke = {
...currentStroke,
points: [...currentStroke.points, point],
endTime: Date.now(),
...calculateStrokeProperties([...currentStroke.points, point])
};
setCurrentStrokes(prev => new Map(prev.set(pointerId, updatedStroke)));
// Add to trail
if (showTrail) {
setTrailPoints(prev => [...prev, {
point,
opacity: 1,
size: Math.min(8, Math.max(2, (point.pressure || 1) * 6))
}]);
}
onGestureMove?.(point, updatedStroke);
}, [active, getPointerPosition, currentStrokes, calculateStrokeProperties, showTrail, onGestureMove]);
const handlePointerUp = useCallback(event => {
if (!active) return;
const pointerId = event.pointerId.toString();
const stroke = currentStrokes.get(pointerId);
if (!stroke) return;
const finalStroke = {
...stroke,
endTime: Date.now(),
...calculateStrokeProperties(stroke.points)
};
setCurrentStrokes(prev => {
const newMap = new Map(prev);
newMap.delete(pointerId);
return newMap;
});
setCompletedStrokes(prev => [...prev.slice(-19), finalStroke]);
setActivePoints(prev => prev.filter(p => p.id !== stroke.points[stroke.points.length - 1]?.id));
// Recognize gesture after a short delay
setTimeout(() => {
recognizeGesture(finalStroke);
}, 100);
onGestureEnd?.(finalStroke);
play("success");
}, [active, currentStrokes, calculateStrokeProperties, recognizeGesture, onGestureEnd, play]);
// Handle multi-touch
useEffect(() => {
const activeCount = currentStrokes.size;
if (multiTouch && activeCount >= 2) {
setIsMultiTouchActive(true);
const points = Array.from(currentStrokes.values()).map(stroke => stroke.points[stroke.points.length - 1]);
onMultiTouch?.(points);
} else {
setIsMultiTouchActive(false);
}
}, [currentStrokes, multiTouch, onMultiTouch]);
// Fade trail points
useEffect(() => {
if (!showTrail) return;
const interval = setInterval(() => {
setTrailPoints(prev => prev.map(item => ({
...item,
opacity: Math.max(0, item.opacity - 0.02)
})).filter(item => item.opacity > 0));
}, 16);
return () => clearInterval(interval);
}, [showTrail]);
// Fade feedback
useEffect(() => {
if (!showFeedback) return;
const interval = setInterval(() => {
setFeedbackGestures(prev => prev.map(item => ({
...item,
opacity: Math.max(0, item.opacity - 0.01)
})).filter(item => item.opacity > 0));
}, 16);
return () => clearInterval(interval);
}, [showFeedback]);
// Render canvas
const render = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Draw trail
if (showTrail && !prefersReducedMotion) {
trailPoints.forEach((item, index) => {
const {
point,
opacity,
size
} = item;
ctx.globalAlpha = opacity;
ctx.fillStyle = trailColor;
ctx.beginPath();
ctx.arc(point.x, point.y, size, 0, Math.PI * 2);
ctx.fill();
});
}
// Draw active strokes
ctx.globalAlpha = 1;
ctx.strokeStyle = trailColor;
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.lineJoin = "round";
currentStrokes.forEach(stroke => {
if (stroke.points.length < 2) return;
ctx.beginPath();
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.stroke();
});
// Draw multi-touch indicators
if (isMultiTouchActive) {
activePoints.forEach(point => {
ctx.strokeStyle = "#FF6B6B";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(point.x, point.y, 15, 0, Math.PI * 2);
ctx.stroke();
});
}
// Draw feedback
feedbackGestures.forEach(({
gesture,
opacity
}) => {
const centerX = width / 2;
const centerY = height / 2;
ctx.globalAlpha = opacity;
ctx.fillStyle = "#4ADE80";
ctx.font = "18px sans-serif";
ctx.textAlign = "center";
ctx.fillText(gesture.type.replace("_", " ").toUpperCase(), centerX, centerY);
// Draw confidence
ctx.font = "12px sans-serif";
ctx.fillText(`${Math.round(gesture.confidence * 100)}%`, centerX, centerY + 25);
});
// Debug info
if (debug) {
ctx.globalAlpha = 1;
ctx.fillStyle = "white";
ctx.font = "10px monospace";
ctx.textAlign = "left";
let y = 15;
ctx.fillText(`Active Strokes: ${currentStrokes.size}`, 10, y);
y += 15;
ctx.fillText(`Completed: ${completedStrokes.length}`, 10, y);
y += 15;
ctx.fillText(`Recognized: ${recognizedGestures.length}`, 10, y);
y += 15;
ctx.fillText(`Multi-touch: ${isMultiTouchActive}`, 10, y);
y += 15;
ctx.fillText(`Trail Points: ${trailPoints.length}`, 10, y);
}
}, [width, height, showTrail, prefersReducedMotion, trailPoints, trailColor, currentStrokes, isMultiTouchActive, activePoints, feedbackGestures, debug, completedStrokes.length, recognizedGestures.length]);
// Animation loop
useEffect(() => {
const animate = () => {
render();
animationRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [render]);
// Canvas setup
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = width;
canvas.height = height;
}, [width, height]);
// Clear all data
const clearAll = useCallback(() => {
setCurrentStrokes(new Map());
setCompletedStrokes([]);
setRecognizedGestures([]);
setTrailPoints([]);
setFeedbackGestures([]);
setActivePoints([]);
play("error");
}, [play]);
// Controls
const renderControls = () => {
if (!showControls) return null;
return jsxs(OptimizedGlassCore, {
elevation: "level2",
intensity: "medium",
depth: 1,
tint: "neutral",
border: "subtle",
className: "glass-gesture-controls glass-flex glass-flex-wrap glass-items-center glass-gap-4 glass-p-4 glass-radius-lg glass-glass-glass-backdrop-blur-md glass-border glass-border-glass-border/20 glass-contrast-guard",
children: [jsxs("div", {
className: "glass-flex glass-items-center glass-gap-2",
children: [jsx("button", {
onClick: () => {},
className: cn("glass-px-3 glass-py-1 glass-radius-md glass-focus glass-touch-target glass-contrast-guard", active ? "bg-green-500/20 hover:bg-green-500/30 text-green-400" : "bg-red-500/20 hover:bg-red-500/30 text-red-400"),
children: active ? "Active" : "Inactive"
}), jsx("button", {
onClick: clearAll,
className: 'glass-px-3 glass-py-1 glass-radius-md bg-secondary/20 hover:bg-secondary/30 glass-focus glass-touch-target glass-focus glass-touch-target glass-contrast-guard',
children: "Clear"
})]
}), jsxs("div", {
className: "glass-flex glass-items-center glass-gap-2",
children: [jsx("label", {
className: "glass-text-sm",
children: "Sensitivity:"
}), jsx("input", {
type: "range",
min: "0.1",
max: "1",
step: "0.1",
value: sensitivity,
onChange: e => {},
className: 'w-20 glass-focus glass-touch-target glass-contrast-guard'
}), jsxs("span", {
className: 'glass-text-sm min-w-[3ch]',
children: [Math.round(sensitivity * 100), "%"]
})]
}), jsxs("div", {
className: "glass-flex glass-items-center glass-gap-2",
children: [jsxs("label", {
className: "glass-text-sm",
children: [jsx("input", {
type: "checkbox",
checked: showTrail,
onChange: e => {},
className: "glass-mr-1 glass-focus glass-touch-target glass-contrast-guard"
}), "Trail"]
}), jsxs("label", {
className: "glass-text-sm",
children: [jsx("input", {
type: "checkbox",
checked: multiTouch,
onChange: e => {},
className: "glass-mr-1 glass-focus glass-touch-target glass-contrast-guard"
}), "Multi-touch"]
}), jsxs("label", {
className: "glass-text-sm",
children: [jsx("input", {
type: "checkbox",
checked: debug,
onChange: e => {},
className: "glass-mr-1 glass-focus glass-touch-target glass-contrast-guard"
}), "Debug"]
})]
})]
});
};
return jsx(OptimizedGlassCore, {
ref: ref,
id: gestureZoneId,
elevation: "level1",
intensity: "subtle",
depth: 1,
tint: "neutral",
border: "subtle",
className: cn("glass-gesture-zone relative glass-radius-lg glass-glass-backdrop-blur-md border border-border/20 glass-contrast-guard", className),
...props,
children: jsxs(MotionFramer, {
preset: isMotionSafe && respectMotionPreference ? "fadeIn" : "none",
className: "glass-flex glass-flex-col glass-gap-4 glass-p-4",
children: [renderControls(), jsx("div", {
className: 'relative',
children: jsx("canvas", {
ref: canvasRef,
width: width,
height: height,
className: cn("border border-border/20 glass-radius-md bg-background/5", active && "cursor-crosshair"),
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onPointerLeave: handlePointerUp,
style: {
width,
height,
touchAction: "none"
}
})
}), recognizedGestures.length > 0 && jsxs("div", {
className: "glass-p-4 glass-surface-overlay glass-radius-md",
children: [jsx("div", {
className: 'glass-text-sm font-medium mb-2',
children: "Recent Gestures:"
}), jsx("div", {
className: 'space-y-1',
children: recognizedGestures.slice(-5).map(gesture => jsxs("div", {
className: "glass-text-xs glass-p-2 glass-surface-primary/10 glass-radius-sm",
children: [jsx("span", {
className: 'font-medium',
children: gesture.type.replace("_", " ")
}), jsxs("span", {
className: "glass-text-secondary glass-ml-2",
children: [Math.round(gesture.confidence * 100), "% confidence"]
})]
}, gesture.id))
})]
})]
})
});
});
GlassGestureZone.displayName = "GlassGestureZone";
export { GlassGestureZone, GlassGestureZone as default };
//# sourceMappingURL=GlassGestureZone.js.map