wangEditor5 富文本编辑器中使用 kityformula 公式编辑器的具体实践

15 阅读5分钟

摘要

在wangEditor5 富文本编辑器中添加 kityformula 公式编辑器,实现在文字中插入公式的功能,因为需求要求,这里直接将 LaTeX 格式的公式插入富文本,而不是使用图片。

前言

开发工具:idea

jquery版本:1.11.0

wangEditor版本:5.0.0

wangEditor官网:wangEditor

kityFormula版本:1.0.0

kityFormula官网:首页 - Kity Formula HTML(SVG)数学公式呈现库

wangEditorWithFormula版本:1.0.11

wangEditorWithFormula官网:github.com/wangeditor-…

因为实际项目框架所限,这里不会使用vue等框架,直接通过js代码实现功能

正文

下面简单整理下实现方法,以及过程中踩到的坑。

(一)实现方式

1.先在项目中引入官方提供的工具 wangEditorWithFormula,需要用到里面的部分文件,借鉴部分代码。

image.png

2.然后,编写自定义 wangEditor 菜单。可以从网上找到类似的代码进行修改。只要满足 wangEditor 的类型要求即可。自定义菜单需要实现拉起 kityFormula 给的官方页面,同时实现公式的传递和渲染功能。

image.png

image.png

3.在需要使用富文本的页面中,引入jquery,wangEditorWithFormula,自定义菜单,和kityFormula。在页面的onload生命周期中,编写初始化实例方法,将自定义菜单插入到wangEditor中,这里需要注意,新增公式和编辑公式是两个菜单。

image.png


$(document).ready(function () {
    initEditor()
});

function initEditor() {
    const {createEditor, createToolbar, Boot} = window.wangEditor

    console.log(WangEditorPluginFormula)

    const {default: module} = window.WangEditorPluginFormula

    console.log(module)
    // Boot.registerMenu(menuConf)
    // 确保公式插件已加载
    if (!window.WangEditorPluginFormula) {
        console.error("公式插件未加载成功!");
        return;
    }

    // 注册公式插件
    // Boot.registerPlugin(window.WangEditorPluginFormula);
    Boot.registerModule(module);
    // 注册kity公式编辑器插件
    Boot.registerMenu(menuConf);
    Boot.registerMenu(editKityFormula);

    const editorConfig = {
        placeholder: 'Type here...',
        onChange(editor) {
            const html = editor.getHtml()
            console.log('editor content', html)
        },
        // 选中公式时的悬浮菜单
        hoverbarKeys: {
            formula: {
                // menuKeys: ['editFormula'], // “编辑公式”菜单
                menuKeys: ['editKityFormula'], // “编辑公式”菜单
            },
        },
    }

    const editor = createEditor({
        selector: '#editor-container',
        html: '<p><br></p>',
        config: editorConfig,
        mode: 'default', // or 'simple'
    })

    window.editor = editor

    // 解决回车问题
    $(document).on('keyup', '#editor-container', function(e) {
        console.log('Key up in editor container:', e);
        // 处理键盘事件
        if (e.keyCode === 13) {
            editor.dangerouslyInsertHtml("<p><br></p>");
        }
    });

    const toolbarConfig = {
        insertKeys: {
            index: 0,
            keys: [
                'kityFormula', // “插入公式”菜单
                // 'insertFormula', // “插入公式”菜单
                // 'editFormula' // “编辑公式”菜单
            ],
        },
    }

    toolbarConfig.toolbarKeys = [
        {
            key: "group-justify", // 必填,要以 group 开头
            title: "对齐", // 必填
            iconSvg: "<svg viewBox="0 0 1024 1024"><path d="M768 793.6v102.4H51.2v-102.4h716.8z m204.8-230.4v102.4H51.2v-102.4h921.6z m-204.8-230.4v102.4H51.2v-102.4h716.8zM972.8 102.4v102.4H51.2V102.4h921.6z"></path></svg>", // 可选
            menuKeys: ["justifyLeft", "justifyRight", "justifyCenter"], // 下级菜单 key ,必填
        },
        // 分割线
        '|',
        "sup",
        "sub",
        '|',
        // 菜单 key
        'undo',
        'redo'
    ]

    const toolbar = createToolbar({
        editor,
        selector: '#toolbar-container',
        config: toolbarConfig,
        mode: 'simple', // or 'simple'
    })
}

