背景
业务背景
前几个月做了一个业务文档风险分析的AI助理------将用户上传的业务文件(pdf、word)用AI协助分析并展示这份文档存在的所有风险点。里面有一个类似这样的需求:

左侧展示业务文档原文,右侧展示Agent根据文档原文分析后的风险点列表,点击右侧某个风险点时,根据风险点的原文段落,左侧自动滚动到对应位置,类似锚点功能。前端实现锚点有很多种方案,但这里场景有所不同:我们会将用户上传的pdf、word文件解析成html字符串并由服务端返回,前端根据这份html字符串渲染文档原文,在渲染后的内容中实现类似锚点交互。这里大家可能会想到富文本,没错项目中已经有类似的场景使用WangEditor富文本编辑器实现了文档原文渲染,本次我们为了保持统一,减少维护成本,也同样选择使用WangEditor实现,所以问题总结下来就是:在富文本渲染中实现锚点交互。我们随即对类似场景的产品进行方案调研。
方案调研
飞书
交互流程
飞书里有一个类似的功能------复制选区链接
这个功能经常用飞书的同学应该比较熟悉,点击后会复制一个这样的链接到你的剪贴板:https://xxxx.feishu.cn/xxx/OEogxxJZPkqi23xxxxdxxxxx#share-YQxxxeddoxxxxxx 用这个链接打开时,飞书会自动帮你将文档滚动到对应位置。
实现方案
我们通过控制台抓取DOM及网络请求,大概还原了这个功能的实现,仅供参考。
在了解具体实现前,需要先了解飞书文档中的几个基本概念:
- 页面id:飞书会为每个页面分配一个唯一id
- 块(block):飞书中的每一节段落都是一个block,每个block都有一个唯一id,在新增段落时(手动换行)生成
- 版本控制:飞书有针对页面级别和页面里的段落级别的版本维护
1. 点击"复制选区链接"后,请求服务端接口
在点击按钮后,飞书会请求一个名为/create的接口,这个接口会返回一个锚点id,用作链接中 xxx#share- 后的部分。下面是请求接口时的传参,服务端会将这部分传参保存,在下次用带anchor_id的链接访问时返回:

- page_id:当前页面id
- block_id:当前选区所在段落(块)的id
- src_end_idx:选区最后一个字符的索引
- src_start_idx:选区第一个字符的索引
- src_version:选区所在块的版本
- structure_version:文档结构版本
服务端返回锚点id,此时你会得到一个类似 xxx#share-xx 的链接:

2. 前端解析链接anchor_id,请求接口获取锚点信息
当你用带anchor_id的链接https://xxx.feishu.cn/xxx/OxxxKzTxxkqi23ch3dxxeg#share-YQxxdo5urkxxjqxxnpe访问时,前端会在页面加载时用 anchor_id 请求服务端接口,服务端返回创建锚点 /create 时保存的信息,前端根据服务端返回的block_id、src_start_idx、src_end_idx等计算选区所在位置信息,将页面滚动到具体位置。如果文档版本发生变化,选区被删除------即现有文档的block_id列表不存在该接口返回的block_id,提示用户选区已被删除。

