去年项目有个需求:需要展示产品的案例页,要求展示标题、图片、表格、公式;并提取标题生成目录,支持目录跳转、滚动高亮目录需求。考虑到多个案例,并且案例可能多次修改,我决定弄一个markdown编辑器,让公司产品可以在编辑器自由发挥,然后在案例展示页根据需求展示markdown文本。我调研了一些常用的react的markdown编辑器,选择了react-markdown-editor-lite组件作为编辑器工具。react-markdown组件进行编辑器md文本以及案例展示页文本解析。 同时引入remark-gfm插件解析GitHub Flavored Markdown语法,remark-math和rehype-katex插件解析公式。使用markdown-navbar插件识别目录,通过监听滚动事件高亮目录。
项目遇到一些难点:
1.如何保存图片?
对编辑器来说需要插入一个图片url,对用户来说,他上传的是一个图片,那如何将图片转换为地址呢。这里使用的react-markdown-editor-lite插件带的onCustomImageUpload方法,我的处理方法是点击编辑器的图片按钮,弹窗进行图片上传,关闭弹窗时将后端返回的地址插入到编辑器中。实现了用户上传获取后端url地址并成功解析图片。
2.如何滚动时高亮目录?
使用markdown-navbar可以根据markdown内容自动生成目录,通过瞄点可以实现点击目录跳转功能。滚动页面时如何高亮目录呢?很容易想到监听鼠标滚动事件。当标题所在的div元素滚动到离视口一定区域(这里我使用的是距离顶部**位置时,在目录对应的元素上添加高亮的className)。
必要的引入
需要的插件将在文章后面进行介绍,基本都是npm i 插件名进行下载的
import MarkdownIt from 'markdown-it'
import MdEditor from 'react-markdown-editor-lite'
// 导入编辑器的样式
import 'react-markdown-editor-lite/lib/index.css'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' // 引入remark-gfm插件来支持GitHub Flavored Markdown语法
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw' //解析为HTML标签的插件
编辑功能实现
使用MdEditor显示编辑插件,在renderHTML根据需要添加解析html的组件,这里使用的是react-markdown。进一步,react-markdown里解析html也需要详细的插件,这里必要的插件remarkGfm。其次,remarkMath解析数字。rehypeKatex解析latex类似的公式,这里强调一下,最好加上output:'html',不然解析的公式样式不对,在页面中显示在底部空白区域。
<MdEditor
className="markdown-editor"
renderHTML={(text) => {
return (
<ReactMarkdown
remarkPlugins={[
remarkGfm,
[remarkMath, { singleTilde: false }], // 注意这里要传递对象参数
]}
rehypePlugins={[rehypeRaw, [rehypeKatex, { output: 'html' }]]}
>
{text}
</ReactMarkdown>
)
}}
value={editorValue}
onChange={({ html, text }) => {
handleEditorChange({ html, text })
}}
onCustomImageUpload={handleImageUpload}
/>
singleTilde: false
这个配置项的作用是不将单波浪线~
解释为数学公式的符号。这样做的好处是可以避免在文本中使用~
表示非数学意义的波浪线时出现误解或错误的数学公式解析。
rehypeRaw
:
rehypeRaw
插件允许你在 HTML 中保留原始的 HTML 结构。这对于一些需要处理未转义的 HTML 内容的情况非常有用。例如,如果你希望保留<script>
标签或者自定义的 HTML 元素,你可以使用这个插件。
rehypeKatex
:
rehypeKatex
插件用于支持 KaTeX 数学公式渲染。KaTeX 是一个快速、易于使用的数学公式渲染库,可以用来渲染 LaTeX 格式的数学公式。通过使用rehypeKatex
,你可以方便地在 Markdown 文档中插入数学公式,并且这些公式会在生成的 HTML 中被正确渲染。
{ output: 'html' }
:
{ output: 'html' }
是传递给rehypeKatex
插件的配置选项。它指定了输出格式为 HTML。这意味着 KaTeX 渲染后的数学公式将以 HTML 格式嵌入到最终的 HTML 文档中,而不是其他可能的格式(例如 SVG)。
上传图片保存后端图片地址
在onCustomImageUpload方法中添加自定义事件,这里我添加一个antd 的Modal组件,点击编辑器的图片按钮时打开该上传弹框。点击确定后将后端保存的地址嵌入到编辑器文本末尾。
const insertImageToEditor = () => {
// 获取当前编辑器的文本
let text = editorValue
uploadFileList.map((item) => {
if (item.status == 'done') {
const markdownText = `<center>
<img src= "后端返回的图片地址" />
</center>
`
text = text.slice(0) + markdownText
}
})
// 更新编辑器的内容
setEditorValue(text)
handleEditorChange({ html: mdParser.render(text), text })
}
md文件转前端页面实现
markdown显示和编辑器类似,都是用react-markdown解析md文本。不一样的是不用包在编译器里面。
这里在components离进行了h1-h3以及p标签的识别,是为了配合UI进行样式自定义。调整显示的文本字体大小、颜色、行高等。同时也是为了能够滚动高亮目录,定位页面的标题元素。
<ReactMarkdown
remarkPlugins={[
remarkGfm,
[remarkMath, { singleTilde: false }], // 注意这里要传递对象参数
]}
rehypePlugins={[rehypeRaw, [rehypeKatex, { output: 'html' }]]}
components={{
h1({ children }) {
return (
<h1 className="case-detail-title" id={children?.toString()}>
{children}
</h1>
)
},
h2({ children }) {
return (
<h2 className="case-detail-subtitle" id={children?.toString()}>
{children}
</h2>
)
},
h3({ children }) {
return (
<h3
className="case-detail-subtitle_3"
id={children?.toString()}
>
{children}
</h3>
)
},
p({ children }) {
return <p className="case-detail-text">{children}</p>
},
}}
>
{source?.content}
</ReactMarkdown>
md解析表格无法渲染?
遇到在页面某些无法识别的样式可以手动添加css文件进行样式适配。同时通过查看页面元素,可以为元素设置统一的样式。
table {
border: 1px solid #f6f6f6;
font-size: 14px;
line-height: 1.7;
max-width: 100%;
overflow: auto;
border-collapse: collapse;
border-spacing: 0;
box-sizing: border-box;
color: #333;
tr {
border: 1px solid #f6f6f6;
th {
text-align: center;
font-weight: 700;
border: 1px solid #efefef;
padding: 10px 6px;
background-color: #f5f7fa;
word-break: break-word;
}
}
}
td {
border: 1px solid #efefef;
text-align: left;
padding: 10px 15px;
word-break: break-word;
min-width: 60px;
}
table tr:nth-child(2n) {
background-color: transparent;
}
目录功能实现
引入markdown-navbar插件
import MarkNav from 'markdown-navbar' // markdown 目录
MarkNav自动识别目录结构。
source.content是后端保存的之前编辑器上传的markdown文本。
onNavItemClick方法是点击目录后抛出的方法,在这个方法中实现点击跳转到目录功能
<MarkNav
ordered={false}
source={source?.content as string}
updateHashAuto={true}
declarative={true}
onNavItemClick={(event, element, hashValue) => {
scrollToElement(hashValue)
}}
onHashChange={(newHash, oldHash) => {
window.location.hash = newHash
}}
></MarkNav>
点击目录进行跳转
使用瞄点的方式,点击元素后使用scrollIntoView将元素滚动到视口顶部。但是由于下面的滚动高亮进行了元素的监听,为了避免点击目录的滚动和滚动高亮同时对目录高亮操作。这里在点击目录的时候移除滚动的事件监听
// 滚动到锚点
const scrollToElement = (elementId: string) => {
const element = document.getElementById(elementId)
if (element) {
// 移除滚动事件监听器
contentRef?.current?.removeEventListener('scroll', handleScroll)
// 平滑滚动到指定元素
element.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
// 在滚动完成后重新添加滚动事件监听器
setTimeout(() => {
contentRef?.current?.addEventListener('scroll', handleScroll)
}, 1000)
}
}
滚动高亮目录功能实现
这里实现的逻辑就是添加滚动事件监听,获取所有的标题元素。对移到视口顶部的元素高亮元素所在目录。
useEffect(() => {
return () => {
contentRef?.current?.removeEventListener('scroll', handleScroll)
markNav?.current?.removeEventListener('wheel', handleWheel)
}
}, [])
useEffect(() => {
if (contentRef.current) {
contentRef?.current?.addEventListener('scroll', handleScroll)
}
markNav?.current?.addEventListener('wheel', handleWheel)
}, [])
const handleScroll = useCallback(() => {
const sections = document.querySelectorAll(
'.case-detail-subtitle, .case-detail-subtitle_3',
)
let closestSection = null
let minDistance = Infinity
let hightIndex = -1
sections.forEach((section, index) => {
const sectionTop = (section as HTMLElement).offsetTop
const distanceFromTop =
(contentRef.current as HTMLElement).scrollTop + 170 - sectionTop
// 可见视口内找到距离顶部最近的元素
if (distanceFromTop >= 0 && distanceFromTop <= minDistance) {
minDistance = distanceFromTop
closestSection = section
hightIndex = index
}
})
// 移除所有已有的 active 类
const directoryItems = document.querySelectorAll(
'.markdown-navigation .title-anchor',
)
directoryItems.forEach((item) => item.classList.remove('active'))
if (closestSection) {
const hashValue = `#${(closestSection as HTMLElement).id}`
window.location.hash = hashValue
const menu = document.querySelector('.markdown-navigation')
menu?.children[hightIndex].classList.add('active')
}
}, [contentRef])
对使用到的插件进行介绍
react-markdown-editor-lite编辑器插件
react-markdown-editor-lite
是一个基于 React 的轻量级 Markdown 编辑器组件。它提供了简单易用的接口来创建一个集 Markdown 编辑与预览于一体的编辑器。这个库非常适合那些需要在项目中集成 Markdown 功能的应用,比如博客系统、文档编写工具等。
该组件的主要特点包括:
- 轻量级:相比其他复杂的 Markdown 编辑器,
react-markdown-editor-lite
尽可能减少了不必要的依赖和功能,使得其体积较小,加载更快。 - 易于集成:作为 React 组件,它可以轻松地嵌入到任何 React 应用中。
- Markdown 支持:支持标准的 Markdown 语法,同时也可以配置额外的语法扩展。
- 实时预览:用户在编辑 Markdown 文本时,可以实时看到渲染后的效果。
要使用 react-markdown-editor-lite
,首先需要安装它。可以通过 npm 或 yarn 来安装:
npm install react-markdown-editor-lite
# 或者
yarn add react-markdown-editor-lite
react-markdown插件
react-markdown
是一个用于 React 应用的库,它允许你将 Markdown 格式的文本转换为 HTML,以便在网页上进行渲染。react-markdown
是非常流行的一个库,因为它简单易用,并且支持丰富的 Markdown 语法扩展。
主要特点
- 强大的 Markdown 支持:
react-markdown
支持标准的 Markdown 语法以及许多常用的扩展,如表格、脚注、任务列表等。 - 高度可定制化:你可以自定义如何渲染不同的 Markdown 元素,例如,你可以自定义标题、列表、链接等的样式。
- 易于集成:作为一个 React 组件,
react-markdown
可以很容易地集成到现有的 React 应用中。
安装
你可以通过 npm 或 yarn 来安装 react-markdown
:
npm install react-markdown
# 或者
yarn add react-markdown
rehype-katex插件
rehype-katex插件的主要作用是将 Markdown 文档中的 LaTeX 公式转换为美观的 HTML 渲染效果。具体来说,
rehype-katex
插件的作用是将 Markdown 中的 LaTeX 公式解析并转换成可以在网页上显示的 HTML 格式的数学公式。
npm install rehype-katex
rehype-raw插件
rehype-raw
是一个用于在使用 Rehype(一个用于处理 HTML 的工具)时允许解析和渲染未经处理的 HTML 代码的插件。默认情况下,Rehype 会过滤掉一些可能不安全的 HTML 标签和属性,以防止 XSS(跨站脚本攻击)。但有时你可能需要保留一些特定的 HTML 结构,这时rehype-raw
就派上用场了。
npm install rehype-raw
remark-math插件
remark-math
是一个用于在 Markdown 中处理数学公式的 Remark.js 插件。Remark.js 是一个用于解析 Markdown 的工具。remark-math
插件使得在 Markdown 文档中使用 LaTeX 数学公式变得更加简单。
npm install remark-math
remark-gfm插件
remark-gfm
是一个用于在 Markdown 中启用 GitHub Flavored Markdown (GFM) 特性的 Remark.js 插件。GitHub Flavored Markdown 是一种扩展了标准 Markdown 的语法,提供了更多的功能,如表格、任务列表等。
npm install remark-gfm