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: enhance keyboard shortcuts and implement cross-panel copy-paste functionality #383

Merged
merged 6 commits into from
Feb 21, 2025
Merged
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]);
}