(二)过程踩坑

1.首先就是找现成的实现方式,在网上没有搜索到比较符合要求的代码实现,比较接近的一个是把公式转成图片插入到富文本中,这不是项目想要的效果。最后只能根据已有代码进行修改。

2.latex公式在富文本编辑器中的显示有问题,直接把公式编辑器中的公式插入到富文本编辑器中,不能正常显示。需要进行渲染,渲染成富文本编辑器能识别的html元素,但是,官方文档并没有提供方法的接口文档,这一点非常坑,不知道哪个方法能进行公式渲染。最后在排查WangEditorPluginFormula的测试用例时,才找到了官方的渲染方法。

官方测试用例: image.png

把渲染方法放到自定义菜单的插入方法中,这一步渲染就算完成了

image.png

3.最困难的问题解决之后,万万没想到wangEditor官方还留了一个坑,怎么把渲染之后的公式字符串插入的富文本里?官方文档上依然没有找到插入接口是哪个,只能自己找。最后在通过使用console.log打印editor实例的方法,在实例里找到个貌似可以实现插入的方法 editor.dangerouslyInsertHtml 。测试了下,的确能用,可以的完结撒花了。

image.png

4.插入解决了,然后就是编辑功能的实现。依然是没有官方文档的问题,只能自己从源码,测试用例和实例里找。好在不是第一次这么干了,速度快了不少。

源码: image.png

借鉴过来: image.png

image.png

(三)自定义菜单

const formulaIcon =   '<svg t="1682415575656" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8100" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M902.383 461.687c6.417-6.417 12.834-12.8 19.217-12.8h89.6c6.383 0 12.8-6.382 12.8-12.765v-102.23c0-6.417-6.417-12.8-12.8-12.8H908.8c-6.417 0-19.183 6.383-19.183 12.8L780.8 448.922c-6.383 6.348-12.8 6.348-12.8 0l-57.583-115.03c-6.417-6.417-12.8-12.8-19.217-12.8H524.8c-6.383 0-12.8 6.383-12.8 12.8v102.23c0 6.383 6.417 12.765 12.8 12.765h102.4c6.417 0 12.8 6.383 19.183 12.8l38.4 83.081v19.149l-115.2 134.178c-6.383 0-12.766 6.417-19.183 6.417h-89.6c-6.383 0-12.8 6.383-12.8 12.766v102.23c0 6.382 6.417 12.8 12.8 12.8h102.4c6.383 0 19.183-6.418 19.183-12.8L729.6 653.38c6.417-6.383 12.8-6.383 12.8 0l83.183 166.127c0 6.383 12.8 12.8 19.217 12.8h102.4a13.756 13.756 0 0 0 12.8-12.8v-102.23a13.756 13.756 0 0 0-12.8-12.765h-38.4c-6.417 0-12.8-6.417-19.183-12.8l-64.034-127.795v-19.149l76.8-83.08zM377.617 65.502C345.6 91.068 313.583 129.399 294.4 193.297l-31.983 127.795H76.8a13.756 13.756 0 0 0-12.8 12.8v102.23c0 6.383 6.383 12.765 12.8 12.765h153.6l-96.017 383.42C115.2 908.971 64 896.205 64 896.205H0V1024h64c51.2 0 102.4-6.383 128-38.332 32.017-31.949 51.2-89.463 64-153.36l96.017-383.42H499.2c6.383 0 12.8-6.383 12.8-12.766v-102.23c0-6.417-6.417-12.8-12.8-12.8H384l32.017-121.412c6.383-19.149 38.4-51.098 57.583-63.898C543.983 84.651 640 116.6 704 129.4V20.753c-64-12.766-204.8-57.48-326.383 44.715z" p-id="8101"></path></svg>';
// 定义 MyKityFormulaMenu 类
class MyKityFormulaMenu {
  constructor() {
    this.title = "编辑公式";
    this.iconSvg = formulaIcon;
    this.tag = "button";
    this.showModal = true;
    this.modalWidth = 900;
    this.modalHeight = 600;
    this.currentSelection = null; // 跟踪当前选区
    this.lastInsertAt = 0; // 上次插入时间戳
    this.lastLatex = null; // 上次插入的latex
    this.getModalPositionNode = this.getModalPositionNode.bind(this);
    this.getModalContentElem = this.getModalContentElem.bind(this);
  }

