组件demo体验地址:https://dbfu.github.io/bp-script-editor

背景

最近公司让我实现一个低代码在线脚本编辑器组件,组件需要支持点击左边模型字段插入标签,还需要支持函数自动补全。

框架选型

我们公司前端使用的是react,从网上查了一些资料,找到了目前市面上比较流行的两款在线编辑器,一个是微软出的monaco-editor,对应的react组件是react-monaco-editor。还有一款是本文的主角codemirror,codemirror6对应的react组件是react-codemirror,还有一个基于codemirror6之前版本封装的react-codemirror2,两款编辑器都很强大,但是monaco-editor不支持在编辑器中插入html元素,也就是说实现不了上面说的插入标签的功能,所以放弃了monaco-editor,选用了codemirror。codemirror官网文档例子很少,为了实现功能踩了很多坑,这篇文章主要记录一下我踩的坑,以及解决方案。

吐槽

codemirror6的文档真的很少,例子也很少,官方论坛中很多人吐槽。论坛地址:https://discuss.codemirror.net

第一坑 实现插入标签功能

在官网示例中找到一个例子,已经实现了把文本变成标签的功能,就是因为看到了这个功能,我才决定使用codemirror。https://codemirror.net/examples/decoration/

image.png
从例子中找到代码,然后把代码复制到本地运行,发现有一块代码例子中没有写完整,直接用会报错。

image.png
就是这个PlaceholderWidget类,文档中只写了是从WidgetType继承而来,具体内部实现搞不清楚,只好自己去研究WidgetType,关于这个类官网也没有给具体的例子,只给出了这个类的说明,花了一段时间也没搞出来,就想其他的方法,既然官网已经实现了,肯定有源码,又去找源码,找了很长时间也没找到,后来灵光一闪,直接f12看官网请求的js,猜测应该会有,只是希望不要是压缩混淆后的代码。

image.png
在请求的js找到了这个js,看上去和例子名称差不多,进去看了一下,果然PlaceholderWidget的代码在里面,还是没有压缩的。

image.png
把代码拷到本地,功能可以正常使用了。插件完整代码如下:

import { ViewUpdate } from '@codemirror/view';
import { DecorationSet } from '@codemirror/view';
import {
  Decoration,
  ViewPlugin,
  MatchDecorator,
  EditorView,
  WidgetType,
} from '@codemirror/view';

import { PlaceholderThemesType } from '../interface';

export const placeholdersPlugin = (themes: PlaceholderThemesType, mode: string = 'name') => {

  class PlaceholderWidget extends WidgetType {
    curFlag: string;
    text: string;

    constructor(text: string) {
      super();
      if (text) {
        const [curFlag, ...texts] = text.split('.');
        if (curFlag && texts.length) {
          this.text = texts.map(t => t.split(':')[mode === 'code' ? 1 : 0]).join('.');
          this.curFlag = curFlag;
        }
      }
    }

    eq(other: PlaceholderWidget) {
      return this.text == other.text;
    }

    toDOM() {
      let elt = document.createElement('span');
      if (!this.text) return elt;

      const { backgroudColor, borderColor, textColor } = themes[this.curFlag];
      elt.style.cssText = `
      border: 1px solid ${borderColor};
      border-radius: 4px;
      line-height: 20px;
      background: ${backgroudColor};
      color: ${textColor};
      font-size: 12px;
      padding: 2px 7px;
      user-select: none;
      `;
      elt.textContent = this.text;
      return elt;
    }
    ignoreEvent() {
      return true;
    }
  }

  const placeholderMatcher = new MatchDecorator({
    regexp: /\[\[(.+?)\]\]/g,
    decoration: (match) => {
      return Decoration.replace({
        widget: new PlaceholderWidget(match[1]),
      });
    },
  });

  return ViewPlugin.fromClass(
    class {
      placeholders: DecorationSet;
      constructor(view: EditorView) {
        this.placeholders = placeholderMatcher.createDeco(view);
      }
      update(update: ViewUpdate) {
        this.placeholders = placeholderMatcher.updateDeco(
          update,
          this.placeholders
        );
      }
    },
    {
      decorations: (instance: any) => {
        return instance.placeholders;
      },
      provide: (plugin: any) =>
        EditorView.atomicRanges.of((view: any) => {
          return view.plugin(plugin)?.placeholders || Decoration.none;
        }),
    }
  );
}

