一、需要安装的依赖
npm i element-ui -S
"@antv/layout": "^0.2.0",
"@antv/x6": "^1.1.1",
"vue": "^2.5.2",
二、代码
<template>
<div class='antv'>
<el-container>
<el-header>
<el-button type="info" @click="zoomIn">放大</el-button>
<el-button type="info" @click="zoomOut">缩小</el-button>
<el-upload :show-file-list="false" ref="upload" :http-request="importData">
<el-button type="info">导入</el-button>
</el-upload>
<el-button type="info" @click="exportData">导出</el-button>
<el-button type="info" @click="changeLayout">自动布局</el-button>
</el-header>
<el-container>
<el-aside>
<div id="left" ref="left"></div>
</el-aside>
<el-main>
<div id="area" ref="area"></div>
</el-main>
</el-container>
</el-container>
<el-drawer size="30%" title="情节属性设置" :visible.sync="drawer" direction="rtl" :before-close="handleClose">
<el-form id="form" :model="form" :rules="rules" ref="form">
<el-form-item label="标题" label-width="60px" prop="title">
<el-input v-model="form.title" maxlength="12" placeholder="请输入标题内容"></el-input>
</el-form-item>
<el-form-item label="文本" label-width="60px" prop="text">
<el-input v-model="form.text" :autosize="{ minRows: 3, maxRows: 5}" type="textarea" maxlength="300" placeholder="请输入文本内容"></el-input>
</el-form-item>
<el-form-item label="分值" label-width="60px" prop="score">
<el-input-number v-model="form.score" controls-position="right" :min="0" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="评价" label-width="60px" prop="comment">
<el-input v-model="form.comment" :autosize="{ minRows: 3, maxRows: 5}" type="textarea" maxlength="300" placeholder="请输入评价内容"></el-input>
</el-form-item>
<el-form-item label="情节" label-width="60px" prop="nextPieceMode">
<el-select v-model="form.nextPieceMode" placeholder="请选择情节展开的方式">
<el-option label="用户选择" value="choose"></el-option>
<el-option label="随机进行" value="random"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<script>
import { Addon, Graph, Shape } from '@antv/x6'
import { DagreLayout } from '@antv/layout'
export default {
name: 'antv',
components: {},
data () {
return {
graph: null,
nodeProps: {},//全部有效节点(非默认拖拽的节点,修改过得节点)的数据对象
node: null,//双击之后,选中的当前节点对象
drawer: false,
form: {
text: "",
title: "",
score: 20,
comment: "",
nextPieceMode: ""
},
rules: {
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ min: 0, max: 12, message: '长度在1到12个字符', trigger: 'blur' }
],
text: [
{ required: true, message: '请输入文本', trigger: 'blur' },
{ min: 1, max: 300, message: '长度在1到300个字符', trigger: 'blur' }
],
score: [
{ required: true, message: '得分不能为空' },
{ type: 'number', message: '得分必须是数字' },
{ pattern: /^(0|[1-9]\d?|100)$/, message: '范围在0-100', trigger: 'blur' }
],
comment: [
{ min: 1, max: 300, message: '长度在1到300个字符', trigger: 'blur' }
],
nextPieceMode: [
{ required: true, message: '情节发展方式不能为空' }
]
},
}
},
created () { },
mounted () {
this.initGraph()
},
methods: {
initGraph () {
const left = this.$refs.left
const area = this.$refs.area
// Graph 是图的载体
const graph = new Graph({
container: area,
history: true,
grid: true,//显示坐标点
// 画布缩放参数配置
mousewheel: {
enabled: true,
zoomAtMousePosition: true,
modifiers: 'ctrl',
minScale: 0.5,
maxScale: 5,
},
connecting: {
router: {
name: 'manhattan',
args: {
padding: 1,
},
},
connector: {
name: 'rounded',
args: {
radius: 8,
},
},
anchor: 'center',
connectionPoint: 'anchor',
allowBlank: false,
snap: {
radius: 20,
},
createEdge () {
return new Shape.Edge({
attrs: {
line: {
stroke: '#A2B1C3',
strokeWidth: 2,
targetMarker: {
name: 'block',
width: 12,
height: 8,
},
},
},
zIndex: 0,
})
},
validateConnection ({ targetMagnet }) {
return !!targetMagnet
},
},
highlighting: {
magnetAdsorbed: {
name: 'stroke',
args: {
attrs: {
fill: '#5F95FF',
stroke: '#5F95FF',
},
},
},
},
resizing: true,
rotating: true,
// 是否可拖动
panning: {
enabled: true,
eventTypes: ['leftMouseDown']
},
// 选择器
selecting: {
enabled: true,
rubberband: false,
showNodeSelectionBox: true,
},
snapline: true,
keyboard: true,
clipboard: true,
})
this.graph = graph;
// 左侧可被拖拽的图形节点所在的 “区域容器”
const stencil = new Addon.Stencil({
title: '流程图',
target: graph,
stencilGraphWidth: 250,
stencilGraphHeight: 180,
collapsable: false,
groups: [
{
title: '基础流程图',
name: 'group1',
},
],
layoutOptions: {
columns: 1,//区域容器每行显示的节点数量
columnWidth: 200,//区域容器的行宽
rowHeight: 60,//区域容器的行高
},
})
left.appendChild(stencil.container)
// 注册两种图形节点
const ports = {
groups: {
top: {
position: 'top',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
right: {
position: 'right',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
bottom: {
position: 'bottom',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
left: {
position: 'left',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
},
items: [
{
group: 'top',
},
{
group: 'right',
},
{
group: 'bottom',
},
{
group: 'left',
},
],
}
Graph.registerNode(
'RectAi',
{
inherit: 'rect',
width: 160,
height: 36,
attrs: {
body: {
strokeWidth: 1,
stroke: '#5F95FF',
fill: '#CCFFFF',
},
text: {
fontSize: 12,
fill: '#262626',
},
},
ports: { ...ports },
},
true,
)
Graph.registerNode(
'RectMe',
{
inherit: 'rect',
width: 160,
height: 36,
attrs: {
body: {
strokeWidth: 1,
stroke: '#5F95FF',
fill: '#CCFFCC',
},
text: {
fontSize: 12,
fill: '#262626',
},
},
ports: { ...ports },
},
true,
)
// 将两种图形节点添加到“区域容器”中
const r1 = graph.createNode({
shape: 'RectAi',
label: 'AI情节',
})
const r2 = graph.createNode({
shape: 'RectMe',
label: '客户情节',
})
stencil.load([r1, r2], 'group1')
// 配置键盘快捷键
// select all
graph.bindKey(['meta+a', 'ctrl+a'], () => {
const nodes = graph.getNodes()
if (nodes) {
graph.select(nodes)
}
})
// copy
graph.bindKey(['meta+c', 'ctrl+c'], () => {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.copy(cells)
}
return false
})
// cut
graph.bindKey(['meta+x', 'ctrl+x'], () => {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.cut(cells)
}
return false
})
// paste
graph.bindKey(['meta+v', 'ctrl+v'], () => {
if (!graph.isClipboardEmpty()) {
const cells = graph.paste({ offset: 32 })
graph.cleanSelection()
graph.select(cells)
}
return false
})
//delete
graph.bindKey('backspace', () => {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.removeCells(cells)
}
})
// 节点撤销
graph.bindKey(['meta+z', 'ctrl+z'], () => {
if (graph.history.canUndo()) {
graph.history.undo()
}
return false
})
// 节点撤销后,的恢复操作
graph.bindKey(['meta+shift+z', 'ctrl+shift+z'], () => {
if (graph.history.canRedo()) {
graph.history.redo()
}
return false
})
// 监听节点事件函数
graph.on('node:removed', ({ node: curNode }) => {
// 更新有效节点数据对象
delete this.nodeProps[curNode.id];
})
// 节点双击事件 存储当前节点对象,把当前节点对象存到nodeProps(有效节点数据对象)里
graph.on('node:dblclick', ({ node: curNode }) => {
this.node = curNode;
if (this.nodeProps[this.node.id]) {
this.form = this.nodeProps[this.node.id];
} else {
this.nodeProps[this.node.id] = {
title: "",
text: "",
score: 20,
comment: "",
nextPieceMode: ""
};
this.form = this.nodeProps[this.node.id];
}
this.drawer = true;
})
// 控制全部节点对象的锚点 显示/隐藏
graph.on('node:mouseenter', () => {
const ports = area.querySelectorAll('.x6-port-body')
ports.forEach(port => {
port.style.visibility = 'visible'
});
})
graph.on('node:mouseleave', () => {
const ports = area.querySelectorAll('.x6-port-body')
ports.forEach(port => {
port.style.visibility = 'hidden'
});
})
},
changeLayout () {
let edges = []
let nodes = []
this.graph.toJSON().cells.forEach(cell => {
if (cell.shape === "edge") {
edges.push({
source: cell.source.cell,
target: cell.target.cell
})
} else {
nodes.push(cell)
}
});
const dagreLayout = new DagreLayout({
type: 'dagre',
rankdir: 'TB',
align: 'DL',
controlPoints: true,
})
const newModel = dagreLayout.layout({
nodes: nodes,
edges: edges
})
let nodeMap = {};
newModel.nodes.forEach(node => {
nodeMap[node.id] = node
// 需要注意的是,布局算法返回的 x、y 其实是节点的中心点坐标,在 X6 中,节点的 x、y 其实是左上角坐标,所以布局之后,我们需要做一次坐标转换。
node.x -= node.size.width / 2;
node.y -= node.size.height / 2;
console.log(node)
})
// 解决布局之后,节点之间的连线跑到节点中间去了,我们需要描点之间的连线
newModel.edges.forEach(edge => {
edge.source = {
cell: edge.source,
port: nodeMap[edge.source].ports.items[2].id
}
edge.target = {
cell: edge.target,
port: nodeMap[edge.target].ports.items[0].id
}
console.log(edge)
})
this.graph.fromJSON(newModel)
},
zoomIn () {
let zoom1 = this.graph.zoom();//获取当前缩放级别
let zoom2 = Math.min(5, zoom1 + 0.5); // 设置放大范围,最大是5
this.graph.zoom(zoom2 - zoom1);//在原来缩放级别上增加多少
},
zoomOut () {
let zoom1 = this.graph.zoom();// 获取当前缩放级别
let zoom2 = Math.max(0.5, zoom1 - 0.5);// 设置缩小范围,最小是0.5
this.graph.zoom(zoom2 - zoom1);//在原来缩放级别上减少多少
},
importData (file) {
let that = this
let reader = new FileReader() //new一个FileReader实例
reader.readAsText(file.file)
reader.onload = function (f) {
// 读取文件获得的对象
let data = JSON.parse(this.result)
// 这两行代码是将获取到的对象,转化为节点对象
that.nodeProps = data.nodeProps
that.graph.fromJSON(data.cellInfo)
}
},
exportData () {
let data = {
nodeProps: this.nodeProps,
cellInfo: this.graph.toJSON()
}
//定义文件内容,类型必须为Blob 否则createObjectURL会报错
let content = new Blob([JSON.stringify(data)])
//生成url对象
let urlObject = window.URL || window.webkitURL || window
let url = urlObject.createObjectURL(content)
//生成<a></a>DOM元素
let el = document.createElement('a')
//链接赋值
el.href = url
el.download = "剧情文件.txt"
//必须点击否则不会下载
el.click()
//移除链接释放资源
urlObject.revokeObjectURL(url)
el.remove();
},
handleClose (done) {
this.$refs.form.validate((result) => {
if (result) {
this.$message.success('验证通过')
this.node.label = this.form.title;
done();
} else {
this.$message.error('表单验证失败,请填写正确后关闭')
}
})
},
},
}
</script>
<style lang='scss'>
.antv {
height: 100vh;
width: 100%;
background-color: rgba($color: #333, $alpha: 0.1);
.el-container {
width: 100%;
height: 100%;
.el-header {
border: 1px solid yellow;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
padding-left: 200px;
.el-upload {
margin-left: 10px;
margin-right: 10px;
}
}
.el-aside {
width: 250px !important;
box-sizing: border-box;
height: 100%;
border: 1px solid red;
#left {
width: 100%;
height: 100%;
position: relative;
}
}
.el-main {
height: 100%;
width: 100%;
border: 1px solid blue;
#area {
width: 100%;
height: 100%;
}
}
}
.el-form {
padding: 20px;
.el-form-item__label {
font-weight: normal;
white-space: nowrap;
}
}
}
</style>
三、预览图

