monaco editor各种功能实现总结

初始化

我使用的vue,以下是Editor.vue部分代码,只显示了初始化部分。monaco.editor.create方法生成了一个新的编辑器对象,第一个参数是html对象,第二个是options,里面有很多参数,这里只随便设置了两个:主题和自适应layout,接下来将使用这里定义的this.editor对象进行操作,下面提到的方法都定义在methods对象里面(注意由于定义在对象里面,所以下面的所有方法都没有function标志), css式样都定义在<style></style>里面。

<template>
  <div ref="main" style="width: 100%;height: 100%;margin-left: 5px;"></div>
</template>

<script>
import * as monaco from 'monaco-editor/esm/vs/editor/editor.main.js'
import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
import { StandaloneCodeEditorServiceImpl } from 'monaco-editor/esm/vs/editor/standalone/browser/standaloneCodeServiceImpl.js'
export default {
  name: 'Editor',
  data () {
    return {
      editor: null,
      //黑色主题,vs是白色主题,我喜欢黑色
      curTheme: 'vs-dark'
    }
  },
  methods: {},
  mounted () {
    //注意这个初始化没有指定model,可以自己创建一个model,然后使用this.editor.setModel设置进去
    //创建model时指定uri,之后可以通过monaco.editor.getModel(uri)获取指定的model
    //没有设置model的话,接下来的代码没有办法执行
    this.editor = monaco.editor.create(this.$refs.main, {theme: this.curTheme, automaticLayout: true})
  }
</script>
<style>
</style>

1、添加删除断点

需要注意的是,删除断点的操作我之前不是这么写的,而是在添加断点的操作let ids = model.deltaDecorations([], [value])有一个返回值是添加的断点的Id集合,我将该集合按照每个model分类存了起来,然后在删除的时候直接操作model.deltaDecorations(ids, []),刚开始并没有发现问题是好用的,然而,后来发现当删除大段多行的文字,并且这些文字里面包含好几个断点的时候,断点会堆积到最上面,视觉上只有一个断点,但是其实是很多个断点叠加在一起,效果就是运行removeBreakpoint时候没有反应,并且换行的时候,下面一行也会出现断点。后来通过监控model的内容change事件将多余的breakpoint删除了,但是为了防止万一,删除断点的方法也改成了下面这种复杂的方法。

//添加断点
    async addBreakPoint (line) {
      let model = this.editor.getModel()
      if (!model) return
      let value = {range: new monaco.Range(line, 1, line, 1), options: { isWholeLine: true, linesDecorationsClassName: 'breakpoints' }}
      model.deltaDecorations([], [value])
    },
    //删除断点,如果指定了line,删除指定行的断点,否则删除当前model里面的所有断点
    async removeBreakPoint (line) {
      let model = this.editor.getModel()
      if (!model) return
      let decorations
      let ids = []
      if (line !== undefined) {
        decorations = this.editor.getLineDecorations(line)
      } else {
        decorations = this.editor.getAllDecorations()
      }
      for (let decoration of decorations) {
        if (decoration.options.linesDecorationsClassName === 'breakpoints') {
          ids.push(decoration.id)
        }
      }
      if (ids && ids.length) {
        model.deltaDecorations(ids, [])
      }
    },
    //判断该行是否存在断点
    hasBreakPoint (line) {
      let decorations = this.editor.getLineDecorations(line)
      for (let decoration of decorations) {
        if (decoration.options.linesDecorationsClassName === 'breakpoints') {
          return true
        }
      }
      return false
    }

这段css是控制breakpoint的样式的,我是个css小白,将就着看吧,,,,

<style> .breakpoints{
    background: red;
    background: radial-gradient(circle at 3px 3px, white, red);
    width: 10px !important;
    height: 10px !important;
    left: 0px !important;
    top: 3px;
    border-radius: 5px;
  }
</style>

这段代码是为了解决breakpoint堆积的问题,监听了ChangeModelContent事件,在内容发生改变之后进行相应的处理。(添加在mounted中editor初始化之后)

this.editor.onDidChangeModelContent((e) => {
        let model = this.editor.getModel()
        //必须在nextTick处理,不然getPosition返回的位置有问题
        this.$nextTick(() => {
          //获取当前的鼠标位置
          let pos = this.editor.getPosition()
          if (pos) {
            //获取当前的行
            let line = pos.lineNumber
            //如果当前行的内容为空,删除断点(空行不允许设置断点,我自己规定的,,,)
            if (this.editor.getModel().getLineContent(line).trim() === '') {
              this.removeBreakPoint(line)
            } else {
              //如果当前行存在断点,删除多余的断点只保留一个
              if (this.hasBreakPoint(line)) {
                this.removeBreakPoint(line)
                this.addBreakPoint(line)
              }
            }
          }
        })
      })

最后的breakpoint的效果图大概如下:
在这里插入图片描述
到之前为止,我们只是定义了添加删除breakpoint的方法,你可以在代码里面调用方法进行添加删除breakpoint的操作,但是实际上大多编辑器都是通过点击指定行的方式添加breakpoint的,为了达到点击添加的目的,我们需要监听一下MouseDown事件,添加相应的操作:

this.editor.onMouseDown(e => {
        //我建立了很多不同种类的编辑器js, text等,这里只允许js编辑器添加breakpoint,如果你想在mousedown里面做点别的,放在这个前面啊,否则,return了,,,,
        if (!this.isJsEditor()) return
        //这里限制了一下点击的位置,只有点击breakpoint应该出现的位置,才会创建,其他位置没反应
        if (e.target.detail && e.target.detail.offsetX && e.target.detail.offsetX >= 0 && e.target.detail.offsetX <= 10) {
          let line = e.target.position.lineNumber
          //空行不创建
          if (this.editor.getModel().getLineContent(line).trim() === '') {
            return
          }
          //如果点击的位置没有的话创建breakpoint,有的话,删除
          if (!this.hasBreakPoint(line)) {
            this.addBreakPoint(line)
          } else {
            this.removeBreakPoint(line)
          }
          //如果存在上个位置,将鼠标移到上个位置,否则使editor失去焦点
          if (this.lastPosition) {
            this.editor.setPosition(this.lastPosition)
          } else {
            document.activeElement.blur()
          }
        }
        //更新lastPosition为当前鼠标的位置(只有点击编辑器里面的内容的时候)
        if (e.target.type === 6 || e.target.type === 7) {
          this.lastPosition = this.editor.getPosition()
        }
      })
isJsEditor () {
      return this.editor.getModel().getLanguageIdentifier().language === 'javascript'
    }

上述的代码最下面的部分设置位置那部分,其实和设置断点没有关系,我只是觉得,点击的时候会改变鼠标的位置特别不科学,于是自己处理了一下位置,可以删除的。 另外e.target.type这个主要是判断点击的位置在哪里,这里6,7表示是编辑器里面的内容的位置,具体可以参考官方文档。以下截图是从官方文档截得:
在这里插入图片描述
到上面为止,添加断点部分基本上完成了,但是我使用了一下vscode(它使用monaco editor做的编辑器),发现人家在鼠标移动到该出现breakpoint的时候会出现一个半透明的圆点,表示点击这个位置可以出现breakpoint?或者表示breakpoint应该出现在这个位置?不管它什么原因,我觉得我也应该有。
注意啊,这里因为鼠标移开就删除了,所以完全没有删除真的breakpoint时那样麻烦。

//添加一个伪breakpoint
    addFakeBreakPoint (line) {
      if (this.hasBreakPoint(line)) return
      let value = {range: new monaco.Range(line, 1, line, 1), options: { isWholeLine: true, linesDecorationsClassName: 'breakpoints-fake' }}
      this.decorations = this.editor.deltaDecorations(this.decorations, [value])
    },
    //删除所有的伪breakpoint
    removeFakeBreakPoint () {
      this.decorations = this.editor.deltaDecorations(this.decorations, [])
    }

这个是css样式,一个半透明的圆点

<style> .breakpoints-fake{
    background: rgba(255, 0, 0, 0.2);
    width: 10px !important;
    height: 10px !important;
    left: 0px !important;
    top: 3px;
    border-radius: 5px;
  }
</style>

最后添加mouse相关的事件监听:

this.editor.onMouseMove(e => {
        if (!this.isJsEditor()) return
        this.removeFakeBreakPoint()
        if (e.target.detail && e.target.detail.offsetX && e.target.detail.offsetX >= 0 && e.target.detail.offsetX <= 10) {
          let line = e.target.position.lineNumber
          this.addFakeBreakPoint(line)
        }
      })
      this.editor.onMouseLeave(() => {
        this.removeFakeBreakPoint()
      })
      //这个是因为鼠标放在breakpoint的位置,然后焦点在editor里面,点击enter的话,出现好多伪breakpoint,emmmm,我也不知道怎么回事,没办法,按enter键的话,强制删除所有的伪breakpoint
      this.editor.onKeyDown(e => {
        if (e.code === 'Enter') {
          this.removeFakeBreakPoint()
        }
      })

好吧,大概就可以用了,实际使用可能会有更多问题,具体问题具体分析,慢慢解决吧,我真的觉得这个部分简直全是问题,,,,添加个断点真不容易,其实我推荐自己做断点,不用它的破decoration,,,,

2、插入文本

在当前鼠标的位置插入指定文本的代码如下,比较麻烦,但是也没有太多代码,如果你已经选定了一段代码的话,应该会替换当前选中的文本。

insertContent (text) {
      if (this.editor) {
        let selection = this.editor.getSelection()
        let range = new monaco.Range(selection.startLineNumber, selection.startColumn, selection.endLineNumber, selection.endColumn)
        let id = { major: 1, minor: 1 }
        let op = {identifier: id, range: range, text: text, forceMoveMarkers: true}
        this.editor.executeEdits(this.root, [op])
        this.editor.focus()
      }
    }

3、手动触发Action

这个方法特别简单也没有,但是关键是你得知道Action的id是什么,,,你问我怎么知道的,我去看的源码。
很坑有没有,不过我通过看源码发现了一个可以调用的方法require('monaco-editor/esm/vs/editor/browser/editorExtensions.js').EditorExtensionsRegistry.getEditorActions()这个结果是一个Action数组,包括注册了的Action的各种信息,当然也包括id。(ps: trigger的第一个参数没发现有什么用,就都用anything代替了)

trigger (id) {
      if (!this.editor) return
      this.editor.trigger('anyString', id)
    }

举个例子,format document的Action对象大概就是下面这个样子,我们可以通过trigger('editor.action.formatDocument')触发格式化文件的功能。

{
    "id": "editor.action.formatDocument",
    "precondition": {
        "key": "editorReadonly"
    },
    "_kbOpts": {
        "kbExpr": {
            "key": "editorTextFocus",
            "_defaultValue": false
        },
        "primary": 1572,
        "linux": {
            "primary": 3111
        },
        "weight": 100
    },
    "label": "Format Document",
    "alias": "Format Document",
    "menuOpts": {
        "when": {
            "key": "editorHasDocumentFormattingProvider",
            "_defaultValue": false
        },
        "group": "1_modification",
        "order": 1.3
    }
}

4、多model支持转到定义和查找引用

这个之前出过很多错误,网上的搜到的很多答案根本不好用,为了弄明白为啥不好用我还去阅读了相关的源码,下面说一下好用的版本:

//这个函数是从网上找的,用于自定义一个TextModelService,替换原先的
    getTextModelService () {
      return {
        createModelReference (uri) {
          const model = {
            load () {
              return Promise.resolve(model)
            },
            dispose () {
            },
            textEditorModel: monaco.editor.getModel(uri)
          }
          return Promise.resolve({
            object: model,
            dispose () {
            }
          })
        }
      }
    },
    //这个两个方法是为了替换CodeEditorService,可以看出和上面的实现不一样,区别在哪里呢
    //本来也是打算按照上面的方法来做的,但是也看到了上面的方法需要定义各种需要用到的方法,你得很理解这个Service才可以自己定义啊
    //这个就不需要了,只通过原型修改了两个相关的方法,然后其他的就不需要关心了
    //上面的好处是在创建editor的时候使用上面的service代替,只影响替换了的editor,下面这个直接影响了所有的editor
    //具体使用什么方法可以自己考量,我这个service采用了这种方法,主要是因为自定义的service各种报错,失败了,,,
    initGoToDefinitionCrossModels () {
      let self = this
      StandaloneCodeEditorServiceImpl.prototype.findModel = function (editor, resource) {
        let model = null
        if (resource !== null) {
          model = monaco.editor.getModel(resource)
        }
        return model
      }

      StandaloneCodeEditorServiceImpl.prototype.doOpenEditor = function (editor, input) {
        //这个this.findModel调用的是StandaloneCodeEditorServiceImpl.prototype.findModel这个方法
        let model = this.findModel(editor, input.resource)
        if (model) {
          editor.setModel(model)
        } else {
          return null
        }
        let selection = input.options.selection
        if (selection) {
          if (typeof selection.endLineNumber === 'number' && typeof selection.endColumn === 'number') 
            editor.setSelection(selection)
            editor.revealRangeInCenter(selection, 1 /* Immediate */)
          } else {
            let pos = {
              lineNumber: selection.startLineNumber,
              column: selection.startColumn
            }
            editor.setPosition(pos)
            editor.revealPositionInCenter(pos, 1 /* Immediate */)
          }
          editor.focus()
        }
        return editor
      }
    }

