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如果写成 pdata-anchor-id 或 spandata-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就是基于该库进行改造。
相关推荐
RobinDevNotes10 分钟前
从 Electron 到 GPUI,9 个跨平台桌面方案该怎么选
前端·javascript·rust·electron·openresty·前端开发工具
jay神1 小时前
基于 YOLOv8 的轴承缺陷检测(Web 版)
前端·人工智能·yolo
jay神1 小时前
基于 YOLOv8 的森林火灾检测系统(Web 版)
前端·yolo
code2cat1 小时前
HTML基础篇之第一张网页:从index.html到浏览器里的任务清单
前端·html
miss2 小时前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan3 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真3 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment3 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan3 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么3 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模