🌋 WebIDE 的开发记录其三(editor 集成)
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
一方面说怎么集成 monaco-editor 到项目中,另一方面怎么用扩展的形式加入新功能。
PS: 本文集成 monaco-editor 不是普遍适用的,包含很多针对当前项目的集成,所以仅供参考。
monaco-editor 是一个非常优秀的代码编辑器,大名鼎鼎的 VSCode 的编辑器内核,有非常强大的 IDE 功能支持。也正是有了这个优秀的工具才让浏览器编辑器成为了现实。
monaco-editor 准确的讲是从 VSCode 的源码中单独抽离出来能够直接在浏览器上运行,很多地方和 VSCode 的实现并不相同,不过编辑器本身的体验大部分是互通的。
WorkPad 项目中编辑器的集成不可能简单一篇文章说得清楚,这里只是最基础值得说的方面
使用 rquire 的方式加载 AMD 标准模块的库,而不是用 ESM 模块标准交给 webpack 打包,毕竟这个内核蛮大的,这样做比较灵活,多语言、worker 之类的不需要费神配置,后续的内部模块引用也可以使用
require
来获取:<script src="/public/vs/loader.js"></script><script>require.config({paths: {'vs': '/public/vs'},'vs/nls': {availableLanguages: {'*': 'zh-cn'}}});// ubug: 确保 monaco 加载成功才能调用实际功能window.require(['vs/editor/editor.main'], function (editor) {console.log('monaco-editor 加载成功')var n = document.createElement('script');n.type = 'text/javascript';n.src = 'index.js';document.body.appendChild(n);n.onload = ()=> {console.log('主逻辑加载成功')}});</script>
配置 monaco 默认配置,监听 editor 创建后配置等操作:
function bindTextmate() {// ......monaco.editor.setTheme(currentEditorTheme);for (const { id } of monaco.languages.getLanguages()) {monaco.languages.onLanguage(id, () => activateLanguage(id));}}const dirtyManager = new DirtyDiffManager()function configureMonaco() {bindTextmate(); // 配置语法高亮等// ... bind other things}function configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {dirtyManager.start(editor) // 配置 git diff 逻辑}configureMonaco();monaco.editor.onDidCreateEditor(configureEditor);
// 本来是let editorOverrides = {editorService: {// openEditor: function () {// alert(`open editor called!` + JSON.stringify(arguments));// },// resolveEditor: function () {// alert(`resolve editor called!` + JSON.stringify(arguments));// }}}let options: monaco.editor.IEditorOptions = {minimap: {enabled: true,showSlider: 'always',renderCharacters: false},glyphMargin: true,lightbulb: {enabled: true},extraEditorClassName: '__ubug_monaco',};const defaultModel = monaco.editor.createModel('', 'plaintext');const editor = monaco.editor.create(this.el, { ...options, model: defaultModel }, editorOverrides);
其中 第三个参数 OverrideServices 目的是覆盖内置的一些服务,比如跳转到或者引用等功能,在某些版本中可能会被覆盖导致失效,可以通过相对 hack 的方式添加功能:
function hackOverides(editor: monaco.editor.IStandaloneCodeEditor) {// @ts-ignoreconst services = [...editor._instantiationService._parent._services._entries.values()];const textModelService = services.find((service) => {const props = Object.getPrototypeOf(service)return props && 'createModelReference' in props});// @ts-ignoreconst codeEditorService = editor._codeEditorService;codeEditorService.openCodeEditor = (input: { resource: monaco.Uri, options: any }) => {const { resource, options } = inputconst file = resource.path;const range = options.selection;// Add code here to open the code editor.const states = window._novus.models.workspace.statelet path = file.replace(states.config.path, '')commandsPool.writer.openFile.trigger(path, () => {window._novus.models.writer.state.currentEditor.setSelection(range);window._novus.models.writer.state.currentEditor.revealLineInCenter(range.startLineNumber);});}textModelService.createModelReference = async (uri: monaco.Uri) => {const fileManagerModel = window._novus.models["fileManager"]const states = window._novus.models.workspace.statelet fileId = uri.path.replace(states.config.path, '')let model = modelsHolder.models[fileId];if (!model) {let file = await fileManagerModel.actions.queryFile(fileId);const contentsMap = fileManagerModel.state.contentsMapconst content = contentsMap[fileId];const language = getLang(`.${fileId.split('.').pop()}`)if (file) {await fileManagerModel.actions.fetchFileContent(fileId);modelsHolder.addModel(file.relative, file.path, content, language);model = modelsHolder.models[fileId];}}const reference = {load() {return Promise.resolve(model.model)},// in my case, I have nothing to dispose as I just re-use existing resourcesdispose() { },textEditorModel: model.model}return {object: reference,dispose() { },};};}hackOverides(editor);
在添加 LSP 功能或者本地 model 路径完整的情况下,支持跳转和引用查看。
在 IDE 概念中不可能每次编辑文件都创建一个 editor,所以提供了 model 和 state 的功能来储存文件的模型和状态。
model 表示一个编辑文件的快照,包含文件信息、语言标记、配置、装饰信息等,可以使用 getModel 和 setModel 切换不同的文件。state 表示视图上的状态,比如光标状态、滚动位置等信息。
根据这两个信息来支持多文件的编辑和切换功能,我在项目中使用了一个 modelManager 来单独处理文件内容、文件状态、编辑状态之类的,可以根据逻辑灵活的多文件切换。
// 获取并存储let model = this.editor.getModel();let state = this.editor.saveViewState();modelsManager.updateModeler({ id: currentTabId, model, state})// 恢复let targetModel = modelsManager.models[tabId];this.editor.setModel(targetModel.model);this.editor.restoreViewState(targetModel.state);
常见的一些配置比如 缩进、空格、行列选择、语言、换行模式、文件大小等信息,比如缩进:
在状态栏展示部分:
const IndentWidget = () => {let state = useNovus<TState>((models) => {const activeId = models.layout.state.layout.dealer.activeId;return {activeId: activeId,activeTab: models.writer.state.tabs[activeId],};}, ['workspace', 'writer'])const model = modelsHolder.models[state.activeId];if (!model || !state.activeTab || state.activeTab.type !== 'text') return null;const options = model.model.getOptions();const space = options.insertSpaces;const tabSize = options.tabSize;return <Tooltip title="修改缩进"><CommandBtn command="writer.changeIndent">{`${space ? '空格' : 'Tab'}: ${space ? tabSize : null}`}</CommandBtn></Tooltip>}
点击的命令,支持使用 monaco-editor 自带的 quickOpen 功能展示配置选项:
import { getModel } from '.';const { QuickOpenModel, QuickOpenEntryGroup } = window.require('vs/base/parts/quickopen/browser/quickOpenModel');const { BaseEditorQuickOpenAction } = window.require('vs/editor/standalone/browser/quickOpen/editorQuickOpen');const { matchesFuzzy } = window.require('vs/base/common/filters');const { Range } = window.require('vs/editor/common/core/range');type TActionOption = {withBorder?: booleangroup?: string}function getIndentationEditOperations(model: monaco.editor.ITextModel, tabSize: number, tabsToSpaces: boolean) {if (model.getLineCount() === 1 && model.getLineMaxColumn(1) === 1) { return; }let spaces = ' '.repeat(tabSize);let spacesRegExp = new RegExp(spaces, 'gi');let operations = [];for (let lineNumber = 1, lineCount = model.getLineCount(); lineNumber <= lineCount; lineNumber++) {let lastIndentationColumn = model.getLineFirstNonWhitespaceColumn(lineNumber) || model.getLineMaxColumn(lineNumber);if (lastIndentationColumn === 1) continue;const originalIndentationRange = new Range(lineNumber, 1, lineNumber, lastIndentationColumn);const originalIndentation = model.getValueInRange(originalIndentationRange);const newIndentation = (tabsToSpaces ? originalIndentation.replace(/\t/ig, spaces) : originalIndentation.replace(spacesRegExp, '\t'));operations.push({range: originalIndentationRange,text: newIndentation});}model.applyEdits(operations);}class DemoActionCommandEntry extends QuickOpenEntryGroup {constructor(highlights: any, editor: monaco.editor.IStandaloneCodeEditor, option: TActionOption) {super();this.editor = editor;this.option = option;this.withBorder = option.withBorder;return this;}getLabel = () => this.option.label;getAriaLabel = () => this.option.label;getDetail = () => this.option.desc;getGroupLabel = () => this.option.group || '';run = (mode: number) => {if (mode === 1 /* OPEN */) {var model = getModel(this.editor);if (this.option.operate[0] === 0) {if (this.option.operate[1] === 0) {model.updateOptions({tabSize: this.option.operate[2],insertSpaces: true});} else {model.updateOptions({insertSpaces: false});}} else {let modelOpts = model.getOptions();// 转换 model, builder, tabSize, tabsToSpacesgetIndentationEditOperations(model, modelOpts.tabSize, this.option.operate[1] === 0)}return true;}return false;};}export class IndentCommandAction extends BaseEditorQuickOpenAction {constructor() {super("ubug: 修改缩进", {id: 'editor.ubug.changeIndent',label: "ubug: 修改缩进",alias: 'ubug: 修改缩进',// menuOpts: {}menuOpts: null})return this;}run = function (accessor: any, editor: monaco.editor.IStandaloneCodeEditor) {this._show(this.getController(editor), {getModel: function (value: string) {var entries = [{label: '使用 2 空格缩进',group: '视图',desc: 'Indent Using 2 Spaces',operate: [0, 0, 2] // insertSpaces, space nums},{label: '使用 4 空格缩进',desc: 'Indent Using 4 Spaces',operate: [0, 0, 4]},{label: '使用 Tab 缩进',desc: 'Indent Using Tab',operate: [0, 1, 2]},{label: '使用空格转换已有内容',group: '转换',desc: 'Indent Using 2 Spaces',operate: [1, 0], // insertSpaces, space numswithBorder: true},{label: '使用 Tab 转换已有内容',desc: 'Indent Using Tab',operate: [1, 1]},];var models = entries.filter(e => (matchesFuzzy(value, e.label) || matchesFuzzy(value, e.desc))).map((e) => {return new DemoActionCommandEntry(value, editor, e);})return new QuickOpenModel(models);},getAutoFocus: function (searchValue: string) {return {autoFocusFirstEntry: searchValue.length > 0,autoFocusIndex: getModel(editor).getOptions().defaultEOL - 1};}});};}
这个相当于是将怎么在 monaco-editor 的菜单中添加自定义的选项列表,不需要自己写具体的逻辑,功能的提供是 monaco-editor 自带的,非常有趣。
其他的比如 语言选取、编码模式都可以用这种方式扩展。
LSP 集成、DirtyDiff 集成、TextMate 高亮集成在后面都会深入说。
集成需要涉及的东西包括注册扩展、注册数据模型、注册 command 动作库、注册状态栏。
monaco-editor 额外需要做的有编辑器配置、model 配置、model 和 state 状态拆分、DirtyDiff 和 Peek 方案、引用和跳转方案、多语言切换以及缩进等选项、textmate 高亮方案、LSP 方案等
在内核搭建起来之后,能够通过拓展的形式在视图上的某个区域注册内容,所以我们只需要写一个拓展,然后按照配置约定写上逻辑就能将编辑器集成进去了。
简单示例:
import React from 'react';import model from "./model";import { Extension } from '..';import Novus from '../../models';import Writer from "./Component";import WriterTab from "./Component/tab";import { registerStatusBar } from "@utils/statusBar";import LineWidget from "./statusBar/LineWidget";import IndentWidget from "./statusBar/IndentWidget";import EncodeWidget from "./statusBar/EncodeWidget";import EOLWidget from "./statusBar/EOLWidget";import LanguageWidget from "./statusBar/LanguageWidget";// monaco editor addonsimport initEditorPlugins from './monacoPlugins/index';import configureMonaco from './afterMonaco/index';import configureEditor from './afterEditor/index';initEditorPlugins();import { commands } from "./commands";export default (novus: Novus): Extension => {novus.bindModel(model);commandsCenter.registerCommands("writer", commands);registerStatusBar({lineWidget: { com: LineWidget },indentWidget: { com: IndentWidget },encodeWidget: { com: EncodeWidget },EOLWidget: { com: EOLWidget },LanguageWidget: { com: LanguageWidget },});return {id: 'writer',useSticky: 'writer-dealer',panel: Writer,tab: WriterTab,init: () => {// config Monaco and every editorconfigureMonaco();monaco.editor.onDidCreateEditor(configureEditor);},}}
上面的代码表示注册一个
writer
的扩展,commands 里面提供打开 tab 的方法,然后内核框架渲染的时候,会根据 tab 的类型调用这个 writer 来渲染界面。同时还注册了底部的状态栏,能够根据当前打开的文件显示辅助数据。上一篇文章已经说明了数据模型在这个项目中的用法,这个数据模型保存当前的全部打开文件以及相关操作方法:
type TabConfig = {id: string; // id 唯一标识preview: boolean; // 是否是预览状态name: string; // 文件名type: string; // 文件类型(可以是文本文件、图片、HTML 预览)file: TFile; // 文件的类型(这个在文件管理器中定义,附带文件的地址、相对地址、大小、修改事件、简单 git 信息等)language: string; // 文本文件的话,代码语言changed: boolean; // 标识当前编辑器的内容是否有改动content: string; // 暂存当前 tab 的文件内容status: number; // 文件状态modelReady: boolean; // 是否已经注册了 editor 的 modelspecialType?: string; // 是否是特殊类型,需要特殊渲染}type Tabs = {[index: string]: TTab;}export type TTabs = ImmutableObjectMixin<Tabs> & Tabs;export type TTab = ImmutableObjectMixin<TabConfig> & TabConfig;export interface IWriterState {tabs: TTabs,currentEditor: monaco.editor.IStandaloneCodeEditor,}class WriterModel extends NovusBaseModel<IWriterState> {namespace = 'writer'constructor(){super()this.state = {tabs: Immutable({}), // 使用不可变数据类型,使得深层对象改变也能触发更新状态currentEditor: null,}}actions = {// ---------- tabsnotifyTabClose: (tabId: string) => {// ...},editorChange: (editor: monaco.editor.IStandaloneCodeEditor) => {this.setState({ currentEditor: editor })},updateTab: <K extends keyof TabConfig>(tabId: string, data: Pick<TabConfig, K>) => {// ...},previewConfirm: (tabId: string) => {// ...},openTab: async (file: TFile, skipPreview: boolean = false) => {// ...let tab = generateTab(file, skipPreview);if (tab) {// ...await this.getModel('layout').actions.modifyPanels({adds: [{component: 'writer',id: fileId,data: { tabId: fileId },stackPosition: 'dealer'}],removes});}},}};export default new WriterModel();
import { getSpecialType } from "./specialEditors";import Immutable from "seamless-immutable";import { TFile, TFileRaw } from "../FileManager/model";export const commands = {gotoLine: {trigger: () => {// ...}},changeIndent: {trigger: () => {// ...}},chooseLanguage: {trigger: () => {// ...}},showSourceCode: {label: "查看源代码",trigger: () => {// ...}},openFile: {trigger: async (fileRelative: string,cb?: Function,skipPreview?: boolean) => {window._novus.models["writer"].actions.previewConfirm(fileId);// or ...await window._novus.models.layout.actions.activePanel(fileId);// or ...window._novus.models["writer"].actions.openTab(file, skipPreview);}}},createUntitledFile: {key: ["ctrl+n"],label: "新建文件",trigger: () => {// ...}}};
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/workpad-part-3)