monaco
依赖
-
"monaco-editor": "^0.45.0",
-
"antlr4":语法校验
-
sql
- "sql-formatter": "^15.1.2",
- "dt-sql-parser": "^4.0.0-beta.4.11",
-
vue3/vite
- "vite-plugin-node-polyfills": "^0.19.0",
初始化
<template>
<div id="editor-box" :style="width: width, height: height"></div>
</template>
<script>
import * as monaco from 'monaco-editor'
export default {
name: 'VMonacoEditor',
props: {
width: String, // 宽度
height: String, // 高度
editorOptions: Object // 编辑器初始化属性配置
},
mounted() {
this.init()
},
methods: {
init() {
const editorOptions = {
//编辑器初始显示代码
value: this.code,
language: 'javascript',
// 语言支持自行查阅demo
automaticLayout: true,//自动布局
theme: 'vs-black',
//官方白带三种主题vS, hc-btack, or vs-dark
tabsize: 2,
...this.editorOptions
}
// 初始化编辑器,确保dom已经渲染
this.editor = monaco.editor.create(document.getelementById('editor-box'), editorOptions)
}
}
}
</script>
不带元素创建
this.editorInstance = monaco.editor.createModel(this.codeInfo.oldCode);
周边配置
状态栏展示部分
常见的一些配置比如 缩进、空格、行列选择、语言、换行模式、文件大小等信息,比如缩进:
在状态栏展示部分:
const IndentWidget = () => {
let state = useNovus<TState>((models) => {
const activeId = models.layout.state.layout.dealer.activeId;
return {
activeId: activeId,
activeTab: models.writer.state.tabs[activeId],
};
}, ['workspace', 'writer'])
const model = modelsHolder.models[state.activeId];
if (!model || !state.activeTab || state.activeTab.type !== 'text') return null;
const options = model.model.getOptions();
const space = options.insertSpaces;
const tabSize = options.tabSize;
return <Tooltip title="修改缩进">
<CommandBtn command="writer.changeIndent">
{`${space ? '空格' : 'Tab'}: ${space ? tabSize : null}`}
</CommandBtn>
</Tooltip>
}
quickOpen
[
点击的命令,支持使用 monaco-editor 自带的 quickOpen 功能展示配置选项:
import { getModel } from '.';
const { QuickOpenModel, QuickOpenEntryGroup } = window.require('vs/base/parts/quickopen/browser/quickOpenModel');
const { BaseEditorQuickOpenAction } = window.require('vs/editor/standalone/browser/quickOpen/editorQuickOpen');
const { matchesFuzzy } = window.require('vs/base/common/filters');
const { Range } = window.require('vs/editor/common/core/range');
type TActionOption = {
withBorder?: boolean
group?: string
}
function getIndentationEditOperations(model: monaco.editor.ITextModel, tabSize: number, tabsToSpaces: boolean) {
if (model.getLineCount() === 1 && model.getLineMaxColumn(1) === 1) { return; }
let spaces = ' '.repeat(tabSize);
let spacesRegExp = new RegExp(spaces, 'gi');
let operations = [];
for (let lineNumber = 1, lineCount = model.getLineCount(); lineNumber <= lineCount; lineNumber++) {
let lastIndentationColumn = model.getLineFirstNonWhitespaceColumn(lineNumber) || model.getLineMaxColumn(lineNumber);
if (lastIndentationColumn === 1) continue;
const originalIndentationRange = new Range(lineNumber, 1, lineNumber, lastIndentationColumn);
const originalIndentation = model.getValueInRange(originalIndentationRange);
const newIndentation = (tabsToSpaces ? originalIndentation.replace(/\t/ig, spaces) : originalIndentation.replace(spacesRegExp, '\t'));
operations.push({
range: originalIndentationRange,
text: newIndentation
});
}
model.applyEdits(operations);
}
class DemoActionCommandEntry extends QuickOpenEntryGroup {
constructor(highlights: any, editor: monaco.editor.IStandaloneCodeEditor, option: TActionOption) {
super();
this.editor = editor;
this.option = option;
this.withBorder = option.withBorder;
return this;
}
getLabel = () => this.option.label;
getAriaLabel = () => this.option.label;
getDetail = () => this.option.desc;
getGroupLabel = () => this.option.group || '';
run = (mode: number) => {
if (mode === 1 /* OPEN */) {
var model = getModel(this.editor);
if (this.option.operate[0] === 0) {
if (this.option.operate[1] === 0) {
model.updateOptions({
tabSize: this.option.operate[2],
insertSpaces: true
});
} else {
model.updateOptions({
insertSpaces: false
});
}
} else {
let modelOpts = model.getOptions();
// 转换 model, builder, tabSize, tabsToSpaces
getIndentationEditOperations(model, modelOpts.tabSize, this.option.operate[1] === 0)
}
return true;
}
return false;
};
}
export class IndentCommandAction extends BaseEditorQuickOpenAction {
constructor() {
super("ubug: 修改缩进", {
id: 'editor.ubug.changeIndent',
label: "ubug: 修改缩进",
alias: 'ubug: 修改缩进',
// menuOpts: {}
menuOpts: null
})
return this;
}
run = function (accessor: any, editor: monaco.editor.IStandaloneCodeEditor) {
this._show(this.getController(editor), {
getModel: function (value: string) {
var entries = [
{
label: '使用 2 空格缩进',
group: '视图',
desc: 'Indent Using 2 Spaces',
operate: [0, 0, 2] // insertSpaces, space nums
},
{
label: '使用 4 空格缩进',
desc: 'Indent Using 4 Spaces',
operate: [0, 0, 4]
},
{
label: '使用 Tab 缩进',
desc: 'Indent Using Tab',
operate: [0, 1, 2]
},
{
label: '使用空格转换已有内容',
group: '转换',
desc: 'Indent Using 2 Spaces',
operate: [1, 0], // insertSpaces, space nums
withBorder: true
},
{
label: '使用 Tab 转换已有内容',
desc: 'Indent Using Tab',
operate: [1, 1]
},
];
var models = entries.filter(e => (matchesFuzzy(value, e.label) || matchesFuzzy(value, e.desc))).map((e) => {
return new DemoActionCommandEntry(value, editor, e);
})
return new QuickOpenModel(models);
},
getAutoFocus: function (searchValue: string) {
return {
autoFocusFirstEntry: searchValue.length > 0,
autoFocusIndex: getModel(editor).getOptions().defaultEOL - 1
};
}
});
};
}
这个相当于是将怎么在 monaco-editor 的菜单中添加自定义的选项列表,不需要自己写具体的逻辑,功能的提供是 monaco-editor 自带的,非常有趣。
其他的比如 语言选取、编码模式都可以用这种方式扩展。
终端集成
command
import { getSpecialType } from "./specialEditors";
import Immutable from "seamless-immutable";
import { TFile, TFileRaw } from "../FileManager/model";
export const commands = {
gotoLine: {
trigger: () => {
// ...
}
},
changeIndent: {
trigger: () => {
// ...
}
},
chooseLanguage: {
trigger: () => {
// ...
}
},
showSourceCode: {
label: "查看源代码",
trigger: () => {
// ...
}
},
openFile: {
trigger: async (
fileRelative: string,
cb?: Function,
skipPreview?: boolean
) => {
window._novus.models["writer"].actions.previewConfirm(fileId);
// or ...
await window._novus.models.layout.actions.activePanel(fileId);
// or ...
window._novus.models["writer"].actions.openTab(file, skipPreview);
}
}
},
createUntitledFile: {
key: ["ctrl+n"],
label: "新建文件",
trigger: () => {
// ...
}
}
};
DirtyDiff
API
示例事件
生命周期
onDidChangeContent
onLanguage事件
当一种语言首次与文本模型相关联时发出的事件。以在语言配置就绪时调用该回调
monaco
setModelMarkers标记
// 标记错误信息
markMistake(range: any, type: string, message: any) {
const { startLineNumber, endLineNumber, startColumn, endColumn }: any = range;
console.log(1, monaco.editor, monaco.MarkerSeverity[type]);
monaco.editor.setModelMarkers(
this.editorInstance.getModel(),
'eslint',
[{
startLineNumber,
startColumn,
endLineNumber,
endColumn,
// Hint = 1,
// Info = 2,
// Warning = 4,
// Error = 8
severity: monaco.MarkerSeverity[type], // type可以是Error,Warning,Info
message: '123'
}]
)
// const issues = monacoLinter.getEditorMarks(this.editorInstance);
}
自定义token
monarchTokens: {
tokenizer: {
root: [
[/[(.+?)]/, 'custom-point'],
]
}
}
monaco.languages.setMonarchTokensProvider(languageConfig.name, languageConfig.monarchTokens);
state
撤销
editor.trigger("myapp", "undo");//触发撤销
editor.trigger("myapp", "redo");//触发重做
修改属性
editor.updateOptions({ fontSize: 20 }):修改属性
action
默认actions
以下是 Monaco Editor 的默认 actions 列表,每个 action 都标注了英文名称和中文解释:
editor.action.addCommentLine:添加行注释(Toggle Line Comment)
editor.action.addCursorsToBottom:在文件尾部添加光标(Add Cursors To Bottom)
editor.action.addCursorsToTop:在文件开头添加光标(Add Cursors To Top)
editor.action.addSelectionToNextFindMatch:选取当前匹配后的下一个匹配项(Add Selection To Next Find Match)
editor.action.addSelectionToPreviousFindMatch:选取当前匹配前的上一个匹配项(Add Selection To Previous Find Match)
editor.action.centerLineInViewport:将当前行垂直居中(Center Line In Viewport)
editor.action.clipboardCopyAction:复制当前选中内容到剪切板(Copy)
editor.action.clipboardCutAction:剪切当前选中内容到剪切板(Cut)
editor.action.clipboardPasteAction:从剪切板中粘贴内容(Paste)
editor.action.commentLine:对选中文本添加或取消行注释(Toggle Line Comment)
editor.action.copyLinesDownAction:复制当前行并粘贴到下一行(Copy Lines Down)
editor.action.copyLinesUpAction:复制当前行并粘贴到上一行(Copy Lines Up)
editor.action.createCursorUndo:撤销所有光标修改(Undo Cursor)
editor.action.cursorColumnSelectDown:向下纵向区域选择行(Cursor Column Select Down)
editor.action.cursorColumnSelectLeft:向左纵向区域选择该行字符(Cursor Column Select Left)
editor.action.cursorColumnSelectPageDown:向下以页面为单位进行纵向区域选择(Cursor Column Select Page Down)
editor.action.cursorColumnSelectPageUp:向上以页面为单位进行纵向区域选择(Cursor Column Select Page Up)
editor.action.cursorColumnSelectRight:向右纵向区域选择该行字符(Cursor Column Select Right)
editor.action.cursorColumnSelectUp:向上纵向区域选择行(Cursor Column Select Up)
editor.action.cursorDown:将光标向下移动(Cursor Down)
editor.action.cursorEnd:将光标定位到行末尾(Cursor End)
editor.action.cursorHalfPageDown:向下移动半个页面距离(Cursor Half Page Down)
editor.action.cursorHalfPageUp:向上移动半个页面距离(Cursor Half Page Up)
editor.action.cursorHome:将光标定位到行首(Cursor Home)
editor.action.cursorLeft:将光标向左移动(Cursor Left)
editor.action.cursorPageDown:向下翻页(Cursor Page Down)
editor.action.cursorPageUp:向上翻页(Cursor Page Up)
editor.action.cursorRight:将光标向右移动(Cursor Right)
editor.action.cursorTop:将光标定位到文件开头(Cursor Top)
editor.action.cursorUp:将光标向上移动(Cursor Up)
editor.action.deleteLines:删除当前行或选中的多行(Delete Lines)
editor.action.deleteAllLeft:删除光标到行首的所有内容(Delete All Left)
editor.action.deleteAllRight:删除光标到行尾的所有内容(Delete All Right)
editor.action.deleteLeft:删除光标前的字符(Delete Left)
editor.action.deleteRight:删除光标后的字符(Delete Right)
editor.action.duplicateSelection:复制并插入一份当前选中内容(Duplicate Selection)
editor.action.editor.action.insertSnippet:插入代码片段(Insert Snippet)
editor.action.filterActions:将行为过滤器或快捷键过滤器应用于列表(Filter Actions)
editor.action.goToDeclaration:跳转到变量声明处(Go to Declaration)
editor.action.goToImplementation:跳转到变量实现处(Go to Implementation)
editor.action.goToTypeDefinition:查找类型定义(Go to Type Definition)
editor.action.insertCursorAbove:插入一个向上的光标(Insert Cursor Above)
editor.action.insertCursorAtEndOfEachLineSelected:插入在每行末尾的光标(Insert Cursor at End of Each Line Selected)
editor.action.insertCursorBelow:插入一个向下的光标(Insert Cursor Below)
editor.action.insertLineAfter:在当前行下面插入一行(Insert Line After)
editor.action.insertLineBefore:在当前行上面插入一行(Insert Line Before)
editor.action.indentLines:将选中行缩进(Indent Lines)
editor.action.indentUsingSpaces:使用空格进行缩进(Indent Using Spaces)
editor.action.intersectSelections:保留所有光标的交集,取消其余光标(Intersect Selections)
editor.action.moveLinesDownAction:将选中的行向下移动一行(Move Lines Down)
editor.action.moveLinesUpAction:将选中的行向上移动一行(Move Lines Up)
editor.action.moveSelectionToNextFindMatch:将光标移到下一个匹配项处,并取消选择(Move Selection to Next Find Match)
editor.action.moveSelectionToPreviousFindMatch:将光标移到上一个匹配项处,并取消选择(Move Selection to Previous Find Match)
editor.action.moveToCenter:将光标定位到屏幕中央(Move To Center)
editor.action.navigateToNextError:跳转到下一个错误(Go to Next Error)
editor.action.navigateToPreviousError:跳转到上一个错误(Go to Previous Error)
editor.action.newLineAbove:在当前行上方新建一行(New Line Above)
editor.action.newLineBelow:在当前行下方新建一行(New Line Below)
editor.action.nextMatchFindAction:查找下一个匹配项(Next Match Find)
editor.action.outdentLines:将选中行的缩进减少(Outdent Lines)
editor.action.outdentUsingSpaces:使用空格减少缩进(Outdent Using Spaces)
editor.action.pasteSelection:粘贴文本并替换当前选中内容(Paste Selection)
editor.action.quickFix:快速修复错误(Quick Fix)
editor.action.quickOutline:打开当前文件的大纲视图(Quick Outline)
editor.action.redo:重做最近一次取消操作(Redo)
editor.action.referenceSearch.trigger:查找该变量的所有引用(Find All References)
editor.action.removeCommentLine:移除行注释(Toggle Line Comment)
editor.action.replaceAll:在全局范围内查找并替换所有匹配项;(Replace All);通常使用 Command+Shift+H 快捷键
model
获取语言
const languageID = this.editorInstance.getModel()?.getLanguageId();
//languageID === 'html'
设置语言
monaco.editor.setModelLanguage(this.editor.getModel(), val || 'javascript')
编程语言
扩展
LSP
lsp:microsoft.github.io/language-se…
内置语言
对已有的语言配置
JS/TS/CSS/HTML 是内置
// validation settings
monaco.languages.typescript.javascriptDefaults.setDiagnostics0ptions({
noSemanticValidation: true,
noSyntaValidation: true
})
// compiler options
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2017,
strict: true,
allowNonTsExtensions: true
})
代码补全
monaco-editor 本身已经具备了常见的代码补全,比如 window 变量,dom,css 属性等。但是并未提供node_modules 中的代码补全,比如最常见的 react,没有提示,体验会差很多。
经过调研,monaco-editor 可以提供代码提示的入口至少有两个 api:
- registerCompletionItemProvider,需要自定义触发规则及内容
- addExtraLib,通过添加 index.d.ts,使得在自动输入的时候,提供由 index.d.ts 解析出来的变量进行自动补全。
第一种方案网上的文章较多,但是对于实际的需求,导入 react, react-dom,如果采用此种方案,就需要自行完成对 index.d.ts 的解析,同时输出类型定义方案,在实际使用时非常繁琐,不利于后期维护。
registerCompletionItemProvider
monaco.languages.registerCompletionItemProvider(languageConfig.name, {
// triggerCharacters: ['$'],
// replaceTriggerChar: true,
provideCompletionItems: function () {
// { label: any; kind: monaco.languages.CompletionItemKind; insertText: any }[]
let newSuggestions: any = [];
Object.keys(suggestions as any).forEach((item: any) => {
newSuggestions.push(...suggestions[item]);
})
return {
suggestions: newSuggestions.map((item: any) => {
return ({
label: item.label ? item.label : item,// 显示的提示内容
kind: item?.kind ? item.kind : monaco.languages.CompletionItemKind.Function,// 用来显示提示内容后的不同的图标
insertText: item.label ? item.label : item, // 选择后粘贴到编辑器中的文字
// detail: '123', // 提示内容后的说明
// insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
});
})
};
},
});
-
suggestion提示重复
//monaco.languages.registerCompletionItemProvider赋值,调用dispose this.suggestInstance = monaco.languages.registerCompletionItemProvider(languageConfig.name, {}); this.suggestInstance.dispose()
addExtraLib
//addExtraLib添加库
let libSource: any;
await fetch('./index.d.ts', {
mode: 'no-cors',
})
.then(response => response.text()).then(response => {
libSource = response
})
monaco.languages.typescript.javascriptDefaults.addExtraLib(libSource, 'index.d.ts');
monarch语法高亮
Monarch 是 Monaco Editor 自带的一个语法高亮库,通过它,我们可以用类似 Json 的语法来实现自定义语言的语法高亮功能。
tokenizer
monaco.languages.setMonarchTokensProvider('log', {
tokenizer: {
root:[
[/\d+/,{token:"keyword"}],
[/[a-z]+/,{token:"string"}]
],
}
});
在 root 中设置了两个 rule ,分别用来匹配数字和字母,匹配成功后就接着执行对应的 action 在 action 中,我们设置了匹配文本的 token class :keyword和string
[regex, action]
我们在 tokenizer 中定义了一个 root 属性,root 是 tokenizer 中的一个 state , 这就是我们用来编写解析规则(rule)的地方,在 rule 中,我们可以编写匹配文本的正则表达式,然后再给匹配到的文本设置一个执行动作的 action ,在 action 中,我们可以给匹配到的文本设置 token class 。
本质上,token class 其实就是设置 css 中的 class
Monarch 中内置了以下几种 token class:
identifier entity constructor
operators tag namespace
keyword info-token type
string warn-token predefined
string.escape error-token invalid
comment debug-token
comment.doc regexp
constant attribute
delimiter .[curly,square,parenthesis,angle,array,bracket]
number .[hex,octal,binary,float]
variable .[name,value]
meta .[content]
Rules规则
每个状态定义为一个用于匹配输入的规则数组,规则可以有如下形式:
[regex, action]
{regex: regex, action: action}
形式的简写。
[regex, action, next]
{ regex: regex, action: action{next: next} }
形式的简写。
{regex: regex, action: action }
当regex
与当前输入匹配时,将引用action
来设置token
类,正则表达式regex
可以是一个标准的正则表达式(使用/regex/
),也可以是一个代表正则表达式的字符串。如果以^
字符开头,那么该表达式只匹配源行的开头,$
则反过来匹配行尾。注意,已经到达行尾时,tokenizer
将不会被调用,因为,空模式/$/
将永远不会匹配(但也请参阅@eos
)。在正则表达式中,可以将名为attr
的字符串属性引用为@attr
,该属性会自动展开。这在标准示例中用于在字符和字符串的正则表达式中使用@escapes
共享转义序列的正则表达式。
正则表达式入门:我们使用的常见的正则表达式转义是\d
代表[0-9]
,\w
代替[a-zA-z0-9_]
,以及\s
代表[ \t\r\n]
。符号regex{n}
表示regex
出现n
次。同样,我们使用(?=regex)
来表示非消费
后面跟着regex
,(?!regex)
表示后面跟着的不是regex
,以及(?:regex)
表示一个非捕获的组(也就是说,不能使用$n
来引用它)。
{ include:state }
用于对规则进行良好的组织,并扩展到定义在state
中的所有规则。这是预先展开的,对性能没有影响。比如许多的例子里都包括@whitespace
状态。
Actions操作
一个action
决定了生成的token
类,一个action
有以下形式:
string
{ token: string }
的简写形式。
[action1,...,actionN]
多个action
组成的数组。这仅在正则表达式恰好由N
个组(即括号部分)组成时才允许。由于tokenizer
的工作方式,你必须以所有的组都出现在顶层并包含整个输入的方式来定义组,举个例子,可以将ascii
码转义序列定义为:
/(')(\(?:[abnfrt]|[xX][0-9]{2}))(')/, ['string','string.escape','string']]
注意我们是如何在内部组中使用一个(?:)
来表示一个非捕获的组。
{ token: tokenclass }
定义用于css
渲染的token
类的对象。常见的token
类有keyword
、comment
、identifier
。你可以用一个.
来使用分级的css
名称,比如type.identifier
或者string.escape
。你还可以使用$
模式,这些模式会被来自匹配的输入或者tokenizer
状态的捕获组替换,本文档的guard section
部分描述了这些模式。下面是一些特殊的token
类:
"@brackets" 或 "@brackets.tokenclass"
表示括号被标记。css
的token
类由括号属性中定义的token
类确定(如果存在,则与tokenclass
一起)。此外,设置括号属性使编辑器匹配大括号(并自动缩进)。举个例子:
[/[{}()[]]/, '@brackets']
"@rematch"
(高级)备份输入并重新调用tokenizer
。这只在状态发生变化时才有效(或者我们进入了无限的递归),所以这个通常和next
属性一起使用。例如,当你处于特定的tokenizer
状态,并想要在看到某些结束标记时退出,但是不想在处于该状态时使用它们,就可以使用这个。另见nextEmbedded
。
一个action
对象可以包含影响词法分析器状态的更多字段。请看下列属性:
next: state
(字符串)如果已定义,则将当前状态推入tokenizer
栈,并使state
成为当前状态。例如,这可以用于开始标记一个注释块:
['/\*', 'comment', '@comment' ]
请注意这是下面的简写:
{ regex: '/\*', action: { token: 'comment', next: '@comment' } }
这里匹配到的/*
被赋予了comment
的token
类,tokenizer
使用@comment
状态中的规则继续匹配输入。
这里有一些特殊的状态可以被next
属性使用:
"@pop"
弹出tokenizer
栈以返回到之前的状态。例如,这会用于在看到结束标记后从块注释标记返回:
['\*/', 'comment', '@pop']
"@push"
推入当前状态,并在当前状态中继续。很适合在看到注释开始标记时进行嵌套块注释,即在@comment
状态时,我们可以执行以下操作:
['/\*', 'comment', '@push']
"@popall"
从tokenizer
栈中弹出所有状态,并回到顶部的状态。这可以在恢复期间从一个深度嵌套级别“跳”回初始状态。
switchTo: state
(高级)切换到state
而不改变堆栈。
goBack: number
(高级)按number
字符备份输入。
bracket: kind
(高级)kind
可以是@open
或@close
。这表示一个token
是开括号还是闭括号。如果token
类是@brackets
会自动设置此属性。编辑器使用括号信息来显示匹配的括号(如果它们的token
类相同,则左括号和右括号匹配)。当用户新开一行时,编辑器将在大括号上自动进行缩进。通常,如果你使用括号属性,则不需要设置此属性,它仅用于复杂的大括号匹配。这会在下一个章节advanced brace matching
中讨论。
nextEmbedded: langId or '@pop'
(高级)向编辑器表示此token
后面跟着由langId
指定的另一种语言的代码。例如对于javascript
,在内部,我们的语法高亮器继续标记源代码,直到它找到一个结束序列。此外,你可以使用nextEmbedded
和一个@pop
值再次弹出嵌入模式。nextEmbedded
通常需要和next
属性配合切换到可以标记外部代码的状态。作为一个例子,下面是如何在我们的语言中支持css
片段:
root: [
[/<style\s*>/, { token: 'keyword', bracket: '@open'
, next: '@css_block', nextEmbedded: 'text/css' }],
[/</style\s*>/, { token: 'keyword', bracket: '@close' }],
...
],
css_block: [
[/[^"<]+/, ''],
[/</style\s*>/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
[/"/, 'string', '@string' ],
[/</, '']
],
注意我们是如何切换到css_block
状态来标记css
源代码的。同样,在css
中我们使用@string
状态来标记css
字符串,这样当我们在字符串中发现</style>
时我们不会停止css
块。当我们发现结束标签时,我们也可以使用@pop
去回到正常的标记过程。最后,我们需要使用@rematch
这个token
(在root
状态),因为编辑器会忽略我们的token
类直到我们真正退出嵌入模式。更多请阅读后面的complex dynamic embeddings
部分内容。
log: message
用于调试。将message
输出到浏览器的窗口控制台(按F12
查看)。这个在查看某个action
是否正在执行时非常有用。举个例子:
[/\d+/, { token: 'number', log: 'found number $0 in state $S0' } ]
{ cases: { guard1: action1, ..., guardN: actionN} }
最后一种action
对象是case
语句。一个case
对象包含一个对象,其中每一个字段都充当一个守卫。每个守卫都应用于匹配的输入,只要其中一个匹配,就应用相应的action
。注意,因为这些是action
本身,case
可以嵌套。case
是为了提升效率:举个例子,我们匹配标识符,然后检测标识符是否可能是一个关键字或一个内置函数:
[/[a-z_$][a-zA-Z0-9_$]*/,
{ cases: { '@typeKeywords': 'keyword.type'
, '@keywords': 'keyword'
, '@default': 'identifier' }
}
]
守卫包括:
-
"@keywords"
keywords
属性必须在语言对象中定义,并且是一个字符串数组。如果匹配的输入与任何一个字符串匹配,则守卫成功。(注意:所有case
都是预编译的,列表使用的是高效的hash
映射进行匹配)。(高级):如果属性引用的是一个单独的字符串(而不是数组),则会把它编译成一个正则表达式,并根据匹配的输入进行测试。 -
"@default"
(
"@"
或""
)默认守卫,总是会守卫到。 -
"@eos"
如果匹配的输入已经到达行尾,那么则守卫成功。
-
"regex"
如果守卫不以
@
或$
字符开头,那么它会被解释为一个针对匹配的输入进行测试的正则表达式。注意:regex
以^
开头和$
结尾,所以它必须匹配整个被匹配的输入。例如,这可以用于测试特定的输入,这里有一个来自Koka
语言的例子,它使用这个基于声明进入各种tokenizer
状态:[/[a-z](\w|-[a-zA-Z])*/, { cases:{ '@keywords': { cases: { 'alias' : { token: 'keyword', next: '@alias-type' } , 'struct' : { token: 'keyword', next: '@struct-type' } , 'type|cotype|rectype': { token: 'keyword', next: '@type' } , 'module|as|import' : { token: 'keyword', next: '@module' } , '@default' : 'keyword' } } , '@builtins': 'predefined' , '@default' : 'identifier' } } ]
请注意使用嵌套case
来提升效率。此外,库还能识别上述简单的正则表达式,并高效的编译他们。举个例子,单词列表type|cotype|rectype
会使用一个javascript
的hash
映射或对象来测试。
(高级)一般来说,守卫的形式是[pat][op]match
,有一个可选的模式,和操作符(默认为$#
和~
)。模式pat
可以是下面的一种:
$#
(默认)匹配的输入(或者当action
是数组时匹配到的组)。
$n
匹配输入的第n
组,或者是$0
代表这个匹配的输入。
$Sn
状态的第n
个部分,比如,$S2
返回状态@tag.foo
中的foo
。使用$0
代表整个状态名。
上述的模式实际上可以出现在许多属性中,并且可以自动展开。这些模式展开的属性有token
、next
、nextEmbedded
和log
。此外,这些模式在守卫的match
部分进行了扩展。
守卫的操作op
和match
可以是:
~regex or !~regex
(op
默认为~
)针对正则表达式或其否定项进行测试pat
。
@attribute or !@attribute
Tests whether *pat*
is an element (@
), or not an element (!@
), of an array of strings defined by *attribute*
.
测试pat
是由attribute
定义的字符串数组的元素是一个元素@
,或者不是一个元素!@
。
==str or !=str
测试pat
和给定的字符串str
是否相等。
自定义log文本颜色
以 [error]
, [info]
, [warning]
作为一行的开头,从而代表日志的级别。
tokenizer: {
root: [
[/^[error]/, { token: "custom-error" }],
[/^[info]/, { token: "custom-info" }],
[/^[warning]/, { token: "custom-warning" }]
]
}
//设置含有custom-error等token class的主题
monaco.editor.defineTheme('logTheme', {
base: 'vs',
inherit: true,
rules: [
{ token: 'custom-info', foreground: '808080' },
{ token: 'custom-error', foreground: 'ff0000', fontStyle: 'bold' },
{ token: 'custom-warning', foreground: 'FFA500' }
]
});
monaco.editor.create(document.getElementById("container"), {
theme: 'logTheme',
value: getCode(),
language: 'log'
});
我们写了三条 rule ,分别将 [error]
标记为 custom-error
,[info]
标记为 custom-info
,[warning]
标记为 custom-warning
。我们发现,这些 rule 都是类似的,所以,我们可以想办法把他们合在一起。
tokenizer: {
root: [
[/^[(\w+)]/, { token: "custom-$1" }]
]
}
这里我们用到了一个美元符号 $
,它代表取正则表达式第几个匹配项,$0
代表取所有的匹配项(例:[error]),$1
代表取第一个匹配项(例:error)。上述代码将日志类型作为参数传入了 token class ,与 custom-
做拼接,从而组成了最终的 token class,例如 custom-error
。
自定义语言
betterprogramming.pub/create-a-cu…
编辑器的总体架构
从上面的架构中我们可以看到,一般来说,任何编辑器中都有两个线程。
- 其中一个负责 UI 事务,例如等待用户输入一些代码或执行一些操作。
- 另一个线程接受用户所做的更改并进行繁重的计算,其中包括代码解析和其他编译内容。
对于编辑器中的每一次更改(可以是用户键入的每一个字符,也可以是直到用户停止键入2秒为止),都会向语言服务工作线程发送一条消息以执行某些操作。进程将回复一条包含结果的消息。例如,当用户键入一些代码并想要格式化代码(单击 Shift + Alt + F)时,工作人员将收到一条包含操作“Format”和要格式化的代码的消息。这应该异步发生以获得良好的用户体验。
另一方面,语言服务负责解析代码,生成抽象语法树(AST),查找任何可能的语法或词汇错误,使用AST查找任何语义错误,格式化代码等。
Monaco 提供了一个 API monaco.editor.createWebWorker来使用内置ES6 Proxies创建代理 Web Worker 。使用getProxy方法获取代理对象(语言服务)。要访问语言服务工作者中的任何服务,我们将使用此代理对象来调用任何方法。所有方法都会返回一个 Promise 对象。
ANTLR
- www.antlr.org/
- github.com/antlr/gramm… 编写的形式语法的集合
- zhuanlan.zhihu.com/p/483679676
- juejin.cn/post/731856…
ANTLR(ANother Tool for Language Recognition)是一个强大的语法分析生成器,用于读取、处理、执行或翻译结构化文本或二进制文件。它被广泛用于构建语言、工具和框架。根据语法,ANTLR生成了一个可以构建和遍历解析树的解析器。
从语法角度来看,ANTLR生成了一个可以构建和遍历解析树的语法分析器。”ANTLR支持许多语言作为目标,这意味着它可以用Java、C#和其他语言生成解析器
npm add antlr4 -D
//ANTLR4TS是 ANTLR 的 Node.js 版本
- Parser语法
- Lexer词法
demo
-
依赖
//package.json "scripts": { "test": "jest", "antlr4ts-mysql": "antlr4ts -Xexact-output-dir -o src/grammar-output/mysql -package parsers src/grammars/mysql/MySQLLexer.g4 src/grammars/mysql/MultiQueryMySQLParser.g4" }, "dependencies": { "antlr4ts": "^0.5.0-alpha.4" }, "devDependencies": { "@types/jest": "^26.0.20", "antlr4ts-cli": "^0.5.0-alpha.4", "jest": "^26.6.3", "ts-jest": "^26.5.3", "typescript": "^4.2.3" }
-
Antlr4 语法文件
github.com/antlr/gramm… 编写的形式语法的集合
我们使用 Antlr4 来实现一个基本的 SQL Parser。Antlr4 是一个强大的解析器生成器,它能根据用户自定义的语法文件来生成对应的解析器。Antlr4 的语法文件为 .g4文件,内部可以包含多条规则,规则可以分为词法规则和语法规则,词法规则用于生成词法分析器,语法规则用于生成语法解析器。
-
执行
npm run antlr4ts-mysql
解析语法文件"antlr4ts-mysql": "antlr4ts -Xexact-output-dir -o src/grammar-output/mysql -package parsers src/grammars/mysql/MySQLLexer.g4 src/grammars/mysql/MultiQueryMySQLParser.g4" //TODO:这里没有生成base文件,具体原因不清楚
-
jest执行测试: npm run test
需要配置ts,babel。因为es6不能识别
//jest.config.js module.exports = { preset: 'ts-jest', testEnvironment: 'node', }; //tsconfig.json { "compilerOptions": { "module": "commonjs", "target": "es2019", "moduleResolution": "node", "esModuleInterop": true, "resolveJsonModule": true, "sourceMap": true, "allowJs": false, "incremental": true, "declaration": true, "outDir": "dist", "experimentalDecorators": true, "baseUrl": "./src", "paths": { "@/*": ["./*"] }, "strictPropertyInitialization": false }, "include": ["**/*.ts"], "exclude": ["node_modules", "dist", "**/*.d.ts", "test", "parse.ts"] }
// ./test/mysql.test.ts import { CommonTokenStream, CharStreams, ParserErrorListener } from "antlr4ts"; import { MySQLLexer } from "../src/grammar-output/mysql/MySQLLexer"; import { MultiQueryMySQLParser } from "../src/grammar-output/mysql/MultiQueryMySQLParser"; //使用 ParserErrorListener 收集错误信息 class SelectErrorListener implements ParserErrorListener { private _parserErrorSet: Set<any> = new Set(); syntaxError(_rec, _ofSym, line, charPosInLine, msg) { let endCol = charPosInLine + 1; this._parserErrorSet.add({ startLine: line, endLine: line, startCol: charPosInLine, endCol: endCol, message: msg, }); } clear() { this._parserErrorSet.clear(); } get parserErrors() { return Array.from(this._parserErrorSet); } } class SelectParser { private _errorListener = new SelectErrorListener(); createLexer(input: string) { const inputStream = CharStreams.fromString(input); const lexer = new MySQLLexer(inputStream); this._errorListener.clear(); lexer.removeErrorListeners(); // 移除 Antlr4 内置的 ErrorListener lexer.addErrorListener(this._errorListener); return lexer; } createParser(input: string) { const lexer = this.createLexer(input); const tokens = new CommonTokenStream(lexer); const parser = new MultiQueryMySQLParser(tokens); parser.removeErrorListeners(); // 移除 Antlr4 内置的 ErrorListener parser.addErrorListener(this._errorListener); return parser; } parse(sql: string) { const parser = this.createParser(sql); const parseTree = parser.selectStatement(); // console.log(this._errorListener.parserErrors); return { parseTree, errors: this._errorListener.parserErrors, }; } } test("can parse and tokenize a query", () => { // // 试一下效果 const selectParser = new SelectParser(); // const parseTree = selectParser.parse("SELECT * FROM table1"); const parseTree = selectParser.parse("SELECT ;"); console.log(parseTree); });
worker
Monaco 提供了一个 API monaco.editor.createWebWorker来使用内置ES6 Proxies创建代理 Web Worker 。使用getProxy方法获取代理对象(语言服务)。要访问语言服务工作者中的任何服务,我们将使用此代理对象来调用任何方法。所有方法都会返回一个 Promise 对象。
this.worker = monaco.editor.createWebWorker<TodoLangWorker>({
moduleId: 'vs/language/vue/TodoLangWorker',
label: this.languageID,
//通过postMessage传递给子线程,self.onmessage中monacoWorker.initialize中获取createData
createData: {
languageId: this.languageID,
}
});
this.workerClientProxy = <Promise<TodoLangWorker>><any>this.worker.getProxy();
onmessage
主进程和worker进程通信
self.onmessage = (e) => {
console.log(669, e, monacoWorker.initialize);
};
AviatorScript语言封装
参考:
ANTLR生成parse
import { TodoLangGrammarParser, TodoExpressionsContext } from "../ANTLR/TodoLangGrammarParser";
import { TodoLangGrammarLexer } from "../ANTLR/TodoLangGrammarLexer";
import { ANTLRInputStream, CommonTokenStream } from "antlr4ts";
export default function parse(code: string): TodoExpressionsContext {
const inputStream = new ANTLRInputStream(code);
const lexer = new TodoLangGrammarLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TodoLangGrammarParser(tokenStream);
// Parse the input, where `compilationUnit` is whatever entry point you defined
return parser.todoExpressions();
}
这里直接使用gitee.com/opendsl/ope…中的文件
monaco类初始化
-
Worker创建
import { WorkerManager } from './language-service/worker'; import DiagnosticsAdapter from './language-service/DiagnosticsAdapter'; import TodoLangWorker from './language-service/todoLangWorker.ts?worker'; (window as any).MonacoEnvironment = { getWorker: function (moduleId, label) { console.log(moduleId, label === 'AviatorScript', label); if (label === 'AviatorScript') { const worker = new TodoLangWorker() //初始化TodoLangWorker,为worker提供代理方法 return worker; } return new monacoWorker(); } } monaco.languages.onLanguage(this.config?.languageConfig?.name, () => { const client = new WorkerManager(this.config?.languageConfig?.name);//初始化WorkerManager,创建worker const worker = (...uris: monaco.Uri[]) => { return client.getLanguageServiceWorker(...uris); // 获取代理方法 }; this.DiagnosticsAdapter = new DiagnosticsAdapter(worker);//初始化DiagnosticsAdapter,onDidChangeContent监听变化并调用worker代理方法doValidation });
getWorkerUrl失效
报错:EditorSimpleWorker.loadForeignModule
TodoLangWorker
创建TodoLangWorker,它是为web worker提供方法。用户通过monaco.editor.createWebWorker().getProxy()
获取web worker中的代理方法
在web worker中内置方法即为TodoLangWorker的方法,使用web Workers内置方法只是在主线程内部调用代理的方法。
import type * as monaco from 'monaco-editor';
import TodoLangLanguageService from './LanguageService';
import type { ITodoLangError } from './TodoLangErrorListener';
import * as monacoWorker from 'monaco-editor/esm/vs/editor/editor.worker.js';
import IWorkerContext = monaco.worker.IWorkerContext;
//worker中代理方法需要去initialize先初始化TodoLangWorker
self.onmessage = () => {
monacoWorker.initialize((ctx, CreateData) => {
// console.log(ctx, CreateData);
return new TodoLangWorker(ctx, CreateData)
});
};
export class TodoLangWorker {
private _ctx: IWorkerContext;
private CreateData: any;
private languageService: TodoLangLanguageService;
constructor(ctx: IWorkerContext, CreateData: any) {
// console.log('TodoLangWorker:ctx', ctx, CreateData);
this._ctx = ctx;
this.CreateData = CreateData;
this.languageService = new TodoLangLanguageService();
}
doValidation(model: monaco.editor.IModel): Promise<ITodoLangError[]> {
// console.log('TodoLangWorker:ctx', this._ctx, this.CreateData);
const code = this.getTextDocument();
// console.log(code)
return Promise.resolve(this.languageService.validate(code, this.CreateData));
}
format(code: string): Promise<string> {
return Promise.resolve(this.languageService.format(code));
}
private getTextDocument(): string {
const model = this._ctx.getMirrorModels()[0];// When there are multiple files open, this will be an array
return model.getValue();
}
}
LanguageService
import { parseAndGetASTRoot, parseAndGetSyntaxErrors } from './parser';
import { parseAndGetASTRoot as parseAndGetASTRootSql, parseAndGetSyntaxErrors as parseAndGetSyntaxErrorsSql } from '../sql/service';
import type { ITodoLangError } from './TodoLangErrorListener';
export default class TodoLangLanguageService {
validate(code: string, CreateData: any): ITodoLangError[] {
let syntaxErrors: ITodoLangError[], ast: TodoExpressionsContext
if (CreateData.languageId === 'sql') {
syntaxErrors = parseAndGetSyntaxErrorsSql(code);
ast = parseAndGetASTRootSql(code);
} else {
syntaxErrors = parseAndGetSyntaxErrors(code);
ast = parseAndGetASTRoot(code);
}
console.log(ast);
return syntaxErrors;
}
format(code: string): string {
}
}
parser
import TodoLangErrorListener from './TodoLangErrorListener';
import { InputStream, CommonTokenStream } from 'antlr4';
import GrammarParser from '../aviatorscript/grammar/GrammarParser.mjs';
function parse(code: string): { ast: TodoExpressionsContext, errors: ITodoLangError[] } {
let grammarParser = new GrammarParser();
let astJson = grammarParser.parse(code);
return astJson
}
export function parseAndGetASTRoot(code: string): TodoExpressionsContext {
const { ast } = parse(code);
return ast;
}
export function parseAndGetSyntaxErrors(code: string): any[] {
const { errors } = parse(code);
return errors;
}
WorkerManager
作用
- 创建worker
- 返回代理方法
import * as monaco from 'monaco-editor';
import Uri = monaco.Uri;
import type { TodoLangWorker } from './todoLangWorker';
// import { languageID } from './config';
// const languageID = 'AviatorScript'
export class WorkerManager {
private worker: monaco.editor.MonacoWebWorker<TodoLangWorker>;
private workerClientProxy: Promise<TodoLangWorker>;
constructor(private languageID) {
this.worker = null;
}
private getClientproxy(): Promise<TodoLangWorker> {
// console.log(!this.workerClientProxy, this.languageID, '!this.workerClientProxy');
//判断是否存在worker代理
if (!this.workerClientProxy) {
this.worker = monaco.editor.createWebWorker<TodoLangWorker>({
// module that exports the create() method and returns a `JSONWorker` instance
moduleId: 'vs/language/vue/TodoLangWorker',
label: this.languageID,
//通过postMessage传递给子线程,self.onmessage中monacoWorker.initialize中获取createData
createData: {
languageId: this.languageID,
// parse: service.parse
}
});
this.workerClientProxy = <Promise<TodoLangWorker>><any>this.worker.getProxy();//使用getProxy方法获取代理对象
}
return this.workerClientProxy;
}
async getLanguageServiceWorker(...resources: Uri[]): Promise<TodoLangWorker> {
// console.log('WorkerManager:resources', resources)
const _client: TodoLangWorker = await this.getClientproxy();
console.log('WorkerManager:_client', _client);
await this.worker.withSyncedResources(resources)
return _client;
}
}
DiagnosticsAdapter
作用
- 初始化onDidChangeContent,并通过获取代理方法的函数参数拿到代理方法并执行validate
import * as monaco from 'monaco-editor';
// import { WorkerAccessor } from "./setup";
// import { languageID } from './config';
// import { WorkerAccessor } from "./setup";
// import { languageID } from './config';
import type { ITodoLangError } from './TodoLangErrorListener';
export default class DiagnosticsAdapter {
codeInfo: any;
constructor(private worker: WorkerAccessor) {
const onModelAdd = (model: monaco.editor.IModel): void => {
let handle: any;
model.onDidChangeContent(() => {
// otherwise if the user is still typing, we cancel the
clearTimeout(handle);
handle = setTimeout(() => this.validate(model.uri), 500);
});
this.validate(model.uri);
};
monaco.editor.onDidCreateModel(onModelAdd);
monaco.editor.getModels().forEach(onModelAdd);
}
private async validate(resource: monaco.Uri): Promise<void> {
console.log(this.worker, 1122);
// get the worker proxy
const worker = await this.worker(resource)
// get the current model(editor or file) which is only one
const model = monaco.editor.getModel(resource);
// console.log(worker);
// call the validate methode proxy from the langaueg service and get errors
const errorMarkers = await worker.doValidation(resource);
// console.log(errorMarkers);
// add the error markers and underline them with severity of Error
monaco.editor.setModelMarkers(model, model.getLanguageId(), errorMarkers);
// monaco.editor.setModelMarkers(model, model.getLanguageId(), errorMarkers.map(toDiagnostics));
}
}
function toDiagnostics(error: ITodoLangError): monaco.editor.IMarkerData {
return {
...error,
severity: monaco.MarkerSeverity.Error,
};
}
lint
仓库
概念
代码风格的检查:
ESLint 的原理,是遍历语法树然后检验,其核心的 Linter,是不依赖 node 环境的,并且官方也进行了单独的打包输出,具体可以通过 clone官方代码 后,执行 npm run webpack 拿到核心的打包后的 ESLint.js。其本质是对 linter.js 文件的打包。
同时官方也基于该打包产物,提供了 ESLint 的官方 demo。
import { Linter } from 'path/to/bundled/ESLint.js';
const linter = new Linter();
// 定义新增的规则,比如react/hooks, react特殊的一些规则
// linter中已经定义了包含了ESLint的所有基本规则,此处更多的是一些插件的规则的定义。
linter.defineRule(ruleName, ruleImpl);
linter.verify(text, {
rules: {
'some rules you want': 'off or warn',
},
settings: {},
parserOptions: {},
env: {},
})
如果只使用上述 linter 提供的方法,存在几个问题:
- 规则太多,一一编写太累且不一定符合团队规范
- 一些插件的规则无法使用,比如 react 项目强依赖的 ESLint-plugin-react, react-hooks的规则。
故还需要进行一些针对性的定制。
在日常的 react 项目中,基本上团队都是基于 ESLint-config-airbnb 规则配置好大部分的 rules,然后再对部分规则根据团队进行适配。
通过阅读 ESLint-config-airbnd 的代码,其做了两部分的工作:
- 对 ESLint 的自带的大部分规则进行了配置
- 对 ESLint 的插件,ESLint-plugin-react, ESLint-plugin-react-hooks 的规则,也进行了配置。
而 ESLint-plugin-react, ESLint-plugin-react-hooks,核心是新增了一些针对 react 及 hooks 的规则。
那么其实解决方案如下: 1. 使用打包后的 ESLint.js 导出的 linter 类 2. 借助其 defineRule 的方法,对 react, react/hooks 的规则进行增加 3. 合并 airbnb 的规则,作为各种规则的 config 合集备用 4. 调用 linter.verify 方法,配合3生成的 airbnb 规则,即可实现完整的 ESLint 验证。
通过上述方法,可以生成一个满足日常使用的 linter 及满足 react 项目使用的 ruleConfig。这一部分由于相对独立,我将其单独放在了一个 github 仓库 yuzai/ESLint-browser,可以酌情参考使用,也可根据团队现状修改使用。
下一步就是调用的时机,在每次代码变更时,频繁同步执行ESLint的verify可能会带来ui的卡顿,在此,我采取方案是:
- 通过 webworker 执行 linter.verify
- 在 model.onDidChangeContent 中通知 worker 进行执行。并通过消抖来减少执行频率
- 通过 model.getVersionId,拿到当前 id,来避免延迟过久导致结果对不上的问题
主进程核心的代码如下:
// 监听ESLint web worker 的返回
worker.onmessage = function (event) {
const { markers, version } = event.data;
const model = editor.getModel();
// 判断当前model的versionId与请求时是否一致
if (model && model.getVersionId() === version) {
window.monaco.editor.setModelMarkers(model, 'ESLint', markers);
}
};
let timer = null;
// model内容变更时通知ESLint worker
model.onDidChangeContent(() => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
worker.postMessage({
code: model.getValue(),
// 发起通知时携带versionId
version: model.getVersionId(),
path,
});
}, 500);
});
worker 内核心代码如下:
// 引入ESLint,内部结构如下:
/*
{
esLinter, // 已经实例化,并且补充了react, react/hooks规则定义的实例
// 合并了airbnb-config的规则配置
config: {
rules,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
},
env: {
browser: true
},
}
}
*/
importScripts('path/to/bundled/ESLint/and/ESLint-airbnbconfig.js');
// 更详细的config, 参考ESLint linter源码中关于config的定义: https://github.com/ESLint/ESLint/blob/main/lib/linter/linter.js#L1441
const config = {
...self.linter.config,
rules: {
...self.linter.config.rules,
// 可以自定义覆盖原本的rules
},
settings: {},
}
// monaco的定义可以参考:https://microsoft.github.io/monaco-editor/api/enums/monaco.MarkerSeverity.html
const severityMap = {
2: 8, // 2 for ESLint is error
1: 4, // 1 for ESLint is warning
}
self.addEventListener('message', function (e) {
const { code, version, path } = e.data;
const extName = getExtName(path);
// 对于非js, jsx代码,不做校验
if (['js', 'jsx'].indexOf(extName) === -1) {
self.postMessage({ markers: [], version });
return;
}
const errs = self.linter.esLinter.verify(code, config);
const markers = errs.map(err => ({
code: {
value: err.ruleId,
target: ruleDefines.get(err.ruleId).meta.docs.url,
},
startLineNumber: err.line,
endLineNumber: err.endLine,
startColumn: err.column,
endColumn: err.endColumn,
message: err.message,
// 设置错误的等级,此处ESLint与monaco的存在差异,做一层映射
severity: severityMap[err.severity],
source: 'ESLint',
}));
// 发回主进程
self.postMessage({ markers, version });
});
主进程监听文本变化,消抖后传递给 worker 进行 linter,同时携带 versionId 作为返回的比对标记,linter 验证后将 markers 返回给主进程,主进程设置 markers。
以上,便是整个 ESLint 的完整流程。
当然,由于时间关系,目前只处理了 js,jsx,并未对ts,tsx文件进行处理。支持 ts 需要调用 linter 的 defineParser 修改语法树的解析器,相对来讲稍微麻烦,目前还未做尝试,后续有动静会在 github 仓库 yuzai/ESLint-browser 进行修改同步。
sql格式化
let astJson = parser.validate(code);
console.log(astJson);
let markers = [];
let isError = false;
for (let i = 0; i < astJson.length; i++) {
let error = astJson[i];
console.log(error);
markers.push({
startLineNumber: error.startLine,
startColumn: error.startColumn,
endLineNumber: error.endLine,
endColumn: error.endColumn,
severity: monaco.MarkerSeverity.Error,
message: error.message
});
isError = true;
}
prettier
zhuanlan.zhihu.com/p/471476313
Monaco Editor 提供自定义格式化代码的能力,通过registerDocumentFormattingEditProvider
这个 API 来注册你的格式化规则,当触发格式化时就会调用。
详细步骤如下:
安装 prettier
yarn add prettier
注册 格式化规则
// ...
import prettier from 'prettier'
import parserBabel from 'prettier/parser-babel'
// ...
// 格式化逻辑
function spliceSemiAndDoubleQoute(val) {
return prettier.format(val, {
// 指定文件路径。用来来告知 Prettier 当前是哪种文件,需要调用什么解析器进行处理。
filepath: model.uri.path,
parser: 'babel',//也可以自行根据文件后缀计算后使用 parser 字段指定用哪个解析器。
// parser集合
plugins: PrettierPlugins,
// 更多的options见:https://Prettier.io/docs/en/options.html
//不保留行尾分号去掉,开发规范统一
semi: false,
//字符串用单引号包,裹,开发规范统一
singleQuote: true,
// 代码每行宇符数
printWidth: 500,
//jsx中'>'保持在一行
bracketSameLine: true,
//对象空格
bracketSpacing: true,
//行尾逗号
trailingComma: 'none',
// (x) => {}
arrowParens: 'avoid',
//函数后不带空格
spaceBeforeFunctionParen: false,
})
}
const formatProvider = {
provideDocumentFormattingEdits(model, options, token) {
return [{
text: spliceSemiAndDoubleQoute(model.getValue()),
range: model.getFullModelRange()
}]
}
}
monaco.languages.registerDocumentFormattingEditProvider('javascript', formatProvider)
由于很多需求都需要在 monaco 这个对象上去设置,并且要先设置,再去初始化编辑器,所以笔者将以上逻辑提取出来单独一个文件 decorateMonacoEditor.js
,最后将 monaco
export
出去,然后在初始化的文件中直接import monaco from 'decorateMonacoEditor.js'
即可。
触发格式化规则
编辑器右键是有 格式化文件 的选项的,但有些时候我们需要自动的触发格式化逻辑:
this.editor.getAction(['editor.action.formatDocument' ])._run()
/**
* Format the current selected code
*/
public autoFormatSelection() {
this.monacoEditor.trigger("external", "editor.action.formatSelection",undefined);
}
/**
* Format the current selected code
*/
public autoFormatDocument() {
this.monacoEditor.trigger("external", "editor.action.formatDocument", undefined);
}
多文件支持
在 IDE 概念中不可能每次编辑文件都创建一个 editor,所以提供了 model 和 state 的功能来储存文件的模型和状态。
model 表示一个编辑文件的快照,包含文件信息、语言标记、配置、装饰信息等,可以使用 getModel 和 setModel 切换不同的文件。state 表示视图上的状态,比如光标状态、滚动位置等信息。
let model = this.editor.getModel();
let state = this.editor.saveViewState();
modelsManager.updateModeler({ id: currentTabId, model, state})
// 恢复
let targetModel = modelsManager.models[tabId];
this.editor.setModel(targetModel.model);
this.editor.restoreViewState(targetModel.state);
创建多文件并切换的一般的伪代码如下:
const files = {
'/test.js': 'xxx',
'/app/test.js': 'xxx2',
}
const editor = monaco.editor.create(domNode, {
...options,
model: null, // 此处model设为null,是阻止默认创建的空model
});
Object.keys(files).forEach((path) =>
monaco.editor.createModel(
files[path],
'javascript',
new monaco.Uri().with({ path })
)
);
function openFile(path) {
const model = monaco.editor.getModels().find(model => model.uri.path === path);
editor.setModel(model);
}
openFile('/test.js');
保留切换之前状态
通过上述方法,可以实现多文件切换,但是在文件切换前后,会发现鼠标滚动的位置,文字的选中态均发生丢失的问题。
此时可以通过创建一个 map 来存储不同文件在切换前的状态,核心代码如下:
const editorStatus = new Map();
const preFilePath = '';
const editor = monaco.editor.create(domNode, {
...options,
model: null,
});
function openFile(path) {
const model = monaco.editor
.getModels()
.find(model => model.uri.path === path);
if (path !== preFilePath) {
// 储存上一个path的编辑器的状态
editorStatus.set(preFilePath, editor.saveViewState());
}
// 切换到新的model
editor.setModel(model);
const editorState = editorStates.get(path);
if (editorState) {
// 恢复编辑器的状态
editor.restoreViewState(editorState);
}
// 聚焦编辑器
editor.focus();
preFilePath = path;
}
核心便是借助editor实例的 saveViewState 方法实现编辑器状态的存储,通过 restoreViewState 方法进行恢复。
go to definition
monaco-editor 作为一款优秀的编辑器,其本身是能够感知到其他model的存在,并进行相关代码补全的提示。但是我们最常用的 cmd + 点击,默认是不能够跳转的。虽然 hover 上去能看到相关信息,但是默认不能跳转。
这一条也算是比较常见的问题了,详细的原因及解决方案可以查看此 issue。
简单来说,库本身没有实现这个打开,是因为如果允许跳转,那么用户没有很明显的方法可以再跳转回去。
实际中,可以通过覆盖 openCodeEditor 的方式来解决,在没有找到跳转结果的情况下,自己实现 model 切换
const editorService = editor._codeEditorService;
const openEditorBase = editorService.openCodeEditor.bind(editorService);
editorService.openCodeEditor = async (input, source) => {
const result = await openEditorBase(input, source);
if (result === null) {
const fullPath = input.resource.path;
// 跳转到对应的model
source.setModel(monaco.editor.getModel(input.resource));
// 此处还可以自行添加文件选中态等处理
// 设置选中区以及聚焦的行数
source.setSelection(input.options.selection);
source.revealLine(input.options.selection.startLineNumber);
}
return result; // always return the base result
};
受控
在实际编写 react 组件中,往往还需要对文件内容进行受控的操作,这就需要编辑器在内容变化时通知外界,同时也允许外界直接修改文本内容。
先说内容变化的监听,monaco-editor 的每个 model 都提供了 onDidChangeContent 这样的方法来监听文件改变,可以继续改造我们的 openFile 函数。
let listener = null;
function openFile(path) {
const model = monaco.editor
.getModels()
.find(model => model.uri.path === path);
if (path !== preFilePath) {
// 储存上一个path的编辑器的状态
editorStatus.set(preFilePath, editor.saveViewState());
}
// 切换到新的model
editor.setModel(model);
const editorState = editorStates.get(path);
if (editorState) {
// 恢复编辑器的状态
editor.restoreViewState(editorState);
}
// 聚焦编辑器
editor.focus();
preFilePath = path;
if (listener) {
// 取消上一次的监听
listener.dispose();
}
// 监听文件的变更
listener = model.onDidChangeContent(() => {
const v = model.getValue();
if (props.onChange) {
props.onChange({
value: v,
path,
})
}
})
}
解决了内部改动对外界的通知,外界想要直接修改文件的值,可以直接通过 model.setValue 进行修改,但是这样直接操作,就会丢失编辑器 undo 的堆栈,想要保留 undo,可以通过 model.pushEditOperations 来实现替换,具体代码如下:
function updateModel(path, value) {
let model = monaco.editor.getModels().find(model => model.uri.path === path);
if (model && model.getValue() !== value) {
// 通过该方法,可以实现undo堆栈的保留
model.pushEditOperations(
[],
[
{
range: model.getFullModelRange(),
text: value
}
],
() => {},
)
}
}
主题/样式
deltaDecorations修饰字符
microsoft.github.io/monaco-edit…
通过 createDecorationsCollection
方法来设置高亮代码,这里也需要一个range对象,来代表高亮的位置。注意,这里高亮是会跟随文字输入移动的,比如说你在高亮范围内换行了,新的一行也会继续高亮。
问题:输入中文过程样式会清空
let decorations = editor.createDecorationsCollection([
{
range: new monaco.Range(startLine, 1, endLine, 1),
options: {
isWholeLine: true,
className: className,
},
},
]);
// 移除高亮
decorations.clear()
// 获得高亮的范围
decorations.getRanges()
if (this.decorations?.clear) {
this.decorations.clear();
}
matches.forEach((match): void => {
if (matches.length > 0) {
obj.push({
range: match.range,
options: {
isWholeLine: false,
inlineClassName: 'myInlineDecoration'
}
})
}
});
代码
设置代码
this.editor.setValue(code)
获取编辑器代码
this.editor.getValue()
获取编辑器中被选中文案的 range
获取编辑器中被选中文案的 range ,返回一个对象,如下:
{
startLineNumber: 0,
startColumnNumber: 0,
endLineNumber: 0,
endColumnNumber: 0,
}=editor.getSelection()
// 获取编辑器中选中的sql
const selectSql = this.editor.getModel().getValueInRange(this.editor.getSelection())
删除
editor.getModel()
获取编辑器当前的 textmodel
,一般不会直接使用,通过 textmodel
可以对文本各种操作。
editor.getModel().findMatches(str|regexp)
功能和 ⌘ + F
一致,通过字符串或正则表达式查找编辑器内匹配的文本,并返回匹配文本 range
的集合。
* Search the model.
* @param searchString The string used to search. If it is a regular expression, set `isRegex` to true.
* @param searchOnlyEditableRange Limit the searching to only search inside the editable range of the model.
* @param isRegex Used to indicate that `searchString` is a regular expression.
* @param matchCase Force the matching to match lower/upper case exactly.
* @param wordSeparators Force the matching to match entire words only. Pass null otherwise.
* @param captureMatches The result will contain the captured groups.
* @param limitResultCount Limit the number of results
* @return The ranges where the matches are. It is empty if not matches have been found.
*/
findMatches(searchString: string, searchOnlyEditableRange: boolean, isRegex: boolean, matchCase: boolean, wordSeparators: string | null, captureMatches: boolean, limitResultCount?: number): FindMatch[];
editor.getModel().getValueInRange(range)
通过 range
获取范围内的文本,返回一个字符串。
model.getValueInRange(new monaco.Range(position.lineNumber, model.getWordAtPosition(position).startColumn - 1, position.lineNumber, model.getWordAtPosition(position).endColumn + 1))
editor.getModel().getLinesContent(lineNumber)
如果传入 lineNumber
,则返回对应行的文本字符串,不传参则返回所有行的文本字符串的集合。
光标
与 editor.setValue()
不同,可以用 ⌘ + Z
撤销输入
// monacoEditor 为 实例对象
// 1 获取光标位置
const position = monacoEditor.getPosition()
const insertText = '要插入的内容'
// 3 设置新的光标位置
monacoEditor.setPosition(position.lineNumber, position.column + insertText.length)
// 4 聚焦
monacoEditor.focus()
onDidFocusEditor
onDidChangeCursorPosition(e=>{
//e.source 光标移动类型
})
插入/删除
// 2 插入
monacoEditor.executeEdits('', [
{
range: new monaco.Range(position.lineNumber,
position.column,
position.lineNumber,
position.column),
text: insertText
}
])
line
获取行数model.getLineCount()
获取长度getLineLength(i)
获取行内容getLineContent(i)
禁用指定行
console.log(this.config.readOnlyArr);
if (this.config.readOnlyArr && this.config.readOnlyArr.length) {
const { doc, lineNumber, start, end } = this.config.readOnlyArr[0];
const readonlyRange = new monaco.Range(lineNumber, start, lineNumber, end);
const range = new monaco.Range(lineNumber, start, lineNumber, end);
this.editorInstance.executeEdits('需要插入的代码/string', [
{
range: range,
text: doc
// text: e1
}
]);
this.editorInstance.onKeyDown(e => {
// console.log(e.code);
if (e.code === 'Enter') {
return;
} else {
const contains = this.editorInstance.getSelections().findIndex(range => readonlyRange.intersectRanges(range));
if (contains !== -1) {
e.stopPropagation()
e.preventDefault() // for Ctrl+C, Ctrl+V
}
}
})
}
键盘
// this.editorInstance.addCommand(monaco.KeyMod.Space | monaco.KeyCode.KeyS, function () {
// console.log('Ctrl + S 保存')
// })
hover
stackoverflow.com/questions/7…
右键菜单
addRMenu({ id, label, keybindings, MenuGroup, MenuOrder }, fn) {
let disposable = this.editor.addAction({
id,
label,
keybindings: keybindings || [],
contextMenuGroupId: MenuGroup === undefined ? 'navigation' : MenuGroup,
contextMenuOrder: MenuOrder || 1,
run: fn
})
this.collectDispose(disposable);
}
this.editor.addRMenu(
{
id: ActionsIds.javaOrganizeImports,
keybindings: [monaco.KeyMod.CtrlCmd|monaco.KeyMod.Alt|monaco.KeyCode.KeyF],
label: "重新组织导入",
},
this.organizeImports
);
可以在右键面板中增加自定义功能: id
:该选项的唯一标识。 label
:选项显示的名称。 keybindings
:绑定的快捷键,多个快捷键用竖线分割。每个按键要使用monaco的内置枚举类型来设定。 contextMenuGroupId
:选项所属组的id
,内置了三个组id
(navigation
:该组是右键的第一栏,1_modification
:修改组,9_cutcopypaste
:剪切复制粘贴组)。 run
:选择该选项之后的回调函数,第一个参数为editor
实例,第二个参数为一个剩余参数的数组,注意如果通过快捷键触发那么第二个参数不存在。
diff
/**
* Initialize the diff editor triggered by ctrl+k
*/
private initializeDiffEditor(): void {
let self = this;
let diffEditor: monaco.editor.IStandaloneDiffEditor;
let modal = new tingle.modal({
cssClass: ["tingle-popup-container"],
onOpen: function () {
let originalModel = monaco.editor.createModel(
self._instanceSettings.code,
self._instanceSettings.language
);
let modifiedModel = self.monacoEditor.getModel();
const editorSettings = Object.assign(
{},
self._currentEditorSettings.editor,
self._currentEditorSettings.diffEditor
);
// create the diff editor
const contentElement = this.modalBoxContent.getElementsByClassName("content")[0];
diffEditor = monaco.editor.createDiffEditor(contentElement, editorSettings);
contentElement.onkeydown =
contentElement.onkeypress =
contentElement.onkeyup =
(e) => e.stopPropagation();
diffEditor.setModel({
original: originalModel,
modified: modifiedModel,
});
diffEditor.focus();
},
onClose: function () {
diffEditor.dispose();
},
});
this.modals.push(modal);
// action triggered by CTRL+K
// shows a popup with a diff editor with the initial state of the editor
// reuse the current model, so changes can be made directly in the diff editor
this.monacoEditor.addAction({
id: "viewDiffAction",
label: "View Diff",
contextMenuGroupId: "service",
contextMenuOrder: 1.4,
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK],
keybindingContext: null,
run: (ed) => {
modal.setContent(`<h2>Diff Editor</h2>
<div class="content" style="height: 30vw"/>`);
modal.open();
},
});
}
折叠代码块
/**
* Fold all the current folding indicators
*/
public foldAll() {
this.monacoEditor.trigger("external", "editor.foldAll", undefined);
}
/**
* Unfold all the folding indicators
*/
public unfoldAll() {
this.monacoEditor.trigger("external", "editor.unfoldAll", undefined);
}
// 收起
this.editor.getAction(['editor.foldAll'])._run()
// 展开
this.editor.getAction(['editor.unfoldAll'])._run()
只读
/**
* Set the editor as read-only or not
*/
public setReadOnlyStatus(readOnly: boolean) {
this.monacoEditor.updateOptions({
readOnly: readOnly,
});
}
销毁
public dispose() {
if (this.monacoEditor.getModel()) {
this.monacoEditor.getModel().dispose();
}
for (const disposable of this.disposables) {
disposable.dispose();
}
for (const modal of this.modals) {
modal.destroy();
}
this.monacoEditor.dispose();
}
焦点
this.editor.onDidBlurEditorWidget(() => {
// 你的逻辑
})
scroll
/**
* Scroll vertically or horizontally as necessary and reveal a position centered vertically.
*/
public scrollCodeTo(lineNumber: number, column: number) {
const position = {
lineNumber: lineNumber || 0,
column: column || 0,
};
this.monacoEditor.revealPositionInCenter(position);
this.monacoEditor.setPosition(position);
}
设置编辑区域滚动高度
this.editor.setScrollTop(srollTop)
代码区域滚动事件监听
this.editor.onDidScrollChange (() = {
// 你的逻辑
})
切换语言
public changeLanguage(language: string, code: string) {
if (this._instanceSettings.language != language) {
this.monacoEditor.getModel().dispose();
let model = monaco.editor.createModel(
code,
language,
monaco.Uri.parse("twx://privateModel/" + this._instanceSettings.modelName)
);
this.monacoEditor.setModel(model);
this._instanceSettings.language = language;
}
}
撤销
public undo() {
this.monacoEditor.trigger("external", "undo", undefined);
}
public redo() {
this.monacoEditor.trigger("external", "redo", undefined);
}