diff --git a/packages/ui/src/SerializedReactFlow.ts b/packages/ui/src/SerializedReactFlow.ts index ab47f497..919ff91d 100644 --- a/packages/ui/src/SerializedReactFlow.ts +++ b/packages/ui/src/SerializedReactFlow.ts @@ -1,6 +1,6 @@ import { Param } from '@data-story/core' import type { CSSProperties, ReactNode } from 'react'; - +import { Node , Edge } from '@xyflow/react'; export type SerializedReactFlow = { nodes: SerializedReactFlowNode[], edges: SerializedReactFlowEdge[], @@ -42,7 +42,6 @@ export type SerializedReactFlowNode = { }, dragging?: boolean, } - export type SerializedReactFlowEdge = { id: string source: string, @@ -52,3 +51,5 @@ export type SerializedReactFlowEdge = { label?: string | ReactNode; labelBgStyle?: CSSProperties; } +export type SNode = SerializedReactFlowNode & Node; +export type SEdge = SerializedReactFlowEdge & Edge; diff --git a/packages/ui/src/components/DataStory/DataStoryCanvas.tsx b/packages/ui/src/components/DataStory/DataStoryCanvas.tsx index 61ccc94f..30e119fe 100644 --- a/packages/ui/src/components/DataStory/DataStoryCanvas.tsx +++ b/packages/ui/src/components/DataStory/DataStoryCanvas.tsx @@ -27,7 +27,7 @@ import { getNodesWithNewSelection } from './getNodesWithNewSelection'; import { createDataStoryId, LinkCount, LinkId, NodeStatus, RequestObserverType } from '@data-story/core'; import { useDragNode } from './useDragNode'; import { ReactFlowNode } from '../Node/ReactFlowNode'; -import { useCopyPaste } from './controls/useCopyPaste'; +import { useCopyPaste } from './useCopyPaste'; import '../../styles/dataStoryCanvasStyle.css'; const nodeTypes = { diff --git a/packages/ui/src/components/DataStory/common/clipboard.ts b/packages/ui/src/components/DataStory/common/clipboard.ts new file mode 100644 index 00000000..60d0e9d5 --- /dev/null +++ b/packages/ui/src/components/DataStory/common/clipboard.ts @@ -0,0 +1,31 @@ +import { SEdge, SNode } from '../../../SerializedReactFlow'; + +export const writeToClipboard = ({ + selectedNodes, + selectedEdges, +}: { + selectedNodes: SNode[]; + selectedEdges: SEdge[]; +}) => { + // Write to system clipboard + navigator.clipboard.writeText( + JSON.stringify({ + nodes: selectedNodes, + edges: selectedEdges, + }), + ); +} + +export const readFromClipboard = async(): Promise<{ nodes: SNode[]; edges: SEdge[] }> => { + let nodes: SNode[] = []; + let edges: SEdge[] = []; + try { + // Read from system clipboard + const text = await navigator.clipboard.readText(); + nodes = JSON.parse(text).nodes ?? []; + edges = JSON.parse(text).edges ?? []; + } catch(e) { + console.warn('Error reading from clipboard', e); + } + return { nodes, edges }; +} \ No newline at end of file diff --git a/packages/ui/src/components/DataStory/controls/useCopyPaste.ts b/packages/ui/src/components/DataStory/useCopyPaste.ts similarity index 72% rename from packages/ui/src/components/DataStory/controls/useCopyPaste.ts rename to packages/ui/src/components/DataStory/useCopyPaste.ts index 2d419ea8..0288c396 100644 --- a/packages/ui/src/components/DataStory/controls/useCopyPaste.ts +++ b/packages/ui/src/components/DataStory/useCopyPaste.ts @@ -1,24 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { - Edge, - getConnectedEdges, - type KeyCode, - Node, - useKeyPress, - useReactFlow, - useStore, - XYPosition, -} from '@xyflow/react'; - -export function useCopyPaste< - SerializedReactFlowNode extends Node = Node, - SerializedReactFlowEdge extends Edge = Edge ->() { - const { getNodes, setNodes, getEdges, setEdges, screenToFlowPosition } = useReactFlow(); +import { getConnectedEdges, type KeyCode, useKeyPress, useReactFlow, useStore, XYPosition } from '@xyflow/react'; +import { SEdge, SNode } from '../../SerializedReactFlow'; +import { readFromClipboard, writeToClipboard } from './common/clipboard'; + +export function useCopyPaste() { + const { + getNodes, + setNodes, + getEdges, + setEdges, + screenToFlowPosition, + } = useReactFlow(); const mousePosRef = useRef({ x: 0, y: 0 }); const rfDomNode = useStore((state) => state.domNode); - const [bufferedNodes, setBufferedNodes] = useState([]); - const [bufferedEdges, setBufferedEdges] = useState([]); // Event handling setup useEffect(() => { @@ -53,7 +47,7 @@ export function useCopyPaste< const getSelectedNodesAndEdges = useCallback(() => { const selectedNodes = getNodes().filter(node => node.selected); // the edge is internal if both source and target nodes are selected - const isEdgeInternal = (edge: SerializedReactFlowEdge) => + const isEdgeInternal = (edge: SEdge) => selectedNodes.some(n => n.id === edge.source) && selectedNodes.some(n => n.id === edge.target); @@ -66,14 +60,12 @@ export function useCopyPaste< const copy = useCallback(() => { const { selectedNodes, selectedEdges } = getSelectedNodesAndEdges(); - setBufferedNodes(selectedNodes); - setBufferedEdges(selectedEdges); + writeToClipboard({ selectedNodes, selectedEdges }); }, [getSelectedNodesAndEdges]); const cut = useCallback(() => { const { selectedNodes, selectedEdges } = getSelectedNodesAndEdges(); - setBufferedNodes(selectedNodes); - setBufferedEdges(selectedEdges); + writeToClipboard({ selectedNodes, selectedEdges }); setNodes(nodes => nodes.filter(node => !node.selected)); setEdges(edges => { @@ -83,16 +75,19 @@ export function useCopyPaste< }); }, [getSelectedNodesAndEdges, setNodes, setEdges]); - const paste = useCallback((position = screenToFlowPosition(mousePosRef.current)) => { + const paste = useCallback(async (position = screenToFlowPosition(mousePosRef.current)) => { const now = Date.now(); const generateCopiedId = (originalId: string) => `${originalId}-${now}`; + const { nodes: copiedNodes, edges: copiedEdges } = await readFromClipboard(); + if (!copiedNodes?.length) return; + const calculateNewPosition = (originalPos: XYPosition) => ({ - x: position.x + (originalPos.x - Math.min(...bufferedNodes.map(n => n.position.x))), - y: position.y + (originalPos.y - Math.min(...bufferedNodes.map(n => n.position.y))), + x: position.x + (originalPos.x - Math.min(...copiedNodes.map(n => n.position.x))), + y: position.y + (originalPos.y - Math.min(...copiedNodes.map(n => n.position.y))), }); - const newNodes = bufferedNodes.map(node => ({ + const newNodes = copiedNodes.map(node => ({ ...structuredClone(node), id: generateCopiedId(node.id), position: calculateNewPosition(node.position), @@ -108,9 +103,9 @@ export function useCopyPaste< id: `${generateCopiedId(node.id)}.${(output.id as string).split('.').pop()}`, })), }, - })); + })) as SNode[]; - const newEdges = bufferedEdges.map(edge => ({ + const newEdges = copiedEdges.map(edge => ({ ...structuredClone(edge), id: generateCopiedId(edge.id), source: generateCopiedId(edge.source), @@ -118,28 +113,28 @@ export function useCopyPaste< target: generateCopiedId(edge.target), targetHandle: `${generateCopiedId(edge.target)}.${edge.targetHandle!.split('.').pop()}`, selected: true, - })); + })) as SEdge[]; setNodes(nodes => [...nodes.map(node => ({ ...node, selected: false })), ...newNodes]); setEdges(edges => [...edges.map(edge => ({ ...edge, selected: false })), ...newEdges]); - }, [bufferedNodes, bufferedEdges, screenToFlowPosition, setNodes, setEdges]); + }, [screenToFlowPosition, setNodes, setEdges]); const selectAll = useCallback(() => { setNodes(nodes => nodes.map(node => ({ ...node, selected: true }))); setEdges(edges => edges.map(edge => ({ ...edge, selected: true }))); }, [setNodes, setEdges]); - useKeyboardShortcut(['Meta+x', 'Control+x'], cut); - useKeyboardShortcut(['Meta+c', 'Control+c'], copy); - useKeyboardShortcut(['Meta+v', 'Control+v'], paste); - useKeyboardShortcut(['Meta+a', 'Control+a'], selectAll); + useKeyboardShortcut(['Meta+x', 'Control+x'], cut, rfDomNode); + useKeyboardShortcut(['Meta+c', 'Control+c'], copy, rfDomNode); + useKeyboardShortcut(['Meta+v', 'Control+v'], paste, rfDomNode); + useKeyboardShortcut(['Meta+a', 'Control+a'], selectAll, rfDomNode); - return { cut, copy, paste, bufferedNodes, bufferedEdges }; + return { cut, copy, paste, selectAll }; } -function useKeyboardShortcut(keyCode: KeyCode, handler: () => void) { +function useKeyboardShortcut(keyCode: KeyCode, handler: () => void, rfDomNode: HTMLElement | null) { const [keyPressed, setKeyPressed] = useState(false); - const isPressed = useKeyPress(keyCode); + const isPressed = useKeyPress(keyCode, { target: rfDomNode }); useEffect(() => { if (isPressed && !keyPressed) { @@ -148,5 +143,5 @@ function useKeyboardShortcut(keyCode: KeyCode, handler: () => void) { } else if (!isPressed && keyPressed) { setKeyPressed(false); } - }, [isPressed, keyPressed, handler]); + }, [isPressed, keyPressed, handler, rfDomNode]); }