vue2中,codemirror编辑器的使用

news2025/7/19 19:14:19

交互说明 

在编辑器中输入{时,会自动弹出选项弹窗,然后可以选值插入。

代码

父组件

<variable-editor
        v-model="content"
        :variables="variables"
        placeholder="请输入模板内容..."
        @blur="handleBlur"
/>

data() {
    return {
      content: "这是一个示例 {user.name}",
      variables: [
        {
          id: "user",
          label: "user",
          type: "object",
          children: [
            { id: "user.name", label: "name", type: "string" },
            { id: "user.age", label: "age", type: "number" },
          ],
        },
        {
          id: "items",
          label: "items",
          type: "array<object>",
          children: [
            { id: "items.title", label: "title", type: "string" },
            { id: "items.price", label: "price", type: "number" },
          ],
        },
      ],
      
    };
  },

handleBlur(val) {
      console.log("编辑器内容已更新:", val);
},

子组件 

<template>
  <div class="variable-editor">
    <div ref="editorRef" class="editor-container"></div>
    <el-popover
      v-if="variables && variables.length > 0"
      ref="popover"
      placement="left-start"
      :value="popoverOpen"
      :visible-arrow="false"
      trigger="manual"
      @after-enter="handleAfterOpen"
    >
      <div
        class="tree-wrap my-variable-popover"
        tabindex="-1"
        @keydown="handleKeyDown"
        @keydown.capture="handleKeyDownCapture"
        ref="treeRef"
      >
        <el-tree
          :data="variables"
          :props="defaultProps"
          default-expand-all
          :highlight-current="true"
          :current-node-key="selectedKeys[0]"
          @current-change="handleCurrentChange"
          @node-click="handleVariableInsert"
          ref="tree"
          node-key="id"
        >
          <div slot-scope="{ node, data }" class="flex-row-center">
            <i v-if="getTypeIcon(data)" :class="getTypeIcon(data)"></i>
            <span class="ml-1">{{ node.label }}</span>
          </div>
        </el-tree>
      </div>
      <span slot="reference" ref="anchorRef" class="anchor-point"></span>
    </el-popover>
  </div>
</template>

<script>
import {
  EditorView,
  ViewPlugin,
  placeholder,
  Decoration,
  keymap,
} from "@codemirror/view";
import { EditorState, RangeSetBuilder, StateEffect } from "@codemirror/state";
import { defaultKeymap, insertNewlineAndIndent } from "@codemirror/commands";

// 扁平化树结构
const flattenTree = (nodes, result = []) => {
  for (const node of nodes) {
    result.push({ key: node.id, title: node.label });
    if (node.children) {
      flattenTree(node.children, result);
    }
  }
  return result;
};

export default {
  name: "VariableEditor",
  props: {
    value: {
      type: String,
      default: "",
    },
    variables: {
      type: Array,
      default: () => [],
    },
    placeholder: {
      type: String,
      default: "请输入内容...",
    },
  },
  data() {
    return {
      popoverOpen: false,
      selectedKeys: [],
      editorView: null,
      lastCursorPos: null,
      flattenedTree: [],
      defaultProps: {
        children: "children",
        label: "label",
      },
      // 类型图标映射
      typeIcons: {
        string: "el-icon-document",
        number: "el-icon-tickets",
        boolean: "el-icon-switch-button",
        object: "el-icon-folder",
        "array<object>": "el-icon-collection",
      },
    };
  },
  computed: {
    currentIndex() {
      return this.flattenedTree.findIndex(
        (node) => node.key === this.selectedKeys[0]
      );
    },
  },
  mounted() {
    this.flattenedTree = flattenTree(this.variables);
    this.initEditor();
  },
  beforeDestroy() {
    if (this.editorView) {
      this.editorView.destroy();
    }
  },
  watch: {
    variables: {
      handler(newVal) {
        this.flattenedTree = flattenTree(newVal);
        if (this.editorView) {
          // 重新配置编辑器以更新插件
          this.editorView.dispatch({
            effects: StateEffect.reconfigure.of(this.createExtensions()),
          });
        }
      },
      deep: true,
    },
    value(newVal) {
      if (this.editorView && newVal !== this.editorView.state.doc.toString()) {
        this.editorView.dispatch({
          changes: {
            from: 0,
            to: this.editorView.state.doc.length,
            insert: newVal,
          },
        });
      }
    },
    popoverOpen(val) {
      if (val && this.flattenedTree.length > 0) {
        this.selectedKeys = [this.flattenedTree[0].key];
        this.$nextTick(() => {
          if (this.$refs.tree) {
            this.$refs.tree.setCurrentKey(this.selectedKeys[0]);
          }
        });
      }
    },
  },
  methods: {
    getTypeIcon(data) {
      return this.typeIcons[data.type] || this.typeIcons.string;
    },
    initEditor() {
      if (!this.$refs.editorRef) return;

      this.editorView = new EditorView({
        doc: this.value,
        parent: this.$refs.editorRef,
        extensions: this.createExtensions(),
      });

      // 添加失焦事件
      this.$refs.editorRef.addEventListener("blur", this.onEditorBlur);
    },
    createExtensions() {
      return [
        placeholder(this.placeholder || "请输入内容..."),
        EditorView.editable.of(true),
        EditorView.lineWrapping,
        keymap.of([
          ...defaultKeymap,
          { key: "Enter", run: insertNewlineAndIndent },
        ]),
        EditorState.languageData.of(() => {
          return [{ autocomplete: () => [] }];
        }),
        this.createUpdateListener(),
        this.createVariablePlugin(),
        this.createInterpolationPlugin(this.variables),
      ];
    },
    createUpdateListener() {
      return EditorView.updateListener.of((update) => {
        if (update.docChanged) {
          // const content = update.state.doc.toString();
          // 不要在每次更改时都触发,而是在失焦时触发
        }
      });
    },
    createVariablePlugin() {
      const self = this;
      return ViewPlugin.fromClass(
        class {
          constructor(view) {
            this.view = view;
          }

          update(update) {
            if (update.docChanged || update.selectionSet) {
              const pos = update.state.selection.main.head;
              const doc = update.state.doc.toString();

              // 只有当光标位置真正变化时才更新
              if (self.lastCursorPos !== pos) {
                self.lastCursorPos = pos;
                // 延迟更新 Popover 位置
                setTimeout(() => {
                  self.$refs.popover &&
                    self.$refs.popover.$el &&
                    self.$refs.popover.updatePopper();
                }, 10);
              }

              // 1. 正则查找所有的 {xxx}
              const regex = /\{(.*?)\}/g;
              let match;
              let inInterpolation = false;

              while ((match = regex.exec(doc)) !== null) {
                const start = match.index;
                const end = start + match[0].length;

                if (pos > start && pos < end) {
                  // 光标在插值表达式内
                  inInterpolation = true;
                  setTimeout(() => {
                    const coords = this.view.coordsAtPos(pos);
                    const editorRect = this.view.dom.getBoundingClientRect();

                    if (coords) {
                      self.$refs.anchorRef.style.position = "absolute";
                      self.$refs.anchorRef.style.left = `${
                        coords.left - editorRect.left - 10
                      }px`;
                      self.$refs.anchorRef.style.top = `${
                        coords.top - editorRect.top
                      }px`;
                      self.$refs.anchorRef.dataset.start = start;
                      self.$refs.anchorRef.dataset.end = end;
                      self.popoverOpen = true;
                    }
                  }, 0);

                  break;
                }
              }

              if (!inInterpolation) {
                // 检测输入 { 的情况
                const prev = update.state.sliceDoc(pos - 1, pos);
                if (prev === "{") {
                  setTimeout(() => {
                    const coords = this.view.coordsAtPos(pos);
                    const editorRect = this.view.dom.getBoundingClientRect();
                    if (coords) {
                      self.$refs.anchorRef.style.position = "absolute";
                      self.$refs.anchorRef.style.left = `${
                        coords.left - editorRect.left - 10
                      }px`;
                      self.$refs.anchorRef.style.top = `${
                        coords.top - editorRect.top
                      }px`;
                      self.$refs.anchorRef.dataset.start = pos;
                      self.$refs.anchorRef.dataset.end = pos;
                      self.popoverOpen = true;
                    }
                  }, 0);
                } else {
                  self.popoverOpen = false;
                }
              }
            }
          }
        }
      );
    },
    createInterpolationPlugin(variables) {
      const self = this;
      return ViewPlugin.fromClass(
        class {
          constructor(view) {
            this.decorations = this.buildDecorations(view);
          }

          update(update) {
            if (update.docChanged || update.viewportChanged) {
              this.decorations = this.buildDecorations(update.view);
            }
          }

          buildDecorations(view) {
            const builder = new RangeSetBuilder();
            const doc = view.state.doc;
            const text = doc.toString();
            const regex = /\{(.*?)\}/g;
            let match;

            while ((match = regex.exec(text)) !== null) {
              const [full, expr] = match;
              const start = match.index;
              const end = start + full.length;

              const isValid = self.validatePath(variables, expr.trim());
              const deco = Decoration.mark({
                class: isValid
                  ? "cm-decoration-interpolation-valid"
                  : "cm-decoration-interpolation-invalid",
              });

              builder.add(start, end, deco);
            }

            return builder.finish();
          }
        },
        {
          decorations: (v) => v.decorations,
        }
      );
    },
    validatePath(schema, rawPath) {
      const segments = rawPath.replace(/\[(\d+)\]/g, "[$1]").split(".");

      // 递归匹配
      function match(nodes, index) {
        if (index >= segments.length) return true;

        const currentKey = segments[index];

        for (const node of nodes) {
          const { label: title, type, children } = node;

          // 匹配数组字段,如 abc[0]
          if (/\[\d+\]$/.test(currentKey)) {
            const name = currentKey.replace(/\[\d+\]$/, "");
            if (title === name && type === "array<object>" && children) {
              return match(children, index + 1);
            }
          }

          // 匹配普通字段
          if (title === currentKey) {
            if ((type === "object" || type === "array<object>") && children) {
              return match(children, index + 1);
            }
            // 如果不是object类型,且已经是最后一个字段
            return index === segments.length - 1;
          }
        }

        return false;
      }

      return match(schema, 0);
    },
    handleAfterOpen() {
      if (this.$refs.treeRef) {
        this.$refs.treeRef.focus();
      }
    },
    handleCurrentChange(data) {
      if (data) {
        this.selectedKeys = [data.id];
      }
    },
    handleVariableInsert(data) {
      const key = data.id;
      this.selectedKeys = [key];

      const view = this.editorView;
      if (!view) return;

      const state = view.state;
      const pos = state.selection.main.head;
      const doc = state.doc.toString();

      let insertText = `{${key}}`;
      let targetFrom = pos;
      let targetTo = pos;
      let foundInBraces = false;

      // 检查光标是否在 {...} 内部
      const regex = /\{[^}]*\}/g;
      let match;
      while ((match = regex.exec(doc)) !== null) {
        const [full] = match;
        const start = match.index;
        const end = start + full.length;
        if (pos > start && pos < end) {
          targetFrom = start;
          targetTo = end;
          foundInBraces = true;
          break;
        }
      }

      // 如果不在 {...} 中,但光标前是 `{`,只插入 `${key}}`,不要加多一个 `{`
      if (!foundInBraces && doc[pos - 1] === "{") {
        targetFrom = pos;
        insertText = `${key}}`; // 前面已经有 {,只补后半段
      }

      const transaction = state.update({
        changes: {
          from: targetFrom,
          to: targetTo,
          insert: insertText,
        },
        selection: { anchor: targetFrom + insertText.length },
      });

      view.dispatch(transaction);
      view.focus();
      this.popoverOpen = false;
    },
    onEditorBlur(e) {
      const related = e.relatedTarget;

      // 如果焦点转移到了 Popover 内部,则不处理 blur
      if (related && related.closest(".my-variable-popover")) {
        return;
      }

      const view = this.editorView;
      if (view) {
        this.$emit("input", view.state.doc.toString());
        this.$emit("blur");
      }
    },
    handleKeyDownCapture(e) {
      if (!["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) {
        e.stopPropagation();
      }
    },
    handleKeyDown(e) {
      if (!["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) return;

      if (e.key === "ArrowDown") {
        let nextKey;
        if (this.currentIndex < this.flattenedTree.length - 1) {
          nextKey = this.flattenedTree[this.currentIndex + 1].key;
        } else {
          nextKey = this.flattenedTree[0].key;
        }
        this.selectedKeys = [nextKey];
        this.$refs.tree.setCurrentKey(nextKey);
      } else if (e.key === "ArrowUp") {
        let prevKey;
        if (this.currentIndex > 0) {
          prevKey = this.flattenedTree[this.currentIndex - 1].key;
        } else {
          prevKey = this.flattenedTree[this.flattenedTree.length - 1].key;
        }
        this.selectedKeys = [prevKey];
        this.$refs.tree.setCurrentKey(prevKey);
      } else if (e.key === "Enter" && this.selectedKeys[0]) {
        // 查找对应的节点数据
        const findNodeData = (key, nodes) => {
          for (const node of nodes) {
            if (node.id === key) return node;
            if (node.children) {
              const found = findNodeData(key, node.children);
              if (found) return found;
            }
          }
          return null;
        };

        const nodeData = findNodeData(this.selectedKeys[0], this.variables);
        if (nodeData) {
          this.handleVariableInsert(nodeData);
        }
      }
    },
  },
};
</script>

<style scoped>
.variable-editor {
  position: relative;
  width: 100%;
}

.editor-container {
  width: 100%;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  min-height: 150px;
  overflow: hidden;
  transition: border-color 0.2s, box-shadow 0.2s;
}

/* CodeMirror 6 编辑器样式 */
:global(.cm-editor) {
  height: 150px !important;
  min-height: 150px !important;
  overflow-y: auto;
}

/* 编辑器获取焦点时的样式 */
:global(.cm-editor.cm-focused) {
  outline: none;
}

/* 使用更具体的选择器确保只有一层边框高亮 */
.editor-container:focus-within {
  border-color: #409eff !important;
}

.anchor-point {
  position: absolute;
  z-index: 10;
}

.tree-wrap {
  min-width: 200px;
}

.flex-row-center {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
}

.ml-1 {
  margin-left: 4px;
}

/* 添加到全局样式中 */
:global(.cm-decoration-interpolation-valid) {
  color: #409eff;
  background-color: rgba(64, 158, 255, 0.1);
}

:global(.cm-decoration-interpolation-invalid) {
  color: #f56c6c;
  background-color: rgba(245, 108, 108, 0.1);
  text-decoration: wavy underline #f56c6c;
}
</style>

依赖安装 

npm install @codemirror/state @codemirror/view @codemirror/commands

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

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

相关文章

FastAPI与MongoDB分片集群:异步数据路由与聚合优化

title: FastAPI与MongoDB分片集群:异步数据路由与聚合优化 date: 2025/05/26 16:04:31 updated: 2025/05/26 16:04:31 author: cmdragon excerpt: FastAPI与MongoDB分片集群集成实战探讨了分片集群的核心概念、Motor驱动配置技巧、分片数据路由策略、聚合管道高级应用、分片…

Perl单元测试实战指南:从Test::Class入门到精通的完整方案

阅读原文 前言:为什么Perl开发者需要重视单元测试? "这段代码昨天还能运行,今天就出问题了!"——这可能是每位Perl开发者都经历过的噩梦。在没有充分测试覆盖的情况下,即使是微小的改动也可能导致系统崩溃。单元测试正是解决这一痛点的最佳实践,它能帮助我们在…

强大的免费工具,集合了30+功能

今天给大家分享一款免费的绿色办公软件&#xff0c;它涵盖了自动任务、系统工具、文件工具、PDF 工具、OCR 图文识别、文字处理、电子表格这七个模块&#xff0c;多达 30 余项实用功能&#xff0c;堪称办公利器。 作者开发这款软件的初衷是为了解决日常办公中常见的痛点问题&am…

从0开始学习R语言--Day11--主成分分析

主成分分析&#xff08;PCA&#xff09; PCA是一种降维技术&#xff0c;它把一堆相关的变量&#xff08;比如身高、体重、年龄&#xff09;转换成少数几个不相关的新变量&#xff08;叫“主成分”&#xff09;&#xff0c;这些新变量能最大程度保留原始数据的信息。 核心理念 …

AI辅助写作 从提笔难到高效创作的智能升级

你是否经历过面对空白文档头脑空白的绝望&#xff1f;是否为整理实验数据通宵达旦&#xff1f;在这个信息爆炸的时代&#xff0c;一种新型写作方式正悄悄改变知识工作者的创作模式—AI辅助写作。这种技术既不像科幻作品里的自动生成机器人&#xff0c;也非简单的文字模板&#…

C++23中std::span和std::basic_string_view可平凡复制提案解析

文章目录 一、引言二、相关概念解释2.1 平凡复制&#xff08;Trivially Copyable&#xff09;2.2 std::span2.3 std::basic_string_view 三、std::span和std::basic_string_view的应用场景3.1 std::span的应用场景3.2 std::basic_string_view的应用场景 四、P2251R1提案对std::…

[yolov11改进系列]基于yolov11引入感受野注意力卷积RFAConv的python源码+训练源码

[RFAConv介绍] 1、RFAConv 在传统卷积操作中&#xff0c;每个感受野都使用相同的卷积核参数&#xff0c;无法区分不同位置的信息差异&#xff0c;这都限制了网络性能。此外&#xff0c;由于空间注意力以及现有空间注意力机制的局限性&#xff0c;虽然能够突出关键特征&#xf…

Springboot引入Spring Cloud for AWS的配置中心(Parameter Store和Secrets)

问题 现在手上有一个老Spring2.5.15项目&#xff0c;需要使用AWS Parameter Store作为配置中心服务。 思路 引入这个Spring版本对应的Spring Cloud&#xff0c;然后再引入Spring Cloud AWS相关组件。然后&#xff0c;在AWS云上面准备好配置&#xff0c;然后&#xff0c;启动…

打破云平台壁垒支持多层级JSON生成的MQTT网关技术解析

工业智能网关的上行通信以MQTT协议为核心&#xff0c;但在实际应用中&#xff0c;企业往往需要将数据同时或分场景接入多个公有云平台&#xff08;如华为云IoT、阿里云IoT、亚马逊AWS IoT&#xff09;&#xff0c;甚至私有化部署的第三方平台。为实现这一目标&#xff0c;网关需…

SAAS架构设计2-流程图-用户与租户之间对应关系图

在SAAS&#xff08;Software as a Service&#xff0c;软件即服务&#xff09;结构中&#xff0c;用户与租户之间的关系可以通过一对一和多对多两种方式来定义。这两种关系模式各自有着不同的应用场景和特点。 用户和租户的关系&#xff08;一对一&#xff09; 一对一关系 在这…

TypeScript入门到精通

学习ts之前&#xff0c;我们首先了解一下我们为什么要学ts,ts是什么&#xff1f;ts比js有不同呢&#xff1f; TypeScript 是 JavaScript 的一个超集&#xff0c;是由微软开发的自由和开源的编程语言&#xff0c;支持 ECMAScript 6 标准&#xff08;ES6 教程&#xff09;。在 Ja…

三、Docker目录挂载、卷映射、网络

目录挂载 如果主机目录为空&#xff0c;则容器内也为空 -v表示目录挂载 冒号前面的是主机上的目录&#xff0c;冒号后面的是docker容器里面的地址 修改主机上的文件&#xff0c;发现docker容器里面的内容也随之改变。 同样修改docker容器里面的内容&#xff0c;主机上的文件…

迪米特法则 (Law of Demeter, LoD)

定义&#xff1a;迪米特法则(Law of Demeter, LoD)&#xff1a;一个软件实体应当尽可能少地与其他实体发生相互作用。 迪米特法则&#xff08;Law of Demeter&#xff0c;LoD&#xff09;又叫作最少知识原则&#xff08;Least Knowledge Principle&#xff0c;LKP)&#xff0c;…

【R语言编程绘图-函数篇】

基础函数绘制 R语言可通过curve()函数直接绘制数学函数图形&#xff0c;无需预先生成数据点。例如绘制正弦函数&#xff1a; curve(sin, from -pi, to pi, col "blue", lwd 2)自定义函数绘制 对于用户自定义函数&#xff0c;需先定义函数表达式&#xff1a; …

训练自己的yolo模型,并部署到rk3588上

文章目录 1. 训练自己的模型2. pt转onnx3. onnx转rknn4. 后续…… 1. 训练自己的模型 如何训练自己的yolo模型&#xff0c;网络上已经有足够多的教程&#xff0c;这里只简单的描述一下。如果已经有了自己的.pt模型&#xff0c;那么可以直接跳转到第二节。 此处是以检测模型的…

以少学习:通过无标签数据从大型语言模型进行知识蒸馏

Learning with Less: Knowledge Distillation from Large Language Models via Unlabeled Data 发表&#xff1a;NNACL-Findings 2025 机构&#xff1a;密歇根州立大学 Abstract 在实际的自然语言处理&#xff08;NLP&#xff09;应用中&#xff0c;大型语言模型&#xff08…

Qt qml Network error问题

最近在学习Qt&#xff0c;需要调用地图&#xff0c;所以用到了QML&#xff0c;但是却遇到了这样的问题 d://qt_project//run//main.qml: Network error 现在我展示一下我的main文件的代码&#xff1a; #include <QApplication> #include <QQuickView> #include &l…

Spring Boot微服务架构(六):伪装的微服务有哪些问题?

伪装的微服务有哪些问题&#xff1f; 伪装的微服务架构&#xff08;即表面上模仿微服务设计&#xff0c;但未真正遵循其核心原则的系统&#xff09;通常具备以下特征点&#xff0c;这些特征可能导致系统复杂度增加、维护困难或性能下降&#xff1a; ​​1. 服务间强耦合​​ …

恶意npm与VS Code包窃取数据及加密货币资产

60个npm包窃取系统敏感信息 安全研究人员在npm软件包注册表中发现60个恶意组件&#xff0c;这些组件能够收集主机名、IP地址、DNS服务器和用户目录信息&#xff0c;并将其发送至Discord平台控制的终端节点。据Socket安全研究员Kirill Boychenko上周发布的报告显示&#xff0c;…

什么是3D全景视角?3D全景有什么魅力?

什么是3D全景视角&#xff1f;3D全景视角的全面解析。 3D全景视角&#xff0c;又称为3D全景技术或3D实景技术&#xff0c;是新兴的富媒体技术&#xff0c;基于静态图像和虚拟现实&#xff08;VR&#xff09;技术&#xff0c;通过全方位、无死角地捕捉和展示环境&#xff0c;为…