'use client'; import * as React from 'react'; import { useInView, type UseInViewOptions } from 'motion/react'; import { useTheme } from 'next-themes'; import { cn } from '@workspace/ui/lib/utils'; import { CopyButton } from '@/registry/buttons/copy'; type CodeEditorProps = Omit, 'onCopy'> & { children: string; lang: string; themes?: { light: string; dark: string; }; duration?: number; delay?: number; header?: boolean; dots?: boolean; icon?: React.ReactNode; cursor?: boolean; inView?: boolean; inViewMargin?: UseInViewOptions['margin']; inViewOnce?: boolean; copyButton?: boolean; writing?: boolean; title?: string; onDone?: () => void; onCopy?: (content: string) => void; }; function CodeEditor({ children: code, lang, themes = { light: 'github-light', dark: 'github-dark', }, duration = 5, delay = 0, className, header = true, dots = true, icon, cursor = false, inView = false, inViewMargin = '0px', inViewOnce = true, copyButton = false, writing = true, title, onDone, onCopy, ...props }: CodeEditorProps) { const { resolvedTheme } = useTheme(); const editorRef = React.useRef(null); const [visibleCode, setVisibleCode] = React.useState(''); const [highlightedCode, setHighlightedCode] = React.useState(''); const [isDone, setIsDone] = React.useState(false); const inViewResult = useInView(editorRef, { once: inViewOnce, margin: inViewMargin, }); const isInView = !inView || inViewResult; React.useEffect(() => { if (!visibleCode.length || !isInView) return; const loadHighlightedCode = async () => { try { const { codeToHtml } = await import('shiki'); const highlighted = await codeToHtml(visibleCode, { lang, themes: { light: themes.light, dark: themes.dark, }, defaultColor: resolvedTheme === 'dark' ? 'dark' : 'light', }); setHighlightedCode(highlighted); } catch (e) { console.error(`Language "${lang}" could not be loaded.`, e); } }; loadHighlightedCode(); }, [ lang, themes, writing, isInView, duration, delay, visibleCode, resolvedTheme, ]); React.useEffect(() => { if (!writing) { setVisibleCode(code); onDone?.(); return; } if (!code.length || !isInView) return; const characters = Array.from(code); let index = 0; const totalDuration = duration * 1000; const interval = totalDuration / characters.length; let intervalId: NodeJS.Timeout; const timeout = setTimeout(() => { intervalId = setInterval(() => { if (index < characters.length) { setVisibleCode((prev) => { const currentIndex = index; index += 1; return prev + characters[currentIndex]; }); editorRef.current?.scrollTo({ top: editorRef.current?.scrollHeight, behavior: 'smooth', }); } else { clearInterval(intervalId); setIsDone(true); onDone?.(); } }, interval); }, delay * 1000); return () => { clearTimeout(timeout); clearInterval(intervalId); }; }, [code, duration, delay, isInView, writing, onDone]); return (
{header ? (
{dots && (
)} {title && (
{icon ? (
{typeof icon !== 'string' ? icon : null}
) : null}
{title}
)} {copyButton ? ( ) : null}
) : ( copyButton && ( ) )}
pre,_&_code]:!bg-transparent [&>pre,_&_code]:[background:transparent_!important] [&>pre,_&_code]:border-none [&_code]:!text-[13px]', cursor && !isDone && "[&_.line:last-of-type::after]:content-['|'] [&_.line:last-of-type::after]:animate-pulse [&_.line:last-of-type::after]:inline-block [&_.line:last-of-type::after]:w-[1ch] [&_.line:last-of-type::after]:-translate-px", )} dangerouslySetInnerHTML={{ __html: highlightedCode }} />
); } export { CodeEditor, type CodeEditorProps };