Skip to content

feat: implement copy, paste, cut, and select all functionality for node/edge management #382

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}