diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts index ec689f23f9..23f1abde95 100644 --- a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -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 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 } }, diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index ccf4f114c1..3d56b0b5bf 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -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 = {} - const pastedNodesMap: Record = {} - 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 = {} + const pastedNodesMap: Record = {} + 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, ]) diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts index 1f02ac7c74..85169b4af3 100644 --- a/web/app/components/workflow/hooks/use-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -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({ diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index c917986953..0b218a974f 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -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', () => { diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts index 5a479d3e44..cb5c3c602c 100644 --- a/web/app/components/workflow/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-slice.ts @@ -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 = 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, diff --git a/web/app/components/workflow/utils/__tests__/clipboard.spec.ts b/web/app/components/workflow/utils/__tests__/clipboard.spec.ts new file mode 100644 index 0000000000..05fe97a74b --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/clipboard.spec.ts @@ -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>() + const writeTextMock = vi.fn<(text: string) => Promise>() + + 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, + }) + }) +}) diff --git a/web/app/components/workflow/utils/clipboard.ts b/web/app/components/workflow/utils/clipboard.ts new file mode 100644 index 0000000000..4be52c8b13 --- /dev/null +++ b/web/app/components/workflow/utils/clipboard.ts @@ -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 + 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 => { + const text = stringifyWorkflowClipboardData(payload) + await navigator.clipboard.writeText(text) +} + +export const readWorkflowClipboard = async (): Promise => { + try { + const text = await navigator.clipboard.readText() + return parseWorkflowClipboardText(text) + } + catch { + return emptyClipboardReadResult + } +} diff --git a/web/app/components/workflow/utils/index.ts b/web/app/components/workflow/utils/index.ts index 715ce081a3..b84483f655 100644 --- a/web/app/components/workflow/utils/index.ts +++ b/web/app/components/workflow/utils/index.ts @@ -1,3 +1,4 @@ +export * from './clipboard' export * from './common' export * from './data-source' export * from './edge' diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 777d4f5587..e2709ee794 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -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",