"仿一个蓝湖?"~~设计稿全景图—jsplumd使用

953 阅读4分钟

封面图与文字内容~图文无关

1. 背景

设计稿标注是使用开源的sketch-meaxure插件导出的HTML文件,但是它缺少设计稿之间的关系展示,特别是APP设计稿,每个页面之间的流转关系,对于APP、H5开发来说是很有必要的。所以需求就是,参考蓝湖设计稿的全景图,每个设计稿之间可以设置连线、随意拖动、对齐操作等功能。

2.调研

通过调研发现,蓝湖设计稿的全景图是采用canvas加html两者结合的方式来实现,但是canvas的API有点繁琐,所以放弃该方案。 image.png 继续搜寻,看了一下jsplumb、joint、Raphael、GoJS、dagre-d3这几个,最后选择了Jsplumb,因为它开源,使用起来也比较方便,文档也是比较齐全的。

3.开发与配置

1、jsplumd基本概念:

  • Souce 源节点
  • Target 目标节点
  • Anchor 锚点 锚点位于源节点或者目标节点上
  • Endpoint 端点 端点位于连线上
  • Connector 连接 或者也可以理解是连接线
  • Overlays 可以理解为在连接线上的文字或者箭头之类的东东

image.png

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.效果

效果如图所示: image.png

5.总结

节点元素、锚点、连线都是兄弟元素,都是相对于父节点定位,在节点附近自定义添加其他元素也比较容易,其中连线是采用svg绘制。jsplumb是操作dom进行运行的,必须要等dom全部加载完之后在进行渲染jsPlumb。同时需要注意:当数据改变时,流程图也要跟着改变,所以要清除之前的连接线,然后重新绘制。

// 会使整个jsPlumb立即重绘。 this.jsPlumb.setSuspendDrawing(false, true);

本次设计稿全景图,实现了画布缩放、节点连线、节点框选、节点拖动、对齐操作和自动保存,后续还需实现批注、设置连线名称等功能。