【element-tiptap】添加公式编辑器【MathQuill】

前言:前面的文章【element-tiptap】实现公式编辑 中,已经实现了一种非常简单的公式编辑,键入latex公式直接在文档中转换。今天讲的另一个更加复杂的公式编辑器的扩展,双击公式的时候弹出公式编辑的弹窗,可以对公式进行可视化编辑。
公式编辑器,是文本编辑中必不可少的一项功能,俺这里有一个开源的公式编辑器项目 mathquill,界面比较的简单清晰,可以尝试一下将这个mathquill加入到我们的编辑器中。首先看一下WPS中公式编辑器的界面,是在插入的下拉菜单中在这里插入图片描述

是一个弹出框的形式。插入的下拉框咱们先不管,先做一个按钮,点击的时候弹出这个模态框

一、增加模态框

1、增加扩展 src/extensions/formula-editor.ts

import type { Editor } from '@tiptap/core';
import { Extension } from '@tiptap/core';
import FormulaEditorDialog from '@/components/menu-commands/formula-editor.dialog.vue';

const FormulaEditor = Extension.create({
  name: 'formulaEditor',

  addOptions() {
    return {
      ...this.parent?.(),
      button({ editor, t }: { editor: Editor; t: (...args: any[]) => string }) {
        return {
          component: FormulaEditorDialog,
          componentProps: {
            editor
          },
        };
      },
    };
  },
});

export default FormulaEditor;

2、导出扩展 /src/extensions/index.ts

export { default as FormulaEditor } from './formula-editor';

3、图标文件 src/icons/formula-editor.svg

<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke-width="1.5"><g id="group-0" stroke="#333333" fill="#333333"><path d="M10.9954 9.48532V9.48532C11.7434 10.8632 12.4934 12.4986 14.0082 12.9025C14.1745 12.9468 14.3394 12.9706 14.5 12.9706M10.9954 9.48532V9.48532C10.2479 8.10819 9.52602 6.40326 7.99805 6.05589C7.83534 6.0189 7.66687 6 7.49086 6M10.9954 9.48532V9.48532C11.7438 8.10667 12.4343 6.35565 13.9727 6.04913C14.1435 6.01511 14.3184 6 14.5 6M10.9954 9.48532V9.48532C10.2473 10.8635 9.65752 12.8449 8.09596 12.989C8.08334 12.9902 8.07063 12.9912 8.05782 12.9921C7.23416 13.0479 6.88097 12.8029 6.5 12.4359" stroke-linecap="round" stroke-linejoin="miter" fill="none" vector-effect="non-scaling-stroke"></path><path d="M15.0026 2.25H5.02037C4.86672 2.25 4.73791 2.36609 4.72199 2.51892L3.5 14.25L1.75 12L1 12.75" stroke-linecap="round" stroke-linejoin="miter" fill="none" vector-effect="non-scaling-stroke"></path></g></svg>

4、弹出框组件 src/components/menu-commands/formula-editor.dialog.vue

<template>
  <div>
    <command-button
      :is-active="editor.isActive('formulaEditor')"
      :command="openDialog"
      :enable-tooltip="enableTooltip"
      :tooltip="t('editor.extensions.FormulaEditor.tooltip')"
      icon="formula-editor"
      :button-icon="buttonIcon"
    />

    <el-dialog
      v-model="formulaEditorDialogVisible"
      :title="t('editor.extensions.FormulaEditor.dialog.title')"
      :append-to-body="true"
      width="400px"
      class="el-tiptap-edit-link-dialog"
    >
      ffffffffff
      <template #footer>
        <el-button size="small" round @click="closeDialog">
          {{ t('editor.extensions.FormulaEditor.dialog.cancel') }}
        </el-button>

        <el-button
          type="primary"
          size="small"
          round
          @mousedown.prevent
          @click="addLink"
        >
          {{ t('editor.extensions.FormulaEditor.dialog.confirm') }}
        </el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script lang="ts">