initGoToDefinitionCrossModels这个方法需要在mounted里面调用一下,不然什么都不会发生。然后创建editor的方法也要修改一下:

//第三个参数表示使用指定的service替换默认的
      this.editor = monaco.editor.create(this.$refs.main, {
        theme: this.curTheme,
        automaticLayout: true
      }, {
        textModelService: this.getTextModelService()
      })

之前网上有推荐使用new StandaloneCodeEditorServiceImpl()生成一个codeEditorService,然后像替换textModelService一样替换codeEditorService的,亲测不好用,new这个操作里面有一些额外的操作,并不可以,想要替换的话,个人认为应该如textModelService一样,自己定义一个对象(可以读读源码了解一下需要实现的方法)。
完成了以上内容,再执行右键-》go to definition就可以跳到定义了,其他如peek definition和find all references都可以正常执行了。

5、全局搜索

monaco编辑器支持单个model内部的搜索,mac快捷键是cmd+f,没有找到全局的搜索,如果我们想在打开的文件夹下面的每个model里面进行搜索的话,需要自己操作一下:

findAllMatches (searchText) {
      let result = {}
      if (searchText) {
        //注意如果你一个model都没有注册的话,这里什么都拿不到
        //举个例子啊,下面将一个路径为filePath,语言为lang,文件内容为fileContent的本地文件注册为model
        //monaco.editor.createModel(fileContent, lang, monaco.Uri.file(filePath))
        monaco.editor.getModels().forEach(model => {
          result[model.uri.toString()] = []
          for (let match of model.findMatches(searchText)) {
            result[model.uri.toString()].push({
              text: model.getLineContent(match.range.startLineNumber),
              range: match.range,
              model: model
            })
          }
        })
      }
      return result
    }

