codemirror 6.x 爬坑之旅,给您带来更好的旅途快乐!!!

1,387 阅读7分钟

开始

技术栈:react:^16.14.0, typescript scss codemirror 6.x

目标

实现一个编辑器组件,支持JavaScript,json,html,Sql,以及自定义的公式变量;

基础知识

文档地址:codemirror.net/

优势

Codemirror6和Codemirror5的主要区别体现在以下几个方面:

  1. 架构:CM6是对CM5进行了全面重写和重构的版本,采用了新的架构。CM6的设计目标是提供更好的可扩展性、模块化和定制性,以适应不同的编辑器需求。
  2. 模块化:CM6引入了模块化的概念,使得编辑器的功能可以以更灵活的方式组织和扩展。它提供了一套核心模块,以及可选的插件和扩展模块,使开发人员能够根据自己的需求选择和组合功能。
  3. 插件系统:CM6的插件系统更加强大和灵活。它采用了新的基于状态和触发器的机制,使得插件能够更好地与编辑器交互并响应变化。这使得开发人员能够创建更复杂和定制化的编辑器功能。
  4. 渲染方式:CM6使用新的渲染引擎,采用了虚拟DOM的概念,以提高性能和响应能力。它还支持可选的受限制的线性渲染模式,可以在大型文档上提供更好的性能。

周边

react-codemirrorgithub 1.4k的star;也是基于codemirror 6.x实现的;只要进行一些配置就可实现;

import React from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';

function App() {
  const [value, setValue] = React.useState("console.log('hello world!');");
  const onChange = React.useCallback((val, viewUpdate) => {
    console.log('val:', val);
    setValue(val);
  }, []);
  return <CodeMirror value={value} height="200px" extensions={[javascript({ jsx: true })]} onChange={onChange} />;
}
export default App;

实现一个基础编辑器

import { basicSetup, EditorView } from 'codemirror';
import { EditorState } from "@codemirror/state";
// 状态管理
let startState = EditorState.create({ doc: "Hello World", extensions: [ basicSetup ], });
// 视图层
let view = new EditorView({ state: startState, parent: document.body })

basicSetup内包含了一系列常用的插件和设置,基本上使用它就可以设置好一个基础的编辑器了。

知识点之状态管理

@codemirror/state:这个包定义了编辑器的状态管理和文档模型。它提供了EditorState类,用于表示编辑器的完整状态,包括文本内容、光标位置、选中范围、编辑器的配置选项等。通过@codemirror/state,你可以创建和管理编辑器的状态,执行编辑操作、查询编辑器状态,以及响应状态的变化。

const stateStart = EditorState.create({
    doc: defaultValue, // 文档值
    extensions: [ // 扩展值
      ...extensions(props, callback, handleError),
      ...(extensionsProps || []),
    ],
});

这里的extensions着重说一下,它好比电脑的各个扩展插口,用户可以根据自己的需求,加不同的扩展,创造不同的编辑器,这也是codemirror升级之后的一大特色;这里列举几个可扩展的功能,让你更好的理解它:

  1. 快捷键,比如Tab键的效果;
  2. 主题色的个性化配置;
  3. mode,你要使用的语言,比如我要开发一个JavaScript编辑器,可以安装对应的包@codemirror/lang-javascript,进行扩展;
  4. 代码高亮的配置;
  5. 增加装饰器;
  6. 当然也可以去监听视图层的变化去执行自己的逻辑;EditorView.updateListener.of(...)
  7. placeholder的设置;
  8. 对语言包的汉化;
  9. 语法检查;
  10. ...
import { json as jsonLang } from "@codemirror/lang-json";
import { baseTheme } from "./plugin/base-theme";
import { LightHighlight } from "./plugin/high-lighting";

// 此包导出通用编辑命令的集合,以及其中许多命令的键绑定
import { indentWithTab, defaultKeymap } from "@codemirror/commands";

