交互说明
在编辑器中输入{时,会自动弹出选项弹窗,然后可以选值插入。
代码
父组件
<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