上面的方法返回的是monaco.editor里面注册过的每个model对应的搜索对象,包括当前行的文本,目标对象的范围,和model对象。返回的结果可以用于显示,如果想要点击指定的文本跳到对应的model的话,需要做如下操作:

//这里range和model,对应findAllMatches返回结果集合里面对象的range和model属性
   goto (range, model) {
      //设置model,如果是做编辑器的话,打开了多个文本,还会涉及到标签页的切换等其他细节,这里不考虑这些
      this.editor.setModel(model)
      //选中指定range的文本
      this.editor.setSelection(range)
      //把选中的位置放到中间显示
      this.editor.revealRangeInCenter(range)
    }

6、Git新旧版本比较使用DiffEditor

async showDiffEditor (filePath, language) {
      //这个方法是我自己定义的,因为用于显示git的修改对比,所以是使用的git命令获取的相关的原始文本
      let oriText = await git.catFile(filePath)
      let originalModel = monaco.editor.createModel(oriText, language)
      //修改后的文本这里在打开文件之前我都初始化好了,所以可以直接通过该方法获得,没有提前创建好的话,可以参照上面的例子创建
      let modifiedModel = monaco.editor.getModel(monaco.Uri.file(filePath))

      if (!this.diffEditor) {
        //创建一个diffEditor,readOnly表示只读,this.$refs.main是html对象
        this.diffEditor = monaco.editor.createDiffEditor(this.$refs.main, {
          enableSplitViewResizing: false,
          automaticLayout: true,
          readOnly: true
        })
      }

      this.diffEditor.setModel({
        original: originalModel,
        modified: modifiedModel
      })

7、添加Completions和Defaults

添加一个default对象,代码是从官方的文档找到的,然后自己改写了下面的引用部分。主要作用是这么做之后,在编辑器里面输入tools.js文件里面定义的toolUtls.之后,将会提示toString这个function,并且显示注释信息。感觉和competition挺像啊。

initDefaults () {
      // validation settings
      monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
        noSemanticValidation: true,
        noSyntaxValidation: false
      })
      // compiler options
      monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
        target: monaco.languages.typescript.ScriptTarget.ES6,
        allowNonTsExtensions: true
      })
      let toolsPath = path.join(__dirname, 'tools.js')
      let str = require('fs').readFileSync(toolsPath).toString()
      monaco.languages.typescript.javascriptDefaults.addExtraLib(str, 'tools.js')
    },

