Files
EasyFlow/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/useCopyPasteHandler.svelte.ts
陈子默 aedefe6b5e perf: 优化智能体与工作流幕布渲染性能
- 分阶段加载智能体配置并按需缓存 MCP 工具

- 合并画布状态更新与节点尺寸监听,启用大图可视区域渲染和静态连线

- 隔离 Tinyflow Store 实例并补充数据同步与回归测试
2026-07-27 18:27:01 +08:00

254 lines
7.3 KiB
TypeScript
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.
import { useTinyflowStore } from '#store/stores.svelte';
import { genShortId } from '#components/utils/IdGen';
import { type Edge, type Node, useSvelteFlow } from '@xyflow/svelte';
import { sanitizeEdge, sanitizeNode } from '#utils/sanitize';
interface ClipboardData {
tinyflowNodes: Node[];
tinyflowEdges?: Edge[];
version: string;
}
/**
* 对 nodes 数组排序,确保每个父节点出现在其所有子节点之前。
* 使用 Kahn 算法(拓扑排序)处理任意嵌套层级。
*/
export function sortNodesForSvelteFlow(nodes: Node[]): Node[] {
const nodeMap = new Map<string, Node>();
const inDegree = new Map<string, number>(); // 每个节点的“依赖数”(即是否为子节点)
const childrenMap = new Map<string, string[]>(); // parentId -> childIds
// 初始化
for (const node of nodes) {
nodeMap.set(node.id, node);
inDegree.set(node.id, 0); // 默认无依赖
if (node.parentId) {
// 子节点依赖父节点
inDegree.set(node.id, 1);
if (!childrenMap.has(node.parentId)) {
childrenMap.set(node.parentId, []);
}
childrenMap.get(node.parentId)!.push(node.id);
}
}
// 所有根节点(无 parentId 或父不存在)入队
const queue: Node[] = [];
for (const node of nodes) {
if (!node.parentId || !nodeMap.has(node.parentId)) {
queue.push(node);
}
}
const result: Node[] = [];
const visited = new Set<string>();
// BFS 拓扑排序
while (queue.length > 0) {
const node = queue.shift()!;
if (visited.has(node.id)) continue;
visited.add(node.id);
result.push(node);
// 将该节点的所有直接子节点入队(如果其父已处理)
const children = childrenMap.get(node.id) || [];
for (const childId of children) {
if (!visited.has(childId)) {
queue.push(nodeMap.get(childId)!);
}
}
}
// 补充可能遗漏的节点(如循环引用或孤立子节点)
for (const node of nodes) {
if (!visited.has(node.id)) {
result.push(node);
}
}
return result;
}
/**
* 递归遍历对象,仅当遇到 { refType: 'ref', ref: string } 时重写 ref 的 id
*/
function rewriteRefsInData(obj: any, idMap: Map<string, string>): any {
if (obj === null || obj === undefined) {
return obj;
}
// 如果是数组,递归处理每个元素
if (Array.isArray(obj)) {
return obj.map((item) => rewriteRefsInData(item, idMap));
}
// 如果是对象,检查是否为引用对象
if (typeof obj === 'object') {
// 检查是否是引用定义refType === 'ref' 且有 ref 字段
if (obj.refType === 'ref' && typeof obj.ref === 'string') {
const match = obj.ref.match(/^([^.\s]+)\.(.+)$/);
if (match) {
const [_, oldNodeId, paramId] = match;
const newNodeId = idMap.get(oldNodeId);
if (newNodeId !== undefined) {
// 返回新对象,避免修改原数据
return {
...obj,
ref: `${newNodeId}.${paramId}`,
};
}
}
}
// 递归处理所有子属性
const result: Record<string, any> = {};
for (const key in obj) {
if (Object.hasOwn(obj, key)) {
result[key] = rewriteRefsInData(obj[key], idMap);
}
}
return result;
}
// 原始类型string/number/boolean直接返回
return obj;
}
/**
* 复制粘贴处理器 Hook
*/
export const useCopyPasteHandler = () => {
const store = useTinyflowStore();
const svelteFlow = useSvelteFlow();
const copyHandler = async (event: ClipboardEvent | KeyboardEvent) => {
const selectedNodes = store.getNodes().filter((node) => node.selected);
if (selectedNodes.length === 0) return;
// 获取完全包含在选中节点之间的边(起点和终点都被选中)
const allEdges = store.getEdges();
const relatedEdges = allEdges.filter(
(edge) =>
selectedNodes.some((n) => n.id === edge.source) &&
selectedNodes.some((n) => n.id === edge.target),
);
const serializableNodes = selectedNodes.map(sanitizeNode);
const serializableEdges = relatedEdges.map(sanitizeEdge);
const clipboardData: ClipboardData = {
tinyflowNodes: serializableNodes,
tinyflowEdges: serializableEdges,
version: '1.0',
};
const jsonStr = JSON.stringify(clipboardData, null, 0);
try {
// 优先使用 event.clipboardData在 copy 事件中可用,无需权限)
if ('clipboardData' in event && event.clipboardData) {
event.clipboardData.setData('text/plain', jsonStr);
if (event instanceof ClipboardEvent) {
event.preventDefault();
}
} else {
// 降级到 navigator.clipboard需用户手势
await navigator.clipboard.writeText(jsonStr);
}
console.log('Copied nodes and edges to clipboard');
} catch (err) {
console.error('Failed to copy:', err);
// 可选:同源降级存储
try {
sessionStorage.setItem('tinyflow_clipboard', jsonStr);
} catch {}
}
};
const pasteHandler = (event: ClipboardEvent) => {
const text = event.clipboardData?.getData('text/plain');
if (!text) return;
let parsed: ClipboardData | null = null;
try {
parsed = JSON.parse(text);
} catch {
return; // 忽略非 JSON 内容
}
if (!parsed?.tinyflowNodes || !Array.isArray(parsed.tinyflowNodes)) {
return;
}
event.preventDefault();
const pastedNodes = sortNodesForSvelteFlow(parsed.tinyflowNodes);
const pastedEdges = parsed.tinyflowEdges || [];
// 创建新节点(带新 ID 和偏移)
const newNodeIdMap = new Map<string, string>();
const newNodes: Node[] = [];
for (const node of pastedNodes) {
const newId = `node_${genShortId()}`;
newNodeIdMap.set(node.id, newId);
}
// 构建新节点(含重写后的 data
for (const node of pastedNodes) {
const newId = newNodeIdMap.get(node.id)!;
const newParentId =
node.parentId !== undefined
? newNodeIdMap.get(node.parentId) // 安全:即使父不在粘贴范围内,也会是 undefined
: undefined;
const newData = rewriteRefsInData(node.data, newNodeIdMap);
newNodes.push({
...node,
id: newId,
parentId: newParentId,
data: newData,
position: {
x: (node.position?.x ?? 0) + 50,
y: (node.position?.y ?? 0) + 50,
},
selected: true,
});
}
// 创建新边(仅当两端都在粘贴范围内)
const newEdges: Edge[] = [];
for (const edge of pastedEdges) {
const newSource = newNodeIdMap.get(edge.source);
const newTarget = newNodeIdMap.get(edge.target);
if (newSource && newTarget) {
newEdges.push({
...edge,
id: `edge_${genShortId()}`,
source: newSource,
target: newTarget,
});
}
}
// 更新 store取消其他节点选中添加新内容
store.updateNodes((nodes) => {
const unselected = nodes.map((n) => ({ ...n, selected: false }));
return [...unselected, ...newNodes];
});
store.updateEdges((edges) => {
const unselected = edges.map((n) => ({ ...n, selected: false }));
return [...unselected, ...newEdges];
});
};
return {
copyHandler,
pasteHandler,
};
};