Files
MemoryBear/web/src/components/Markdown/index.tsx
2026-01-19 15:53:03 +08:00

205 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Image, Input, Select, Form, Checkbox, Radio, ColorPicker, DatePicker, TimePicker, InputNumber, Slider } from 'antd'
import ReactMarkdown from 'react-markdown'
import RemarkGfm from 'remark-gfm'
import RemarkMath from 'remark-math'
import RemarkBreaks from 'remark-breaks'
import RehypeKatex from 'rehype-katex'
import RehypeRaw from 'rehype-raw'
import type { FC } from 'react'
import { useState, useRef, useEffect } from 'react'
import Code from './Code'
import VideoBlock from './VideoBlock'
import AudioBlock from './AudioBlock'
import Link from './Link'
import RbButton from './RbButton'
interface RbMarkdownProps {
content: string;
showHtmlComments?: boolean; // 是否显示 HTML 注释,默认为 false隐藏
editable?: boolean; // 是否可编辑,默认为 false
onContentChange?: (content: string) => void; // 内容变化回调
}
const components = {
h1: ({ children, ...props }: any) => <h1 className="rb:text-2xl rb:font-bold rb:mb-2" {...props}>{children}</h1>,
h2: ({ children, ...props }: any) => <h2 className="rb:text-xl rb:font-bold rb:mb-2" {...props}>{children}</h2>,
h3: ({ children, ...props }: any) => <h3 className="rb:text-lg rb:font-bold rb:mb-2" {...props}>{children}</h3>,
h4: ({ children, ...props }: any) => <h4 className="rb:text-md rb:font-bold rb:mb-2" {...props}>{children}</h4>,
h5: ({ children, ...props }: any) => <h5 className="rb:text-sm rb:font-bold rb:mb-2" {...props}>{children}</h5>,
h6: ({ children, ...props }: any) => <h6 className="rb:text-xs rb:font-bold rb:mb-2" {...props}>{children}</h6>,
ul: ({ children, ...props }: any) => <ul className="rb:list-disc rb:ml-6 rb:mb-2" {...props}>{children}</ul>,
ol: ({ children, ...props }: any) => <ol className="rb:list-decimal rb:ml-6 rb:mb-2" {...props}>{children}</ol>,
li: ({ children, ...props }: any) => <li className="rb:mb-1" {...props}>{children}</li>,
blockquote: ({ children, ...props }: any) => <blockquote className="rb:border-l-4 rb:border-[#D9D9D9] rb:pl-4 rb:mb-2" {...props}>{children}</blockquote>,
p: ({ children, ...props }: any) => <p className="rb:mb-2" {...props}>{children}</p>,
strong: ({ children, ...props }: any) => <strong className="rb:font-bold" {...props}>{children}</strong>,
em: ({ children, ...props }: any) => <em className="rb:italic" {...props}>{children}</em>,
del: ({ children, ...props }: any) => <del className="rb:line-through" {...props}>{children}</del>,
span: ({ children, style, ...restProps }: any) => {
// 如果是 HTML 注释的 span应用特殊样式
if (style?.color === '#999') {
return <span style={{ color: '#999', fontSize: '0.9em' }}>{children}</span>
}
return <span style={style} {...restProps}>{children}</span>
},
code: ({ children, className, ...props }: any) => <Code children={String(children)} className={className || ''} {...props} />,
img: ({ src, alt, ...props }: any) => <Image src={src} alt={alt} {...props} />,
video: ({ src, ...props }: any) => <VideoBlock node={{ children: [{ properties: { src: src || '' } }] }} {...props} />,
audio: ({ src, ...props }: any) => <AudioBlock node={{ children: [{ properties: { src: src || '' } }] }} {...props} />,
a: ({ href, children, ...props }: any) => <Link href={href || '#'} {...props}>{children}</Link>,
button: ({ children }: any) => <RbButton node={{ children }}>{[children]}</RbButton>,
table: ({ children, ...props }: any) => <table className="rb:border rb:border-[#D9D9D9] rb:mb-2" {...props}>{children}</table>,
tr: ({ children, ...props }: any) => <tr className="rb:border rb:border-[#D9D9D9]" {...props}>{children}</tr>,
th: ({ children, ...props }: any) => <th className="rb:border rb:border-[#D9D9D9] rb:px-2 rb:py-1 rb:text-left rb:font-bold" {...props}>{children}</th>,
td: ({ children, ...props }: any) => <td className="rb:border rb:border-[#D9D9D9] rb:px-2 rb:py-1 rb:text-left" {...props}>{children}</td>,
input: ({ children, ...props }: any) => {
switch (props.type) {
case 'color':
return <ColorPicker {...props} />
case 'time':
return <TimePicker {...props} />
case 'date':
return <DatePicker {...props} />
case 'datetime':
case 'datetime-local':
return <DatePicker showTime={true} {...props} />
case 'week':
return <DatePicker picker="week" {...props} />
case 'month':
return <DatePicker picker="month" {...props} />
case 'number':
return <InputNumber {...props} />
case 'search':
return <Input.Search {...props} />
case 'range':
return <Slider {...props} />
case 'submit':
case 'button':
return <RbButton node={{ children: props.value || children }}>{[props.value || children]}</RbButton>
case 'checkbox':
return <Checkbox {...props}>{children}</Checkbox>
case 'password':
return <Input.Password {...props} />
case 'radio':
return <Radio {...props}>{children}</Radio>
default:
return <Input value={children} {...props} />
}
},
select: ({ children, ...props }: any) => <Select style={{width: '100%'}} {...props}>{children}</Select>,
textarea: ({ children, ...props }: any) => <Input.TextArea {...props}>{children}</Input.TextArea>,
form: ({ children, ...props }: any) => <Form {...props}>{children}</Form>,
}
const RbMarkdown: FC<RbMarkdownProps> = ({
content,
showHtmlComments = false,
editable = false,
onContentChange,
}) => {
const [editContent, setEditContent] = useState(content)
const textareaRef = useRef<any>(null)
// 当外部 content 变化时,同步更新编辑内容
useEffect(() => {
setEditContent(content)
}, [content])
// 处理 textarea 内容变化
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value
setEditContent(newContent)
// 实时回调内容变化
onContentChange?.(newContent)
}
// 根据参数决定是否将 HTML 注释转换为可见文本
// 使用特殊的 markdown 语法来显示注释,避免被 rehype-raw 过滤
const processedContent = showHtmlComments
? (editable ? editContent : content).replace(/<!--([\s\S]*?)-->/g, (_match, commentContent) => {
// 转换为带样式的文本,使用 <span class="html-comment"> 标记
const escaped = commentContent.trim().replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `<span class="html-comment">&lt;!-- ${escaped} --&gt;</span>`
})
: (editable ? editContent : content)
// 如果是编辑模式,显示 textarea
if (editable) {
return (
<div className="rb:relative">
<style>{`
.html-comment {
color: #999;
font-size: 0.9em;
}
`}</style>
{/* 编辑区域 */}
<Input.TextArea
ref={textareaRef}
value={editContent}
onChange={handleTextareaChange}
rows={10}
className="rb:font-mono rb:text-sm"
placeholder="请输入 Markdown 内容..."
style={{ resize: 'vertical' }}
/>
</div>
)
}
// 处理键盘快捷键
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
const selection = window.getSelection()
if (selection && selection.toString()) {
navigator.clipboard.writeText(selection.toString())
}
}
}
// 预览模式
return (
<div className="rb:relative" onKeyDown={handleKeyDown} tabIndex={0}>
<style>{`
.html-comment {
color: #999;
font-size: 0.9em;
}
`}</style>
<ReactMarkdown
// allowElement={[]}
// allowedElements={[]}
components={components as any}
disallowedElements={['script', 'iframe', 'head', 'html', 'meta', 'link', 'style', 'body']}
rehypePlugins={[
RehypeKatex,
RehypeRaw,
// The Rehype plug-in is used to remove the ref attribute of an element
// () => {
// return (tree) => {
// const iterate = (node: any) => {
// if (node.type === 'element' && !node.properties?.src && node.properties?.ref && node.properties.ref.startsWith('{') && node.properties.ref.endsWith('}'))
// delete node.properties.ref
// if (node.children)
// node.children.forEach(iterate)
// }
// tree.children.forEach(iterate)
// }
// },
]}
remarkPlugins={[RemarkGfm, RemarkMath, RemarkBreaks]}
remarkRehypeOptions={{
allowDangerousHtml: true,
}}
>
{processedContent}
</ReactMarkdown>
</div>
)
}
export default RbMarkdown