import { defineComponent, inject } from 'vue';
import {
  ElDialog,
  ElForm,
  ElFormItem,
  ElInput,
  ElCheckbox,
  ElButton,
} from 'element-plus';
import { Editor } from '@tiptap/core';
import CommandButton from './command.button.vue';

export default defineComponent({
  name: 'FormulaEditor',

  components: {
    ElDialog,
    ElForm,
    ElFormItem,
    ElInput,
    ElCheckbox,
    ElButton,
    CommandButton,
  },

  props: {
    editor: {
      type: Editor,
      required: true,
    },
    buttonIcon: {
      default: '',
      type: String
    },
    placeholder: {
      default: '',
      type: String
    }
  },

  setup() {
    const t = inject('t');
    const enableTooltip = inject('enableTooltip', true);

    return { t, enableTooltip };
  },

  data() {
    return {
      formulaEditorDialogVisible: false,
    };
  },

  watch: {
  },

  methods: {
    openDialog() {
      this.formulaEditorDialogVisible = true;
    },

    closeDialog() {
      this.formulaEditorDialogVisible = false;
    },

    addLink() {

      // this.editor.commands.setLink({ href: this.linkAttrs.href });


      this.closeDialog();
    },
  },
});
</script>

5、src/i18n/locales/zh/index.ts 和 src/i18n/locales/zh-tw/index.ts

FormulaEditor: {
  tooltip: '公式编辑',
  dialog: {
    title: '公式编辑',
    cancel: '取消',
    confirm: '确认'
  },
}
FormulaEditor: {
  tooltip: '公式编辑',
  dialog: {
    title: '公式编辑',
    cancel: '取消',
    confirm: '確認'
  },
}

二、引入公式编辑器

mathquill
把源码下载下来以后,首先需要执行 npm install 安装依赖
安装完依赖之后,根目录有一个 quickstart.html,将这个文件右键在浏览器中打开
在这里插入图片描述
会出现一个比较简陋的页面
在这里插入图片描述
我们看一下这个文件的代码,其中引入的依赖文件有三个

<link rel="stylesheet" type="text/css" href="build/mathquill.css" />
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.5.2/jquery.js"></script>
<script src="build/mathquill.js"></script>

所以我们先把 build 文件夹放到我们的 【element-tiptap】项目中
然后在 index.html 中引入这几个文件
index.html

<link rel="stylesheet" href="./src/utils/mathquill/mathquill.css" />
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.5.2/jquery.js"></script>
<script src="./src/utils/mathquill/mathquill.js"></script>

然后在下拉框组件中 src/components/menu-commands/formula-editor-dialog.vue 把代码复制过去

<el-dialog
  v-model="formulaEditorDialogVisible"
  :title="t('editor.extensions.FormulaEditor.dialog.title')"
  :append-to-body="true"
  width="400px"
  class="el-tiptap-edit-link-dialog"
>
  <p>
    Static math span:
    <span id="static-math">x = \frac{ -b \pm \sqrt{b^2-4ac} }{ 2a }</span>
  </p>
  <p>Editable math field: <span id="math-field">x^2</span></p>
  <p>LaTeX of what you typed: <code id="latex">x^2</code></p>
  <p>
    <a href="http://docs.mathquill.com/en/latest/Getting_Started/"
    >MathQuill&rsquo;s Getting Started Guide</a
    >
  </p>
  <template #footer>
    <el-button size="small" round @click="closeDialog">
      {{ t('editor.extensions.FormulaEditor.dialog.cancel') }}
    </el-button>

    <el-button
      type="primary"
      size="small"
      round
      @mousedown.prevent
      @click="addLink"
    >
      {{ t('editor.extensions.FormulaEditor.dialog.confirm') }}
    </el-button>
  </template>
</el-dialog>

渲染需要一些时间,所以这里先写了一个延时函数

