如何使用 Monaco Editor 做一个在线的网页代码编辑器

DevUI 发表于 2022/05/17 15:04:21 2022/05/17
【摘要】 monaco editor 是一个由微软开发的代码编辑器,我们熟知的 vscode 就是基于其来实现的。也就是说,我们在 vscode 里面能够做到的功能理论上你也是可以通过 monaco editor 来实现。

汤汤Tang.png

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 安装相关依赖

更详细的你可能更想看官方指导

集成 AMD 版本

集成 ESM 版本

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,出现下图效果说明已经成功的加载了

load-monaco.png

接下来我们就可以开始将其封装为一个组件了。

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>

之后在页面中看到如下效果即可

monaco-component.png

到此我们已经完成了一个简单的 editor 组件编写,后续大家就可以根据自己的喜好添加各种各样的 api, option 等配置了,下面为了方便我们将直接在 monaco editor play ground 上去进行功能的实现(在这里实现的也都是可以在组件中实现的)

3 Monaco Editor PlayGround

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);

goto-reference.png

那我们要如何从当前文件跳转到其有引用的文件呢,我们来看下面的代码,下面的代码可以直接添加到上方的代码中即可

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 即可)

change-file.png

change-file-result.png

接下来我们再增加一个功能,跳转文件之后,要怎样高亮相关的引用变量呢,我们再看下面的代码

// 在 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 变量被高亮了

jump-with-highlight.png

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);

import.png

3.3 添加自定义区域 viewZone, overlayWidget

在官方的 demo 中已经给我们介绍了 viewZone, overlayWidget, contentWidget了,我们接下来看一下,如何将 overlayWidget 放到 viewZone 中,让我们可以在其中加入自己写的组件

  • 首先这里需要用到 onDomNodeToponComputedHeight 两个方法,这两个方法是关键,我们用于定位 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);

overlay.png

在图中可以看到绿色区域溢出到了滚动条上,这个是我们所不希望的,那么这种时候我们还需要计算区域的宽度,只需要加上下方代码即可

var editorLayoutInfo = editor.getLayoutInfo();
var domWidth = editorLayoutInfo.width - editorLayoutInfo.minimap.minimapWidth;
overlayDom.style.width = `${domWidth}px`;

overlay-width.png

最后再重新提一下,关键点在于我们要在 addZone 中使用 onDomNodeToponComputedHeight 才能够完美的使我们的 overlayWidget 定位准确。

那么为什么要使用 overlayWidget 而不是直接使用 viewZone 中的 domNode呢?在官方的说明中,viewZone 的目的是撑开一片区域,而不是让你在其中加入一个复杂的 dom 元素的,这也是其为什么提供 onDomNodeToponComputedHeight 这两个方法的原因,包括在 vscode 中,定义引用的提示框(如下图)也是使用这种方法来实现的。

vscode-reference.png

这两个方法不光可以用于定位 overlayWidget ,也可以用于定位 contentWidget。直接使用 viewZone 也会有点问题:

  • 行号下方的区域是 viewZone 达不到的
  • 如果 dom 元素有滚动条是无法滚动的

4 最后

Monaco API

Monaco Editor 提供了很多的 api 给开发者使用,大家可以多查看文档与其给出的 demo,相信还有更多的能力等着大家去发现!

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区),文章链接,文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件至:cloudbbs@huaweicloud.com进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容。
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。