diff --git a/src/components/CustomNode/TextNode.tsx b/src/components/CustomNode/TextNode.tsx index 3977ddc38db..a25bc592522 100644 --- a/src/components/CustomNode/TextNode.tsx +++ b/src/components/CustomNode/TextNode.tsx @@ -1,8 +1,28 @@ import React from "react"; +import { BiChevronLeft, BiChevronRight } from "react-icons/bi"; import { ConditionalWrapper, CustomNodeProps } from "src/components/CustomNode"; import useConfig from "src/hooks/store/useConfig"; +import styled from "styled-components"; import * as Styled from "./styles"; +const StyledExpand = styled.button` + pointer-events: all; + position: absolute; + display: flex; + align-items: center; + justify-content: center; + top: 0; + right: 0; + padding: 0; + color: ${({ theme }) => theme.TEXT_NORMAL}; + background: rgba(0, 0, 0, 0.1); + min-height: 0; + height: 100%; + width: 40px; + border-radius: 0; + border-left: 1px solid ${({ theme }) => theme.BACKGROUND_MODIFIER_ACCENT}; +`; + const TextNode: React.FC> = ({ width, height, @@ -12,21 +32,43 @@ const TextNode: React.FC> = ({ y, }) => { const performanceMode = useConfig((state) => state.performanceMode); + const expand = useConfig((state) => state.expand); + const [isExpanded, setIsExpanded] = React.useState(!expand); + + React.useEffect(() => { + setIsExpanded(expand); + }, [expand]); + + const handleExpand = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; return ( - + - {JSON.stringify(value).replaceAll('"', "")} + + {JSON.stringify(value).replaceAll('"', "")} + + {isParent && ( + + {isExpanded ? ( + + ) : ( + + )} + + )} ); }; diff --git a/src/components/CustomNode/styles.tsx b/src/components/CustomNode/styles.tsx index ef4aa221d8f..e7a5aa5b490 100644 --- a/src/components/CustomNode/styles.tsx +++ b/src/components/CustomNode/styles.tsx @@ -23,7 +23,11 @@ export const StyledTextWrapper = styled.div` cursor: pointer; `; -export const StyledText = styled.pre<{ width: number; height: number }>` +export const StyledText = styled.pre<{ + width: number; + height: number; + parent: boolean; +}>` display: flex; justify-content: center; flex-direction: column; @@ -31,6 +35,7 @@ export const StyledText = styled.pre<{ width: number; height: number }>` height: ${({ height }) => height}; min-height: 50; color: ${({ theme }) => theme.TEXT_NORMAL}; + padding-right: ${({ parent }) => parent && "20px"}; `; export const StyledForeignObject = styled.foreignObject` diff --git a/src/components/Graph/index.tsx b/src/components/Graph/index.tsx index 73f9c034859..361fd054499 100644 --- a/src/components/Graph/index.tsx +++ b/src/components/Graph/index.tsx @@ -4,18 +4,17 @@ import { TransformComponent, TransformWrapper, } from "react-zoom-pan-pinch"; -import { Canvas, EdgeData, ElkRoot, NodeData, NodeProps } from "reaflow"; +import { Canvas, Edge, ElkRoot, useSelection } from "reaflow"; import { CustomNode } from "src/components/CustomNode"; import { NodeModal } from "src/containers/Modals/NodeModal"; import { getEdgeNodes } from "src/containers/Editor/LiveEditor/helpers"; import useConfig from "src/hooks/store/useConfig"; import styled from "styled-components"; import shallow from "zustand/shallow"; +import useNodeTools from "src/hooks/store/useNodeTools"; interface LayoutProps { isWidget: boolean; - openModal: () => void; - setSelectedNode: (node: object) => void; } const StyledEditorWrapper = styled.div<{ isWidget: boolean }>` @@ -33,14 +32,21 @@ const StyledEditorWrapper = styled.div<{ isWidget: boolean }>` } `; -const MemoizedGraph = React.memo(function Layout({ - isWidget, - openModal, - setSelectedNode, -}: LayoutProps) { +const MemoizedGraph = React.memo(function Layout({ isWidget }: LayoutProps) { const json = useConfig((state) => state.json); - const [nodes, setNodes] = React.useState([]); - const [edges, setEdges] = React.useState([]); + + const [nodes, edges, newNodes, newEdges, selectedNode] = useNodeTools( + (state) => [ + state.nodes, + state.edges, + state.newNodes, + state.newEdges, + state.selectedNode, + ], + shallow + ); + const setNodeTools = useNodeTools((state) => state.setNodeTools); + const [size, setSize] = React.useState({ width: 2000, height: 2000, @@ -55,38 +61,34 @@ const MemoizedGraph = React.memo(function Layout({ React.useEffect(() => { const { nodes, edges } = getEdgeNodes(json, expand); - setNodes(nodes); - setEdges(edges); - }, [json, expand]); + setNodeTools("nodes", nodes); + setNodeTools("edges", edges); + setNodeTools("newNodes", nodes); + setNodeTools("newEdges", edges); + }, [json, expand, setNodeTools]); const onInit = (ref: ReactZoomPanPinchRef) => { setConfig("zoomPanPinch", ref); }; - const onCanvasClick = () => { - const input = document.querySelector("input:focus") as HTMLInputElement; - if (input) input.blur(); - }; - const onLayoutChange = (layout: ElkRoot) => { if (layout.width && layout.height) setSize({ width: layout.width, height: layout.height }); }; - const handleNodeClick = React.useCallback( - (e: React.MouseEvent, props: NodeProps) => { - setSelectedNode(props.properties.text); - openModal(); + const { selections, onClick, removeSelection } = useSelection({ + nodes, + edges, + onSelection: (s) => { + if (!isWidget) { + if (s[0] === selectedNode) { + removeSelection(selectedNode); + } else { + setNodeTools("selectedNode", s[0]); + } + } }, - [openModal, setSelectedNode] - ); - - const node = React.useCallback( - (props) => ( - handleNodeClick(e, props)} {...props} /> - ), - [handleNodeClick] - ); + }); return ( @@ -98,6 +100,9 @@ const MemoizedGraph = React.memo(function Layout({ wheel={{ step: 0.05, }} + doubleClick={{ + disabled: true, + }} > } + edge={(props) => + newEdges.find((e) => e.id === props.id) ? : <> + } zoomable={false} readonly /> @@ -126,23 +134,20 @@ const MemoizedGraph = React.memo(function Layout({ }); export const Graph = ({ isWidget = false }: { isWidget?: boolean }) => { - const [isModalVisible, setModalVisible] = React.useState(false); - const [selectedNode, setSelectedNode] = React.useState({}); - - const openModal = React.useCallback(() => setModalVisible(true), []); - + const [newNodes, selectedNode, copySelectedNode] = useNodeTools( + (state) => [state.nodes, state.selectedNode, state.copySelectedNode], + shallow + ); + const setNodeTools = useNodeTools((state) => state.setNodeTools); + const selectedNodeObject = newNodes.find((n) => n.id === selectedNode); return ( <> - + {!isWidget && ( setModalVisible(false)} + selectedNode={selectedNodeObject?.text} + visible={copySelectedNode} + closeModal={() => setNodeTools("copySelectedNode", false)} /> )} diff --git a/src/containers/Editor/LiveEditor/helpers.ts b/src/containers/Editor/LiveEditor/helpers.ts index ee93b5944e3..9ea0a52747a 100644 --- a/src/containers/Editor/LiveEditor/helpers.ts +++ b/src/containers/Editor/LiveEditor/helpers.ts @@ -32,7 +32,7 @@ export function getEdgeNodes( data: { isParent: el.parent, }, - width: isExpanded ? 35 + longestLine * (el.parent ? 9 : 8) : 180, + width: isExpanded ? 35 + longestLine * 8 + (el.parent && 60) : 180, height, }); } else { diff --git a/src/containers/Editor/NodeTools.ts b/src/containers/Editor/NodeTools.ts new file mode 100644 index 00000000000..c5adcb082ea --- /dev/null +++ b/src/containers/Editor/NodeTools.ts @@ -0,0 +1,85 @@ +import { NodeData, EdgeData, removeAndUpsertNodes } from "reaflow"; +import { findNodeChildren } from "src/utils/findNodeChildren"; +import toast from "react-hot-toast"; +import { NodeTools } from "src/hooks/store/useNodeTools"; +import { findEdgeChildren } from "src/utils/findEdgeChildren"; + +export const collapseNodes = ( + selectedNode: string, + nodes: NodeData[], + edges: EdgeData[], + collapsedNodes: { [key: string]: NodeData[] }, + collapsedEdges: { [key: string]: EdgeData[] }, + setNodeTools: (key: keyof NodeTools, value: unknown) => void +) => { + if (selectedNode) { + const childrenOfNode = findNodeChildren(selectedNode, nodes, edges); + const childrenOfEdge = findEdgeChildren( + selectedNode, + childrenOfNode, + edges + ); + + const newCollapsedEdges = {}; + newCollapsedEdges[selectedNode] = childrenOfEdge; + setNodeTools("collapsedEdges", { + ...collapsedEdges, + ...newCollapsedEdges, + }); + + const newCollapsedNodes = {}; + newCollapsedNodes[selectedNode] = childrenOfNode; + setNodeTools("collapsedNodes", { + ...collapsedNodes, + ...newCollapsedNodes, + }); + + const resultOfRemovedNodes = removeAndUpsertNodes( + nodes, + edges, + childrenOfNode + ); + + const edgesResult = resultOfRemovedNodes.edges.filter((e) => + e.id.startsWith("e") + ); + setNodeTools("newNodes", resultOfRemovedNodes.nodes); + setNodeTools("newEdges", edgesResult); + setTimeout(() => toast.dismiss("restartToast"), 230); + } else { + toast("Please select a node to collapse!"); + } +}; + +export const expandNodes = ( + selectedNode: string, + nodes: NodeData[], + edges: EdgeData[], + collapsedNodes: { [key: string]: NodeData[] }, + collapsedEdges: { [key: string]: EdgeData[] }, + setNodeTools: (nodeTools: keyof NodeTools, value: unknown) => void +) => { + if (selectedNode) { + const concatEdges = edges.concat(collapsedEdges[selectedNode]); + const concatNodes = nodes.concat(collapsedNodes[selectedNode]); + + setNodeTools("newNodes", concatNodes); + setNodeTools("newEdges", concatEdges); + } else { + toast("Please select a node to expand!"); + } +}; + +export const restartNodes = ( + initialNodes: NodeData[], + initialEdges: EdgeData[], + nodes: NodeData[], + setNodeTools: (nodeTools: keyof NodeTools, value: unknown) => void +) => { + if (nodes !== initialNodes) { + setNodeTools("newNodes", initialNodes); + setNodeTools("newEdges", initialEdges); + } else { + toast("Collapse at last once the node to restart!", { id: "restartToast" }); + } +}; diff --git a/src/containers/Editor/Tools.tsx b/src/containers/Editor/Tools.tsx index 634b2aac916..fbd03623a2d 100644 --- a/src/containers/Editor/Tools.tsx +++ b/src/containers/Editor/Tools.tsx @@ -1,18 +1,26 @@ -import React from "react"; +import React, { useEffect } from "react"; import { AiOutlineFullscreen, AiOutlineMinus, AiOutlinePlus, + AiOutlineNodeCollapse, + AiOutlineNodeExpand, } from "react-icons/ai"; import { FiDownload } from "react-icons/fi"; import { HiOutlineSun, HiOutlineMoon } from "react-icons/hi"; -import { MdCenterFocusWeak } from "react-icons/md"; +import { + MdCenterFocusWeak, + MdOutlineCopyAll, + MdOutlineRestartAlt, +} from "react-icons/md"; import { SearchInput } from "src/components/SearchInput"; import styled from "styled-components"; import useConfig from "src/hooks/store/useConfig"; import shallow from "zustand/shallow"; import { DownloadModal } from "../Modals/DownloadModal"; import useStored from "src/hooks/store/useStored"; +import useNodeTools from "src/hooks/store/useNodeTools"; +import { collapseNodes, expandNodes, restartNodes } from "./NodeTools"; export const StyledTools = styled.div` display: flex; @@ -43,6 +51,8 @@ const StyledToolElement = styled.button` export const Tools: React.FC = () => { const [isDownloadVisible, setDownloadVisible] = React.useState(false); + const [expand, setExpand] = React.useState(true); + const [isLastNode, setIsLastNode] = React.useState(false); const lightmode = useStored((state) => state.lightmode); const setLightTheme = useStored((state) => state.setLightTheme); @@ -53,11 +63,49 @@ export const Tools: React.FC = () => { const setConfig = useConfig((state) => state.setConfig); + const [ + selectedNode, + nodes, + edges, + newNodes, + newEdges, + collapsedNodes, + collapsedEdges, + ] = useNodeTools( + (state) => [ + state.selectedNode, + state.nodes, + state.edges, + state.newNodes, + state.newEdges, + state.collapsedNodes, + state.collapsedEdges, + ], + shallow + ); + + const setNodeTools = useNodeTools((state) => state.setNodeTools); + useEffect(() => { + if (selectedNode) { + const haveChildren = newEdges.find((edge) => edge.from === selectedNode); + if (!collapsedNodes[selectedNode] && !haveChildren) { + setIsLastNode(true); + } else if (haveChildren) { + setExpand(true); + setIsLastNode(false); + } else if (collapsedNodes[selectedNode] && !haveChildren) { + setExpand(false); + setIsLastNode(false); + } + } + }, [collapsedNodes, newEdges, selectedNode]); + const zoomIn = useConfig((state) => state.zoomIn); const zoomOut = useConfig((state) => state.zoomOut); const centerView = useConfig((state) => state.centerView); const toggleEditor = () => setConfig("hideEditor", !hideEditor); const toggleTheme = () => setLightTheme(!lightmode); + const selectedNodeColor = "#00D69D"; return ( @@ -68,7 +116,57 @@ export const Tools: React.FC = () => { {lightmode ? : } {!performanceMode && } - {!performanceMode && ( + {selectedNode && ( + <> + setNodeTools("copySelectedNode", true)} + > + + + restartNodes(nodes, edges, newNodes, setNodeTools)} + > + + + + )} + {isLastNode ? null : expand ? ( + + collapseNodes( + selectedNode, + newNodes, + newEdges, + collapsedNodes, + collapsedEdges, + setNodeTools + ) + } + > + + + ) : ( + + expandNodes( + selectedNode, + newNodes, + newEdges, + collapsedNodes, + collapsedEdges, + setNodeTools + ) + } + > + + + )} + + {!performance && ( setDownloadVisible(true)} diff --git a/src/hooks/store/useNodeTools.tsx b/src/hooks/store/useNodeTools.tsx new file mode 100644 index 00000000000..6f2b9f42a7a --- /dev/null +++ b/src/hooks/store/useNodeTools.tsx @@ -0,0 +1,39 @@ +import create from "zustand"; +import { EdgeData, NodeData } from "reaflow"; + +export interface NodeTools { + selectedNode: string; + copySelectedNode: boolean; + nodes: NodeData[]; + edges: EdgeData[]; + newNodes: NodeData[]; + newEdges: EdgeData[]; + collapsedNodes: { [key: string]: NodeData[] }; + collapsedEdges: { [key: string]: EdgeData[] }; +} + +export interface SettingsNodeTools { + setNodeTools: (key: keyof NodeTools, value: unknown) => void; +} + +const initialStates: NodeTools = { + selectedNode: "", + copySelectedNode: false, + nodes: [], + edges: [], + newNodes: [], + newEdges: [], + collapsedNodes: {}, + collapsedEdges: {}, +}; + +const useNodeTools = create((set) => ({ + ...initialStates, + setNodeTools: (nodeTool: keyof NodeTools, value: unknown) => + set((state) => ({ + ...state, + [nodeTool]: value, + })), +})); + +export default useNodeTools; diff --git a/src/utils/findEdgeChildren.ts b/src/utils/findEdgeChildren.ts new file mode 100644 index 00000000000..9ce78f1ef53 --- /dev/null +++ b/src/utils/findEdgeChildren.ts @@ -0,0 +1,15 @@ +import { NodeData, EdgeData } from "reaflow/dist/types"; + + +export const findEdgeChildren = (selectedNode: string, connections: NodeData[], edges: EdgeData[]) => { + + const nodeIds = connections.map((n) => n.id); + nodeIds.push(selectedNode); + const newEdges = edges.filter( + (e) => + nodeIds.includes(e.from as string) && nodeIds.includes(e.to as string) + ); + + return newEdges; + + }; \ No newline at end of file diff --git a/src/utils/findNodeChildren.ts b/src/utils/findNodeChildren.ts new file mode 100644 index 00000000000..784f008b188 --- /dev/null +++ b/src/utils/findNodeChildren.ts @@ -0,0 +1,34 @@ +import { NodeData, EdgeData } from "reaflow/dist/types"; + + +export const findNodeChildren = (selectedNode: string, nodes: NodeData[], edges: EdgeData[]) => { + +const toByFrom = {}; +for (const edge of edges) { + if(edge.from){ + toByFrom[edge.from] ??= []; + toByFrom[edge.from].push(edge.to); + } +} + +const getNodes = (parent, allNodesIds:string[] = []) => { + const tos = toByFrom[parent]; + if (tos) { + allNodesIds.push(...tos); + for (const to of tos) { + getNodes(to, allNodesIds); + } + } + return allNodesIds; +}; + + + const myNodes = getNodes(selectedNode); + + const findNodes = myNodes.map((id) => { + const node = nodes.find((n) => n.id === id); + return node as NodeData; + }); + + return findNodes; + }; \ No newline at end of file