openDialog() {
  this.formulaEditorDialogVisible = true;
  setTimeout(()=>{
    var staticMathSpan = document.getElementById('static-math');
    var mathFieldSpan = document.getElementById('math-field');
    var latexSpan = document.getElementById('latex');

    var MQ = MathQuill.getInterface(2); // keeps the API stable
    // easily create static or editable math from a DOM element by calling the
    // appropriate constructor: http://docs.mathquill.com/en/latest/Api_Methods/
    MQ.StaticMath(staticMathSpan);
    // you may pass in an options object:
    var mathField = MQ.MathField(mathFieldSpan, {
      spaceBehavesLikeTab: true, // an example config option, for more see:
      //   http://docs.mathquill.com/en/latest/Config/
      handlers: {
        edit: function () {
          console.log("mathField.latex()")
          console.log(mathField.latex())
          // retrieve, in LaTeX format, the math that was typed:
          latexSpan.textContent = mathField.latex();
        },
      },
    });
  }, 300)
},

然后就有了
在这里插入图片描述

三、可编辑的latex

需要有一个编辑区域,可用来输入latex,然后生成公式

<template>
  <div>
    <command-button
      :is-active="editor.isActive('formulaEditor')"
      :command="openDialog"
      :enable-tooltip="enableTooltip"
      :tooltip="t('editor.extensions.FormulaEditor.tooltip')"
      icon="formula-editor"
      :button-icon="buttonIcon"
    />

    <el-dialog
      v-model="formulaEditorDialogVisible"
      :title="t('editor.extensions.FormulaEditor.dialog.title')"
      :append-to-body="true"
      width="800px"
      class="formula-editor-dialog"
    >
      <div class="latex-input-section">
        <div class="input-area">
          <span id="math-field" class="math-input"></span>
        </div>
        <div class="latex-preview">
          <span>LaTeX:</span>
          <code id="latex">x^2</code>
        </div>
      </div>

      <template #footer>
        <el-button size="small" round @click="closeDialog">
          {{ t('editor.extensions.FormulaEditor.dialog.cancel') }}
        </el-button>

        <el-button
          type="primary"
          size="small"
          round
          @mousedown.prevent
          @click="insertFormula"
        >
          {{ t('editor.extensions.FormulaEditor.dialog.confirm') }}
        </el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script lang="ts">
import { defineComponent, inject } from 'vue';
import {
  ElDialog,
  ElButton,
} from 'element-plus';
import { Editor } from '@tiptap/core';
import CommandButton from './command.button.vue';

export default defineComponent({
  name: 'FormulaEditor',

  components: {
    ElDialog,
    ElButton,
    CommandButton,
  },

  props: {
    editor: {
      type: Editor,
      required: true,
    },
    buttonIcon: {
      default: '',
      type: String
    },
    placeholder: {
      default: '',
      type: String
    }
  },

  setup() {
    const t = inject('t');
    const enableTooltip = inject('enableTooltip', true);

    return { t, enableTooltip };
  },

  data() {
    return {
      formulaEditorDialogVisible: false,
      mathField: null as any,
      MQ: null as any,
      currentLatex: '',
    };
  },

  methods: {
    openDialog() {
      this.formulaEditorDialogVisible = true;
      this.initMathQuill();
    },

    initMathQuill() {
      setTimeout(() => {
        const mathFieldSpan = document.getElementById('math-field');
        const latexSpan = document.getElementById('latex');
        
        this.MQ = MathQuill.getInterface(2);
        this.mathField = this.MQ.MathField(mathFieldSpan, {
          spaceBehavesLikeTab: true,
          handlers: {
            edit: () => {
              this.currentLatex = this.mathField.latex();
              latexSpan.textContent = this.currentLatex;
            },
          },
        });
      }, 300);
    },

    closeDialog() {
      this.formulaEditorDialogVisible = false;
      this.currentLatex = '';
    },

    insertFormula() {
      if (this.currentLatex) {
        // this.editor.commands.insertFormula({ latex: this.currentLatex });
      }
      this.closeDialog();
    },
  },
});
</script>

