富文本解析组件
核心组件
import React, { useEffect, useState, useRef } from 'react';
import map from 'lodash/map';
import { Image } from 'antd';
import CodeBlock from '@/components/codeBlock/CodeBlock.jsx';
import TableOfContents, { extractHeadings } from './TableOfContents.jsx';
import styles from './style.module.scss';
/**
* 生成唯一的键值
* @returns {string} - 唯一的键值
*/
function generateUniqueId() {
return Math.random().toString(36).substr(2, 9);
}
/**
* 解析 HTML 字符串并转换为 React 元素数组
* @param {string} htmlString - HTML 字符串
* @param {Object} headingIds - 标题 id 映射
* @returns {Array<React.Element>} - 解析后的 React 元素数组
*/
export function parseHTML(htmlString, headingIds = {}) {
const wrapper = document.createElement('div');
wrapper.innerHTML = htmlString;
const elements = map(Array.from(wrapper.childNodes), (node) => convertNodeToElement(node, headingIds));
return elements;
}
/**
* 将 HTML 节点转换为 React 元素
* @param {Node} node - HTML 节点
* @param {Object} headingIds - 标题 id 映射
* @returns {React.Element} - 转换后的 React 元素
*/
function convertNodeToElement(node, headingIds) {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent.trim() === '') {
return null;
}
return node.textContent;
}
const tagName = node.tagName.toLowerCase();
const props = { key: generateUniqueId() };
const inlineStyles = {};
Array.from(node.style).forEach((property) => {
inlineStyles[property] = node.style.getPropertyValue(property);
});
props.style = inlineStyles;
// 给标题元素生成并应用 id
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
const id = node.textContent.trim().toLowerCase().replace(/\s+/g, '-');
props.id = id;
headingIds[node.textContent.trim()] = id;
}
if (tagName === 'img') {
return (
<Image
key={generateUniqueId()}
style={inlineStyles}
src={node.getAttribute('src')}
alt={node.getAttribute('alt')}
/>
);
}
if (tagName === 'a') {
props.target = '_blank';
props.href = node.href || "";
}
if (tagName === 'hr' || tagName === 'br') {
return React.createElement(tagName, props);
}
if (tagName === 'input') {
const attributes = Array.from(node.attributes);
attributes.forEach((attribute) => {
props[attribute.name] = attribute.value;
});
return React.createElement(tagName, props);
}
if (tagName === 'video') {
const videoProps = {
style: inlineStyles,
controls: true,
poster: node.getAttribute('poster'),
key: generateUniqueId(),
};
const sourceElements = map(Array.from(node.getElementsByTagName('source')), (sourceNode) => {
return (
<source
key={generateUniqueId()}
src={sourceNode.getAttribute('src')}
type={sourceNode.getAttribute('type')}
/>
);
});
return <video {...videoProps}>{sourceElements}</video>;
}
if (node.nodeType === Node.ELEMENT_NODE && tagName === 'pre') {
const codeNode = node.querySelector('code');
if (codeNode) {
const language = codeNode.getAttribute('class')?.split(' ')[0].replace('language-', '');
return (
<CodeBlock
key={generateUniqueId()}
language={language}
code={codeNode.textContent.trim()}
/>
);
}
}
const children = map(Array.from(node.childNodes), (childNode) => convertNodeToElement(childNode, headingIds));
return React.createElement(tagName, props, children);
}
/**
* 富文本解析器组件
* @param {Object} props - 组件属性
* @param {string} props.html - HTML 字符串
* @param {ReactElement} props.slotTop - 插槽
* @param {ReactElement} props.slotBottom - 插槽
* @returns {React.Element} - 富文本解析器组件
*/
// eslint-disable-next-line react/prop-types
const RichTextParser = ({ html, slotTop, slotBottom }) => {
const [elements, setElements] = useState([]);
const [headings, setHeadings] = useState([]);
const contentRef = useRef(null);
useEffect(() => {
const headingIds = {};
const parsedElements = parseHTML(html, headingIds);
const extractedHeadings = extractHeadings(html).map(heading => ({
...heading,
id: headingIds[heading.text]
}));
setElements(parsedElements);
setHeadings(() => extractedHeadings);
}, [html]);
return (
<div className={styles['richText_parser']}>
{slotTop}
<div className={styles['content']} ref={contentRef}>
{elements}
{slotBottom}
</div>
<TableOfContents headings={headings} oneKEY={headings[0]} getContainer={() => contentRef.current} />
</div>
);
};
export default RichTextParser;
目录组件
/* eslint-disable react/prop-types */
import { useEffect, useState } from 'react';
import { Anchor } from 'antd';
import map from 'lodash/map';
const { Link } = Anchor;
/**
* 提取 HTML 字符串中的标题元素,并生成唯一的 id
* @param {string} htmlString - HTML 字符串
* @returns {Array<Object>} - 包含标题信息的数组
*/
// eslint-disable-next-line react-refresh/only-export-components
export function extractHeadings(htmlString) {
const wrapper = document.createElement('div');
wrapper.innerHTML = htmlString;
const headings = [];
wrapper.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((heading) => {
const id = heading.textContent.trim().toLowerCase().replace(/\s+/g, '-');
headings.push({
id,
text: heading.textContent.trim(),
level: parseInt(heading.tagName.replace('H', ''), 10),
});
heading.id = id; // 给原始 HTML 元素添加 id
});
return headings;
}
/**
* 构建层级结构
* @param {Array<Object>} headings - 包含标题信息的数组
* @returns {Array<Object>} - 构建的层级结构
*/
const buildHierarchy = (headings) => {
const hierarchy = [];
const stack = [];
headings.forEach((heading) => {
const item = { ...heading, children: [] };
while (stack.length && stack[stack.length - 1].level >= heading.level) {
stack.pop();
}
if (stack.length === 0) {
hierarchy.push(item);
} else {
stack[stack.length - 1].children.push(item);
}
stack.push(item);
});
return hierarchy;
};
/**
* 递归渲染目录链接
* @param {Array<Object>} headings - 包含标题信息的数组
* @returns {React.Element} - 渲染的目录链接
*/
const renderLinks = (headings) => {
return map(headings, (heading) => (
<Link key={heading.id} href={`#${heading.id}`} title={heading.text}>
{renderLinks(heading.children)}
</Link>
));
};
/**
* 目录组件
* @param {Object} props - 组件属性
* @param {Array<Object>} props.headings - 标题信息数组
* @param {function} [props.getContainer] - 获取容器的函数
* @param {Object} [props.oneKEY] - 默认高亮的标题信息
* @returns {React.Element} - 目录组件
*/
const TableOfContents = ({ headings = [], getContainer, oneKEY }) => {
const hierarchy = buildHierarchy(headings);
const [activeLink, setActiveLink] = useState('');
const handleLinkChange = (link) => {
setActiveLink(link);
if (link.length === 0) {
focusFirstHeading();
}
};
const focusFirstHeading = () => {
if (oneKEY?.id) {
setActiveLink(`#${oneKEY.id}`);
}
};
useEffect(() => {
focusFirstHeading();
}, [oneKEY]);
return (
<Anchor
getCurrentAnchor={() => activeLink}
getContainer={getContainer}
affix={false}
onChange={handleLinkChange}
>
{renderLinks(hierarchy)}
</Anchor>
);
};
export default TableOfContents;
样式
@import '@/assets/scss/variables.scss';
@import '@/assets/scss/utils.scss';
/* 设置基础样式 */
:root {
--primary-color: rgb(122, 193, 67);
--primary-dark: #65A43F;
--primary-light: #E1F0D1;
--text-color: #333333;
--background-color: #F9F9F9;
}
.richText_parser {
display: flex;
.content {
width: 60vw;
overflow-y: scroll;
height: 60vh;
}
:global {
.ant-anchor {
position: fixed;
right: 10px;
}
}
li,
ul,
ol {
list-style: revert !important;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
figure,
blockquote,
dl,
dd,
ul,
ol,
li,
fieldset,
legend,
textarea,
th,
td {
margin: revert;
padding: revert;
}
a {
color: $theme-color;
}
hr {
background-color: #ccc;
border: 0;
display: block;
height: 1px;
}
blockquote {
// color: #fff;
background: #f5f2f0;
border-left: 10px solid $theme-color;
margin: 1.5em 10px;
margin-left: 0;
padding: 0.5em 10px;
quotes: "\201C" "\201D" "\2018" "\2019";
}
blockquote span {
display: block;
background-image: url(images/closequote1.gif);
background-repeat: no-repeat;
background-position: bottom right;
}
p {
white-space: pre-wrap;
/* 保留空白符并自动换行 */
word-wrap: break-word;
/* 避免长单词溢出容器 */
word-break: break-all;
/* 强制换行 */
overflow-wrap: break-word;
/* 对于长单词,允许在任何地方进行换行 */
}
// 我用的是less,其他样式请写自己的语法
table {
// width: 700px;
// 下面设置表格整体的边框,左上
border-top: 1px solid #d2d2d2;
border-left: 1px solid #d2d2d2;
tr {
width: 100%;
height: 40px; //每一行高度
th,
td {
padding: 0 1vw;
// 下面设置每个格子边框,右下
border-right: 1px solid #d2d2d2;
border-bottom: 1px solid #d2d2d2;
text-align: center;
& * {
/* 解释下这个是干啥
& 代表的就是当前选择器选中的项,也就是td
* 匹配所有的元素(因为我不确定表格里是放的文本还是别的元素什么,就加分通配符,其实写成 &>* 会更好
里面的属性是垂直居中,具体说明自己百度吧
*/
vertical-align: middle;
}
}
}
}
}