11

前言

最近新接手的后台管理系统需要一个在线编辑代码的工具,主要是SQLPython。经过查找,发现了monaco-editor这么一个开源工具,可以说是VS Code的web版本。

使用

因为中文文档没找到齐全的资料,只能从搜索引擎提供的博客中查找。当然,少不了GitHub——monaco-editor。官方已经提供了齐全的demo。
第一步是npm安装,然后引入页面。

$ npm install monaco-editor
import * as monaco from "monaco-editor"

第二步是提供一个DOM元素,然后实例化monaco-editor

<div class="the-code-editor-container" ref="container"></div>

this.monacoInstance = monaco.editor.create(
    this.$refs["container"], {
        value: this.value,
        ...this.options
    });

到这一步,核心代码已经完成,简单。

功能追加

然后,我们的编辑器需要几个功能:

  • 自定义语言
  • 自定义样式
  • 自定义关键字提示

还有其他辅助功能:

  • 同步取值
  • 离开销毁

API文档已经提供了接口:

  • language: String
  • theme: String
  • value: String

同步取值:

this.monacoInstance.onDidChangeModelContent(
() => {
        this.$emit("contentChange",
        this.monacoInstance.getValue());
      });

离开销毁:

if (this.monacoInstance) {
    if (this.monacoInstance.getModel()) {
        this.monacoInstance.getModel().dispose();
    }        
    this.monacoInstance.dispose();
    this.monacoInstance = null;
    if(this.provider){
      this.provider.dispose();
      this.provider = null
    }
}

唯一比较麻烦的是自定义关键字提示:

this.provider = monaco.languages.registerCompletionItemProvider("sql", {
    provideCompletionItems(model, position) {
      var textUntilPosition = model.getValueInRange({
        startLineNumber: position.lineNumber,
        startColumn: 1,
        endLineNumber: position.lineNumber,
        endColumn: position.column
      });
      var suggestions = createCompleters(textUntilPosition);
      return {
        suggestions: suggestions
      };
    }
});

这是对sql语言的关键字提示注册,provideCompletionItems方法的返回值就是提示的内容,如果没有返回值,默认在编辑器的内容区域内匹配。如果有返回值,提示返回值。也就是说,做不到即使用自定义关键字,又能自动匹配文本输入区域的内容
关键字提示的核心代码在于createCompleters方法:

let createCompleters = textUntilPosition => {
        //过滤特殊字符
        let _textUntilPosition = textUntilPosition
          .replace(/[\*\[\]@\$\(\)]/g, "")
          .replace(/(\s+|\.)/g, " ");
        //切割成数组
        let arr = _textUntilPosition.split(" ");
        //取当前输入值
        let activeStr = arr[arr.length - 1];
        //获得输入值的长度
        let len = activeStr.length;

        //获得编辑区域内已经存在的内容
        let rexp = new RegExp('([^\\w]|^)'+activeStr+'\\w*', "gim");
        let match = that.value.match(rexp);
        let _hints = !match ? [] : match.map(ele => {
          let rexp = new RegExp(activeStr, "gim");
          let search = ele.search(rexp);
          return ele.substr(search)
        })
        
        //查找匹配当前输入值的元素
        let hints = Array.from(new Set([...that.hints, ..._hints])).sort().filter(ele => {
          let rexp = new RegExp(ele.substr(0, len), "gim");
          return match && match.length === 1 && ele === activeStr || ele.length === 1
            ? false
            : activeStr.match(rexp);
        });
        //添加内容提示
        let res = hints.map(ele => {
          return {
            label: ele,
            kind: that.hints.indexOf(ele) > -1 ? monaco.languages.CompletionItemKind.Keyword : monaco.languages.CompletionItemKind.Text,
            documentation: ele,
            insertText: ele
          };
        });
        return res;
      };

目前写的方法,能够做到两个功能:

  • 取到当前输入的单词,匹配自定义的关键字hints
  • 取到当前输入的单词,匹配编辑区域内容_hints

