import React, { type CSSProperties, type ReactNode } from 'react'
import type { RichTextDocument, RichTextInlineStyle, RichTextNode, RichTextRenderOptions } from '../types'
import { cn } from './cn'
/**
* 限制图片来源协议。
*
* React 会自动转义文本和属性,但不会替我们判断 URL 协议是否安全,所以这里仍然做白名单。
*/
const sanitizeImageSource = (value: string): string | null => {
const normalized = value.trim()
if (!normalized) {
return null
}
if (/^https?:\/\//i.test(normalized)) {
return normalized
}
if (/^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[a-z0-9+/=]+$/i.test(normalized)) {
return normalized
}
return null
}
/**
* 富文本表情的默认排版。
*
* 这些规则属于 richtext 节点的渲染语义,不应该散落在各个平台模板里:
* - 表情跟随当前文字大小缩放;
* - 作为行内内容参与排版;
* - 不撑破行高,也不突破当前文本块宽度。
*/
const getRichTextEmojiStyle = (scale: number = 1): CSSProperties => ({
display: 'inline-block',
height: `${1.5 * scale}em`,
width: 'auto',
verticalAlign: scale > 1 ? 'text-bottom' : 'middle',
lineHeight: 1
})
/** 统一的搜索图标,避免 template 每次都自己重复拼 JSX。 */
const SearchKeywordIcon = ({ className }: { className?: string }) => {
return (
)
}
/** 话题图标 */
const TopicIcon = ({ className }: { className?: string }) => (
)
/** 抽奖图标 */
const LotteryIcon = ({ className }: { className?: string }) => (
)
/** 网页链接图标 */
const WebLinkIcon = ({ className }: { className?: string }) => (
)
/** 站内图文链接图标 */
const OpusLinkIcon = ({ className }: { className?: string }) => (
)
/** 投票图标 */
const VoteIcon = ({ className }: { className?: string }) => (
)
/** 查看图片图标 */
const ViewPictureIcon = ({ className }: { className?: string }) => (
)
/**
* 渲染带行内样式的文本节点。
*/
const renderStyledText = (text: string, style: RichTextInlineStyle | undefined, index: number | string): ReactNode => {
if (!style) {
return {text}
}
const classNames: string[] = []
if (style.bold) classNames.push('font-bold')
if (style.italic) classNames.push('italic')
if (style.strike) classNames.push('line-through')
const inlineStyle: CSSProperties = {}
if (style.color) {
inlineStyle.color = style.color
}
if (style.fontSize) {
inlineStyle.fontSize = style.fontSize
}
if (style.link) {
return (
{text}
)
}
if (classNames.length === 0 && Object.keys(inlineStyle).length === 0) {
return {text}
}
return (
{text}
)
}
/**
* 将单个富文本节点渲染成 React 节点。
*
* 这里的设计重点是"只在 template 侧落地 UI 语义":
* - `core` 不需要依赖 React,也不用承担 JSX/SSR 运行时边界。
* - React 负责文本与属性转义,我们只补上图片协议白名单。
*/
const renderNodeToReact = (node: RichTextNode, index: number, options: RichTextRenderOptions): ReactNode => {
switch (node.type) {
case 'text': {
const text = node.text
const urlRegex = /https?:\/\/[-\w._~:/?#[\]@!$&'()*+,;=%]+/g
if (!urlRegex.test(text)) {
return renderStyledText(text, node.style, index)
}
const parts: ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
urlRegex.lastIndex = 0
while ((match = urlRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(renderStyledText(text.slice(lastIndex, match.index), node.style, `${index}-pre-${match.index}`))
}
parts.push(
{renderStyledText(match[0], node.style, `${index}-url-${match.index}`)}
)
lastIndex = match.index + match[0].length
}
if (lastIndex < text.length) {
parts.push(renderStyledText(text.slice(lastIndex), node.style, `${index}-post`))
}
return
case 'mention':
return (
{node.text}
)
case 'searchKeyword':
return (
{node.text}
)
}
case 'topic':
return (
{node.nodes.map((child, childIndex) => renderNodeToReact(child, childIndex, options))}
) case 'image': { const safeSrc = sanitizeImageSource(node.src) if (!safeSrc) { return null } return ({node.nodes.map((child, childIndex) => renderNodeToReact(child, childIndex, options))}) case 'list': { const ListTag = node.ordered ? 'ol' : 'ul' return (
{line || ' '}