第二坑 代码补全后,第一个参数自动选中,并可以使用tab切换到其他参数

这个实现时参考了官网的这个例子,开始实现起来很简单,但是后面想实现类似于vscode那种自动补全一个方法后,光标选中第一个参数,并可以切换到其他参数上,很显然官网给的这个例子并不支持,然后我就在论坛中去找,找了很长时间,在别人的问题中找到了一段代码。

image.png
使用${}包裹参数应该就可以了,然后试了一下不行,后面看了源码后才发现必须用snippetCompletion包一下才行。到此这个功能终于实现了。
实现效果:
image.png

插件代码如下:

import { snippetCompletion } from '@codemirror/autocomplete';
import { CompletionsType } from '../interface';

export function customCompletions(completions: CompletionsType[]) {
  return (context: any) => {
    let word = context.matchBefore(/\w*/);
    if (word.from == word.to && !context.explicit) return null;
    return {
      from: word.from,
      options: completions?.map((item) => (
        snippetCompletion(item.template, {
          label: item.label,
          detail: item.detail,
          type: item.type,
        })
      )) || [],
    };
  }
}

第三坑 点击函数自动插入到编辑器中,并实现和自动补全一样的参数切换效果

这个功能官网是一点都没说,我想了一下,既然自动补全时可以实现这个功能,肯定是有办法实现的,我就在源码一点点debugger,最后终于找到了snippet方法。下面贴一下我封装的insertText方法,第一个参数是要插入的文本,第二个参数表示该文本中是否有占位符。

插件代码如下:

  const insertText = useCallback((text: string, isTemplate?: boolean) => {
    const { view } = editorRef.current!;
    if (!view) return;

    const { state } = view;
    if (!state) return;

    const [range] = state?.selection?.ranges || [];

    view.focus();

    if (isTemplate) {
      snippet(text)(
        {
          state,
          dispatch: view.dispatch,
        },
        {
          label: text,
          detail: text,
        },
        range.from,
        range.to
      );
    } else {
      view.dispatch({
        changes: {
          from: range.from,
          to: range.to,
          insert: text,
        },
        selection: {
          anchor: range.from + text.length
        },
      });
    }
  }, []);

第四坑 实现自定义关键字高亮功能

这个功能在monaco editor中实现起来比较简单,但是在codemirror6中比较麻烦,可能是我没找到更好的方法。
这个功能官网推荐两个方法:

  1. 自己实现一个语言解释器,官方例子。https://github.com/codemirror/lang-example 可以从这个仓库中fork一个仓库去改,改完后编译一下,把编译后文件放到自己项目中就行了。主要是改项目中的src/syntax.grammar文件。可以在这里面加一个keyword类型,然后写正则表达式去匹配。

image.png
2. 使用MatchDecorator类写正则表达式匹配自己的关键字,这个类只支持正则表达式,只能遍历关键字动态创建正则表达式,然后用Decoration.mark去给匹配的文字设置样式和颜色。这里有个小坑,比如我的关键字是”a“,但是"aa"也能匹配上,查了很多正则表达式资料,学到了\b这个正则边界符,但是这个支持英文和数字,不支持中文,所以只能自己实现这个判断了,下面是插件代码。

 const regexp = new RegExp(keywords.join('|'), 'g');

  const keywordsMatcher = new MatchDecorator({
    regexp,
    decoration: (match, view, pos) => {
      const lineText = view.state.doc.lineAt(pos).text;
      const [matchText] = match;

      // 如果当前匹配字段后面一位有值且不是空格的时候,这种情况不能算匹配到,不做处理
      if (lineText?.[pos + matchText.length] && lineText?.[pos + matchText.length] !== ' ') {
        return Decoration.mark({});
      }

      // 如果当前匹配字段前面一位有值且不是空格的时候,这种情况不能算匹配到,不做处理
      if (lineText?.[pos - 1] && lineText?.[pos - 1] !== ' ') {
        return Decoration.mark({});
      }

      let style: string;

      if (keywordsColor) {
        style = `color: ${keywordsColor};`;
      }

      return Decoration.mark({
        attributes: {
          style,
        },
        class: keywordsClassName,
      });
    },
  });

第五坑 这个不能算是坑,主要是一个稍微复杂点的功能实现,对象属性提示