通过正则表达式的匹配,同时过滤必要的元字符,查找对应的值,主要是因为用户可能会输入$^*[]()等字符。
不同的匹配来源,使用不同的icon,即monaco.languages.CompletionItemKind,这算是一个额外的用户体验优化。
效果如下:
image.png
花了一天,大概的需求已经实现,后期继续优化,基本都是设计上的问题了。
完整的代码如下:
CodeEditor.vue

<template>
  <div class="the-code-editor-container" ref="container"></div>
</template>

<script>
import * as monaco from "monaco-editor";
let sqlStr =
  "ADD EXCEPT PERCENT ALL EXEC PLAN ALTER EXECUTE PRECISION AND EXISTS PRIMARY ANY EXIT PRINT AS FETCH PROC ASC FILE PROCEDURE AUTHORIZATION FILLFACTOR PUBLIC BACKUP FOR RAISERROR BEGIN FOREIGN READ BETWEEN FREETEXT READTEXT BREAK FREETEXTTABLE RECONFIGURE BROWSE FROM REFERENCES BULK FULL REPLICATION BY FUNCTION RESTORE CASCADE GOTO RESTRICT CASE GRANT RETURN CHECK GROUP REVOKE CHECKPOINT HAVING RIGHT CLOSE HOLDLOCK ROLLBACK CLUSTERED IDENTITY ROWCOUNT COALESCE IDENTITY_INSERT ROWGUIDCOL COLLATE IDENTITYCOL RULE COLUMN IF SAVE COMMIT IN SCHEMA COMPUTE INDEX SELECT CONSTRAINT INNER SESSION_USER CONTAINS INSERT SET CONTAINSTABLE INTERSECT SETUSER CONTINUE INTO SHUTDOWN CONVERT IS SOME CREATE JOIN STATISTICS CROSS KEY SYSTEM_USER CURRENT KILL TABLE CURRENT_DATE LEFT TEXTSIZE CURRENT_TIME LIKE THEN CURRENT_TIMESTAMP LINENO TO CURRENT_USER LOAD TOP CURSOR NATIONAL TRAN DATABASE NOCHECK TRANSACTION DBCC NONCLUSTERED TRIGGER DEALLOCATE NOT TRUNCATE DECLARE NULL TSEQUAL DEFAULT NULLIF UNION DELETE OF UNIQUE DENY OFF UPDATE DESC OFFSETS UPDATETEXT DISK ON USE DISTINCT OPEN USER DISTRIBUTED OPENDATASOURCE VALUES DOUBLE OPENQUERY VARYING DROP OPENROWSET VIEW DUMMY OPENXML WAITFOR DUMP OPTION WHEN ELSE OR WHERE END ORDER WHILE ERRLVL OUTER WITH ESCAPE OVER WRITETEXT";