总结
飞书的方案在交互层面上考虑的很周到:
- 可以定位到文档任意位置
- 方便进行版本控制
但也存在一些成本问题:
- 需要前后端配合实现,人力投入更多
- 页面进来时,需要等待接口请求完成拿到锚点信息后才能滚动页面,存在一定延迟
语雀
交互流程
语雀的交互和传统前端级别的锚点方案一致,点击目录上<a>标签渲染的小标题时,链接自动变为对应锚点的链接,后续用这个链接打开时自动滚动到对应位置。
实现方案
前端<a>标签href属性配合id
xml
<a href="#intro1">简介 简介 简介</a>
<section id="intro1">
<h2>简介 简介 简介</h2>
<p>这里演示原生锚点的平滑滚动</p>
</section>
总结
语雀实现方案比较简单------前端常见实现锚点的方式,成本低,但只能针对个别元素,给每个元素都带上id属性在真实业务场景不现实。
最终方案
结合飞书和语雀的方案,以及从可用性、成本、WangEditor可扩展性方面的考虑,我们决定让Agent在分析文档内容时,将有风险的内容用自定义html标签包裹,并分配一个自定义属性 data-risk-anchor :
javascript
// 原始DOM结构
<div>
<p>这是一段文档内容</p>
<p>这是一段文档内容</p>
// 第三段
<p>这是一段文档内容, 这是有风险的文档内容, 这是一段文档内容</p>
// 第四段
<p>
<span>这是一段文档内容, </span>
<span>这是有风险的文档内容</span>
<span>, 这是一段文档内容</span>
</p>
</div>
// Agent分析完后
<div>
<p>这是一段文档内容</p>
<p>这是一段文档内容</p>
// 第三段
<p>
<span>这是一段文档内容, </span>
<doc-risk data-risk-anchor="xxxx">这是有风险的文档内容</doc-risk>
<span>, 这是一段文档内容</span>
</p>
// 第四段
<p>
<span>这是一段文档内容, </span>
<doc-risk data-risk-anchor="xxxx">这是有风险的文档内容</doc-risk>
<span>, 这是一段文档内容</span>
</p>
</div>
前端根据 data-risk-anchor 判断风险点关联的是哪个元素,从而滚动到具体位置,那么这就需要让WangEditor渲染自定义标签,且保留标签上的自定义属性。
至于为什么要用自定义标签下文会讲到。
代码实现
对于自定义的标签属性,如果不做处理,WangEditor在渲染时会进行过滤,导致最终自定义的标签和属性无法被渲染到真实DOM中,这点可以理解,富文本内容需要做好字符串过滤避免xss攻击。
但我们查阅了大量的文章以及WangEditor官网,没有明显看到如何渲染自定义标签或属性的api,甚至在WangEditor的github的issue中,有贡献者已经明确给出了不支持的回复:github.com/wangeditor-...
但好在通过把官网例子喂给AI帮助分析和不断尝试下,我们发现渲染自定义属性和标签是可行的。
参考文档:www.wangeditor.com/v5/developm...
这篇文章在说明了如何在WangEditor中自定义渲染结果,虽然没有明确说明如何保留标签的自定义属性和有帮助的api,但可以根据文章里的流程受到一定启发。
1. 明确自定义标签的数据结构
官网中在这一步贴了这样一段代码:
go
const myResume: AttachmentElement = { // TS 语法// const resume = { // JS 语法
type: 'attachment'
fileName: 'resume.pdf'
link: 'https://xxx.com/files/resume.pdf'
children: [{ text: '' }] // void 元素必须有一个 children ,其中只有一个空字符串,重要!!!}
}
这段代码中的type、fileName、link并不是WangEditor官方的字段,而是下文渲染自定义标签时会用到的数据,这里定义的字段都会原封不动透传到下文渲染自定义标签的函数中,所以这一步只是确定你渲染自定义标签时需要哪些数据。比如我们的数据结构是这样:
arduino
const myRisk = {
type: "risk",
attrs: {
"data-anchor-id": anchorId,
},
riskText: domElem.innerText || "",
children: [{ text: "" }], // void node 必须有 children ,其中有一个空字符串,重要!!!
};
这段结构会在自定义html解析规则时用到。
2. 根据你的场景,定义 inline 和 void
typescript
import { DomEditor } from "@wangeditor/editor";
function withRisk(editor) {
const { isInline, isVoid } = editor;
const newEditor = editor;
// 根据你的场景定义是否是inline类型
newEditor.isInline = (elem) => {
const type = DomEditor.getNodeType(elem);
// risk是我们本次场景的语义化type,你可以根据你的场景自定义,和上方数据结构定义的type一致即可
if (type === "risk") return true; // 针对 type: risk,设置为 inline
return isInline(elem);
};
newEditor.isVoid = (elem) => {
const type = DomEditor.getNodeType(elem);
// risk是我们本次场景的语义化type,你可以根据你的场景自定义,和上方数据结构定义的type一致即可
if (type === "risk") return true; // 针对 type: risk,设置为 void
return isVoid(elem);
};
return newEditor; // 返回 newEditor ,重要!!!
}
这一步重写了编辑器的两个关键方法:isInline 和 isVoid
WangEditor中关于inline和void元素的定义:
-
isInline:这个方法用于判断一个元素是否是"行内"元素
- 它首先检查当前正在处理的节点 elem 的类型。如果这个节点的 type 属性是你自定义的 'risk',就返回 true,告诉编辑器:"这是一个行内元素"。否则,就沿用编辑器原来的判断逻辑。
-
isVoid:这个方法用于判断一个元素是否是"void"元素
- 如果节点类型是 'risk',就返回 true,告诉编辑器:"这是一个 void 元素"。否则,沿用原来的逻辑。
为什么要设置成 inline 和 void?
- inline:这意味着元素在段落中与普通文本混排,类似将元素设置成css属性中的display: inline,当然这里可以根据你的场景定义是否需要与其他文本混排
- void:这是从 HTML 的 void element 概念借用的。在编辑器中,void 元素没有子内容,它本身就是一个自包含的"整体",例如图片 或视频 。用户不能在其内部输入文字。文档中也特别提示了 void 元素的 children 需要有一个只包含空字符串的子节点,这是 Slate 框架的要求。
3. 渲染自定义元素
定义好数据结构,以及处理好inline和void后,开始将元素渲染成你需要的样子,这里会用到一个依赖包:snabbdom,执行npm install或dx add即可安装。
javascript
import { SlateElement } from "@wangeditor/editor";
import { h } from "snabbdom";
/**
* 渲染"风险"元素到编辑器
* @param elem 风险元素,即上文的 myRisk
* @param children 元素子节点,void 元素可忽略
* @param editor 编辑器实例
* @returns vnode 节点(通过 snabbdom.js 的 h 函数生成)
*/
function renderRisk(elem: SlateElement, children, editor) {
// 附件 icon 图标 vnode
const iconVnode = h(
// HTML tag
"img",
// HTML 属性
{
props: {
src: "https://xxxx.com/node-common/1xxxf-0xxxfaf-384-384.png",
}, // HTML 属性,驼峰式写法
style: { width: "1em", margin: "0 0.1em 0 0.1em" /* 其他... */ }, // HTML style ,驼峰式写法
},
// img 没有子节点,所以第三个参数不用写
);
// 附件元素 vnode
const riskNode = h(
// HTML tag
"span",
// HTML 属性、样式、事件
{
props: { contentEditable: false }, // HTML 属性,驼峰式写法
style: { marginLeft: "3px", color: "orange" }, // style ,驼峰式写法
on: {
click() {
console.log("clicked");
} /* 其他... */ ,
},
attrs: {
// elem.attrs = 数据结构中传入的 attrs
"data-anchor-id": elem.attrs["data-anchor-id"],
},
},
// 子节点
[iconVnode, elem.riskText, iconVnode],
);
return riskNode;
}
const renderElemConf = {
type: "risk", // 新元素 type ,重要!!!
renderElem: renderRisk,
};
snabbdom中会导出一个 h 函数,传参方式类似AST中对DOM的描述。根据riskNode可以看出,这里的渲染结构是:
css
<span>
<img />
风险内容
<img />
</span>
最终渲染结果,你可以根据你的场景渲染成任何你想要的形式:
通过 h 函数的 attrs 属性将自定义属性渲染到DOM中,执行这一步我们最终渲染的DOM上就会保留自定义属性了。
4. 自定义Html解析规则
关键一步。在WangEditor渲染时,如果原始DOM中有符合selector规则的DOM,会走你定义的解析方法:parseElemHtml,上文提到的数据结构就是在这个方法中派上用场,会作为第一个参数传递给上文定义的renderRisk方法。同时我认为这也是上文定义的所有方法的入口,只有这里的selector匹配上,上文定义的那些方法才会被执行
javascript
const parseTemplateConf = {
selector: "doc-risk[data-anchor-id]",
parseElemHtml: (domElem: Element) => {
const anchorId = domElem.getAttribute("data-anchor-id") || "";
const myRisk = {
// 其他方法注册时给的type需要和这里的type统一
type: "risk",
attrs: {
"data-anchor-id": anchorId,
},
riskText: domElem.innerText || "",
children: [{ text: "" }], // void node 必须有 children ,其中有一个空字符串,重要!!!
};
return myRisk;
},
};
拿到原始DOM上的 data-anchor-id 的值,并通过 attrs 字段传递给上文的renderRisk。
这一步也踩过一些坑:
- 经过测试,selector如果写成 p[data-anchor-id] 或 span[data-anchor-id],parseElemHtml 不会执行,这也导致我们一直以为这个方法不起作用,浪费了大量的调研时间。所以这里用了自定义标签 。
- 自定义标签不可被span包裹,否则 selector 也会失效导致 parseElemHtml 不执行
5. 最后一步:注册事件
定义好解析规则与对应方法后,你需要把这些内容注册到WangEditor中:
javascript
import { Boot } from "@wangeditor/editor";
// 上文的 withRisk
Boot.registerPlugin(withRisk);
// 上文的 renderElemConf
Boot.registerRenderElem(renderElemConf);
// 上文的 parseTemplateConf
Boot.registerParseElemHtml(parseTemplateConf);
现在,你的WangEditor可以渲染标签自定义属性了。
6. 完整代码
javascript
import { useEffect, useMemo, useState, useRef } from "react";
import { Editor } from "@wangeditor/editor-for-react";
import "@wangeditor/editor/dist/css/style.css";
import "./App.css";
import { Boot } from "@wangeditor/editor";
import { withRisk, renderElemConf, parseTemplateConf } from "./editorConfig";
Boot.registerPlugin(withRisk);
Boot.registerRenderElem(renderElemConf);
Boot.registerParseElemHtml(parseTemplateConf);
function App() {
const [editor, setEditor] = useState(null);
const editorWrapperRef = useRef<HTMLDivElement | null>(null);
const html = useMemo(
() =>
`<html><body><div>
<p>这是一段文档内容</p>
<p>
<span>123</span>
<span>456</span>
</p>
<p data-anchor-id="4qw123dfg">
<span>这是一段文档内容, </span>
<doc-risk data-anchor-id="4qw123dfg">这是一段有风险的内容</doc-risk>
</p>
${Array.from({ length: 50 })
.map(( _ , index) => `<p>${index}</p>`)
.join("")}
<p>
<span>
文档内容文档内容文档内容文档内容文档内容
文档内容文档内容文档内容文档内容文档内容
文档内容文档内容文档内容文档内容文档内容
</span>
<doc-risk data-anchor-id="s8sdf7g88s">这是一段有风险的内容</doc-risk>
<span>
文档内容文档内容文档内容文档内容文档内容
文档内容文档内容文档内容文档内容文档内容
文档内容文档内容文档内容文档内容文档内容
</span>
</p>
${Array.from({ length: 50 })
.map(( _ , index) => `<p>${index}</p>`)
.join("")}
<p>end</p>
</div></body></html>`,
[],
);
useEffect(() => {
if (!editor) return;
editor.clear();
editor.dangerouslyInsertHtml(html);
editor.disable();
return () => {
editor.destroy();
};
}, [editor, html]);
return (
<div>
<div
ref={editorWrapperRef}
style={{
width: "800px",
height: "500px",
overflow: "auto",
}}
>
<Editor
defaultConfig={{
customPaste() {
return false;
},
// 建议保留
autoFocus: true,
}}
onCreated={setEditor}
mode="default"
/>
</div>
</div>
);
}
export default App;
typescript
import { DomEditor, SlateElement } from "@wangeditor/editor";
import { h } from "snabbdom";
function withRisk(editor) {
const { isInline, isVoid } = editor;
const newEditor = editor;
newEditor.isInline = (elem) => {
const type = DomEditor.getNodeType(elem);
if (type === "risk") return true; // 针对 type: risk ,设置为 inline
return isInline(elem);
};
newEditor.isVoid = (elem) => {
const type = DomEditor.getNodeType(elem);
if (type === "risk") return true; // 针对 type: risk ,设置为 void
return isVoid(elem);
};
return newEditor; // 返回 newEditor ,重要!!!
}
/**
* 渲染"风险"元素到编辑器
* @param elem 风险元素,即上文的 myRisk
* @param children 元素子节点,void 元素可忽略
* @param editor 编辑器实例
* @returns vnode 节点(通过 snabbdom.js 的 h 函数生成)
*/
function renderRisk(elem: SlateElement, children, editor) {
// 附件 icon 图标 vnode
const iconVnode = h(
// HTML tag
"img",
// HTML 属性
{
props: {
src: "https://xxx.com/node-common/1f7xxx08eexxxaff-baa3ac130xxx4-384.png",
}, // HTML 属性,驼峰式写法
style: { width: "1em", margin: "0 0.1em 0 0.1em" /* 其他... */ }, // HTML style ,驼峰式写法
},
// img 没有子节点,所以第三个参数不用写
);
// 附件元素 vnode
const riskNode = h(
// HTML tag
"span",
// HTML 属性、样式、事件
{
props: { contentEditable: false }, // HTML 属性,驼峰式写法
style: { marginLeft: "3px", color: "orange" }, // style ,驼峰式写法
on: {
click() {
console.log("clicked");
} /* 其他... */ ,
},
attrs: {
"data-anchor-id": elem.attrs["data-anchor-id"],
},
},
// 子节点
[iconVnode, elem.riskText, iconVnode],
);
return riskNode;
}
const parseTemplateConf = {
selector: "doc-risk[data-anchor-id]",
parseElemHtml: (domElem: Element) => {
const anchorId = domElem.getAttribute("data-anchor-id") || "";
const myRisk = {
type: "risk",
attrs: {
"data-anchor-id": anchorId,
},
riskText: domElem.innerText || "",
children: [{ text: "" }], // void node 必须有 children ,其中有一个空字符串,重要!!!
};
return myRisk;
},
};
const renderElemConf = {
type: "risk", // 新元素 type ,重要!!!
renderElem: renderRisk,
};
export { renderElemConf, withRisk, parseTemplateConf };
总结
WangEditor可以通过注册 registerParseElemHtml 配合其他辅助函数实现保留标签自定义属性,但需要注意registerParseElemHtml 中 selector 的某些写法会导致 registerParseElemHtml 不被执行,容易误导开发者。平时在进行方案调研时,可以将一些案例喂给AI帮忙分析,也许会有收获。
拓展
- 什么是slate.js - juejin.cn/post/706817...
- 什么是snabbdom.js - snabbdom是一个虚拟dom算法库,它的特点是效率高、可扩展,Vue 虚拟DOM就是基于该库进行改造。
