Web IDE 在线编辑器综合实践(Web IDE 技术探索 三)

news2024/10/7 10:17:00

前言

        前面两篇文章,我们简单讲述了 WebContainer/api 、Terminal 的基本使用,离完备的在线代码编辑器就差一个代码编辑了。今天通过 monaco editor ,来实现初级代码编辑功能,讲述的是整个应用的搭建,并不单独针对monaco editor的使用哈,因为Monaco editor 确实有些难度,仅在使用到的API 、功能模块上做讲解。如果大家有需要,可以留言,会考虑后期做一篇monaco的保姆级教程。

页面布局

        初始化 pnpm、vite、typescript的项目,将页面初始化为下:

 文件树

        此处的文件树,是指项目左侧的文件列表,使用ElementPlus tree 组件进行渲染,如下:

// 定义 filemenu tree data
export interface ITreeDataFile {
  id: string;
  icon?: string;
  label: string;
  suffix: string;
}
// 文件夹数据结构
export interface ITreeDataFolder {
  id: string;
  label: string;
  isFolder: boolean;
  children: ITreeDataFile[];
}
// 可能是新建文件
export interface ITreeDataIsNew {
  id: string;
  isNew: boolean;
  isFolder: boolean;
}

        针对新建文件/文件夹时,需要知道当前层级,例如我是在 根目录新建 还是在src内新建,因此,需要监听tree 的click 事件:

 /**
   * 节点点击回调 - 通过该参数实现识别当前的目录层级
   * @param data
   */
  function nodeClick(data: ITree) {
    currentNodeKey.value = data.id;
  }

        同时,在点击外部时,还需要取消目录选中:

  /**
   * cancelChecked
   */
  function cancelChecked() {
    //  .is-current 通过该类实现的当前文件激活样式
    currentNodeKey.value = "";
    treeRef.value?.setCurrentKey();
  }

事件响应

  // 折叠所有文件
  function collapseAll() {
    // 全部展开 - 可用于定位某个文件
    // Object.values(treeRef.value!.store.nodesMap).forEach((v) => v.expand())
    Object.values(treeRef.value!.store.nodesMap).forEach((v) => v.collapse());
  }

         新建文件/文件夹的核心就是blur后,使用 newFileName push到指定位置上:

  /**
   * confirm 新建文件/文件夹确认事件
   */
  function confirm() {
    removeNewItem(dataSource);
    if (!newFileName.value) return;
    // 不然,就根据当前位置,push 真实的数据到dataTree中,通过 newFileFlag.value 识别是文件还是文件夹
    const fileSuffix = newFileName.value.split(".")[1];
    const data: ITreeDataFile | ITreeDataFolder = {
      id: `${new Date().getTime()}`,
      label: newFileName.value,
      isFolder: !newFileFlag.value,
      children: [],
      icon: newFileFlag.value ? getFileIcon(fileSuffix) : "",
    };
    if (currentNodeKey.value) {
      // 如果有节点被选中,则看是文件,还是文件夹,是文件-在父级添加,是文件夹-直接在当前添加
      const currentNode = treeRef.value?.getNode(currentNodeKey.value);
      if (currentNode?.data.isFolder) {
        // 如果是文件夹,则在当前节点下添加
        treeRef.value?.append(data, currentNodeKey.value);
      } else {
        // 如果是文件,则在 Tree 中给定节点后插入一个节点
        treeRef.value?.insertAfter(data, currentNodeKey.value);
      }
    } else {
      // 如果没有节点被选中,则直接添加到根目录
      dataSource.push(data);
    }
  }

Terminal

        这块应该是简单的,参考上篇文章哈Terminal Web终端基础(Web IDE 技术探索 二)

        往后可能需要拓展多终端场景,因此设计上需要考虑周全,剩下的功能待开发时再细说。

Web Container

       这里强调下哈!Web Container的API基本都是 async / await 方式,因此,在使用时一定需要注意执行时机和等待结果!!!

        配置 WebContainer/api 跨源隔离:

headers: {
      "Cross-Origin-Embedder-Policy": "require-corp",
      "Cross-Origin-Opener-Policy": "same-origin",
    }

        WebContainer的很多事件都需要await执行,在设计上需要考虑周全,因为多处需要共享container的状态,因此我们直接使用pinia实现全局状态管理:

// Web Container 共享文件,因为 fileTree Container对象需要在其他文件中共享
import { WebContainer } from "@webcontainer/api";
import { defineStore } from "pinia";

