组件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/

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

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

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

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

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那种自动补全一个方法后,光标选中第一个参数,并可以切换到其他参数上,很显然官网给的这个例子并不支持,然后我就在论坛中去找,找了很长时间,在别人的问题中找到了一段代码。

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

插件代码如下:

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中比较麻烦,可能是我没找到更好的方法。 这个功能官网推荐两个方法:

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

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的效果:

我实现的效果:

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

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

相关文章

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: