Monaco-Editor 调教日记

Customize Monaco Editor

Jul 14, 2020

见习魔法师

Monaco Editor

Customize Monaco Editor

Monaco Editor是微软的VSCode的内核,vscode的优良品质大家有目共睹,因此将该编辑器移植到本站,就可以利其齐优良的特性, 为在文章内的内嵌脚本提供语法高亮以及代码提示了。

虽然Monaco Editor的使用体验十分良好,但是其文档实在不敢恭维,虽然有较为全面的使用文档, 但是利用其API进行改造的文档实在过于薄弱,甚至有些简陋。虽然monaco声称可以扩展,但是在利用已有的API的基础上 根据自己需求扩展改造的介绍实在过于单薄,在Monaco Playground 上确实有一些案例,但是这些案例实在是过于抽象以及简单。对于Roselia-Blog需要的场景也没有过多介绍。

明确场景

我们需要:

  1. 在markdown语法的基础上扩展一种标记:r {{ }}。该标记里面的语法高亮是JavaScript。

  2. 在输入内嵌脚本的时候提供代码提示,自动完成,这种提示应当不仅仅是JavaScript现有的API的定义,还得包括:

    • Roselia-Blog提供的内置API

    • 用户通过 def defState 定义的变量以及其类型提示

    • 一些重要的API的带类型提示

  3. 在用户鼠标移动到实体上时,提供代码提示。

  4. 支持粘贴图片上传

  5. 部分用户可能会怀念SimpleMDE的操作按钮,因此编辑器需要能切换。

实现

我们先从简单的开始实现。

编辑器切换

编辑器切换是普通的组件切换的实现,其中MonacoEditor的component使用了异步的方式加载,加快速度。 不过需要注意的是,官方文档中推荐的:

const AsyncComponent: Vue.AsyncComponent = () => ({
    component: import('./async-component.vue'),
    loading: LoadingComponent
})
这个的type是写错的,应该写成:
import { AsyncComponentPromise } from 'vue'
const AsyncComponent: Vue.AsyncComponent = () => ({
    component: import('./async-component.vue') as unknown as AsyncComponentPromise,
    loading: LoadingComponent
})
否则不work。

图片粘贴上传

很遗憾,monaco的onDidPaste只能获得文字,不能获得剪切板对象, 因此我们要转而向其所在的dom上添加监听器。

editor.getDomNode()?.addEventListener('paste', event => {
  const items = event.clipboardData?.items || []
  for (let i = 0; i < items.length; ++i) {
    const item = items[i]
    if (item.kind === 'file' && item.type.startsWith('image/')) {
      const image = item.getAsFile()!
      const selection = editor.getSelection()!

      uploadImage(image).then(data => {
        const url = data.url
        editor.executeEdits('', [
          {
            range: monaco.Range.lift(selection),
            text: `![](${url})`
          }
        ])
      })
    }
  }
})
思路就是先记住粘贴时的选择位置,在上传完整之后在那个位置插入图片。

Language Server

最困难的应该是Roselia-Script相关的内容了,事实上,这也几乎是选择monaco的唯一的理由。

  1. 编写一门语言

我们扩展markdown语法的本质是新建一门语言,为了新建一门语言,我们必须在monaco中注册他。我们取名为Roselia Favored Markdown (RFM)

import * as monaco from 'monaco-editor'

export const RFMLanguageId = 'roselia-favored-markdown'
monaco.languages.register({
    id: RFMLanguageId
})

然后下一步我们应该为他提供一个tokenizer。与VSCode不同的是,monaco不支持texmate的语言定义语法, 在某一个issue中说是因为性能问题。不过显然,前者更加成熟也更加好用。 monaco使用的是monarch,更像一种栈的操作。虽然不能像.tmLanguage一样直接import语法,我们还是能在复制原有的语法定义的基础上做一些扩展。

查看monaco-languages/markdown.ts,直接复制其对于markdown语言的定义。参照embeddedScript这个sub-tokenizer的写法,在root的tokenizer里加上如下字段:

monaco.languages.setMonarchTokensProvider(RFMLanguageId, <monaco.languages.IMonarchLanguage> {
  // Keep other properties unchanged.
  tokenizer: {
    root: [
      /** Extend the root with such: */
      [/((r|R|roselia|Roselia){{)/, {
          token: 'tag',
          next: 'roseliaScript',
          nextEmbedded: 'text/javascript'
      }]
    ],
    /** Add such tokenizer. */
    roseliaScript: [
      [/}}/, { token: 'tag', next: '@pop', nextEmbedded: '@pop' }],
      [/(?!}})/, '']
    ]
  }
})

这个意味着,我们添加了如下的tokenizer,使得在其遇到r后跟着{{时,将整个token标记为tag,接下来进入roseliaScript这个状态,其中的语言 是text/javascript。直到遇见}}token,将状态栈弹出,恢复到上一个状态。

这么寥寥几行,我们就完成了对新语法高亮的处理。但是我们不会止步于此,我们还需要语法提示。

