127 lines
6.1 KiB
TypeScript
127 lines
6.1 KiB
TypeScript
import React, { useState } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import rehypeRaw from 'rehype-raw';
|
|
|
|
interface MarkdownRendererProps {
|
|
content: string;
|
|
className?: string;
|
|
onCitationClick?: (index: number) => void;
|
|
}
|
|
|
|
const extractTextFromNode = (node: React.ReactNode): string => {
|
|
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
if (Array.isArray(node)) return node.map(extractTextFromNode).join('');
|
|
if (React.isValidElement<{ children?: React.ReactNode }>(node)) return extractTextFromNode(node.props.children);
|
|
return '';
|
|
};
|
|
|
|
const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => {
|
|
const [copied, setCopied] = useState(false);
|
|
const codeText = extractTextFromNode(children).replace(/\n$/, '');
|
|
const language = className?.match(/language-(\w+)/)?.[1];
|
|
|
|
const handleCopy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(codeText);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1500);
|
|
} catch (error) {
|
|
console.error('复制代码失败:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="group relative my-4 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm dark:border-slate-700 dark:bg-slate-900">
|
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
|
|
<span>{language || 'code'}</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
|
>
|
|
{copied ? '已复制' : '复制'}
|
|
</button>
|
|
</div>
|
|
<pre className="m-0 overflow-x-auto p-4 text-sm leading-relaxed text-gray-800 dark:text-slate-100">
|
|
<code className={className}>{children}</code>
|
|
</pre>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '', onCitationClick }) => {
|
|
const contentWithCitationBadge = content.replace(
|
|
/\[\[(\d+)\]\]/g,
|
|
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={`markdown-content ${className}`}
|
|
onClick={(event) => {
|
|
if (!onCitationClick) return;
|
|
const target = event.target as HTMLElement;
|
|
const button = target.closest('[data-citation-index]') as HTMLElement | null;
|
|
if (!button) return;
|
|
const index = Number(button.dataset.citationIndex || '0');
|
|
if (Number.isNaN(index) || index <= 0) return;
|
|
onCitationClick(index);
|
|
}}
|
|
>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
rehypePlugins={[rehypeRaw]}
|
|
components={{
|
|
h1: ({ node, ...props }) => <h1 className="text-2xl font-bold mb-4" {...props} />,
|
|
h2: ({ node, ...props }) => <h2 className="text-xl font-bold mb-3" {...props} />,
|
|
h3: ({ node, ...props }) => <h3 className="text-lg font-bold mb-2" {...props} />,
|
|
h4: ({ node, ...props }) => <h4 className="text-base font-bold mb-2" {...props} />,
|
|
h5: ({ node, ...props }) => <h5 className="text-sm font-bold mb-2" {...props} />,
|
|
h6: ({ node, ...props }) => <h6 className="text-xs font-bold mb-2" {...props} />,
|
|
p: ({ node, ...props }) => <p className="mb-4 leading-relaxed" {...props} />,
|
|
ul: ({ node, ...props }) => <ul className="list-disc list-inside mb-4 ml-4" {...props} />,
|
|
ol: ({ node, ...props }) => <ol className="list-decimal list-inside mb-4 ml-4" {...props} />,
|
|
li: ({ node, ...props }) => <li className="mb-1" {...props} />,
|
|
// code: ({ node, inline, className, children, ...props }: any) => {
|
|
// if (inline) {
|
|
// return (
|
|
// <code
|
|
// className={`rounded bg-gray-100 px-1 py-0.5 text-sm text-gray-800 dark:bg-slate-800 dark:text-slate-100 ${className || ''}`}
|
|
// {...props}
|
|
// >
|
|
// {children}
|
|
// </code>
|
|
// );
|
|
// }
|
|
// return <CodeBlock className={className}>{children}</CodeBlock>;
|
|
// },
|
|
// pre: ({ node, children }) => <>{children}</>,
|
|
blockquote: ({ node, ...props }) => (
|
|
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic my-4" {...props} />
|
|
),
|
|
a: ({ node, ...props }) => (
|
|
<a className="text-blue-600 dark:text-blue-400 hover:underline" {...props} />
|
|
),
|
|
table: ({ node, ...props }) => (
|
|
<div className="overflow-x-auto mb-4">
|
|
<table className="min-w-full border-collapse" {...props} />
|
|
</div>
|
|
),
|
|
thead: ({ node, ...props }) => <thead className="bg-gray-100 dark:bg-gray-800" {...props} />,
|
|
tbody: ({ node, ...props }) => <tbody className="divide-y divide-gray-200 dark:divide-gray-700" {...props} />,
|
|
tr: ({ node, ...props }) => <tr className="hover:bg-gray-50 dark:hover:bg-gray-700" {...props} />,
|
|
th: ({ node, ...props }) => <th className="px-4 py-2 text-left font-semibold" {...props} />,
|
|
td: ({ node, ...props }) => <td className="px-4 py-2" {...props} />,
|
|
hr: ({ node, ...props }) => <hr className="my-4 border-gray-300 dark:border-gray-600" {...props} />,
|
|
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
|
|
em: ({ node, ...props }) => <em className="italic" {...props} />,
|
|
}}
|
|
>
|
|
{contentWithCitationBadge}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MarkdownRenderer; |