export default {
  name: "codeEditor",

  props: {
    options: {
      type: Object,
      default() {
        return {
          language: "sql", // shell、sql、python
          readOnly: true // 不能编辑
        };
      }
    },

    value: {
      type: String,
      default: ""
    }
  },

  data() {
    return {
      monacoInstance: null,
      provider: null,
      hints: [
        "SELECT",
        "INSERT",
        "DELETE",
        "UPDATE",
        "CREATE TABLE",
        "DROP TABLE",
        "ALTER TABLE",
        "CREATE VIEW",
        "DROP VIEW",
        "CREATE INDEX",
        "DROP INDEX",
        "CREATE PROCEDURE",
        "DROP PROCEDURE",
        "CREATE TRIGGER",
        "DROP TRIGGER",
        "CREATE SCHEMA",
        "DROP SCHEMA",
        "CREATE DOMAIN",
        "ALTER DOMAIN",
        "DROP DOMAIN",
        "GRANT",
        "DENY",
        "REVOKE",
        "COMMIT",
        "ROLLBACK",
        "SET TRANSACTION",
        "DECLARE",
        "EXPLAN",
        "OPEN",
        "FETCH",
        "CLOSE",
        "PREPARE",
        "EXECUTE",
        "DESCRIBE",
        "FORM",
        "ORDER BY"
      ]
    };
  },
  created() {
    this.initHints();
  },
  mounted() {
    this.init();
  },
  beforeDestroy() {
    this.dispose();
  },

  methods: {
    dispose() {
      if (this.monacoInstance) {
        if (this.monacoInstance.getModel()) {
                    this.monacoInstance.getModel().dispose();
                }        
        this.monacoInstance.dispose();
        this.monacoInstance = null;
        if(this.provider){
          this.provider.dispose();
          this.provider = null
        }
      }
    },
    initHints() {
      let sqlArr = sqlStr.split(" ");
      this.hints = Array.from(new Set([...this.hints, ...sqlArr])).sort();
    },
    init() {
      let that = this;
      // console.log(monaco.languages.CompletionItemKind)
      this.dispose();
      let createCompleters = textUntilPosition => {
        //过滤特殊字符
        let _textUntilPosition = textUntilPosition
          .replace(/[\*\[\]@\$\(\)]/g, "")
          .replace(/(\s+|\.)/g, " ");
        //切割成数组
        let arr = _textUntilPosition.split(" ");
        //取当前输入值
        let activeStr = arr[arr.length - 1];
        //获得输入值的长度
        let len = activeStr.length;

        //获得编辑区域内已经存在的内容
        let rexp = new RegExp('([^\\w]|^)'+activeStr+'\\w*', "gim");
        let match = that.value.match(rexp);
        let _hints = !match ? [] : match.map(ele => {
          let rexp = new RegExp(activeStr, "gim");
          let search = ele.search(rexp);
          return ele.substr(search)
        })
        
        //查找匹配当前输入值的元素
        let hints = Array.from(new Set([...that.hints, ..._hints])).sort().filter(ele => {
          let rexp = new RegExp(ele.substr(0, len), "gim");
          return match && match.length === 1 && ele === activeStr || ele.length === 1
            ? false
            : activeStr.match(rexp);
        });
        //添加内容提示
        let res = hints.map(ele => {
          return {
            label: ele,
            kind: that.hints.indexOf(ele) > -1 ? monaco.languages.CompletionItemKind.Keyword : monaco.languages.CompletionItemKind.Text,
            documentation: ele,
            insertText: ele
          };
        });
        return res;
      };
      this.provider = monaco.languages.registerCompletionItemProvider("sql", {
        provideCompletionItems(model, position) {
          var textUntilPosition = model.getValueInRange({
            startLineNumber: position.lineNumber,
            startColumn: 1,
            endLineNumber: position.lineNumber,
            endColumn: position.column
          });
          var suggestions = createCompleters(textUntilPosition);
          return {
            suggestions: suggestions
          };

          return createCompleters(textUntilPosition);
        }
      });

      // 初始化编辑器实例
      this.monacoInstance = monaco.editor.create(this.$refs["container"], {
        value: this.value,
        theme: "vs-dark",
        autoIndex: true,
        ...this.options
      });

      // 监听编辑器content变化事件
      this.monacoInstance.onDidChangeModelContent(() => {
        this.$emit("contentChange", this.monacoInstance.getValue());
      });
    }
  }
};
</script>

<style lang="scss" scope>
.the-code-editor-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
  border: 1px solid #eaeaea;
  .monaco-editor .scroll-decoration {
    box-shadow: none;
  }
}
</style>

SQLEditor.vue

<template>
  <div class="container">
    <h3 class="title">SQL Editor</h3>
    <code-editor
      :options="options"
      :value="content"
      @contentChange="contentChange"
      style="height: 600px; width: 600px;"
    ></code-editor>
  </div>
</template>

<script>
/**
 * @language : javascript, php, python, sql,...
 * @theme : vs, hc-black, or vs-dark
 * @readOnly : true or false
 */
import CodeEditor from "@/components/common/CodeEditor";
export default {
  name: "SQLEditor",
  components: {
    CodeEditor
  },
  data() {
    return {
      content: "",
      options: {
        language: "sql",
        theme: 'vs',
        readOnly: false
      }
    };
  },
  created() {},
  methods: {
    // 绑定编辑器value值的变化
    contentChange(val) {
      this.content = val;
    }
  }
};
</script>


