Skip to content
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

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

Merged
merged 10 commits into from
Feb 20, 2025
Merged
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
133 changes: 133 additions & 0 deletions packages/ui/src/components/DataStory/controls/useCopyPaste.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import {
Node,
useKeyPress,
useReactFlow,
getConnectedEdges,
Edge,
XYPosition,
useStore,
type KeyCode,
} from '@xyflow/react';

export function useCopyPaste<
NodeType extends Node = Node,
EdgeType extends Edge = Edge
>() {
const { getNodes, setNodes, getEdges, setEdges, screenToFlowPosition } = useReactFlow<NodeType, EdgeType>();
const mousePosRef = useRef<XYPosition>({ x: 0, y: 0 });
const rfDomNode = useStore((state) => state.domNode);
const [bufferedNodes, setBufferedNodes] = useState<NodeType[]>([]);
const [bufferedEdges, setBufferedEdges] = useState<EdgeType[]>([]);

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

return {
nodes: selectedNodes,
edges: getConnectedEdges(selectedNodes, getEdges()).filter(isEdgeInternal),
};
}, [getNodes, getEdges]);

const copy = useCallback(() => {
const { nodes, edges } = getSelectedNodesAndEdges();
setBufferedNodes(nodes);
setBufferedEdges(edges as EdgeType[]);
}, [getSelectedNodesAndEdges]);

const cut = useCallback(() => {
const { nodes, edges } = getSelectedNodesAndEdges();
setBufferedNodes(nodes);
setBufferedEdges(edges as EdgeType[]);

setNodes(nodes => nodes.filter(node => !node.selected));
setEdges(edges => edges.filter(edge => !(edges as EdgeType[]).includes(edge as EdgeType)));
}, [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 => ({
...node,
id: generateCopiedId(node.id),
position: calculateNewPosition(node.position),
selected: true,
}));

const newEdges = bufferedEdges.map(edge => ({
...edge,
id: generateCopiedId(edge.id),
source: generateCopiedId(edge.source),
target: generateCopiedId(edge.target),
selected: true,
}));

setNodes(nodes => [...nodes.map(n => ({ ...n, selected: false })), ...newNodes]);
setEdges(edges => [...edges.map(e => ({ ...e, 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;
}