diff --git a/packages/extension/components.json b/packages/extension/components.json index 423eaa0..6b3d6a7 100644 --- a/packages/extension/components.json +++ b/packages/extension/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/index.css", + "css": "src/assets/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" @@ -16,7 +16,7 @@ "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", - "hooks": "@/hooks" + "hooks": "@/lib/hooks" }, "registries": { "@magicui": "https://magicui.design/r/{name}.json" diff --git a/packages/extension/src/assets/index.css b/packages/extension/src/assets/index.css index 7c39a6c..7f9a09f 100644 --- a/packages/extension/src/assets/index.css +++ b/packages/extension/src/assets/index.css @@ -111,17 +111,40 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --animate-blink-cursor: blink-cursor 1.2s step-end infinite; + @keyframes blink-cursor { + 0%, + 49% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } + } } -@keyframes glow-breathe { +@keyframes glow-a { + 0%, + 100% { + opacity: 0.45; + transform: scale(1); + } + 50% { + opacity: 0; + transform: scale(1.1); + } +} + +@keyframes glow-b { 0%, 100% { opacity: 0; - transform: scale(0.85); + transform: scale(1.1); } 50% { - opacity: 0.35; - transform: scale(1.1); + opacity: 0.45; + transform: scale(1); } } diff --git a/packages/extension/src/components/ui/typing-animation.tsx b/packages/extension/src/components/ui/typing-animation.tsx new file mode 100644 index 0000000..f9898df --- /dev/null +++ b/packages/extension/src/components/ui/typing-animation.tsx @@ -0,0 +1,164 @@ +import { MotionProps, motion, useInView } from 'motion/react' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { cn } from '@/lib/utils' + +interface TypingAnimationProps extends MotionProps { + children?: string + words?: string[] + className?: string + duration?: number + typeSpeed?: number + deleteSpeed?: number + delay?: number + pauseDelay?: number + loop?: boolean + as?: React.ElementType + startOnView?: boolean + showCursor?: boolean + blinkCursor?: boolean + cursorStyle?: 'line' | 'block' | 'underscore' +} + +export function TypingAnimation({ + children, + words, + className, + duration = 100, + typeSpeed, + deleteSpeed, + delay = 0, + pauseDelay = 1000, + loop = false, + as: Component = 'span', + startOnView = true, + showCursor = true, + blinkCursor = true, + cursorStyle = 'line', + ...props +}: TypingAnimationProps) { + const MotionComponent = motion.create(Component, { + forwardMotionProps: true, + }) + + const [displayedText, setDisplayedText] = useState('') + const [currentWordIndex, setCurrentWordIndex] = useState(0) + const [currentCharIndex, setCurrentCharIndex] = useState(0) + const [phase, setPhase] = useState<'typing' | 'pause' | 'deleting'>('typing') + const elementRef = useRef(null) + const isInView = useInView(elementRef as React.RefObject, { + amount: 0.3, + once: true, + }) + + const wordsToAnimate = useMemo(() => words || (children ? [children] : []), [words, children]) + const hasMultipleWords = wordsToAnimate.length > 1 + + const typingSpeed = typeSpeed || duration + const deletingSpeed = deleteSpeed || typingSpeed / 2 + + const shouldStart = startOnView ? isInView : true + + useEffect(() => { + if (!shouldStart || wordsToAnimate.length === 0) return + + const timeoutDelay = + delay > 0 && displayedText === '' + ? delay + : phase === 'typing' + ? typingSpeed + : phase === 'deleting' + ? deletingSpeed + : pauseDelay + + const timeout = setTimeout(() => { + const currentWord = wordsToAnimate[currentWordIndex] || '' + const graphemes = Array.from(currentWord) + + switch (phase) { + case 'typing': + if (currentCharIndex < graphemes.length) { + setDisplayedText(graphemes.slice(0, currentCharIndex + 1).join('')) + setCurrentCharIndex(currentCharIndex + 1) + } else { + if (hasMultipleWords || loop) { + const isLastWord = currentWordIndex === wordsToAnimate.length - 1 + if (!isLastWord || loop) { + setPhase('pause') + } + } + } + break + + case 'pause': + setPhase('deleting') + break + + case 'deleting': + if (currentCharIndex > 0) { + setDisplayedText(graphemes.slice(0, currentCharIndex - 1).join('')) + setCurrentCharIndex(currentCharIndex - 1) + } else { + const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length + setCurrentWordIndex(nextIndex) + setPhase('typing') + } + break + } + }, timeoutDelay) + + return () => clearTimeout(timeout) + }, [ + shouldStart, + phase, + currentCharIndex, + currentWordIndex, + displayedText, + wordsToAnimate, + hasMultipleWords, + loop, + typingSpeed, + deletingSpeed, + pauseDelay, + delay, + ]) + + const currentWordGraphemes = Array.from(wordsToAnimate[currentWordIndex] || '') + const isComplete = + !loop && + currentWordIndex === wordsToAnimate.length - 1 && + currentCharIndex >= currentWordGraphemes.length && + phase !== 'deleting' + + const shouldShowCursor = + showCursor && + !isComplete && + (hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length) + + const getCursorChar = () => { + switch (cursorStyle) { + case 'block': + return '▌' + case 'underscore': + return '_' + case 'line': + default: + return '|' + } + } + + return ( + + {displayedText} + {shouldShowCursor && ( + + {getCursorChar()} + + )} + + ) +} diff --git a/packages/extension/src/entrypoints/sidepanel/App.tsx b/packages/extension/src/entrypoints/sidepanel/App.tsx index 372b156..e89b688 100644 --- a/packages/extension/src/entrypoints/sidepanel/App.tsx +++ b/packages/extension/src/entrypoints/sidepanel/App.tsx @@ -125,7 +125,7 @@ export default function App() { Page Agent Ext -
+