cmvr-iot-common/src/views/flow/vue-flow/adapter.js

1179 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 文件说明:旧流程数据与 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, "");
/**
* 判断一个节点是否可以挂到指定容器下。
*
* 实现思路:普通节点仍可进入 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;
};
/**
* 处理 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 },
};
};
/**
* 将后端绝对坐标节点转换为 Vue Flow 的多层父子节点结构。
*
* 实现思路:先冻结所有节点的绝对坐标,再建立父子关系并换算相对坐标,避免父循环
* 已转成相对坐标后影响更深层节点。循环尺寸按“最深层优先”向外扩展,确保外层循环
* 能包含已经完成尺寸计算的内层循环。
*
* @param {Array} nodes Vue Flow 节点列表,初始 position 均为画布绝对坐标
* @returns {Array} 父节点排在子节点之前的节点列表
*/
const attachChildrenToGroups = (nodes) => {
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
const declaredParents = new Map();
const absolutePositions = new Map(nodes.map((node) => [node.id, { ...node.position }]));
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);
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);
}
const isLoop = parent.data.flowType === "loop";
const childPosition = absolutePositions.get(child.id) || child.position;
const parentPosition = absolutePositions.get(parentId) || parent.position;
const relativePosition = {
x: isLoop
? Math.max(childPosition.x - parentPosition.x, LOOP_BODY_INSET.left)
: childPosition.x - parentPosition.x,
y: isLoop
? Math.max(childPosition.y - parentPosition.y, LOOP_BODY_INSET.top)
: childPosition.y - parentPosition.y,
};
child.parentNode = parentId;
child.extent = isLoop ? loopChildExtent() : groupChildExtent();
child.expandParent = false;
child.position = relativePosition;
// 边统一位于 zIndex 4。嵌套循环若仍使用普通子节点的 zIndex 5,其白色容器背景会
// 遮住循环体内新建的边,看起来像“无法连线”;容器置于边下方,普通节点仍置于边上方。
child.zIndex = GROUP_TYPES.has(child.data.flowType) ? 1 : 5;
child.data.properties = { ...child.data.properties, parentId };
parent.data.children = Array.from(new Set([...(parent.data.children || []), child.id]));
});
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 };
});
return nodes.sort((left, right) => (
nodeHierarchyDepth(left, nodeLookup) - nodeHierarchyDepth(right, nodeLookup)
));
};
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 || [];
/**
* 获取节点在整个画布中的绝对左上角坐标。
*
* 实现思路: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 节点序列化为后端使用的绝对坐标节点。
*
* 实现思路:先沿完整父链取得绝对位置,再恢复旧流程协议的中心点坐标;这样普通节点、
* 内层循环及更深层后代在保存和重新加载后都不会发生位置漂移。
*
* @param {*} node 当前流程节点
* @returns {*} 后端格式节点
*/
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;
const absolutePosition = getAbsoluteNodePosition(node);
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() });
};
/**
* 获取节点在画布绝对坐标系中的矩形。
*
* @param {*} node 当前流程节点
* @returns {{x: number, y: number, width: number, height: number}} 绝对矩形
*/
const getNodeRect = (node) => {
const position = getAbsoluteNodePosition(node);
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;
};
/**
* 根据直接子节点的绝对矩形调整循环容器。
*
* 实现思路:布局计算统一使用画布绝对坐标;写回嵌套循环时,再减去父循环的绝对坐标
* 得到 Vue Flow 所需的相对 position,从而让同一算法同时适用于顶层和任意深度循环。
*
* @param {*} group 待调整的循环节点
* @param {string} activeChildId 正在拖动的直接子节点 ID
* @param {{x: number, y: number}} activePosition 正在拖动节点的绝对坐标
* @returns {boolean} 是否完成布局
*/
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 };
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,
};
store.updateNode(group.id, {
position: relativePosition,
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;
};
/**
* 从指定节点开始向上重新计算所有父循环尺寸。
*
* 实现思路:内层循环尺寸变化后,外层循环必须由内向外依次更新,否则外层可能裁剪
* 新增内容。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;
}
};
/**
* 获取 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);
},
/**
* 删除节点,并在删除循环时递归清理其完整循环体。
*
* 实现思路:嵌套循环可能包含多层后代,不能只删除 children 中的第一层;先从当前
* 图数据统一收集后代,再一次性删除节点与关联边,避免留下带失效 parentId 的孤儿节点。
*
* @param {string} id 目标元素 ID
*/
deleteNode(id) {
const target = store.findNode(id);
if (target?.data?.flowType === "selectArea") {
facade.ungroup(id);
return;
}
const parentId = target?.parentNode;
const descendantIds = target?.data?.flowType === "loop"
? collectGraphDescendantIds(facade.getGraphData(), id)
: new Set();
const childIds = [...descendantIds];
const removeChildren = childIds.length > 0;
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 });
}
});
removedIds.forEach((nodeIdToDelete) => {
componentInstances.delete(nodeIdToDelete);
loopLayoutStates.delete(nodeIdToDelete);
});
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 };
},
/**
* 将节点或循环子树挂载到目标容器。
*
* 实现思路:普通节点保持原有编组行为;loop 只允许进入另一个 loop,并在修改前
* 检查目标是否属于自身后代,防止产生环形父子关系。移动循环时整棵子树按相同位移
* 平移,同时只断开循环根节点跨作用域的连线,保留循环体内部连线。
*
* @param {string} groupId 目标容器节点 ID
* @param {string} childId 待挂载节点 ID
* @param {{x: number, y: number}} dropCenter 拖放中心绝对坐标
* @returns {boolean} 是否成功接受本次挂载
*/
addToGroup(groupId, childId, dropCenter) {
const group = store.findNode(groupId);
const child = store.findNode(childId);
if (!group || !child || groupId === childId) return false;
if (!canAttachToGroup(group.data.flowType, child.data.flowType)) return false;
const graph = facade.getGraphData();
const groupData = graph.nodes.find((node) => node.id === groupId);
const childData = graph.nodes.find((node) => node.id === childId);
if (!groupData || !childData) return false;
const descendantIds = collectGraphDescendantIds(graph, childId);
if (descendantIds.has(groupId)) return false;
const previousParentId = child.parentNode || child.data?.properties?.parentId;
// 节点从循环体外进入循环体后,原连线的两端已处于不同执行作用域,必须全部断开。
// 在重新渲染父子结构前直接过滤图数据,可保证入边、出边和层级变更在同一次更新中完成。
const enteringLoop = group.data.flowType === "loop" && previousParentId !== groupId;
if (enteringLoop) {
graph.edges = graph.edges.filter((edge) => (
edge.sourceNodeId !== childId && edge.targetNodeId !== childId
));
}
const previousGroup = graph.nodes.find((node) => node.id === previousParentId);
if (previousGroup && previousGroup.id !== groupId) {
previousGroup.children = (previousGroup.children || []).filter((id) => id !== childId);
}
groupData.children = Array.from(new Set([...(groupData.children || []), childId]));
childData.properties = { ...(childData.properties || {}), parentId: groupId };
if (dropCenter) {
const dx = Number(dropCenter.x) - Number(childData.x || 0);
const dy = Number(dropCenter.y) - Number(childData.y || 0);
childData.x = dropCenter.x;
childData.y = dropCenter.y;
graph.nodes.forEach((node) => {
if (!descendantIds.has(node.id)) return;
node.x = Number(node.x || 0) + dx;
node.y = Number(node.y || 0) + dy;
});
}
facade.render(graph, {
resetHistory: false,
replaceNodes: true,
preserveNodeSizes: true,
preserveNodePositions: true,
});
nextTick(() => {
store.updateNodeInternals([groupId, childId]);
changed();
});
return true;
},
/**
* 编组 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 } };
},
/**
* 在拖拽循环体节点时同步调整当前循环及所有外层循环。
*
* @param {string} groupId 当前直接父循环 ID
* @param {string} childId 正在拖动的节点 ID
* @param {{x: number, y: number}} position 节点绝对坐标
*/
layoutLoopDuringDrag(groupId, childId, position) {
const group = store.findNode(groupId);
if (!group || group.data.flowType !== "loop" || !position) return;
if (layoutLoop(group, childId, position)) layoutParentLoops(group);
},
/**
* 按循环体内容调整当前循环及其外层循环尺寸。
*
* @param {string} groupId 待调整的循环 ID
* @param {{markChanged?: boolean}} options 是否记录一次流程变更
*/
resizeGroupToChildren(groupId, options = {}) {
// 循环容器沿用统一的内边距和最小尺寸约束,并按配置决定是否记录变更。
// 拖拽过程可关闭变更标记,避免高频移动产生多余历史记录。
const group = store.findNode(groupId);
if (!group || group.data.flowType !== "loop") return;
if (layoutLoop(group)) {
layoutParentLoops(group);
if (options.markChanged !== false) changed();
}
},
/**
* 移动 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;
};