  // 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
  isActive(editor) {
    return false;
  }

  // 获取菜单执行时的 value ,用不到则返回空 字符串或 false
  getValue(editor) {
    return "";
  }

  // 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
  isDisabled(editor) {
    return false;
  }
  // 点击菜单时触发的函数
  exec(editor, value) {
    // Modal menu ,这个函数不用写,空着即可
    console.log('exec=====editor====', editor);
    console.log('exec=====value====', value);
    return editor;
  }

  // 弹出框 modal 的定位:1. 返回某一个 SlateNode; 2. 返回 null (根据当前选区自动定位)
  getModalPositionNode(editor) {
    this.currentSelection = editor.selection; // 保存当前选区
  }

  // 定义 modal 内部的 DOM Element
  getModalContentElem(editor) {
    // panel 中需要用到的id
    const inputIFrameId = "kityformula_" + Math.ceil(Math.random() * 10);
    const btnOkId = "kityformula-btn" + Math.ceil(Math.random() * 10);

    const $content = $('<div><iframe id="' + inputIFrameId + '" class="iframe" height="400px" width="100%" frameborder="0" scrolling="no" src="../../js/kityformula/index.html"></iframe></div>');
    const $button = $('<div id="' + btnOkId + '" class="right" style="margin: 5px 0; background: #176EAF;color: #FFFFFF;height: 1.8rem;line-height: 1.8rem;text-align: center;border-radius: 0.1rem;font-size: 0.7rem;font-weight: normal;width:10rem;">确认插入</div>');
    $content.append($button);

    $button.on("click", () => {
      // 执行插入公式
      const node = document.getElementById(inputIFrameId);
      const kfe = node.contentWindow.kfe;

      kfe.execCommand("get.image.data", function (data) {
        // 获取base64
        // console.log(data.img);
      });

      let latex = kfe.execCommand("get.source");
      latex = latex.replace(/\s/g, ""); // 去掉空格

      // 防重复:相同latex在短时间内(800ms)不重复插入
      const now = Date.now();
      if (this.lastLatex === latex && now - this.lastInsertAt < 800) {
        editor.hidePanelOrModal();
        return;
      }

      console.log('KityFormula插入公式:', latex);

      // 创建公式节点,与新版 wangEditor 兼容
      const formulaNode = {
        type: "formula",
        value: latex,
        children: [{text: "",},],
      };

      // 使用 setHtml 方法直接插入 LaTeX 文本
      try {
        // console.log('使用 setHtml 方法插入 LaTeX 文本');
        var currentHtml = editor.getHtml();
        console.log('当前HTML:', currentHtml);

        // 如果当前内容为空或只有换行,直接设置
        // if (!currentHtml || currentHtml.trim() === '' || currentHtml === '<p><br></p>') {
        //   var newHtml = '<p>' + latex + '</p>';
        //   editor.setHtml(newHtml);
        //   console.log('设置新HTML:', newHtml);
        // } else {
        //   // 在现有内容后添加
        //   var newHtml = currentHtml + latex;
        //   editor.setHtml(newHtml);
        //   console.log('追加到现有HTML:', newHtml);
        // }
        var newHtml = WangEditorPluginFormula.default.elemsToHtml[0].elemToHtml({value: latex},"");
        console.log('设置新HTML:', newHtml);
        editor.focus();
        if (!currentHtml || currentHtml.trim() === '' || currentHtml === '<p><br></p>') {
          console.log('使用 setHtml 方法插入 LaTeX 文本');
          editor.setHtml(newHtml);
        } else {
          console.log('使用 insertText 方法插入 LaTeX 文本');
          editor.dangerouslyInsertHtml(newHtml);
          // editor.insertText(newHtml);
        }

        // 触发内容变化事件,让 renderFormulaNodes 处理
        setTimeout(function() {
          var verifyHtml = editor.getHtml();
          console.log('插入后的HTML:', verifyHtml);

          // 手动触发公式渲染
          if (typeof window.renderFormulaNodes === 'function') {
            console.log('触发公式渲染');
            window.renderFormulaNodes('standard');
          }
        }, 100);

      } catch (error) {
        console.error('setHtml 插入失败:', error);
        // 如果 setHtml 失败,尝试使用 insertText
        try {
          editor.insertText(latex);
          console.log('使用 insertText 作为备用方案');
        } catch (e) {
          console.error('insertText 也失败:', e);
        }
      }

      this.lastLatex = latex;
      this.lastInsertAt = now;
      console.log("===========================formulaNode");
      console.log(formulaNode);
      editor.hidePanelOrModal();
    });
    return $content[0]; // 返回 DOM Element 类型

    // PS:也可以把 $content 缓存下来,这样不用每次重复创建、重复绑定事件,优化性能
  }
}

