added useUndoRedo to context and added Undo and Redo buttons to header

This commit is contained in:
Lucas Oliveira 2023-06-13 11:41:03 -03:00
commit 1452e9c7a2
7 changed files with 245 additions and 199 deletions

View file

@ -40,8 +40,6 @@ export default function App() {
setSuccessOpen,
} = useContext(alertContext);
const { flows, addFlow } = useContext(TabsContext);
// Initialize state variable for the list of alerts
const [alertsList, setAlertsList] = useState<
Array<{

View file

@ -30,11 +30,13 @@ import ApiModal from "../../../../modals/ApiModal";
import { alertContext } from "../../../../contexts/alertContext";
import { updateFlowInDatabase } from "../../../../controllers/API";
import { Link } from "react-router-dom";
import { undoRedoContext } from "../../../../contexts/undoRedoContext";
export const MenuBar = ({ setRename, rename, flows, tabId }) => {
const { updateFlow, setTabId, addFlow } = useContext(TabsContext);
const { setErrorData } = useContext(alertContext);
const { openPopUp } = useContext(PopUpContext);
const { undo, redo } = useContext(undoRedoContext);
function handleSaveFlow(flow) {
try {
@ -121,6 +123,22 @@ export const MenuBar = ({ setRename, rename, flows, tabId }) => {
<Edit className="w-4 h-4 mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
undo();
}}
>
<Edit className="w-4 h-4 mr-2" />
Undo
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
redo();
}}
>
<Edit className="w-4 h-4 mr-2" />
Redo
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Flows</DropdownMenuLabel>
<DropdownMenuRadioGroup

View file

@ -6,6 +6,7 @@ import PopUpProvider from "./popUpContext";
import { TabsProvider } from "./tabsContext";
import { TypesProvider } from "./typesContext";
import { ReactFlowProvider } from "reactflow";
import { UndoRedoProvider } from "./undoRedoContext";
export default function ContextWrapper({ children }: { children: ReactNode }) {
//element to wrap all context
@ -13,15 +14,17 @@ export default function ContextWrapper({ children }: { children: ReactNode }) {
<>
<ReactFlowProvider>
<DarkProvider>
<TypesProvider>
<LocationProvider>
<AlertProvider>
<TabsProvider>
<PopUpProvider>{children}</PopUpProvider>
</TabsProvider>
</AlertProvider>
</LocationProvider>
</TypesProvider>
<TypesProvider>
<LocationProvider>
<AlertProvider>
<TabsProvider>
<UndoRedoProvider>
<PopUpProvider>{children}</PopUpProvider>
</UndoRedoProvider>
</TabsProvider>
</AlertProvider>
</LocationProvider>
</TypesProvider>
</DarkProvider>
</ReactFlowProvider>
</>

View file

@ -0,0 +1,194 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { Edge, Node, useReactFlow } from "reactflow";
import { cloneDeep } from "lodash";
import { TabsContext } from "./tabsContext";
type undoRedoContextType = {
undo: () => void;
redo: () => void;
takeSnapshot: () => void;
};
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 initialValue = {
undo: () => {},
redo: () => {},
takeSnapshot: () => {},
};
const defaultOptions: UseUndoRedoOptions = {
maxHistorySize: 100,
enableShortcuts: true,
};
export const undoRedoContext = createContext<undoRedoContextType>(initialValue);
export function UndoRedoProvider({ children }) {
const { tabId, flows } = useContext(TabsContext);
const [past, setPast] = useState<HistoryItem[][]>(flows.map(() => []));
const [future, setFuture] = useState<HistoryItem[][]>(flows.map(() => []));
const [tabIndex, setTabIndex] = useState(flows.findIndex((f) => f.id === tabId));
useEffect(() => {
// whenever the flows variable changes, we need to add one array to the past and future states
setPast((old) => flows.map((f, i) => (old[i] ? old[i] : [])));
setFuture((old) => flows.map((f, i) => (old[i] ? old[i] : [])));
setTabIndex(flows.findIndex((f) => f.id === tabId));
}, [flows, tabId]);
const { setNodes, setEdges, getNodes, getEdges } = useReactFlow();
const takeSnapshot = useCallback(() => {
// push the current graph to the past state
console.log(past);
console.log(tabIndex);
setPast((old) => {
let newPast = cloneDeep(old);
newPast[tabIndex] = old[tabIndex].slice(
old[tabIndex].length - defaultOptions.maxHistorySize + 1,
old[tabIndex].length
);
newPast[tabIndex].push({ nodes: getNodes(), edges: getEdges() });
return newPast;
});
// whenever we take a new snapshot, the redo operations need to be cleared to avoid state mismatches
setFuture((old) => {
let newFuture = cloneDeep(old);
newFuture[tabIndex] = [];
return newFuture;
});
}, [
getNodes,
getEdges,
past,
future,
flows,
tabId,
setPast,
setFuture,
]);
const undo = useCallback(() => {
// get the last state that we want to go back to
const pastState = past[tabIndex][past[tabIndex].length - 1];
if (pastState) {
// first we remove the state from the history
setPast((old) => {
let newPast = cloneDeep(old);
newPast[tabIndex] = old[tabIndex].slice(0, old[tabIndex].length - 1);
return newPast;
});
// we store the current graph for the redo operation
setFuture((old) => {
let newFuture = cloneDeep(old);
newFuture[tabIndex] = old[tabIndex];
newFuture[tabIndex].push({ nodes: getNodes(), edges: getEdges() });
return newFuture;
});
// now we can set the graph to the past state
setNodes(pastState.nodes);
setEdges(pastState.edges);
}
}, [
setNodes,
setEdges,
getNodes,
getEdges,
future,
past,
setFuture,
setPast,
tabIndex,
]);
const redo = useCallback(() => {
const futureState = future[tabIndex][future[tabIndex].length - 1];
if (futureState) {
setFuture((old) => {
let newFuture = cloneDeep(old);
newFuture[tabIndex] = old[tabIndex].slice(0, old[tabIndex].length - 1);
return newFuture;
});
setPast((old) => {
let newPast = cloneDeep(old);
newPast[tabIndex] = old[tabIndex];
newPast[tabIndex].push({ nodes: getNodes(), edges: getEdges() });
return newPast;
});
setNodes(futureState.nodes);
setEdges(futureState.edges);
}
}, [
future,
past,
setFuture,
setPast,
setNodes,
setEdges,
getNodes,
getEdges,
future,
tabIndex,
]);
useEffect(() => {
// this effect is used to attach the global event handlers
if (!defaultOptions.enableShortcuts) {
return;
}
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();
}
};
document.addEventListener("keydown", keyDownHandler);
return () => {
document.removeEventListener("keydown", keyDownHandler);
};
}, [undo, redo]);
return (
<undoRedoContext.Provider
value={{
undo,
redo,
takeSnapshot,
}}
>
{children}
</undoRedoContext.Provider>
);
}

View file

@ -25,9 +25,9 @@ import { typesContext } from "../../../../contexts/typesContext";
import { APIClassType } from "../../../../types/api";
import { FlowType, NodeType } from "../../../../types/flow";
import { isValidConnection } from "../../../../utils";
import useUndoRedo from "../../hooks/useUndoRedo";
import ConnectionLineComponent from "../ConnectionLineComponent";
import ExtraSidebar from "../extraSidebarComponent";
import { undoRedoContext } from "../../../../contexts/undoRedoContext";
const nodeTypes = {
genericNode: GenericNode,
@ -47,7 +47,7 @@ export default function Page({ flow }: { flow: FlowType }) {
useContext(typesContext);
const reactFlowWrapper = useRef(null);
const { takeSnapshot } = useUndoRedo();
const { takeSnapshot } = useContext(undoRedoContext);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [lastSelection, setLastSelection] =

View file

@ -12,9 +12,16 @@ import { APIClassType, APIObjectType } from "../../../../types/api";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import ShadTooltip from "../../../../components/ShadTooltipComponent";
import { Code, Code2, FileDown, FileUp, Import, Save } from "lucide-react";
import { PopUpContext } from "../../../../contexts/popUpContext";
import ImportModal from "../../../../modals/importModal";
import ExportModal from "../../../../modals/exportModal";
import ApiModal from "../../../../modals/ApiModal";
import { TabsContext } from "../../../../contexts/tabsContext";
export default function ExtraSidebar() {
const { data } = useContext(typesContext);
const { openPopUp } = useContext(PopUpContext);
const { flows, tabId } = useContext(TabsContext);
const [dataFilter, setFilterData] = useState(data);
const [search, setSearch] = useState("");
@ -49,6 +56,10 @@ export default function ExtraSidebar() {
});
}
function handleSaveFlow(current_flow: any) {
throw new Error("Function not implemented.");
}
return (
<div className="flex flex-col overflow-auto scrollbar-hide h-full border-r">
<div className="mt-2 w-full flex gap-2 justify-between px-2 items-center">
@ -56,9 +67,10 @@ export default function ExtraSidebar() {
<button
className="hover:dark:hover:bg-[#242f47] text-gray-700 w-full justify-center shadow-sm transition-all duration-500 ease-in-out dark:bg-gray-800 dark:text-gray-300 relative inline-flex items-center rounded-md bg-white px-2 py-2 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
onClick={() => {
openPopUp(<ImportModal />);
}}
>
<FileDown className="w-5 h-5 dark:text-gray-300"></FileDown>
<FileUp className="w-5 h-5 dark:text-gray-300"></FileUp>
</button>
</ShadTooltip>
@ -67,10 +79,10 @@ export default function ExtraSidebar() {
className={classNames("hover:dark:hover:bg-[#242f47] text-gray-700 w-full justify-center shadow-sm transition-all duration-500 ease-in-out dark:bg-gray-800 dark:text-gray-300 relative inline-flex items-center bg-white px-2 py-2 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 rounded-md"
)}
onClick={(event) => {
openPopUp(<ExportModal />);
}}
>
<FileUp className="w-5 h-5 dark:text-gray-300"></FileUp>
<FileDown className="w-5 h-5 dark:text-gray-300"></FileDown>
</button>
</ShadTooltip>
<ShadTooltip delayDuration={1000} content="Code" side="top">
@ -78,7 +90,7 @@ export default function ExtraSidebar() {
className={classNames("hover:dark:hover:bg-[#242f47] text-gray-700 w-full justify-center shadow-sm transition-all duration-500 ease-in-out dark:bg-gray-800 dark:text-gray-300 relative inline-flex items-center bg-white px-2 py-2 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 rounded-md"
)}
onClick={(event) => {
openPopUp(<ApiModal flow={flows.find((f) => f.id === tabId)} />);
}}
>
<Code2 className="w-5 h-5 dark:text-gray-300"></Code2>
@ -88,7 +100,9 @@ export default function ExtraSidebar() {
<ShadTooltip delayDuration={1000} content="Save" side="top">
<button
className="hover:dark:hover:bg-[#242f47] text-gray-700 w-full justify-center transition-all shadow-sm duration-500 ease-in-out dark:bg-gray-800 dark:text-gray-300 relative inline-flex items-center bg-white px-2 py-2 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 rounded-md"
onClick={(event) => {
handleSaveFlow(flows.find((f) => f.id === tabId));
}}
>
<Save className="w-5 h-5 dark:text-gray-300"></Save>
</button>

View file

@ -1,181 +0,0 @@
import { useCallback, useContext, useEffect, useState } from "react";
import { Edge, Node, useReactFlow } from "reactflow";
import { TabsContext } from "../../../contexts/tabsContext";
import { cloneDeep } from "lodash";
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 { tabId, flows } = useContext(TabsContext);
const [past, setPast] = useState<HistoryItem[][]>(flows.map(() => []));
const [future, setFuture] = useState<HistoryItem[][]>(flows.map(() => []));
useEffect(() => {
// whenever the flows variable changes, we need to add one array to the past and future states
setPast((old) => flows.map((f, i) => (old[i] ? old[i] : [])));
setFuture((old) => flows.map((f, i) => (old[i] ? old[i] : [])));
}, [flows]);
const { setNodes, setEdges, getNodes, getEdges } = useReactFlow();
const takeSnapshot = useCallback(() => {
// push the current graph to the past state
setPast((old) => {
let newPast = cloneDeep(old);
newPast[tabIndex] = old[tabIndex].slice(
old[tabIndex].length - maxHistorySize + 1,
old[tabIndex].length
);
newPast[tabIndex].push({ nodes: getNodes(), edges: getEdges() });
return newPast;
});
// whenever we take a new snapshot, the redo operations need to be cleared to avoid state mismatches
setFuture((old) => {
let newFuture = cloneDeep(old);
newFuture[tabIndex] = [];
return newFuture;
});
}, [
getNodes,
getEdges,
past,
future,
tabId,
setPast,
setFuture,
maxHistorySize,
]);
const tabIndex = flows.findIndex((f) => f.id === tabId);
const undo = useCallback(() => {
// get the last state that we want to go back to
const pastState = past[tabIndex][past[tabIndex].length - 1];
if (pastState) {
// first we remove the state from the history
setPast((old) => {
let newPast = cloneDeep(old);
newPast[tabIndex] = old[tabIndex].slice(0, old[tabIndex].length - 1);
return newPast;
});
// we store the current graph for the redo operation
setFuture((old) => {
let newFuture = cloneDeep(old);
newFuture[tabIndex] = old[tabIndex];
newFuture[tabIndex].push({ nodes: getNodes(), edges: getEdges() });
return newFuture;
});
// now we can set the graph to the past state
setNodes(pastState.nodes);
setEdges(pastState.edges);
}
}, [
setNodes,
setEdges,
getNodes,
getEdges,
future,
past,
setFuture,
setPast,
tabIndex,
]);
const redo = useCallback(() => {
const futureState = future[tabIndex][future[tabIndex].length - 1];
if (futureState) {
setFuture((old) => {
let newFuture = cloneDeep(old);
newFuture[tabIndex] = old[tabIndex].slice(0, old[tabIndex].length - 1);
return newFuture;
});
setPast((old) => {
let newPast = cloneDeep(old);
newPast[tabIndex] = old[tabIndex];
newPast[tabIndex].push({ nodes: getNodes(), edges: getEdges() });
return newPast;
});
setNodes(futureState.nodes);
setEdges(futureState.edges);
}
}, [
future,
past,
setFuture,
setPast,
setNodes,
setEdges,
getNodes,
getEdges,
future,
tabIndex,
]);
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 === "y" && (event.ctrlKey || event.metaKey)) {
event.preventDefault(); // prevent the default action
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;