From 3c49feecaf3523e2aa9183cdcdbe12a1abf70f8a Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 5 May 2023 10:58:25 -0300 Subject: [PATCH 1/5] Copy and Paste function done --- src/frontend/src/pages/FlowPage/index.tsx | 83 +++++++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx index be168d6d4..b15a99aab 100644 --- a/src/frontend/src/pages/FlowPage/index.tsx +++ b/src/frontend/src/pages/FlowPage/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useRef } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import ReactFlow, { Background, Controls, @@ -10,6 +10,8 @@ import ReactFlow, { EdgeChange, Connection, Edge, + useKeyPress, + useOnSelectionChange, } from "reactflow"; import { locationContext } from "../../contexts/locationContext"; import ExtraSidebar from "./components/extraSidebarComponent"; @@ -36,6 +38,76 @@ export default function FlowPage({ flow }:{flow:FlowType}) { useContext(typesContext); const reactFlowWrapper = useRef(null); + const copied = useKeyPress(['Meta+c', 'Strg+c']) + const pasted = useKeyPress(['Meta+v', 'Strg+v']) + const [lastSelection, setLastSelection] = useState(null); + const [lastCopiedSelection, setLastCopiedSelection] = useState(null); + + useOnSelectionChange({ + onChange: (flow) => {setLastSelection(flow);}, + }) + + useEffect(() => { + if(copied === true && lastSelection){ + setLastCopiedSelection(lastSelection); + } + }, [copied]) + + useEffect(() => { + if(pasted === true && lastCopiedSelection){ + let maximumHeight = 0; + let minimumHeight = Infinity; + let idsMap = {}; + lastCopiedSelection.nodes.forEach((n) => { + if(n.height + n.position.y > maximumHeight){ + maximumHeight = n.height + n.position.y; + } + if(n.position.y < minimumHeight){ + minimumHeight = n.position.y; + } + }); + let heightDifference = maximumHeight - minimumHeight + 30; + + lastCopiedSelection.nodes.forEach((n) => { + + // Generate a unique node ID + let newId = getId(); + idsMap[n.id] = newId; + + // Create a new node object + const newNode: NodeType = { + id: newId, + type: "genericNode", + position: { + x: n.position.x, + y: n.position.y + heightDifference, + }, + data: { + ...n.data, + id: newId, + }, + }; + + // Add the new node to the list of nodes in state + setNodes((nds) => nds.map((e) => ({...e, selected: false})).concat({...newNode, selected: false})); + }) + + lastCopiedSelection.edges.forEach((e) => { + let source = idsMap[e.source]; + let target = idsMap[e.target]; + let sourceHandleSplitted = e.sourceHandle.split('|'); + let sourceHandle = sourceHandleSplitted[0] + '|' + source + '|' + sourceHandleSplitted.slice(2).join('|'); + let targetHandleSplitted = e.targetHandle.split('|'); + let targetHandle = targetHandleSplitted.slice(0, -1).join('|') + '|' + target; + let id = "reactflow__edge-" + source + sourceHandle + "-" + target + targetHandle; + setEdges((eds) => + addEdge({ source, target, sourceHandle, targetHandle, id, className: "animate-pulse", selected: false }, eds.map((e) => ({...e, selected: false}))) + ); + }) + } + }, [pasted]) + + const { setExtraComponent, setExtraNavigation } = useContext(locationContext); const { setErrorData } = useContext(alertContext); const [nodes, setNodes, onNodesChange] = useNodesState( @@ -47,6 +119,10 @@ export default function FlowPage({ flow }:{flow:FlowType}) { const { setViewport } = useReactFlow(); const edgeUpdateSuccessful = useRef(true); + function getId() { + return `dndnode_` + incrementNodeId(); + } + useEffect(() => { if (reactFlowInstance && flow) { flow.data = reactFlowInstance.toObject(); @@ -101,11 +177,6 @@ export default function FlowPage({ flow }:{flow:FlowType}) { (event: React.DragEvent) => { event.preventDefault(); - // Helper function to generate a unique node ID - function getId() { - return `dndnode_` + incrementNodeId(); - } - // Get the current bounds of the ReactFlow wrapper element const reactflowBounds = reactFlowWrapper.current.getBoundingClientRect(); From 2122c0d92edcc8d1b14c78014b1289e35dd80a6c Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 5 May 2023 11:42:08 -0300 Subject: [PATCH 2/5] Fixed position where the nodes are pasted --- src/frontend/src/pages/FlowPage/index.tsx | 43 +++++++++++++++++------ 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx index b15a99aab..ef1c44660 100644 --- a/src/frontend/src/pages/FlowPage/index.tsx +++ b/src/frontend/src/pages/FlowPage/index.tsx @@ -40,13 +40,31 @@ export default function FlowPage({ flow }:{flow:FlowType}) { const copied = useKeyPress(['Meta+c', 'Strg+c']) const pasted = useKeyPress(['Meta+v', 'Strg+v']) + const undo = useKeyPress(['Meta+z', 'Strg+z']) + const redo = useKeyPress(['Meta+Shift+z', 'Strg+Shift+z']) const [lastSelection, setLastSelection] = useState(null); const [lastCopiedSelection, setLastCopiedSelection] = useState(null); + const [actualActionIndex] = useState(0); + + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const handleMouseMove = (event) => { + setPosition({ x: event.clientX, y: event.clientY }); + }; useOnSelectionChange({ onChange: (flow) => {setLastSelection(flow);}, }) + useEffect(() => { + if(undo) { + + } + else if(redo) { + + } + }, [redo, undo]) + useEffect(() => { if(copied === true && lastSelection){ setLastCopiedSelection(lastSelection); @@ -55,18 +73,23 @@ export default function FlowPage({ flow }:{flow:FlowType}) { useEffect(() => { if(pasted === true && lastCopiedSelection){ - let maximumHeight = 0; - let minimumHeight = Infinity; + let minimumX = Infinity; + let minimumY = Infinity; let idsMap = {}; lastCopiedSelection.nodes.forEach((n) => { - if(n.height + n.position.y > maximumHeight){ - maximumHeight = n.height + n.position.y; + if(n.position.y < minimumY){ + minimumY = n.position.y } - if(n.position.y < minimumHeight){ - minimumHeight = n.position.y; + if(n.position.x < minimumX){ + minimumX = n.position.x; } }); - let heightDifference = maximumHeight - minimumHeight + 30; + + const bounds = reactFlowWrapper.current.getBoundingClientRect(); + const insidePosition = reactFlowInstance.project({ + x: position.x - bounds.left, + y: position.y - bounds.top + }); lastCopiedSelection.nodes.forEach((n) => { @@ -79,8 +102,8 @@ export default function FlowPage({ flow }:{flow:FlowType}) { id: newId, type: "genericNode", position: { - x: n.position.x, - y: n.position.y + heightDifference, + x: insidePosition.x + n.position.x - minimumX, + y: insidePosition.y + n.position.y - minimumY, }, data: { ...n.data, @@ -257,7 +280,7 @@ export default function FlowPage({ flow }:{flow:FlowType}) { }, []); return ( -
+
{Object.keys(templates).length > 0 && Object.keys(types).length > 0 ? ( <> Date: Fri, 5 May 2023 12:18:47 -0300 Subject: [PATCH 3/5] Undo and redo working --- .../src/pages/FlowPage/hooks/useUndoRedo.ts | 105 ++++++++++++++++++ src/frontend/src/pages/FlowPage/index.tsx | 67 ++++++++--- 2 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts diff --git a/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts b/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts new file mode 100644 index 000000000..5108e1767 --- /dev/null +++ b/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Edge, Node, useReactFlow } from 'reactflow'; + +type UseUndoRedoOptions = { + maxHistorySize: number; + enableShortcuts: boolean; +}; + +type UseUndoRedo = (options?: UseUndoRedoOptions) => { + undo: () => void; + redo: () => void; + takeSnapshot: () => void; + canUndo: boolean; + canRedo: boolean; +}; + +type HistoryItem = { + nodes: Node[]; + edges: Edge[]; +}; + +const defaultOptions: UseUndoRedoOptions = { + maxHistorySize: 100, + enableShortcuts: true, +}; + +// https://redux.js.org/usage/implementing-undo-history +export const useUndoRedo: UseUndoRedo = ({ + maxHistorySize = defaultOptions.maxHistorySize, + enableShortcuts = defaultOptions.enableShortcuts, +} = defaultOptions) => { + // the past and future arrays store the states that we can jump to + const [past, setPast] = useState([]); + const [future, setFuture] = useState([]); + + const { setNodes, setEdges, getNodes, getEdges } = useReactFlow(); + + const takeSnapshot = useCallback(() => { + // push the current graph to the past state + setPast((past) => [ + ...past.slice(past.length - maxHistorySize + 1, past.length), + { nodes: getNodes(), edges: getEdges() }, + ]); + + // whenever we take a new snapshot, the redo operations need to be cleared to avoid state mismatches + setFuture([]); + }, [getNodes, getEdges, maxHistorySize]); + + const undo = useCallback(() => { + // get the last state that we want to go back to + const pastState = past[past.length - 1]; + + if (pastState) { + // first we remove the state from the history + setPast((past) => past.slice(0, past.length - 1)); + // we store the current graph for the redo operation + setFuture((future) => [...future, { nodes: getNodes(), edges: getEdges() }]); + // now we can set the graph to the past state + setNodes(pastState.nodes); + setEdges(pastState.edges); + } + }, [setNodes, setEdges, getNodes, getEdges, past]); + + const redo = useCallback(() => { + const futureState = future[future.length - 1]; + + if (futureState) { + setFuture((future) => future.slice(0, future.length - 1)); + setPast((past) => [...past, { nodes: getNodes(), edges: getEdges() }]); + setNodes(futureState.nodes); + setEdges(futureState.edges); + } + }, [setNodes, setEdges, getNodes, getEdges, future]); + + useEffect(() => { + // this effect is used to attach the global event handlers + if (!enableShortcuts) { + return; + } + + const keyDownHandler = (event: KeyboardEvent) => { + if (event.key === 'z' && (event.ctrlKey || event.metaKey) && event.shiftKey) { + redo(); + } else if (event.key === 'z' && (event.ctrlKey || event.metaKey)) { + undo(); + } + }; + + document.addEventListener('keydown', keyDownHandler); + + return () => { + document.removeEventListener('keydown', keyDownHandler); + }; + }, [undo, redo, enableShortcuts]); + + return { + undo, + redo, + takeSnapshot, + canUndo: !past.length, + canRedo: !future.length, + }; +}; + +export default useUndoRedo; diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx index ef1c44660..aead42fea 100644 --- a/src/frontend/src/pages/FlowPage/index.tsx +++ b/src/frontend/src/pages/FlowPage/index.tsx @@ -12,6 +12,10 @@ import ReactFlow, { Edge, useKeyPress, useOnSelectionChange, + NodeDragHandler, + OnEdgesDelete, + OnNodesDelete, + SelectionDragHandler, } from "reactflow"; import { locationContext } from "../../contexts/locationContext"; import ExtraSidebar from "./components/extraSidebarComponent"; @@ -24,6 +28,7 @@ import ConnectionLineComponent from "./components/ConnectionLineComponent"; import { FlowType, NodeType } from "../../types/flow"; import { APIClassType } from "../../types/api"; import { isValidConnection } from "../../utils"; +import useUndoRedo from "./hooks/useUndoRedo"; const nodeTypes = { genericNode: GenericNode, @@ -38,13 +43,25 @@ export default function FlowPage({ flow }:{flow:FlowType}) { useContext(typesContext); const reactFlowWrapper = useRef(null); - const copied = useKeyPress(['Meta+c', 'Strg+c']) - const pasted = useKeyPress(['Meta+v', 'Strg+v']) - const undo = useKeyPress(['Meta+z', 'Strg+z']) - const redo = useKeyPress(['Meta+Shift+z', 'Strg+Shift+z']) + const { undo, redo, canUndo, canRedo, takeSnapshot } = useUndoRedo(); + + const copied = useKeyPress(['Meta+c', 'Strg+c']); + const pasted = useKeyPress(['Meta+v', 'Strg+v']); + const undoed = useKeyPress(['Meta+z', 'Strg+z']); + const redoed = useKeyPress(['Meta+Shift+z', 'Strg+Shift+z']); + + useEffect(() => { + if(canUndo && undoed){ + undo(); + } + if(canRedo && redoed){ + redo(); + } + + }, [undoed, redoed]) + const [lastSelection, setLastSelection] = useState(null); const [lastCopiedSelection, setLastCopiedSelection] = useState(null); - const [actualActionIndex] = useState(0); const [position, setPosition] = useState({ x: 0, y: 0 }); @@ -56,15 +73,6 @@ export default function FlowPage({ flow }:{flow:FlowType}) { onChange: (flow) => {setLastSelection(flow);}, }) - useEffect(() => { - if(undo) { - - } - else if(redo) { - - } - }, [redo, undo]) - useEffect(() => { if(copied === true && lastSelection){ setLastCopiedSelection(lastSelection); @@ -180,6 +188,7 @@ export default function FlowPage({ flow }:{flow:FlowType}) { const onConnect = useCallback( (params: Connection) => { + takeSnapshot(); setEdges((eds) => addEdge({ ...params, className: "animate-pulse" }, eds) ); @@ -188,9 +197,26 @@ export default function FlowPage({ flow }:{flow:FlowType}) { return newX; }); }, - [setEdges, setNodes] + [setEdges, setNodes, takeSnapshot] ); + const onNodeDragStart: NodeDragHandler = useCallback(() => { + // 👇 make dragging a node undoable + takeSnapshot(); + // 👉 you can place your event handlers here + }, [takeSnapshot]); + + const onSelectionDragStart: SelectionDragHandler = useCallback(() => { + // 👇 make dragging a selection undoable + takeSnapshot(); + }, [takeSnapshot]); + + + const onEdgesDelete: OnEdgesDelete = useCallback(() => { + // 👇 make deleting edges undoable + takeSnapshot(); + }, [takeSnapshot]); + const onDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; @@ -199,6 +225,7 @@ export default function FlowPage({ flow }:{flow:FlowType}) { const onDrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); + takeSnapshot(); // Get the current bounds of the ReactFlow wrapper element const reactflowBounds = reactFlowWrapper.current.getBoundingClientRect(); @@ -246,16 +273,17 @@ export default function FlowPage({ flow }:{flow:FlowType}) { } }, // Specify dependencies for useCallback - [incrementNodeId, reactFlowInstance, setErrorData, setNodes] + [incrementNodeId, reactFlowInstance, setErrorData, setNodes, takeSnapshot] ); - const onDelete = (mynodes) => { + const onDelete = useCallback((mynodes) => { + takeSnapshot(); setEdges( edges.filter( (ns) => !mynodes.some((n) => ns.source === n.id || ns.target === n.id) ) ); - }; + }, [takeSnapshot, edges, setEdges]); const onEdgeUpdateStart = useCallback(() => { edgeUpdateSuccessful.current = false; @@ -298,6 +326,9 @@ export default function FlowPage({ flow }:{flow:FlowType}) { onEdgeUpdate={onEdgeUpdate} onEdgeUpdateStart={onEdgeUpdateStart} onEdgeUpdateEnd={onEdgeUpdateEnd} + onNodeDragStart={onNodeDragStart} + onSelectionDragStart={onSelectionDragStart} + onEdgesDelete={onEdgesDelete} connectionLineComponent={ConnectionLineComponent} onDragOver={onDragOver} onDrop={onDrop} From 247310360d6eadc4410b117569d6fb94af90ceae Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Sat, 6 May 2023 18:45:51 -0300 Subject: [PATCH 4/5] Fixed shortcuts not working on Linux --- src/frontend/src/pages/FlowPage/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx index aead42fea..641e652fa 100644 --- a/src/frontend/src/pages/FlowPage/index.tsx +++ b/src/frontend/src/pages/FlowPage/index.tsx @@ -45,10 +45,10 @@ export default function FlowPage({ flow }:{flow:FlowType}) { const { undo, redo, canUndo, canRedo, takeSnapshot } = useUndoRedo(); - const copied = useKeyPress(['Meta+c', 'Strg+c']); - const pasted = useKeyPress(['Meta+v', 'Strg+v']); - const undoed = useKeyPress(['Meta+z', 'Strg+z']); - const redoed = useKeyPress(['Meta+Shift+z', 'Strg+Shift+z']); + const copied = useKeyPress(['Meta+c', 'Strg+c', 'Ctrl+c']); + const pasted = useKeyPress(['Meta+v', 'Strg+v', 'Ctrl+v']); + const undoed = useKeyPress(['Meta+z', 'Strg+z', 'Ctrl+z']); + const redoed = useKeyPress(['Meta+Shift+z', 'Strg+Shift+z', 'Ctrl+Shift+z']); useEffect(() => { if(canUndo && undoed){ From ab1bbf8d90f51f8a5e6229ed7a266c42e11d3f60 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Sat, 6 May 2023 20:18:46 -0300 Subject: [PATCH 5/5] Undo and redo shortcuts working properly now --- .../src/pages/FlowPage/hooks/useUndoRedo.ts | 8 +++-- src/frontend/src/pages/FlowPage/index.tsx | 35 +++++++------------ 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts b/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts index 5108e1767..72dc6699c 100644 --- a/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts +++ b/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts @@ -81,6 +81,10 @@ export const useUndoRedo: UseUndoRedo = ({ const keyDownHandler = (event: KeyboardEvent) => { if (event.key === 'z' && (event.ctrlKey || event.metaKey) && event.shiftKey) { redo(); + } + else if (event.key === 'y' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); // prevent the default action + redo(); } else if (event.key === 'z' && (event.ctrlKey || event.metaKey)) { undo(); } @@ -97,8 +101,8 @@ export const useUndoRedo: UseUndoRedo = ({ undo, redo, takeSnapshot, - canUndo: !past.length, - canRedo: !future.length, + canUndo: !!past.length, + canRedo: !!future.length, }; }; diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx index 641e652fa..fdb226629 100644 --- a/src/frontend/src/pages/FlowPage/index.tsx +++ b/src/frontend/src/pages/FlowPage/index.tsx @@ -45,20 +45,16 @@ export default function FlowPage({ flow }:{flow:FlowType}) { const { undo, redo, canUndo, canRedo, takeSnapshot } = useUndoRedo(); - const copied = useKeyPress(['Meta+c', 'Strg+c', 'Ctrl+c']); - const pasted = useKeyPress(['Meta+v', 'Strg+v', 'Ctrl+v']); - const undoed = useKeyPress(['Meta+z', 'Strg+z', 'Ctrl+z']); - const redoed = useKeyPress(['Meta+Shift+z', 'Strg+Shift+z', 'Ctrl+Shift+z']); - - useEffect(() => { - if(canUndo && undoed){ - undo(); + const onKeyDown = (event : React.KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && (event.key === 'c') && lastSelection){ + event.preventDefault(); + setLastCopiedSelection(lastSelection); } - if(canRedo && redoed){ - redo(); + if ((event.ctrlKey || event.metaKey) && (event.key === 'v') && lastCopiedSelection){ + event.preventDefault(); + paste(); } - - }, [undoed, redoed]) + } const [lastSelection, setLastSelection] = useState(null); const [lastCopiedSelection, setLastCopiedSelection] = useState(null); @@ -73,15 +69,8 @@ export default function FlowPage({ flow }:{flow:FlowType}) { onChange: (flow) => {setLastSelection(flow);}, }) - useEffect(() => { - if(copied === true && lastSelection){ - setLastCopiedSelection(lastSelection); - } - }, [copied]) - - useEffect(() => { - if(pasted === true && lastCopiedSelection){ - let minimumX = Infinity; + let paste = () => { + let minimumX = Infinity; let minimumY = Infinity; let idsMap = {}; lastCopiedSelection.nodes.forEach((n) => { @@ -135,8 +124,7 @@ export default function FlowPage({ flow }:{flow:FlowType}) { addEdge({ source, target, sourceHandle, targetHandle, id, className: "animate-pulse", selected: false }, eds.map((e) => ({...e, selected: false}))) ); }) - } - }, [pasted]) + } const { setExtraComponent, setExtraNavigation } = useContext(locationContext); @@ -319,6 +307,7 @@ export default function FlowPage({ flow }:{flow:FlowType}) { edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChangeMod} + onKeyDown={(e) => onKeyDown(e)} onConnect={onConnect} onLoad={setReactFlowInstance} onInit={setReactFlowInstance}