const menuConf = {
  key: "kityFormula", // menu key ,唯一。注册之后,需通过 toolbarKeys 配置到工具栏
  factory() {
    return new MyKityFormulaMenu();
  },
};

// 暴露全局变量
window.menuConf = menuConf;

// 创建 MyKityFormulaMenu 类的实例
const myKityFormulaMenuInstance = new MyKityFormulaMenu();
// 导出 exec 方法
window.exec = myKityFormulaMenuInstance.exec;

class EditKityFormulaMenu {
  // 定义 EditKityFormulaMenu 类
  formulaIcon =   '<svg t="1682415575656" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8100" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M902.383 461.687c6.417-6.417 12.834-12.8 19.217-12.8h89.6c6.383 0 12.8-6.382 12.8-12.765v-102.23c0-6.417-6.417-12.8-12.8-12.8H908.8c-6.417 0-19.183 6.383-19.183 12.8L780.8 448.922c-6.383 6.348-12.8 6.348-12.8 0l-57.583-115.03c-6.417-6.417-12.8-12.8-19.217-12.8H524.8c-6.383 0-12.8 6.383-12.8 12.8v102.23c0 6.383 6.417 12.765 12.8 12.765h102.4c6.417 0 12.8 6.383 19.183 12.8l38.4 83.081v19.149l-115.2 134.178c-6.383 0-12.766 6.417-19.183 6.417h-89.6c-6.383 0-12.8 6.383-12.8 12.766v102.23c0 6.382 6.417 12.8 12.8 12.8h102.4c6.383 0 19.183-6.418 19.183-12.8L729.6 653.38c6.417-6.383 12.8-6.383 12.8 0l83.183 166.127c0 6.383 12.8 12.8 19.217 12.8h102.4a13.756 13.756 0 0 0 12.8-12.8v-102.23a13.756 13.756 0 0 0-12.8-12.765h-38.4c-6.417 0-12.8-6.417-19.183-12.8l-64.034-127.795v-19.149l76.8-83.08zM377.617 65.502C345.6 91.068 313.583 129.399 294.4 193.297l-31.983 127.795H76.8a13.756 13.756 0 0 0-12.8 12.8v102.23c0 6.383 6.383 12.765 12.8 12.765h153.6l-96.017 383.42C115.2 908.971 64 896.205 64 896.205H0V1024h64c51.2 0 102.4-6.383 128-38.332 32.017-31.949 51.2-89.463 64-153.36l96.017-383.42H499.2c6.383 0 12.8-6.383 12.8-12.766v-102.23c0-6.417-6.417-12.8-12.8-12.8H384l32.017-121.412c6.383-19.149 38.4-51.098 57.583-63.898C543.983 84.651 640 116.6 704 129.4V20.753c-64-12.766-204.8-57.48-326.383 44.715z" p-id="8101"></path></svg>';

