如何使用 Monaco Editor 做一个在线的网页代码编辑器
monaco editor
是一个由微软开发的代码编辑器,我们熟知的 vscode
就是基于其来实现的。也就是说,我们在 vscode
里面能够做到的功能理论上你也是可以通过 monaco editor
来实现的。
这次我们就来详细讲一讲如何在你的页面里面使用 monaco editor
来做一个漂亮,好用的代码编辑器。在本文中将会介绍下面几个内容:
- 在项目中集成
monaco editor
,本文将会基于Angular
来进行实践; - 参数定义和引用的跳转;
- 引入依赖的自动补全;
- 在代码行下方添加自定义区域。
还有更多的功能比如:
- 集成
prettier
进行格式化 - 集成
eslint
进行规范的提示 - 自定义
快速修复(quick fix)
- …
大家有兴趣的话可以在评论区留言我们下一期再讲~
1 在项目中集成 Monaco Editor
1.1 新建项目
# 我们创建一个多应用的工程,方便我们页面与组件的编写
ng new try-monaco-editor --createApplication=false
# 创建应用用于展示效果
ng g application website
# 创建库用于组件编写
ng g library tools
1.2 安装相关依赖
更详细的你可能更想看官方指导。
npm i monaco-editor
1.3 修改 angular.json
{
...,
"projects": {
"website": {
...,
"architect": {
"build": {
"options": {
"assets": [
{
"glob": "**/*",
"input": "node_modules/monaco-editor/min/vs",
"output": "/assets/vs/"
},
]
}
}
}
}
}
}
1.4 写一个 service 来加载 monaco 脚本
cd projects/website
ng g s services/code-editor
完成 code-editor.service.ts 加载脚本
// code-editor.service.ts
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
import { AsyncSubject, Subject } from 'rxjs';
// 根据使用时部署情况可能会需要更改资源路径
export const APP_MONACO_BASE_HREF = new InjectionToken<string>(
'appMonacoBaseHref'
);
@Injectable({
providedIn: 'root'
})
export class CodeEditorService {
private afterScriptLoad$: AsyncSubject<boolean> = new AsyncSubject<boolean>();
private isScriptLoaded = false;
constructor(@Optional() @Inject(APP_MONACO_BASE_HREF) private base: string) {
this.loadMonacoScript();
}
public getScriptLoadSubject(): AsyncSubject<boolean> {
return this.afterScriptLoad$;
}
public loadMonacoScript(): void {
// 通过 AMD 的方式加载 monaco 脚本
const onGotAmdLoader: any = () => {
// load monaco here
(<any>window).require.config({
paths: { vs: `${this.base || 'assets/vs'}` }
});
(<any>window).require(['vs/editor/editor.main'], () => {
this.isScriptLoaded = true;
this.afterScriptLoad$.next(true);
this.afterScriptLoad$.complete();
});
};
// 在这里会需要加载到 monaco 的 loader.js
if (!(<any>window).require) {
const loaderScript = document.createElement('script');
loaderScript.type = 'text/javascript';
loaderScript.src = `${this.base || 'assets/vs'}/loader.js`;
loaderScript.addEventListener('load', onGotAmdLoader);
document.body.appendChild(loaderScript);
} else {
onGotAmdLoader();
}
}
}
在 app.module.ts 中设置 base href
// app.module.ts
...
@NgModule({
providers: [{ provide: APP_MONACO_BASE_HREF, useValue: 'assets/vs' }],
})
export class AppModule {}
到此为止我们的前置工作就已经完成了,测试一下我们是否成功加载了 monaco
。
// app.component.ts
export class AppComponent implements AfterViewInit {
private destroy$: Subject<void> = new Subject<void>();
constructor(private codeEditorService: CodeEditorService) {}
ngAfterViewInit(): void {
this.codeEditorService
.getScriptLoadSubject()
.pipe(takeUntil(this.destroy$))
.subscribe((isLoaded) => {
if (isLoaded) {
// 后续我们初始化的操作都应该在 monaco 成功加载之后进行操作
console.log('load success');
}
});
}
}
修改之后我们在浏览器中打开 console
窗口,输入 monaco
,出现下图效果说明已经成功的加载了
接下来我们就可以开始将其封装为一个组件了。
2 editor 组件的编写
cd projects/tools/src/lib
ng g m editor --flat
ng g c editor --flat
现在我们的 tools/src/lib
目录下应该有三个文件 editor.component.scss
, editor.component.ts
, editor.module.ts
(记得修改一下 public-api.ts
中的文件导出),我们只用修改 editor.component.ts
就行了
// editor.component.ts
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
forwardRef,
HostBinding,
Input,
NgZone,
OnDestroy,
Output,
Renderer2,
ViewChild
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { CodeEditorService } from 'projects/website/services/code-editor.service';
import { fromEvent, Subject, takeUntil } from 'rxjs';
declare const monaco: any;
@Component({
selector: 'app-editor',
template: ` <div #editor class="my-editor"></div> `,
styleUrls: ['./editor.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
// 这里我们会通过双向绑定的方式来给 editor 传值,注意引入 NG_VALUE_ACCESSOR, ControlValueAccessor
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => EditorComponent),
multi: true
}
]
})
export class EditorComponent
implements AfterViewInit, OnDestroy, ControlValueAccessor
{
// 这里详细的 options 可以查看 https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneEditorConstructionOptions.html
@Input() options: any;
// 设置高度
@Input() @HostBinding('style.height') height: string;
// 初始化完成后将 editor 实例抛出,用户可以使用 editor 实例做一些个性化操作
@Output() readonly editorInitialized: EventEmitter<any> =
new EventEmitter<any>();
@ViewChild('editor', { static: true }) editorContentRef: ElementRef;
private destroy$: Subject<void> = new Subject<void>();
private _editor: any = undefined;
private _value: string = '';
// 由于 monaco 的很多方法都会返回 IDisposable 类型,大家在使用的时候需要注意,在组件销毁时将他们销毁,执行 dispose() 方法
private _disposables: any[] = [];
onChange = (_: any) => {};
onTouched = () => {};
constructor(
private zone: NgZone,
private codeEditorService: CodeEditorService,
private renderer: Renderer2
) {}
// 双向绑定设置 editor 内容
writeValue(value: string): void {
this._value = value || '';
this.setValue();
}
// 通过 onChange 方法将改变后的值传出,即 ngModelChange
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
ngAfterViewInit(): void {
this.codeEditorService
.getScriptLoadSubject()
.pipe(takeUntil(this.destroy$))
.subscribe((isLoaded) => {
if (isLoaded) {
this.initMonaco();
}
});
// 监听浏览器窗口大小,做 editor 的自适应
fromEvent(window, 'resize')
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
if (this._editor) {
this._editor.layout();
}
});
}
private initMonaco(): void {
const options = this.options;
const language = this.options['language'];
const editorDiv: HTMLDivElement = this.editorContentRef.nativeElement;
if (!this._editor) {
this._editor = monaco.editor.create(editorDiv, options);
this._editor.setModel(monaco.editor.createModel(this._value, language));
this.editorInitialized.emit(this._editor);
this.renderer.setStyle(
this.editorContentRef.nativeElement,
'height',
this.height
);
this.setValueEmitter();
this._editor.layout();
}
}
private setValue(): void {
if (!this._editor || !this._editor.getModel()) {
return;
}
this._editor.getModel().setValue(this._value);
}
private setValueEmitter() {
if (this._editor) {
const model = this._editor.getModel();
// 在内容改变时会触发 onDidChangeContent 方法,在此时将 value 抛出,注意将其返回值加到 _disposables 中
this._disposables.push(
model.onDidChangeContent(() => {
this.zone.run(() => {
this.onChange(model.getValue());
this._value = model.getValue();
});
})
);
}
}
ngOnDestroy(): void {
// 组件销毁时需要将订阅,实例都清除
this.destroy$.next();
this.destroy$.complete();
if (this._editor) {
this._editor.dispose();
this._editor = undefined;
}
// 在 monaco 中很多方法都会返回一个 IDisposable,需要在销毁时统一执行其 dispose() 方法进行销毁
if (this._disposables.length) {
this._disposables.forEach((disposable) => disposable.dispose());
this._disposables = [];
}
}
}
现在我们来测试一下效果,在 app.module.ts
中引入 EditorModule
,然后在 app.component.html
中使用 <app-editor></app-editor>
<div class="app">
<app-editor
[options]="{language: 'typescript'}"
[height]="'300px'"
[ngModel]="'export class Test {}'"
></app-editor>
</div>
之后在页面中看到如下效果即可
到此我们已经完成了一个简单的 editor
组件编写,后续大家就可以根据自己的喜好添加各种各样的 api, option 等配置了,下面为了方便我们将直接在 monaco editor play ground 上去进行功能的实现(在这里实现的也都是可以在组件中实现的)
3 Monaco Editor PlayGround
3.1 参数定义和引用的跳转
下方代码可以直接放到 PlayGround
中运行,该代码主要做了这几件事:
- 创建了多个 model,使他们能够关联起来,让我们可以查看到变量间的引用和定义
var code3 = `let d = a + 5;
\nlet mmm = a + 7;
\nlet sum = 0;
\nfor (let i = 0; i < 10; i ++) {\n\tsum += i;\n};`;
var models = [
{
code: 'let a = 1;\nlet b = 2;',
language: 'typescript',
uri: 'file://root/file1.ts'
},
{
code: 'let c = a + 3;\nlet mm = a - 6;',
language: 'typescript',
uri: 'file://root/file2.ts'
},
{
code: code3,
language: 'typescript',
uri: 'file://root/file3.ts'
}
];
var myModel;
models.forEach((model) => {
myModel = monaco.editor.createModel(
model.code,
model.language,
monaco.Uri.parse(model.uri)
);
});
var editor = monaco.editor.create(document.getElementById('container'), {
value: '',
language: 'typescript'
});
editor.setModel(myModel);
那我们要如何从当前文件跳转到其有引用的文件呢,我们来看下面的代码,下面的代码可以直接添加到上方的代码中即可
var editorService = editor._codeEditorService;
var openEditorBase = editorService.openCodeEditor.bind(editorService);
editorService.openCodeEditor = async (input, source) => {
const result = await openEditorBase(input, source);
if (result === null) {
const currentModel = monaco.editor.getModel(input.resource);
const range = {
startLineNumber: input.options.selection.startLineNumber,
endLineNumber: input.options.selection.endLineNumber,
startColumn: input.options.selection.startColumn,
endColumn: input.options.selection.endColumn
};
editor.setModel(currentModel);
editor.revealRangeInCenterIfOutsideViewport(range);
editor.setPosition({
lineNumber: input.options.selection.startLineNumber,
column: input.options.selection.startColumn
});
}
return result; // always return the base result
};
添加完之后我们直接双击 file2.ts
中出现的引用即可跳转。(关于定义的跳转也是一样的,直接点击 Goto Definition
即可)
接下来我们再增加一个功能,跳转文件之后,要怎样高亮相关的引用变量呢,我们再看下面的代码
// 在 editorService.openCodeEditor 中添加
editorService.openCodeEditor = async (input, source) => {
...
editor.setPosition({
lineNumber: input.options.selection.startLineNumber,
column: input.options.selection.startColumn,
});
// 在 css 文件中为类 myInlineDecoration 加上一个背景色,并且在 1s 后消失
const decorations = editor.deltaDecorations(
[],
[
{
range,
options: { inlineClassName: 'myInlineDecoration' },
},
]
);
setTimeout(() => {
editor.deltaDecorations(decorations, []);
}, 1000);
...
}
跳转的步骤和上方描述的一致,我们来看一下跳转后的效果,可以看到跳转过来对应的 a
变量被高亮了
3.2 引入依赖的自动提示
有时候我们引入了一个第三方库,我们可能会需要能够获取到其对应的一些方法,同样的下方代码可以直接在 PlayGround
中运行
- 这里关键的点在于我们要设置
typescript
的编译配置
// 修改这一步是必须的,否则 import 会不生效
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(),
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs
});
var models = [
{
code: `import * as t from 'test';\n\nt.X;\nt.Y;`,
language: 'typescript',
uri: 'file://root/file1.ts'
}
];
var denpendencies = [
{
code: 'export const X = 1;\nexport const Y = 2;',
language: 'typescript',
uri: 'file://root/node_modules/test/index.d.ts'
}
];
var myModel = monaco.editor.createModel(
models[0].code,
models[0].language,
monaco.Uri.parse(models[0].uri)
);
denpendencies.forEach((denpendency) => {
monaco.editor.createModel(
denpendency.code,
denpendency.language,
monaco.Uri.parse(denpendency.uri)
);
});
var editor = monaco.editor.create(document.getElementById('container'), {
value: '',
language: 'typescript'
});
editor.setModel(myModel);
3.3 添加自定义区域 viewZone, overlayWidget
在官方的 demo 中已经给我们介绍了 viewZone
, overlayWidget
, contentWidget
了,我们接下来看一下,如何将 overlayWidget
放到 viewZone
中,让我们可以在其中加入自己写的组件
- 首先这里需要用到
onDomNodeTop
和onComputedHeight
两个方法,这两个方法是关键,我们用于定位overlayWidget
让其能够很好的位于viewZone
中,我们先看一下代码
var jsCode = [
'"use strict";',
'function Person(age) {',
' if (age) {',
' this.age = age;',
' }',
'}',
'Person.prototype.getAge = function () {',
' return this.age;',
'};'
].join('\n');
var editor = monaco.editor.create(document.getElementById('container'), {
value: jsCode,
language: 'javascript',
glyphMargin: true,
contextmenu: false
});
var overlayDom = document.createElement('div');
overlayDom.innerHTML = 'My overlay widget';
overlayDom.style.background = 'lightgreen';
overlayDom.style.top = '50px';
overlayDom.style.width = '100%';
var viewZoneId = null;
editor.changeViewZones(function (changeAccessor) {
var domNode = document.createElement('div');
viewZoneId = changeAccessor.addZone({
afterLineNumber: 3,
heightInPx: 100,
domNode: domNode,
onDomNodeTop: (top) => {
overlayDom.style.top = `${top}px`;
},
onComputedHeight: (height) => {
overlayDom.style.height = `${height}px`;
}
});
});
// Add an overlay widget
var overlayWidget = {
getId: () => {
return 'my.overlay.widget';
},
getDomNode: () => {
return overlayDom;
},
getPosition: function () {
// 这里需要 return null,因为我们将使用 viewZone 来定位
return null;
}
};
editor.addOverlayWidget(overlayWidget);
在图中可以看到绿色区域溢出到了滚动条上,这个是我们所不希望的,那么这种时候我们还需要计算区域的宽度,只需要加上下方代码即可
var editorLayoutInfo = editor.getLayoutInfo();
var domWidth = editorLayoutInfo.width - editorLayoutInfo.minimap.minimapWidth;
overlayDom.style.width = `${domWidth}px`;
最后再重新提一下,关键点在于我们要在 addZone
中使用 onDomNodeTop
和 onComputedHeight
才能够完美的使我们的 overlayWidget
定位准确。
那么为什么要使用 overlayWidget
而不是直接使用 viewZone
中的 domNode
呢?在官方的说明中,viewZone
的目的是撑开一片区域,而不是让你在其中加入一个复杂的 dom
元素的,这也是其为什么提供 onDomNodeTop
和 onComputedHeight
这两个方法的原因,包括在 vscode
中,定义引用的提示框(如下图)也是使用这种方法来实现的。
这两个方法不光可以用于定位 overlayWidget
,也可以用于定位 contentWidget
。直接使用 viewZone
也会有点问题:
- 行号下方的区域是
viewZone
达不到的 - 如果
dom
元素有滚动条是无法滚动的
4 最后
Monaco Editor 提供了很多的 api 给开发者使用,大家可以多查看文档与其给出的 demo,相信还有更多的能力等着大家去发现!
- 点赞
- 收藏
- 关注作者
评论(0)