【CodeMirror】如何构建属于自己的线上编辑器 - 自定义高亮

1,284 阅读5分钟

前一篇中的基础使用和详解介绍了基本的SQL IDE的构建。但是有的时候,我们期望按照需求去高亮我们自定义的语句。这个时候我们需要通过CodeMirror中的defineMode去构建自己的高亮逻辑。

CodeMirror.defineMode详解

defineMode的类型定义如下:

  /**
      * id will be the id for the defined mode. Typically, you should use this second argument to defineMode as your module scope function
     * (modes should not leak anything into the global scope!), i.e. write your whole mode inside this function.
  */
function defineMode(id: string, modefactory: ModeFactory<any>): void;

我们可以看到函数中具有两个参数:id、modefactory

  • id:Mode 的名称,应为小写的字符串,最好是 Mode 的文件名
  • modefactory: 第二个参数是一个函数,该函数的第一个参数为 CodeMirror 配置对象(CodeMirror 构造函数的配置对象), 该函数的第二个可选参数为 Mode 的配置对象(mode),返回值为 Mode 对象
    /**
     * A function that, given a CodeMirror configuration object and an optional mode configuration object, returns a mode object.
     */
    interface ModeFactory<T> {
        (config: EditorConfiguration, modeOptions?: any): Mode<T>;
    }

然后我们可以看到Mode对象的类型

interface Mode<T> {
        name?: string | undefined;

        /**
         * This function should read one token from the stream it is given as an argument, optionally update its state,
         * and return a style string, or null for tokens that do not have to be styled. Multiple styles can be returned, separated by spaces.
         */
        token: (stream: StringStream, state: T) => string | null;
      
        startState?: (() => T) | undefined;
        
        blankLine?: ((state: T) => void) | undefined;
       
        copyState?: ((state: T) => T) | undefined;
 
        indent?: ((state: T, textAfter: string, line: string) => number) | undefined;

        lineComment?: string | undefined;
        
        blockCommentStart?: string | undefined;
        
        blockCommentEnd?: string | undefined;
        
        blockCommentLead?: string | undefined;
       
        electricChars?: string | undefined;
        
        electricinput?: RegExp | undefined;
    }

我们这里比较关注的其实是token这个函数,所有 Mode 都必须定义此方法。 该函数从 stream 参数中提取 token,可选的更新状态,并返回样式字符串,或 null 表示 token 无需样式。大致来说,就是我们通过token解析出来字符,然后根据业务逻辑返回这个字符属于某个样式。在codeMirror中,一般会有如下样式。如果是变量,你可以返回'variable'字符串。那么对于这个字符来说,就会命中cm-operator这个样式。当然,如果你需要修改样式,你可以直接使用css文件覆盖或者在外出wrapper中添加className。原理类似于theme的配置。