<style scoped lang="scss">
.container {
  text-align: left;
  padding: 10px;
}
</style>

后记

前面的代码是没问题的,但是有一个小小的不足:自定义语言,没有变色。虽然可以代码提示,但不是关键字。
经过查找api,现整理如下:

import * as monaco from "monaco-editor";

let sqlStr = "ADD,EXCEPT,PERCENT,ALL,EXEC,PLAN,ALTER,EXECUTE,PRECISION,AND,EXISTS,PRIMARY,ANY,EXIT,PRINT,AS,FETCH,PROC,ASC,FILE,PROCEDURE,AUTHORIZATION,FILLFACTOR,PUBLIC,BACKUP,FOR,RAISERROR,BEGIN,FOREIGN,READ,BETWEEN,FREETEXT,READTEXT,BREAK,FREETEXTTABLE,RECONFIGURE,BROWSE,FROM,REFERENCES,BULK,FULL,REPLICATION,BY,FUNCTION,RESTORE,CASCADE,GOTO,RESTRICT,CASE,GRANT,RETURN,CHECK,GROUP,REVOKE,CHECKPOINT,HAVING,RIGHT,CLOSE,HOLDLOCK,ROLLBACK,CLUSTERED,IDENTITY,ROWCOUNT,COALESCE,IDENTITY_INSERT,ROWGUIDCOL,COLLATE,IDENTITYCOL,RULE,COLUMN,IF,SAVE,COMMIT,IN,SCHEMA,COMPUTE,INDEX,SELECT,CONSTRAINT,INNER,SESSION_USER,CONTAINS,INSERT,SET,CONTAINSTABLE,INTERSECT,SETUSER,CONTINUE,INTO,SHUTDOWN,CONVERT,IS,SOME,CREATE,JOIN,STATISTICS,CROSS,KEY,SYSTEM_USER,CURRENT,KILL,TABLE,CURRENT_DATE,LEFT,TEXTSIZE,CURRENT_TIME,LIKE,THEN,CURRENT_TIMESTAMP,LINENO,TO,CURRENT_USER,LOAD,TOP,CURSOR,NATIONAL,TRAN,DATABASE,NOCHECK,TRANSACTION,DBCC,NONCLUSTERED,TRIGGER,DEALLOCATE,NOT,TRUNCATE,DECLARE,NULL,TSEQUAL,DEFAULT,NULLIF,UNION,DELETE,OF,UNIQUE,DENY,OFF,UPDATE,DESC,OFFSETS,UPDATETEXT,DISK,ON,USE,DISTINCT,OPEN,USER,DISTRIBUTED,OPENDATASOURCE,VALUES,DOUBLE,OPENQUERY,VARYING,DROP,OPENROWSET,VIEW,DUMMY,OPENXML,WAITFOR,DUMP,OPTION,WHEN,ELSE,OR,WHERE,END,ORDER,WHILE,ERRLVL,OUTER,WITH,ESCAPE,OVER,WRITETEXT,SELECT,INSERT,DELETE,UPDATE,CREATE TABLE,DROP TABLE,ALTER TABLE,CREATE VIEW,DROP VIEW,CREATE INDEX,DROP INDEX,CREATE PROCEDURE,DROP PROCEDURE,CREATE TRIGGER,DROP TRIGGER,CREATE SCHEMA,DROP SCHEMA,CREATE DOMAIN,ALTER DOMAIN,DROP DOMAIN,GRANT,DENY,REVOKE,COMMIT,ROLLBACK,SET TRANSACTION,DECLARE,EXPLAN,OPEN,FETCH,CLOSE,PREPARE,EXECUTE,DESCRIBE,FORM,ORDER BY";
let pythonStr = "False,None,self,True,and,as,assert,break,class,continue,def,del,elif,else,except,finally,for,from,global,if,import,in,is,lambda,nonlocal,not,or,pass,raise,return,try,while,with,yield";
//let ScalaStr = "import,val,def,spark,sqlContext,show";
let ScalaStr = ''
const LoadToken = (Hints, languages) => {
  monaco.languages.setMonarchTokensProvider(languages, {
    keywords: Hints,
    tokenizer: {
      root: [
        // identifiers and keywords
        [/[a-z_$][\w$]*/, {
          cases: {
            '@keywords': 'keyword',
            '@default': 'identifier'
          }
        }],
      ]

    }
  })
}

