claude-code-cchui/src/components/markdown/MarkdownRenderer.tsx
gaoziman 615a59567d feat(组件): 添加搜索图片展示和工具使用提示
SearchImagesGrid (新增):
- 瀑布流布局展示搜索图片
- 智能动态回填机制,自动替换加载失败的图片
- 支持图片灯箱预览
- 显示图片来源链接

MessageBubble:
- 添加工具使用提示栏,显示本次对话使用的工具
- 集成 SearchImagesGrid 展示图片搜索结果
- 支持 Markdown 中图片链接在灯箱中打开

MarkdownRenderer:
- 添加图片链接点击回调支持
- 识别并处理图片URL链接
2025-12-22 12:36:31 +08:00

263 lines
6.9 KiB
TypeScript

'use client';
import { memo, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CodeBlock } from './CodeBlock';
import { cn } from '@/lib/utils';
interface MarkdownRendererProps {
content: string;
className?: string;
/** 图片链接点击回调,用于在灯箱中打开图片 */
onImageLinkClick?: (url: string) => void;
}
/**
* 判断 URL 是否为图片链接
*/
function isImageUrl(url: string): boolean {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'];
const urlLower = url.toLowerCase();
// 检查扩展名(忽略查询参数)
const urlWithoutQuery = urlLower.split('?')[0];
return imageExtensions.some(ext => urlWithoutQuery.endsWith(ext));
}
/**
* 创建 Markdown 组件配置
* 使用工厂函数以支持传入回调
*/
function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
return {
// 代码块
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !className;
if (isInline) {
// 行内代码 - 无特殊样式,仅等宽字体
return (
<code
className="font-mono"
{...props}
>
{children}
</code>
);
}
// 代码块
return (
<CodeBlock
code={String(children).replace(/\n$/, '')}
language={match ? match[1] : 'text'}
/>
);
},
// 段落
p({ children }: { children?: React.ReactNode }) {
return (
<p className="mb-3 last:mb-0 leading-[1.7]">
{children}
</p>
);
},
// 标题 - 使用相对单位保持与全局字体的比例
h1({ children }: { children?: React.ReactNode }) {
return (
<h1 className="text-[1.25em] font-bold mt-5 mb-3 text-[var(--color-text-primary)]">
{children}
</h1>
);
},
h2({ children }: { children?: React.ReactNode }) {
return (
<h2 className="text-[1.125em] font-bold mt-4 mb-2 text-[var(--color-text-primary)]">
{children}
</h2>
);
},
h3({ children }: { children?: React.ReactNode }) {
return (
<h3 className="text-[1em] font-semibold mt-3 mb-2 text-[var(--color-text-primary)]">
{children}
</h3>
);
},
h4({ children }: { children?: React.ReactNode }) {
return (
<h4 className="text-[1em] font-semibold mt-2 mb-1 text-[var(--color-text-primary)]">
{children}
</h4>
);
},
// 列表
ul({ children }: { children?: React.ReactNode }) {
return (
<ul className="list-disc pl-5 my-2 space-y-1 marker:text-[#E06B3E]">
{children}
</ul>
);
},
ol({ children }: { children?: React.ReactNode }) {
return (
<ol className="list-decimal pl-5 my-2 space-y-1 marker:text-[#E06B3E]">
{children}
</ol>
);
},
li({ children }: { children?: React.ReactNode }) {
return (
<li className="leading-relaxed">
{children}
</li>
);
},
// 链接 - 支持图片链接在灯箱中打开
a({ href, children }: { href?: string; children?: React.ReactNode }) {
// 如果是图片链接且有回调,则拦截点击事件
if (href && onImageLinkClick && isImageUrl(href)) {
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
onImageLinkClick(href);
}}
className="text-[var(--color-primary)] hover:underline cursor-pointer"
>
{children}
</a>
);
}
// 非图片链接保持原有行为
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--color-primary)] hover:underline"
>
{children}
</a>
);
},
// 粗体
strong({ children }: { children?: React.ReactNode }) {
return (
<strong className="font-semibold">
{children}
</strong>
);
},
// 斜体
em({ children }: { children?: React.ReactNode }) {
return (
<em className="italic">
{children}
</em>
);
},
// 引用
blockquote({ children }: { children?: React.ReactNode }) {
return (
<blockquote className="border-l-3 border-[var(--color-primary)] pl-3 my-3 italic text-[var(--color-text-secondary)]">
{children}
</blockquote>
);
},
// 分割线
hr() {
return (
<hr className="my-4 border-[var(--color-border)]" />
);
},
// 表格
table({ children }: { children?: React.ReactNode }) {
return (
<div className="overflow-x-auto my-3">
<table className="min-w-full border border-[var(--color-border)] rounded-lg overflow-hidden text-[0.9em]">
{children}
</table>
</div>
);
},
thead({ children }: { children?: React.ReactNode }) {
return (
<thead className="bg-[var(--color-bg-tertiary)]">
{children}
</thead>
);
},
tbody({ children }: { children?: React.ReactNode }) {
return (
<tbody className="divide-y divide-[var(--color-border)]">
{children}
</tbody>
);
},
tr({ children }: { children?: React.ReactNode }) {
return (
<tr className="hover:bg-[var(--color-bg-hover)] transition-colors">
{children}
</tr>
);
},
th({ children }: { children?: React.ReactNode }) {
return (
<th className="px-3 py-1.5 text-left text-[0.85em] font-semibold text-[var(--color-text-primary)]">
{children}
</th>
);
},
td({ children }: { children?: React.ReactNode }) {
return (
<td className="px-3 py-1.5 text-[0.85em] text-[var(--color-text-secondary)]">
{children}
</td>
);
},
// 图片
img(props: React.ImgHTMLAttributes<HTMLImageElement>) {
return (
<img
{...props}
alt={props.alt || ''}
className="max-w-full h-auto rounded-lg my-3"
/>
);
},
};
}
// 使用 memo 包裹组件,避免不必要的重渲染
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick }: MarkdownRendererProps) {
// 使用 useMemo 缓存 components 配置,仅在 onImageLinkClick 变化时重新创建
const components = useMemo(
() => createMarkdownComponents(onImageLinkClick),
[onImageLinkClick]
);
return (
<div className={cn('markdown-content', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={components}
>
{content}
</ReactMarkdown>
</div>
);
});