// 第一个参数是应用程序中商店的唯一 id
export const useContainerStore = defineStore("container", {
  state: () => {
    return {
      container: <InstanceType<typeof WebContainer> | null>null,
      boot: false, // 定义容器是否启动
    };
  },

  actions: {
    // 1. bootContainer 启动容器
    async bootContainer() {
      // @ts-ignore
      this.container = await WebContainer.boot();
      this.boot = true;
    },
  },
});

        在App页面监听 boot 实现loading效果:

    <!-- loading -->
    <div class="loading" v-if="!containerStore.boot">
      <div class="loader"></div>
      <span>Wait for the web container to boot...</span>
    </div>

         在Container中,需要频繁监听输出流,统一做事件封装处理:

    // 封装统一的输出函数 - 监听容器输出
    async output(stdout: WebContainerProcess, fun: voidFun) {
      stdout.output.pipeTo(
        new WritableStream({
          write(data) {
            fun(data);
          },
        })
      );
    },

        封装统一的命令执行函数,提供给terminal执行:

    // 3. 执行 terminal 命令
    async runTerminal(cmd: string, fun: voidFun) {
      if (!this.container) return;
      const command = cmd.split(" "); // 这里是按空格进行分割
      const state = await this.container.spawn(command[0], command.slice(1));
      // 如果是下载命令,则需要获取状态码
      if (command[1] === "install" || command[1] === "i") {
        const code = await state.exit;
        if (code === 0) // ... 执行相关代码
      }
      // 不管成功还是失败,都输出
      this.output(state, fun);
    },

         在terminal 中,监听 command事件,直接传递到 container中执行,通过回传参数实现terminal的终端显示:

