背景
基于G6实现的可自定义元素的模型流程图编辑器,在原来初步调研的基础上继续进行了自定义元素功能扩展和样式定制的调研。
效果展示
目前简单支持名称的修改和节点左侧颜色的修改,后续可根据具体需求扩展。节点样式的修改,边的修改都是可以实现的。
动态效果:
效果图:
技术框架
G6:
npm install --save @antv/g6
elementUI:
npm i element-ui -S
关键代码
Home.vue
<template>
<div id="mountNode" :style="{width:width}">
<div class="editor" >
<context-menu />
<toolbar v-show="false" />
<div class="bottom-container">
<item-panel />
<detail-panel />
<page :height="height" :width="width" :data="data" />
</div>
</div>
<Flow />
</div>
</template>
<script>
import Toolbar from "../Toolbar";
import ItemPanel from "../ItemPanel";
import DetailPanel from "../DetailPanel";
import Page from "../Page";
import Flow from "../Flow"
import ContextMenu from "../ContextMenu";
import Editor from "@/components/Base/Editor";
import command from "@/command";
export default {
name: "G6Editor",
components: {
Toolbar,
ItemPanel,
DetailPanel,
Page,
ContextMenu,
Flow
},
props: {
height: {
type: Number,
default: document.documentElement.clientHeight
},
width: {
type: Number,
default: document.documentElement.clientWidth
},
data: {
type: Object,
default: () => {}
}
},
created() {
this.init();
},
data() {
return {
editor: {},
command: null
};
},
methods: {
init() {
this.editor = new Editor();
this.command = new command(this.editor);
}
}
};
</script>
<style scoped>
.editor {
position: relative;
width: 100%;
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
.bottom-container {
position: relative;
}
</style>
ItemPanel.vue
<template>
<ul>
<li
v-for="(item,index) in list"
:key="index"
class="getItem"
:data-shape="item.shape"
:data-type="item.type"
:data-size="item.size"
draggable
@dragstart="handleDragstart"
@dragend="handleDragEnd($event,item)"
>
<span class="pannel-type-icon" :style="{background:'url('+item.image+')'}"></span>
{{item.name}}
</li>
</ul>
</template>
<script>
import eventBus from "@/utils/eventBus";
import okSvg from "@/assets/icons/ok1.svg";
import bgImg from "@/assets/bg.jpg";
export default {
data() {
return {
page: null,
command: null,
offsetX: 0,
offsetY: 0,
list: [
{
name: "测试节点",
label: "测试节点",
size: "170*34",
type: "node",
x: 0,
y: 0,
shape: "customNode",
color: "#1890ff",
image:
"https://gw.alipayobjects.com/zos/rmsportal/czNEJAmyDpclFaSucYWB.svg",
stateImage: okSvg,
inPoints: [[0, 0.5]],
outPoints: [[1, 0.5]]
},
]
};
},
created() {
this.bindEvent();
},
methods: {
handleDragstart(e) {
this.offsetX = e.offsetX;
this.offsetY = e.offsetY;
},
handleDragEnd(e, item) {
let data = {};
Object.assign(data, item);
data.offsetX = this.offsetX;
data.offsetY = this.offsetY;
if (this.page) {
const graph = this.page.graph;
// const size = e.target.dataset.size.split("*");
const xy = graph.getPointByClient(e.x, e.y);
data.x = xy.x;
data.y = xy.y;
data.size = item.size.split("*");
data.type = "node";
this.command.executeCommand("add", [data]);
}
},
bindEvent() {
eventBus.$on("afterAddPage", page => {
this.page = page;
this.command = page.command;
});
}
}
};
</script>
<style scoped>
.itempannel {
height: 100%;
position: absolute;
left: 0px;
z-index: 2;
background: #f7f9fb;
width: 200px;
padding-top: 8px;
border-right: 1px solid #e6e9ed;
}
.itempannel ul {
padding: 0px;
padding-left: 16px;
}
.itempannel li {
color: rgba(0, 0, 0, 0.65);
border-radius: 4px;
width: 160px;
height: 28px;
line-height: 26px;
padding-left: 8px;
border: 1px solid rgba(0, 0, 0, 0);
list-style-type: none;
}
.itempannel li:hover {
background: white;
border: 1px solid #ced4d9;
cursor: move;
}
.itempannel .pannel-type-icon {
width: 16px;
height: 16px;
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
</style>
DetailPanel.vue
<template>
<div class="detailpannel">
<div>
<div v-if="status=='node-selected'" class="pannel" id="node_detailpannel">
<div class="pannel-title">模型详情</div>
<div class="block-container">
<el-row :gutter="10">
<el-col :span="8">名称</el-col>
<el-col :span="16">
<el-input v-model="node.label" @change="handleChangeName" />
</el-col>
<el-col :span="8">颜色</el-col>
<el-col :span="16">
<el-input v-model="node.color" @change="handleChangeColor" />
</el-col>
</el-row>
</div>
</div>
<div v-if="status==='canvas-selected'" class="pannel" id="canvas_detailpannel">
<div class="pannel-title">画布</div>
<div class="block-container">
<el-checkbox v-model="showGrid" @change="changeGridState">网格对齐</el-checkbox>
</div>
</div>
</div>
</div>
</template>
<script>
import eventBus from "@/utils/eventBus";
import Grid from "@antv/g6/build/grid";
export default {
data() {
return {
status: "canvas-selected",
showGrid: false,
page: {},
graph: {},
item: {},
node: {},
grid: null
};
},
created() {
this.init();
this.bindEvent();
},
methods: {
init() {},
bindEvent() {
let self = this;
eventBus.$on("afterAddPage", page => {
self.page = page;
self.graph = self.page.graph;
eventBus.$on("nodeselectchange", item => {
if (item.select === true && item.target.getType() === "node") {
self.status = "node-selected";
self.item = item.target;
self.node = item.target.getModel();
} else {
self.status = "canvas-selected";
self.item = null;
self.node = null;
}
});
});
},
handleChangeName(e) {
const model = {
label: e
};
this.graph.update(this.item, model);
},
handleChangeColor(e) {
const model = {
color: e
};
this.graph.update(this.item, model);
},
changeGridState(value) {
if (value) {
this.grid = new Grid();
this.graph.addPlugin(this.grid);
} else {
this.graph.removePlugin(this.grid);
}
}
}
};
</script>
<style scoped>
.detailpannel {
height: 100%;
position: absolute;
right: 0px;
z-index: 2;
background: #f7f9fb;
width: 200px;
border-left: 1px solid #e6e9ed;
}
.detailpannel .block-container {
padding: 16px 8px;
}
.block-container .el-col {
height: 28px;
display: flex;
align-items: center;
margin-bottom: 10px;
}
.pannel-title {
height: 32px;
border-top: 1px solid #dce3e8;
border-bottom: 1px solid #dce3e8;
background: #ebeef2;
color: #000;
line-height: 28px;
padding-left: 12px;
}
</style>
Page.vue
<template>
<div class="page">
<div :id="pageId" class="graph-container" style="position: relative;"></div>
</div>
</template>
<script>
import G6 from "@antv/g6/build/g6";
import { initBehavors } from "@/behavior";
export default {
data() {
return {
pageId: "graph-container",
graph: null
};
},
props: {
height: {
type: Number,
default: 0
},
width: {
type: Number,
default: 0
},
data: {
type: Object,
default: () => {}
}
},
created() {
initBehavors();
},
mounted() {
this.$nextTick(() => {
this.init();
});
},
methods: {
init() {
const height = this.height - 42
const width = this.width - 400
this.graph = new G6.Graph({
container: "graph-container",
height: height,
width: width,
modes: {
// 支持的 behavior
default: [
"drag-canvas",
"zoom-canvas",
"hover-node",
"select-node",
"hover-edge",
"keyboard",
"customer-events",
"add-menu"
],
mulitSelect: ["mulit-select"],
addEdge: ["add-edge"],
moveNode:[ "drag-item"]
}
});
const { editor, command } = this.$parent;
editor.emit("afterAddPage", { graph: this.graph, command });
this.readData();
},
readData() {
let data = this.data;
if (data) {
this.graph.read(data);
}
}
}
};
</script>
<style scoped>
.page{
margin-left:200px;
margin-right: 200px;
}
</style>
customNode.js(实现自定义元素的核心代码)
import G6 from "@antv/g6/build/g6";
import { uniqueId } from '@/utils'
import Shape from '@antv/g/src/shapes'
const customNode = {
init() {
G6.registerNode("customNode", {
draw(cfg, group) {
let size = cfg.size;
if(!size){
size=[170,34]
}
// 此处必须是NUMBER 不然bbox不正常
const width = parseInt(size[0]);
const height = parseInt(size[1]);
const color = cfg.color;
// 此处必须有偏移 不然drag-node错位
const offsetX = -width / 2
const offsetY = -height / 2
const mainId = 'rect' + uniqueId()
const shape = group.addShape("rect", {
attrs: {
id: mainId,
x: offsetX,
y: offsetY,
width: width,
height: height,
stroke: "#ced4d9",
fill: '#fff',//此处必须有fill 不然不能触发事件
radius: 4
}
});
group.addShape("rect", {
attrs: {
x: offsetX,
y: offsetY,
width: 4,
height: height,
fill: color,
parent: mainId,
radius: [4, 0, 0, 4]
}
});
group.addShape("image", {
attrs: {
x: offsetX + 16,
y: offsetY + 8,
width: 20,
height: 16,
img: cfg.image,
parent: mainId
}
});
group.addShape("image", {
attrs: {
x: offsetX + width - 32,
y: offsetY + 8,
width: 16,
height: 16,
parent: mainId,
img: cfg.stateImage
}
});
if(cfg.backImage){
const clip = new Shape.Rect({
attrs: {
x: offsetX,
y: offsetY,
width: width,
height: height,
fill:'#fff',
radius: 4
}
});
group.addShape("image", {
attrs: {
x: offsetX,
y: offsetY,
width: width,
height: height,
img: cfg.backImage,
clip: clip
}
});
}
if (cfg.label) {
group.addShape("text", {
attrs: {
id: 'label' + uniqueId(),
x: offsetX + width / 2,
y: offsetY + height / 2,
textAlign: "center",
textBaseline: "middle",
text: cfg.label,
parent: mainId,
fill: "#565758"
}
});
}
if (cfg.inPoints) {
for (let i = 0; i < cfg.inPoints.length; i++) {
let x,
y = 0;
//0为顶 1为底
if (cfg.inPoints[i][0] === 0) {
y = 0;
} else {
y = height;
}
x = width * cfg.inPoints[i][1];
const id = 'circle' + uniqueId()
group.addShape("circle", {
attrs: {
id: 'circle' + uniqueId(),
parent: id,
x: x + offsetX,
y: y + offsetY,
r: 10,
isInPointOut: true,
fill: "#1890ff",
opacity: 0
}
});
group.addShape("circle", {
attrs: {
id: id,
x: x + offsetX,
y: y + offsetY,
r: 3,
isInPoint: true,
fill: "#fff",
stroke: "#1890ff",
opacity: 0
}
});
}
}
if (cfg.outPoints) {
for (let i = 0; i < cfg.outPoints.length; i++) {
let x,
y = 0;
//0为顶 1为底
if (cfg.outPoints[i][0] === 0) {
y = 0;
} else {
y = height;
}
x = width * cfg.outPoints[i][1];
const id = 'circle' + uniqueId()
group.addShape("circle", {
attrs: {
id: 'circle' + uniqueId(),
parent: id,
x: x + offsetX,
y: y + offsetY,
r: 10,
isOutPointOut: true,
fill: "#1890ff",
opacity: 0//默認0 需要時改成0.3
}
});
group.addShape("circle", {
attrs: {
id: id,
x: x + offsetX,
y: y + offsetY,
r: 3,
isOutPoint: true,
fill: "#fff",
stroke: "#1890ff",
opacity: 0
}
});
}
}
//group.sort()
// 添加文本、更多图形
return shape;
},
//设置状态
setState(name, value, item) {
const group = item.getContainer();
const shape = group.get("children")[0]; // 顺序根据 draw 时确定
const children = group.findAll(g => {
return g._attrs.parent === shape._attrs.id
});
const circles = group.findAll(circle => {
return circle._attrs.isInPoint || circle._attrs.isOutPoint;
});
const selectStyles = () => {
shape.attr("fill", "#f3f9ff");
shape.attr("stroke", "#6ab7ff");
shape.attr("cursor", "move");
children.forEach(child => {
child.attr("cursor", "move");
});
circles.forEach(circle => {
circle.attr('opacity', 1)
})
};
const unSelectStyles = () => {
shape.attr("fill", "#fff");
shape.attr("stroke", "#ced4d9");
circles.forEach(circle => {
circle.attr('opacity', 0)
})
};
switch (name) {
case "selected":
case "hover":
if (value) {
selectStyles()
} else {
unSelectStyles()
}
break;
}
}
});
}
}
export default customNode