145 lines · 5.2 KB · TypeScript
RawDownload
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";import { jsx, jsxs, Fragment } from "react/jsx-runtime";import { toJsxRuntime } from "hast-util-to-jsx-runtime";import { getHighlighter, resolveLang, THEME } from "../lib/shiki";import { cn } from "../lib/utils"; // ---------------------------------------------------------------------------// Language detection// --------------------------------------------------------------------------- function detectLanguage(code: string, hint?: string): string {  if (hint) return resolveLang(hint) ?? "json";  const trimmed = code.trimStart();  if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json";  if (trimmed.startsWith("<")) return "xml";  if (trimmed.startsWith("---")) return "yaml";  return "json";} // ---------------------------------------------------------------------------// Icons// --------------------------------------------------------------------------- const CopyIcon = () => (  <svg viewBox="0 0 16 16" className="size-3">    <rect x="5" y="5" width="8" height="8" rx="1" fill="none" stroke="currentColor" strokeWidth="1.2" />    <path d="M3 11V3h8" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />  </svg>); const CheckIcon = () => (  <svg viewBox="0 0 16 16" className="size-3">    <path d="M3 8l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />  </svg>); // ---------------------------------------------------------------------------// Highlight hook// --------------------------------------------------------------------------- function useHighlighted(code: string, lang: string): ReactNode | null {  const [highlighted, setHighlighted] = useState<ReactNode | null>(null);   useEffect(() => {    let cancelled = false;     getHighlighter().then((highlighter) => {      if (cancelled) return;       const hast = highlighter.codeToHast(code, { lang, theme: THEME });      const nodes = toJsxRuntime(hast, { jsx, jsxs, Fragment });       if (!cancelled) setHighlighted(nodes);    });     return () => { cancelled = true; };  }, [code, lang]);   return highlighted;} // ---------------------------------------------------------------------------// CodeBlock// --------------------------------------------------------------------------- export function CodeBlock(props: {  code: string;  lang?: string;  title?: string;  maxHeight?: string;  className?: string;}) {  const { code, lang: langHint, title, className } = props;  const [expanded, setExpanded] = useState(false);  const [copied, setCopied] = useState(false);   const language = useMemo(() => detectLanguage(code, langHint), [code, langHint]);  const highlighted = useHighlighted(code, language);   const lines = code.split("\n");  const isLong = lines.length > 24;  const maxH = !expanded && isLong ? props.maxHeight ?? "24rem" : undefined;   const handleCopy = useCallback(() => {    void navigator.clipboard.writeText(code).then(() => {      setCopied(true);      setTimeout(() => setCopied(false), 1500);    });  }, [code]);   return (    <div className={cn("rounded-lg border border-border bg-card/60 overflow-hidden", className)}>      {title && (        <div className="flex items-center justify-between border-b border-border px-3 py-2">          <span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">            {title}          </span>          <button            type="button"            onClick={handleCopy}            className="size-6 flex items-center justify-center text-muted-foreground/30 hover:text-muted-foreground transition-colors"            title="Copy"          >            {copied ? <CheckIcon /> : <CopyIcon />}          </button>        </div>      )}      <div className="group relative">        {!title && (          <button            type="button"            onClick={handleCopy}            className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 p-1.5 text-muted-foreground/40 opacity-0 backdrop-blur-sm hover:text-foreground group-hover:opacity-100 transition-opacity"            title="Copy to clipboard"          >            {copied ? <CheckIcon /> : <CopyIcon />}          </button>        )}         <div          className="overflow-auto text-[12px] leading-relaxed [&_pre]:!bg-transparent [&_pre]:p-3 [&_code]:font-mono"          style={maxH ? { maxHeight: maxH } : undefined}        >          {highlighted ?? (            <pre className="p-3 font-mono text-[12px] leading-relaxed text-foreground/60">              {code}            </pre>          )}        </div>         {isLong && !expanded && (          <div className="absolute bottom-0 left-0 right-0 flex justify-center bg-gradient-to-t from-card/90 to-transparent pb-2 pt-8">            <button              type="button"              onClick={() => setExpanded(true)}              className="rounded-md border border-border bg-background px-2.5 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"            >              Show all ({lines.length} lines)            </button>          </div>        )}      </div>    </div>  );}