image.png
================================分割线==================================
四、新代码
<template>
<div class='antv'>
<el-container>
<el-header>
<p style="color:red">客户1、用户1暂时无法演示</p>
<el-button-group>
<el-button type="primary" icon="el-icon-zoom-in" @click="zoomIn" plain>放大</el-button>
<el-button type="primary" icon="el-icon-zoom-out" @click="zoomOut" plain>缩小</el-button>
</el-button-group>
<el-upload :show-file-list="false" ref="upload" :http-request="importData">
<el-button type="primary" icon="el-icon-upload2" plain>上传</el-button>
</el-upload>
<el-button type="primary" icon="el-icon-download" @click="exportData" plain>下载</el-button>
<el-button type="primary" icon="el-icon-download" @click="exportSVG" plain>SVG</el-button>
<el-button type="primary" icon="el-icon-s-fold" @click="changeLayout" plain>布局</el-button>
<el-button type="primary" icon="el-icon-thumb" @click="handleChangePanning" plain>{{pannable?'拖拽':"框选"}}</el-button>
<el-button type="danger" icon="el-icon-refresh" @click="sync" plain>同步</el-button>
<el-button type="danger" icon="el-icon-close" @click="$emit('close')" plain>关闭</el-button>
</el-header>
<el-container>
<el-aside>
<div id="left" ref="left"></div>
</el-aside>
<el-main>
<div id="area" ref="area"></div>
</el-main>
</el-container>
</el-container>
<el-drawer size="30%" title="情节属性设置" :visible.sync="drawer" direction="rtl" :modal="false" :before-close="handleClose">
<el-form id="form" :model="form" :rules="rules" ref="form">
<el-form-item label="标题" label-width="60px" prop="title">
<el-input v-model="form.title" maxlength="20" placeholder="请输入标题内容"></el-input>
</el-form-item>
<el-form-item label="文本" label-width="60px" prop="text">
<el-input v-model="form.text" :autosize="{ minRows: 3, maxRows: 5}" type="textarea" maxlength="300" placeholder="请输入文本内容"></el-input>
</el-form-item>
<el-form-item label="分值" label-width="60px" prop="score">
<el-input-number v-model="form.score" controls-position="right" :min="0" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="评价" label-width="60px" prop="comment">
<el-input v-model="form.comment" :autosize="{ minRows: 3, maxRows: 5}" type="textarea" maxlength="300" placeholder="请输入评价内容"></el-input>
</el-form-item>
<el-form-item label="情节" label-width="60px" prop="nextPieceMode">
<el-select v-model="form.nextPieceMode" placeholder="请选择情节展开的方式">
<el-option label="用户选择" value="choose"></el-option>
<el-option label="随机进行" value="random"></el-option>
</el-select>
</el-form-item>
<el-form-item label="备注" label-width="60px" prop="remark">
<el-input v-model="form.remark" :autosize="{ minRows: 3, maxRows: 5}" type="textarea" maxlength="300" placeholder="请输入备注内容"></el-input>
</el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<script>
import { Addon, Graph, Shape, ObjectExt, Vector, DataUri } from '@antv/x6'
import { DagreLayout } from '@antv/layout'
import { generateDramaData, getDramaData } from '@/api/api'
export default {
name: 'antv',
components: {},
data () {
return {
graph: null,
nodeProps: {},//全部有效节点(非默认拖拽的节点,修改过得节点)的数据对象
node: null,//双击之后,选中的当前节点对象
drawer: false,
pannable: false,
seeTitle: false,
form: {
name: "",
text: "",
title: "",
score: 0,
comment: "",
speaker: "",
nextPieceMode: "",
remark: "",
},
rules: {
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ min: 0, max: 20, message: '长度在1到20个字符', trigger: 'blur' }
],
text: [
{ required: true, message: '请输入文本', trigger: 'blur' },
{ min: 1, max: 500, message: '长度在1到500个字符', trigger: 'blur' }
],
score: [
{ required: true, message: '得分不能为空' },
{ type: 'number', message: '得分必须是数字' },
{ pattern: /^(0|[1-9]\d?|100)$/, message: '范围在0-100', trigger: 'blur' }
],
comment: [
{ min: 1, max: 300, message: '长度在1到300个字符', trigger: 'blur' }
],
nextPieceMode: [
{ required: true, message: '情节发展方式不能为空' }
]
},
}
},
created () {
this.getDramaData()
},
mounted () {
this.initGraph()
},
methods: {
getDramaData () {
getDramaData(this.$route.query.dramaId).then((res) => {
if (res.data.jsonObjectData) {
let cells = res.data.jsonObjectData.cellInfo.cells
let isOld = false
for(let index in cells) {
let cell = cells[index]
if (cell.shape === "examiner" || cell.shape === "user" || cell.shape === "aside") {
isOld = true
break
}
}
if (isOld) {
this.initFromOldData(res.data)
} else {
this.nodeProps = res.data.jsonObjectData.nodeProps
this.graph.fromJSON(res.data.jsonObjectData.cellInfo)
// 强制刷新所有内容
this.graph.getNodes().forEach(node => {
// 设置新节点属性
this.form = this.nodeProps[node.id]
this.node = node
this.seeTitle = node.shape.startsWith("user") ? true : false
this.handleClose(() => {})
})
}
}
})
},
sync () {
this.$confirm('你确定保存该剧本下的这些情节吗?', '提示', {
confirmButtonText: '保存',
cancelButtonText: '取消',
type: 'warning'
}).then(() => { // 确定操作
//开启loading
const load = this.$loading({
lock: true,
text: 'Loading',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
generateDramaData({
dramaId: this.$route.query.dramaId,
cellInfo: this.graph.toJSON(),
nodeProps: this.nodeProps
}).then((res) => {
//关闭loading
load.close();
this.$emit('sync')
})
})
},
initGraph () {
const left = this.$refs.left
const area = this.$refs.area
// Graph 是图的载体
const graph = new Graph({
container: area,
history: true,
grid: true,//显示坐标点
// 画布滚动设置
scroller: {
enabled: true,
pannable: this.pannable,
pageVisible: true,
pageBreak: false,
},
// 画布缩放参数配置
mousewheel: {
enabled: true,
zoomAtMousePosition: true,
modifiers: ['ctrl', 'meta'],
minScale: 0.5,
maxScale: 5,
},
connecting: {
router: {
name: 'manhattan',
args: {
padding: 1,
},
},
connector: {
name: 'rounded',
args: {
radius: 8,
},
},
anchor: 'center',
connectionPoint: 'anchor',
allowBlank: false,
snap: {
radius: 20,
},
createEdge () {
return new Shape.Edge({
attrs: {
line: {
stroke: '#A2B1C3',
strokeWidth: 2,
targetMarker: {
name: 'block',
fill: 'blue',
width: 12,
height: 8,
},
},
},
zIndex: 0,
})
},
validateConnection ({ targetMagnet }) {
return !!targetMagnet
},
},
highlighting: {
magnetAdsorbed: {
name: 'stroke',
args: {
attrs: {
fill: '#5F95FF',
stroke: '#5F95FF',
},
},
},
},
resizing: true,
rotating: false,
// 选择器
selecting: {
enabled: true,
rubberband: !this.pannable, // 启用框选
multiple: true, // 启用多选
movable: true, // 多选可同时移动
// showNodeSelectionBox: true,
// showEdgeSelectionBox: true,
showNodeSelectionBox: false,
showEdgeSelectionBox: true,
},
snapline: true,
keyboard: true,
clipboard: true,
})
this.graph = graph;
// 左侧可被拖拽的图形节点所在的 “区域容器”
const stencil = new Addon.Stencil({
title: '流程图',
target: graph,
stencilGraphWidth: 250,
stencilGraphHeight: 500,
collapsable: false,
groups: [
{
title: '基础流程图',
name: 'group1',
collapsable: false
},
],
layoutOptions: {
columns: 1,//区域容器每行显示的节点数量
columnWidth: 220,//区域容器的行宽
rowHeight: 100,//区域容器的行高
},
})
left.appendChild(stencil.container)
// 构建图形连接桩
let buildBasePort = function (pos) {
return {
position: pos,
// position: {
// name: "absolute"
// },
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
}
}
// 注册两种图形节点
const ports = {
groups: {
top: buildBasePort("top"),
right: buildBasePort("right"),
bottom: buildBasePort("bottom"),
left: buildBasePort("left"),
},
items: [
{
group: 'top',
},
{
group: 'right',
},
{
group: 'bottom',
},
{
group: 'left',
},
],
}
let buildNode = function (color, name, title, content) {
return {
inherit: 'rect',
markup: [
{
tagName: 'rect',
selector: 'body',
},
{
tagName: 'rect',
selector: 'nameRect',
},
{
tagName: 'rect',
selector: 'titleRect',
},
{
tagName: 'rect',
selector: 'contentRect',
},
{
tagName: 'text',
selector: 'nameText',
},
{
tagName: 'text',
selector: 'titleText',
},
{
tagName: 'text',
selector: 'contentText',
},
],
attrs: {
rect: {
width: 220,
},
body: {
stroke: '#fff',
},
nameRect: {
fill: color,
stroke: '#fff',
strokeWidth: 0.5,
},
titleRect: {
fill: '#eff4ff',
stroke: '#fff',
strokeWidth: 0.5,
},
contentRect: {
fill: '#eff4ff',
stroke: '#fff',
strokeWidth: 0.5,
},
nameText: {
ref: 'nameRect',
refY: 0.5,
refX: 0.5,
textAnchor: 'middle',
fontWeight: 'bold',
fill: '#fff',
fontSize: 12,
},
titleText: {
ref: 'titleRect',
refY: 0.5,
refX: 5,
textAnchor: 'left',
fill: 'black',
fontSize: 10,
},
contentText: {
ref: 'contentRect',
refY: 0.5,
refX: 5,
textAnchor: 'left',
fill: 'black',
fontSize: 10,
textWrap: {
width: 210,
height: 54,
ellipsis: true,
},
},
},
ports: {...ports},
propHooks(meta) {
const { ...others } = meta
let offsetY = 0;
ObjectExt.setByPath(others, `attrs/nameText/text`, name)
ObjectExt.setByPath(others, `attrs/nameRect/height`, 28)
ObjectExt.setByPath(others, `attrs/nameRect/transform`,'translate(0,0)')
offsetY += 28
if (meta.shape.startsWith("user")) {
ObjectExt.setByPath(others, `attrs/titleText/text`, title)
ObjectExt.setByPath(others, `attrs/titleRect/height`, 28)
ObjectExt.setByPath(others, `attrs/titleRect/transform`,'translate(0,' + offsetY + ')')
offsetY += 28
}
const maxContentLines = 3
const lineLength = 20
const lines = Math.min(Math.max(1, Math.ceil(content.length * 1.0 / lineLength)), maxContentLines)
const contentHeight = 12 * lines + 16
ObjectExt.setByPath(others, `attrs/contentText/text`, content)
ObjectExt.setByPath(others, `attrs/contentRect/height`, contentHeight)
ObjectExt.setByPath(others, `attrs/contentRect/transform`,'translate(0,' + offsetY + ')')
others.size = { width: 220, height: offsetY + contentHeight }
return others
},
}
}
Graph.registerNode('customer0', buildNode('#5f95ff', '客户_0', "", "内容"), true)
Graph.registerNode('customer1', buildNode('#5f95ff', '客户_1', "", "内容"), true)
Graph.registerNode('user0', buildNode('#ff8000', '用户_0', "标题", "内容"), true)
Graph.registerNode('user1', buildNode('#ff8000', '用户_1', "标题", "内容"), true)
Graph.registerNode('voiceOver', buildNode('#006600', '旁白', "", "内容"), true)
const r00 = graph.createNode({"shape": "customer0"})
const r01 = graph.createNode({"shape": "customer1"})
const r10 = graph.createNode({"shape": "user0"})
const r11 = graph.createNode({"shape": "user1"})
const r20 = graph.createNode({"shape": "voiceOver"})
// 加入模板节点
stencil.load([r00, r01, r10, r11, r20], 'group1')
// 配置键盘快捷键
// select all
graph.bindKey(['meta+a', 'ctrl+a'], () => {
const nodes = graph.getNodes()
if (nodes) {
graph.select(nodes)
}
})
// copy
graph.bindKey(['meta+c', 'ctrl+c'], () => {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.copy(cells)
}
return false
})
// cut
graph.bindKey(['meta+x', 'ctrl+x'], () => {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.cut(cells)
}
return false
})
// paste
graph.bindKey(['meta+v', 'ctrl+v'], () => {
if (!graph.isClipboardEmpty()) {
const cells = graph.paste({ offset: 32 })
graph.cleanSelection()
graph.select(cells)
}
return false
})
//delete
graph.bindKey('backspace', () => {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.removeCells(cells)
}
})
// 节点撤销
graph.bindKey(['meta+z', 'ctrl+z'], () => {
if (graph.history.canUndo()) {
graph.history.undo()
}
return false
})
// 节点撤销后,的恢复操作
graph.bindKey(['meta+shift+z', 'ctrl+shift+z'], () => {
if (graph.history.canRedo()) {
graph.history.redo()
}
return false
})
graph.on('node:added', ({ node: curNode }) => {
// 初始化数据
this.nodeProps[curNode.id] = {
name: curNode.attrs.nameText.text,
title: curNode.attrs.titleText.text,
text: curNode.attrs.contentText.text,
score: 0,
comment: "",
nextPieceMode: "choose",
speaker: curNode.shape
};
this.form = this.nodeProps[curNode.id]
this.node = curNode
})
// 监听节点事件函数
graph.on('node:removed', ({ node: curNode }) => {
// 更新有效节点数据对象
delete this.nodeProps[curNode.id];
})
// 节点双击事件 存储当前节点对象,把当前节点对象存到nodeProps(有效节点数据对象)里
graph.on('node:dblclick', ({ node: curNode }) => {
this.node = curNode;
this.seeTitle = this.node.shape.startsWith('user')
this.form = this.nodeProps[this.node.id];
this.drawer = true;
})
// 单机节点时 展示数据流向
graph.on('node:click', ({ node: curNode }) => {
let outEdges = this.graph.getOutgoingEdges(curNode)
if (outEdges) {
outEdges.forEach(edge => {
let view = this.graph.findViewByCell(edge);
let token = Vector.create('circle', { r: 6, fill: 'green' })
let stop = view.sendToken(token.node, 1000, () => { })
// 2s 后停止该动画
setTimeout(stop, 2000)
})
}
let inEdges = this.graph.getIncomingEdges(curNode)
if (inEdges) {
inEdges.forEach(edge => {
let view = this.graph.findViewByCell(edge);
let token = Vector.create('circle', { r: 6, fill: 'red' })
let stop = view.sendToken(token.node, 1000, () => { })
// 2s 后停止该动画
setTimeout(stop, 2000)
})
}
})
// 控制全部节点对象的锚点 显示/隐藏
graph.on('node:mouseenter', () => {
const ports = area.querySelectorAll('.x6-port-body')
ports.forEach(port => {
port.style.visibility = 'visible'
});
})
graph.on('node:mouseleave', () => {
const ports = area.querySelectorAll('.x6-port-body')
ports.forEach(port => {
port.style.visibility = 'hidden'
});
})
},
// TODO 临时用
initFromOldData(data) {
this.nodeProps = data.jsonObjectData.nodeProps
let cells = data.jsonObjectData.cellInfo.cells
let edges = []
let nodes = []
for (let index in cells) {
let cell = cells[index]
if (cell.shape === "edge") {
edges.push(cell)
} else {
nodes.push(cell)
}
}
let newNodes = []
let newNodeMap = {}
nodes.forEach(node => {
let shape = null
if (node.shape === "user") {
this.nodeProps[node.id].name = "用户_0"
this.nodeProps[node.id].speaker = "user0"
shape = "user0"
}
if (node.shape === "examiner") {
this.nodeProps[node.id].name = "客户_0"
this.nodeProps[node.id].title = ""
this.nodeProps[node.id].speaker = "customer0"
shape = "customer0"
}
if (node.shape === "aside") {
this.nodeProps[node.id].name = "旁白"
this.nodeProps[node.id].title = ""
this.nodeProps[node.id].speaker = "voiceOver"
shape = "voiceOver"
}
// 创建新节点
let newNode = this.graph.createNode({
"id": node.id,
"shape": shape
})
newNode.attrs.nameText.text = this.nodeProps[node.id].name
newNode.attrs.titleText.text = this.nodeProps[node.id].title
newNode.attrs.contentText.text = this.nodeProps[node.id].text
let newNodeJson = newNode.toJSON()
newNodes.push(newNodeJson)
newNodeMap[node.id] = newNodeJson
})
// 重新规划布局的点 source取bottom点, target取top点
edges.forEach(edge => {
edge.source.port = newNodeMap[edge.source.cell].ports.items[2].id
edge.target.port = newNodeMap[edge.target.cell].ports.items[0].id
})
let dagreLayout = new DagreLayout({
type: 'dagre',
rankdir: 'TB',
align: 'DL',
controlPoints: true,
})
let newModel = dagreLayout.layout({
nodes: newNodes,
edges: edges
})
// 坐标转换
newModel.nodes.forEach(node => {
node.x -= node.size.width / 2;
node.y -= node.size.height / 2;
})
this.graph.fromJSON(newModel)
// 强制刷新所有内容
this.graph.getNodes().forEach(node => {
// 设置新节点属性
this.form = this.nodeProps[node.id]
this.node = node
this.seeTitle = node.shape.startsWith("user") ? true : false
this.handleClose(() => {})
})
},
changeLayout () {
let edges = []
let nodes = []
let nodeMap = {}
this.graph.toJSON().cells.forEach(cell => {
if (cell.shape === "edge") {
edges.push(cell)
} else {
nodes.push(cell)
nodeMap[cell.id] = cell
}
});
let dagreLayout = new DagreLayout({
type: 'dagre',
rankdir: 'TB',
align: 'DL',
controlPoints: true,
})
// 重新规划布局的点 source取bottom点, target取top点
edges.forEach(edge => {
edge.source.port = nodeMap[edge.source.cell].ports.items[2].id
edge.target.port = nodeMap[edge.target.cell].ports.items[0].id
})
let newModel = dagreLayout.layout({
nodes: nodes,
edges: edges
})
newModel.nodes.forEach(node => {
// 需要注意的是,布局算法返回的 x、y 其实是节点的中心点坐标,在 X6 中,节点的 x、y 其实是左上角坐标,所以布局之后,我们需要做一次坐标转换。
node.x -= node.size.width / 2;
node.y -= node.size.height / 2;
})
this.graph.fromJSON(newModel)
},
zoomIn () {
let zoom1 = this.graph.zoom();//获取当前缩放级别
let zoom2 = Math.min(5, zoom1 + 0.2); // 设置放大范围,最大是5
this.graph.zoom(zoom2 - zoom1);//在原来缩放级别上增加多少
},
zoomOut () {
let zoom1 = this.graph.zoom();// 获取当前缩放级别
let zoom2 = Math.max(0.2, zoom1 - 0.2);// 设置缩小范围,最小是0.2
this.graph.zoom(zoom2 - zoom1);//在原来缩放级别上减少多少
},
importData (file) {
let that = this
let reader = new FileReader() //new一个FileReader实例
reader.readAsText(file.file)
reader.onload = function (f) {
// 读取文件获得的对象
let data = JSON.parse(this.result)
// 这两行代码是将获取到的对象,转化为节点对象
that.nodeProps = data.nodeProps
that.graph.fromJSON(data.cellInfo)
}
},
exportData () {
let data = {
nodeProps: this.nodeProps,
cellInfo: this.graph.toJSON()
}
//定义文件内容,类型必须为Blob 否则createObjectURL会报错
let content = new Blob([JSON.stringify(data)])
//生成url对象
let urlObject = window.URL || window.webkitURL || window
let url = urlObject.createObjectURL(content)
//生成<a></a>DOM元素
let el = document.createElement('a')
//链接赋值
el.href = url
el.download = "剧情文件.txt"
//必须点击否则不会下载
el.click()
//移除链接释放资源
urlObject.revokeObjectURL(url)
el.remove();
},
exportSVG() {
this.graph.toSVG((dataUri) => {
// 下载
DataUri.downloadDataUri(DataUri.svgToDataUrl(dataUri), '剧本图片.svg')
}, {
preserveDimensions: true,
copyStyles: false
})
},
handleChangePanning () {
this.pannable = !this.pannable
if (this.pannable) {
// 可以拖拽,取消框选
this.graph.enablePanning()
this.graph.disableSelection()
} else {
// 不可拖拽,打开框选
this.graph.disablePanning()
this.graph.enableSelection()
}
},
handleClose (done) {
let maxContentLines = 3
let lineLength = 20
let lines = Math.min(Math.max(1, Math.ceil(this.form.text.length * 1.0 / lineLength)), maxContentLines)
let contentHeight = 12 * lines + 16
let newAttrs = {
"nameText": {
"text": this.form.name
},
"contentText": {
"text": this.form.text
},
"contentRect": {
"height": contentHeight
}
}
let offsetY = 28
if (this.seeTitle) {
newAttrs['titleText'] = {"text" : this.form.title}
offsetY = 56
} else {
this.form.title = ""
}
this.node.updateAttrs(ObjectExt.merge({}, this.node.attrs, newAttrs), {silent: false})
this.node.size(220, offsetY + contentHeight);
// 强制刷新该节点相关的连线
let outEdges = this.graph.getOutgoingEdges(this.node)
let incomingEdges = this.graph.getIncomingEdges(this.node)
if (outEdges) {
outEdges.forEach(edge => {
edge.updateAttrs(ObjectExt.merge({}, this.node.attrs, {}), {silent:false})
})
}
if (incomingEdges) {
incomingEdges.forEach(edge => {
edge.updateAttrs(ObjectExt.merge({}, this.node.attrs, {}), {silent:false})
})
}
done();
},
}
}
</script>
<style lang='scss'>
.antv {
height: 100vh;
width: 100%;
background-color: #fff;
.x6-widget-stencil {
background-color: #fff;
}
.x6-widget-transform {
margin: -1px 0 0 -1px;
padding: 0px;
border: 1px solid #239edd;
}
.x6-widget-transform > div {
border: 1px solid #239edd;
}
.x6-widget-transform > div:hover {
background-color: #3dafe4;
}
.x6-widget-transform-active-handle {
background-color: #3dafe4;
}
.x6-widget-transform-resize {
border-radius: 0;
visibility: hidden; // 设置resize的节点不可见
}
.x6-widget-selection-inner {
border: 1px solid #239edd;
}
.x6-widget-selection-box {
opacity: 1;
}
.el-container {
width: 100%;
height: 100%;
.el-header {
border: 1px solid yellow;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
padding-left: 200px;
.el-upload {
margin-left: 10px;
margin-right: 10px;
}
}
.el-aside {
width: 250px !important;
box-sizing: border-box;
height: 100%;
border: 1px solid red;
#left {
width: 100%;
height: 100%;
position: relative;
}
}
.el-main {
height: 100%;
width: 100%;
border: 1px solid blue;
#area {
width: 100%;
height: 100%;
}
}
}
.el-form {
padding: 20px;
.el-form-item__label {
font-weight: normal;
white-space: nowrap;
}
}
}
</style>
五、新预览图

image.png