SPB Git

Repository Public MIT

markdown.tsx 4.2 KB 111 lines tsx

1import { memo, useState, type ReactNode } from "react";2import ReactMarkdown, { type Components } from "react-markdown";3import remarkGfm from "remark-gfm";4import { Check, Copy, Maximize2 } from "lucide-react";5import { cn } from "@/lib/utils";67interface MarkdownProps {8  content: string;9  className?: string;10  size?: "sm" | "md";11  /** Résout la source d'une image (fig://, chemins locaux, …). Retourne null pour masquer. */12  resolveImage?: (src: string, alt: string) => string | null;13  onImageClick?: (src: string, alt: string) => void;14}1516function CopyButton({ text, className }: { text: string; className?: string }) {17  const [copied, setCopied] = useState(false);18  return (19    <button20      type="button"21      onClick={async () => {22        try {23          await navigator.clipboard.writeText(text);24          setCopied(true);25          setTimeout(() => setCopied(false), 1600);26        } catch { /* ignore */ }27      }}28      className={cn("inline-flex items-center gap-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] font-medium text-white/70 hover:bg-white/10 hover:text-white transition", className)}29      aria-label="Copier le code"30    >31      {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}32      {copied ? "Copié" : "Copier"}33    </button>34  );35}3637function extractText(node: ReactNode): string {38  if (node == null || typeof node === "boolean") return "";39  if (typeof node === "string" || typeof node === "number") return String(node);40  if (Array.isArray(node)) return node.map(extractText).join("");41  if (typeof node === "object" && "props" in (node as { props?: { children?: ReactNode } })) {42    return extractText((node as { props: { children?: ReactNode } }).props.children);43  }44  return "";45}4647export const Markdown = memo(function Markdown({ content, className, size = "md", resolveImage, onImageClick }: MarkdownProps) {48  const components: Components = {49    a: ({ href, children }) => {50      if (href?.startsWith("fig://")) return <>{children}</>;51      return (52        <a href={href} target="_blank" rel="noopener noreferrer">53          {children}54        </a>55      );56    },57    table: ({ children }) => (58      <div className="vq-table-wrap">59        <table>{children}</table>60      </div>61    ),62    pre: ({ children }) => {63      const code = extractText(children).replace(/\n$/, "");64      // langue depuis la classe du <code> enfant65      let lang = "";66      const child = Array.isArray(children) ? children[0] : children;67      const cls = (child as { props?: { className?: string } })?.props?.className ?? "";68      const m = /language-([\w-]+)/.exec(cls);69      if (m) lang = m[1];70      return (71        <div className="relative group my-5">72          <div className="flex items-center justify-between rounded-t-[.9rem] border border-b-0 border-[hsl(228_12%_16%)] bg-[hsl(228_22%_11%)] px-3.5 py-1.5">73            <span className="font-mono text-[11px] uppercase tracking-wider text-white/50">{lang || "code"}</span>74            <CopyButton text={code} />75          </div>76          <pre className="!mt-0 !rounded-t-none">{children}</pre>77        </div>78      );79    },80    img: ({ src, alt }) => {81      const raw = src || "";82      const resolved = resolveImage ? resolveImage(raw, alt || "") : raw;83      if (!resolved) return null;84      return (85        <figure className="vq-figure not-prose">86          <button87            type="button"88            className="block w-full cursor-zoom-in"89            onClick={() => onImageClick?.(resolved, alt || "")}90            aria-label={alt ? `Agrandir : ${alt}` : "Agrandir la figure"}91          >92            <img src={resolved} alt={alt || "Figure"} loading="lazy" />93          </button>94          <figcaption>95            <span className="truncate">{alt || "Figure"}</span>96            <span className="inline-flex items-center gap-1 text-[11px] shrink-0"><Maximize2 className="h-3 w-3" /> Agrandir</span>97          </figcaption>98        </figure>99      );100    },101  };102103  return (104    <div className={cn("vq-prose", size === "sm" && "vq-prose-sm", className)}>105      <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>106        {content}107      </ReactMarkdown>108    </div>109  );110});111
Files main
Menu

Theme