假设我们有一个user对象,user对象中有一个name属性,我在输入user.的时候,想显示他下面有哪些属性,这个功能还是很常见的。很可惜,我在官网也没有找到现成的实现,只能借助一些api自己去实现,下面是插件代码,实现思路在代码注释中。

vscode的效果:
image.png

我实现的效果:

image.png
样式有点丑,后面有时间把样式优化一下。

import { CompletionContext, snippetCompletion } from '@codemirror/autocomplete';
import { HintPathType } from '../interface'

export const hintPlugin = (hintPaths: HintPathType[]) => {
  return (context: CompletionContext) => {
    // 匹配当前输入前面的所有非空字符
    const word = context.matchBefore(/\S*/);

    // 判断如果为空,则返回null
    if (!word || (word.from == word.to && !context.explicit)) return null;

    // 获取最后一个字符
    const latestChar = word.text[word.text.length - 1];

    // 获取当前输入行所有文本
    const curLineText = context.state.doc.lineAt(context.pos).text;

    let path: string = '';

    // 从当前字符往前遍历,直到遇到空格或前面没有字符了,把遍历的字符串存起来
    for (let i = word.to; i >= 0; i -= 1) {
      if (i === 0) {
        path = curLineText.slice(i, word.to);
        break;
      }
      if (curLineText[i] === ' ') {
        // 这里加1,是为了把前面的空格去掉
        path = curLineText.slice(i + 1, word.to);
        break;
      }
    }

    if (!path) return null;

    // 下面返回提示的数组 一共有三种情况

    // 第一种:得到的字符串中没有.,并且最后一个输入的字符不是点。
    //       直接把定义提示数组的所有根节点返回

    // 第二种:字符串有.,并且最后一个输入的字符不是点。
    //       首先用.分割字符串得到字符串数组,把最后一个数组元素删除,然后遍历数组,根据路径获取当前对象的children,然后格式化返回。
    //       这里返回值里面的from字段有个坑,form其实就是你当前需要匹配字段的开始位置,假设你输入user.na,实际上这个form是n的位置,
    //       to是a的位置,所以我这里给form处理了一下

    // 第三种:最后一个输入的字符是点
    //       和第二种情况处理方法差不多,区别就是不用删除数组最后一个元素,并且格式化的时候,需要给label前面补上.,然后才能匹配上。

    if (!path.includes('.') && latestChar !== '.') {
      return {
        from: word.from,
        options: hintPaths?.map?.((item: any) => (
          snippetCompletion(`${item.label}`, {
            label: `${item.label}`,
            detail: item.detail,
            type: item.type,
          })
        )) || [],
      };
    } else if (path.includes('.') && latestChar !== '.') {
      const paths = path.split('.').filter(o => o);
      const cur = paths.pop() || '';

      let temp: any = hintPaths;
      paths.forEach(p => {
        temp = temp.find((o: any) => o.label === p)?.children || [];
      });

      return {
        from: word.to - cur.length,
        to: word.to,
        options: temp?.map?.((item: any) => (
          snippetCompletion(`${item.label}`, {
            label: `${item.label}`,
            detail: item.detail,
            type: item.type,
          })
        )) || [],
      };
    } else if (latestChar === '.') {
      const paths = path.split('.').filter(o => o);
      if (!paths.length) return null;

      let temp: any = hintPaths;
      paths.forEach(p => {
        temp = temp.find((o: any) => o.label === p)?.children || [];
      });

      return {
        from: word.to - 1,
        to: word.to,
        options: temp?.map?.((item: any) => (
          snippetCompletion(`.${item.label}`, {
            label: `.${item.label}`,
            detail: item.detail,
            type: item.type,
          })
        )) || [],
      };
    }
    return null;
  };
}

总结

上面的一些吐槽其实只是是一种调侃,内心还是很感谢那些做开源的人,没有他们的开源,如果什么都从底层实现一遍,花费的时间肯定会更多,甚至很多功能自己都实现不了。

或许上面功能都有更好的实现,只是我没有发现,大家如果有更好的实现,可以提醒我一下。我把这些功能封装成了一个react组件,让有需要的同学直接开箱即用,不用再自己实现一遍了。

组件仓库地址:https://github.com/dbfu/bp-script-editor

demo体验地址:https://dbfu.github.io/bp-script-editor

Logo

低代码爱好者的网上家园

更多推荐