当前位置: 首页 > article >正文

Vue3+codemirror6实现公式(规则)编辑器

实现截图

在这里插入图片描述

实现/带实现功能

  • 插入标签
  • 插入公式
  • 提示补全
  • 公式验证
  • 公式计算

需要的依赖

    "@codemirror/autocomplete": "^6.18.4",
    "@codemirror/lang-javascript": "^6.2.2",
    "@codemirror/state": "^6.5.2",
    "@codemirror/view": "^6.36.2",
    "codemirror": "^6.0.1",

初始化编辑器

// index.ts
export const useCodemirror = () => {
  const code = ref("");
  const view = shallowRef<EditorView>();
  const editorRef = ref<InstanceType<typeof HTMLDivElement>>();
  const extensions = [
    placeholderTag, //插入tag
    placeholderFn, //插入函数
    baseTheme, //基础样式
    EditorView.lineWrapping, //换行
    basicSetup, //基础配置
    javascript(), //js语言支持
    autocompletion({ override: [myCompletions] }), //补全提示
  ];
  /**
   * @description 初始化编辑器
   */
  const init = () => {
    if (editorRef.value) {
      view.value = new EditorView({
        parent: editorRef.value,
        state: EditorState.create({
          doc: code.value,
          extensions: extensions,
        }),
      });
      setTimeout(() => {
        view.value?.focus();
      }, 0);
    }
  };
  /**
   * @description 销毁编辑器
   */
  const destroyed = () => {
    view.value?.destroy();
    view.value = undefined;
  };
  /**
   * @description 插入文本并设置光标位置
   */
  const insertText = (text: string, type: "fn" | "tag" = "tag") => {
    if (view.value) {
      let content = type === "tag" ? `[[${text}]]` : `{{${text}}}()`;
      const selection = view.value.state.selection;
      if (!selection.main.empty) {
        // 如果选中文本,则替换选中文本
        const from = selection.main.from;
        const to = selection.main.to;
        const anchor =
          type === "tag" ? from + content.length : from + content.length - 1;
        const transaction = view.value!.state.update({
          changes: { from, to, insert: content }, // 在当前光标位置插入标签
          selection: {
            anchor,
          }, // 指定新光标位置
        });
        view.value.dispatch(transaction);
      } else {
        // 如果没有选中文本,则插入标签
        const pos = selection.main.head;
        const anchor =
          type === "tag" ? pos + content.length : pos + content.length - 1;
        const transaction = view.value.state.update({
          changes: { from: pos, to: pos, insert: content }, // 在当前光标位置插入标签
          selection: {
            anchor: anchor,
          }, // 指定新光标位置
        });
        view.value.dispatch(transaction);
      }
      setTimeout(() => {
        view.value?.focus();
      }, 0);
    }
  };

  return {
    code,
    view,
    editorRef,
    init,
    destroyed,
    insertText,
  };
};
<template>
  <MyDialog
    v-model="state.visible"
    title="Editor"
    :width="800"
    center
    :close-on-click-modal="false"
    :destroy-on-close="true"
    @close="close"
  >
    <div class="editor-container">
      <TreeCom
        class="editor-tree"
        :data="state.paramsData"
        @node-click="insertTag"
      ></TreeCom>
      <div class="editor-content">
        <div class="editor-main" ref="editorRef"></div>
        <div class="fn">
          <div class="fn-list">
            <TreeCom
              :default-expand-all="true"
              :data="state.fnData"
              @node-click="insertFn"
              @mouseenter="hoverFn"
            ></TreeCom>
          </div>
          <div class="fn-desc">
            <DescCom v-bind="state.info"></DescCom>
          </div>
        </div>
      </div>
    </div>
    <template #footer>
      <div>
        <el-button @click="close">取消</el-button>
        <el-button type="primary" @click="submit">确认</el-button>
      </div>
    </template>
  </MyDialog>
</template>

<script lang="ts">
export default { name: "Editor" };
</script>
<script lang="ts" setup>
import { nextTick, reactive } from "vue";
import TreeCom from "./components/tree.vue";
import DescCom from "./components/desc.vue";
import { useCodemirror, functionDescription } from ".";
import { Tree } from "@/types/common";

const state = reactive({
  visible: false,
  paramsData: [
    {
      label: "参数1",
      id: "1",
    },
    {
      label: "参数2",
      id: "2",
    },
    {
      label: "参数3",
      id: "3",
    },
  ],
  fnData: [
    {
      label: "常用函数",
      id: "1",
      children: [
        {
          label: "SUM",
          desc: "求和",
          id: "1-1",
        },
        {
          label: "IF",
          desc: "条件判断",
          id: "1-2",
        },
      ],
    },
  ],
  info: {},
});

