关于使用vue-codemirror6编写公式编辑器

148 阅读5分钟

1.背景

最近产品有需求是编写公式编辑器,其实在以前是写过一款这种公式编辑器,是用textarea和contenteditable实现的,这种手写的东西其实有个缺陷,一旦有一些需求变动或者迭代更新维护起来会十分复杂而且实现的东西基本都要手写,代码量肯定会暴增式增加,所以我咨询团队里的前端大牛,建议是使用vcm6这个插件来做一个底层支撑,这样更方便拓展与维护。

在我查阅了很多资料以及官方文档发现,大家都吐槽官方文档给出的例子实在是太少了,而且对于没有使用过cm5的人来说十分不友好,在看6文档时还要结合5文档的内容,才能得出大概的一个方向

2.需求

微信图片_20250309212040.png

微信图片_20250309212600.png

在需求开始之前,我们首先要知道代码编辑器如果想在定义一款输入特定字符会改字体颜色的主题,需要编写一款语言和为该语言打上标签的主题,我们可以拿js作为例子想象一下,输入"if"字符的时候,编辑器通过正则匹配,匹配到输入的是"if",然后为该"if"打上一个token,主题可以设置tag定义一个字体颜色为该token附上一个颜色。理解这个,对于上图我们需要自己去写一款语言,与为该语言打上标签的主题。

至于自动补全只需复写autocompletion就可以了,当然,需求上的这个公式编辑器还需要一个校验其错误的地方,我们只需要编写一个markdecoration即可解决,话不多说下面直接上代码

在开始前当然要安装 vue-codemirror 6的版本

需实现需求点以先列出来

1.语言

2.主题

3.按@自动补全

4.linter

2.1 编写语言

1.安装 @codemirror/language 与 @codemirror/legacy-modes

2.编写一个language.js文件 (为正则匹配通过的字符打上token标识)

import { simpleMode } from '@codemirror/legacy-modes/mode/simple-mode';
import { StreamLanguage } from '@codemirror/language';