接下来我们就需要查看monaco-typescript中的实现了。在tsMode.ts中我们可以看见JavaScript中那些fancy的feature是怎么实现的了。首先他们启动了一个JavaScript Worker,之后他们利用这个worker注册了一些列的feature。因为这里的languageWorker过于难看,我就将其视为一个黑盒,转而看languageFeatures.ts的实现。然后发现这里面其实全是adapter,做的都是数据结构转换的工作 一个功能多个model,巨硬老传统了。所幸Roselia-Script其实和JavaScript无异,可以直接用他们的adapter。

自动完成

为了实现自动完成,我们需要注册那个proveder。

import { getJavaScriptWorker } from 'monaco-editor/esm/vs/language/typescript/tsMode'
import { SuggestAdapter } from 'monaco-editor/esm/vs/language/typescript/languageFeatures'

const getJSWorker = async (...uri: monaco.Uri[]): Promise<monaco.languages.typescript.TypeScriptWorker> => {
    const getter = await getJavaScriptWorker()
    return await getter(...uri)
}

monaco.languages.registerCompletionItemProvider(RFMLanguageId, {
  triggerCharacters: ['.'],
  provideCompletionItems(model, position, context, token) {
    if (isInRoseliaScript(model, position)) {
      const adapter: monaco.languages.CompletionItemProvider = new SuggestAdapter(getJSWorker)
      return adapter.provideCompletionItems(model, position, context, token)
    }

    return {
      suggestions: []
    }
  }
})

isInRoseliaScript判断当前光标是否在Roselia-Script的tag里面,这个只需一个正则表达式去匹配即可。

const isInRoseliaScript = (model: monaco.editor.IModel, position: monaco.Position) => {
  const textUntilPosition = model.getValueInRange({
    startLineNumber: 1, startColumn: 1,
    endLineNumber: position.lineNumber, endColumn: position.column
  });
  return textUntilPosition.match(/(?:r|R|roselia|Roselia){{(([\s\S](?!}}))+)$/);
}
实现方法简单粗暴,直接判断从内容开始到当前光标为止有没有左右不匹配的标签即可。 这样就大功告成了。但是问题来了,我们要如何处理由defdefState引入的全局变量呢。

这里的解决方案是,考虑到worker是可以接受多个文件作为输入而得出类型提示的,就像平时我们一个工程里面的代码提示都是跨文件的, 我们可以将所有的declaration提取出来,然后将他们放到另外一个fake的文件里,然后给出联合的代码提示。

转换方式如下:

// To unify function types and value types.
declare function _ext<T>(params: T | (() => T)): T;

def('value', 0) // Translate to
const value = 0;

defState('state', () => 0)
let state = _ext(() => 0);

def(['myState', 'setMyState'], useState(() => 0))
const [myState, setMyState] = useState(() => 0)
考虑到defState可以接受返回该值的函数,和值,因此编写_ext帮助函数来帮助ts推断正确的类型。

然后代码的转换方式在此不赘述,假设已经写好了:

/** 
 * Extract def clauses to .d.ts file content.
 * @param content The content of the article.
 * @returns The fake generated .d.ts file content.
*/
declare function getGlobalDefinitionClauses(content: string): string

那么我们只需要写一个假的model,然后传入那个worker,一切都变得顺利了。

monaco.languages.registerCompletionItemProvider(RFMLanguageId, {
  triggerCharacters: ['.'],
  provideCompletionItems(model, position, context, token) {
    if (isInRoseliaScript(model, position)) {
      const decls = getGlobalDefinitionClauses(model.getValue())
      const declarationModel = monaco.editor.createModel(decls, 'typescript')
      const worker = (...uri: monaco.Uri[]) => {
        return getJSWorker(declarationModel.uri, ...uri)
      }

      const adapter: monaco.languages.CompletionItemProvider = new SuggestAdapter(worker)
      return adapter.provideCompletionItems(model, position, context, token)
    }

    return {
      suggestions: []
    }
  }
})

这一招狸猫换太子,让monaco从此认识了Roselia-Blog特殊的语法,其他的feature也是如此,就是简单的adapter,就能实现, 但是发现这个做法却没有官方的文档,只能通过阅读源码推测做法,这个实在是让人有所不爽。

效果如下: 可以看见,setMyState这个变量其类型也能被正确识别。 同时,其也能在输入时获得提示:

在一些特别常用的API,比如模糊主图片,你不用再记住API了,可以直接获得提示,外加属性的hint:

缺陷

该解决方案的问题是,monaco过于庞大,导致打包后大部分的大小都是monaco-editor贡献的,甚至增加的尺寸已经比Roselia-Blog原本整个bundle的大小要大了;同时,monaco不是一个markdown的编辑器,只有在代码才十分实用,否则在中文markdown的输入下,会出现奇怪的自动完成提示。

此外,Roselia-Blog的script的执行环境不是完整的JavaScript,而是在沙箱中执行的,但是,代码提示却默认会将所有选项列举出来。

r{{ changeExtraDisplaySettings({ blurMainImage: true }) }} r{{ post.img = 'https://pic1.zhimg.com/80/v2-36849103f5d366e5dcab77c0a2918e43_1440w.jpg'; }}