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-ignorethis.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.jspath += 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,无值为falseconst 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();}},
    // eventCtrlSeventSave() {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. 关键参数 mapconst fileStateMap = new Map();//   切换文件 - 需要保存 stateasync 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. 读取文件内容赋给monacoconst 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.mzph.cn/web/22636.shtml

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

相关文章

LeetCode-43. 字符串相乘【数学 字符串 模拟】

LeetCode-43. 字符串相乘【数学 字符串 模拟】 题目描述&#xff1a;解题思路一&#xff1a;模拟乘法&#xff0c;两个数中每一位数相乘的时候乘上他们各自的进制数&#xff0c;之后求和。循环时&#xff0c;分别记录各自的进制数背诵版&#xff1a;解题思路三&#xff1a;0 题…

项目-双人五子棋对战:匹配模块的实现(3)

完整代码见: 邹锦辉个人所有代码: 测试仓库 - Gitee.com 模块详细讲解 功能需求 匹配就类似于大家平常玩的王者荣耀这样的匹配功能, 当玩家点击匹配之后, 就会进入到一个匹配队列, 当匹配到足够数量的玩家后, 就会进入确认页. 在这里, 我们主要实现的是1 - 1匹配功能, 首先先…

pycharm链接auto al服务器

研0提前进组&#xff0c;最近阻力需求是把一个大模型复现&#xff0c;笔者电脑18年老机子&#xff0c;无法满足相应的需求。因此租用auto dl服务器。本文记录自己使用pycharm&#xff08;专业版&#xff09;链接auto dl期间踩过的坑。 1.下载pycharm专业版 这一步不解释了&am…

逐步掌握最佳Ai Agents框架-AutoGen 九 RAG应用

在最近的几篇文章里&#xff0c;我们使用AutoGen实现了一些Demo。这篇文章&#xff0c;我们将使用AutoGen来完成RAG应用开发。 RAG应用 RAG全称"Retrieval-Augmented Generation",即检索增强生成&#xff0c;它是自然语言处理中的一项技术。这种模型结合了检索式&a…

Latex之图片排列的简单使用(以MiKTeX工具为例)

一、参考资料 Latex如何插入图片 Latex 学术撰写工具推荐&#xff08;在线、Windows、Mac、Linux&#xff09; 关于Latex并排多张图片及加入图片说明的方法 二、准备工作 1. 在线LaTex工具 Overleaf 2. 本地LaTex工具 MiKTeX 3. 测试用例 \documentclass{article} \ti…

拓展商机的金钥匙:成为SSL证书合作商的长期回报

在当今数字化浪潮中&#xff0c;网络安全已经成为企业生存和发展不可或缺的一部分。随着在线交易和数据交换的增多&#xff0c;SSL证书作为保障网站安全和增强用户信任的关键工具&#xff0c;其重要性日益凸显。成为SSL证书的合作商后&#xff0c;不仅能够立即开启新的收入来源…

解决微信小程序分享按钮不可用

问题描述 在微信小程序中点击胶囊按钮上的三个点&#xff0c;在弹出的对话框中的【分享给好友】【分享到朋友圈】按钮都属于不可用的状态&#xff0c;显示未设置。 问题截图 解决方案 在每个需要此功能的页面都需要添加此代码&#xff0c;否则就不能进行使用。 // vue3时&l…

证件照太大了怎么压缩到100k?6个软件教你快速进行压缩

证件照太大了怎么压缩到100k&#xff1f;6个软件教你快速进行压缩 压缩证件照大小通常需要使用专门的图片压缩工具或者图片编辑软件。以下是六款常用的软件&#xff0c;它们可以帮助你快速压缩证件照大小到100KB以内&#xff1a; 1.迅捷压缩&#xff1a;这是一款图片压缩工具…

【Kubernetes】k8s的调度约束(亲和与反亲和)

一、调度约束 list-watch 组件 Kubernetes 是通过 List-Watch 的机制进行每个组件的协作&#xff0c;保持数据同步的&#xff0c;每个组件之间的设计实现了解耦。 用户是通过 kubectl 根据配置文件&#xff0c;向 APIServer 发送命令&#xff0c;在 Node 节点上面建立 Pod 和…

