feat(workflow): add node duplication
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
|
||||
/**
|
||||
* 通用节点渲染器。所有节点类型共用一个组件,规格来自 NodeSpecsContext
|
||||
* (后端 /api/node-types)。悬停/选中时在右上角显示「编辑 / 删除」按钮。
|
||||
* (后端 /api/node-types)。悬停/选中时在右上角显示「编辑 / 复制 / 删除」按钮。
|
||||
*/
|
||||
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { MessageSquareText, Pencil, Trash2 } from "lucide-react";
|
||||
import { Copy, MessageSquareText, Pencil, Trash2 } from "lucide-react";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -130,6 +130,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
<button
|
||||
type="button"
|
||||
title="编辑节点"
|
||||
aria-label="编辑节点"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -139,17 +140,32 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
{type !== "start" && (
|
||||
<button
|
||||
type="button"
|
||||
title="删除节点"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
actions.remove(id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="复制节点"
|
||||
aria-label="复制节点"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
actions.duplicate(id);
|
||||
}}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="删除节点"
|
||||
aria-label="删除节点"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
actions.remove(id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -328,6 +328,50 @@ export function WorkflowCanvas({
|
||||
[editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges],
|
||||
);
|
||||
|
||||
const duplicateNode = useCallback(
|
||||
(id: string) => {
|
||||
const source = nodes.find((node) => node.id === id);
|
||||
if (!source || source.type === "start") return;
|
||||
|
||||
const spec = specsByType[source.type as string];
|
||||
if (!spec) return;
|
||||
const limit = spec.constraints.maxInstances;
|
||||
if (
|
||||
limit !== undefined &&
|
||||
nodes.filter((node) => node.type === source.type).length >= limit
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
nodeSeq += 1;
|
||||
const duplicateId = `${source.type}-${Date.now()}-${nodeSeq}`;
|
||||
const sourceData = source.data as WorkflowNodeData;
|
||||
const duplicateData = structuredClone(sourceData);
|
||||
duplicateData.name = `${sourceData.name || spec.displayName}(副本)`;
|
||||
|
||||
setNodes((currentNodes) => [
|
||||
...currentNodes.map((node) => ({ ...node, selected: false })),
|
||||
{
|
||||
id: duplicateId,
|
||||
type: source.type,
|
||||
position: {
|
||||
x: source.position.x + 40,
|
||||
y: source.position.y + 40,
|
||||
},
|
||||
data: duplicateData,
|
||||
selected: true,
|
||||
},
|
||||
]);
|
||||
onEditingNodeIdChange(null);
|
||||
},
|
||||
[
|
||||
nodes,
|
||||
onEditingNodeIdChange,
|
||||
setNodes,
|
||||
specsByType,
|
||||
],
|
||||
);
|
||||
|
||||
const handleNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
const startIds = new Set(
|
||||
@@ -431,10 +475,12 @@ export function WorkflowCanvas({
|
||||
onEditingEdgeIdChange(null);
|
||||
onEditingNodeIdChange(id);
|
||||
},
|
||||
duplicate: duplicateNode,
|
||||
remove: deleteNode,
|
||||
}),
|
||||
[
|
||||
deleteNode,
|
||||
duplicateNode,
|
||||
onDebugOpenChange,
|
||||
onEditingEdgeIdChange,
|
||||
onEditingNodeIdChange,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* 画布内共享上下文。GenericNode / ConditionEdge 由 ReactFlow 渲染,拿不到外层
|
||||
* 回调,统一通过这些 context 取节点规格与编辑/删除动作。
|
||||
* 回调,统一通过这些 context 取节点规格与编辑/复制/删除动作。
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
@@ -17,9 +17,14 @@ export type ElementActions = {
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
|
||||
const noop: ElementActions = { edit: () => {}, remove: () => {} };
|
||||
export type NodeActions = ElementActions & {
|
||||
duplicate: (id: string) => void;
|
||||
};
|
||||
|
||||
export const NodeActionContext = createContext<ElementActions>(noop);
|
||||
const noop: ElementActions = { edit: () => {}, remove: () => {} };
|
||||
const noopNodeActions: NodeActions = { ...noop, duplicate: () => {} };
|
||||
|
||||
export const NodeActionContext = createContext<NodeActions>(noopNodeActions);
|
||||
export const EdgeActionContext = createContext<ElementActions>(noop);
|
||||
|
||||
/** 调试通话中当前激活的节点 id(画布据此高亮),无激活为 null。 */
|
||||
|
||||
Reference in New Issue
Block a user