copy nodes cross apps

This commit is contained in:
hjlarry
2026-03-11 10:46:52 +08:00
parent cc35e92258
commit d35bbf0fc5
9 changed files with 673 additions and 196 deletions

View File

@ -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 } },

View File

@ -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,
])

View File

@ -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({

View File

@ -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', () => {

View File

@ -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,

View 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,
})
})
})

View 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
}
}

View File

@ -1,3 +1,4 @@
export * from './clipboard'
export * from './common'
export * from './data-source'
export * from './edge'

View File

@ -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",