Files
EasyFlow/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/useCopyPasteHandler.svelte.ts
陈子默 7e7c236c2a fix: 修复管理端前端 lint 与构建问题
- 收敛 easyflow-ui-admin 的 lint、格式和类型问题

- 修正 demo 页面与管理端前端构建失败点

- 验证 pnpm lint 与 pnpm build 均已通过
2026-04-05 21:39:13 +08:00

282 lines
8.0 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 { store } from '#store/stores.svelte';
import { genShortId } from '#components/utils/IdGen';
import { type Edge, type Node, useSvelteFlow } from '@xyflow/svelte';
interface ClipboardData {
tinyflowNodes: Node[];
tinyflowEdges?: Edge[];
version: string;
}
/**
* 清理节点中不可序列化的字段,确保可安全 JSON.stringify
*/
function sanitizeNode(node: Node): Node {
const { id, type, position, data, parentId } = node;
return {
id,
type,
position: { x: position.x, y: position.y },
parentId: parentId ? parentId : undefined,
data: data ? JSON.parse(JSON.stringify(data)) : {},
...(parentId !== undefined && { parentId }),
};
}
/**
* 对 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;
}
/**
* 清理边中不可序列化的字段
*/
function sanitizeEdge(edge: Edge): Edge {
const { id, source, target, sourceHandle, targetHandle, type, data } = edge;
return {
id,
source,
target,
...(sourceHandle !== undefined && { sourceHandle }),
...(targetHandle !== undefined && { targetHandle }),
...(type !== undefined && { type }),
data: data ? JSON.parse(JSON.stringify(data)) : {},
};
}
/**
* 递归遍历对象,仅当遇到 { 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 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,
};
};