const { code, view, editorRef, init, destroyed, insertText } = useCodemirror();
/**
 * @description 插入标签
 */
const insertTag = (data: Tree) => {
  if (!data.children) {
    insertText(`${data.id}.${data.label}`);
  }
};
/**
 * @description 插入函数
 */
const insertFn = (data: Tree) => {
  if (!data.children) {
    insertText(`${data.label}`, "fn");
  }
};
/**
 * @description 鼠标悬停展示函数描述
 */
const hoverFn = (data: Tree) => {
  const info = functionDescription(data.label);
  if (info) {
    state.info = info;
  }
};
/**
 * @description 获取数据
 */
const submit = () => {
  const data = view.value?.state.doc;
  console.log(data);
};
const open = () => {
  state.visible = true;
  nextTick(() => {
    init();
  });
};
const close = () => {
  destroyed();
  state.visible = false;
};

defineExpose({
  open,
});
</script>

<style lang="scss" scoped>
.editor-container {
  position: relative;
  .editor-tree {
    width: 200px;
    position: absolute;
    left: 0;
    top: 0;
    height: 100%;
  }
  .editor-content {
    margin-left: 210px;
    display: flex;
    flex-direction: column;
    .editor-main {
      border: 1px solid #ccc;
      height: 200px;
    }
    .fn {
      display: flex;
      height: 200px;
      > div {
        flex: 1;
        border: 1px solid #ccc;
      }
    }
  }
}
:deep(.cm-focused) {
  outline: none;
}
:deep(.cm-gutters) {
  display: none;
}
</style>

插入标签的实现

根据官网例子以及部分大佬思路改编

  1. 插入标签使用[[${id}.${label}]]
  /**
   * @description 插入文本并设置光标位置
   */
  const insertText = (text: string, type: "fn" | "tag" = "tag") => {
    if (view.value) {
      let content = type === "tag" ? `[[${text}]]` : `{{${text}}}()`;
      const selection = view.value.state.selection;
      if (!selection.main.empty) {
        // 如果选中文本,则替换选中文本
        const from = selection.main.from;
        const to = selection.main.to;
        const anchor =
          type === "tag" ? from + content.length : from + content.length - 1;
        const transaction = view.value!.state.update({
          changes: { from, to, insert: content }, // 在当前光标位置插入标签
          selection: {
            anchor,
          }, // 指定新光标位置
        });
        view.value.dispatch(transaction);
      } else {
        // 如果没有选中文本,则插入标签
        const pos = selection.main.head;
        const anchor =
          type === "tag" ? pos + content.length : pos + content.length - 1;
        const transaction = view.value.state.update({
          changes: { from: pos, to: pos, insert: content }, // 在当前光标位置插入标签
          selection: {
            anchor: anchor,
          }, // 指定新光标位置
        });
        view.value.dispatch(transaction);
      }
      setTimeout(() => {
        view.value?.focus();
      }, 0);
    }
  };
  1. 然后去匹配[[]]中的内容,取出来用span包裹
/**
 * @description 插入tag
 */
const placeholderTagMatcher = new MatchDecorator({
  regexp: /\[\[(.+?)\]\]/g,
  decoration: (match) => {
    return Decoration.replace({ widget: new PlaceholderTag(match[1]) });
  },
});
// 定义一个 PlaceholderTag 类,继承自 WidgetType
class PlaceholderTag extends WidgetType {
  // 定义一个字符串类型的 id 属性,默认值为空字符串
  id: string = "";
  // 定义一个字符串类型的 text 属性,默认值为空字符串
  text: string = "";
  // 构造函数,接收一个字符串类型的 text 参数
  constructor(text: string) {
    // 调用父类的构造函数
    super();
    // 被替换的数据处理
    if (text) {
      const [id, ...texts] = text.split(".");
      if (id && texts.length) {
        this.text = texts.join(".");
        this.id = id;
        console.log(this.text, "id:", this.id);
      }
    }
  }
  eq(other: PlaceholderTag) {
    return this.text == other.text;
  }
  // 此处是我们的渲染方法
  toDOM() {
    let elt = document.createElement("span");
    if (!this.text) return elt;
    elt.className = "cm-tag";
    elt.textContent = this.text;
    return elt;
  }
  ignoreEvent() {
    return true;
  }
}
// 导出一个名为placeholders的常量,它是一个ViewPlugin实例,通过fromClass方法创建
const placeholderTag = ViewPlugin.fromClass(
  // 定义一个匿名类,该类继承自ViewPlugin的基类
  class {
    // 定义一个属性placeholders,用于存储装饰集
    placeholders: DecorationSet;
    // 构造函数,接收一个EditorView实例作为参数
    constructor(view: EditorView) {
      // 调用placeholderMatcher.createDeco方法,根据传入的view创建装饰集,并赋值给placeholders属性
      this.placeholders = placeholderTagMatcher.createDeco(view);
    }
    // update方法,用于在视图更新时更新装饰集
    update(update: ViewUpdate) {
      // 调用placeholderMatcher.updateDeco方法,根据传入的update和当前的placeholders更新装饰集,并重新赋值给placeholders属性
      this.placeholders = placeholderTagMatcher.updateDeco(
        update,
        this.placeholders
      );
    }
  },
  // 配置对象,用于定义插件的行为
  {
    // decorations属性,返回当前实例的placeholders属性,用于提供装饰集
    decorations: (v) => v.placeholders,
    // provide属性,返回一个函数,该函数返回一个EditorView.atomicRanges的提供者
    provide: (plugin) =>
      EditorView.atomicRanges.of((view) => {
        // 从view中获取当前插件的placeholders属性,如果不存在则返回Decoration.none
        return view.plugin(plugin)?.placeholders || Decoration.none;
      }),
  }
);
  1. 设置样式