function command(
  cmdKey: string,
  command: string,
  success: voidFun,
  failed: voidFun,
  name: string
) {
  containerStore.runTerminal(command, (content) => {
    success({ content });
    console.log(name, "执行command:", command);
  });

 

文件菜单与FileSystemTree

        在逻辑上,是先有的文件,才去执行 mounted 操作,因此,当我新建文件的时候,都去调用 mounted 。在初始化时,我们提供三种基本的项目结构:mockVueProject、mockNodeProject、mockReactProject,用Vue 举例哈,其他类似,具体的FileSystemTree可以参考我的上篇文章File System Tree:

 读取成树结构

        通过以上的树结构,读取成El-tree 组件的数据源,应该不是难事,递归实现即可,在上一篇中已经实现了,但是注意哈,需要在结束时,进行排序,先排目录结构 isFolder,在排name属性,这样就是与vscode类似的效果:

新增文件

  /**
   *  将新建的文件/文件夹挂载到Web Container File System Tree 中
   */
  function mountedFileSystemTree() {
    tryCatch(async () => {
      let path = "/";
      // 如果有选中节点,则需要处理选中节点的路径问题
      if (currentNodeKey.value) {
        // 需要在这里加上父级 - 这里还需要判断激活的是文件还是文件夹
        const currentNode = treeRef.value?.getNode(currentNodeKey.value); // 当前激活节点
        const dataMap = JSON.parse(JSON.stringify(dataSource)) as TFullData;
        let fullpath = <string[]>getFullPath(dataMap, currentNodeKey.value);
        if (currentNode?.data.isFolder) path += fullpath?.join("/");
        else {
          // 删除最后一项
          fullpath = fullpath?.slice(0, -1);
          path += fullpath?.join("/");
        }
        path += "/";
      }
      // 如果没有选中节点,则直接拼接文件名称,放置到根路径下即可
      // 例如 /vite.config.js
      path += newFileName.value;
      console.log("### path ==> ", path);
      newFileFlag.value
        ? containerStore.addFile(path)
        : containerStore.addFolder(path);
    });
  }

Monaco Editor

        上诉简单介绍了整个系统的文件系统、container与termina的关系与核心实现,并通过新增文件/文件夹实现Web Container FileSystemTree的文件挂载、写入、创建文件夹,但是还是没有实质性的文件内容编辑,现在通过monaco editor 插件实现文件内容编辑,monaco确实是有难度的,本文不过及底层原理,仅在应用层面上做叙述。

create

// use monaco editor
import { editor } from "monaco-editor";

  /**
   * init monaco
   */
  function initMonaco(selector: string) {
    const dom = document.querySelector(selector) as HTMLElement;
    editor.create(dom, {
      value: "function x() {\n\tconsole.log('Hello world!');\t\n}",
      language: "javascript",
    });
  }

        但是这样是要报错的:Uncaught Error: Unexpected usage,详见ISSUES,解决办法:

// 解决 monaco editor 报错 Uncaught Error: Unexpected usage:

import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";

export function fixEnvError() {
  window.MonacoEnvironment = {
    getWorker(_, label) {
      if (label === "json") {
        return new jsonWorker();
      }
      if (label === "css" || label === "scss" || label === "less") {
        return new cssWorker();
      }
      if (label === "html" || label === "handlebars" || label === "razor") {
        return new htmlWorker();
      }
      if (label === "typescript" || label === "javascript") {
        return new tsWorker();
      }
      return new editorWorker();
    },
  };
}

         create 之前,先调用 fixEnvError 方法,导入需要的worker文件:

  function initMonaco(selector: string) {
    fixEnvError();
    const dom = document.querySelector(selector) as HTMLElement;
    editor.create(dom, {
      value: "function x() {\n\tconsole.log('Hello world!');\t\n}",
      language: "javascript",
    });
  }

动态设置属性

    /** 为了避免Vue响应式对编辑器的影响,使用toRaw转成普通对象 */
    getEditor() {
      return toRaw(this.editor);
    },

    /** 设置编辑器的值 + 设置语言模型 */
    setValue(value: string, language: string) {
      this.getEditor()?.setValue(value);
      // 1. 文件后缀与语言模型匹配
      const languageModel = this.languages.find((item) => {
        return item.extensions?.includes(`.${language}`);
      });
      editor.setModelLanguage(
        this.getEditor()?.getModel()!,
        languageModel?.id || ""
      );
    },


    /** 获取编辑器的值 */
    getValue() {
      return this.getEditor()?.getValue();
    },

         在菜单点击时,获取文件内容,进行editor赋值,处理上,直接使用 this.editor.setValue会导致页面卡死,转成普通对象,避免响应式的影响,同时,在设置值上,需要动态调整语言类型,不然不会高亮显示:

监听保存事件

        通过保存事件,实现真正的文件存储:

    onKeyDownHandle(e: any) {
      // 通过keycode/ctrlKey/shiftKey/altKey 的状态唯一确定一个事件- 有值为true,无值为false
      const eventMap: TKeyMap<string, voidFun> = {
        "49/true/false/false": () => {
          console.log("Ctrl S");
        },
      };
      const key = `${e.keyCode}/${e.ctrlKey}/${e.shiftKey}/${e.altKey}`;
      if (eventMap[key]) {
        eventMap[key]();
        e.browserEvent.preventDefault();
      }
    },
    // eventCtrlS
    eventSave() {
      const containerStore = useContainerStore();
      const fileMenuStore = useFileMenuStore();
      // 1. 获取当前编辑器的内容
      const contents = this.getEditor()?.getValue() as string;
      // 2. 调用 container 的 saveFile 方法
      containerStore.writeFile(fileMenuStore.filePath, contents);
    },

针对依赖下载的优化

// 特殊的命令需要单独处理
    if (installCmdList.includes(command)) {
      // 执行下载依赖,应该用回显模式
      success(flash);
      containerStore.runTerminal(command, (content) => {
        console.log(content, content.includes("Done"));
        if (content.includes("Done")) {
          flash.finish();
          // 把最后的信息输出
          success({ content: "✅ " + content });
        } else flash.flush(content);
      });
    }

        使用回显模式展示依赖下载,会更加合适

多tab页模式 

        tab 切换的和核心,是通过记录editor 的状态及语言模型实现的:

  // 1. 关键参数 map
  const fileStateMap = new Map();

     //   切换文件 - 需要保存 state
    async switchFile(index: number) {
      const fileSuffix = this.fileList[index].suffix;
      // 2. 跳转到指定文件
      this.currentFile = index;

      // 3. 看看跳转后文件时候有 model 有的话直接使用,没有就创建新的
      const file = this.fileStateMap.get(this.getCurrentFileID());

      if (file && file.model) {
        this.setModel(toRaw(file.model));
        this.restoreViewState(toRaw(file.state)); // 恢复文件的编辑状态
      } else {
        // 2. 读取文件内容赋给monaco
        const contents = await this.containerStore.readFile(
          this.fileMenuStore.filePath
        );

        const model = this.createModel(
          contents || "",
          this.getLanguageModel(fileSuffix)?.id as string
        );

        this.setModel(model);

        this.fileStateMap.set(this.getCurrentFileID(), {
          model: this.getModel(),
          state: this.saveViewState(),
        });
      }
      this.getEditor()?.focus();
    },

        关闭则是通过监听事件实现:

window.addEventListener("mouseup", (e: MouseEvent) => {
  const span = e.target as HTMLElement;
  if (e.button === 1 && span.getAttribute("data-key") === "closeFileButton") {
    // 1. 先保存
    monacoStore.eventSave();
    // 2. 关闭文件
    const index = span.getAttribute("data-index");
    monacoStore.deleteFile(Number(index));
  }
});

        在你关闭的是其他tab页的时候,涉及到不同的model获取内容,因此,需要先跳转到需要关闭的页面,获取完内容,再跳转回正常的页面,类似VScode,不然你获取的内容是不对的哈!

总结

        通过WebContainer、Terminal、MonacoEditor的结合,初步实现了Web IDE在线编辑器的开发,整体实现过程还是比较顺利的,但是monaco的应用太痛苦了,全英文,官网API还是.d.ts类型文件!

        不过不得不说,monaco的强大之处,远不止这么简单,支持git冲突模型对比:

        利用yjs 原生支持 y- monaco:

         大家感兴趣,后续会考虑整理Monaco Editor的保姆级使用教程,大家多多支持呀~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1792222.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

鸿蒙轻内核M核源码分析系列六 任务及任务调度(2)任务模块

任务是操作系统一个重要的概念&#xff0c;是竞争系统资源的最小运行单元。任务可以使用或等待CPU、使用内存空间等系统资源&#xff0c;并独立于其它任务运行。鸿蒙轻内核的任务模块可以给用户提供多个任务&#xff0c;实现任务间的切换&#xff0c;帮助用户管理业务程序流程。…

设计模式-工厂方法(创建型)

创建型-工厂方法 简单工厂 将被创建的对象称为“产品”&#xff0c;将生产“产品”对象称为“工厂”&#xff1b;如果创建的产品不多&#xff0c;且不需要生产新的产品&#xff0c;那么只需要一个工厂就可以&#xff0c;这种模式叫做“简单工厂”&#xff0c;它不属于23中设计…

【PL理论】(5) F#:递归类型 | Immutability 特性(F#中值一旦定义就不会改变)

&#x1f4ad; 写在前面&#xff1a;本文旨在探讨不可变数据结构在 F# 编程中的应用&#xff0c;特别是如何利用递归记录类型来表示和操作数值表达式。通过定义存储整数的二叉树和数值表达式的类型&#xff0c;我们将展示不可变性如何简化程序的理解和维护。文章将对比 F# 与命…

LeetCode刷题之HOT100之搜索旋转排序数组

2024/6/2 雨一直下&#xff0c;一个上午都在床上趴着看完了《百年孤独》&#xff0c;撑伞去吃了个饭&#xff0c;又回到了宿舍。打开许久未开的老电脑&#xff0c;准备做题了。《百年孤独》讲了什么&#xff0c;想表达什么&#xff0c;想给读者留下什么&#xff0c;我不知道&am…

IP黑名单与IP白名单是什么?

在IP代理使用中&#xff0c;我们经常听到黑名单与白名单两个名词&#xff0c;它们不仅提供了强大的防御机制&#xff0c;还可以灵活应对不同的安全威胁。本文将详细探讨IP黑名单和白名单在网络安全中的双重屏障作用。 一、IP黑名单和白名单定义 IP黑名单与IP白名单是网络安全中…

2024会声会影全新旗舰版,下载体验!

在当今数字时代&#xff0c;视频内容已成为最受欢迎的媒介之一。无论是个人娱乐、教育还是商业推广&#xff0c;优秀的视频制作都是吸引观众的关键。为了满足广大用户对高质量视频制作软件的需求&#xff0c;我们隆重推出了会声会影2024最新旗舰版。这款软件不仅集成了最先进的…

六、SQL执行器的定义和实现

之前的Sql执行都是耦合在SqlSession里的&#xff0c;现在要对这部分进行解耦和重构&#xff0c;引申出执行器&#xff0c;查了相关概念&#xff0c;Executor执行器可以说是定义了一个个的SQL的执行流程&#xff0c;用查询方法举例&#xff0c;大概一下几步&#xff1a; 1.获取数…

基于SOA海鸥优化算法的三维曲面最高点搜索matlab仿真

目录 1.程序功能描述 2.测试软件版本以及运行结果展示 3.核心程序 4.本算法原理 5.完整程序 1.程序功能描述 基于SOA海鸥优化算法的三维曲面最高点搜索matlab仿真&#xff0c;输出收敛曲线以及三维曲面最高点搜索结果。 2.测试软件版本以及运行结果展示 MATLAB2022A版本…

OPPO 文件传输 - 将文件从 OPPO 手机传输到 PC 的 5 种方法

OPPO手机以其出色的拍照功能而闻名&#xff0c;尤其是新推出的OPPO Find X2系列&#xff0c;它配备了高清前置镜头和超夜景模式&#xff0c;让您轻松拍出精彩瞬间。当您需要将这些照片或其他文件从OPPO手机传输到PC时&#xff0c;以下是五种简便的方法。 第 1 部分&#xff…

掘金AI商战宝典-高阶班:如何用AI制作视频(11节视频课)

课程下载&#xff1a;掘金AI商战宝典-高阶班&#xff1a;如何用AI制作视频(11节视频课)-课程网盘链接提取码下载.txt资源-CSDN文库 更多资源下载&#xff1a;关注我。 课程目录&#xff1a; 1-第一讲用AI自动做视频(上)_1.mp4 2-第二讲用AI自动做视频(中)_1.mp4 3-第四讲A…

你真的理解补码了吗?

下面来看三句话&#xff1a; &#xff08;1&#xff09;一个数的补码等于原码取反加1 &#xff08;2&#xff09;减去一个数等于加这个数的补码 &#xff08;3&#xff09;一个数的反码就是这个数原码的每一位都取反 学过补码的同学应该都听过类似的表述&#xff0c;如果你…

使用迁移助手 (SSMA for Oracle) 将Oracle19c数据库迁移到SQL Server2022

如何使用适用于 Oracle 的 SQL Server 迁移助手Microsoft SQL Server Migration Assistant for Oracle (SSMA for Oracle) 将 Oracle 数据库迁移到 SQL Server Microsoft SQL Server Migration Assistant (SSMA) for Oracle is a tool to automate migration from Oracle data…

python数据分析——模型诊断

参考资料&#xff1a;活用pandas库 创建模型是持续性活动。当向模型中添加或删除变量时&#xff0c;需要设法比较模型&#xff0c;并需要统一的方法衡量模型的性能。 1、残差 模型的残差指实际观测值与模型估计值之差。 # 导入pandas库 import pandas as pd # 读取数据集 hou…

学Python,看一篇就够

学Python&#xff0c;看一篇就够 python基础注释变量标识符命名规则使用变量认识bugDebug工具打断点 数据类型输出转义字符输入输入语法输入的特点 转换数据类型pycharm交互运算符的分类赋值运算符复合赋值运算符比较运算符逻辑运算符拓展 条件语句单分支语法多分支语法拓展 if…

JavaScript 学习笔记 总结

回顾&#xff1a; Web页面标准 页面结构&#xff1a;HTML4、HTML5页面外观和布局&#xff1a;CSS页面行为&#xff1a;JavaScript强调三者的分离前后端分离开发模式 响应式设计Bootstrap框架入门 Bootstrap总结 基础 下载和使用基础样式&#xff1a;文本样式、图片样式、表格…

模式识别涉及的常用算法

一、线性回归 1.算法执行流程&#xff1a; 算法的执行流程可以简述如下&#xff1a; 导入必要的库&#xff1a; 导入NumPy库&#xff0c;用于数值计算。导入Matplotlib库&#xff0c;用于数据可视化。导入Pandas库&#xff0c;用于数据处理&#xff08;尽管在这个例子中&#…

SpringBoot定时任务+Quartz 动态调度

1、分部解释 2、完整代码 3、SpringBoot定时任务Quartz 1、动态定时任务&#xff1a; 动态定时任务&#xff0c;即定时任务的动态调度&#xff0c;可根据需求自由的进行任务的生成、暂停、恢复、删除和更新操作。Quartz本身没有提供动态调度的功能,需要自己根据相关的API开发。…

Nvidia Jetson/Orin +FPGA+AI大算力边缘计算盒子:美团小袋自动配送车

大型电商公司美团已选用NVIDIA Jetson AGX Xavier 平台&#xff0c;作为无人配送机器人核心AI算力。 美团点评是全球大型的按需食品配送公司&#xff0c;结合了Uber Eats、Yelp和Groupon的商业模式&#xff0c;与超过40万家本地企业开展合作。他们推出了小袋自动配送车&#…

Hive3.1.2分区与排序(内置函数)

Hive3.1.2分区与排序&#xff08;内置函数&#xff09; 1、Hive分区(十分重要&#xff01;&#xff01;) 分区的目的&#xff1a;避免全表扫描&#xff0c;加快查询速度&#xff01; 在大数据中&#xff0c;最常见的一种思想就是分治&#xff0c;我们可以把大的文件切割划分成…

【InternLM实战营第二期笔记】05:LMDeploy 量化部署 LLM 实践

文章目录 课程背景常见部署方法LMDeploy安装、部署、量化量化默认比例 KV cachecache-max-entry-count0.5cache-max-entry-count0.014bit 量化 Serve a model启动服务链接 API 服务器网页客户端访问服务器 API 代码集成Python 代码运行 1.8B 模型向 TurboMind 后端传递参数 拓展…