UNPKG

@handit.ai/onboarding

Version:

Interactive onboarding components and service for AI agents

355 lines (315 loc) 9.97 kB
import React, { useState, useEffect, useRef } from 'react'; import { Box, Fade } from '@mui/material'; import { CursorClick, Hand } from '@phosphor-icons/react'; const InvisibleMouse = ({ visible = false, position = { x: 0, y: 0 }, type = 'pointer', // 'pointer', 'click', 'hand' size = 24, color = '#42a5f5', animationDuration = 150, trail = false, pulsing = false }) => { const [currentPosition, setCurrentPosition] = useState(position); const [isAnimating, setIsAnimating] = useState(false); const [trailPositions, setTrailPositions] = useState([]); const mouseRef = useRef(null); useEffect(() => { if (position.x !== currentPosition.x || position.y !== currentPosition.y) { if (trail) { setTrailPositions(prev => [...prev.slice(-5), currentPosition]); } setCurrentPosition(position); } }, [position, currentPosition, trail]); const getMouseIcon = () => { switch (type) { case 'click': return <CursorClick size={size} color={color} weight="fill" />; case 'hand': return <Hand size={size} color={color} weight="fill" />; default: return ( <Box sx={{ position: 'relative', display: 'inline-block', width: '60px', height: '40px' }}> {/* Clean SVG mouse cursor */} <svg width="16" height="20" viewBox="0 0 16 20" style={{ position: 'absolute', top: 16, left: 0, zIndex: 100000, filter: 'drop-shadow(1px 1px 3px rgba(0, 0, 0, 0.4))', }} > <path d="M0 0 L0 14 L4 10 L7 16 L9 15 L6 9 L11 9 Z" fill={color} /> </svg> {/* HandIt banner positioned at bottom right - clean design */} <Box sx={{ bgcolor: color, color: 'white', borderRadius: '12px', px: 1.5, py: 0.5, fontSize: '10px', fontWeight: 600, whiteSpace: 'nowrap', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: '40px', height: '20px', position: 'absolute', top: '28px', left: '18px', zIndex: 100000, }} > HandIt </Box> </Box> ); } }; if (!visible) return null; return ( <> {/* Trail Effect */} {trail && trailPositions.map((trailPos, index) => ( <Fade key={`trail-${index}`} in={true} timeout={500} style={{ transitionDelay: `${index * 100}ms` }} > <Box sx={{ position: 'fixed', left: trailPos.x, top: trailPos.y, zIndex: 100000 + index, pointerEvents: 'none', opacity: (index + 1) / trailPositions.length * 0.3, transform: 'translate(-50%, -50%)', cursor: 'none', }} > {getMouseIcon()} </Box> </Fade> ))} {/* Main Mouse Cursor */} <Fade in={visible} timeout={200}> <Box ref={mouseRef} sx={{ position: 'fixed', left: currentPosition.x, top: currentPosition.y, zIndex: 100000, cursor: 'none', transform: 'translate(-50%, -50%)', transition: isAnimating ? 'none' : `all ${animationDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`, animation: pulsing ? 'pulse 1.5s ease-in-out infinite' : 'none', pointerEvents: 'none', // Allow clicks to pass through '@keyframes pulse': { '0%': { transform: 'translate(-50%, -50%) scale(1)', opacity: 1, }, '50%': { transform: 'translate(-50%, -50%) scale(1.2)', opacity: 0.7, }, '100%': { transform: 'translate(-50%, -50%) scale(1)', opacity: 1, }, }, }} > {getMouseIcon()} </Box> </Fade> </> ); }; // Hook for controlling the invisible mouse export const useInvisibleMouse = () => { const [mouseState, setMouseState] = useState({ visible: false, position: { x: 0, y: 0 }, type: 'pointer', pulsing: false, trail: false, }); // Use ref to track current position to avoid stale state issues const currentPositionRef = useRef({ x: 0, y: 0 }); const isVisibleRef = useRef(false); // Update refs whenever state changes useEffect(() => { currentPositionRef.current = mouseState.position; isVisibleRef.current = mouseState.visible; }, [mouseState.position, mouseState.visible]); const showMouse = (options = {}) => { setMouseState(prev => ({ ...prev, visible: true, ...options, })); }; const hideMouse = () => { setMouseState(prev => ({ ...prev, visible: false, })); }; const moveMouse = (x, y, options = {}) => { setMouseState(prev => ({ ...prev, position: { x, y }, ...options, })); }; const clickAt = (x, y, callback) => { moveMouse(x, y, { type: 'click', pulsing: true }); setTimeout(() => { if (callback) callback(); setMouseState(prev => ({ ...prev, type: 'pointer', pulsing: false, })); }, 200); }; const moveToElement = (selector, offset = { x: 0, y: 0 }) => { const element = document.querySelector(selector); if (element) { const rect = element.getBoundingClientRect(); const x = rect.left + rect.width / 2 + offset.x; const y = rect.top + rect.height / 2 + offset.y; moveMouse(x, y); return { x, y }; } return null; }; const animateToElement = (selector, options = {}) => { const { duration = 800, startFromCenter = true, offset = { x: 0, y: 0 }, onComplete } = options; const element = document.querySelector(selector); if (!element) { console.warn('Element not found for selector:', selector); return null; } const rect = element.getBoundingClientRect(); const targetX = rect.left + rect.width / 2 + offset.x; const targetY = rect.top + rect.height / 2 + offset.y; // Smart start position logic: // 1. If mouse is already visible, start from current position // 2. If mouse is not visible, respect startFromCenter parameter let startX, startY; if (isVisibleRef.current) { // Mouse is already visible, start from current position startX = currentPositionRef.current.x; startY = currentPositionRef.current.y; } else { // Mouse is not visible, use startFromCenter parameter startX = startFromCenter ? window.innerWidth / 2 : currentPositionRef.current.x; startY = startFromCenter ? window.innerHeight / 2 : currentPositionRef.current.y; } // Only update state if mouse is not visible, or if we need to change other properties if (!isVisibleRef.current) { setMouseState(prev => ({ ...prev, visible: true, position: { x: startX, y: startY }, type: 'pointer', pulsing: true, trail: true, })); // Update refs to stay in sync currentPositionRef.current = { x: startX, y: startY }; isVisibleRef.current = true; } else { // Mouse is already visible, just ensure proper state without changing position setMouseState(prev => ({ ...prev, pulsing: true, trail: true, })); } // Animate to target with smooth steps const steps = 60; // 60 steps for smooth animation const stepDuration = duration / steps; let currentStep = 0; const animate = () => { if (currentStep >= steps) { // Animation complete setMouseState(prev => ({ ...prev, position: { x: targetX, y: targetY }, pulsing: true, })); // Update position ref to stay in sync currentPositionRef.current = { x: targetX, y: targetY }; if (onComplete) onComplete(); return; } // Calculate current position using easing const progress = currentStep / steps; const easeProgress = easeInOutCubic(progress); const currentX = startX + (targetX - startX) * easeProgress; const currentY = startY + (targetY - startY) * easeProgress; setMouseState(prev => ({ ...prev, position: { x: currentX, y: currentY }, })); // Update position ref to stay in sync currentPositionRef.current = { x: currentX, y: currentY }; currentStep++; setTimeout(animate, stepDuration); }; // Start animation after a brief delay setTimeout(animate, 50); return { x: targetX, y: targetY }; }; // Easing function for smooth animation const easeInOutCubic = (t) => { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }; const clickElement = (selector, callback) => { const position = moveToElement(selector); if (position) { setTimeout(() => { clickAt(position.x, position.y, callback); }, 100); } }; const MouseComponent = () => <InvisibleMouse {...mouseState} />; return { showMouse, hideMouse, moveMouse, clickAt, moveToElement, animateToElement, clickElement, mouseState, MouseComponent, }; }; export default InvisibleMouse;