  constructor() {
    this.title = "编辑公式";
    this.iconSvg = formulaIcon;
    this.tag = "button";
    this.showModal = true;
    this.modalWidth = 900;
    this.modalHeight = 600;
    this.currentSelection = null; // 跟踪当前选区
    this.lastInsertAt = 0; // 上次插入时间戳
    this.lastLatex = null; // 上次插入的latex
    this.getModalPositionNode = this.getModalPositionNode.bind(this);
    this.getModalContentElem = this.getModalContentElem.bind(this);
    this.DomEditor = window.wangEditor.DomEditor;
    this.SlateTransforms = window.wangEditor.SlateTransforms;
    this.WangEditorPluginFormula = window.WangEditorPluginFormula;
  }

  // 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
  isActive(editor) {
    return false;
  }

  getSelectedElem(editor) {
    // const node = editor.getFragment()[0].children[0]
    // console.log('editor.getFragment()', editor.getFragment())
    // console.log('editor.getFragment()[0]', editor.getFragment()[0])
    // console.log('editor.getFragment()[0].children', editor.getFragment()[0].children)
    // console.log('editor.getFragment()[0].children[0]', editor.getFragment()[0].children[0])
    const node = this.DomEditor.getSelectedNodeByType(editor, 'formula')
    if (node == null) return null
    return node
  }

  // 获取菜单执行时的 value ,用不到则返回空 字符串或 false
  getValue(editor) {
    const formulaElem = this.getSelectedElem(editor)
    console.log('formulaElem:', formulaElem);
    if (formulaElem) {
      return formulaElem.value || ''
    }
    return ''
  }

  // 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
  isDisabled(editor) {
    return false;
  }
  // 点击菜单时触发的函数
  exec(editor, value) {
    // Modal menu ,这个函数不用写,空着即可
    console.log('exec=====editor====', editor);
    console.log('exec=====value====', value);
    return editor;
  }

  // 弹出框 modal 的定位:1. 返回某一个 SlateNode; 2. 返回 null (根据当前选区自动定位)
  getModalPositionNode(editor) {
    this.currentSelection = editor.selection; // 保存当前选区
  }