<style lang="scss" scoped>
.formula-editor-dialog {
  .latex-input-section {
    padding: 20px;
    
    .input-area {
      margin-bottom: 16px;

      .math-input {
        display: block;
        width: 100%;
        min-height: 60px;
        border: 1px solid #dcdfe6;
        border-radius: 4px;
        padding: 8px;
      }
    }

    .latex-preview {
      font-size: 14px;
      color: #606266;
    }
  }
}
</style>

挺 6 的,因为在编辑区域编辑的时候能够直接生成可编辑的公式
在这里插入图片描述

四、将公式插入编辑器

【element-tiptap】如何把分隔线改造成下拉框的形式? 一文中,我们有探索了怎么新建一个在编辑器中可以显示的DOM节点,可以先浏览一下。

1、创建插入的组件

向编辑器中插入内容的时候,需要使用 node-view-wrapper 标签包裹起来,我们首先需要创建一个这样子的组件,作为我们插入公式的时候实际插入的内容
src/components/extension-views/formula-view.vue

<template>
	<node-view-wrapper as="span" class="formula-view">
		<span v-html="getFormulaHtml()" :latex="latex" @dblclick="editFormula">
		</span>
	</node-view-wrapper>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3';

export default defineComponent({
	name: 'FormulaView',

	components: {
		NodeViewWrapper,
	},

	props: nodeViewProps,

	computed: {
		mathml(): string {
			return this.node!.attrs['mathml'];
		},
		latex(): string {
			return this.node!.attrs['latex'];
		},
	},

	methods: {
		getFormulaHtml(){
			return this.mathml;
		},
		getLatex(){
			return this.latex;
		},
		editFormula(){
			this.editor.commands.editFormula({ latex: this.latex });
		}
	},
});
</script>

<style>
.formula-view .mq-editable-field.mq-focused, 
.formula-view .mq-math-mode .mq-editable-field.mq-focused {
	box-shadow: none;
	border: none;
	cursor: pointer;
}
.formula-view .mq-editable-field .mq-cursor {
	display: none;
}
.formula-view .mq-math-mode:hover {
	box-shadow: #8bd 0 0 1px 2px, inset #6ae 0 0 2px 0;
    border-color: #709AC0;
}
</style>

2、改造 formulaEditor 扩展

需要增加两个命令,一个是往编辑器中插入公式,一个是编辑公式,因为公式双击的时候需要进入公式编辑器进行编辑。需要额外说一下,有两个属性:inline: true,group: 'inline',,有了这两个属性,公式插入之后才能在行内显示,否则会独占一行。
src/extensions/formula-editor.ts

import { Node, mergeAttributes } from '@tiptap/core';
import { Editor, VueNodeViewRenderer } from '@tiptap/vue-3';
import FormulaEditorDialog from '@/components/menu-commands/formula-editor.dialog.vue';
import FormulaView from '@/components/extension-views/formula-view.vue';

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    formula: {
      setFormula: (options: { src: string }) => ReturnType;
    };
  }
}

const FormulaEditor = Node.create({
  name: 'formulaEditor',

  // schema
  inline: true,
  group: 'inline',
  selectable: false,

  addAttributes() {
    return {
      ...this.parent?.(),
      src: {
        default: null,
        parseHTML: (element) => {
          const src = element.getAttribute('src');
          return src;
        },
      },
      latex: {
        default: null,
        parseHTML: (element) => {
          const latex = element.getAttribute('latex');
          return latex;
        },
      },
      mathml: {
        default: null,
        parseHTML: (element) => {
          const mathml = element.getAttribute('mathml');
          return mathml;
        },
      },
    };
  },

  parseHTML() {
    return [
      {
        tag: 'span',
      },
    ];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      'span',
      mergeAttributes(HTMLAttributes, {
      }),
    ];
  },

  addCommands() {
    return {
      setFormula:
        (options) =>
        ({ commands }) => {
          return commands.insertContent({
            type: this.name,
            attrs: options,
          });
        },
      editFormula:
        (options) => {
          this.editor.emit('openFormulaEditor', options);
        }
    };
  },

  addOptions() {
    return {
      button({ editor }: { editor: Editor }) {
        return {
          component: FormulaEditorDialog,
          componentProps: {
            editor,
          },
        };
      },
    };
  },
  addNodeView() {
    return VueNodeViewRenderer(FormulaView);
  },
});

