Monaco Editor
Customize Monaco Editor
Monaco Editor是微软的VSCode的内核,vscode的优良品质大家有目共睹,因此将该编辑器移植到本站,就可以利其齐优良的特性, 为在文章内的内嵌脚本提供语法高亮以及代码提示了。
虽然Monaco Editor的使用体验十分良好,但是其文档实在不敢恭维,虽然有较为全面的使用文档, 但是利用其API进行改造的文档实在过于薄弱,甚至有些简陋。虽然monaco声称可以扩展,但是在利用已有的API的基础上 根据自己需求扩展改造的介绍实在过于单薄,在Monaco Playground 上确实有一些案例,但是这些案例实在是过于抽象以及简单。对于Roselia-Blog需要的场景也没有过多介绍。
明确场景
我们需要:
-
在markdown语法的基础上扩展一种标记:
r {{ }}
。该标记里面的语法高亮是JavaScript。 -
在输入内嵌脚本的时候提供代码提示,自动完成,这种提示应当不仅仅是JavaScript现有的API的定义,还得包括:
-
Roselia-Blog提供的内置API
-
用户通过
def
defState
定义的变量以及其类型提示 -
一些重要的API的带类型提示
-
-
在用户鼠标移动到实体上时,提供代码提示。
-
支持粘贴图片上传
-
部分用户可能会怀念
SimpleMDE
的操作按钮,因此编辑器需要能切换。
实现
我们先从简单的开始实现。
编辑器切换
编辑器切换是普通的组件切换的实现,其中MonacoEditor
的component使用了异步的方式加载,加快速度。
不过需要注意的是,官方文档中推荐的:
const AsyncComponent: Vue.AsyncComponent = () => ({
component: import('./async-component.vue'),
loading: LoadingComponent
})
import { AsyncComponentPromise } from 'vue'
const AsyncComponent: Vue.AsyncComponent = () => ({
component: import('./async-component.vue') as unknown as AsyncComponentPromise,
loading: LoadingComponent
})
图片粘贴上传
很遗憾,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的唯一的理由。
- 编写一门语言
我们扩展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](?!}}))+)$/);
}
def
和defState
引入的全局变量呢。
这里的解决方案是,考虑到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'; }}