tools.js文件:

let toolUtls = {
  /** * convert obj to string */
  toString (obj) {}
}

至于添加completion也有官方文档,很容易实现:

addCompletions () {
      //keyMap是一个普通对象(比如:let keyMap = {Man: 1, Woman: 2})
      //这样做的好处是,假如一个方法需要的参数都是类型,但是类型使用1,2,3,4这种数字表示,你很难记住对应的类型名称
      //通过这种方式,你输入Man的时候可以插入1 /*Man*/,参数仍然是数字,但是看起来有意义多了,输入也比较方便
      //为了key的提示更清楚,可以使用People_Man,People_Woman这种相同前缀的key值,输入People就会提示各种type了
      let suggestions = []
      for (let key in keyMap) {
        suggestions.push({
          label: key,
          kind: monaco.languages.CompletionItemKind.Enum,
          insertText: keyMap[key].toString() + ` /*${key}*/`
        })
      }
      monaco.languages.registerCompletionItemProvider('javascript', {
        provideCompletionItems: () => {
          return {
            suggestions: suggestions
          }
        }
      })
    }

对了为了可以顺利的找到worker,需要在webpack的配置文件里面添加const MonacoWebpackPlugin = require(‘monaco-editor-webpack-plugin’)定义,在plugins里面添加new MonacoWebpackPlugin(),这个其实支持参数设置的,我设置失败了,emmm,网上的解决方案都没能解决问题,好在删除参数的话,啥事儿没有,所以就这么用了。 本来还打算实现refactor功能,不过由于没有时间,这个功能无线搁置了,如果有谁实现了,欢迎分享啊。另外,上述的实现都是我自己研究的,不排除有bug,发现bug的话,欢迎提出啊。