likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 40

如何使用 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 安装相关依赖

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

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

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

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

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 Editor 做一个在线的网页代码编辑器

到此我们已经完成了一个简单的 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);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4 最后

Monaco API

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

转载自:https://juejin.cn/post/7085896602124025887
评论
请登录