2026-08-25 10:15:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 文件说明:旧流程数据与 Vue Flow 状态、操作 API 之间的兼容适配层。
|
|
|
|
|
|
* 作用范围:仅服务于流程设计器模块。
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { nextTick } from "vue";
|
|
|
|
|
|
import { v4 as uuid } from "uuid";
|
|
|
|
|
|
import { MarkerType } from "@vue-flow/core";
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 克隆 clone 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} value 待处理的新值
|
|
|
|
|
|
*/
|
|
|
|
|
|
const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
|
|
|
|
|
|
|
|
|
|
|
|
const GROUP_TYPES = new Set(["loop", "selectArea"]);
|
|
|
|
|
|
const TARGET_ONLY_TYPES = new Set(["end", "stopLoop", "subEnd"]);
|
|
|
|
|
|
const SOURCE_ONLY_TYPES = new Set(["start", "subStart"]);
|
|
|
|
|
|
const LOOP_BODY_INSET = { left: 24, top: 112, right: 24, bottom: 24 };
|
|
|
|
|
|
const GROUP_BODY_INSET = { left: 24, top: 120, right: 24, bottom: 24 };
|
|
|
|
|
|
const LOOP_MIN_SIZE = { width: 520, height: 360 };
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 loopChildExtent 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const loopChildExtent = () => ({
|
|
|
|
|
|
range: "parent",
|
|
|
|
|
|
padding: [
|
|
|
|
|
|
LOOP_BODY_INSET.top,
|
|
|
|
|
|
LOOP_BODY_INSET.right,
|
|
|
|
|
|
LOOP_BODY_INSET.bottom,
|
|
|
|
|
|
LOOP_BODY_INSET.left,
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 编组 groupChildExtent 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const groupChildExtent = () => ({
|
|
|
|
|
|
range: "parent",
|
|
|
|
|
|
padding: [
|
|
|
|
|
|
GROUP_BODY_INSET.top,
|
|
|
|
|
|
GROUP_BODY_INSET.right,
|
|
|
|
|
|
GROUP_BODY_INSET.bottom,
|
|
|
|
|
|
GROUP_BODY_INSET.left,
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 nodeSize 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} node 当前流程节点
|
|
|
|
|
|
*/
|
|
|
|
|
|
const nodeSize = (node) => {
|
|
|
|
|
|
// 分支节点采用统一的标准卡片尺寸,忽略旧流程数据中遗留的大尺寸记录。
|
|
|
|
|
|
if (node.type === "branch") return { width: 372, height: 180 };
|
|
|
|
|
|
const width = Number(node.properties?.width || node.width);
|
|
|
|
|
|
const height = Number(node.properties?.height || node.height);
|
|
|
|
|
|
if (Number.isFinite(width) && Number.isFinite(height)) {
|
|
|
|
|
|
return { width, height };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (GROUP_TYPES.has(node.type)) return { width: 720, height: 460 };
|
|
|
|
|
|
if (TARGET_ONLY_TYPES.has(node.type) || SOURCE_ONLY_TYPES.has(node.type)) {
|
|
|
|
|
|
return { width: 328, height: 120 };
|
|
|
|
|
|
}
|
|
|
|
|
|
return { width: 372, height: 180 };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 edgeId 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const edgeId = () => `edge_${uuid().replace(/-/g, "")}`;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 nodeId 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const nodeId = () => uuid().replace(/-/g, "");
|
|
|
|
|
|
|
2026-09-20 13:37:23 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 判断一个节点是否可以挂到指定容器下。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现思路:普通节点仍可进入 loop 或 selectArea;容器节点中只开放 loop 嵌套 loop,
|
|
|
|
|
|
* 避免顺带改变 selectArea 的编组语义。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} parentType 父容器的流程节点类型
|
|
|
|
|
|
* @param {string} childType 待挂载节点的流程节点类型
|
|
|
|
|
|
* @returns {boolean} 是否允许建立该父子关系
|
|
|
|
|
|
*/
|
|
|
|
|
|
const canAttachToGroup = (parentType, childType) => {
|
|
|
|
|
|
if (!GROUP_TYPES.has(parentType)) return false;
|
|
|
|
|
|
if (!GROUP_TYPES.has(childType)) return true;
|
|
|
|
|
|
return parentType === "loop" && childType === "loop";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 收集流程节点的全部后代,用于移动和删除嵌套循环时保持整棵子树一致。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现思路:同时读取 children 与 properties.parentId,兼容新旧流程数据可能只保存
|
|
|
|
|
|
* 其中一种关系的情况;visited 可阻止异常历史数据形成死循环。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {{ nodes?: Array }} graph 后端格式的流程图
|
|
|
|
|
|
* @param {string} rootId 根节点 ID
|
|
|
|
|
|
* @returns {Set<string>} 不包含根节点自身的全部后代 ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
const collectGraphDescendantIds = (graph, rootId) => {
|
|
|
|
|
|
const childrenByParent = new Map();
|
|
|
|
|
|
(graph.nodes || []).forEach((node) => {
|
|
|
|
|
|
const childIds = new Set(node.children || []);
|
|
|
|
|
|
if (childIds.size) childrenByParent.set(node.id, childIds);
|
|
|
|
|
|
});
|
|
|
|
|
|
(graph.nodes || []).forEach((node) => {
|
|
|
|
|
|
const parentId = node.properties?.parentId;
|
|
|
|
|
|
if (!parentId) return;
|
|
|
|
|
|
const childIds = childrenByParent.get(parentId) || new Set();
|
|
|
|
|
|
childIds.add(node.id);
|
|
|
|
|
|
childrenByParent.set(parentId, childIds);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const descendants = new Set();
|
|
|
|
|
|
const pending = [...(childrenByParent.get(rootId) || [])];
|
|
|
|
|
|
while (pending.length) {
|
|
|
|
|
|
const childId = pending.pop();
|
|
|
|
|
|
if (!childId || childId === rootId || descendants.has(childId)) continue;
|
|
|
|
|
|
descendants.add(childId);
|
|
|
|
|
|
pending.push(...(childrenByParent.get(childId) || []));
|
|
|
|
|
|
}
|
|
|
|
|
|
return descendants;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 计算 Vue Flow 节点的父子深度,保证父节点始终排在后代之前挂载。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {*} node Vue Flow 节点
|
|
|
|
|
|
* @param {Map<string, *>} nodeLookup 节点索引
|
|
|
|
|
|
* @returns {number} 根节点为 0 的层级深度
|
|
|
|
|
|
*/
|
|
|
|
|
|
const nodeHierarchyDepth = (node, nodeLookup) => {
|
|
|
|
|
|
let depth = 0;
|
|
|
|
|
|
let current = node;
|
|
|
|
|
|
const visited = new Set([node.id]);
|
|
|
|
|
|
while (current?.parentNode) {
|
|
|
|
|
|
if (visited.has(current.parentNode)) break;
|
|
|
|
|
|
visited.add(current.parentNode);
|
|
|
|
|
|
current = nodeLookup.get(current.parentNode);
|
|
|
|
|
|
if (!current) break;
|
|
|
|
|
|
depth += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return depth;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-25 10:15:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 处理 toVueNode 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} node 当前流程节点
|
|
|
|
|
|
* @param {*} sizeOverride 调用方传入的 sizeOverride 参数
|
|
|
|
|
|
* @param {*} positionOverride 调用方传入的 positionOverride 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
const toVueNode = (node, sizeOverride, positionOverride) => {
|
|
|
|
|
|
const normalized = clone(node);
|
|
|
|
|
|
normalized.id ||= nodeId();
|
|
|
|
|
|
normalized.properties ||= {};
|
|
|
|
|
|
const overrideWidth = Number(sizeOverride?.width);
|
|
|
|
|
|
const overrideHeight = Number(sizeOverride?.height);
|
|
|
|
|
|
const size = Number.isFinite(overrideWidth) && overrideWidth > 0
|
|
|
|
|
|
&& Number.isFinite(overrideHeight) && overrideHeight > 0
|
|
|
|
|
|
? { width: overrideWidth, height: overrideHeight }
|
|
|
|
|
|
: nodeSize(normalized);
|
|
|
|
|
|
const center = {
|
|
|
|
|
|
x: Number(normalized.x) || 0,
|
|
|
|
|
|
y: Number(normalized.y) || 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
const overrideX = Number(positionOverride?.x);
|
|
|
|
|
|
const overrideY = Number(positionOverride?.y);
|
|
|
|
|
|
const position = Number.isFinite(overrideX) && Number.isFinite(overrideY)
|
|
|
|
|
|
? { x: overrideX, y: overrideY }
|
|
|
|
|
|
: {
|
|
|
|
|
|
x: center.x - size.width / 2,
|
|
|
|
|
|
y: center.y - size.height / 2,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const dimensions = GROUP_TYPES.has(normalized.type)
|
|
|
|
|
|
? { width: size.width, height: size.height }
|
|
|
|
|
|
: { width: size.width };
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: normalized.id,
|
|
|
|
|
|
type: "flow-node",
|
|
|
|
|
|
position,
|
|
|
|
|
|
...dimensions,
|
|
|
|
|
|
draggable: normalized.draggable !== false,
|
|
|
|
|
|
selectable: true,
|
|
|
|
|
|
deletable: !SOURCE_ONLY_TYPES.has(normalized.type) && normalized.type !== "end",
|
|
|
|
|
|
connectable: true,
|
|
|
|
|
|
zIndex: GROUP_TYPES.has(normalized.type) ? 0 : 2,
|
|
|
|
|
|
data: {
|
|
|
|
|
|
flowType: normalized.type,
|
|
|
|
|
|
properties: normalized.properties,
|
|
|
|
|
|
children: Array.isArray(normalized.children) ? normalized.children : [],
|
|
|
|
|
|
text: normalized.text,
|
|
|
|
|
|
backend: normalized,
|
|
|
|
|
|
originalCenter: center,
|
|
|
|
|
|
originalPosition: position,
|
|
|
|
|
|
size,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 toVueEdge 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} edge 调用方传入的 edge 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
const toVueEdge = (edge) => {
|
|
|
|
|
|
const normalized = clone(edge);
|
|
|
|
|
|
normalized.id ||= edgeId();
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: normalized.id,
|
|
|
|
|
|
source: normalized.sourceNodeId,
|
|
|
|
|
|
target: normalized.targetNodeId,
|
|
|
|
|
|
sourceHandle: normalized.sourceAnchorId || null,
|
|
|
|
|
|
targetHandle: normalized.targetAnchorId || null,
|
|
|
|
|
|
type: "flow-edge",
|
|
|
|
|
|
animated: true,
|
|
|
|
|
|
selectable: true,
|
|
|
|
|
|
focusable: true,
|
|
|
|
|
|
deletable: true,
|
|
|
|
|
|
interactionWidth: 32,
|
|
|
|
|
|
markerEnd: {
|
|
|
|
|
|
type: MarkerType.ArrowClosed,
|
|
|
|
|
|
color: "#ff6b35",
|
|
|
|
|
|
width: 20,
|
|
|
|
|
|
height: 20,
|
|
|
|
|
|
},
|
|
|
|
|
|
style: { stroke: "#ff6b35", strokeWidth: 2 },
|
|
|
|
|
|
data: { backend: normalized },
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* 将后端绝对坐标节点转换为 Vue Flow 的多层父子节点结构。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现思路:先冻结所有节点的绝对坐标,再建立父子关系并换算相对坐标,避免父循环
|
|
|
|
|
|
* 已转成相对坐标后影响更深层节点。循环尺寸按“最深层优先”向外扩展,确保外层循环
|
|
|
|
|
|
* 能包含已经完成尺寸计算的内层循环。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {Array} nodes Vue Flow 节点列表,初始 position 均为画布绝对坐标
|
|
|
|
|
|
* @returns {Array} 父节点排在子节点之前的节点列表
|
2026-08-25 10:15:37 +08:00
|
|
|
|
*/
|
|
|
|
|
|
const attachChildrenToGroups = (nodes) => {
|
|
|
|
|
|
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
|
|
|
|
|
|
const declaredParents = new Map();
|
2026-09-20 13:37:23 +08:00
|
|
|
|
const absolutePositions = new Map(nodes.map((node) => [node.id, { ...node.position }]));
|
2026-08-25 10:15:37 +08:00
|
|
|
|
|
|
|
|
|
|
nodes.filter((node) => GROUP_TYPES.has(node.data.flowType)).forEach((group) => {
|
|
|
|
|
|
(group.data.children || []).forEach((childId) => {
|
|
|
|
|
|
if (!declaredParents.has(childId)) declaredParents.set(childId, group.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
nodes.forEach((child) => {
|
|
|
|
|
|
const parentId = child.data.properties?.parentId || declaredParents.get(child.id);
|
|
|
|
|
|
const parent = nodeLookup.get(parentId);
|
2026-09-20 13:37:23 +08:00
|
|
|
|
if (!parent || !canAttachToGroup(parent.data.flowType, child.data.flowType)) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 历史数据可能包含错误的循环父子引用;沿父链检查可避免 Vue Flow 收到环形层级。
|
|
|
|
|
|
const visited = new Set([child.id]);
|
|
|
|
|
|
let ancestor = parent;
|
|
|
|
|
|
while (ancestor) {
|
|
|
|
|
|
if (visited.has(ancestor.id)) return;
|
|
|
|
|
|
visited.add(ancestor.id);
|
|
|
|
|
|
const ancestorParentId = ancestor.data.properties?.parentId || declaredParents.get(ancestor.id);
|
|
|
|
|
|
ancestor = nodeLookup.get(ancestorParentId);
|
|
|
|
|
|
}
|
2026-08-25 10:15:37 +08:00
|
|
|
|
|
|
|
|
|
|
const isLoop = parent.data.flowType === "loop";
|
2026-09-20 13:37:23 +08:00
|
|
|
|
const childPosition = absolutePositions.get(child.id) || child.position;
|
|
|
|
|
|
const parentPosition = absolutePositions.get(parentId) || parent.position;
|
2026-08-25 10:15:37 +08:00
|
|
|
|
const relativePosition = {
|
|
|
|
|
|
x: isLoop
|
2026-09-20 13:37:23 +08:00
|
|
|
|
? Math.max(childPosition.x - parentPosition.x, LOOP_BODY_INSET.left)
|
|
|
|
|
|
: childPosition.x - parentPosition.x,
|
2026-08-25 10:15:37 +08:00
|
|
|
|
y: isLoop
|
2026-09-20 13:37:23 +08:00
|
|
|
|
? Math.max(childPosition.y - parentPosition.y, LOOP_BODY_INSET.top)
|
|
|
|
|
|
: childPosition.y - parentPosition.y,
|
2026-08-25 10:15:37 +08:00
|
|
|
|
};
|
|
|
|
|
|
child.parentNode = parentId;
|
|
|
|
|
|
child.extent = isLoop ? loopChildExtent() : groupChildExtent();
|
|
|
|
|
|
child.expandParent = false;
|
|
|
|
|
|
child.position = relativePosition;
|
2026-09-20 13:37:23 +08:00
|
|
|
|
// 边统一位于 zIndex 4。嵌套循环若仍使用普通子节点的 zIndex 5,其白色容器背景会
|
|
|
|
|
|
// 遮住循环体内新建的边,看起来像“无法连线”;容器置于边下方,普通节点仍置于边上方。
|
|
|
|
|
|
child.zIndex = GROUP_TYPES.has(child.data.flowType) ? 1 : 5;
|
2026-08-25 10:15:37 +08:00
|
|
|
|
child.data.properties = { ...child.data.properties, parentId };
|
|
|
|
|
|
parent.data.children = Array.from(new Set([...(parent.data.children || []), child.id]));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-20 13:37:23 +08:00
|
|
|
|
nodes
|
|
|
|
|
|
.filter((node) => node.data.flowType === "loop")
|
|
|
|
|
|
.sort((left, right) => nodeHierarchyDepth(right, nodeLookup) - nodeHierarchyDepth(left, nodeLookup))
|
|
|
|
|
|
.forEach((group) => {
|
|
|
|
|
|
const children = nodes.filter((node) => node.parentNode === group.id);
|
|
|
|
|
|
if (!children.length) return;
|
|
|
|
|
|
const requiredWidth = Math.max(...children.map((child) => (
|
|
|
|
|
|
child.position.x + Number(child.data.size?.width || 0) + LOOP_BODY_INSET.right
|
|
|
|
|
|
)));
|
|
|
|
|
|
const requiredHeight = Math.max(...children.map((child) => (
|
|
|
|
|
|
child.position.y + Number(child.data.size?.height || 0) + LOOP_BODY_INSET.bottom
|
|
|
|
|
|
)));
|
|
|
|
|
|
const width = Math.max(Number(group.data.size?.width || 0), LOOP_MIN_SIZE.width, requiredWidth);
|
|
|
|
|
|
const height = Math.max(Number(group.data.size?.height || 0), LOOP_MIN_SIZE.height, requiredHeight);
|
|
|
|
|
|
group.width = width;
|
|
|
|
|
|
group.height = height;
|
|
|
|
|
|
group.data.size = { width, height };
|
|
|
|
|
|
group.data.properties = { ...group.data.properties, width, height };
|
|
|
|
|
|
});
|
2026-08-25 10:15:37 +08:00
|
|
|
|
|
2026-09-20 13:37:23 +08:00
|
|
|
|
return nodes.sort((left, right) => (
|
|
|
|
|
|
nodeHierarchyDepth(left, nodeLookup) - nodeHierarchyDepth(right, nodeLookup)
|
|
|
|
|
|
));
|
2026-08-25 10:15:37 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const toVueFlowData = (graph = {}, options = {}) => {
|
|
|
|
|
|
const nodes = attachChildrenToGroups((graph.nodes || []).map((node) => (
|
|
|
|
|
|
toVueNode(node, options.nodeSizes?.get(node.id), options.nodePositions?.get(node.id))
|
|
|
|
|
|
)));
|
|
|
|
|
|
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
|
|
|
|
|
|
const edges = (graph.edges || []).map((edge) => {
|
|
|
|
|
|
const vueEdge = toVueEdge(edge);
|
|
|
|
|
|
const source = nodeLookup.get(vueEdge.source);
|
|
|
|
|
|
const target = nodeLookup.get(vueEdge.target);
|
|
|
|
|
|
if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4;
|
|
|
|
|
|
return vueEdge;
|
|
|
|
|
|
});
|
|
|
|
|
|
return { nodes, edges };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const createFlowFacade = (store) => {
|
|
|
|
|
|
const handlers = new Map();
|
|
|
|
|
|
const componentInstances = new Map();
|
|
|
|
|
|
const loopLayoutStates = new Map();
|
|
|
|
|
|
let historySuspended = false;
|
|
|
|
|
|
let history = [];
|
|
|
|
|
|
let historyIndex = -1;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 getNodes 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const getNodes = () => store.getNodes.value || [];
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 getEdges 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const getEdges = () => store.getEdges.value || [];
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* 获取节点在整个画布中的绝对左上角坐标。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现思路:Vue Flow 的子节点 position 相对直接父节点,嵌套循环需要沿完整父链累加;
|
|
|
|
|
|
* visited 用于防御异常数据中的父子环,避免保存或布局时无限递归。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {*} node Vue Flow 节点
|
|
|
|
|
|
* @param {Set<string>} visited 已访问的节点 ID
|
|
|
|
|
|
* @returns {{x: number, y: number}} 画布绝对坐标
|
|
|
|
|
|
*/
|
|
|
|
|
|
const getAbsoluteNodePosition = (node, visited = new Set()) => {
|
|
|
|
|
|
const position = node?.position || node?.computedPosition || { x: 0, y: 0 };
|
|
|
|
|
|
if (!node?.parentNode || visited.has(node.id)) {
|
|
|
|
|
|
return { x: Number(position.x || 0), y: Number(position.y || 0) };
|
|
|
|
|
|
}
|
|
|
|
|
|
const nextVisited = new Set(visited);
|
|
|
|
|
|
nextVisited.add(node.id);
|
|
|
|
|
|
const parent = store.findNode(node.parentNode);
|
|
|
|
|
|
if (!parent) return { x: Number(position.x || 0), y: Number(position.y || 0) };
|
|
|
|
|
|
const parentPosition = getAbsoluteNodePosition(parent, nextVisited);
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: parentPosition.x + Number(position.x || 0),
|
|
|
|
|
|
y: parentPosition.y + Number(position.y || 0),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 将 Vue Flow 节点序列化为后端使用的绝对坐标节点。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现思路:先沿完整父链取得绝对位置,再恢复旧流程协议的中心点坐标;这样普通节点、
|
|
|
|
|
|
* 内层循环及更深层后代在保存和重新加载后都不会发生位置漂移。
|
|
|
|
|
|
*
|
2026-08-25 10:15:37 +08:00
|
|
|
|
* @param {*} node 当前流程节点
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* @returns {*} 后端格式节点
|
2026-08-25 10:15:37 +08:00
|
|
|
|
*/
|
|
|
|
|
|
const backendNode = (node) => {
|
|
|
|
|
|
const result = clone(node.data.backend);
|
|
|
|
|
|
const size = {
|
|
|
|
|
|
width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0),
|
|
|
|
|
|
height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0),
|
|
|
|
|
|
};
|
|
|
|
|
|
const parent = node.parentNode ? store.findNode(node.parentNode) : null;
|
2026-09-20 13:37:23 +08:00
|
|
|
|
const absolutePosition = getAbsoluteNodePosition(node);
|
2026-08-25 10:15:37 +08:00
|
|
|
|
result.id = node.id;
|
|
|
|
|
|
result.type = node.data.flowType;
|
|
|
|
|
|
if (GROUP_TYPES.has(result.type)) {
|
|
|
|
|
|
result.x = absolutePosition.x + size.width / 2;
|
|
|
|
|
|
result.y = absolutePosition.y + size.height / 2;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const originalCenter = node.data.originalCenter || {
|
|
|
|
|
|
x: absolutePosition.x + size.width / 2,
|
|
|
|
|
|
y: absolutePosition.y + size.height / 2,
|
|
|
|
|
|
};
|
|
|
|
|
|
const originalPosition = node.data.originalPosition || absolutePosition;
|
|
|
|
|
|
result.x = originalCenter.x + absolutePosition.x - originalPosition.x;
|
|
|
|
|
|
result.y = originalCenter.y + absolutePosition.y - originalPosition.y;
|
|
|
|
|
|
}
|
|
|
|
|
|
result.properties = clone(node.data.properties || {});
|
|
|
|
|
|
if (parent?.data?.flowType === "selectArea") delete result.properties.parentId;
|
|
|
|
|
|
if (GROUP_TYPES.has(result.type)) {
|
|
|
|
|
|
result.properties.width = size.width;
|
|
|
|
|
|
result.properties.height = size.height;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.data.children?.length || GROUP_TYPES.has(result.type)) {
|
|
|
|
|
|
result.children = [...(node.data.children || [])];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
delete result.children;
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 backendEdge 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} edge 调用方传入的 edge 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
const backendEdge = (edge) => ({
|
|
|
|
|
|
...clone(edge.data?.backend),
|
|
|
|
|
|
id: edge.id,
|
|
|
|
|
|
type: edge.data?.backend?.type || "bezier",
|
|
|
|
|
|
sourceNodeId: edge.source,
|
|
|
|
|
|
targetNodeId: edge.target,
|
|
|
|
|
|
sourceAnchorId: edge.sourceHandle || `${edge.source}_1`,
|
|
|
|
|
|
targetAnchorId: edge.targetHandle || `${edge.target}_3`,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 emit 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} name 名称或事件名称
|
|
|
|
|
|
* @param {*} payload 事件数据
|
|
|
|
|
|
*/
|
|
|
|
|
|
const emit = (name, payload) => {
|
|
|
|
|
|
(handlers.get(name) || []).forEach((handler) => handler(payload));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 recordHistory 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const recordHistory = () => {
|
|
|
|
|
|
if (historySuspended) return;
|
|
|
|
|
|
const graph = facade.getGraphData();
|
|
|
|
|
|
const serialized = JSON.stringify(graph);
|
|
|
|
|
|
if (history[historyIndex]?.serialized === serialized) return;
|
|
|
|
|
|
history = history.slice(0, historyIndex + 1);
|
|
|
|
|
|
history.push({ serialized, graph: clone(graph) });
|
|
|
|
|
|
if (history.length > 100) history.shift();
|
|
|
|
|
|
historyIndex = history.length - 1;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 更新 changed 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const changed = () => {
|
|
|
|
|
|
if (historySuspended) return;
|
|
|
|
|
|
recordHistory();
|
|
|
|
|
|
emit("history:change", { data: facade.getGraphData() });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* 获取节点在画布绝对坐标系中的矩形。
|
|
|
|
|
|
*
|
2026-08-25 10:15:37 +08:00
|
|
|
|
* @param {*} node 当前流程节点
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* @returns {{x: number, y: number, width: number, height: number}} 绝对矩形
|
2026-08-25 10:15:37 +08:00
|
|
|
|
*/
|
|
|
|
|
|
const getNodeRect = (node) => {
|
2026-09-20 13:37:23 +08:00
|
|
|
|
const position = getAbsoluteNodePosition(node);
|
2026-08-25 10:15:37 +08:00
|
|
|
|
return {
|
|
|
|
|
|
x: Number(position?.x || 0),
|
|
|
|
|
|
y: Number(position?.y || 0),
|
|
|
|
|
|
width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0),
|
|
|
|
|
|
height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 getLoopLayoutState 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} group 调用方传入的 group 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
const getLoopLayoutState = (group) => {
|
|
|
|
|
|
const current = getNodeRect(group);
|
|
|
|
|
|
const existing = loopLayoutStates.get(group.id);
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} !existing 调用方传入的 !existing 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
|
const state = { base: { ...current }, last: { ...current } };
|
|
|
|
|
|
loopLayoutStates.set(group.id, state);
|
|
|
|
|
|
return state;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sizeChanged = Math.abs(current.width - existing.last.width) > 1
|
|
|
|
|
|
|| Math.abs(current.height - existing.last.height) > 1;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} sizeChanged 调用方传入的 sizeChanged 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
if (sizeChanged) {
|
|
|
|
|
|
existing.base = { ...current };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const dx = current.x - existing.last.x;
|
|
|
|
|
|
const dy = current.y - existing.last.y;
|
|
|
|
|
|
existing.base.x += dx;
|
|
|
|
|
|
existing.base.y += dy;
|
|
|
|
|
|
}
|
|
|
|
|
|
existing.last = { ...current };
|
|
|
|
|
|
return existing;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* 根据直接子节点的绝对矩形调整循环容器。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现思路:布局计算统一使用画布绝对坐标;写回嵌套循环时,再减去父循环的绝对坐标
|
|
|
|
|
|
* 得到 Vue Flow 所需的相对 position,从而让同一算法同时适用于顶层和任意深度循环。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {*} group 待调整的循环节点
|
|
|
|
|
|
* @param {string} activeChildId 正在拖动的直接子节点 ID
|
|
|
|
|
|
* @param {{x: number, y: number}} activePosition 正在拖动节点的绝对坐标
|
|
|
|
|
|
* @returns {boolean} 是否完成布局
|
2026-08-25 10:15:37 +08:00
|
|
|
|
*/
|
|
|
|
|
|
const layoutLoop = (group, activeChildId, activePosition) => {
|
|
|
|
|
|
const state = getLoopLayoutState(group);
|
|
|
|
|
|
const children = getNodes().filter((node) => node.parentNode === group.id);
|
|
|
|
|
|
if (!children.length) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const snapshots = children.map((child) => {
|
|
|
|
|
|
const rect = getNodeRect(child);
|
|
|
|
|
|
const position = child.id === activeChildId && activePosition
|
|
|
|
|
|
? { x: activePosition.x, y: activePosition.y }
|
|
|
|
|
|
: { x: rect.x, y: rect.y };
|
|
|
|
|
|
return { child, position, width: rect.width, height: rect.height };
|
|
|
|
|
|
});
|
|
|
|
|
|
const contentLeft = Math.min(...snapshots.map(({ position }) => position.x));
|
|
|
|
|
|
const contentTop = Math.min(...snapshots.map(({ position }) => position.y));
|
|
|
|
|
|
const contentRight = Math.max(...snapshots.map(({ position, width }) => position.x + width));
|
|
|
|
|
|
const contentBottom = Math.max(...snapshots.map(({ position, height }) => position.y + height));
|
|
|
|
|
|
const left = Math.min(state.base.x, contentLeft - LOOP_BODY_INSET.left);
|
|
|
|
|
|
const top = Math.min(state.base.y, contentTop - LOOP_BODY_INSET.top);
|
|
|
|
|
|
const right = Math.max(
|
|
|
|
|
|
state.base.x + Math.max(state.base.width, LOOP_MIN_SIZE.width),
|
|
|
|
|
|
contentRight + LOOP_BODY_INSET.right,
|
|
|
|
|
|
);
|
|
|
|
|
|
const bottom = Math.max(
|
|
|
|
|
|
state.base.y + Math.max(state.base.height, LOOP_MIN_SIZE.height),
|
|
|
|
|
|
contentBottom + LOOP_BODY_INSET.bottom,
|
|
|
|
|
|
);
|
|
|
|
|
|
const width = right - left;
|
|
|
|
|
|
const height = bottom - top;
|
|
|
|
|
|
const properties = { ...group.data.properties, width, height };
|
2026-09-20 13:37:23 +08:00
|
|
|
|
const parent = group.parentNode ? store.findNode(group.parentNode) : null;
|
|
|
|
|
|
const parentPosition = parent ? getAbsoluteNodePosition(parent) : { x: 0, y: 0 };
|
|
|
|
|
|
const relativePosition = {
|
|
|
|
|
|
x: left - parentPosition.x,
|
|
|
|
|
|
y: top - parentPosition.y,
|
|
|
|
|
|
};
|
2026-08-25 10:15:37 +08:00
|
|
|
|
|
|
|
|
|
|
store.updateNode(group.id, {
|
2026-09-20 13:37:23 +08:00
|
|
|
|
position: relativePosition,
|
2026-08-25 10:15:37 +08:00
|
|
|
|
computedPosition: { ...group.computedPosition, x: left, y: top },
|
|
|
|
|
|
dimensions: { width, height },
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
});
|
|
|
|
|
|
store.updateNodeData(group.id, {
|
|
|
|
|
|
...group.data,
|
|
|
|
|
|
properties,
|
|
|
|
|
|
size: { width, height },
|
|
|
|
|
|
}, { replace: true });
|
|
|
|
|
|
snapshots.forEach(({ child, position }) => {
|
|
|
|
|
|
store.updateNode(child.id, {
|
|
|
|
|
|
position: { x: position.x - left, y: position.y - top },
|
|
|
|
|
|
computedPosition: { ...child.computedPosition, x: position.x, y: position.y },
|
|
|
|
|
|
extent: loopChildExtent(),
|
|
|
|
|
|
expandParent: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
state.last = { x: left, y: top, width, height };
|
|
|
|
|
|
nextTick(() => store.updateNodeInternals([group.id, ...children.map((child) => child.id)]));
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-20 13:37:23 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从指定节点开始向上重新计算所有父循环尺寸。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现思路:内层循环尺寸变化后,外层循环必须由内向外依次更新,否则外层可能裁剪
|
|
|
|
|
|
* 新增内容。visited 防止异常父子关系导致无限循环。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {*} node 尺寸或位置已经变化的节点
|
|
|
|
|
|
*/
|
|
|
|
|
|
const layoutParentLoops = (node) => {
|
|
|
|
|
|
let parentId = node?.parentNode;
|
|
|
|
|
|
const visited = new Set();
|
|
|
|
|
|
while (parentId && !visited.has(parentId)) {
|
|
|
|
|
|
visited.add(parentId);
|
|
|
|
|
|
const parent = store.findNode(parentId);
|
|
|
|
|
|
if (!parent) break;
|
|
|
|
|
|
if (parent.data.flowType === "loop") layoutLoop(parent);
|
|
|
|
|
|
parentId = parent.parentNode;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-25 10:15:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 获取 getAnchors 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} node 当前流程节点
|
|
|
|
|
|
*/
|
|
|
|
|
|
const getAnchors = (node) => {
|
|
|
|
|
|
const type = node.data.flowType;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} type 目标类型
|
|
|
|
|
|
*/
|
|
|
|
|
|
if (type === "branch") {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{ id: `${node.id}_entry`, name: "left" },
|
|
|
|
|
|
...(node.data.properties?.anchor || []).map((item) => ({ ...item, name: "right" })),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (TARGET_ONLY_TYPES.has(type)) return [{ id: `${node.id}_3`, name: "left" }];
|
|
|
|
|
|
if (SOURCE_ONLY_TYPES.has(type)) return [{ id: `${node.id}_1`, name: "right" }];
|
|
|
|
|
|
if (type === "selectArea") return [];
|
|
|
|
|
|
return [
|
|
|
|
|
|
{ id: `${node.id}_1`, name: "right" },
|
|
|
|
|
|
{ id: `${node.id}_3`, name: "left" },
|
|
|
|
|
|
];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 modelFor 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} id 目标元素 ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
const modelFor = (id) => {
|
|
|
|
|
|
const node = store.findNode(id);
|
|
|
|
|
|
if (!node) return null;
|
|
|
|
|
|
const model = {
|
|
|
|
|
|
id,
|
|
|
|
|
|
type: node.data.flowType,
|
|
|
|
|
|
get x() { return backendNode(node).x; },
|
|
|
|
|
|
get y() { return backendNode(node).y; },
|
|
|
|
|
|
get width() { return node.dimensions?.width || node.data.size?.width || 0; },
|
|
|
|
|
|
get height() { return node.dimensions?.height || node.data.size?.height || 0; },
|
|
|
|
|
|
get left() { return getNodeRect(node).x; },
|
|
|
|
|
|
get top() { return getNodeRect(node).y; },
|
|
|
|
|
|
get parentId() { return node.parentNode || null; },
|
|
|
|
|
|
get anchors() { return getAnchors(node); },
|
|
|
|
|
|
get children() { return new Set(node.data.children || []); },
|
|
|
|
|
|
set children(value) {
|
|
|
|
|
|
const children = Array.from(value || []);
|
|
|
|
|
|
store.updateNodeData(id, { ...node.data, children }, { replace: true });
|
|
|
|
|
|
changed();
|
|
|
|
|
|
},
|
|
|
|
|
|
get draggable() { return node.draggable !== false; },
|
|
|
|
|
|
set draggable(value) { store.updateNode(id, { draggable: Boolean(value) }); },
|
|
|
|
|
|
getProperties: () => facade.getProperties(id),
|
|
|
|
|
|
setProperties: (properties) => facade.setProperties(id, properties),
|
|
|
|
|
|
setComponentInstance: (instance) => componentInstances.set(id, instance),
|
|
|
|
|
|
closePopover: () => componentInstances.get(id)?.closePopover?.(),
|
|
|
|
|
|
updateSize: () => store.updateNodeInternals([id]),
|
|
|
|
|
|
setCustomProperties: () => store.updateNodeInternals([id]),
|
|
|
|
|
|
};
|
|
|
|
|
|
return model;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const facade = {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 订阅 on 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} name 名称或事件名称
|
|
|
|
|
|
* @param {*} handler 事件处理函数
|
|
|
|
|
|
*/
|
|
|
|
|
|
on(name, handler) {
|
|
|
|
|
|
const list = handlers.get(name) || [];
|
|
|
|
|
|
list.push(handler);
|
|
|
|
|
|
handlers.set(name, list);
|
|
|
|
|
|
return () => facade.off(name, handler);
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 订阅 once 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} name 名称或事件名称
|
|
|
|
|
|
* @param {*} handler 事件处理函数
|
|
|
|
|
|
*/
|
|
|
|
|
|
once(name, handler) {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 wrapped 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} payload 事件数据
|
|
|
|
|
|
*/
|
|
|
|
|
|
const wrapped = (payload) => {
|
|
|
|
|
|
facade.off(name, wrapped);
|
|
|
|
|
|
handler(payload);
|
|
|
|
|
|
};
|
|
|
|
|
|
facade.on(name, wrapped);
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 取消订阅 off 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} name 名称或事件名称
|
|
|
|
|
|
* @param {*} handler 事件处理函数
|
|
|
|
|
|
*/
|
|
|
|
|
|
off(name, handler) {
|
|
|
|
|
|
if (!handler) handlers.delete(name);
|
|
|
|
|
|
else handlers.set(name, (handlers.get(name) || []).filter((item) => item !== handler));
|
|
|
|
|
|
},
|
|
|
|
|
|
emit,
|
|
|
|
|
|
markChanged: changed,
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 getGraphData 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
getGraphData() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
nodes: getNodes().map(backendNode),
|
|
|
|
|
|
edges: getEdges().map(backendEdge),
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 渲染 render 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} graph 调用方传入的 graph 参数
|
|
|
|
|
|
* @param {*} options 行为配置
|
|
|
|
|
|
*/
|
|
|
|
|
|
render(graph, options = {}) {
|
|
|
|
|
|
historySuspended = true;
|
|
|
|
|
|
loopLayoutStates.clear();
|
|
|
|
|
|
const nodeSizes = options.preserveNodeSizes === true
|
|
|
|
|
|
? new Map(getNodes().map((node) => [node.id, {
|
|
|
|
|
|
width: Number(node.dimensions?.width || node.width || node.data.size?.width || 0),
|
|
|
|
|
|
height: Number(node.dimensions?.height || node.height || node.data.size?.height || 0),
|
|
|
|
|
|
}]))
|
|
|
|
|
|
: options.nodeSizes;
|
|
|
|
|
|
const nodePositions = options.preserveNodePositions === true
|
|
|
|
|
|
? new Map(getNodes().map((node) => {
|
|
|
|
|
|
const rect = getNodeRect(node);
|
|
|
|
|
|
return [node.id, { x: rect.x, y: rect.y }];
|
|
|
|
|
|
}))
|
|
|
|
|
|
: options.nodePositions;
|
|
|
|
|
|
const data = toVueFlowData(graph, { nodeSizes, nodePositions });
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 finishRender 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const finishRender = () => {
|
|
|
|
|
|
historySuspended = false;
|
|
|
|
|
|
if (options.resetHistory !== false) {
|
|
|
|
|
|
history = [];
|
|
|
|
|
|
historyIndex = -1;
|
|
|
|
|
|
recordHistory();
|
|
|
|
|
|
}
|
|
|
|
|
|
nextTick(() => store.updateNodeInternals());
|
|
|
|
|
|
};
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 applyData 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const applyData = () => {
|
|
|
|
|
|
store.setNodes(data.nodes);
|
|
|
|
|
|
store.setEdges(data.edges);
|
|
|
|
|
|
finishRender();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (options.replaceNodes === true) {
|
|
|
|
|
|
// Changing parentNode is structural in Vue Flow. Unmount in one tick
|
|
|
|
|
|
// and remount in the next so node wrappers and edges cannot retain
|
|
|
|
|
|
// different graph-node instances for the same id.
|
|
|
|
|
|
store.setEdges([]);
|
|
|
|
|
|
store.setNodes([]);
|
|
|
|
|
|
nextTick(applyData);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
applyData();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 新增 addNode 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} node 当前流程节点
|
|
|
|
|
|
*/
|
|
|
|
|
|
addNode(node) {
|
|
|
|
|
|
const vueNode = toVueNode(node);
|
|
|
|
|
|
store.addNodes(vueNode);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
return backendNode(vueNode);
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* 删除节点,并在删除循环时递归清理其完整循环体。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现思路:嵌套循环可能包含多层后代,不能只删除 children 中的第一层;先从当前
|
|
|
|
|
|
* 图数据统一收集后代,再一次性删除节点与关联边,避免留下带失效 parentId 的孤儿节点。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} id 目标元素 ID
|
2026-08-25 10:15:37 +08:00
|
|
|
|
*/
|
|
|
|
|
|
deleteNode(id) {
|
|
|
|
|
|
const target = store.findNode(id);
|
|
|
|
|
|
if (target?.data?.flowType === "selectArea") {
|
|
|
|
|
|
facade.ungroup(id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const parentId = target?.parentNode;
|
2026-09-20 13:37:23 +08:00
|
|
|
|
const descendantIds = target?.data?.flowType === "loop"
|
|
|
|
|
|
? collectGraphDescendantIds(facade.getGraphData(), id)
|
|
|
|
|
|
: new Set();
|
|
|
|
|
|
const childIds = [...descendantIds];
|
|
|
|
|
|
const removeChildren = childIds.length > 0;
|
2026-08-25 10:15:37 +08:00
|
|
|
|
const nodeIds = removeChildren ? [id, ...childIds] : id;
|
|
|
|
|
|
const removedIds = new Set(Array.isArray(nodeIds) ? nodeIds : [nodeIds]);
|
|
|
|
|
|
store.removeNodes(nodeIds, true, removeChildren);
|
|
|
|
|
|
getNodes().forEach((node) => {
|
|
|
|
|
|
if (node.data.children?.includes(id) && !removedIds.has(node.id)) {
|
|
|
|
|
|
store.updateNodeData(node.id, {
|
|
|
|
|
|
...node.data,
|
|
|
|
|
|
children: node.data.children.filter((childId) => childId !== id),
|
|
|
|
|
|
}, { replace: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-09-20 13:37:23 +08:00
|
|
|
|
removedIds.forEach((nodeIdToDelete) => {
|
|
|
|
|
|
componentInstances.delete(nodeIdToDelete);
|
|
|
|
|
|
loopLayoutStates.delete(nodeIdToDelete);
|
|
|
|
|
|
});
|
2026-08-25 10:15:37 +08:00
|
|
|
|
changed();
|
|
|
|
|
|
if (parentId) nextTick(() => facade.resizeGroupToChildren(parentId));
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 新增 addEdge 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} edge 调用方传入的 edge 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
addEdge(edge) {
|
|
|
|
|
|
const vueEdge = toVueEdge(edge);
|
|
|
|
|
|
const source = store.findNode(vueEdge.source);
|
|
|
|
|
|
const target = store.findNode(vueEdge.target);
|
|
|
|
|
|
if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4;
|
|
|
|
|
|
store.addEdges(vueEdge);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
return backendEdge(vueEdge);
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 删除 deleteEdge 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} id 目标元素 ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
deleteEdge(id) {
|
|
|
|
|
|
store.removeEdges(id);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 删除 deleteEdgeByNodeId 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} id 目标元素 ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
deleteEdgeByNodeId(id) {
|
|
|
|
|
|
store.removeEdges(getEdges().filter((edge) => edge.source === id || edge.target === id));
|
|
|
|
|
|
changed();
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 getProperties 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} id 目标元素 ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
getProperties(id) {
|
|
|
|
|
|
return clone(store.findNode(id)?.data?.properties || {});
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置 setProperties 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} id 目标元素 ID
|
|
|
|
|
|
* @param {*} properties 调用方传入的 properties 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
setProperties(id, properties) {
|
|
|
|
|
|
const node = store.findNode(id);
|
|
|
|
|
|
if (!node) return;
|
|
|
|
|
|
store.updateNodeData(id, { ...node.data, properties: clone(properties) }, { replace: true });
|
|
|
|
|
|
nextTick(() => store.updateNodeInternals([id]));
|
|
|
|
|
|
changed();
|
|
|
|
|
|
},
|
|
|
|
|
|
getNodeModelById: modelFor,
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 getSelectElements 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
getSelectElements() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
nodes: (store.getSelectedNodes.value || []).map(backendNode),
|
|
|
|
|
|
edges: (store.getSelectedEdges.value || []).map(backendEdge),
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清理 clearSelectElements 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
clearSelectElements() {
|
|
|
|
|
|
store.removeSelectedElements();
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 selectElementById 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} id 目标元素 ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
selectElementById(id) {
|
|
|
|
|
|
store.removeSelectedElements();
|
|
|
|
|
|
if (!id) return;
|
|
|
|
|
|
const node = store.findNode(id);
|
|
|
|
|
|
if (node) store.addSelectedNodes([node]);
|
|
|
|
|
|
const edge = store.findEdge(id);
|
|
|
|
|
|
if (edge) store.addSelectedEdges([edge]);
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 selectElementsByIds 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} ids 调用方传入的 ids 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
selectElementsByIds(ids = []) {
|
|
|
|
|
|
store.removeSelectedElements();
|
|
|
|
|
|
const nodes = ids.map((id) => store.findNode(id)).filter(Boolean);
|
|
|
|
|
|
if (nodes.length) store.addSelectedNodes(nodes);
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 focusOn 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} { id } 调用方传入的 { id } 参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
focusOn({ id }) {
|
|
|
|
|
|
const node = store.findNode(id);
|
|
|
|
|
|
if (!node) return;
|
|
|
|
|
|
const center = backendNode(node);
|
|
|
|
|
|
store.setCenter(center.x, center.y, { zoom: Math.max(store.viewport.value?.zoom || 1, 0.7), duration: 300 });
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 fitView 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
fitView() {
|
|
|
|
|
|
return store.fitView({ padding: 0.18, duration: 300 });
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 translateCenter 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
translateCenter() {
|
|
|
|
|
|
return facade.fitView();
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 缩放 zoom 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} scale 缩放比例
|
|
|
|
|
|
* @param {*} center 缩放中心
|
|
|
|
|
|
*/
|
|
|
|
|
|
zoom(scale, center) {
|
|
|
|
|
|
if (Array.isArray(center) && center.length === 2) {
|
|
|
|
|
|
return store.setCenter(center[0], center[1], { zoom: scale, duration: 250 });
|
|
|
|
|
|
}
|
|
|
|
|
|
return store.zoomTo(scale, { duration: 250 });
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 getPointByClient 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} x 客户端横坐标
|
|
|
|
|
|
* @param {*} y 客户端纵坐标
|
|
|
|
|
|
*/
|
|
|
|
|
|
getPointByClient(x, y) {
|
|
|
|
|
|
const point = store.screenToFlowCoordinate({ x, y });
|
|
|
|
|
|
return { canvasOverlayPosition: point, domOverlayPosition: point };
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* 将节点或循环子树挂载到目标容器。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现思路:普通节点保持原有编组行为;loop 只允许进入另一个 loop,并在修改前
|
|
|
|
|
|
* 检查目标是否属于自身后代,防止产生环形父子关系。移动循环时整棵子树按相同位移
|
|
|
|
|
|
* 平移,同时只断开循环根节点跨作用域的连线,保留循环体内部连线。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} groupId 目标容器节点 ID
|
|
|
|
|
|
* @param {string} childId 待挂载节点 ID
|
|
|
|
|
|
* @param {{x: number, y: number}} dropCenter 拖放中心绝对坐标
|
|
|
|
|
|
* @returns {boolean} 是否成功接受本次挂载
|
2026-08-25 10:15:37 +08:00
|
|
|
|
*/
|
|
|
|
|
|
addToGroup(groupId, childId, dropCenter) {
|
|
|
|
|
|
const group = store.findNode(groupId);
|
|
|
|
|
|
const child = store.findNode(childId);
|
2026-09-20 13:37:23 +08:00
|
|
|
|
if (!group || !child || groupId === childId) return false;
|
|
|
|
|
|
if (!canAttachToGroup(group.data.flowType, child.data.flowType)) return false;
|
2026-08-25 10:15:37 +08:00
|
|
|
|
const graph = facade.getGraphData();
|
|
|
|
|
|
const groupData = graph.nodes.find((node) => node.id === groupId);
|
|
|
|
|
|
const childData = graph.nodes.find((node) => node.id === childId);
|
2026-09-20 13:37:23 +08:00
|
|
|
|
if (!groupData || !childData) return false;
|
|
|
|
|
|
const descendantIds = collectGraphDescendantIds(graph, childId);
|
|
|
|
|
|
if (descendantIds.has(groupId)) return false;
|
|
|
|
|
|
const previousParentId = child.parentNode || child.data?.properties?.parentId;
|
2026-08-25 10:15:37 +08:00
|
|
|
|
|
|
|
|
|
|
// 节点从循环体外进入循环体后,原连线的两端已处于不同执行作用域,必须全部断开。
|
|
|
|
|
|
// 在重新渲染父子结构前直接过滤图数据,可保证入边、出边和层级变更在同一次更新中完成。
|
|
|
|
|
|
const enteringLoop = group.data.flowType === "loop" && previousParentId !== groupId;
|
|
|
|
|
|
if (enteringLoop) {
|
|
|
|
|
|
graph.edges = graph.edges.filter((edge) => (
|
|
|
|
|
|
edge.sourceNodeId !== childId && edge.targetNodeId !== childId
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 13:37:23 +08:00
|
|
|
|
const previousGroup = graph.nodes.find((node) => node.id === previousParentId);
|
|
|
|
|
|
if (previousGroup && previousGroup.id !== groupId) {
|
|
|
|
|
|
previousGroup.children = (previousGroup.children || []).filter((id) => id !== childId);
|
|
|
|
|
|
}
|
2026-08-25 10:15:37 +08:00
|
|
|
|
groupData.children = Array.from(new Set([...(groupData.children || []), childId]));
|
|
|
|
|
|
childData.properties = { ...(childData.properties || {}), parentId: groupId };
|
|
|
|
|
|
if (dropCenter) {
|
2026-09-20 13:37:23 +08:00
|
|
|
|
const dx = Number(dropCenter.x) - Number(childData.x || 0);
|
|
|
|
|
|
const dy = Number(dropCenter.y) - Number(childData.y || 0);
|
2026-08-25 10:15:37 +08:00
|
|
|
|
childData.x = dropCenter.x;
|
|
|
|
|
|
childData.y = dropCenter.y;
|
2026-09-20 13:37:23 +08:00
|
|
|
|
graph.nodes.forEach((node) => {
|
|
|
|
|
|
if (!descendantIds.has(node.id)) return;
|
|
|
|
|
|
node.x = Number(node.x || 0) + dx;
|
|
|
|
|
|
node.y = Number(node.y || 0) + dy;
|
|
|
|
|
|
});
|
2026-08-25 10:15:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
facade.render(graph, {
|
|
|
|
|
|
resetHistory: false,
|
|
|
|
|
|
replaceNodes: true,
|
|
|
|
|
|
preserveNodeSizes: true,
|
|
|
|
|
|
preserveNodePositions: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
store.updateNodeInternals([groupId, childId]);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
});
|
2026-09-20 13:37:23 +08:00
|
|
|
|
return true;
|
2026-08-25 10:15:37 +08:00
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 编组 groupNodes 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} groupNode 调用方传入的 groupNode 参数
|
|
|
|
|
|
* @param {*} childIds 子节点 ID 集合
|
|
|
|
|
|
*/
|
|
|
|
|
|
groupNodes(groupNode, childIds = []) {
|
|
|
|
|
|
// 先计算选中节点的外接矩形确定容器尺寸,再把子节点坐标转换为容器内相对坐标。
|
|
|
|
|
|
// 与分组外节点相连的边保持不变,此处仅更新节点层级和几何信息。
|
|
|
|
|
|
const graph = facade.getGraphData();
|
|
|
|
|
|
const selectedIds = new Set(childIds);
|
|
|
|
|
|
const children = graph.nodes.filter((node) => (
|
|
|
|
|
|
selectedIds.has(node.id)
|
|
|
|
|
|
&& !GROUP_TYPES.has(node.type)
|
|
|
|
|
|
&& !store.findNode(node.id)?.parentNode
|
|
|
|
|
|
));
|
|
|
|
|
|
if (!children.length) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const group = clone(groupNode);
|
|
|
|
|
|
group.id ||= nodeId();
|
|
|
|
|
|
group.type = "selectArea";
|
|
|
|
|
|
group.properties = { ...(group.properties || {}), action: "NONE" };
|
|
|
|
|
|
group.children = children.map((node) => node.id);
|
|
|
|
|
|
children.forEach((node) => {
|
|
|
|
|
|
node.properties = { ...(node.properties || {}), parentId: group.id };
|
|
|
|
|
|
});
|
|
|
|
|
|
graph.nodes.push(group);
|
|
|
|
|
|
facade.render(graph, {
|
|
|
|
|
|
resetHistory: false,
|
|
|
|
|
|
replaceNodes: true,
|
|
|
|
|
|
preserveNodeSizes: true,
|
|
|
|
|
|
preserveNodePositions: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
store.updateNodeInternals([group.id, ...group.children]);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
});
|
|
|
|
|
|
return clone(group);
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 ungroup 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} groupId 分组节点 ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
ungroup(groupId) {
|
|
|
|
|
|
const graph = facade.getGraphData();
|
|
|
|
|
|
const group = graph.nodes.find((node) => node.id === groupId && node.type === "selectArea");
|
|
|
|
|
|
if (!group) return false;
|
|
|
|
|
|
const childIds = new Set([
|
|
|
|
|
|
...(group.children || []),
|
|
|
|
|
|
...graph.nodes.filter((node) => node.properties?.parentId === groupId).map((node) => node.id),
|
|
|
|
|
|
]);
|
|
|
|
|
|
graph.nodes = graph.nodes.filter((node) => node.id !== groupId);
|
|
|
|
|
|
graph.nodes.forEach((node) => {
|
|
|
|
|
|
if (!childIds.has(node.id)) return;
|
|
|
|
|
|
const properties = { ...(node.properties || {}) };
|
|
|
|
|
|
delete properties.parentId;
|
|
|
|
|
|
node.properties = properties;
|
|
|
|
|
|
});
|
|
|
|
|
|
facade.render(graph, {
|
|
|
|
|
|
resetHistory: false,
|
|
|
|
|
|
replaceNodes: true,
|
|
|
|
|
|
preserveNodeSizes: true,
|
|
|
|
|
|
preserveNodePositions: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
store.updateNodeInternals([...childIds]);
|
|
|
|
|
|
changed();
|
|
|
|
|
|
});
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 准备 prepareLoopChildDrag 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} childId 子节点 ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
prepareLoopChildDrag(childId) {
|
|
|
|
|
|
const child = store.findNode(childId);
|
|
|
|
|
|
const group = child?.parentNode ? store.findNode(child.parentNode) : null;
|
|
|
|
|
|
if (!group || group.data.flowType !== "loop") return;
|
|
|
|
|
|
store.updateNode(childId, {
|
|
|
|
|
|
extent: loopChildExtent(),
|
|
|
|
|
|
expandParent: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
getLoopLayoutState(group);
|
|
|
|
|
|
const rect = getNodeRect(child);
|
|
|
|
|
|
return { groupId: group.id, position: { x: rect.x, y: rect.y } };
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* 在拖拽循环体节点时同步调整当前循环及所有外层循环。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} groupId 当前直接父循环 ID
|
|
|
|
|
|
* @param {string} childId 正在拖动的节点 ID
|
|
|
|
|
|
* @param {{x: number, y: number}} position 节点绝对坐标
|
2026-08-25 10:15:37 +08:00
|
|
|
|
*/
|
|
|
|
|
|
layoutLoopDuringDrag(groupId, childId, position) {
|
|
|
|
|
|
const group = store.findNode(groupId);
|
|
|
|
|
|
if (!group || group.data.flowType !== "loop" || !position) return;
|
2026-09-20 13:37:23 +08:00
|
|
|
|
if (layoutLoop(group, childId, position)) layoutParentLoops(group);
|
2026-08-25 10:15:37 +08:00
|
|
|
|
},
|
|
|
|
|
|
/**
|
2026-09-20 13:37:23 +08:00
|
|
|
|
* 按循环体内容调整当前循环及其外层循环尺寸。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} groupId 待调整的循环 ID
|
|
|
|
|
|
* @param {{markChanged?: boolean}} options 是否记录一次流程变更
|
2026-08-25 10:15:37 +08:00
|
|
|
|
*/
|
|
|
|
|
|
resizeGroupToChildren(groupId, options = {}) {
|
|
|
|
|
|
// 循环容器沿用统一的内边距和最小尺寸约束,并按配置决定是否记录变更。
|
|
|
|
|
|
// 拖拽过程可关闭变更标记,避免高频移动产生多余历史记录。
|
|
|
|
|
|
const group = store.findNode(groupId);
|
|
|
|
|
|
if (!group || group.data.flowType !== "loop") return;
|
2026-09-20 13:37:23 +08:00
|
|
|
|
if (layoutLoop(group)) {
|
|
|
|
|
|
layoutParentLoops(group);
|
|
|
|
|
|
if (options.markChanged !== false) changed();
|
|
|
|
|
|
}
|
2026-08-25 10:15:37 +08:00
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 移动 moveNodeBy 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} id 目标元素 ID
|
|
|
|
|
|
* @param {*} dx 横向位移
|
|
|
|
|
|
* @param {*} dy 纵向位移
|
|
|
|
|
|
*/
|
|
|
|
|
|
moveNodeBy(id, dx, dy) {
|
|
|
|
|
|
const node = store.findNode(id);
|
|
|
|
|
|
if (!node) return;
|
|
|
|
|
|
store.updateNode(id, {
|
|
|
|
|
|
position: {
|
|
|
|
|
|
x: node.position.x + dx,
|
|
|
|
|
|
y: node.position.y + dy,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
changed();
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 撤销 undo 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
undo() {
|
|
|
|
|
|
if (historyIndex <= 0) return false;
|
|
|
|
|
|
historyIndex -= 1;
|
|
|
|
|
|
const snapshot = clone(history[historyIndex].graph);
|
|
|
|
|
|
facade.render(snapshot, { resetHistory: false, replaceNodes: true });
|
|
|
|
|
|
nextTick(() => emit("history:change", { data: facade.getGraphData() }));
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 重做 redo 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
*/
|
|
|
|
|
|
redo() {
|
|
|
|
|
|
if (historyIndex >= history.length - 1) return false;
|
|
|
|
|
|
historyIndex += 1;
|
|
|
|
|
|
const snapshot = clone(history[historyIndex].graph);
|
|
|
|
|
|
facade.render(snapshot, { resetHistory: false, replaceNodes: true });
|
|
|
|
|
|
nextTick(() => emit("history:change", { data: facade.getGraphData() }));
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 注册 registerComponentInstance 对应的数据或交互;作用范围仅限画布适配层。
|
|
|
|
|
|
* @param {*} id 目标元素 ID
|
|
|
|
|
|
* @param {*} instance 组件实例
|
|
|
|
|
|
*/
|
|
|
|
|
|
registerComponentInstance(id, instance) {
|
|
|
|
|
|
if (instance) componentInstances.set(id, instance);
|
|
|
|
|
|
},
|
|
|
|
|
|
extension: {
|
|
|
|
|
|
selectionSelect: {
|
|
|
|
|
|
openSelectionSelect: () => emit("selection:open"),
|
|
|
|
|
|
closeSelectionSelect: () => emit("selection:close"),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return facade;
|
|
|
|
|
|
};
|