const baseTheme = EditorView.baseTheme({
  ".cm-tag": {
    paddingLeft: "6px",
    paddingRight: "6px",
    paddingTop: "3px",
    paddingBottom: "3px",
    marginLeft: "3px",
    marginRight: "3px",
    backgroundColor: "#ffcdcc",
    borderRadius: "4px",
  },
  ".cm-fn": {
    color: "#01a252",
  },
});
  1. 使用插件
    在这里插入图片描述

插入公式的实现

同理,我只是把[[]]换成了{{}},然后样式也修改了

注意:我们插入标签和公式的时候要指定光标位置,不然会出现问题,使用起来也不方便

提示补全的实现

也是根据官网例子改编,注意要先下载依赖@codemirror/autocomplete

/**
 * @description 补全提示
 */
const completions = [
  {
    label: "SUM",
    apply: insetCompletion,
  },
  {
    label: "IF",
    apply: insetCompletion,
  },
];
/**
 * @description 补全提示
 * @param {CompletionContext} context
 * @return {*}
 */
function myCompletions(context: CompletionContext) {
  // 匹配到以s或su或sum或i或if开头的单词
  let before = context.matchBefore(/[s](?:u(?:m)?)?|[i](?:f)?/gi);
  if (!context.explicit && !before) return null;
  return {
    from: before ? before.from : context.pos,
    options: completions,
  };
}
/**
 * @description 插入补全
 * @param {EditorView} view
 * @param {Completion} completion
 * @param {number} from
 * @param {number} to
 */
function insetCompletion(
  view: EditorView,
  completion: Completion,
  from: number,
  to: number
) {
  const content = `{{${completion.label}}}()`;
  const anchor = from + content.length - 1;
  const transaction = view.state.update({
    changes: { from, to, insert: content }, // 在当前光标位置插入标签
    selection: {
      anchor: anchor,
    }, // 指定新光标位置
  });
  view.dispatch(transaction);
}

使用插件
在这里插入图片描述
仓库地址
在线预览


http://www.kler.cn/a/536994.html

相关文章:

  • Golang的引用类型和指针
  • 力扣.270. 最接近的二叉搜索树值(中序遍历思想)
  • go并发和并行
  • 使用数学工具和大模型结合训练专有小模型(有限元算法和大模型微调)
  • 【DeepSeek论文精读】2. DeepSeek LLM:以长期主义扩展开源语言模型
  • 第六期:开放银行突围战 - API经济下的跨域经营合规框架
  • 记录一次mysql主从
  • 【远程控制】安装虚拟显示器
  • 快速上手——.net封装使用DeekSeek-V3 模型
  • openCV函数使用(一)
  • JMeter通过BeanShell写入CSV文件中的中文乱码
  • MoviePy,利用Python自动剪辑tiktok视频
  • 【Unity 墓地和自然环境场景资产包】PBR Graveyard and Nature Set 2.0 高质量的墓地3D 模型,丰富的自然环境元素,轻松构建具有沉浸感和氛围感的游戏世界
  • 三级等保、二级等保谁更高级 ?等保都有哪些?
  • Gateway路由匹配规则详解
  • k8s网络插件及基础命令
  • LINUX——内核驱动程序
  • Python+requests实现接口自动化测试
  • 阿里云不同账号vpc对等连接
  • 文件上传全详解
  • 当春晚遇上AI,传统与科技的奇妙碰撞
  • 使用 Axios 进行高效的数据交互
  • 各种协议设计
  • (2025|Meta,LLM,token 压缩/挑选,离散潜在标记,VQ-VAE)混合潜在标记和文本标记以改进语言模型推理
  • 详解正则表达式与案例
  • DOMParser解析TikTok页面中的图片元素