export default FormulaEditor;

3、改造公式编辑器弹出框组件

  • 点击 确认 按钮的时候,调用命令 setFormula 插入公式;
  • 监听 openFormulaEditor ,双击公式的时候打开公式编辑器弹窗
<template>
  <div>
    <command-button
      :is-active="editor.isActive('formulaEditor')"
      :command="openDialog"
      :enable-tooltip="enableTooltip"
      :tooltip="t('editor.extensions.FormulaEditor.tooltip')"
      icon="formula-editor"
      :button-icon="buttonIcon"
    />

    <el-dialog
      v-model="formulaEditorDialogVisible"
      :title="t('editor.extensions.FormulaEditor.dialog.title')"
      :append-to-body="true"
      width="800px"
      class="formula-editor-dialog"
    >
      <div class="latex-input-section">
        <div class="input-area">
          <span id="math-field" class="math-input"></span>
        </div>
        <div class="latex-preview">
          <span>LaTeX:</span>
          <code id="latex">{{ currentLatex }}</code>
        </div>
      </div>

      <template #footer>
        <el-button size="small" round @click="closeDialog">
          {{ t('editor.extensions.FormulaEditor.dialog.cancel') }}
        </el-button>

        <el-button
          type="primary"
          size="small"
          round
          @mousedown.prevent
          @click="insertFormula"
        >
          {{ t('editor.extensions.FormulaEditor.dialog.confirm') }}
        </el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script lang="ts">
import { defineComponent, inject, onMounted, getCurrentInstance } from 'vue';
import {
  ElDialog,
  ElButton,
} from 'element-plus';
import { Editor } from '@tiptap/core';
import CommandButton from './command.button.vue';

export default defineComponent({
  name: 'FormulaEditorDialog',

  components: {
    ElDialog,
    ElButton,
    CommandButton,
  },

  props: {
    editor: {
      type: Editor,
      required: true,
    },
    buttonIcon: {
      default: '',
      type: String
    },
    placeholder: {
      default: '',
      type: String
    }
  },

  setup(props) {
    const t = inject('t');
    const enableTooltip = inject('enableTooltip', true);

    
    onMounted(() => {
      console.log(props.editor);
      // 获取当前组件
      const currentComponent = getCurrentInstance();
      const openDialog = currentComponent?.ctx.openDialog;
      props.editor.on('openFormulaEditor', (options: { latex: string }) => {
        openDialog(options.latex);
      });
    });

    return { t, enableTooltip };
  },

  data() {
    return {
      formulaEditorDialogVisible: false,
      mathField: null as any,
      MQ: null as any,
      currentLatex: '',
    };
  },

  methods: {
    openDialog(latex: string) {
      this.formulaEditorDialogVisible = true;
      this.initMathQuill(latex);
    },

    initMathQuill(latex: string) {
      setTimeout(() => {
        const mathFieldSpan = document.getElementById('math-field');
        const latexSpan = document.getElementById('latex');       
        this.MQ = MathQuill.getInterface(2);
        if(latex) {
          this.setLatex(latex);
        }
        this.mathField = this.MQ.MathField(mathFieldSpan, {
          spaceBehavesLikeTab: true,
          handlers: {
            edit: () => {
              this.currentLatex = this.mathField.latex();
              latexSpan.textContent = this.currentLatex;
            },
          },
        });
      }, 300);
    },

    closeDialog() {
      this.formulaEditorDialogVisible = false;
      this.currentLatex = '';
      if (this.mathField) {
        this.mathField.latex('');
      }
    },

    insertFormula() {
      const mathml = document.getElementById('math-field')?.outerHTML?.replace(/id="math-field"/g, '');
      this.editor.chain()
        .focus()
        .setFormula({
          mathml,
          latex: this.currentLatex
        })
        .run();
      this.closeDialog();
    },

    setLatex(latex: string) {
      this.currentLatex = latex;
      if (this.mathField) {
        this.mathField.latex(latex);
      } else {
        this.$nextTick(() => {
          if (this.mathField) {
            this.mathField.latex(latex);
          }
        });
      }
    },
  },
});
</script>

