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

前言

        前面两篇文章,我们简单讲述了 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的保姆级使用教程,大家多多支持呀~

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

nginx --- 反向代理|负载均衡 | 动静分离

目录 反向代理如何配置 1、反向代理实例一 2、反向代理实例二 ocation 指令说明 Nginx 负载均衡 负载均衡常用算法 应用场景 总结 Nginx实现动静分离 一、什么是动静分离 二、实现方案 三、配置Nginx动静分离 四、验证测试 反向代理如何配置 1、反向代理实例一 实…

css文字超出元素省略,单行、多行省略

通用CSS .box {width: 500px;border: 1px solid red;padding: 10px;line-height: 24px;} 1.单行省略 .singe-line {text-overflow: ellipsis;overflow: hidden;word-break: break-all;white-space: nowrap;}<p>单行省略</p><div class"singe-line box&qu…

tomcat8w.exe指向了别的tomcat

这种情况通常发生是因为Tomcat服务在注册表中的配置指向了错误的可执行文件路径。tomcat8w.exe是一个Windows服务配置工具&#xff0c;它用于管理Tomcat服务&#xff0c;包括设置Path to executable&#xff0c;即指向Tomcat服务实际启动的.exe文件的路径。如果Path to executa…

【LeetCode】40. 组合总和 II

组合总和 II 题目描述&#xff1a; 给定一个候选人编号的集合 candidates 和一个目标数 target &#xff0c;找出 candidates 中所有可以使数字和为 target 的组合。 candidates 中的每个数字在每个组合中只能使用 一次 。 注意&#xff1a;解集不能包含重复的组合。 示例…

HTML静态网页成品作业(HTML+CSS+JS)—— 明星EXO介绍网页(5个页面)(table布局)

&#x1f389;不定期分享源码&#xff0c;关注不丢失哦 文章目录 一、作品介绍二、作品演示三、代码目录四、网站代码HTML部分代码 五、源码获取 一、作品介绍 &#x1f3f7;️本套采用HTMLCSS&#xff0c;使用Javacsript代码实现首页图片轮播切换&#xff0c;table 切换&…

更新关于其宠物产品质量的电子学习课程

​我们受托更新关于其宠物产品质量的电子学习课程。我们决定采用流行的“Corporate Memphis”风格设计插图&#xff0c;这是一种适用于商业的友好卡通风格&#xff08;该名称来源于80年代因其亮丽的色彩和独特的项目方法而闻名的设计团体“Memphis”&#xff09;。我们选择“Co…

使用迁移助手 (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写脚本的时候获取设备,没有端口(COM3)

首先python 可以用一下代码 测试端口是不是存在 import serial.tools.list_portsports list(serial.tools.list_ports.comports()) if not ports:print("没有检测到可用的串口设备。") else:for port in ports:print(f"可用串口: {port.device}")如果提示…

C语言 | Leetcode C语言题解之第133题克隆图

题目&#xff1a; 题解&#xff1a; struct Node** visited; int* state; //数组存放结点状态 0&#xff1a;结点未创建 1&#xff1a;仅创建结点 2&#xff1a;结点已创建并已填入所有内容void bfs(struct Node* s) {if (visited[s->val] && state[s->val] 2…

【亚马逊云科技 CSDN 联合巨献】 「对话AI 构建者:从基础到应用的 LLM 全景培训」 限时免费!

&#x1f680;&#x1f31f;【亚马逊云科技 & CSDN 联合巨献】 &#x1f4da;「对话AI 构建者&#xff1a;从基础到应用的 LLM 全景培训」&#x1f525; 限时免费&#xff01; &#x1f4c6; 抓紧时间&#xff01;6月7日前注册&#xff0c;原价 399&#xff0c;现在仅需 0…

【DevOps】网站安全案例分析:真实事件中的经验与教训

目录 一、常见的网站安全事故案例 1. Equifax 数据泄露事件&#xff08;2017年&#xff09; 2. WannaCry 勒索软件攻击事件&#xff08;2017年&#xff09; 3. GitHub DDoS 攻击事件&#xff08;2018年&#xff09; 二、网站安全事件的一般分析方法 1、事件背景调查 2、…

[WWW2024]轻量数据依赖的异常检测重训练方法LARA

开篇 近日&#xff0c;由阿里云计算平台大数据基础工程技术团队主导&#xff0c;与浙江大学合作的论文《LARA: ALight and Anti-overfitting Retraining Approach for Unsupervised Time Series Anomaly Detection 》被WWW2024收录&#xff0c;该方法解决了云服务正常模式随时…

“网络战时代的国家安全:策略、技术和国际合作“

网络战时代&#xff0c;国家安全面临着前所未有的挑战&#xff0c;这要求国家在策略、技术和国际合作方面采取更为综合和先进的应对措施。以下几点概述了这一领域的关键要素&#xff1a; 策略层面&#xff1a; 1. 建立全面的网络战战略&#xff1a;国家需要一个清晰、前瞻性…

C# 判断字符串不等于空的示例

在C#中&#xff0c;要判断一个字符串是否不等于空&#xff08;即它既不是null也不是空字符串""&#xff09;&#xff0c;方法有如下几种&#xff0c;如下。 方法1 使用逻辑运算符和string.IsNullOrEmpty方法 string myString "123"; // 假设要检查的字…

WPF Treeview控件开虚拟化后定位节点

不开虚拟化&#xff0c;可以用下面的方法直接定位 <Window x:Class"WpfApplication2.MainWindow"xmlns"http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x"http://schemas.microsoft.com/winfx/2006/xaml"Title"Main…

电脑在线怎么改图片格式?3步改图片格式的操作步骤

在日常生活和工作中经常会因为不同的用途&#xff0c;需要使用不同格式的图片&#xff0c;那么如果遇到图片格式问题时&#xff0c;有什么方法能够快速在线转图片格式呢&#xff1f; 想要快速将图片格式转换成自己需要使用的格式&#xff0c;比较简单的一种方法可以使用网上的…

使用 Django 和 MQTT 构建实时数据传输应用

文章目录 什么是 MQTT&#xff1f;Django 中的 MQTT结论 在现代的 Web 应用程序开发中&#xff0c;实时数据传输变得越来越重要。MQTT&#xff08;Message Queuing Telemetry Transport&#xff09;是一种轻量级的发布/订阅消息传输协议&#xff0c;而 Django 是一个流行的 Pyt…

66、API攻防——接口安全阿里云KEYPostmanDVWS

文章目录 一、工具使用——Postman自动化测试二、安全问题——Dvws泄露&鉴权&XXE三、安全问题——阿里KEY信息泄露利用 dvws-node 一、工具使用——Postman自动化测试 二、安全问题——Dvws泄露&鉴权&XXE 路径中出现/api/&#xff0c;一般都是接口。 请求包是…

Jail管理器AppJail的使用@FreeBSD

Jail的简介 Jail是FreeBSD操作系统中一个功能强大的安全机制&#xff0c;自FreeBSD 4.X版本起便投入使用&#xff0c;并且随着系统的发展&#xff0c;其功能、效率、稳定性和安全性得到了持续的强化。 Jail基于chroot的概念&#xff0c;通过更改一系列程序的根目录&#xff0…

【面试题】创建两个线程交替打印100以内数字(一个打印偶数一个打印奇数)

阅读导航 一、问题概述二、解决思路三、代码实现四、代码优化 一、问题概述 面试官&#xff1a;C多线程了解吗&#xff1f;你给我写一下&#xff0c;起两个线程交替打印0~100的奇偶数。就是有两个线程&#xff0c;一个线程打印奇数另一个打印偶数&#xff0c;它们交替输出&…