组件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组件,让有需要的同学直接开箱即用,不用再自己实现一遍了。