封面图与文字内容~图文无关
1. 背景
设计稿标注是使用开源的sketch-meaxure插件导出的HTML文件,但是它缺少设计稿之间的关系展示,特别是APP设计稿,每个页面之间的流转关系,对于APP、H5开发来说是很有必要的。所以需求就是,参考蓝湖设计稿的全景图,每个设计稿之间可以设置连线、随意拖动、对齐操作等功能。
2.调研
通过调研发现,蓝湖设计稿的全景图是采用canvas加html两者结合的方式来实现,但是canvas的API有点繁琐,所以放弃该方案。
继续搜寻,看了一下jsplumb、joint、Raphael、GoJS、dagre-d3这几个,最后选择了Jsplumb,因为它开源,使用起来也比较方便,文档也是比较齐全的。
3.开发与配置
1、jsplumd基本概念:
- Souce 源节点
- Target 目标节点
- Anchor 锚点 锚点位于源节点或者目标节点上
- Endpoint 端点 端点位于连线上
- Connector 连接 或者也可以理解是连接线
- Overlays 可以理解为在连接线上的文字或者箭头之类的东东
2、我们把设计稿图片放在Source和Target节点上,这样就可以实现设计稿之间的关系连线了。我们先来定义一个对象,用来存放连线和设计稿节点
{
// 连线列表
"lineList": [
{
"from": "673A5A92-B016-4A88-9E3F-200823AAAC0A",
"remark": "",
"to": "A0A2DCBA-69F9-47E1-9252-06B3F916E1EB",
"label": "连线名称",
"id": "v0iegtpwvxc00"
}
],
// 节点列表
"nodeList": [
{
"pagination": 0,
"top": "380px",
"left": "745px",
"imagePath": "https://xxx.test.yingzi.com/xxx/uploads/design/1644483586621/ISV开放平台-商家运营005/preview/%E5%95%86%E5%AE%B6%E8%BF%90%E8%90%A5-13-%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90-%E6%95%B0%E6%8D%AE%E6%A6%82%E5%86%B5-%E7%99%BD%E8%89%B2.png",
"name": "数据分析-数据概况-白色",
"id": "673A5A92-B016-4A88-9E3F-200823AAAC0A",
"type": "panorama"
}
]
}
3、节点组件,flowNode.vue 实现的功能有右键菜单、节点是否可编辑、节点是否选中、点击指定元素,保留选中节点的状态。
<template>
<div
class="node-item"
ref="nodeRef"
:class="[isActive || isSelected ? 'active' : '']"
:style="flowNodeContainer"
v-click-outside="setNotActive"
@click="setActive"
@dblclick="dblclickNode"
@mouseenter="showAnchor"
@mouseleave="hideAnchor"
@contextmenu.prevent="onContextmenu"
>
<!-- 右键菜单 -->
<ox-dropdown ref="contextMenuRef" trigger="click" placement="top-start" @command="onMenuCommand">
<span></span>
<ox-dropdown-menu slot="dropdown">
<ox-dropdown-item command="delete">删除</ox-dropdown-item>
</ox-dropdown-menu>
</ox-dropdown>
<!-- 节点名字/图片 -->
<div class="node-wrap">
<div class="node-name"><span>{{ nodeData.name }}</span></div>
<div class="img-wrap">
<img :src="nodeData.imagePath" alt="" />
</div>
</div>
<!--连线用--//触发连线的区域-->
<template v-if="isEdit">
<div class="node-anchor anchor-top" v-show="mouseEnter"></div>
<div class="node-anchor anchor-right" v-show="mouseEnter"></div>
<div class="node-anchor anchor-bottom" v-show="mouseEnter"></div>
<div class="node-anchor anchor-left" v-show="mouseEnter"></div>
</template>
</div>
</template>
<script>
// 当鼠标点击了指令所绑定元素的外部时,触发绑定方法
import ClickOutside from "vue-click-outside";
export default {
name: "flowNode",
components: {},
props: {
node: {
type:Object,
default: () => {
return {};
}
},
// 是否可以右键
isRightClick: {
type: Boolean,
default: () => {
return false;
}
},
// 是否可以编辑
isEdit: {
type: Boolean,
default: () => {
return true;
}
},
isSelect: {
type: Boolean,
default: () => {
return false;
}
},
// 点击指定元素,保留选中节点的状态
keepActive: {
type: Array,
default: () => {
return [];
}
},
},
watch: {
node(newVal, oldVal) {
if(newVal) {
this.nodeData = newVal;
}
},
isSelect(newVal, oldVal) {
this.isSelected = newVal;
}
},
directives: {
ClickOutside,
},
computed: {
// 节点容器样式
flowNodeContainer: {
get() {
return {
top: this.nodeData.top,
left: this.nodeData.left,
};
},
},
},
data() {
return {
nodeData: this.node,
mouseEnter: false,
isActive: false,
isSelected: this.isSelect,
};
},
mounted() {},
methods: {
showAnchor() {
this.mouseEnter = true;
},
hideAnchor() {
this.mouseEnter = false;
},
// 右键菜单
onContextmenu() {
if(this.isRightClick) {
this.$refs.contextMenuRef.handleClick();
}
},
onMenuCommand(type) {
switch (type) {
case "delete":
this.deleteNode();
break;
default:
break;
}
},
// 节点双击事件
dblclickNode() {
this.$emit("dblclickNode", this.nodeData);
},
setActive() {
if (window.event.ctrlKey) {
this.isSelected = !this.isSelected;
this.$emit("selectNode", this.nodeData, true);
return false;
}
this.isActive = true;
this.isSelected = false;
setTimeout(() => {
this.$emit("changeLineState", this.nodeData.id, true);
}, 0);
},
setNotActive(e) {
if(this.keepActive.includes(e.target.innerText)) {
return;
}
if (!window.event.ctrlKey) {
this.$emit("selectNode", this.nodeData, false);
this.isSelected = false;
}
if (!this.isActive) {
return;
}
this.$emit("changeLineState", this.nodeData.id, false);
this.isActive = false;
},
deleteNode() {
this.$emit("deleteNode", this.nodeData);
},
},
};
</script>
<style lang="less" scoped>
@labelColor: #409eff;
@nodeItemWidth: 278px;
@nodeSize: 20px;
@viewSize: 10px;
.node-item {
position: absolute;
width: @nodeItemWidth;
height: auto;
font-size: 0;
cursor: move;
box-sizing: content-box;
z-index: 9995;
&:hover {
z-index: 9998;
.delete-btn {
display: block;
}
}
.node-wrap{
display: block;
}
.node-name {
span{
display: inline-block;
background: #f0f2f5;
margin-bottom: 4px;
font-size: 14px;
line-height: 20px;
}
}
.img-wrap {
width: @nodeItemWidth;
min-height: 150px;
background: #ffffff;
img {
display: block;
width: @nodeItemWidth;
height: 100%;
}
}
.node-anchor {
display: flex;
position: absolute;
width: @nodeSize;
height: @nodeSize;
align-items: center;
justify-content: center;
border-radius: 10px;
cursor: crosshair;
z-index: 9999;
background: -webkit-radial-gradient(sandybrown 10%, white 30%, #0096ff 60%);
}
.anchor-top {
top: calc((@nodeSize / 2) * -1);
left: 50%;
margin-left: calc((@nodeSize / 2) * -1);
}
.anchor-right {
top: 50%;
right: calc((@nodeSize / 2) * -1);
margin-top: calc((@nodeSize / 2) * -1);
}
.anchor-bottom {
bottom: calc((@nodeSize / 2) * -1);
left: 50%;
margin-left: calc((@nodeSize / 2) * -1);
}
.anchor-left {
top: 50%;
left: calc((@nodeSize / 2) * -1);
margin-top: calc((@nodeSize / 2) * -1);
}
}
.active {
border: 1px dashed @labelColor;
box-shadow: 0px 5px 9px 0px rgba(0, 0, 0, 0.5);
}
</style>
4、鼠标框选指令
鼠标框选也算是一个比较常用的功能,所以将其封装成vue的全局指令,方便调用。在main.js
/**
* 鼠标左右键框选
*/
Vue.directive("batch-select", {
// v-batch-select="{ className: '.el-checkbox', selectNodes, onmouseType: 'right' }"
// 当被绑定的元素插入到 DOM 中时
inserted: (el, binding) => {
// 设置被绑定元素el(即上述的box)的position为relative,目的是让蓝色半透明遮罩area相对其定位
el.style.position = "relative";
// 记录el在视窗中的位置elPos
const { x, y } = el.getBoundingClientRect();
const elPos = { x, y };
// 获取该指令调用者传递过来的参数:className,表示要使用鼠标框选类名的元素
const optionClassName = binding.value.className;
// 创建一个div作为area区域,注意定位是absolute,visibility初始值是hidden
const area = document.createElement("div");
area.style =
"position: absolute; border: 1px solid #409eff; background-color: rgba(64, 158, 255, 0.1); z-index: 10; visibility: hidden;";
area.className = "area";
area.innerHTML = "";
el.appendChild(area);
el.onmousedown = (e) => {
// 获取鼠标按下时相对box的坐标
const startX = e.clientX - elPos.x;
const startY = e.clientY - elPos.y;
// 判断鼠标按下后是否发生移动的标识
let hasMove = false;
document.onmousemove = (e) => {
const onmouseType = binding.value.onmouseType || "left";
// onmouseType指定框选使用鼠标那个按键,1鼠标左键 2鼠标右键
if (
!(
(onmouseType == "left" && e.buttons == 1) ||
(onmouseType == "right" && e.buttons == 2)
)
) {
return;
}
hasMove = true;
// 显示area
area.style.visibility = "visible";
// 获取鼠标移动过程中指针的实时位置
const endX = e.clientX - elPos.x;
const endY = e.clientY - elPos.y;
// 这里使用绝对值是为了兼容鼠标从各个方向框选的情况
const width = Math.abs(endX - startX);
const height = Math.abs(endY - startY);
// 根据初始位置和实时位置计算出area的left、top、width、height
const left = Math.min(startX, endX);
const top = Math.min(startY, endY);
// 实时绘制
area.style.left = `${left}px`;
area.style.top = `${top}px`;
area.style.width = `${width}px`;
area.style.height = `${height}px`;
};
document.onmouseup = (e) => {
document.onmousemove = document.onmouseup = null;
if (hasMove) {
// 鼠标抬起时,如果之前发生过移动,则执行碰撞检测
const { left, top, width, height } = area.style;
const areaTop = parseInt(top);
const areaRight = parseInt(left) + parseInt(width);
const areaBottom = parseInt(top) + parseInt(height);
const areaLeft = parseInt(left);
binding.value.selectNodes.length = 0;
let options = [].slice.call(
document.querySelectorAll(optionClassName)
);
let optionsXYWH = [];
// 获取被框选对象们的x、y、width、height
options.forEach((v) => {
const obj = v.getBoundingClientRect();
optionsXYWH.push({
dom: v,
x: obj.x - elPos.x,
y: obj.y - elPos.y,
w: obj.width,
h: obj.height,
});
});
optionsXYWH.forEach((v, i) => {
const optionTop = v.y;
const optionRight = v.x + v.w;
const optionBottom = v.y + v.h;
const optionLeft = v.x;
if (
!(
areaTop > optionBottom ||
areaRight < optionLeft ||
areaBottom < optionTop ||
areaLeft > optionRight
)
) {
// 该指令的调用者可以监听到selectIdxs的变化
binding.value.selectNodes.push(v.dom);
}
});
}
// 恢复以下数据
hasMove = false;
area.style.visibility = "hidden";
area.style.left = 0;
area.style.top = 0;
area.style.width = 0;
area.style.height = 0;
return false;
};
};
},
});
// 调用示方式className表示要使用鼠标框选类名的元素、selectNodes选中的节点列表、onmouseType指定框选使用鼠标那个按键
<div v-batch-select="{ className: '.node-item', selectNodes, onmouseType: 'right' }">
...
</div>
5、全景图页面
使用panzoom控件实现画布的平移缩放、节点连线、节点框选、节点对齐操作功能。
<template>
<div class="flow_region">
<!-- 全景画板连线图 -->
<div
id="flowWrap"
ref="flowWrapRef"
class="flow-wrap"
@drop="drop($event)"
@dragover="allowDrop($event)"
@contextmenu.prevent
v-batch-select="{ className: '.node-item', selectNodes, onmouseType: 'right' }"
>
<div id="flow">
<!-- 辅助线 -->
<div
v-show="auxiliaryLine.isShowXLine"
class="auxiliary-line-x"
:style="{
width: auxiliaryLinePos.width,
top: auxiliaryLinePos.y + 'px',
left: auxiliaryLinePos.offsetX + 'px',
}"
></div>
<div
v-show="auxiliaryLine.isShowYLine"
class="auxiliary-line-y"
:style="{
height: auxiliaryLinePos.height,
left: auxiliaryLinePos.x + 'px',
top: auxiliaryLinePos.offsetY + 'px',
}"
></div>
<!-- 画板节点 -->
<FlowNode
v-for="item in flowItemData.nodeList"
:id="item.id"
:key="item.id"
:node="item"
:isEdit="isEdit"
:isSelect="getIsSelect(item.id)"
:keepActive="['左对齐', '右对齐', '顶对齐', '底对齐']"
@dblclickNode="dblclickNode"
@selectNode="selectNode"
@deleteNode="deleteNode"
@changeLineState="changeLineState"
></FlowNode>
</div>
</div>
<!-- 右上侧操作栏 -->
<div v-if="isEdit" class="panorama-right-top-wrap">
<ox-row style="margin-bottom: 10px">
<ox-button type="primary" plain @click="onSaveBtn"
>保存全景图</ox-button
>
</ox-row>
<ox-row style="margin-bottom: 10px">
<ox-col :span="5">
<ox-button size="mini" @click="onAlign('left')">左对齐</ox-button>
</ox-col>
<ox-col :span="5">
<ox-button size="mini" @click="onAlign('right')">右对齐</ox-button>
</ox-col>
<ox-col :span="5">
<ox-button size="mini" @click="onAlign('top')">顶对齐</ox-button>
</ox-col>
<ox-col :span="5">
<ox-button size="mini" @click="onAlign('bottom')">底对齐</ox-button>
</ox-col>
</ox-row>
<span class="desc-text">操作说明:</span>
<span class="desc-text">1、双击设计稿:跳转具体页面</span>
<span class="desc-text">2、删除连线:选中连线变色后,双击删除</span>
<span class="desc-text"
>3、多选:右键框选或先按住Ctrl,点击选中多个画板,然后拖动</span
>
</div>
</div>
</template>
<script>
import { jsPlumb } from "jsplumb";
import panzoom from "panzoom";
import { nodeTypeList } from "./config/init";
import {
jsplumbSetting,
jsplumbConnectOptions,
jsplumbSourceOptions,
jsplumbTargetOptions,
} from "./config/commonConfig";
import FlowNode from "./flowNode.vue";
export default {
name: "Panorama",
components: {
FlowNode,
},
props: {
// PRD详情数据
infoData: {
type: Object,
default: () => {
return {};
},
},
// 是否可以编辑
isEdit: {
type: Boolean,
default: () => {
return true;
},
},
},
data() {
this.nodeTop = 160; // 画板top位置
this.nodeWidth = 278; // 画板宽度,注意:该节点宽度需要与flowNode组件中的节点宽度保持一致
this.nodeHeight = 40; // 画板高度
this.nodeSpace = 100; // 画板之间的间距
return {
selectNodes: [],
itemData: this.infoData,
currentItem: null,
nodeTypeList: nodeTypeList,
flowItemData: {
nodeList: [], // 节点数据
lineList: [], // 连线数据
},
selectNodeList: [],
jsplumbSetting: jsplumbSetting,
jsplumbConnectOptions: jsplumbConnectOptions,
jsplumbSourceOptions: jsplumbSourceOptions,
jsplumbTargetOptions: jsplumbTargetOptions,
auxiliaryLine: { isShowXLine: false, isShowYLine: false }, // 对齐辅助线是否显示
auxiliaryLinePos: {
width: "100%",
height: "100%",
offsetX: 0,
offsetY: 0,
x: 20,
y: 20,
},
commonGrid: [5, 5], // 节点移动最小距离
rectAngle: {
px: "", // 多选框绘制时的起始点横坐标
py: "", // 多选框绘制时的起始点纵坐标
left: 0,
top: 0,
height: 0,
width: 0,
},
};
},
watch: {
// 监听自定义指令v-batch-select返回的selectNodes
selectNodes(list) {
this.selectNodeList = [];
if(list.length > 0) {
list.map(item => {
this.selectNodeList.push(`${item.id}`);
// 添加多个节点为拖动选中
this.jsPlumb.addToDragSelection(`${item.id}`);
})
} else {
// 删除所有节点选中
this.jsPlumb.clearDragSelection();
}
}
},
// 销毁事件
beforeDestroy() {
if (this.isEdit) {
this.onSaveBtn(false);
}
this.clearWork();
},
created() {
this.jsPlumb = jsPlumb.getInstance();
},
async mounted() {
if (this.itemData.id) {
await Promise.all([this.getPanoramaData(), this.getDesignData()]);
this.compareData();
this.fixNodesPosition();
this.$nextTick(() => {
this.init();
})
this.selectNodeList = []; // 选中的节点列表
}
},
methods: {
// 获取全景图连线数据
async getPanoramaData() {
await this.$axios
.get(`/xxx/prd/lineDataById?id=${this.itemData.id}`)
.then((response) => {
if (response.status === 200) {
this.panoramaData = response.data.object || {};
}
});
},
// 获取设计稿数据
async getDesignData() {
this.designData = [];
// 存在设计稿
if (this.itemData.designHtmlFileUrl) {
const patch = this.itemData.designHtmlFileUrl.slice(
0,
this.itemData.designHtmlFileUrl.lastIndexOf("/")
);
// 画板图片链接前缀
const imageBaseUrl = `${window.location.origin}/xxx/uploads/design${patch}/`;
await this.$axios
.get(`/xxx/uploads/design${patch}/data.json`)
.then((response) => {
if (response.status === 200) {
if (response.data && response.data.artboards) {
this.designData = this.getArtboardHtmlNode(
response.data.artboards || [],
imageBaseUrl
);
}
}
})
.catch(() => {
this.designData = [];
});
}
},
// 对比全景图画板节点和设计稿画板节点
compareData() {
// 已经有了全景图数据
if (
this.panoramaData &&
this.panoramaData.nodeList &&
this.panoramaData.nodeList.length
) {
this.designData.forEach((x) => {
this.panoramaData.nodeList.forEach((y) => {
if (x.id === y.id) {
// 保留之前画板的位置
x.top = y.top;
x.left = y.left;
}
});
});
// 保留之前画板的连线
this.flowItemData.lineList = this.panoramaData.lineList;
this.flowItemData.nodeList = this.designData;
} else {
this.flowItemData.lineList = [];
this.flowItemData.nodeList = this.designData;
}
},
// 获取设计稿画板节点
getArtboardHtmlNode(list, imageBaseUrl = "") {
if (!list) {
return [];
}
return list.map((item, index) => {
return {
type: "panorama", // 画板
name: item.name,
id: item.objectID,
top: `${this.nodeTop}px`,
left: `${(this.nodeWidth + this.nodeSpace) * index}px`,
pagination: index, // 页码
imagePath: `${imageBaseUrl}${item.imagePath}`, // 画板图片
};
});
},
init() {
if (this.jsPlumb) {
this.jsPlumb.ready(() => {
// 导入默认配置
this.jsPlumb.importDefaults(this.jsplumbSetting);
// 完成连线前的校验
this.jsPlumb.bind("beforeDrop", (evt) => {
let res = () => {}; // 此处可以添加是否创建连接的校验, 返回 false 则不添加;
return res;
});
// 连线创建成功后,维护本地数据
this.jsPlumb.bind("connection", (evt) => {
this.addLine(evt);
});
//连线双击删除事件
this.jsPlumb.bind("dblclick", (conn, originalEvent) => {
this.confirmDelLine(conn);
});
// 断开连线后,维护本地数据
this.jsPlumb.bind("connectionDetached", (evt) => {
this.deleLine(evt);
});
this.loadEasyFlow();
// 会使整个jsPlumb立即重绘。
this.jsPlumb.setSuspendDrawing(false, true);
});
this.initPanZoom();
}
},
// 清理工作
clearWork() {
this.jsPlumb.unbind("beforeDrop");
this.jsPlumb.unbind("connection");
this.jsPlumb.unbind("dblclick");
this.jsPlumb.unbind("connectionDetached");
this.jsPlumb.unbind("connection");
this.jsPlumb.cleanupListeners();
this.jsPlumb.clear();
this.jsPlumb = null;
},
// 生成指定长度的唯一ID
genNonDuplicateID(randomLength) {
return Number(
Math.random().toString().substr(3, randomLength) + Date.now()
).toString(36);
},
// 加载流程图
loadEasyFlow() {
// 初始化节点
for (let i = 0; i < this.flowItemData.nodeList.length; i++) {
let node = this.flowItemData.nodeList[i];
// 设置源点,可以拖出线连接其他节点
this.jsPlumb.makeSource(node.id, this.jsplumbSourceOptions);
// 设置目标点,其他源点拖出的线可以连接该节点
this.jsPlumb.makeTarget(node.id, this.jsplumbTargetOptions);
this.draggableNode(node.id);
}
// 初始化连线
this.jsPlumb.unbind("connection"); // 取消连接事件
for (let i = 0; i < this.flowItemData.lineList.length; i++) {
let line = this.flowItemData.lineList[i];
this.jsPlumb.connect(
{
source: line.from,
target: line.to,
},
this.jsplumbConnectOptions
);
}
this.jsPlumb.bind("connection", (evt) => {
let from = evt.source.id;
let to = evt.target.id;
this.flowItemData.lineList.push({
from: from,
to: to,
label: "连线名称",
id: this.genNonDuplicateID(8),
remark: "",
});
});
},
draggableNode(nodeId) {
this.jsPlumb.draggable(nodeId, {
grid: this.commonGrid,
drag: (params) => {
this.alignForLine(nodeId, params.pos);
},
start: () => {},
stop: (params) => {
this.auxiliaryLine.isShowXLine = false;
this.auxiliaryLine.isShowYLine = false;
this.changeNodePosition(nodeId, params.pos);
},
});
},
// 移动节点时,动态显示对齐线
alignForLine(nodeId, position) {
let showXLine = false,
showYLine = false;
this.flowItemData.nodeList.some((el) => {
if (el.id !== nodeId && el.left == position[0] + "px") {
this.auxiliaryLinePos.x = position[0] + 60;
showYLine = true;
}
if (el.id !== nodeId && el.top == position[1] + "px") {
this.auxiliaryLinePos.y = position[1] + 20;
showXLine = true;
}
});
this.auxiliaryLine.isShowYLine = showYLine;
this.auxiliaryLine.isShowXLine = showXLine;
},
changeNodePosition(nodeId, pos) {
this.flowItemData.nodeList.some((v) => {
if (nodeId == v.id) {
v.left = pos[0] + "px";
v.top = pos[1] + "px";
return true;
} else {
return false;
}
});
},
drag(ele, item) {
this.currentItem = item;
},
drop(event) {
const containerRect = this.jsPlumb.getContainer().getBoundingClientRect();
const scale = this.getScale();
let left = (event.pageX - containerRect.left - 60) / scale;
let top = (event.pageY - containerRect.top - 20) / scale;
var temp = {
...this.currentItem,
id: this.genNonDuplicateID(8),
top: Math.round(top / 20) * 20 + "px",
left: Math.round(left / 20) * 20 + "px",
};
this.addNode(temp);
},
addLine(line) {
let from = line.source.id;
let to = line.target.id;
this.flowItemData.lineList.push({
from: from,
to: to,
label: "连线名称",
id: this.genNonDuplicateID(8),
remark: "",
});
},
confirmDelLine(line) {
if (!this.isEdit) {
return;
}
this.$confirm("确认删除该连线?", "删除连线", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
// 确定
this.jsPlumb.deleteConnection(line);
})
.catch(() => {
// 取消
});
},
deleLine(line) {
this.flowItemData.lineList.forEach((item, index) => {
if (item.from === line.sourceId && item.to === line.targetId) {
this.flowItemData.lineList.splice(index, 1);
}
});
},
// dragover默认事件就是不触发drag事件,取消默认事件后,才会触发drag事件
allowDrop(event) {
event.preventDefault();
},
getScale() {
let scale1;
if (this.jsPlumb.pan) {
const { scale } = this.jsPlumb.pan.getTransform();
scale1 = scale;
} else {
const matrix = window.getComputedStyle(
this.jsPlumb.getContainer()
).transform;
scale1 = matrix.split(", ")[3] * 1;
}
this.jsPlumb.setZoom(scale1);
return scale1;
},
// 添加新的节点
addNode(temp) {
this.flowItemData.nodeList.push(temp);
this.$nextTick(() => {
this.jsPlumb.makeSource(temp.id, this.jsplumbSourceOptions);
this.jsPlumb.makeTarget(temp.id, this.jsplumbTargetOptions);
this.draggableNode(temp.id);
});
},
initPanZoom() {
const mainContainer = this.jsPlumb.getContainer();
const mainContainerWrap = mainContainer.parentNode;
const pan = panzoom(mainContainer, {
smoothScroll: true,
zoomDoubleClickSpeed: 1,
// minZoom: 0.3,
// maxZoom: 2,
// 设置滚动缩放的组合键,默认不需要组合键
beforeWheel: (e) => {
// let shouldIgnore = !e.ctrlKey
// return shouldIgnore
},
// 只有当ctrl按键关闭时,才允许鼠标向下平移
beforeMouseDown: function (e) {
// var shouldIgnore = e.ctrlKey;
// return shouldIgnore;
},
});
this.jsPlumb.mainContainerWrap = mainContainerWrap;
this.jsPlumb.pan = pan;
// 缩放时设置jsPlumb的缩放比率
pan.on("zoom", (e) => {
const { x, y, scale } = e.getTransform();
this.jsPlumb && this.jsPlumb.setZoom(scale);
// 根据缩放比例,缩放对齐辅助线长度和位置
this.auxiliaryLinePos.width = (1 / scale) * 100 + "%";
this.auxiliaryLinePos.height = (1 / scale) * 100 + "%";
this.auxiliaryLinePos.offsetX = -(x / scale);
this.auxiliaryLinePos.offsetY = -(y / scale);
});
// 平移结束
pan.on("panend", (e) => {
const { x, y, scale } = e.getTransform();
this.auxiliaryLinePos.width = (1 / scale) * 100 + "%";
this.auxiliaryLinePos.height = (1 / scale) * 100 + "%";
this.auxiliaryLinePos.offsetX = -(x / scale);
this.auxiliaryLinePos.offsetY = -(y / scale);
});
// 平移时设置鼠标样式
mainContainerWrap.style.cursor = "grab";
mainContainerWrap.addEventListener("mousedown", function wrapMousedown() {
this.style.cursor = "grabbing";
mainContainerWrap.addEventListener("mouseout", function wrapMouseout() {
this.style.cursor = "grab";
});
});
mainContainerWrap.addEventListener("mouseup", function wrapMouseup() {
this.style.cursor = "grab";
});
},
// 选中节点
selectNode(node, isSelected) {
if (isSelected) {
if (!this.selectNodeList.includes(node.id)) {
this.selectNodeList.push(node.id);
this.selectNodeList.map((item) => {
// 添加多个节点为拖动选中
this.jsPlumb.addToDragSelection(item);
});
}
} else {
this.selectNodeList = [];
// 删除所有节点选中
this.jsPlumb.clearDragSelection();
}
},
// 鼠标双击节点
dblclickNode(node) {
this.$emit("dblclickNode", node);
},
// 删除节点
deleteNode(node) {
this.flowItemData.nodeList.some((v, index) => {
if (v.id === node.id) {
this.flowItemData.nodeList.splice(index, 1);
this.jsPlumb.remove(v.id);
return true;
} else {
return false;
}
});
},
// 更改连线状态
changeLineState(nodeId, isActive) {
let lines = this.jsPlumb.getAllConnections();
lines.forEach((line) => {
if (line.targetId === nodeId || line.sourceId === nodeId) {
if (isActive) {
line.canvas.classList.add("active");
} else {
line.canvas.classList.remove("active");
}
}
});
},
// 初始化节点位置 (以便对齐,居中)
fixNodesPosition() {
if (this.flowItemData.nodeList && this.$refs.flowWrapRef) {
let wrapInfo = this.$refs.flowWrapRef.getBoundingClientRect();
let maxLeft = 0,
minLeft = wrapInfo.width,
maxTop = 0,
minTop = wrapInfo.height;
let nodePoint = {
left: 0,
right: 0,
top: 0,
bottom: 0,
};
let fixTop = 0,
fixLeft = 0;
this.flowItemData.nodeList.forEach((el) => {
let top = Number(el.top.substring(0, el.top.length - 2));
let left = Number(el.left.substring(0, el.left.length - 2));
maxLeft = left > maxLeft ? left : maxLeft;
minLeft = left < minLeft ? left : minLeft;
maxTop = top > maxTop ? top : maxTop;
minTop = top < minTop ? top : minTop;
});
nodePoint.left = minLeft;
nodePoint.right = wrapInfo.width - maxLeft - this.nodeWidth;
nodePoint.top = minTop;
nodePoint.bottom = wrapInfo.height - maxTop - this.nodeHeight;
fixTop =
nodePoint.top !== nodePoint.bottom
? (nodePoint.bottom - nodePoint.top) / 2
: 0;
fixLeft =
nodePoint.left !== nodePoint.right
? (nodePoint.right - nodePoint.left) / 2
: 0;
this.flowItemData.nodeList.map((el) => {
let top = Number(el.top.substring(0, el.top.length - 2)) + fixTop;
let left = Number(el.left.substring(0, el.left.length - 2)) + fixLeft;
el.top = Math.round(top / 20) * 20 + "px";
el.left = Math.round(left / 20) * 20 + "px";
});
}
},
// 保存全景图
onSaveBtn(isMessage = true) {
if (
this.itemData.id &&
this.flowItemData.nodeList &&
this.flowItemData.nodeList.length > 0
) {
this.clearFutilityLine();
let params = {
designModuleTitle: this.itemData.designModuleTitle,
id: this.itemData.id,
lineData: this.flowItemData,
};
this.$axios
.post(`/xxxx/prd/editDesignLine`, params)
.then((response) => {
if (response.status === 200) {
if (response.data.code === 0) {
isMessage && this.$message.success("保存成功");
} else if (response.data.code === -1) {
this.$message.error(response.data.message);
}
}
});
}
},
// 对齐方式
onAlign(type) {
if(this.selectNodeList.length < 2) {
this.$message.warning("至少选中两个画板");
return;
}
let arr = []; // 选中的节点
let leftArr = []; // 选中节点的left位置
let topArr = [];
this.flowItemData.nodeList.map((item) => {
this.selectNodeList.map((seleItem) => {
if (item.id == seleItem) {
arr.push(item);
leftArr.push(document.getElementById(seleItem).offsetLeft);
topArr.push(document.getElementById(seleItem).offsetTop);
}
});
});
let leftMax = Math.max.apply(null, leftArr);
let leftMin = Math.min.apply(null, leftArr);
let horizontalLeftMin = Math.min.apply(null, leftArr);
let topMax = Math.max.apply(null, topArr);
let topMin = Math.min.apply(null, topArr);
let verticalTopMin = Math.min.apply(null, topArr);
this.flowItemData.nodeList.forEach((item) => {
this.selectNodeList.forEach((seleItem) => {
if (item.id == seleItem) {
switch (type) {
case "left": // 左对齐
item.left = `${leftMin}px`;
item.top = `${verticalTopMin}px`;
verticalTopMin =
verticalTopMin + document.getElementById(`${seleItem}`).offsetHeight + this.nodeSpace;
break;
case "right": // 右对齐
item.left = `${leftMax}px`;
item.top = `${verticalTopMin}px`;
verticalTopMin =
verticalTopMin + document.getElementById(`${seleItem}`).offsetHeight + this.nodeSpace;
break;
case "top": // 顶对齐
item.top = `${topMin}px`;
item.left = `${horizontalLeftMin}px`;
horizontalLeftMin =
horizontalLeftMin + this.nodeWidth + this.nodeSpace;
break;
case "bottom": // 底对齐
item.top = `${topMax}px`;
item.left = `${horizontalLeftMin}px`;
horizontalLeftMin =
horizontalLeftMin + this.nodeWidth + this.nodeSpace;
break;
default:
break;
}
}
});
});
this.$nextTick(() => {
// 立即重绘
this.jsPlumb.setSuspendDrawing(false, true);
});
},
// 清空无用的连线数据
clearFutilityLine() {
if (this.flowItemData.lineList && this.flowItemData.lineList.length > 0) {
let arr = [];
this.flowItemData.nodeList.forEach((nodeItem) => {
this.flowItemData.lineList.forEach((lineItem) => {
if ([lineItem.from, lineItem.to].includes(nodeItem.id)) {
arr.push(lineItem);
}
});
});
let hash = {}
// 对象数组去重
arr = arr.reduce(function (item, next) {
hash[next.id] ? "" : (hash[next.id] = true && item.push(next));
return item;
}, []);
this.flowItemData.lineList = arr;
}
},
getIsSelect(id) {
return this.selectNodeList.includes(id);
},
}
};
</script>
6、jsPlumd配置,commonConfig.js
export const jsplumbSetting = {
grid: [10, 10],
// 动态锚点、位置自适应
Anchors: ["TopCenter", "RightMiddle", "BottomCenter", "LeftMiddle"],
Container: "flow",
// 连线的样式 StateMachine、Flowchart,有四种默认类型:Bezier(贝塞尔曲线),Straight(直线),Flowchart(流程图),State machine(状态机)
Connector: [
"Bezier",
{ curviness: '30' }
],
// 鼠标不能拖动删除线
ConnectionsDetachable: false,
// 删除线的时候节点不删除
DeleteEndpointsOnDetach: false,
// 连线的端点
// Endpoint: ["Dot", {radius: 5}],
Endpoint: [
"Rectangle",
{
height: 10,
width: 10,
},
],
// 线端点的样式
EndpointStyle: {
fill: "rgba(255,255,255,0)",
outlineWidth: 1,
},
LogEnabled: false, //是否打开jsPlumb的内部日志记录
// 绘制线
PaintStyle: {
stroke: "#409eff",
strokeWidth: 2,
},
HoverPaintStyle: { stroke: "#ff00cc", strokeWidth: 2 },
// 绘制箭头
Overlays: [
[
"Arrow",
{
width: 8,
length: 8,
location: 1,
},
],
],
RenderMode: "svg",
};
// jsplumb连接参数
export const jsplumbConnectOptions = {
isSource: true,
isTarget: true,
// 动态锚点、提供了4个方向 Continuous、AutoDefault
anchor: ["TopCenter", "RightMiddle", "BottomCenter", "LeftMiddle"],
};
export const jsplumbSourceOptions = {
filter: ".node-anchor", //触发连线的区域
/*"span"表示标签,".className"表示类,"#id"表示元素id*/
filterExclude: false,
anchor: ["TopCenter", "RightMiddle", "BottomCenter", "LeftMiddle"],
allowLoopback: false,
};
export const jsplumbTargetOptions = {
filter: ".node-anchor",
/*"span"表示标签,".className"表示类,"#id"表示元素id*/
filterExclude: false,
anchor: ["TopCenter", "RightMiddle", "BottomCenter", "LeftMiddle"],
allowLoopback: false,
};
4.效果
效果如图所示:
5.总结
节点元素、锚点、连线都是兄弟元素,都是相对于父节点定位,在节点附近自定义添加其他元素也比较容易,其中连线是采用svg绘制。jsplumb是操作dom进行运行的,必须要等dom全部加载完之后在进行渲染jsPlumb。同时需要注意:当数据改变时,流程图也要跟着改变,所以要清除之前的连接线,然后重新绘制。
// 会使整个jsPlumb立即重绘。 this.jsPlumb.setSuspendDrawing(false, true);
本次设计稿全景图,实现了画布缩放、节点连线、节点框选、节点拖动、对齐操作和自动保存,后续还需实现批注、设置连线名称等功能。