<style lang="scss" scoped>
.formula-editor-dialog {
  .latex-input-section {
    padding: 20px;

    .input-area {
      margin-bottom: 16px;

      .math-input {
        display: block;
        width: 100%;
        min-height: 60px;
        border: 1px solid #dcdfe6;
        border-radius: 4px;
        padding: 8px;
      }
    }

    .latex-preview {
      font-size: 14px;
      color: #606266;
    }
  }
}
</style>

五、编辑公式

当前已经可以实现向编辑器中插入公式,并且可以双击编辑器中的公式的时候,进入公式编辑器进行公式编辑。但是还有一个问题,就是编辑公式点击确认的时候,会重新向编辑器中加入一个新的公式,而不是替换之前的旧的公式。
我有一个大胆的想法。你看,在输入文字的时候,如果你鼠标选中了几个文字,然后输入,那么选中的文字就会直接被删了,那么能不能双击公式的时候,给公式新建一个 selection,让它是选中状态?然后再插入的时候,直接把现有的删了。显然,是不行的,还是应该双击的时候,记录下来当前的DOM元素,然后编辑公式之后操作指定的DOM元素。
src/components/extension-views/formula-view.vue 组件中,公式元素增加双击的绑定方法

<template>
	<node-view-wrapper as="span" class="formula-view">
		<span 
			ref="mathField" 
			:latex="latex" 
			@dblclick.stop="editFormula"
			class="formula-content"
		>
		</span>
	</node-view-wrapper>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3';
import katex from 'katex';

export default defineComponent({
	name: 'FormulaView',

	components: {
		NodeViewWrapper,
	},

	props: nodeViewProps,

	computed: {
		latex(): string {
			return this.node?.attrs?.latex || '';
		},
	},

	methods: {
		editFormula(event: MouseEvent) {
			event.preventDefault();
			event.stopPropagation();
			
			const target = event.currentTarget as HTMLElement;
			if (!target) return;

			requestAnimationFrame(() => {
				this.editor.commands.editFormula({
					latex: this.latex,
					currentEle: target
				});
			});
		},

		renderFormula() {
			if (this.$refs.mathField && this.latex) {
				try {
					katex.render(this.latex, this.$refs.mathField as HTMLElement, {
						throwOnError: false,
						displayMode: true
					});
				} catch (error) {
					console.error('Error rendering formula:', error);
				}
			}
		}
	},

	mounted() {
		this.renderFormula();
	},

	updated() {
		this.renderFormula();
	}
});
</script>

<style>
.formula-view {
	display: inline-block;
	cursor: pointer;
	user-select: none;
}

.formula-content {
	display: inline-block;
	padding: 2px 4px;
	pointer-events: auto;
}

.formula-view:hover .formula-content {
	background-color: rgba(0, 0, 0, 0.05);
	border-radius: 4px;
}
</style>

六、支持使用快捷方式输入公式

我们的latex公式,通常都是使用双$或者单$包裹的。在扩展中,我们需要制定输入规则以及粘贴,当检测到输入的内容有双$或者单$包裹时,自动转换为公式。

1、输入规则

