feat(flow): 添加节点选择和复制粘贴功能
- 实现了 selectElementsByIds 方法用于批量选择元素 - 更新了选择和拖拽行为配置以支持禁用表单时的操作 - 引入 uuid 库用于生成唯一标识符 - 重构复制粘贴逻辑以支持节点和边的完整复制 - 实现了节点引用映射和锚点ID重映射功能 - 添加了子节点递归选择和引用更新机制 - 优化了粘贴后节点的渲染和选择逻辑 - 更新了拖拽相关的CSS样式以改善用户体验
This commit is contained in:
parent
424df50112
commit
916e6f5756
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user