  // 定义 modal 内部的 DOM Element
  getModalContentElem(editor) {

    console.log('getModalContentElem:', editor);

    // 设置 input val
    let value = this.getValue(editor)
    console.log('value:', value);

    // panel 中需要用到的id
    const inputIFrameId = "kityformula_" + Math.ceil(Math.random() * 10);
    const btnOkId = "kityformula-btn" + Math.ceil(Math.random() * 10);

    let src = "../../js/kityformula/index.html";
    if (value) {
      src = "../../js/kityformula/index.html?value=" + encodeURIComponent(value);
    }

    const $content = $('<div><iframe id="' + inputIFrameId + '" class="iframe" height="400px" width="100%" frameborder="0" scrolling="no" src="' + src + '"></iframe></div>');
    const $button = $('<div id="' + btnOkId + '" class="right" style="margin: 5px 0; background: #176EAF;color: #FFFFFF;height: 1.8rem;line-height: 1.8rem;text-align: center;border-radius: 0.1rem;font-size: 0.7rem;font-weight: normal;width:10rem;">确认插入</div>');
    $content.append($button);

    $button.on("click", () => {
      // 执行插入公式
      const node = document.getElementById(inputIFrameId);
      const kfe = node.contentWindow.kfe;

      kfe.execCommand("get.image.data", function (data) {
        // 获取base64
        // console.log(data.img);
      });

      let latex = kfe.execCommand("get.source");
      // latex = latex.replace(/\s/g, ""); // 去掉空格

      // 防重复:相同latex在短时间内(800ms)不重复插入
      const now = Date.now();
      if (this.lastLatex === latex && now - this.lastInsertAt < 800) {
        editor.hidePanelOrModal();
        return;
      }

      console.log('KityFormula插入公式:', latex);

      // 创建公式节点,与新版 wangEditor 兼容
      const formulaNode = {
        type: "formula",
        value: latex,
        children: [{text: "",},],
      };

      // 使用 setHtml 方法直接插入 LaTeX 文本
      try {
        // console.log('使用 setHtml 方法插入 LaTeX 文本');
        var currentHtml = editor.getHtml();
        console.log('当前HTML:', currentHtml);

        // 如果当前内容为空或只有换行,直接设置
        // if (!currentHtml || currentHtml.trim() === '' || currentHtml === '<p><br></p>') {
        //   var newHtml = '<p>' + latex + '</p>';
        //   editor.setHtml(newHtml);
        //   console.log('设置新HTML:', newHtml);
        // } else {
        //   // 在现有内容后添加
        //   var newHtml = currentHtml + latex;
        //   editor.setHtml(newHtml);
        //   console.log('追加到现有HTML:', newHtml);
        // }
        var newHtml = this.WangEditorPluginFormula.default.elemsToHtml[0].elemToHtml({value: latex},"");
        console.log('设置新HTML:', newHtml);
        editor.focus();
        if (!currentHtml || currentHtml.trim() === '' || currentHtml === '<p><br></p>') {
          console.log('使用 setHtml 方法插入 LaTeX 文本');
          editor.setHtml(newHtml);
        } else {
          console.log('替换 LaTeX 文本');

          let selectedElem = this.getSelectedElem(editor)
          console.log('selectedElem', selectedElem);
          if (selectedElem == null) return
          console.log('selectedElem.value', selectedElem.value);
          // 从 wangEditor 获取选中的元素
          console.log('SlateTransforms', this.SlateTransforms);
          console.log('DomEditor', this.DomEditor);

          const path = this.DomEditor.findPath(editor, selectedElem)
          const props = { value: latex }
          this.SlateTransforms.setNodes(editor, props, { at: path })

        }

        // 触发内容变化事件,让 renderFormulaNodes 处理
        setTimeout(function() {
          var verifyHtml = editor.getHtml();
          console.log('插入后的HTML:', verifyHtml);

          // 手动触发公式渲染
          if (typeof window.renderFormulaNodes === 'function') {
            console.log('触发公式渲染');
            window.renderFormulaNodes('standard');
          }
        }, 100);

      } catch (error) {
        console.error('setHtml 插入失败:', error);
        // 如果 setHtml 失败,尝试使用 insertText
        try {
          editor.insertText(latex);
          console.log('使用 insertText 作为备用方案');
        } catch (e) {
          console.error('insertText 也失败:', e);
        }
      }

      this.lastLatex = latex;
      this.lastInsertAt = now;
      console.log("===========================formulaNode");
      console.log(formulaNode);
      editor.hidePanelOrModal();
    });

    return $content[0]; // 返回 DOM Element 类型

    // PS:也可以把 $content 缓存下来,这样不用每次重复创建、重复绑定事件,优化性能
  }
}

const editKityFormula = {
  key: "editKityFormula", // menu key ,唯一。注册之后,需通过 toolbarKeys 配置到工具栏
  factory() {
    return new EditKityFormulaMenu();
  },
};

// 暴露全局变量
window.editKityFormula = editKityFormula;

// // 创建 EditKityFormulaMenu 类的实例
// const myKityFormulaMenuInstance = new EditKityFormulaMenu();
// // 导出 exec 方法
// window.exec = myKityFormulaMenuInstance.exec;

三、效果图

1。打开公式编辑器效果

0a7bcbd33c970a9eda5265fb38a47422.png

2.插入光是效果

02406b77fed37bd14842ee4f8472d58c.png

3.编辑菜单效果

16be29a48130e659b1b23205d0bdb21d.png

四、后记

希望官方能提供下接口文档,方便开发者二次开发,自己爬源码太浪费时间了,不过源码写的的确不错,赏心悦目。