输入规则通过扩展的 addInputRules 属性规定,返回一个对象数组,对象中包含 findhandlerfind指明要匹配的正则表达式,handler指明要进行的处理方法。
src/extensions/formula-editor.ts

  addInputRules() {
    return [
      // 双$规则
      {
        find: DOUBLE_DOLLAR_REGEX,
        handler: ({ state, range, match }) => {
          const latex = match[1];
          if (!latex) return null;

          const { tr } = state;
          const start = range.from;
          const end = range.to;

          tr.delete(start, end);
          tr.insert(start, this.type.create({
            latex
          }));
        },
      },
      // 单$规则
      {
        find: SINGLE_DOLLAR_REGEX,
        handler: ({ state, range, match }) => {
          const latex = match[1];
          if (!latex) return null;

          const { tr } = state;
          const start = range.from;
          const end = range.to;

          tr.delete(start, end);
          tr.insert(start, this.type.create({
            latex
          }));
        },
      },
    ];
  },

2、粘贴规则

如果粘贴的文本中有公式,也需要进行转换。

addPasteRules() {
  return [
    {
      find: DOUBLE_DOLLAR_REGEX,
      handler: ({ state, range, match }) => {
        const latex = match[1];
        if (!latex) return null;

        const { tr } = state;
        const start = range.from;
        const end = range.to;

        tr.delete(start, end);
        tr.insert(start, this.type.create({
          latex
        }));
      },
    },
    {
      find: SINGLE_DOLLAR_REGEX,
      handler: ({ state, range, match }) => {
        const latex = match[1];
        if (!latex) return null;

        const { tr } = state;
        const start = range.from;
        const end = range.to;

        tr.delete(start, end);
        tr.insert(start, this.type.create({
          latex
        }));
      },
    },
  ];
},

addProseMirrorPlugins() {
  return [
    new Plugin({
      key: new PluginKey('formulaEditorPaste'),
      props: {
        handlePaste: (view: EditorView, event: ClipboardEvent, slice: Slice) => {
          const content = event.clipboardData?.getData('text/plain');
          if (!content) return false;

          // 查找所有的公式
          const doubleMatches = Array.from(content.matchAll(DOUBLE_DOLLAR_REGEX));
          const singleMatches = Array.from(content.matchAll(SINGLE_DOLLAR_REGEX));

          if (doubleMatches.length === 0 && singleMatches.length === 0) {
            return false; // 如果没有公式,使用默认粘贴行为
          }

          // 创建一个新的事务
          const tr = view.state.tr;
          const pos = view.state.selection.from;
          
          // 保存所有匹配的位置和内容
          const allMatches = [...doubleMatches, ...singleMatches].map(match => ({
            start: match.index!,
            end: match.index! + match[0].length,
            latex: match[1],
            fullMatch: match[0]
          })).sort((a, b) => a.start - b.start);

          // 处理文本,将公式替换为节点
          let lastPos = 0;
          let insertPos = pos;

          allMatches.forEach(match => {
            // 插入公式前的文本
            if (match.start > lastPos) {
              const textBefore = content.slice(lastPos, match.start);
              tr.insert(insertPos, view.state.schema.text(textBefore));
              insertPos += textBefore.length;
            }

            // 插入公式节点
            const node = this.type.create({ latex: match.latex });
            tr.insert(insertPos, node);
            insertPos += 1; // 公式节点长度为 1

            lastPos = match.end;
          });

          // 插入剩余的文本
          if (lastPos < content.length) {
            const remainingText = content.slice(lastPos);
            tr.insert(insertPos, view.state.schema.text(remainingText));
          }

          // 应用事务
          view.dispatch(tr);
          return true;
        },
      },
    }),
  ];
},

3、修改mathquill的源码,阻止报错

在编辑框中进行输入的时候,会报错:
在这里插入图片描述

并且会阻碍后面的代码的执行,我们在代码中修改一下,不让它跑错,输出一下错误就行了,这个错误不影响编辑,只在开发环境会报错
src/utils/mathquill/mathquill.js