const stateStart = EditorState.create({
    doc: defaultValue, // 文档值
    extensions: [
        baseTheme,
        LightHighlight,
        keymap.of([...defaultKeymap, indentWithTab]),
        jsonLang(),
        EditorView.editable.of(editable),
        // 最小高度
        EditorView.theme({
            ".cm-content": minHeight ? {
                minHeight: `${minHeight}px`
            } : {},
            "&": height ? {
                height: `${height}px`
            } : {}
        }),
        placeholder("请输入json");
        // 监听doc变化
        EditorView.updateListener.of((v: ViewUpdate) => {
            let error = {};
            // 遍历编辑器中的错误提示;
            forEachDiagnostic(v.state, (d: Diagnostic) => {
                error = d
            });
            handleError(error);
          if (v.docChanged && trigger !== "blur") {
            callback(v);
          }
        })
    ],
});

知识点之视图层

核心包:@codemirror/view,此提供了构建编辑器用户界面(UI)的核心功能。它定义了编辑器的视图组件、输入处理、渲染逻辑等。@codemirror/view中的主要类是EditorView,它代表了一个codemirror6编辑器实例的视图。你可以使用@codemirror/view来创建、控制和自定义编辑器的外观和交互行为。

import { EditorView } from "@codemirror/view";

// 创建实例
editorView.current = new EditorView({
  state: state, // 数据状态管理;
  parent: editorRef.current || document.body, // 要挂载的容器;
});

这里可以通过实例的一些方法,实现一些逻辑功能;例如 dispatch update setState destroy

  • view.dispatch 的一个用法介绍; 用来插入一段值到编辑器中;

    cosnt text = '插入的值'
    view.dispatch({
        changes: {
          from: range.from,
          to: range.to,
          insert: text,
        },
        selection: {
          anchor: range.from + text.length,
        }
    })
    
  • view.destroy 在组件生命周期结束之后一定要记得销毁;

关于lint

lint的作用就是检测诊断代码的正确性, 比较关注的一个字段就是Diagnostic,它携带的信息就有哪行哪列什么错误的信息;以及对于错误纠正的action等;

当你有一个语法检测库的时候lang-lint,可以直接使用linter方法,在扩展extensions中使用;

import { json as jsonLang, jsonParseLinter } from "@codemirror/lang-json";

...
extensions: [
  jsonLang(),
  // 错误语法检测提示
  linter(jsonParseLinter()),  
]

亦或者自定义语法检测,定一个文件/plugin/custom-linter,这里举例自定义函数的语法检测;

export const customLinter = (functions) => {
    return linter((view) => {
        let diagnostics: Diagnostic[] = [];
        const lines: number = view.state.doc.lines;
        for (let i = 0; i < lines; i++) {
          const lineText = line.text.replace(/\s+/g, "");
          // 遍历所有的补全项
          if (lineText) {
            const isSome = functions.some((it) => lineText.includes(it.label))
            if (!isSome) {
              diagnostics.push({
                from: line.from,
                to: line.to,
                severity: "error",
                message: `语法错误或未定义`,
                actions: [];
              });
            }
          }
       }
       return diagnostics;
   })
}

extensions中使用;

...
extensions:[
    customLinter(functions),
]

关于插件ViewPlugin

ViewPlugin 将有状态值与视图相关联。他们可以影响内容的绘制方式,并且当视图层改变也能收到change通知。

这里使用formClass为一个类创建一个插件,该类的构造函数将单个编辑器视图作为参数;

  • 参数1:需要一个视图类
  • 参数2:插件的参数
ViewPlugin.fromClass(
    class {
      keywords: DecorationSet;
      constructor(view: EditorView) {
        // 通过装饰器创建 deco (装饰范围的集合)
        this.keywords = keywrodsMatcher.createDeco(view);
      }
      update(update: ViewUpdate) {
        this.function = keywrodsMatcher.updateDeco(update, this.keywords);
      }
    },
    {
      decorations: (instance: any) => {
        return instance.keywords;
      },
      // 指定插件在添加到编辑器配置时提供其他扩展。
      provide: (plugin: ViewPlugin<any>) =>
        EditorView.atomicRanges.of((view) => {
          return view.plugin(plugin)?.keywords || Decoration.none;
        }),
    }
);