.cm-s-default .cm-keyword {color: #708;}
.cm-s-default .cm-atom {color: #219;}
.cm-s-default .cm-number {color: #164;}
.cm-s-default .cm-def {color: #00f;}
.cm-s-default .cm-variable,
.cm-s-default .cm-punctuation,
.cm-s-default .cm-property,
.cm-s-default .cm-operator {}
.cm-s-default .cm-variable-2 {color: #05a;}
.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;}
.cm-s-default .cm-comment {color: #a50;}
.cm-s-default .cm-string {color: #a11;}
.cm-s-default .cm-string-2 {color: #f50;}
.cm-s-default .cm-meta {color: #555;}
.cm-s-default .cm-qualifier {color: #555;}
.cm-s-default .cm-builtin {color: #30a;}
.cm-s-default .cm-bracket {color: #997;}
.cm-s-default .cm-tag {color: #170;}
.cm-s-default .cm-attribute {color: #00c;}
.cm-s-default .cm-hr {color: #999;}
.cm-s-default .cm-link {color: #00c;}

.cm-s-default .cm-error {color: #f00;}
.cm-invalidchar {color: #f00;}

基本实现

cm.defineMode("customer", () => {
    const wordReg = (words: string[]) => new RegExp(`^(${words.join("|")})$`);
    
    const variables = []
    const builtins = []
    const booleanStrings = []
    
    const isOperatorChar = /[+\-*&%=<>!?|^\/]/;
    const isVariable = wordReg(variables);
    const isBuiltins = wordReg(builtins);
    const isBoolean = wordReg(booleanStrings);

    const stringEater = (type: any) => {
      let prev = "";
      return (c: any) => {
        prev = c;
        if (c === type) {
          return prev === "\\";
        }
        return true;
      };
    };
    return {
      startState() {
        return {
          prev: false,
        };
      },
      token(stream, state) {
        let ch;

        if (stream.eatSpace()) {
          state.prev = true;
          return null;
        }
        state.prev = false;

        ch = stream.next();

        // 字符串
        if (ch === "'" || ch === '"') {
          stream.eatWhile(stringEater(ch));
          stream.next();
          return "string";
        }
        // 操作符
        if (ch && isOperatorChar.test(ch)) {
          return "meta";
        }
  
        // 如果是()
        if (ch === "(" || ch === ")") {
          return "bracket";
        }
        return null;
      },
    };
  });

  cm.defineMIME("text/customer", "customer");

token相关API详解

  • eol() → boolean。 当 stream 在行尾时返回 true。

  • sol() → boolean。 当 stream 在行首时返回 true。

  • peek() → string。返回流中的下一个字符但不前进下标。在行尾时返回 null

  • next() → string。返回流中的下一个字符且前进下标。在该行没有剩余字符时返回 null

  • eat(match: string|regexp|function(char: string) → boolean) → stringmatch 可以是字符串、正则表达式或一个参数为字符串并返回 boolean 值的函数。 如果 stream 中的下一个字符与参数项匹配,将会被消耗掉并返回。否则返回 undefined 。

  • eatWhile(match: string|regexp|function(char: string) → boolean) → boolean。 重复调用 eat 直到截止。当所有的单词都被消耗掉后返回 true。

  • eatSpace() → boolean。用 eatWhile 匹配空格的快捷函数。

  • skipToEnd()。移动当前位置到行尾。

  • skipTo(str: string) → boolean。如果再当前行上找到指定的字符,则跳转到下个字符的开始位置(如果没有找到则不跳转)。找到时返回 true。

  • match(pattern: string, consume?: boolean, caseFold?: boolean) → boolean

  • match(pattern: regexp, consume?: boolean) → array<string>consume 为 true 时,类似多字符的 eat 函数, 为 false 或未指定时,不会更新 stream 的位置。 pattern 可以是字符串或由 ^ 开头的正则表达式。 当为字符串时,caseFold 为 true 时则不区分大小写。 当为正则表达式时,match 的返回值是一个数组,可以用来提取匹配结果。

  • backUp(n: integer)。后退 n 个字符。 后退超过当前 token 的开始位置时会导致终端,请当心。

  • column() → integer。返回当前 token 开始位置的列。

  • indentation() → integer。 返回当前行缩进的数量(以空格为单位),不是 tab 符号。

  • current() → string。返回当前 token 开始位置到当前 stream 位置之间的字符串。

  • lookAhead(n: number) → string | null。返回当前行后的 n (>0) 行,用来跨行扫描。 注意:你需要小心地使用该函数,跨太多的行会降低 Mode 状态的缓存效率。

  • baseToken() → {type?: string, size: number} | null。 通过 addOverlay 函数添加的 Mode 可以使用该函数来检查当前Mode 下的 Token 。

总结

CodeMirror具备良好的可扩展性,因此如果需要在已有Mode进行扩展或者继承,我们可以使用CodeMirror.extendMode 函数为 Mode 对象添加属性来生成具体的 Mode。 它的第一个参数是 Mode 的名称,第二个参数需要添加的属性的对象。

木更 2022.08.01