Skip to content

Commit

Permalink
Merge pull request #382 from stone-lyl/feat-copy-paste
Browse files Browse the repository at this point in the history
feat: implement copy, paste, cut, and select all functionality for node/edge management
  • Loading branch information
stone-lyl authored Feb 20, 2025
2 parents 01f3e3d + 781b98a commit 67bf682
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 14 deletions.
18 changes: 7 additions & 11 deletions packages/ui/src/components/DataStory/DataStoryCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ 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 '../../styles/dataStoryCanvasStyle.css';

const nodeTypes = {
commentNodeComponent: CommentNodeComponent,
Expand All @@ -53,7 +55,7 @@ export const DataStoryCanvas = React.memo(DataStoryCanvasComponent);

const Flow = ({
initDiagram,
controls,
controls = [],
onInitialize,
setSidebarKey,
onSave,
Expand Down Expand Up @@ -187,7 +189,7 @@ const Flow = ({
edges,
});

const getOnNodesDelete = useCallback((nodesToDelete: ReactFlowNode[]) => {
const getOnNodesDelete = useCallback((nodesToDelete: ReactFlowNode[]) => {
nodesToDelete.forEach(node => {
const store = reactFlowStore.getState();
const { edges } = store;
Expand All @@ -214,18 +216,12 @@ const Flow = ({
focusOnFlow();
}, [connect, focusOnFlow, reactFlowStore]);

useCopyPaste();

return (
<>
<style>
{`
@keyframes dash {
to {
stroke-dashoffset: -10;
}
}
.react-flow__edge:hover {
cursor: crosshair;
}
${draggedNode ? `
.react-flow__edge {
opacity: 0.5;
Expand Down Expand Up @@ -258,7 +254,7 @@ const Flow = ({
}}
onEdgeDoubleClick={(event, edge) => {
if (!client) return;
if(client.onEdgeDoubleClick) client.onEdgeDoubleClick(edge.id);
if (client.onEdgeDoubleClick) client.onEdgeDoubleClick(edge.id);
}}
onEdgesChange={(changes: EdgeChange[]) => {
onEdgesChange(changes);
Expand Down
152 changes: 152 additions & 0 deletions packages/ui/src/components/DataStory/controls/useCopyPaste.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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>();
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(() => {
if (!rfDomNode) return;

// Listen for mouse move events on the DOM node and disable the default actions for cut, copy, and paste
const handleMouseMove = (event: MouseEvent) => {
mousePosRef.current = { x: event.clientX, y: event.clientY };
};

const preventDefault = (e: Event) => e.preventDefault();
const events: (keyof HTMLElementEventMap)[] = ['cut', 'copy', 'paste'];

events.forEach(event => rfDomNode.addEventListener(event, preventDefault));
rfDomNode.addEventListener('mousemove', handleMouseMove);

// Listen for keydown events on the DOM node and disable the default action for select all
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
}
};

rfDomNode.addEventListener('keydown', handleKeyDown);
return () => {
events.forEach(event => rfDomNode.removeEventListener(event, preventDefault));
rfDomNode.removeEventListener('mousemove', handleMouseMove);
rfDomNode.removeEventListener('keydown', handleKeyDown);
};
}, [rfDomNode]);

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) =>
selectedNodes.some(n => n.id === edge.source) &&
selectedNodes.some(n => n.id === edge.target);

return {
selectedNodes: selectedNodes,
// Filter out the internal edges connected to the selected nodes that have nodes on both ends
selectedEdges: getConnectedEdges(selectedNodes, getEdges()).filter(isEdgeInternal),
};
}, [getNodes, getEdges]);

const copy = useCallback(() => {
const { selectedNodes, selectedEdges } = getSelectedNodesAndEdges();
setBufferedNodes(selectedNodes);
setBufferedEdges(selectedEdges);
}, [getSelectedNodesAndEdges]);

const cut = useCallback(() => {
const { selectedNodes, selectedEdges } = getSelectedNodesAndEdges();
setBufferedNodes(selectedNodes);
setBufferedEdges(selectedEdges);

setNodes(nodes => nodes.filter(node => !node.selected));
setEdges(edges => {
return edges.filter(edge => {
return !selectedEdges.some(selectedEdge => edge.id === selectedEdge.id);
});
});
}, [getSelectedNodesAndEdges, setNodes, setEdges]);

const paste = useCallback((position = screenToFlowPosition(mousePosRef.current)) => {
const now = Date.now();
const generateCopiedId = (originalId: string) => `${originalId}-${now}`;

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

const newNodes = bufferedNodes.map(node => ({
...structuredClone(node),
id: generateCopiedId(node.id),
position: calculateNewPosition(node.position),
selected: true,
data: {
...structuredClone(node.data),
inputs: (node.data.inputs as Record<string, unknown>[] || [])?.map(input => ({
...input,
id: `${generateCopiedId(node.id)}.${(input.id as string).split('.').pop()}`,
})),
outputs: (node.data.outputs as Record<string, unknown>[] || [])?.map(output => ({
...output,
id: `${generateCopiedId(node.id)}.${(output.id as string).split('.').pop()}`,
})),
},
}));

const newEdges = bufferedEdges.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,
}));

setNodes(nodes => [...nodes.map(node => ({ ...node, selected: false })), ...newNodes]);
setEdges(edges => [...edges.map(edge => ({ ...edge, selected: false })), ...newEdges]);
}, [bufferedNodes, bufferedEdges, 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);

return { cut, copy, paste, bufferedNodes, bufferedEdges };
}

function useKeyboardShortcut(keyCode: KeyCode, handler: () => void) {
const [keyPressed, setKeyPressed] = useState(false);
const isPressed = useKeyPress(keyCode);

useEffect(() => {
if (isPressed && !keyPressed) {
handler();
setKeyPressed(true);
} else if (!isPressed && keyPressed) {
setKeyPressed(false);
}
}, [isPressed, keyPressed, handler]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { ReactFlowNode } from '../Node/ReactFlowNode'

export type Direction = 'up' | 'down' | 'left' | 'right'

/**
* using the direction, find the closest node to the current selected node
* @param {Direction} direction
* @param {ReactFlowNode[]} nodes
* @returns {ReactFlowNode | undefined}
*/
export const getNodesWithNewSelection = (
direction: Direction,
nodes: ReactFlowNode[],
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/Node/NodeComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const NodeComponent = ({ id, data, selected }: {
return (
(
<div
className={'text-xs' + (selected ? ' shadow-xl' : '')}
className={`text-xs ${selected ? 'shadow-xl shadow-blue-100 ring-1 ring-blue-200' : ''}`}
data-cy="data-story-node-component"
onDoubleClick={() => {
setOpenNodeSidebarId(id)
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/Node/table/TableNodeComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { MemoizedTableHeader } from './MemoizedTableHeader';
import { CELL_MAX_WIDTH, CELL_MIN_WIDTH, CELL_WIDTH, CellsMatrix, ColumnWidthOptions } from './CellsMatrix';
import { getFormatterOnlyAndDropParam } from './GetFormatterOnlyAndDropParam';

const TableNodeComponent = ({ id, data }: {
const TableNodeComponent = ({ id, data, selected }: {
id: string,
data: DataStoryNodeData,
selected: boolean
Expand Down Expand Up @@ -158,7 +158,7 @@ const TableNodeComponent = ({ id, data }: {
return (
<div
ref={tableRef}
className="shadow-xl bg-gray-50 border rounded border-gray-300 text-xs"
className={`text-xs border rounded border-gray-300 ${selected ? 'shadow-xl shadow-blue-100 ring-1 ring-blue-200' : ''} `}
>
<CustomHandle id={input.id} isConnectable={true} isInput={true} />
<div data-cy={'data-story-table'} className="text-gray-600 max-w-[750px] bg-gray-100 rounded font-mono -mt-3">
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/styles/dataStoryCanvasStyle.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@keyframes dash {
to {
stroke-dashoffset: -10;
}
}

.react-flow__edge:hover {
cursor: crosshair;
}

0 comments on commit 67bf682

Please sign in to comment.