每天的CTF小练--6.5(ascll码高级运用)

题目&#xff1a;[HUBUCTF 2022 新生赛]baby_encrypt hint&#xff1a; 781612443113954655886887407898899451044114412011257135914071455155316031651170318041861191719652013207021272183228423832485254125932643269827992924 注意查看前面的数字&#xff0c;这题不想现…

Mybatis01-初识Mybatis

简介 1、 什么是Mybatis MyBatis 是一款优秀的持久层框架; 它支持自定义 SQL、存储过程以及高级映射 MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作。 MyBatis 可以通过简单的 XML 或注解来配置和映射原始类型、接口和 Java POJO&#xff08;Plain Ol…

try…except语句

自学python如何成为大佬(目录):https://blog.csdn.net/weixin_67859959/article/details/139049996?spm1001.2014.3001.5501 在程序开发时&#xff0c;有些错误并不是每次运行都会出现。例如&#xff0c;实例01&#xff0c;只要输入的数据符合程序的要求&#xff0c;程序就可…

推荐一个免费的相亲工具

推荐一个免费的相亲工具&#xff0c;步骤如下&#xff1a; 1&#xff09;微信里面搜索公众号“光源桥”&#xff0c;并关注 2&#xff09;输入搜索条件进行搜索对象 例如下面搜索&#xff1a;

CDH服务红,查看日志发现host有问题

看host后&#xff0c;发现里面节点ip都是127.0.0.1然后全部改成对应的ip&#xff0c; 1.在/etc/hosts里面全部加上了 ip以及对应的角色名称 2然后注释了127.0.0.1 hostname 3.然后重启所有的机器agent和server&#xff0c;在重新登录&#xff0c;点击重新部署。 重启agent sy…

电子凭证3.0,助力企业实现报销自动化

在数字化浪潮汹涌澎湃的今天&#xff0c;企业对于高效、便捷、安全的财务管理需求日益凸显。传统的报销流程繁琐、耗时&#xff0c;不仅增加了企业的运营成本&#xff0c;还影响了员工的工作效率和满意度。用友BIP电子凭证3.0的发布&#xff0c;无疑为企业实现报销自动化提供了…

Linux:共享内存介绍(进程间通信)

共享内存 共享内存原理介绍共享内存系统调用接口shmget 创建共享内存段ftok 生成唯一键 key开始创建共享内存指令 ipcs -m 查看共享内存指令 ipcrm -m 删除共享内存段shmctl 控制创建的共享内存通过系统调用来删除共享内存 共享内存权限问题关联/去关联共享内存封装处理 共享内…

数据采集项目结案报告

常州嘉爵机械机床采集项目案例 项目背景 常州市嘉爵机械配件厂 响应国家政策&#xff0c;申报智能车间&#xff0c;优化管理 车间设备包括&#xff1a;发那科机床、三菱机床。 项目需求调研分析 采集设备工艺参数&#xff0c;计算设备稼动率。 车间设备情况&#xff1a; …

HarmonyOS应用开发深度指南:从基础到高级实践

1. HarmonyOS开发概述 HarmonyOS是华为推出的分布式操作系统,旨在为不同设备提供统一的体验。它支持多种编程语言,包括ArkTS、JS、C/C++和Java。开发者需要了解HarmonyOS的分布式架构,包括Ability、Service、Data Ability等核心概念。 了解HarmonyOS的分布式架构:HarmonyO…

在视频号上面怎么卖货?只需要开一个店铺即可!具体怎么操作?

大家好&#xff0c;我是电商小V 最近这段时间在视频号上面卖货的热度可以说是非常大的&#xff0c;也是创业者常常提起的话题&#xff0c;大家之所以对视频号卖货非常感兴趣那是因为视频号和抖音处于一个赛道&#xff0c;也是朝着电商的方向发展&#xff0c; 所以说大家对于腾讯…

cmake使用(01)

顶层CMakeLists.txt cmake_minimum_required (VERSION 3.5)# 配置 交叉编译 放置在 project() 命令之前# /opt/fslc-wayland/2.5.2/sysroots/aarch64-fslc-linux/usr/bin/make: error # while loading shared libraries: libdl.so.2: cannot open shared object file: # No su…