function pray(message, cond) {
    if (!cond)
        console.error('prayer failed: ' + message);
        // throw new Error('prayer failed: ' + message);
}

4、抽离公共方法

输入规则和粘贴规则的处理函数中的代码基本上都是相同的,可以进行抽离
src/extensions/formula-editor.ts

const handleRule = (state: EditorState, range: Range, match: RegExpMatchArray, type: NodeType) => {
  const latex = match[1];
  if (!latex) return null;

  const { tr } = state;
  const start = range.from;
  const end = range.to;

  tr.delete(start, end);
  tr.insert(start, type.create({
    latex
  }));
};
addInputRules() {
  return [
    // 双$规则
    {
      find: DOUBLE_DOLLAR_REGEX,
      handler: ({ state, range, match }) => {
        handleRule(state, range, match, this.type);
      },
    },
    // 单$规则
    {
      find: SINGLE_DOLLAR_REGEX,
      handler: ({ state, range, match }) => {
        handleRule(state, range, match, this.type);
      },
    },
  ];
},

addPasteRules() {
  return [
    {
      find: DOUBLE_DOLLAR_REGEX,
      handler: ({ state, range, match }) => {
        handleRule(state, range, match, this.type);
      },
    },
    {
      find: SINGLE_DOLLAR_REGEX,
      handler: ({ state, range, match }) => {
        handleRule(state, range, match, this.type);
      },
    },
  ];
},

七、监听内容修改

虽说上面我们在用户输入内容、粘贴内容的时候都进行了公式的转换,但是如果用户调用 setContent 之类的API修改编辑器内容的话,也需要转换公式。

addProseMirrorPlugins() {
  return [
    // 新增的内容监听插件
    new Plugin({
      key: new PluginKey('formulaEditorContentListener'),
      appendTransaction: (transactions, oldState, newState) => {
        // 检查是否有内容变化
        if (!transactions.some(tr => tr.docChanged)) return;

        const tr = newState.tr;
        let hasChanges = false;

        // 遍历所有文本节点
        newState.doc.descendants((node, pos) => {
          if (node.isText) {
            let text = node.text || '';
            let lastPos = 0;
            let newText = text;

            // 查找所有公式
            const doubleMatches = Array.from(text.matchAll(DOUBLE_DOLLAR_REGEX));
            const singleMatches = Array.from(text.matchAll(SINGLE_DOLLAR_REGEX));
            
            const allMatches = [...doubleMatches, ...singleMatches]
              .map(match => ({
                start: match.index!,
                end: match.index! + match[0].length,
                latex: match[1],
                fullMatch: match[0]
              }))
              .sort((a, b) => a.start - b.start);

            // 如果找到公式,处理每个公式
            if (allMatches.length > 0) {
              hasChanges = true;
              
              // 从后向前替换,以保持位置的正确性
              for (let i = allMatches.length - 1; i >= 0; i--) {
                const match = allMatches[i];
                const from = pos + match.start;
                const to = pos + match.end;

                // 删除原文本
                tr.delete(from, to);
                
                // 插入公式节点
                const formulaNode = this.type.create({ latex: match.latex });
                tr.insert(from, formulaNode);
              }
            }
          }
          return true;
        });

        return hasChanges ? tr : null;
      }
    })
  ];
},

其实有了这个方法就一步到位了,上面的输入、粘贴都不用监听了哈哈哈

总结:公式编辑器非常的复杂,细节还是挺多的。但是这个开源的MathQuill真的挺好用,体验感很好,集成之后也没有太多奇奇怪怪的报错。另外公式编辑也非常的必要,大家学起来!另外,这么复杂的代码怎么可能都是我自己写的?90%是Cursor写的。用习惯了AI好怕自己变笨🥹🥹🥹。但是正因为有AI,我们可以更高效的完成工作,然后去学更重要的东西!自己感兴趣的东西!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值