const init = () => {
  let hintsSql = []
  hintsSql = sqlStr.split(",");
  hintsSql = Array.from(new Set([...hintsSql])).sort();

  let hintsPython = []
  hintsPython = pythonStr.split(",");
  hintsPython = Array.from(new Set([...hintsPython])).sort();

  let hintsScala = []
  hintsScala = ScalaStr.split(",");
  hintsScala = Array.from(new Set([...hintsScala])).sort();

  initLanguage(hintsSql, 'sql')
  initLanguage(hintsPython, 'python')
  //initLanguage(hintsScala, 'scala')

  monaco.editor.defineTheme('my-balck', {
    base: 'vs-dark',
    inherit: true,
    rules: [
      {token: 'source.myLang', foreground: '001529'},
      {background: '001529', fontSize: "14px"}
    ],
    colors: {
      'editor.background': '#001529',
      'editor.lineHighlightBorder': '#001529'
    },
  });

  monaco.editor.defineTheme('my-white', {
    base: 'vs',
    inherit: true,
    rules: [
      {token: 'source.myLang', foreground: 'ffffff'},
      {background: 'ffffff', fontSize: "14px"}
    ],
    colors: {
      'editor.background': '#ffffff',
      'editor.lineHighlightBorder': '#ffffff'
    },
  });
}

export const initLanguage = (Hints, languages) => {
  let createCompleters = (textUntilPosition, value) => {
    //过滤特殊字符
    let _textUntilPosition = textUntilPosition
      .replace(/[\*\[\]@\$\(\)]/g, "")
      .replace(/[\s+\.,]/g, " ");
    //切割成数组
    let arr = _textUntilPosition.split(" ");
    //取当前输入值
    let activeStr = arr[arr.length - 1];
    //获得输入值的长度
    let len = activeStr.length;

    //获得编辑区域内已经存在的内容
    let rexp = new RegExp("([^\\w]|^)" + activeStr + "\\w*", "gim");
    let match = value.match(rexp);
    let _hints = !match
      ? []
      : match.map(ele => {
        let rexp = new RegExp(activeStr, "gim");
        let search = ele.search(rexp);
        return ele.substr(search);
      });

    //查找匹配当前输入值的元素
    let hints = Array.from(new Set([...Hints, ..._hints]))
      .sort()
      .filter(ele => {
        let rexp = new RegExp(ele.substr(0, len), "gim");
        return (match && match.length === 1 && ele === activeStr) ||
        ele.length === 1
          ? false
          : activeStr.match(rexp);
      });

    //添加内容提示
    let res = hints.map(ele => {
      return {
        label: ele,
        kind:
          hints.indexOf(ele) > -1
            ? monaco.languages.CompletionItemKind.Keyword
            : monaco.languages.CompletionItemKind.Text,
        documentation: ele,
        insertText: ele
      };
    });
    return res;
  };
  monaco.languages.register({id: languages});
  LoadToken(Hints, languages);
  monaco.languages.registerCompletionItemProvider(languages, {
    provideCompletionItems(model, position) {
      var textUntilPosition = model.getValueInRange({
        startLineNumber: position.lineNumber,
        startColumn: 1,
        endLineNumber: position.lineNumber,
        endColumn: position.column,
      });
      var value = model.getValue();
      var suggestions = createCompleters(textUntilPosition, value);
      return {
        suggestions: suggestions
      };
    }
  });
}
init()


export default monaco

关键代码在于:
monaco.languages.register({id: languages});
LoadToken(Hints, languages);

完~


陈其文
430 声望19 粉丝

前端