export const formulaLang = simpleMode({
  start: [
    { regex: /\/\/.*/, token: 'comment' },
    { regex: /\/\*/, token: 'comment', next: 'comment' },
    { regex: /[a-z$][\w$]*/, token: 'variable' },
    { regex: /[\u4e00-\u9fa5]+/, token: 'variable' },
    {
      // TODO
      // eslint-disable-next-line security/detect-unsafe-regex
      regex: /0x[a-f\d]+|[-+]?(?:\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/i,
      token: 'number',
    },
    {
      regex: /(?:IF|ELSE|THEN|ELSEIF|RETURN|END|AND|OR|LIKE)\b/,
      token: 'keyword',
    },
    { regex: /[-+/*=<>!'"()]/, token: 'operator' },
    {
      regex: /(?:SUM|MAX|MIN|NVL|AVG)/,
      token: 'labelName',
    },
  ],
  comment: [
    { regex: /.*?\*\//, token: 'comment', next: 'start' },
    { regex: /.*/, token: 'comment' },
  ],
  meta: {
    dontIndentStates: ['comment'],
    lineComment: '//',
  },
});

export const language = StreamLanguage.define(formulaLang);

2.2 编写主题

1.安装 @lezer/highlight 与 thememirror (官方链接 thememirror.net/)

2.编写一个theme.js文件 (tag对应语言的token 为对应的token打上对应的颜色 即刻形成主题)

import { createTheme } from 'thememirror';
import { tags as t } from '@lezer/highlight';

export const theme = createTheme({
  variant: 'dark',
  settings: {
    background: '#000',
    foreground: '#80C6E2',
    caret: '#fff',
    selection: '#16a4c0',
    lineHighlight: '#8a91991a',
    // gutterBackground: '#000',
    gutterForeground: '#f2f2f2',
  },
  styles: [
    {
      tag: t.comment,
      color: '#787b8099',
    },
    {
      tag: t.variableName,
      color: '#80C6E2',
    },
    {
      tag: t.number,
      color: '#A974CB',
    },
    {
      tag: t.keyword,
      color: '#FAAE16',
    },
    {
      tag: t.operator,
      color: '#52C718',
    },
    {
      tag: t.labelName,
      color: '#5fddab',
    },
  ],
});

2.3 编写自动补全

1.安装 @codemirror/autocomplete

自行编写自动补全器,规定补全器的样式,如果想按什么键能稳定触发补全器,请往对应补全器选项中的label的开头拼接好需要按的键的字符,仅提供一个例子,如果想做得更通用我建议是override等变量是一个参数,请运用更高阶的技巧去打造该函数

import { autocompletion as _autocompletion } from '@codemirror/autocomplete';

/**
 * 自动补全override 按@ 自动不全
 * @param {object} context -编辑器上下文
 * @param {Array} autocompleteOptions -自动补全选项
 * @returns {object} -自动补全选项
 */
function override(context, autocompleteOptions) {
  if (autocompleteOptions.length) {
    const matchAllOptionsWord = context.matchBefore(/@/);

    const options = autocompleteOptions.map((row) => {
      return {
        ...row,
        label: `@${row.displayName}`,
        apply: row.displayName,
        displayLabel: row.displayName,
      };
    });

    if (matchAllOptionsWord) {
      // 以@开头,引出模糊搜索
      return {
        from: matchAllOptionsWord.from,
        options,
      };
    }

    const matchOptionsWord = context.matchBefore(/[\u4e00-\u9fa5\w\s]+/);

    if (matchOptionsWord) {
      // 文本输入模糊搜索
      return {
        from: matchOptionsWord.from,
        options,
      };
    }
  }

  return null;
}

/**
 * 自动补全生成器
 * @param {Array} autocompleteOptions 补全器选项
 * @returns {Function} -自动补全器
 */
export function autocompletion(autocompleteOptions) {
  return _autocompletion({
    override: [(context) => override(context, autocompleteOptions)],
    tooltipClass: () => 'codemirror-tooltip', // 样式在root目录下
    optionClass: () => 'codemirror-option', // 样式在root目录下
    addToOptions: [
      {
        render: (option) => {
          const {
            prefix: { text, color },
          } = option;

          const prefixNode = document.createElement('div');

          prefixNode.innerText = text;

          prefixNode.style.color = color;

          prefixNode.style.minWidth = '3rem';

          prefixNode.style.textAlign = 'right';

          return prefixNode;
        },
        position: 20,
      },
    ],
  });
}


2.4 编写linter

1.安装 @codemirror/lint

当我们有语言包情况下,可以引入syntaxTree,就很方便我们设计linter,整个linter同样可以设计样式与修改文字

import { syntaxTree } from '@codemirror/language';
import { linter as _linter, lintGutter as _lintGutter } from '@codemirror/lint';

/**
 * linter生成器
 * @param {Array} error -lint字符集合
 * @returns {Function} linter器
 */
export function linter(error) {
  return _linter(({ visibleRanges, state }) => {
    const diagnostics = [];

    visibleRanges.forEach(({ from, to }) => {
      syntaxTree(state).iterate({
        from,
        to,
        enter: (node) => {
          if (node.name === 'Document') {
            error.forEach((item) => {
              const text = state.doc.sliceString(node.from, node.to);

              const formPos = text.indexOf(item);

              if (formPos !== -1) {
                diagnostics.push({
                  from: formPos,
                  to: formPos + item.length,
                  severity: 'error',
                  message: `${item} 是前端小白, 并非大牛`,
                  actions: [
                    {
                      name: '移除',
                      apply(v, f, t) {
                        v.dispatch({
                          changes: { from: f, to: t },
                        });
                      },
                    },
                  ],
                });
              }
            });
          }
        },
      });
    });

    return diagnostics;
  });
}

export const lintGutter = _lintGutter();

2.5 编写sass

配合自动补全样式

.codemirror-tooltip {
  padding: 4px 0 !important;
  overflow: hidden;
  background-color: rgb(255 255 255) !important;
  border: none;
  border-radius: 8px;
  box-shadow:
    0 4px 12px 0 rgb(0 0 0 / 8%),
    0 2px 6px 0 rgb(0 0 0 / 8%),
    0 0 2px 0 rgb(0 0 0 / 8%);

  [aria-selected='true'] {
    color: unset !important;
    background: rgb(242 243 245) !important;
  }

  .codemirror-option {
    display: flex;
    align-items: center;
    height: 22px;
    padding: 4px 12px !important;
    font-size: 12px;
    color: rgb(29 33 41) !important;
  }
}

2.6 编写main.vue

<script setup>
import { computed } from 'vue';
import { Codemirror } from 'vue-codemirror';
import { autocompletion, language, linter, lintGutter, theme } from './plugin';

const props = defineProps({
  autocompletionOptions: {
    type: Array,
    default: () => [],
  },
  error: {
    type: Array,
    default: () => [],
  },
});

const extensions = computed(() => {
  const { autocompletionOptions, error } = props;

  return [
    autocompletion(autocompletionOptions),
    theme,
    language,
    linter(error),
    lintGutter,
  ];
});
</script>

<template>
  <Codemirror
    :extensions="extensions"
    v-bind="$attrs"
  />
</template>

2.7 编写组件入口

import Codemirror from './main.vue';

import './index.scss';

export default Codemirror;

2.9 使用组件

效果呈现如需求一样

<script setup>
import Codemirror from '@/components/codemirror';

const error = ['ian'];

const autocompletionOptions = [
  {
    displayName: '前端小白',
    prefix: {
      color: '#0F5FFF',
      text: 'Vue',
    },
  },
  {
    displayName: '前端萌新',
    prefix: {
      color: '#FAAE16',
      text: 'React',
    },
  },
  {
    displayName: '前端大牛',
    prefix: {
      color: '#52C718',
      text: 'Css3D',
    },
  },
];
</script>

<template>
  <Codemirror
    :error="error"
    :autocompletion-options="autocompletionOptions"
  />
</template>

2.10 整体文件编排

微信图片_20250309213310.png

3.结语

上面解决的需求 不作为唯一解决codemirror的需求的方法 但我还是比较推荐这种解决方法

其实使用库 本质就是了解api 学习api 用api的过程 而不是通过一些魔改 冗余的方法为了实现需求而去实现需求

本文是通过查阅了社区以及官方文档进行整理和总结的一篇技术分享文章 如果有文字表达错误请及时纠正我

感谢您的细心阅读!!