205 lines
8.6 KiB
TypeScript
205 lines
8.6 KiB
TypeScript
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, '<').replace(/>/g, '>')
|
||
return `<span class="html-comment"><!-- ${escaped} --></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 |