From 23048a28f5a62ee93c110587b700b3259e92b7c4 Mon Sep 17 00:00:00 2001 From: stone Date: Wed, 19 Feb 2025 17:49:55 +0800 Subject: [PATCH 1/6] fix: Only trigger keyboard shortcuts when ReactFlow is focused --- .../DataStory/controls/useCopyPaste.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/DataStory/controls/useCopyPaste.ts b/packages/ui/src/components/DataStory/controls/useCopyPaste.ts index 2d419ea8..2a048842 100644 --- a/packages/ui/src/components/DataStory/controls/useCopyPaste.ts +++ b/packages/ui/src/components/DataStory/controls/useCopyPaste.ts @@ -40,6 +40,7 @@ export function useCopyPaste< if ((e.ctrlKey || e.metaKey) && e.key === 'a') { e.preventDefault(); } + console.log('handleKeyDown', e); }; rfDomNode.addEventListener('keydown', handleKeyDown); @@ -129,24 +130,30 @@ export function useCopyPaste< 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 }; } -function useKeyboardShortcut(keyCode: KeyCode, handler: () => void) { +function useKeyboardShortcut(keyCode: KeyCode, handler: () => void, rfDomNode: HTMLElement | null) { const [keyPressed, setKeyPressed] = useState(false); const isPressed = useKeyPress(keyCode); useEffect(() => { if (isPressed && !keyPressed) { - handler(); + // console.log('if isPressed && !keyPressed'); + // console.log('rfDomNode?.contains(document.activeElement)', rfDomNode?.contains(document.activeElement)); + // Check if ReactFlow DOM node contains the focused element + if (rfDomNode?.contains(document.activeElement)) { + handler(); + } setKeyPressed(true); } else if (!isPressed && keyPressed) { + // console.log('else if !isPressed && keyPressed'); setKeyPressed(false); } - }, [isPressed, keyPressed, handler]); + }, [isPressed, keyPressed, handler, rfDomNode]); } From 260daa96ec1560ccc1bf889fab70a9af56e852d3 Mon Sep 17 00:00:00 2001 From: stone Date: Wed, 19 Feb 2025 18:36:25 +0800 Subject: [PATCH 2/6] fix: resolve issue where shortcut required to be activated twice --- .../src/components/DataStory/controls/useCopyPaste.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/DataStory/controls/useCopyPaste.ts b/packages/ui/src/components/DataStory/controls/useCopyPaste.ts index 2a048842..a4ed59f0 100644 --- a/packages/ui/src/components/DataStory/controls/useCopyPaste.ts +++ b/packages/ui/src/components/DataStory/controls/useCopyPaste.ts @@ -40,7 +40,6 @@ export function useCopyPaste< if ((e.ctrlKey || e.metaKey) && e.key === 'a') { e.preventDefault(); } - console.log('handleKeyDown', e); }; rfDomNode.addEventListener('keydown', handleKeyDown); @@ -140,19 +139,13 @@ export function useCopyPaste< 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) { - // console.log('if isPressed && !keyPressed'); - // console.log('rfDomNode?.contains(document.activeElement)', rfDomNode?.contains(document.activeElement)); - // Check if ReactFlow DOM node contains the focused element - if (rfDomNode?.contains(document.activeElement)) { - handler(); - } + handler(); setKeyPressed(true); } else if (!isPressed && keyPressed) { - // console.log('else if !isPressed && keyPressed'); setKeyPressed(false); } }, [isPressed, keyPressed, handler, rfDomNode]); From d440b7e8060daad203e47e0d752055aeffca9eee Mon Sep 17 00:00:00 2001 From: stone Date: Fri, 21 Feb 2025 08:46:05 +0800 Subject: [PATCH 3/6] feat: Implement copy-paste functionality using clipboard API --- .../DataStory/controls/useCopyPaste.ts | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/DataStory/controls/useCopyPaste.ts b/packages/ui/src/components/DataStory/controls/useCopyPaste.ts index a4ed59f0..c251871d 100644 --- a/packages/ui/src/components/DataStory/controls/useCopyPaste.ts +++ b/packages/ui/src/components/DataStory/controls/useCopyPaste.ts @@ -10,11 +10,47 @@ import { XYPosition, } from '@xyflow/react'; +const writeToClipboard = ({ + selectedNodes, + selectedEdges, +}: { + selectedNodes: Node[]; + selectedEdges: Edge[]; +}) => { + // Write to system clipboard + navigator.clipboard.writeText( + JSON.stringify({ + nodes: selectedNodes, + edges: selectedEdges, + }), + ); +} + +const readFromClipboard = async (): Promise<{ nodes: Node[]; edges: Edge[] }> => { + let nodes: Node[] = []; + let edges: Edge[] = []; + try { + // Read from system clipboard + const text = await navigator.clipboard.readText(); + nodes = JSON.parse(text).nodes; + edges = JSON.parse(text).edges; + } catch (e) { + console.error('Error reading from clipboard', e); + } + return { nodes, edges }; +} + export function useCopyPaste< SerializedReactFlowNode extends Node = Node, SerializedReactFlowEdge extends Edge = Edge >() { - const { getNodes, setNodes, getEdges, setEdges, screenToFlowPosition } = useReactFlow(); + const { + getNodes, + setNodes, + getEdges, + setEdges, + screenToFlowPosition, + } = useReactFlow(); const mousePosRef = useRef({ x: 0, y: 0 }); const rfDomNode = useStore((state) => state.domNode); const [bufferedNodes, setBufferedNodes] = useState([]); @@ -63,18 +99,22 @@ export function useCopyPaste< selectedEdges: getConnectedEdges(selectedNodes, getEdges()).filter(isEdgeInternal), }; }, [getNodes, getEdges]); - const copy = useCallback(() => { const { selectedNodes, selectedEdges } = getSelectedNodesAndEdges(); + writeToClipboard({ selectedNodes, selectedEdges }); + setBufferedNodes(selectedNodes); setBufferedEdges(selectedEdges); }, [getSelectedNodesAndEdges]); const cut = useCallback(() => { const { selectedNodes, selectedEdges } = getSelectedNodesAndEdges(); + writeToClipboard({ selectedNodes, selectedEdges }); + setBufferedNodes(selectedNodes); setBufferedEdges(selectedEdges); + // Existing cut logic setNodes(nodes => nodes.filter(node => !node.selected)); setEdges(edges => { return edges.filter(edge => { @@ -83,16 +123,21 @@ 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, edges } = await readFromClipboard(); + // const copiedNodes = nodes; + // const copiedEdges = edges; + const copiedNodes = bufferedNodes; + const copiedEdges = bufferedEdges; 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), @@ -110,7 +155,7 @@ export function useCopyPaste< }, })); - const newEdges = bufferedEdges.map(edge => ({ + const newEdges = copiedEdges.map(edge => ({ ...structuredClone(edge), id: generateCopiedId(edge.id), source: generateCopiedId(edge.source), From 40a444a9d326a4d11d88e640d796e60acf929664 Mon Sep 17 00:00:00 2001 From: stone Date: Fri, 21 Feb 2025 09:27:08 +0800 Subject: [PATCH 4/6] Refactor: Improve copy-paste functionality with clipboard API --- packages/ui/src/SerializedReactFlow.ts | 5 +- .../components/DataStory/DataStoryCanvas.tsx | 2 +- .../components/DataStory/common/clipboard.ts | 30 ++++++++ .../DataStory/{controls => }/useCopyPaste.ts | 70 ++++--------------- 4 files changed, 49 insertions(+), 58 deletions(-) create mode 100644 packages/ui/src/components/DataStory/common/clipboard.ts rename packages/ui/src/components/DataStory/{controls => }/useCopyPaste.ts (79%) 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..6aebde06 --- /dev/null +++ b/packages/ui/src/components/DataStory/common/clipboard.ts @@ -0,0 +1,30 @@ +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.error('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 79% rename from packages/ui/src/components/DataStory/controls/useCopyPaste.ts rename to packages/ui/src/components/DataStory/useCopyPaste.ts index c251871d..87f440ec 100644 --- a/packages/ui/src/components/DataStory/controls/useCopyPaste.ts +++ b/packages/ui/src/components/DataStory/useCopyPaste.ts @@ -1,60 +1,20 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { - Edge, - getConnectedEdges, - type KeyCode, - Node, - useKeyPress, - useReactFlow, - useStore, - XYPosition, -} from '@xyflow/react'; - -const writeToClipboard = ({ - selectedNodes, - selectedEdges, -}: { - selectedNodes: Node[]; - selectedEdges: Edge[]; -}) => { - // Write to system clipboard - navigator.clipboard.writeText( - JSON.stringify({ - nodes: selectedNodes, - edges: selectedEdges, - }), - ); -} - -const readFromClipboard = async (): Promise<{ nodes: Node[]; edges: Edge[] }> => { - let nodes: Node[] = []; - let edges: Edge[] = []; - try { - // Read from system clipboard - const text = await navigator.clipboard.readText(); - nodes = JSON.parse(text).nodes; - edges = JSON.parse(text).edges; - } catch (e) { - console.error('Error reading from clipboard', e); - } - return { nodes, edges }; -} +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< - SerializedReactFlowNode extends Node = Node, - SerializedReactFlowEdge extends Edge = Edge ->() { +export function useCopyPaste() { const { getNodes, setNodes, getEdges, setEdges, screenToFlowPosition, - } = useReactFlow(); + } = useReactFlow(); const mousePosRef = useRef({ x: 0, y: 0 }); const rfDomNode = useStore((state) => state.domNode); - const [bufferedNodes, setBufferedNodes] = useState([]); - const [bufferedEdges, setBufferedEdges] = useState([]); + const [bufferedNodes, setBufferedNodes] = useState([]); + const [bufferedEdges, setBufferedEdges] = useState([]); // Event handling setup useEffect(() => { @@ -89,7 +49,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); @@ -127,11 +87,11 @@ export function useCopyPaste< const now = Date.now(); const generateCopiedId = (originalId: string) => `${originalId}-${now}`; - // const { nodes, edges } = await readFromClipboard(); - // const copiedNodes = nodes; - // const copiedEdges = edges; - const copiedNodes = bufferedNodes; - const copiedEdges = bufferedEdges; + const { nodes, edges } = await readFromClipboard(); + const copiedNodes = nodes; + const copiedEdges = edges; + // const copiedNodes = bufferedNodes; + // const copiedEdges = bufferedEdges; const calculateNewPosition = (originalPos: XYPosition) => ({ 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))), @@ -153,7 +113,7 @@ export function useCopyPaste< id: `${generateCopiedId(node.id)}.${(output.id as string).split('.').pop()}`, })), }, - })); + })) as SNode[]; const newEdges = copiedEdges.map(edge => ({ ...structuredClone(edge), @@ -163,7 +123,7 @@ 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]); From 9f14ef9012089e0954fbd73aa72929d1bd783ab6 Mon Sep 17 00:00:00 2001 From: stone Date: Fri, 21 Feb 2025 09:33:32 +0800 Subject: [PATCH 5/6] feat: Enhance copy-paste functionality and remove buffered data --- .../components/DataStory/common/clipboard.ts | 2 +- .../src/components/DataStory/useCopyPaste.ts | 21 +++++-------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/DataStory/common/clipboard.ts b/packages/ui/src/components/DataStory/common/clipboard.ts index 6aebde06..5f8936b9 100644 --- a/packages/ui/src/components/DataStory/common/clipboard.ts +++ b/packages/ui/src/components/DataStory/common/clipboard.ts @@ -24,7 +24,7 @@ export const readFromClipboard = async(): Promise<{ nodes: SNode[]; edges: SEdge nodes = JSON.parse(text).nodes; edges = JSON.parse(text).edges; } catch(e) { - console.error('Error reading from clipboard', 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/useCopyPaste.ts b/packages/ui/src/components/DataStory/useCopyPaste.ts index 87f440ec..b8cdf172 100644 --- a/packages/ui/src/components/DataStory/useCopyPaste.ts +++ b/packages/ui/src/components/DataStory/useCopyPaste.ts @@ -13,8 +13,6 @@ export function useCopyPaste() { } = 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(() => { @@ -59,22 +57,16 @@ export function useCopyPaste() { selectedEdges: getConnectedEdges(selectedNodes, getEdges()).filter(isEdgeInternal), }; }, [getNodes, getEdges]); + const copy = useCallback(() => { const { selectedNodes, selectedEdges } = getSelectedNodesAndEdges(); writeToClipboard({ selectedNodes, selectedEdges }); - - setBufferedNodes(selectedNodes); - setBufferedEdges(selectedEdges); }, [getSelectedNodesAndEdges]); const cut = useCallback(() => { const { selectedNodes, selectedEdges } = getSelectedNodesAndEdges(); writeToClipboard({ selectedNodes, selectedEdges }); - setBufferedNodes(selectedNodes); - setBufferedEdges(selectedEdges); - - // Existing cut logic setNodes(nodes => nodes.filter(node => !node.selected)); setEdges(edges => { return edges.filter(edge => { @@ -87,11 +79,8 @@ export function useCopyPaste() { const now = Date.now(); const generateCopiedId = (originalId: string) => `${originalId}-${now}`; - const { nodes, edges } = await readFromClipboard(); - const copiedNodes = nodes; - const copiedEdges = edges; - // const copiedNodes = bufferedNodes; - // const copiedEdges = bufferedEdges; + const { nodes: copiedNodes, edges: copiedEdges } = await readFromClipboard(); + const calculateNewPosition = (originalPos: XYPosition) => ({ 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))), @@ -127,7 +116,7 @@ export function useCopyPaste() { 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 }))); @@ -139,7 +128,7 @@ export function useCopyPaste() { 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, rfDomNode: HTMLElement | null) { From b6975368520002a143f4766e24bd69e4a626bd7e Mon Sep 17 00:00:00 2001 From: stone Date: Fri, 21 Feb 2025 09:49:43 +0800 Subject: [PATCH 6/6] fix: Prevent crash when pasting empty clipboard data in DataStory --- packages/ui/src/components/DataStory/common/clipboard.ts | 5 +++-- packages/ui/src/components/DataStory/useCopyPaste.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/DataStory/common/clipboard.ts b/packages/ui/src/components/DataStory/common/clipboard.ts index 5f8936b9..60d0e9d5 100644 --- a/packages/ui/src/components/DataStory/common/clipboard.ts +++ b/packages/ui/src/components/DataStory/common/clipboard.ts @@ -15,14 +15,15 @@ export const writeToClipboard = ({ }), ); } + 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; + nodes = JSON.parse(text).nodes ?? []; + edges = JSON.parse(text).edges ?? []; } catch(e) { console.warn('Error reading from clipboard', e); } diff --git a/packages/ui/src/components/DataStory/useCopyPaste.ts b/packages/ui/src/components/DataStory/useCopyPaste.ts index b8cdf172..0288c396 100644 --- a/packages/ui/src/components/DataStory/useCopyPaste.ts +++ b/packages/ui/src/components/DataStory/useCopyPaste.ts @@ -80,6 +80,7 @@ export function useCopyPaste() { 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(...copiedNodes.map(n => n.position.x))),