Repository Public MIT
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