decorations 是一个装饰器,返回一个装饰器的实例,装饰器的使用下文介绍;provide 指定插件在添加到编辑器配置时提供其他扩展, EditorView.atomicRanges.of的作用是进行光标移动和删除;

关于装饰器

MatchDecorator,它的理解就是,通过正则表达式对匹配的text进行修饰;主要看它的一些配置了

下面创建一个对关键字的修饰;

class keyWordsWidget extends WidgetType {
    text: string;

    constructor(text: string) {
      super();
      this.text = text;
    }
    // 将此实例与相同类型的另一个实例进行比较, 用于是否dom重绘;
    eq(other: FunctionWidget) {
      return this.text == other.text;
    }
    // 为此小组件实例构建 DOM 结构,这里可以设置关键字的样式;return 为一个dom
    toDOM() {
      const elt = document.createElement("span");
      const textCode = document.createElement("span");
      textCode.style.cssText = `
        color: red;
        font-size: 14px;
        background: #f8d7da;
        user-select: none;
     `;
      textCode.textContent = this.text;
      elt.appendChild(textCode);
      return elt;
    }
    // 配置编辑器应忽略小组件内哪些类型的事件。默认设置是忽略所有事件
    ignoreEvent() {
      return true;
    }
}
// 实现对关键字样式的修饰
const keywrodsMatcher = new MatchDecorator({
    regexp: new RegExp(keywords.join("|"), "g"),
    decoration: (match) => {
      const keyword = match[1];
      return Decoration.replace({
          // 创建一个小部件装饰,在给定位置显示 DOM 元素。
          widget: new keyWordsWidget(`${keyword}`),
      });
    }
  });

这里多余使用了WidgetType,主要用于描述widget。使用这样的描述对象可以延迟创建小部件的 DOM 结构。

主题

import { EditorView } from "@codemirror/view";
// 主题配置
export const baseTheme: any = EditorView.baseTheme({
    '&': {
        color: '#586e75',
        backgroundColor: '#fff',
        caretColor: '#657b83',
        fontSize: "16px"
    },
    ".cm-content": {
        "white-space": "pre-wrap"
    },
    '.cm-tooltip': {
        border: '1px solid #ccc',
        backgroundColor: '#fff',
        padding: '5px',
    },
    '&.cm-focused .cm-cursor': { borderLeftColor: '#657b83' },
    "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "#bfe3ff", color: "#4284d3"
    },
    ...
})

代码高亮

import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language';
import { tags as t  } from '@codemirror/highlight';
export const LightHighlight = syntaxHighlighting(HighlightStyle.define([
    // const, let, function, if
    { tag: t.keyword, color: '#859900', fontWeight: "bold" },
    // document
    { tag: [t.name, t.deleted, t.character, t.macroName], color: '#657b83' },
    // getElementById
    { tag: [t.propertyName], color: '#268BD2' },
    { tag: t.comment, color: "#93A1A1", fontStyle: "italic"},
    // "string"
    { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: '#2AA198' },
    // render
    { tag: [t.function(t.variableName), t.labelName], color: '#268DCC' },
    // ???
    { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: '#CB4B16' },
    // btn, count, fn render()
    { tag: [t.definition(t.name), t.separator], color: '#657b83' },
    { tag: [t.className], color: '#268BD2' },
    { tag: [t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: '#D33682' },
    { tag: [t.typeName], color: '#859900', fontStyle: '' },
    { tag: [t.operator, t.operatorKeyword], color: '#859900' },
    { tag: [t.url, t.escape, t.regexp, t.link], color: '#D30102' },
    { tag: [t.meta, t.comment], color: '#93A1A1' },
    { tag: t.strong, fontWeight: 'bold' },
    { tag: t.emphasis, fontStyle: 'italic' },
    { tag: t.link, textDecoration: 'underline' },
    { tag: t.heading, fontWeight: 'bold', color: '#268BD2' },
    { tag: [t.atom, t.bool, t.special(t.variableName)], color: '#657b83' },
    { tag: t.invalid, color: '' }
] as any[]));