mirror of
https://github.com/langgenius/dify.git
synced 2026-03-21 06:18:27 +08:00
copy nodes cross apps
This commit is contained in:
@ -1,11 +1,22 @@
|
||||
import type * as React from 'react'
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { usePanelInteractions } from '../use-panel-interactions'
|
||||
|
||||
describe('usePanelInteractions', () => {
|
||||
let container: HTMLDivElement
|
||||
let readTextMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
readTextMock = vi.fn().mockResolvedValue('')
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: readTextMock,
|
||||
},
|
||||
})
|
||||
|
||||
container = document.createElement('div')
|
||||
container.id = 'workflow-container'
|
||||
container.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
@ -56,6 +67,34 @@ describe('usePanelInteractions', () => {
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
it('handlePaneContextMenu should sync clipboard from navigator clipboard', async () => {
|
||||
const clipboardNode = createNode({ id: 'clipboard-node' })
|
||||
const clipboardEdge = createEdge({
|
||||
id: 'clipboard-edge',
|
||||
source: clipboardNode.id,
|
||||
target: 'target-node',
|
||||
})
|
||||
readTextMock.mockResolvedValue(JSON.stringify({
|
||||
kind: 'dify-workflow-clipboard',
|
||||
version: 1,
|
||||
nodes: [clipboardNode],
|
||||
edges: [clipboardEdge],
|
||||
}))
|
||||
|
||||
const { result, store } = renderWorkflowHook(() => usePanelInteractions())
|
||||
|
||||
result.current.handlePaneContextMenu({
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 350,
|
||||
clientY: 250,
|
||||
} as unknown as React.MouseEvent)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().clipboardElements).toEqual([clipboardNode])
|
||||
expect(store.getState().clipboardEdges).toEqual([clipboardEdge])
|
||||
})
|
||||
})
|
||||
|
||||
it('handlePaneContextmenuCancel should clear panelMenu', () => {
|
||||
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
|
||||
initialStoreState: { panelMenu: { top: 10, left: 20 } },
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import {
|
||||
CUSTOM_EDGE,
|
||||
ITERATION_CHILDREN_Z_INDEX,
|
||||
@ -47,6 +48,8 @@ import {
|
||||
getNodeCustomTypeByNodeDataType,
|
||||
getNodesConnectedSourceOrTargetHandleIdsMap,
|
||||
getTopLeftNodePosition,
|
||||
readWorkflowClipboard,
|
||||
writeWorkflowClipboard,
|
||||
} from '../utils'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url'
|
||||
@ -1645,71 +1648,154 @@ export const useNodesInteractions = () => {
|
||||
[workflowStore, handleNodeSelect],
|
||||
)
|
||||
|
||||
const isNodeCopyable = useCallback((node: Node) => {
|
||||
if (
|
||||
node.type === CUSTOM_ITERATION_START_NODE
|
||||
|| node.type === CUSTOM_LOOP_START_NODE
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
node.data.type === BlockEnum.Start
|
||||
|| node.data.type === BlockEnum.LoopEnd
|
||||
|| node.data.type === BlockEnum.KnowledgeBase
|
||||
|| node.data.type === BlockEnum.DataSourceEmpty
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return true
|
||||
|
||||
const nodeMeta = nodesMetaDataMap?.[node.data.type as BlockEnum]
|
||||
if (!nodeMeta)
|
||||
return false
|
||||
|
||||
const { metaData } = nodeMeta
|
||||
return !metaData.isSingleton
|
||||
}, [nodesMetaDataMap])
|
||||
|
||||
const getNodeDefaultValueForPaste = useCallback((node: Node) => {
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return {}
|
||||
|
||||
const nodeMeta = nodesMetaDataMap?.[node.data.type as BlockEnum]
|
||||
return nodeMeta?.defaultValue
|
||||
}, [nodesMetaDataMap])
|
||||
|
||||
const handleNodesCopy = useCallback(
|
||||
(nodeId?: string) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { setClipboardElements } = workflowStore.getState()
|
||||
|
||||
const { getNodes } = store.getState()
|
||||
|
||||
const { setClipboardData } = workflowStore.getState()
|
||||
const { getNodes, edges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
let nodesToCopy: Node[] = []
|
||||
|
||||
if (nodeId) {
|
||||
// If nodeId is provided, copy that specific node
|
||||
const nodeToCopy = nodes.find(
|
||||
node =>
|
||||
node.id === nodeId
|
||||
&& node.data.type !== BlockEnum.Start
|
||||
&& node.type !== CUSTOM_ITERATION_START_NODE
|
||||
&& node.type !== CUSTOM_LOOP_START_NODE
|
||||
&& node.data.type !== BlockEnum.LoopEnd
|
||||
&& node.data.type !== BlockEnum.KnowledgeBase
|
||||
&& node.data.type !== BlockEnum.DataSourceEmpty,
|
||||
)
|
||||
const nodeToCopy = nodes.find(node => node.id === nodeId && isNodeCopyable(node))
|
||||
if (nodeToCopy)
|
||||
setClipboardElements([nodeToCopy])
|
||||
nodesToCopy = [nodeToCopy]
|
||||
}
|
||||
else {
|
||||
// If no nodeId is provided, fall back to the current behavior
|
||||
const bundledNodes = nodes.filter((node) => {
|
||||
if (!node.data._isBundled)
|
||||
return false
|
||||
|
||||
if (!isNodeCopyable(node))
|
||||
return false
|
||||
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return true
|
||||
const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum]
|
||||
if (metaData.isSingleton)
|
||||
return false
|
||||
|
||||
return !node.data.isInIteration && !node.data.isInLoop
|
||||
})
|
||||
|
||||
if (bundledNodes.length) {
|
||||
setClipboardElements(bundledNodes)
|
||||
return
|
||||
nodesToCopy = bundledNodes
|
||||
}
|
||||
else {
|
||||
const selectedNodes = nodes.filter(
|
||||
node => node.data.selected && isNodeCopyable(node),
|
||||
)
|
||||
|
||||
const selectedNode = nodes.find((node) => {
|
||||
if (!node.data.selected)
|
||||
return false
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return true
|
||||
const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum]
|
||||
return !metaData.isSingleton
|
||||
})
|
||||
|
||||
if (selectedNode)
|
||||
setClipboardElements([selectedNode])
|
||||
if (selectedNodes.length)
|
||||
nodesToCopy = selectedNodes
|
||||
}
|
||||
}
|
||||
|
||||
if (!nodesToCopy.length)
|
||||
return
|
||||
|
||||
const copiedNodesMap = new Map(nodesToCopy.map(node => [node.id, node]))
|
||||
const queue = nodesToCopy
|
||||
.filter(node => node.data.type === BlockEnum.Iteration || node.data.type === BlockEnum.Loop)
|
||||
.map(node => node.id)
|
||||
|
||||
while (queue.length) {
|
||||
const parentId = queue.shift()!
|
||||
nodes.forEach((node) => {
|
||||
if (node.parentId !== parentId || copiedNodesMap.has(node.id))
|
||||
return
|
||||
|
||||
copiedNodesMap.set(node.id, node)
|
||||
if (node.data.type === BlockEnum.Iteration || node.data.type === BlockEnum.Loop)
|
||||
queue.push(node.id)
|
||||
})
|
||||
}
|
||||
|
||||
const copiedNodes = [...copiedNodesMap.values()]
|
||||
const copiedNodeIds = new Set(copiedNodes.map(node => node.id))
|
||||
const copiedEdges = edges.filter(
|
||||
edge => copiedNodeIds.has(edge.source) && copiedNodeIds.has(edge.target),
|
||||
)
|
||||
|
||||
const clipboardData = {
|
||||
nodes: copiedNodes,
|
||||
edges: copiedEdges,
|
||||
}
|
||||
|
||||
setClipboardData(clipboardData)
|
||||
void writeWorkflowClipboard(clipboardData).catch(() => {})
|
||||
},
|
||||
[getNodesReadOnly, store, workflowStore],
|
||||
[getNodesReadOnly, workflowStore, store, isNodeCopyable],
|
||||
)
|
||||
|
||||
const handleNodesPaste = useCallback(() => {
|
||||
const handleNodesPaste = useCallback(async () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { clipboardElements, mousePosition } = workflowStore.getState()
|
||||
const {
|
||||
clipboardElements: storeClipboardElements,
|
||||
clipboardEdges: storeClipboardEdges,
|
||||
mousePosition,
|
||||
setClipboardData,
|
||||
} = workflowStore.getState()
|
||||
const clipboardData = await readWorkflowClipboard()
|
||||
const hasSystemClipboard = clipboardData.nodes.length > 0
|
||||
if (hasSystemClipboard && clipboardData.isVersionMismatch) {
|
||||
Toast.notify({
|
||||
type: 'warning',
|
||||
message: t('common.clipboardVersionCompatibilityWarning', {
|
||||
ns: 'workflow',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const clipboardElements = hasSystemClipboard
|
||||
? clipboardData.nodes
|
||||
: storeClipboardElements
|
||||
const clipboardEdges = hasSystemClipboard
|
||||
? clipboardData.edges
|
||||
: storeClipboardEdges
|
||||
|
||||
if (hasSystemClipboard)
|
||||
setClipboardData(clipboardData)
|
||||
|
||||
if (!clipboardElements.length)
|
||||
return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
|
||||
@ -1717,55 +1803,130 @@ export const useNodesInteractions = () => {
|
||||
const edgesToPaste: Edge[] = []
|
||||
const nodes = getNodes()
|
||||
|
||||
if (clipboardElements.length) {
|
||||
const { x, y } = getTopLeftNodePosition(clipboardElements)
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const currentPosition = screenToFlowPosition({
|
||||
x: mousePosition.pageX,
|
||||
y: mousePosition.pageY,
|
||||
})
|
||||
const offsetX = currentPosition.x - x
|
||||
const offsetY = currentPosition.y - y
|
||||
let idMapping: Record<string, string> = {}
|
||||
const pastedNodesMap: Record<string, Node> = {}
|
||||
const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = []
|
||||
clipboardElements.forEach((nodeToPaste, index) => {
|
||||
const nodeType = nodeToPaste.data.type
|
||||
const supportedClipboardElements = clipboardElements.filter((node) => {
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return true
|
||||
return !!nodesMetaDataMap?.[node.data.type as BlockEnum]
|
||||
})
|
||||
|
||||
const { newNode, newIterationStartNode, newLoopStartNode }
|
||||
= generateNewNode({
|
||||
type: nodeToPaste.type,
|
||||
data: {
|
||||
...(nodeToPaste.type !== CUSTOM_NOTE_NODE && nodesMetaDataMap![nodeType].defaultValue),
|
||||
...nodeToPaste.data,
|
||||
selected: false,
|
||||
_isBundled: false,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
_dimmed: false,
|
||||
title: genNewNodeTitleFromOld(nodeToPaste.data.title),
|
||||
},
|
||||
position: {
|
||||
x: nodeToPaste.position.x + offsetX,
|
||||
y: nodeToPaste.position.y + offsetY,
|
||||
},
|
||||
extent: nodeToPaste.extent,
|
||||
zIndex: nodeToPaste.zIndex,
|
||||
if (!supportedClipboardElements.length)
|
||||
return
|
||||
|
||||
const clipboardNodeIds = new Set(supportedClipboardElements.map(node => node.id))
|
||||
const rootClipboardNodes = supportedClipboardElements.filter(
|
||||
node => !node.parentId || !clipboardNodeIds.has(node.parentId),
|
||||
)
|
||||
const positionReferenceNodes = rootClipboardNodes.length
|
||||
? rootClipboardNodes
|
||||
: supportedClipboardElements
|
||||
const { x, y } = getTopLeftNodePosition(positionReferenceNodes)
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const currentPosition = screenToFlowPosition({
|
||||
x: mousePosition.pageX,
|
||||
y: mousePosition.pageY,
|
||||
})
|
||||
const offsetX = currentPosition.x - x
|
||||
const offsetY = currentPosition.y - y
|
||||
let idMapping: Record<string, string> = {}
|
||||
const pastedNodesMap: Record<string, Node> = {}
|
||||
const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = []
|
||||
const selectedNode = nodes.find(node => node.selected)
|
||||
|
||||
rootClipboardNodes.forEach((nodeToPaste, index) => {
|
||||
const nodeDefaultValue = getNodeDefaultValueForPaste(nodeToPaste)
|
||||
if (nodeToPaste.type !== CUSTOM_NOTE_NODE && !nodeDefaultValue)
|
||||
return
|
||||
|
||||
const { newNode, newIterationStartNode, newLoopStartNode }
|
||||
= generateNewNode({
|
||||
type: nodeToPaste.type,
|
||||
data: {
|
||||
...(nodeToPaste.type !== CUSTOM_NOTE_NODE ? nodeDefaultValue : {}),
|
||||
...nodeToPaste.data,
|
||||
selected: false,
|
||||
_isBundled: false,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
_dimmed: false,
|
||||
isInIteration: false,
|
||||
iteration_id: undefined,
|
||||
isInLoop: false,
|
||||
loop_id: undefined,
|
||||
title: genNewNodeTitleFromOld(nodeToPaste.data.title),
|
||||
},
|
||||
position: {
|
||||
x: nodeToPaste.position.x + offsetX,
|
||||
y: nodeToPaste.position.y + offsetY,
|
||||
},
|
||||
extent: nodeToPaste.extent,
|
||||
zIndex: nodeToPaste.zIndex,
|
||||
})
|
||||
newNode.id = newNode.id + index
|
||||
|
||||
let newChildren: Node[] = []
|
||||
if (nodeToPaste.data.type === BlockEnum.Iteration) {
|
||||
if (newIterationStartNode) {
|
||||
newIterationStartNode.parentId = newNode.id
|
||||
const iterationNodeData = newNode.data as IterationNodeType
|
||||
iterationNodeData.start_node_id = newIterationStartNode.id
|
||||
}
|
||||
|
||||
const oldIterationStartNodeInClipboard = supportedClipboardElements.find(
|
||||
n =>
|
||||
n.parentId === nodeToPaste.id
|
||||
&& n.type === CUSTOM_ITERATION_START_NODE,
|
||||
)
|
||||
if (oldIterationStartNodeInClipboard && newIterationStartNode)
|
||||
idMapping[oldIterationStartNodeInClipboard.id] = newIterationStartNode.id
|
||||
|
||||
const copiedIterationChildren = supportedClipboardElements.filter(
|
||||
n =>
|
||||
n.parentId === nodeToPaste.id
|
||||
&& n.type !== CUSTOM_ITERATION_START_NODE,
|
||||
)
|
||||
if (copiedIterationChildren.length) {
|
||||
copiedIterationChildren.forEach((child, childIndex) => {
|
||||
const childType = child.data.type
|
||||
const childDefaultValue = getNodeDefaultValueForPaste(child)
|
||||
if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue)
|
||||
return
|
||||
|
||||
const { newNode: newChild } = generateNewNode({
|
||||
type: child.type,
|
||||
data: {
|
||||
...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}),
|
||||
...child.data,
|
||||
selected: false,
|
||||
_isBundled: false,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
_dimmed: false,
|
||||
title: genNewNodeTitleFromOld(child.data.title),
|
||||
isInIteration: true,
|
||||
iteration_id: newNode.id,
|
||||
isInLoop: false,
|
||||
loop_id: undefined,
|
||||
type: childType,
|
||||
},
|
||||
position: child.position,
|
||||
positionAbsolute: child.positionAbsolute,
|
||||
parentId: newNode.id,
|
||||
extent: child.extent,
|
||||
zIndex: ITERATION_CHILDREN_Z_INDEX,
|
||||
})
|
||||
newChild.id = `${newNode.id}${newChild.id + childIndex}`
|
||||
idMapping[child.id] = newChild.id
|
||||
newChildren.push(newChild)
|
||||
})
|
||||
newNode.id = newNode.id + index
|
||||
// This new node is movable and can be placed anywhere
|
||||
let newChildren: Node[] = []
|
||||
if (nodeToPaste.data.type === BlockEnum.Iteration) {
|
||||
newIterationStartNode!.parentId = newNode.id;
|
||||
(newNode.data as IterationNodeType).start_node_id
|
||||
= newIterationStartNode!.id
|
||||
|
||||
}
|
||||
else {
|
||||
const oldIterationStartNode = nodes.find(
|
||||
n =>
|
||||
n.parentId === nodeToPaste.id
|
||||
&& n.type === CUSTOM_ITERATION_START_NODE,
|
||||
)
|
||||
idMapping[oldIterationStartNode!.id] = newIterationStartNode!.id
|
||||
if (oldIterationStartNode && newIterationStartNode)
|
||||
idMapping[oldIterationStartNode.id] = newIterationStartNode.id
|
||||
|
||||
const { copyChildren, newIdMapping }
|
||||
= handleNodeIterationChildrenCopy(
|
||||
@ -1775,24 +1936,80 @@ export const useNodesInteractions = () => {
|
||||
)
|
||||
newChildren = copyChildren
|
||||
idMapping = newIdMapping
|
||||
newChildren.forEach((child) => {
|
||||
newNode.data._children?.push({
|
||||
nodeId: child.id,
|
||||
nodeType: child.data.type,
|
||||
})
|
||||
})
|
||||
newChildren.push(newIterationStartNode!)
|
||||
}
|
||||
else if (nodeToPaste.data.type === BlockEnum.Loop) {
|
||||
newLoopStartNode!.parentId = newNode.id;
|
||||
(newNode.data as LoopNodeType).start_node_id = newLoopStartNode!.id
|
||||
|
||||
newChildren.forEach((child) => {
|
||||
newNode.data._children?.push({
|
||||
nodeId: child.id,
|
||||
nodeType: child.data.type,
|
||||
})
|
||||
})
|
||||
if (newIterationStartNode)
|
||||
newChildren.push(newIterationStartNode)
|
||||
}
|
||||
else if (nodeToPaste.data.type === BlockEnum.Loop) {
|
||||
if (newLoopStartNode) {
|
||||
newLoopStartNode.parentId = newNode.id
|
||||
const loopNodeData = newNode.data as LoopNodeType
|
||||
loopNodeData.start_node_id = newLoopStartNode.id
|
||||
}
|
||||
|
||||
const oldLoopStartNodeInClipboard = supportedClipboardElements.find(
|
||||
n =>
|
||||
n.parentId === nodeToPaste.id
|
||||
&& n.type === CUSTOM_LOOP_START_NODE,
|
||||
)
|
||||
if (oldLoopStartNodeInClipboard && newLoopStartNode)
|
||||
idMapping[oldLoopStartNodeInClipboard.id] = newLoopStartNode.id
|
||||
|
||||
const copiedLoopChildren = supportedClipboardElements.filter(
|
||||
n =>
|
||||
n.parentId === nodeToPaste.id
|
||||
&& n.type !== CUSTOM_LOOP_START_NODE,
|
||||
)
|
||||
if (copiedLoopChildren.length) {
|
||||
copiedLoopChildren.forEach((child, childIndex) => {
|
||||
const childType = child.data.type
|
||||
const childDefaultValue = getNodeDefaultValueForPaste(child)
|
||||
if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue)
|
||||
return
|
||||
|
||||
const { newNode: newChild } = generateNewNode({
|
||||
type: child.type,
|
||||
data: {
|
||||
...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}),
|
||||
...child.data,
|
||||
selected: false,
|
||||
_isBundled: false,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
_dimmed: false,
|
||||
title: genNewNodeTitleFromOld(child.data.title),
|
||||
isInIteration: false,
|
||||
iteration_id: undefined,
|
||||
isInLoop: true,
|
||||
loop_id: newNode.id,
|
||||
type: childType,
|
||||
},
|
||||
position: child.position,
|
||||
positionAbsolute: child.positionAbsolute,
|
||||
parentId: newNode.id,
|
||||
extent: child.extent,
|
||||
zIndex: LOOP_CHILDREN_Z_INDEX,
|
||||
})
|
||||
newChild.id = `${newNode.id}${newChild.id + childIndex}`
|
||||
idMapping[child.id] = newChild.id
|
||||
newChildren.push(newChild)
|
||||
})
|
||||
}
|
||||
else {
|
||||
const oldLoopStartNode = nodes.find(
|
||||
n =>
|
||||
n.parentId === nodeToPaste.id
|
||||
&& n.type === CUSTOM_LOOP_START_NODE,
|
||||
)
|
||||
idMapping[oldLoopStartNode!.id] = newLoopStartNode!.id
|
||||
if (oldLoopStartNode && newLoopStartNode)
|
||||
idMapping[oldLoopStartNode.id] = newLoopStartNode.id
|
||||
|
||||
const { copyChildren, newIdMapping }
|
||||
= handleNodeLoopChildrenCopy(
|
||||
@ -1802,126 +2019,125 @@ export const useNodesInteractions = () => {
|
||||
)
|
||||
newChildren = copyChildren
|
||||
idMapping = newIdMapping
|
||||
newChildren.forEach((child) => {
|
||||
newNode.data._children?.push({
|
||||
nodeId: child.id,
|
||||
nodeType: child.data.type,
|
||||
})
|
||||
}
|
||||
|
||||
newChildren.forEach((child) => {
|
||||
newNode.data._children?.push({
|
||||
nodeId: child.id,
|
||||
nodeType: child.data.type,
|
||||
})
|
||||
newChildren.push(newLoopStartNode!)
|
||||
}
|
||||
else {
|
||||
// single node paste
|
||||
const selectedNode = nodes.find(node => node.selected)
|
||||
if (selectedNode) {
|
||||
const commonNestedDisallowPasteNodes = [
|
||||
// end node only can be placed outermost layer
|
||||
BlockEnum.End,
|
||||
]
|
||||
|
||||
// handle disallow paste node
|
||||
if (commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type))
|
||||
return
|
||||
|
||||
// handle paste to nested block
|
||||
if (selectedNode.data.type === BlockEnum.Iteration || selectedNode.data.type === BlockEnum.Loop) {
|
||||
const isIteration = selectedNode.data.type === BlockEnum.Iteration
|
||||
|
||||
newNode.data.isInIteration = isIteration
|
||||
newNode.data.iteration_id = isIteration ? selectedNode.id : undefined
|
||||
newNode.data.isInLoop = !isIteration
|
||||
newNode.data.loop_id = !isIteration ? selectedNode.id : undefined
|
||||
|
||||
newNode.parentId = selectedNode.id
|
||||
newNode.zIndex = isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX
|
||||
newNode.positionAbsolute = {
|
||||
x: newNode.position.x,
|
||||
y: newNode.position.y,
|
||||
}
|
||||
// set position base on parent node
|
||||
newNode.position = getNestedNodePosition(newNode, selectedNode)
|
||||
// update parent children array like native add
|
||||
parentChildrenToAppend.push({ parentId: selectedNode.id, childId: newNode.id, childType: newNode.data.type })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idMapping[nodeToPaste.id] = newNode.id
|
||||
nodesToPaste.push(newNode)
|
||||
pastedNodesMap[newNode.id] = newNode
|
||||
|
||||
if (newChildren.length) {
|
||||
newChildren.forEach((child) => {
|
||||
pastedNodesMap[child.id] = child
|
||||
})
|
||||
nodesToPaste.push(...newChildren)
|
||||
}
|
||||
})
|
||||
|
||||
// Rebuild edges where both endpoints are part of the pasted set.
|
||||
edges.forEach((edge) => {
|
||||
const sourceId = idMapping[edge.source]
|
||||
const targetId = idMapping[edge.target]
|
||||
|
||||
if (sourceId && targetId) {
|
||||
const sourceNode = pastedNodesMap[sourceId]
|
||||
const targetNode = pastedNodesMap[targetId]
|
||||
const parentNode = sourceNode?.parentId && sourceNode.parentId === targetNode?.parentId
|
||||
? pastedNodesMap[sourceNode.parentId] ?? nodes.find(n => n.id === sourceNode.parentId)
|
||||
: null
|
||||
const isInIteration = parentNode?.data.type === BlockEnum.Iteration
|
||||
const isInLoop = parentNode?.data.type === BlockEnum.Loop
|
||||
const newEdge: Edge = {
|
||||
...edge,
|
||||
id: `${sourceId}-${edge.sourceHandle}-${targetId}-${edge.targetHandle}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
data: {
|
||||
...edge.data,
|
||||
isInIteration,
|
||||
iteration_id: isInIteration ? parentNode?.id : undefined,
|
||||
isInLoop,
|
||||
loop_id: isInLoop ? parentNode?.id : undefined,
|
||||
_connectedNodeIsSelected: false,
|
||||
},
|
||||
zIndex: parentNode
|
||||
? isInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: isInLoop
|
||||
? LOOP_CHILDREN_Z_INDEX
|
||||
: 0
|
||||
: 0,
|
||||
}
|
||||
edgesToPaste.push(newEdge)
|
||||
}
|
||||
})
|
||||
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
parentChildrenToAppend.forEach(({ parentId, childId, childType }) => {
|
||||
const p = draft.find(n => n.id === parentId)
|
||||
if (p) {
|
||||
p.data._children?.push({ nodeId: childId, nodeType: childType })
|
||||
}
|
||||
})
|
||||
draft.push(...nodesToPaste)
|
||||
})
|
||||
if (newLoopStartNode)
|
||||
newChildren.push(newLoopStartNode)
|
||||
}
|
||||
else if (selectedNode) {
|
||||
const commonNestedDisallowPasteNodes = [
|
||||
BlockEnum.End,
|
||||
]
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges([...edges, ...edgesToPaste])
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodePaste, {
|
||||
nodeId: nodesToPaste?.[0]?.id,
|
||||
if (commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type))
|
||||
return
|
||||
|
||||
if (selectedNode.data.type === BlockEnum.Iteration || selectedNode.data.type === BlockEnum.Loop) {
|
||||
const isIteration = selectedNode.data.type === BlockEnum.Iteration
|
||||
|
||||
newNode.data.isInIteration = isIteration
|
||||
newNode.data.iteration_id = isIteration ? selectedNode.id : undefined
|
||||
newNode.data.isInLoop = !isIteration
|
||||
newNode.data.loop_id = !isIteration ? selectedNode.id : undefined
|
||||
|
||||
newNode.parentId = selectedNode.id
|
||||
newNode.zIndex = isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX
|
||||
newNode.positionAbsolute = {
|
||||
x: newNode.position.x,
|
||||
y: newNode.position.y,
|
||||
}
|
||||
newNode.position = getNestedNodePosition(newNode, selectedNode)
|
||||
parentChildrenToAppend.push({
|
||||
parentId: selectedNode.id,
|
||||
childId: newNode.id,
|
||||
childType: newNode.data.type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
idMapping[nodeToPaste.id] = newNode.id
|
||||
nodesToPaste.push(newNode)
|
||||
pastedNodesMap[newNode.id] = newNode
|
||||
|
||||
if (newChildren.length) {
|
||||
newChildren.forEach((child) => {
|
||||
pastedNodesMap[child.id] = child
|
||||
})
|
||||
nodesToPaste.push(...newChildren)
|
||||
}
|
||||
})
|
||||
|
||||
const sourceEdges = clipboardEdges.length ? clipboardEdges : edges
|
||||
|
||||
sourceEdges.forEach((edge) => {
|
||||
const sourceId = idMapping[edge.source]
|
||||
const targetId = idMapping[edge.target]
|
||||
|
||||
if (sourceId && targetId) {
|
||||
const sourceNode = pastedNodesMap[sourceId]
|
||||
const targetNode = pastedNodesMap[targetId]
|
||||
const parentNode = sourceNode?.parentId && sourceNode.parentId === targetNode?.parentId
|
||||
? pastedNodesMap[sourceNode.parentId] ?? nodes.find(n => n.id === sourceNode.parentId)
|
||||
: null
|
||||
const isInIteration = parentNode?.data.type === BlockEnum.Iteration
|
||||
const isInLoop = parentNode?.data.type === BlockEnum.Loop
|
||||
const newEdge: Edge = {
|
||||
...edge,
|
||||
id: `${sourceId}-${edge.sourceHandle}-${targetId}-${edge.targetHandle}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
data: {
|
||||
...edge.data,
|
||||
isInIteration,
|
||||
iteration_id: isInIteration ? parentNode?.id : undefined,
|
||||
isInLoop,
|
||||
loop_id: isInLoop ? parentNode?.id : undefined,
|
||||
_connectedNodeIsSelected: false,
|
||||
},
|
||||
zIndex: parentNode
|
||||
? isInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: isInLoop
|
||||
? LOOP_CHILDREN_Z_INDEX
|
||||
: 0
|
||||
: 0,
|
||||
}
|
||||
edgesToPaste.push(newEdge)
|
||||
}
|
||||
})
|
||||
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
parentChildrenToAppend.forEach(({ parentId, childId, childType }) => {
|
||||
const p = draft.find(n => n.id === parentId)
|
||||
if (p)
|
||||
p.data._children?.push({ nodeId: childId, nodeType: childType })
|
||||
})
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
draft.push(...nodesToPaste)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges([...edges, ...edgesToPaste])
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodePaste, {
|
||||
nodeId: nodesToPaste?.[0]?.id,
|
||||
})
|
||||
handleSyncWorkflowDraft()
|
||||
}, [
|
||||
getNodesReadOnly,
|
||||
workflowStore,
|
||||
store,
|
||||
reactflow,
|
||||
t,
|
||||
saveStateToHistory,
|
||||
handleSyncWorkflowDraft,
|
||||
handleNodeIterationChildrenCopy,
|
||||
handleNodeLoopChildrenCopy,
|
||||
getNodeDefaultValueForPaste,
|
||||
nodesMetaDataMap,
|
||||
])
|
||||
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { readWorkflowClipboard } from '../utils'
|
||||
|
||||
export const usePanelInteractions = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const handlePaneContextMenu = useCallback((e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
void readWorkflowClipboard().then(({ nodes, edges }) => {
|
||||
if (nodes.length)
|
||||
workflowStore.getState().setClipboardData({ nodes, edges })
|
||||
})
|
||||
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Shape, SliceFromInjection } from '../workflow'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { createTestWorkflowStore, renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { createWorkflowStore, useStore, useWorkflowStore } from '../workflow'
|
||||
|
||||
@ -51,6 +52,7 @@ describe('createWorkflowStore', () => {
|
||||
['listeningTriggerNodeIds', 'setListeningTriggerNodeIds', ['n1', 'n2']],
|
||||
['listeningTriggerIsAll', 'setListeningTriggerIsAll', true],
|
||||
['clipboardElements', 'setClipboardElements', []],
|
||||
['clipboardEdges', 'setClipboardEdges', []],
|
||||
['selection', 'setSelection', { x1: 0, y1: 0, x2: 100, y2: 100 }],
|
||||
['bundleNodeSize', 'setBundleNodeSize', { width: 200, height: 100 }],
|
||||
['mousePosition', 'setMousePosition', { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }],
|
||||
@ -68,6 +70,17 @@ describe('createWorkflowStore', () => {
|
||||
expect(store.getState().controlMode).toBe('pointer')
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('workflow-operation-mode', 'pointer')
|
||||
})
|
||||
|
||||
it('should update clipboard nodes and edges with setClipboardData', () => {
|
||||
const store = createStore()
|
||||
const nodes = [createNode({ id: 'n-1' })]
|
||||
const edges = [createEdge({ id: 'e-1', source: 'n-1', target: 'n-2' })]
|
||||
|
||||
store.getState().setClipboardData({ nodes, edges })
|
||||
|
||||
expect(store.getState().clipboardElements).toEqual(nodes)
|
||||
expect(store.getState().clipboardEdges).toEqual(edges)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Slice Setters', () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
TriggerNodeType,
|
||||
WorkflowRunningData,
|
||||
@ -27,7 +28,10 @@ export type WorkflowSliceShape = {
|
||||
listeningTriggerIsAll: boolean
|
||||
setListeningTriggerIsAll: (isAll: boolean) => void
|
||||
clipboardElements: Node[]
|
||||
clipboardEdges: Edge[]
|
||||
setClipboardElements: (clipboardElements: Node[]) => void
|
||||
setClipboardEdges: (clipboardEdges: Edge[]) => void
|
||||
setClipboardData: (clipboardData: { nodes: Node[], edges: Edge[] }) => void
|
||||
selection: null | { x1: number, y1: number, x2: number, y2: number }
|
||||
setSelection: (selection: WorkflowSliceShape['selection']) => void
|
||||
bundleNodeSize: { width: number, height: number } | null
|
||||
@ -60,7 +64,15 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
|
||||
listeningTriggerIsAll: false,
|
||||
setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })),
|
||||
clipboardElements: [],
|
||||
clipboardEdges: [],
|
||||
setClipboardElements: clipboardElements => set(() => ({ clipboardElements })),
|
||||
setClipboardEdges: clipboardEdges => set(() => ({ clipboardEdges })),
|
||||
setClipboardData: ({ nodes, edges }) => {
|
||||
set(() => ({
|
||||
clipboardElements: nodes,
|
||||
clipboardEdges: edges,
|
||||
}))
|
||||
},
|
||||
selection: null,
|
||||
setSelection: selection => set(() => ({ selection })),
|
||||
bundleNodeSize: null,
|
||||
|
||||
100
web/app/components/workflow/utils/__tests__/clipboard.spec.ts
Normal file
100
web/app/components/workflow/utils/__tests__/clipboard.spec.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import {
|
||||
parseWorkflowClipboardText,
|
||||
readWorkflowClipboard,
|
||||
stringifyWorkflowClipboardData,
|
||||
writeWorkflowClipboard,
|
||||
} from '../clipboard'
|
||||
|
||||
describe('workflow clipboard storage', () => {
|
||||
const readTextMock = vi.fn<() => Promise<string>>()
|
||||
const writeTextMock = vi.fn<(text: string) => Promise<void>>()
|
||||
|
||||
beforeEach(() => {
|
||||
readTextMock.mockReset()
|
||||
writeTextMock.mockReset()
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: readTextMock,
|
||||
writeText: writeTextMock,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty clipboard data when clipboard text is empty', async () => {
|
||||
readTextMock.mockResolvedValue('')
|
||||
|
||||
await expect(readWorkflowClipboard()).resolves.toEqual({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
isVersionMismatch: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should write and read clipboard data', async () => {
|
||||
const nodes = [createNode({ id: 'node-1' })]
|
||||
const edges = [createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' })]
|
||||
|
||||
const serialized = stringifyWorkflowClipboardData({ nodes, edges })
|
||||
readTextMock.mockResolvedValue(serialized)
|
||||
|
||||
await writeWorkflowClipboard({ nodes, edges })
|
||||
expect(writeTextMock).toHaveBeenCalledWith(serialized)
|
||||
await expect(readWorkflowClipboard()).resolves.toEqual({
|
||||
nodes,
|
||||
edges,
|
||||
sourceVersion: 1,
|
||||
isVersionMismatch: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow reading clipboard data with different version', async () => {
|
||||
const nodes = [createNode({ id: 'node-1' })]
|
||||
const edges = [createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' })]
|
||||
readTextMock.mockResolvedValue(JSON.stringify({
|
||||
kind: 'dify-workflow-clipboard',
|
||||
version: 2,
|
||||
nodes,
|
||||
edges,
|
||||
}))
|
||||
|
||||
await expect(readWorkflowClipboard()).resolves.toEqual({
|
||||
nodes,
|
||||
edges,
|
||||
sourceVersion: 2,
|
||||
isVersionMismatch: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty clipboard data for invalid JSON', () => {
|
||||
expect(parseWorkflowClipboardText('{invalid-json')).toEqual({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
isVersionMismatch: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty clipboard data for invalid structure', () => {
|
||||
expect(parseWorkflowClipboardText(JSON.stringify({
|
||||
kind: 'unknown',
|
||||
version: 1,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}))).toEqual({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
isVersionMismatch: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty clipboard data when clipboard read fails', async () => {
|
||||
readTextMock.mockRejectedValue(new Error('clipboard denied'))
|
||||
|
||||
await expect(readWorkflowClipboard()).resolves.toEqual({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
isVersionMismatch: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
89
web/app/components/workflow/utils/clipboard.ts
Normal file
89
web/app/components/workflow/utils/clipboard.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
|
||||
const WORKFLOW_CLIPBOARD_VERSION = 1
|
||||
const WORKFLOW_CLIPBOARD_KIND = 'dify-workflow-clipboard'
|
||||
|
||||
type WorkflowClipboardPayload = {
|
||||
kind: string
|
||||
version: number
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
|
||||
export type WorkflowClipboardData = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
|
||||
export type WorkflowClipboardReadResult = WorkflowClipboardData & {
|
||||
sourceVersion?: number
|
||||
isVersionMismatch: boolean
|
||||
}
|
||||
|
||||
const emptyClipboardData: WorkflowClipboardData = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
const emptyClipboardReadResult: WorkflowClipboardReadResult = {
|
||||
...emptyClipboardData,
|
||||
isVersionMismatch: false,
|
||||
}
|
||||
|
||||
const isNodeArray = (value: unknown): value is Node[] => Array.isArray(value)
|
||||
const isEdgeArray = (value: unknown): value is Edge[] => Array.isArray(value)
|
||||
|
||||
export const parseWorkflowClipboardText = (text: string): WorkflowClipboardReadResult => {
|
||||
if (!text)
|
||||
return emptyClipboardReadResult
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Partial<WorkflowClipboardPayload>
|
||||
if (
|
||||
parsed.kind !== WORKFLOW_CLIPBOARD_KIND
|
||||
|| typeof parsed.version !== 'number'
|
||||
|| !isNodeArray(parsed.nodes)
|
||||
|| !isEdgeArray(parsed.edges)
|
||||
) {
|
||||
return emptyClipboardReadResult
|
||||
}
|
||||
|
||||
const sourceVersion = parsed.version
|
||||
|
||||
return {
|
||||
nodes: parsed.nodes,
|
||||
edges: parsed.edges,
|
||||
sourceVersion,
|
||||
isVersionMismatch: sourceVersion !== WORKFLOW_CLIPBOARD_VERSION,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return emptyClipboardReadResult
|
||||
}
|
||||
}
|
||||
|
||||
export const stringifyWorkflowClipboardData = (payload: WorkflowClipboardData): string => {
|
||||
const data: WorkflowClipboardPayload = {
|
||||
kind: WORKFLOW_CLIPBOARD_KIND,
|
||||
version: WORKFLOW_CLIPBOARD_VERSION,
|
||||
nodes: payload.nodes,
|
||||
edges: payload.edges,
|
||||
}
|
||||
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
|
||||
export const writeWorkflowClipboard = async (payload: WorkflowClipboardData): Promise<void> => {
|
||||
const text = stringifyWorkflowClipboardData(payload)
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
export const readWorkflowClipboard = async (): Promise<WorkflowClipboardReadResult> => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
return parseWorkflowClipboardText(text)
|
||||
}
|
||||
catch {
|
||||
return emptyClipboardReadResult
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './clipboard'
|
||||
export * from './common'
|
||||
export * from './data-source'
|
||||
export * from './edge'
|
||||
|
||||
@ -120,6 +120,7 @@
|
||||
"common.branch": "BRANCH",
|
||||
"common.chooseDSL": "Choose DSL file",
|
||||
"common.chooseStartNodeToRun": "Choose the start node to run",
|
||||
"common.clipboardVersionCompatibilityWarning": "This content was copied from a different Dify app version. Some parts may be incompatible.",
|
||||
"common.configure": "Configure",
|
||||
"common.configureRequired": "Configure Required",
|
||||
"common.conversationLog": "Conversation Log",
|
||||
|
||||
Reference in New Issue
Block a user