一、需要安裝的依賴
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">導(dǎo)入</el-button>
</el-upload>
<el-button type="info" @click="exportData">導(dǎo)出</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="情節(jié)屬性設(shè)置" :visible.sync="drawer" direction="rtl" :before-close="handleClose">
<el-form id="form" :model="form" :rules="rules" ref="form">
<el-form-item label="標(biāo)題" label-width="60px" prop="title">
<el-input v-model="form.title" maxlength="12" placeholder="請輸入標(biāo)題內(nèi)容"></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="請輸入文本內(nèi)容"></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="請輸入評價內(nèi)容"></el-input>
</el-form-item>
<el-form-item label="情節(jié)" label-width="60px" prop="nextPieceMode">
<el-select v-model="form.nextPieceMode" placeholder="請選擇情節(jié)展開的方式">
<el-option label="用戶選擇" value="choose"></el-option>
<el-option label="隨機(jī)進(jìn)行" 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: {},//全部有效節(jié)點(非默認(rèn)拖拽的節(jié)點,修改過得節(jié)點)的數(shù)據(jù)對象
node: null,//雙擊之后,選中的當(dāng)前節(jié)點對象
drawer: false,
form: {
text: "",
title: "",
score: 20,
comment: "",
nextPieceMode: ""
},
rules: {
title: [
{ required: true, message: '請輸入標(biāo)題', 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: '得分必須是數(shù)字' },
{ 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: '情節(jié)發(fā)展方式不能為空' }
]
},
}
},
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,//顯示坐標(biāo)點
// 畫布縮放參數(shù)配置
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;
// 左側(cè)可被拖拽的圖形節(jié)點所在的 “區(qū)域容器”
const stencil = new Addon.Stencil({
title: '流程圖',
target: graph,
stencilGraphWidth: 250,
stencilGraphHeight: 180,
collapsable: false,
groups: [
{
title: '基礎(chǔ)流程圖',
name: 'group1',
},
],
layoutOptions: {
columns: 1,//區(qū)域容器每行顯示的節(jié)點數(shù)量
columnWidth: 200,//區(qū)域容器的行寬
rowHeight: 60,//區(qū)域容器的行高
},
})
left.appendChild(stencil.container)
// 注冊兩種圖形節(jié)點
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,
)
// 將兩種圖形節(jié)點添加到“區(qū)域容器”中
const r1 = graph.createNode({
shape: 'RectAi',
label: 'AI情節(jié)',
})
const r2 = graph.createNode({
shape: 'RectMe',
label: '客戶情節(jié)',
})
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)
}
})
// 節(jié)點撤銷
graph.bindKey(['meta+z', 'ctrl+z'], () => {
if (graph.history.canUndo()) {
graph.history.undo()
}
return false
})
// 節(jié)點撤銷后,的恢復(fù)操作
graph.bindKey(['meta+shift+z', 'ctrl+shift+z'], () => {
if (graph.history.canRedo()) {
graph.history.redo()
}
return false
})
// 監(jiān)聽節(jié)點事件函數(shù)
graph.on('node:removed', ({ node: curNode }) => {
// 更新有效節(jié)點數(shù)據(jù)對象
delete this.nodeProps[curNode.id];
})
// 節(jié)點雙擊事件 存儲當(dāng)前節(jié)點對象,把當(dāng)前節(jié)點對象存到nodeProps(有效節(jié)點數(shù)據(jù)對象)里
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;
})
// 控制全部節(jié)點對象的錨點 顯示/隱藏
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 其實是節(jié)點的中心點坐標(biāo),在 X6 中,節(jié)點的 x、y 其實是左上角坐標(biāo),所以布局之后,我們需要做一次坐標(biāo)轉(zhuǎn)換。
node.x -= node.size.width / 2;
node.y -= node.size.height / 2;
console.log(node)
})
// 解決布局之后,節(jié)點之間的連線跑到節(jié)點中間去了,我們需要描點之間的連線
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();//獲取當(dāng)前縮放級別
let zoom2 = Math.min(5, zoom1 + 0.5); // 設(shè)置放大范圍,最大是5
this.graph.zoom(zoom2 - zoom1);//在原來縮放級別上增加多少
},
zoomOut () {
let zoom1 = this.graph.zoom();// 獲取當(dāng)前縮放級別
let zoom2 = Math.max(0.5, zoom1 - 0.5);// 設(shè)置縮小范圍,最小是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)
// 這兩行代碼是將獲取到的對象,轉(zhuǎn)化為節(jié)點對象
that.nodeProps = data.nodeProps
that.graph.fromJSON(data.cellInfo)
}
},
exportData () {
let data = {
nodeProps: this.nodeProps,
cellInfo: this.graph.toJSON()
}
//定義文件內(nèi)容,類型必須為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('表單驗證失敗,請?zhí)顚懻_后關(guān)閉')
}
})
},
},
}
</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>
三、預(yù)覽圖

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>關(guān)閉</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="情節(jié)屬性設(shè)置" :visible.sync="drawer" direction="rtl" :modal="false" :before-close="handleClose">
<el-form id="form" :model="form" :rules="rules" ref="form">
<el-form-item label="標(biāo)題" label-width="60px" prop="title">
<el-input v-model="form.title" maxlength="20" placeholder="請輸入標(biāo)題內(nèi)容"></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="請輸入文本內(nèi)容"></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="請輸入評價內(nèi)容"></el-input>
</el-form-item>
<el-form-item label="情節(jié)" label-width="60px" prop="nextPieceMode">
<el-select v-model="form.nextPieceMode" placeholder="請選擇情節(jié)展開的方式">
<el-option label="用戶選擇" value="choose"></el-option>
<el-option label="隨機(jī)進(jìn)行" 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="請輸入備注內(nèi)容"></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: {},//全部有效節(jié)點(非默認(rèn)拖拽的節(jié)點,修改過得節(jié)點)的數(shù)據(jù)對象
node: null,//雙擊之后,選中的當(dāng)前節(jié)點對象
drawer: false,
pannable: false,
seeTitle: false,
form: {
name: "",
text: "",
title: "",
score: 0,
comment: "",
speaker: "",
nextPieceMode: "",
remark: "",
},
rules: {
title: [
{ required: true, message: '請輸入標(biāo)題', 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: '得分必須是數(shù)字' },
{ 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: '情節(jié)發(fā)展方式不能為空' }
]
},
}
},
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)
// 強(qiáng)制刷新所有內(nèi)容
this.graph.getNodes().forEach(node => {
// 設(shè)置新節(jié)點屬性
this.form = this.nodeProps[node.id]
this.node = node
this.seeTitle = node.shape.startsWith("user") ? true : false
this.handleClose(() => {})
})
}
}
})
},
sync () {
this.$confirm('你確定保存該劇本下的這些情節(jié)嗎?', '提示', {
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) => {
//關(guān)閉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,//顯示坐標(biāo)點
// 畫布滾動設(shè)置
scroller: {
enabled: true,
pannable: this.pannable,
pageVisible: true,
pageBreak: false,
},
// 畫布縮放參數(shù)配置
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;
// 左側(cè)可被拖拽的圖形節(jié)點所在的 “區(qū)域容器”
const stencil = new Addon.Stencil({
title: '流程圖',
target: graph,
stencilGraphWidth: 250,
stencilGraphHeight: 500,
collapsable: false,
groups: [
{
title: '基礎(chǔ)流程圖',
name: 'group1',
collapsable: false
},
],
layoutOptions: {
columns: 1,//區(qū)域容器每行顯示的節(jié)點數(shù)量
columnWidth: 220,//區(qū)域容器的行寬
rowHeight: 100,//區(qū)域容器的行高
},
})
left.appendChild(stencil.container)
// 構(gòu)建圖形連接樁
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',
},
},
},
}
}
// 注冊兩種圖形節(jié)點
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', "", "內(nèi)容"), true)
Graph.registerNode('customer1', buildNode('#5f95ff', '客戶_1', "", "內(nèi)容"), true)
Graph.registerNode('user0', buildNode('#ff8000', '用戶_0', "標(biāo)題", "內(nèi)容"), true)
Graph.registerNode('user1', buildNode('#ff8000', '用戶_1', "標(biāo)題", "內(nèi)容"), true)
Graph.registerNode('voiceOver', buildNode('#006600', '旁白', "", "內(nèi)容"), 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"})
// 加入模板節(jié)點
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)
}
})
// 節(jié)點撤銷
graph.bindKey(['meta+z', 'ctrl+z'], () => {
if (graph.history.canUndo()) {
graph.history.undo()
}
return false
})
// 節(jié)點撤銷后,的恢復(fù)操作
graph.bindKey(['meta+shift+z', 'ctrl+shift+z'], () => {
if (graph.history.canRedo()) {
graph.history.redo()
}
return false
})
graph.on('node:added', ({ node: curNode }) => {
// 初始化數(shù)據(jù)
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
})
// 監(jiān)聽節(jié)點事件函數(shù)
graph.on('node:removed', ({ node: curNode }) => {
// 更新有效節(jié)點數(shù)據(jù)對象
delete this.nodeProps[curNode.id];
})
// 節(jié)點雙擊事件 存儲當(dāng)前節(jié)點對象,把當(dāng)前節(jié)點對象存到nodeProps(有效節(jié)點數(shù)據(jù)對象)里
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;
})
// 單機(jī)節(jié)點時 展示數(shù)據(jù)流向
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)
})
}
})
// 控制全部節(jié)點對象的錨點 顯示/隱藏
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"
}
// 創(chuàng)建新節(jié)點
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
})
// 重新規(guī)劃布局的點 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
})
// 坐標(biāo)轉(zhuǎn)換
newModel.nodes.forEach(node => {
node.x -= node.size.width / 2;
node.y -= node.size.height / 2;
})
this.graph.fromJSON(newModel)
// 強(qiáng)制刷新所有內(nèi)容
this.graph.getNodes().forEach(node => {
// 設(shè)置新節(jié)點屬性
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,
})
// 重新規(guī)劃布局的點 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 其實是節(jié)點的中心點坐標(biāo),在 X6 中,節(jié)點的 x、y 其實是左上角坐標(biāo),所以布局之后,我們需要做一次坐標(biāo)轉(zhuǎn)換。
node.x -= node.size.width / 2;
node.y -= node.size.height / 2;
})
this.graph.fromJSON(newModel)
},
zoomIn () {
let zoom1 = this.graph.zoom();//獲取當(dāng)前縮放級別
let zoom2 = Math.min(5, zoom1 + 0.2); // 設(shè)置放大范圍,最大是5
this.graph.zoom(zoom2 - zoom1);//在原來縮放級別上增加多少
},
zoomOut () {
let zoom1 = this.graph.zoom();// 獲取當(dāng)前縮放級別
let zoom2 = Math.max(0.2, zoom1 - 0.2);// 設(shè)置縮小范圍,最小是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)
// 這兩行代碼是將獲取到的對象,轉(zhuǎn)化為節(jié)點對象
that.nodeProps = data.nodeProps
that.graph.fromJSON(data.cellInfo)
}
},
exportData () {
let data = {
nodeProps: this.nodeProps,
cellInfo: this.graph.toJSON()
}
//定義文件內(nèi)容,類型必須為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);
// 強(qiáng)制刷新該節(jié)點相關(guān)的連線
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; // 設(shè)置resize的節(jié)點不可見
}
.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>
五、新預(yù)覽圖

image.png