WangEditor渲染标签自定义属性的探索

背景

业务背景

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

左侧展示业务文档原文,右侧展示Agent根据文档原文分析后的风险点列表,点击右侧某个风险点时,根据风险点的原文段落,左侧自动滚动到对应位置,类似锚点功能。前端实现锚点有很多种方案,但这里场景有所不同:我们会将用户上传的pdf、word文件解析成html字符串并由服务端返回,前端根据这份html字符串渲染文档原文,在渲染后的内容中实现类似锚点交互。这里大家可能会想到富文本,没错项目中已经有类似的场景使用WangEditor富文本编辑器实现了文档原文渲染,本次我们为了保持统一,减少维护成本,也同样选择使用WangEditor实现,所以问题总结下来就是:在富文本渲染中实现锚点交互。我们随即对类似场景的产品进行方案调研。

方案调研

飞书

交互流程

飞书里有一个类似的功能------复制选区链接

这个功能经常用飞书的同学应该比较熟悉,点击后会复制一个这样的链接到你的剪贴板:https://xxxx.feishu.cn/xxx/OEogxxJZPkqi23xxxxdxxxxx#share-YQxxxeddoxxxxxx 用这个链接打开时,飞书会自动帮你将文档滚动到对应位置。

实现方案

我们通过控制台抓取DOM及网络请求,大概还原了这个功能的实现,仅供参考。

在了解具体实现前,需要先了解飞书文档中的几个基本概念:

  1. 页面id:飞书会为每个页面分配一个唯一id
  2. 块(block):飞书中的每一节段落都是一个block,每个block都有一个唯一id,在新增段落时(手动换行)生成
  1. 版本控制:飞书有针对页面级别和页面里的段落级别的版本维护
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,提示用户选区已被删除。

总结

飞书的方案在交互层面上考虑的很周到:

  1. 可以定位到文档任意位置
  2. 方便进行版本控制

但也存在一些成本问题:

  1. 需要前后端配合实现,人力投入更多
  2. 页面进来时,需要等待接口请求完成拿到锚点信息后才能滚动页面,存在一定延迟

语雀

交互流程

语雀的交互和传统前端级别的锚点方案一致,点击目录上<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元素的定义:

  1. inline:www.wangeditor.com/v5/node-def...
  2. void:www.wangeditor.com/v5/node-def...
  • 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。

这一步也踩过一些坑:

  1. 经过测试,selector如果写成 p[data-anchor-id] 或 span[data-anchor-id],parseElemHtml 不会执行,这也导致我们一直以为这个方法不起作用,浪费了大量的调研时间。所以这里用了自定义标签 。
  2. 自定义标签不可被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帮忙分析,也许会有收获。

拓展

  1. 什么是slate.js - juejin.cn/post/706817...
  2. 什么是snabbdom.js - snabbdom是一个虚拟dom算法库,它的特点是效率高、可扩展,Vue 虚拟DOM就是基于该库进行改造。
相关推荐
kisshyshy1 小时前
从零搭建全栈应用:模块化思想 + 语义化HTML + JSON‑Server快速Mock
前端
yamsfeer1 小时前
电商自动化支付全链路技术拆解:从Playwright到扫码支付的底层原理
前端
沙漠1 小时前
React Native-SyncFormatEdittext:用 JSI 实现零闪烁的实时文本格式化
前端·react native
超人气王1 小时前
JavaScript新手基础入门——this指针指向,一文带你搞清楚
前端·javascript
码上有光1 小时前
c++模板进阶知识讲解(对模板的进一步的运用与理解)
java·前端·c++·特化·模板进阶·偏特化
嘟嘟07171 小时前
Python切片技巧×DeepSeek API:手把手教你打造智能商品文案生成器
前端·javascript
环境工程笔记1 小时前
给 Agent 浏览器任务加一个 Verification Gate:遇到验证页时该如何优雅暂停
前端
一步一个脚印一个坑1 小时前
页面性能监控中”资源加载”指标的深度解析:为什么静态资源加载时间和页面资源加载时间对不上?
前端
是你的小橘呀1 小时前
模型总说瞎话?RAG 技术帮你用私域数据精准 “校准” 大模型
前端