303 lines
7.8 KiB
TypeScript
303 lines
7.8 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Children,
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ComponentType,
|
|
type RefAttributes,
|
|
} from "react";
|
|
import {
|
|
motion,
|
|
useInView,
|
|
type DOMMotionComponents,
|
|
type HTMLMotionProps,
|
|
type MotionProps,
|
|
} from "motion/react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface SequenceContextValue {
|
|
completeItem: (index: number) => void;
|
|
activeIndex: number;
|
|
sequenceStarted: boolean;
|
|
}
|
|
|
|
const SequenceContext = createContext<SequenceContextValue | null>(null);
|
|
|
|
const useSequence = () => useContext(SequenceContext);
|
|
|
|
const ItemIndexContext = createContext<number | null>(null);
|
|
const useItemIndex = () => useContext(ItemIndexContext);
|
|
|
|
const motionElements = {
|
|
article: motion.article,
|
|
div: motion.div,
|
|
h1: motion.h1,
|
|
h2: motion.h2,
|
|
h3: motion.h3,
|
|
h4: motion.h4,
|
|
h5: motion.h5,
|
|
h6: motion.h6,
|
|
li: motion.li,
|
|
p: motion.p,
|
|
section: motion.section,
|
|
span: motion.span,
|
|
} as const;
|
|
|
|
type MotionElementType = Extract<
|
|
keyof DOMMotionComponents,
|
|
keyof typeof motionElements
|
|
>;
|
|
type TerminalTypingMotionComponent = ComponentType<
|
|
Omit<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
|
|
>;
|
|
|
|
interface AnimatedSpanProps extends MotionProps {
|
|
children: React.ReactNode;
|
|
delay?: number;
|
|
className?: string;
|
|
startOnView?: boolean;
|
|
}
|
|
|
|
export const AnimatedSpan = ({
|
|
children,
|
|
delay = 0,
|
|
className,
|
|
startOnView = false,
|
|
...props
|
|
}: AnimatedSpanProps) => {
|
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
|
const isInView = useInView(elementRef as React.RefObject<Element>, {
|
|
amount: 0.3,
|
|
once: true,
|
|
});
|
|
|
|
const sequence = useSequence();
|
|
const itemIndex = useItemIndex();
|
|
const [hasStarted, setHasStarted] = useState(false);
|
|
useEffect(() => {
|
|
if (!sequence || itemIndex === null) return;
|
|
if (!sequence.sequenceStarted) return;
|
|
if (hasStarted) return;
|
|
if (sequence.activeIndex === itemIndex) {
|
|
setHasStarted(true);
|
|
}
|
|
}, [sequence, hasStarted, itemIndex]);
|
|
|
|
const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true;
|
|
|
|
return (
|
|
<motion.div
|
|
ref={elementRef}
|
|
initial={{ opacity: 0, y: -5 }}
|
|
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: -5 }}
|
|
transition={{ duration: 0.3, delay: sequence ? 0 : delay / 1000 }}
|
|
className={cn("grid text-sm font-normal tracking-tight", className)}
|
|
onAnimationComplete={() => {
|
|
if (!sequence) return;
|
|
if (itemIndex === null) return;
|
|
sequence.completeItem(itemIndex);
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
|
|
children: string;
|
|
className?: string;
|
|
duration?: number;
|
|
delay?: number;
|
|
as?: MotionElementType;
|
|
startOnView?: boolean;
|
|
}
|
|
|
|
export const TypingAnimation = ({
|
|
children,
|
|
className,
|
|
duration = 60,
|
|
delay = 0,
|
|
as: Component = "span",
|
|
startOnView = true,
|
|
...props
|
|
}: TypingAnimationProps) => {
|
|
if (typeof children !== "string") {
|
|
throw new Error("TypingAnimation: children must be a string. Received:");
|
|
}
|
|
|
|
const MotionComponent = motionElements[
|
|
Component
|
|
] as TerminalTypingMotionComponent;
|
|
|
|
const [displayedText, setDisplayedText] = useState<string>("");
|
|
const [started, setStarted] = useState(false);
|
|
const elementRef = useRef<HTMLElement | null>(null);
|
|
const isInView = useInView(elementRef as React.RefObject<Element>, {
|
|
amount: 0.3,
|
|
once: true,
|
|
});
|
|
|
|
const sequence = useSequence();
|
|
const itemIndex = useItemIndex();
|
|
const hasSequence = sequence !== null;
|
|
const sequenceStarted = sequence?.sequenceStarted ?? false;
|
|
const sequenceActiveIndex = sequence?.activeIndex ?? null;
|
|
const sequenceCompleteItemRef = useRef<
|
|
SequenceContextValue["completeItem"] | null
|
|
>(null);
|
|
const sequenceItemIndexRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
sequenceCompleteItemRef.current = sequence?.completeItem ?? null;
|
|
sequenceItemIndexRef.current = itemIndex;
|
|
}, [sequence?.completeItem, itemIndex]);
|
|
|
|
useEffect(() => {
|
|
let startTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
if (hasSequence && itemIndex !== null) {
|
|
if (sequenceStarted && !started && sequenceActiveIndex === itemIndex) {
|
|
setStarted(true);
|
|
}
|
|
} else if (!startOnView || isInView) {
|
|
startTimeout = setTimeout(() => setStarted(true), delay);
|
|
}
|
|
|
|
return () => {
|
|
if (startTimeout !== null) {
|
|
clearTimeout(startTimeout);
|
|
}
|
|
};
|
|
}, [
|
|
delay,
|
|
startOnView,
|
|
isInView,
|
|
started,
|
|
hasSequence,
|
|
sequenceActiveIndex,
|
|
sequenceStarted,
|
|
itemIndex,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
let typingEffect: ReturnType<typeof setInterval> | null = null;
|
|
|
|
if (started) {
|
|
let i = 0;
|
|
typingEffect = setInterval(() => {
|
|
if (i < children.length) {
|
|
setDisplayedText(children.substring(0, i + 1));
|
|
i++;
|
|
} else {
|
|
if (typingEffect !== null) {
|
|
clearInterval(typingEffect);
|
|
}
|
|
const completeItem = sequenceCompleteItemRef.current;
|
|
const currentItemIndex = sequenceItemIndexRef.current;
|
|
if (completeItem && currentItemIndex !== null) {
|
|
completeItem(currentItemIndex);
|
|
}
|
|
}
|
|
}, duration);
|
|
}
|
|
|
|
return () => {
|
|
if (typingEffect !== null) {
|
|
clearInterval(typingEffect);
|
|
}
|
|
};
|
|
}, [children, duration, started]);
|
|
|
|
return (
|
|
<MotionComponent
|
|
ref={elementRef}
|
|
className={cn("text-sm font-normal tracking-tight", className)}
|
|
{...props}
|
|
>
|
|
{displayedText}
|
|
</MotionComponent>
|
|
);
|
|
};
|
|
|
|
interface TerminalProps {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
sequence?: boolean;
|
|
startOnView?: boolean;
|
|
}
|
|
|
|
export const Terminal = ({
|
|
children,
|
|
className,
|
|
sequence = true,
|
|
startOnView = true,
|
|
}: TerminalProps) => {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const isInView = useInView(containerRef as React.RefObject<Element>, {
|
|
amount: 0.3,
|
|
once: true,
|
|
});
|
|
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const sequenceHasStarted = sequence ? !startOnView || isInView : false;
|
|
|
|
const contextValue = useMemo<SequenceContextValue | null>(() => {
|
|
if (!sequence) return null;
|
|
return {
|
|
completeItem: (index: number) => {
|
|
setActiveIndex((current) =>
|
|
index === current ? current + 1 : current,
|
|
);
|
|
},
|
|
activeIndex,
|
|
sequenceStarted: sequenceHasStarted,
|
|
};
|
|
}, [sequence, activeIndex, sequenceHasStarted]);
|
|
|
|
const wrappedChildren = useMemo(() => {
|
|
if (!sequence) return children;
|
|
const array = Children.toArray(children);
|
|
return array.map((child, index) => (
|
|
<ItemIndexContext.Provider key={index} value={index}>
|
|
{child as React.ReactNode}
|
|
</ItemIndexContext.Provider>
|
|
));
|
|
}, [children, sequence]);
|
|
|
|
const content = (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"z-0 h-full w-full rounded-xl border border-gray-200 bg-white text-gray-900 flex flex-col overflow-hidden",
|
|
className,
|
|
)}
|
|
>
|
|
<div className="flex flex-col gap-y-2 border-b border-gray-200 bg-gray-50 p-4 shrink-0">
|
|
<div className="flex flex-row gap-x-2">
|
|
<div className="h-3 w-3 rounded-full bg-[#ff5f56] border border-[#e0443e]"></div>
|
|
<div className="h-3 w-3 rounded-full bg-[#ffbd2e] border border-[#dea123]"></div>
|
|
<div className="h-3 w-3 rounded-full bg-[#27c93f] border border-[#1aab29]"></div>
|
|
</div>
|
|
</div>
|
|
<pre className="p-4 flex-1 overflow-auto m-0">
|
|
<code className="grid gap-y-1">{wrappedChildren}</code>
|
|
</pre>
|
|
</div>
|
|
);
|
|
|
|
if (!sequence) return content;
|
|
|
|
return (
|
|
<SequenceContext.Provider value={contextValue}>
|
|
{content}
|
|
</SequenceContext.Provider>
|
|
);
|
|
};
|