Skip to content

Commit

Permalink
Merge pull request #383 from stone-lyl/feat-copy-paste
Browse files Browse the repository at this point in the history
feat: enhance keyboard shortcuts and implement cross-panel copy-paste functionality
  • Loading branch information
stone-lyl authored Feb 21, 2025
2 parents 67bf682 + b697536 commit 67182df
Show file tree
Hide file tree
Showing 4 changed files with 69 additions and 42 deletions.
5 changes: 3 additions & 2 deletions packages/ui/src/SerializedReactFlow.ts
Original file line number Diff line number Diff line change
@@ -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[],
Expand Down Expand Up @@ -42,7 +42,6 @@ export type SerializedReactFlowNode = {
},
dragging?: boolean,
}

export type SerializedReactFlowEdge = {
id: string
source: string,
Expand All @@ -52,3 +51,5 @@ export type SerializedReactFlowEdge = {
label?: string | ReactNode;
labelBgStyle?: CSSProperties;
}
export type SNode = SerializedReactFlowNode & Node;
export type SEdge = SerializedReactFlowEdge & Edge;
2 changes: 1 addition & 1 deletion packages/ui/src/components/DataStory/DataStoryCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
31 changes: 31 additions & 0 deletions packages/ui/src/components/DataStory/common/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
@@ -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<SerializedReactFlowNode, SerializedReactFlowEdge>();
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<SNode, SEdge>();
const mousePosRef = useRef<XYPosition>({ x: 0, y: 0 });
const rfDomNode = useStore((state) => state.domNode);
const [bufferedNodes, setBufferedNodes] = useState<SerializedReactFlowNode[]>([]);
const [bufferedEdges, setBufferedEdges] = useState<SerializedReactFlowEdge[]>([]);

// Event handling setup
useEffect(() => {
Expand Down Expand Up @@ -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);

Expand All @@ -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 => {
Expand All @@ -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),
Expand All @@ -108,38 +103,38 @@ 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),
sourceHandle: `${generateCopiedId(edge.source)}.${edge.sourceHandle!.split('.').pop()}`,
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) {
Expand All @@ -148,5 +143,5 @@ function useKeyboardShortcut(keyCode: KeyCode, handler: () => void) {
} else if (!isPressed && keyPressed) {
setKeyPressed(false);
}
}, [isPressed, keyPressed, handler]);
}, [isPressed, keyPressed, handler, rfDomNode]);
}

0 comments on commit 67182df

Please sign in to comment.