feat(flow): 添加节点选择和复制粘贴功能

- 实现了 selectElementsByIds 方法用于批量选择元素
- 更新了选择和拖拽行为配置以支持禁用表单时的操作
- 引入 uuid 库用于生成唯一标识符
- 重构复制粘贴逻辑以支持节点和边的完整复制
- 实现了节点引用映射和锚点ID重映射功能
- 添加了子节点递归选择和引用更新机制
- 优化了粘贴后节点的渲染和选择逻辑
- 更新了拖拽相关的CSS样式以改善用户体验
This commit is contained in:
lixiaolong 2026-08-05 15:18:52 +08:00
parent 424df50112
commit 916e6f5756
2 changed files with 134 additions and 32 deletions

View File

@ -110,8 +110,8 @@
:elevate-nodes-on-select="false" :elevate-nodes-on-select="false"
:delete-key-code="null" :delete-key-code="null"
:multi-selection-key-code="['Control', 'Meta']" :multi-selection-key-code="['Control', 'Meta']"
:selection-key-code="groupSelectionPending" :selection-key-code="flowStore.disableForm ? null : true"
:pan-on-drag="!groupSelectionPending" :pan-on-drag="flowStore.disableForm ? true : [1, 2]"
:zoom-on-double-click="false" :zoom-on-double-click="false"
:default-edge-options="defaultEdgeOptions" :default-edge-options="defaultEdgeOptions"
@connect="handleConnect" @connect="handleConnect"
@ -251,6 +251,7 @@
<script setup> <script setup>
import { computed, reactive, ref, onMounted, onUnmounted, nextTick } from "vue"; import { computed, reactive, ref, onMounted, onUnmounted, nextTick } from "vue";
import { MarkerType, VueFlow, useVueFlow } from "@vue-flow/core"; import { MarkerType, VueFlow, useVueFlow } from "@vue-flow/core";
import { v4 as uuid } from "uuid";
import { Background } from "@vue-flow/background"; import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls"; import { Controls } from "@vue-flow/controls";
import "@vue-flow/core/dist/style.css"; import "@vue-flow/core/dist/style.css";
@ -1076,7 +1077,7 @@ const clearRun = () => {
const NODE_PASTE_OFFSET = 40; const NODE_PASTE_OFFSET = 40;
const NON_COPYABLE_NODE_TYPES = new Set(["start", "end", "selectArea"]); const NON_COPYABLE_NODE_TYPES = new Set(["start", "end", "selectArea"]);
let copiedNodes = []; let copiedGraph = { nodes: [], edges: [] };
let pasteCount = 0; let pasteCount = 0;
const EDITABLE_SELECTOR = [ const EDITABLE_SELECTOR = [
@ -1105,51 +1106,134 @@ const isTextEditing = (event) => {
}; };
const copySelectedNodes = (event) => { const copySelectedNodes = (event) => {
const nodes = lf.getSelectElements().nodes.filter( const selectedNodes = lf.getSelectElements().nodes;
(node) => !NON_COPYABLE_NODE_TYPES.has(node.type), if (!selectedNodes.length) return;
const graph = lf.getGraphData();
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
const selectedIds = new Set(selectedNodes.map((node) => node.id));
const addChildren = (nodeId) => {
const node = nodeById.get(nodeId);
(node?.children || []).forEach((childId) => {
if (selectedIds.has(childId)) return;
selectedIds.add(childId);
addChildren(childId);
});
};
[...selectedIds].forEach(addChildren);
const copyableIds = new Set(
[...selectedIds].filter((id) => {
const node = nodeById.get(id);
return node && !NON_COPYABLE_NODE_TYPES.has(node.type);
}),
); );
const nodes = graph.nodes.filter((node) => copyableIds.has(node.id));
if (!nodes.length) return; if (!nodes.length) return;
const edges = graph.edges.filter((edge) => (
copyableIds.has(edge.sourceNodeId) && copyableIds.has(edge.targetNodeId)
));
event.preventDefault(); event.preventDefault();
copiedNodes = JSON.parse(JSON.stringify(nodes)); copiedGraph = JSON.parse(JSON.stringify({ nodes, edges }));
pasteCount = 0; pasteCount = 0;
}; };
const remapNodeAnchorId = (anchorId, oldNodeId, newNodeId) => {
if (!anchorId || !String(anchorId).startsWith(`${oldNodeId}_`)) return anchorId;
return `${newNodeId}${String(anchorId).slice(oldNodeId.length)}`;
};
const remapCopiedNodeReferences = (value, idMap, parentKey = "") => {
if (Array.isArray(value)) {
const result = value.map((item) => remapCopiedNodeReferences(item, idMap));
if (parentKey === "quote" && result.length && idMap.has(result[0])) {
result[0] = idMap.get(result[0]);
}
return result;
}
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => (
[key, remapCopiedNodeReferences(item, idMap, key)]
)));
};
const pasteCopiedNodes = (event) => { const pasteCopiedNodes = (event) => {
if (!copiedNodes.length || flowStore.disableForm) return; if (!copiedGraph.nodes.length || flowStore.disableForm) return;
event.preventDefault(); event.preventDefault();
pasteCount += 1; pasteCount += 1;
const offset = NODE_PASTE_OFFSET * pasteCount; const offset = NODE_PASTE_OFFSET * pasteCount;
const pastedNodes = copiedNodes.map((sourceNode) => { const graph = lf.getGraphData();
const properties = JSON.parse(JSON.stringify(sourceNode.properties || {})); const existingNodeIds = new Set(graph.nodes.map((node) => node.id));
const parentId = properties.parentId; const idMap = new Map(copiedGraph.nodes.map((node) => (
delete properties.parentId; [node.id, uuid().replace(/-/g, "")]
)));
const newNode = lf.addNode({ const pastedNodes = copiedGraph.nodes.map((sourceNode) => {
type: sourceNode.type, const newId = idMap.get(sourceNode.id);
const properties = remapCopiedNodeReferences(
JSON.parse(JSON.stringify(sourceNode.properties || {})),
idMap,
);
const parentId = properties.parentId;
if (idMap.has(parentId)) properties.parentId = idMap.get(parentId);
else if (!existingNodeIds.has(parentId)) delete properties.parentId;
if (sourceNode.type === "branch" && Array.isArray(properties.anchor)) {
properties.anchor = properties.anchor.map((anchor) => ({
...anchor,
id: remapNodeAnchorId(anchor.id, sourceNode.id, newId),
}));
}
const children = (sourceNode.children || [])
.filter((childId) => idMap.has(childId))
.map((childId) => idMap.get(childId));
return {
...JSON.parse(JSON.stringify(sourceNode)),
id: newId,
x: Number(sourceNode.x || 0) + offset, x: Number(sourceNode.x || 0) + offset,
y: Number(sourceNode.y || 0) + offset, y: Number(sourceNode.y || 0) + offset,
properties, properties,
}); children,
};
if (sourceNode.type === "branch" && Array.isArray(properties.anchor)) {
const oldElseId = `${sourceNode.id}_else`;
const newElseId = `${newNode.id}_else`;
const anchors = properties.anchor.map((anchor) => (
anchor.id === oldElseId ? { ...anchor, id: newElseId } : anchor
));
lf.setProperties(newNode.id, { ...properties, anchor: anchors });
}
if (parentId && lf.getNodeModelById(parentId)) {
lf.addToGroup(parentId, newNode.id, { x: newNode.x, y: newNode.y });
}
return newNode;
}); });
lf.clearSelectElements(); const pastedEdges = copiedGraph.edges.map((sourceEdge) => {
if (pastedNodes.length === 1) lf.selectElementById(pastedNodes[0].id); const sourceNodeId = idMap.get(sourceEdge.sourceNodeId);
const targetNodeId = idMap.get(sourceEdge.targetNodeId);
return {
...JSON.parse(JSON.stringify(sourceEdge)),
id: `edge_${uuid().replace(/-/g, "")}`,
sourceNodeId,
targetNodeId,
sourceAnchorId: remapNodeAnchorId(
sourceEdge.sourceAnchorId,
sourceEdge.sourceNodeId,
sourceNodeId,
),
targetAnchorId: remapNodeAnchorId(
sourceEdge.targetAnchorId,
sourceEdge.targetNodeId,
targetNodeId,
),
};
});
lf.render({
nodes: [...graph.nodes, ...pastedNodes],
edges: [...graph.edges, ...pastedEdges],
}, {
resetHistory: false,
replaceNodes: true,
preserveNodeSizes: true,
preserveNodePositions: true,
});
nextTick(() => {
lf.selectElementsByIds(pastedNodes.map((node) => node.id));
lf.markChanged();
});
}; };
const handleKeyboardShortcut = (event) => { const handleKeyboardShortcut = (event) => {
@ -1478,9 +1562,11 @@ onUnmounted(() => {
:deep(.vue-flow__node) { :deep(.vue-flow__node) {
border: 0; border: 0;
background: transparent; background: transparent;
cursor: pointer; cursor: grab;
} }
:deep(.vue-flow__node.dragging) { cursor: grabbing; }
:deep(.vue-flow__node .flow-node-shell), :deep(.vue-flow__node .flow-node-shell),
:deep(.vue-flow__node .flow-node-content), :deep(.vue-flow__node .flow-node-content),
:deep(.vue-flow__node .node__container), :deep(.vue-flow__node .node__container),
@ -1501,7 +1587,18 @@ onUnmounted(() => {
} }
:deep(.vue-flow__pane.dragging) { :deep(.vue-flow__pane.dragging) {
cursor: pointer; cursor: grabbing;
}
:deep(.vue-flow__selection) {
border: 1px solid #2f80ed;
background: rgba(47, 128, 237, 0.1);
box-shadow: 0 0 0 1px rgba(47, 128, 237, 0.08);
}
:deep(.vue-flow__nodesselection-rect) {
border: 1px dashed rgba(47, 128, 237, 0.72);
background: rgba(47, 128, 237, 0.04);
} }
:deep(.vue-flow__handle) { :deep(.vue-flow__handle) {

View File

@ -558,6 +558,11 @@ export const createFlowFacade = (store) => {
const edge = store.findEdge(id); const edge = store.findEdge(id);
if (edge) store.addSelectedEdges([edge]); if (edge) store.addSelectedEdges([edge]);
}, },
selectElementsByIds(ids = []) {
store.removeSelectedElements();
const nodes = ids.map((id) => store.findNode(id)).filter(Boolean);
if (nodes.length) store.addSelectedNodes(nodes);
},
focusOn({ id }) { focusOn({ id }) {
const node = store.findNode(id); const node = store.findNode(id);
if (!node) return; if (!node) return;