import { memo, useEffect, useState } from 'react'; import { bundledLanguages, codeToHtml, isSpecialLang, type BundledLanguage, type SpecialLanguage } from 'shiki'; import { classNames } from '~/utils/classNames'; import { createScopedLogger } from '~/utils/logger'; import styles from './CodeBlock.module.scss'; const logger = createScopedLogger('CodeBlock'); interface CodeBlockProps { className?: string; code: string; language?: BundledLanguage | SpecialLanguage; theme?: 'light-plus' | 'dark-plus'; disableCopy?: boolean; } export const CodeBlock = memo( ({ className, code, language = 'plaintext', theme = 'dark-plus', disableCopy = false }: CodeBlockProps) => { const [html, setHTML] = useState<string | undefined>(undefined); const [copied, setCopied] = useState(false); const copyToClipboard = () => { if (copied) { return; } navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => { setCopied(false); }, 2000); }; useEffect(() => { if (language && !isSpecialLang(language) && !(language in bundledLanguages)) { logger.warn(`Unsupported language '${language}'`); } logger.trace(`Language = ${language}`); const processCode = async () => { setHTML(await codeToHtml(code, { lang: language, theme })); }; processCode(); }, [code]); return ( <div className={classNames('relative group text-left', className)}> <div className={classNames( styles.CopyButtonContainer, 'bg-white absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100', { 'rounded-l-0 opacity-100': copied, }, )} > {!disableCopy && ( <button className={classNames( 'flex items-center bg-transparent p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300', { 'before:opacity-0': !copied, 'before:opacity-100': copied, }, )} title="Copy Code" onClick={() => copyToClipboard()} > <div className="i-ph:clipboard-text-duotone"></div> </button> )} </div> <div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div> </div> ); }, );