diff --git a/src/components/CustomNode/TextNode.tsx b/src/components/CustomNode/TextNode.tsx index 1608a63..4b1e30f 100644 --- a/src/components/CustomNode/TextNode.tsx +++ b/src/components/CustomNode/TextNode.tsx @@ -1,6 +1,5 @@ import React from "react"; import { MdCompareArrows } from "react-icons/md"; -import { NodeData } from "reaflow/dist/types"; import { ConditionalWrapper, CustomNodeProps } from "src/components/CustomNode"; import useConfig from "src/hooks/store/useConfig"; import useGraph from "src/hooks/store/useGraph"; @@ -26,12 +25,15 @@ const StyledExpand = styled.button` border-left: 1px solid ${({ theme }) => theme.BACKGROUND_MODIFIER_ACCENT}; `; -const TextNode: React.FC & { node: NodeData }> = ({ +const TextNode: React.FC< + CustomNodeProps & { node: NodeData; hasCollapse: boolean } +> = ({ node, width, height, value, isParent = false, + hasCollapse = false, x, y, }) => { @@ -60,7 +62,7 @@ const TextNode: React.FC & { node: NodeData }> = ({ @@ -76,7 +78,7 @@ const TextNode: React.FC & { node: NodeData }> = ({ - {isParent && !hideCollapse && ( + {isParent && hasCollapse && !hideCollapse && ( diff --git a/src/components/CustomNode/index.tsx b/src/components/CustomNode/index.tsx index 4c7d7c9..dc7a1ad 100644 --- a/src/components/CustomNode/index.tsx +++ b/src/components/CustomNode/index.tsx @@ -39,11 +39,9 @@ export const CustomNode = (nodeProps: NodeProps) => { }> {({ width, height, x, y, node }) => { if (properties.text instanceof Object) { - const entries = Object.entries(properties.text); - return ( { value={properties.text} width={width} height={height} + hasCollapse={properties.data.hasChild} x={x} y={y} /> diff --git a/src/components/CustomNode/styles.tsx b/src/components/CustomNode/styles.tsx index 4640446..1c95329 100644 --- a/src/components/CustomNode/styles.tsx +++ b/src/components/CustomNode/styles.tsx @@ -26,7 +26,7 @@ export const StyledTextWrapper = styled.div` export const StyledText = styled.div<{ width: number; height: number; - parent?: boolean; + hasCollapse?: boolean; hideCollapse?: boolean; }>` display: flex; @@ -36,8 +36,8 @@ export const StyledText = styled.div<{ height: ${({ height }) => height}; min-height: 50; color: ${({ theme }) => theme.TEXT_NORMAL}; - padding-right: ${({ parent, hideCollapse }) => - parent && !hideCollapse && "20px"}; + padding-right: ${({ hasCollapse, hideCollapse }) => + hasCollapse && !hideCollapse && "20px"}; `; export const StyledForeignObject = styled.foreignObject` diff --git a/src/components/Graph/index.tsx b/src/components/Graph/index.tsx index 2578d06..a5c259f 100644 --- a/src/components/Graph/index.tsx +++ b/src/components/Graph/index.tsx @@ -4,19 +4,19 @@ import { TransformComponent, TransformWrapper, } from "react-zoom-pan-pinch"; -import { Canvas, Edge, ElkRoot, NodeData } from "reaflow"; +import { Canvas, Edge, ElkRoot } 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 useGraph from "src/hooks/store/useGraph"; +import { parser } from "src/utils/jsonParser"; interface LayoutProps { isWidget: boolean; openModal: () => void; - setSelectedNode: (node: object) => void; + setSelectedNode: (node: [string, string][]) => void; } const StyledEditorWrapper = styled.div<{ isWidget: boolean }>` @@ -40,6 +40,7 @@ const MemoizedGraph = React.memo(function Layout({ setSelectedNode, }: LayoutProps) { const json = useConfig((state) => state.json); + const setConfig = useConfig((state) => state.setConfig); const nodes = useGraph((state) => state.nodes); const edges = useGraph((state) => state.edges); const setGraphValue = useGraph((state) => state.setGraphValue); @@ -49,7 +50,6 @@ const MemoizedGraph = React.memo(function Layout({ height: 2000, }); - const setConfig = useConfig((state) => state.setConfig); const [expand, layout] = useConfig( (state) => [state.expand, state.layout], shallow @@ -63,17 +63,21 @@ const MemoizedGraph = React.memo(function Layout({ [openModal, setSelectedNode] ); - const onInit = (ref: ReactZoomPanPinchRef) => { - setConfig("zoomPanPinch", ref); - }; + const onInit = React.useCallback( + (ref: ReactZoomPanPinchRef) => { + setConfig("zoomPanPinch", ref); + }, + [setConfig] + ); - const onLayoutChange = (layout: ElkRoot) => { - if (layout.width && layout.height) - setSize({ width: layout.width, height: layout.height }); - }; + const onLayoutChange = React.useCallback((layout: ElkRoot) => { + if (layout.width && layout.height) { + setSize({ width: layout.width + 400, height: layout.height + 400 }); + } + }, []); React.useEffect(() => { - const { nodes, edges } = getEdgeNodes(json, expand); + const { nodes, edges } = parser(json, expand); setGraphValue("nodes", nodes); setGraphValue("edges", edges); @@ -82,13 +86,13 @@ const MemoizedGraph = React.memo(function Layout({ return ( ( - - )} - edge={(props) => ( - - )} + key={layout} zoomable={false} animated={false} readonly={true} dragEdge={null} dragNode={null} fit={true} + node={(props) => ( + + )} + edge={(props) => ( + + )} /> @@ -126,7 +130,9 @@ 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 [selectedNode, setSelectedNode] = React.useState<[string, string][]>( + [] + ); const openModal = React.useCallback(() => setModalVisible(true), []); diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 882bd2c..18b02b1 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -2,7 +2,6 @@ import React from "react"; import toast from "react-hot-toast"; import Link from "next/link"; import styled from "styled-components"; -import { CanvasDirection } from "reaflow"; import { TiFlowMerge } from "react-icons/ti"; import { CgArrowsMergeAltH, CgArrowsShrinkH } from "react-icons/cg"; import { @@ -21,10 +20,10 @@ import { ImportModal } from "src/containers/Modals/ImportModal"; import { ClearModal } from "src/containers/Modals/ClearModal"; import { ShareModal } from "src/containers/Modals/ShareModal"; import useConfig from "src/hooks/store/useConfig"; -import { getNextLayout } from "src/containers/Editor/LiveEditor/helpers"; import { HiHeart } from "react-icons/hi"; import shallow from "zustand/shallow"; import { MdCenterFocusWeak } from "react-icons/md"; +import { getNextLayout } from "src/utils/getNextLayout"; const StyledSidebar = styled.div` display: flex; @@ -129,7 +128,7 @@ const StyledLogo = styled.div` color: ${({ theme }) => theme.FULL_WHITE}; `; -function rotateLayout(layout: CanvasDirection) { +function rotateLayout(layout: "LEFT" | "RIGHT" | "DOWN" | "UP") { if (layout === "LEFT") return 90; if (layout === "UP") return 180; if (layout === "RIGHT") return 270; diff --git a/src/containers/Editor/LiveEditor/helpers.ts b/src/containers/Editor/LiveEditor/helpers.ts deleted file mode 100644 index cf62eaa..0000000 --- a/src/containers/Editor/LiveEditor/helpers.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { CanvasDirection, NodeData, EdgeData } from "reaflow"; -import { parser } from "src/utils/json-editor-parser"; - -export function getEdgeNodes( - graph: string, - isExpanded: boolean = true -): { - nodes: NodeData[]; - edges: EdgeData[]; -} { - const elements = parser(JSON.parse(graph)); - - let nodes: NodeData[] = [], - edges: EdgeData[] = []; - - for (let i = 0; i < elements.length; i++) { - const el = elements[i]; - - if (isNode(el)) { - const text = renderText(el.text); - const lines = text.split("\n"); - const lineLengths = lines - .map((line) => line.length) - .sort((a, b) => a - b); - const longestLine = lineLengths.reverse()[0]; - - const height = lines.length * 17.8 < 30 ? 40 : lines.length * 17.8; - - nodes.push({ - id: el.id, - text: el.text, - data: { - isParent: el.parent, - }, - width: isExpanded ? 35 + longestLine * 8 + (el.parent && 60) : 180, - height, - }); - } else { - edges.push(el); - } - } - - return { - nodes, - edges, - }; -} - -export function getNextLayout(layout: CanvasDirection) { - switch (layout) { - case "RIGHT": - return "DOWN"; - - case "DOWN": - return "LEFT"; - - case "LEFT": - return "UP"; - - default: - return "RIGHT"; - } -} - -function renderText(value: string | object) { - if (value instanceof Object) { - let temp = ""; - const entries = Object.entries(value); - - if (Object.keys(value).every((val) => !isNaN(+val))) { - return Object.values(value).join(""); - } - - entries.forEach((entry) => { - temp += `${entry[0]}: ${String(entry[1])}\n`; - }); - - return temp; - } - - return value; -} - -function isNode(element: NodeData | EdgeData) { - if ("text" in element) return true; - return false; -} diff --git a/src/containers/Modals/NodeModal/index.tsx b/src/containers/Modals/NodeModal/index.tsx index 7b90e38..fcfb1cd 100644 --- a/src/containers/Modals/NodeModal/index.tsx +++ b/src/containers/Modals/NodeModal/index.tsx @@ -30,9 +30,13 @@ export const NodeModal = ({ visible, closeModal, }: NodeModalProps) => { + const nodeData = Array.isArray(selectedNode) + ? Object.fromEntries(selectedNode) + : selectedNode; + const handleClipboard = () => { toast.success("Content copied to clipboard!"); - navigator.clipboard.writeText(JSON.stringify(selectedNode)); + navigator.clipboard.writeText(JSON.stringify(nodeData)); closeModal(); }; @@ -42,8 +46,8 @@ export const NodeModal = ({ { + nodeData, + (_, v) => { if (typeof v === "string") return v.replaceAll('"', ""); return v; }, diff --git a/src/hooks/store/useConfig.tsx b/src/hooks/store/useConfig.tsx index cb1cb77..36f728c 100644 --- a/src/hooks/store/useConfig.tsx +++ b/src/hooks/store/useConfig.tsx @@ -1,7 +1,6 @@ import create from "zustand"; import { defaultJson } from "src/constants/data"; import { ReactZoomPanPinchRef } from "react-zoom-pan-pinch"; -import { CanvasDirection } from "reaflow"; interface ConfigActions { setJson: (json: string) => void; @@ -15,7 +14,7 @@ interface ConfigActions { export interface Config { json: string; cursorMode: "move" | "navigation"; - layout: CanvasDirection; + layout: "LEFT" | "RIGHT" | "DOWN" | "UP"; expand: boolean; hideEditor: boolean; zoomPanPinch?: ReactZoomPanPinchRef; @@ -34,7 +33,7 @@ const initialStates: Config = { const useConfig = create()((set, get) => ({ ...initialStates, getJson: () => get().json, - setJson: (json: string) => set((state) => ({ ...state, json })), + setJson: (json: string) => set({ json }), zoomIn: () => { const zoomPanPinch = get().zoomPanPinch; if (zoomPanPinch) { @@ -57,13 +56,10 @@ const useConfig = create()((set, get) => ({ }, centerView: () => { const zoomPanPinch = get().zoomPanPinch; - if (zoomPanPinch) zoomPanPinch.resetTransform(); + if (zoomPanPinch) zoomPanPinch.centerView(0.6); }, setConfig: (setting: keyof Config, value: unknown) => - set((state) => ({ - ...state, - [setting]: value, - })), + set({ [setting]: value }), })); export default useConfig; diff --git a/src/hooks/store/useGraph.tsx b/src/hooks/store/useGraph.tsx index b230885..fef926a 100644 --- a/src/hooks/store/useGraph.tsx +++ b/src/hooks/store/useGraph.tsx @@ -1,5 +1,4 @@ import create from "zustand"; -import { EdgeData, NodeData } from "reaflow/dist/types"; import { Graph } from "src/components/Graph"; import { getChildrenEdges } from "src/utils/getChildrenEdges"; import { getOutgoers } from "src/utils/getOutgoers"; @@ -24,7 +23,7 @@ const initialStates: Graph = { collapsedEdges: [], }; -const useGraph = create((set) => ({ +const useGraph = create((set, get) => ({ ...initialStates, setGraphValue: (key, value) => set({ @@ -32,38 +31,34 @@ const useGraph = create((set) => ({ collapsedEdges: [], [key]: value, }), - expandNodes: (nodeId) => - set((state) => { - const childrenNodes = getOutgoers(nodeId, state.nodes, state.edges); - const childrenEdges = getChildrenEdges(childrenNodes, state.edges); + expandNodes: (nodeId) => { + const childrenNodes = getOutgoers(nodeId, get().nodes, get().edges); + const childrenEdges = getChildrenEdges(childrenNodes, get().edges); - const nodeIds = childrenNodes.map((node) => node.id); - const edgeIds = childrenEdges.map((edge) => edge.id); + const nodeIds = childrenNodes.map((node) => node.id); + const edgeIds = childrenEdges.map((edge) => edge.id); - return { - ...state, - collapsedNodes: state.collapsedNodes.filter( - (nodeId) => !nodeIds.includes(nodeId) - ), - collapsedEdges: state.collapsedEdges.filter( - (edgeId) => !edgeIds.includes(edgeId) - ), - }; - }), - collapseNodes: (nodeId) => - set((state) => { - const childrenNodes = getOutgoers(nodeId, state.nodes, state.edges); - const childrenEdges = getChildrenEdges(childrenNodes, state.edges); + set({ + collapsedNodes: get().collapsedNodes.filter( + (nodeId) => !nodeIds.includes(nodeId) + ), + collapsedEdges: get().collapsedEdges.filter( + (edgeId) => !edgeIds.includes(edgeId) + ), + }); + }, + collapseNodes: (nodeId) => { + const childrenNodes = getOutgoers(nodeId, get().nodes, get().edges); + const childrenEdges = getChildrenEdges(childrenNodes, get().edges); - const nodeIds = childrenNodes.map((node) => node.id); - const edgeIds = childrenEdges.map((edge) => edge.id); + const nodeIds = childrenNodes.map((node) => node.id); + const edgeIds = childrenEdges.map((edge) => edge.id); - return { - ...state, - collapsedNodes: state.collapsedNodes.concat(nodeIds), - collapsedEdges: state.collapsedEdges.concat(edgeIds), - }; - }), + set({ + collapsedNodes: get().collapsedNodes.concat(nodeIds), + collapsedEdges: get().collapsedEdges.concat(edgeIds), + }); + }, })); export default useGraph; diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..6be86b3 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,33 @@ +type CanvasDirection = "LEFT" | "RIGHT" | "DOWN" | "UP" + +interface NodeData { + id: string; + disabled?: boolean; + text?: any; + height?: number; + width?: number; + parent?: string; + ports?: PortData[]; + icon?: IconData; + nodePadding?: number | [number, number] | [number, number, number, number]; + data?: T; + className?: string; + layoutOptions?: ElkNodeLayoutOptions; + selectionDisabled?: boolean; +} + +interface EdgeData { + id: string; + disabled?: boolean; + text?: any; + from?: string; + to?: string; + fromPort?: string; + toPort?: string; + data?: T; + className?: string; + containerClassName?: string; + arrowHeadType?: any; + parent?: string; + selectionDisabled?: boolean; +} diff --git a/src/utils/getChildrenEdges.ts b/src/utils/getChildrenEdges.ts index 25a1f60..032fffc 100644 --- a/src/utils/getChildrenEdges.ts +++ b/src/utils/getChildrenEdges.ts @@ -1,5 +1,3 @@ -import { NodeData, EdgeData } from "reaflow/dist/types"; - export const getChildrenEdges = ( nodes: NodeData[], edges: EdgeData[] diff --git a/src/utils/getNextLayout.ts b/src/utils/getNextLayout.ts new file mode 100644 index 0000000..f710958 --- /dev/null +++ b/src/utils/getNextLayout.ts @@ -0,0 +1,15 @@ +export function getNextLayout(layout: "LEFT" | "RIGHT" | "DOWN" | "UP") { + switch (layout) { + case "RIGHT": + return "DOWN"; + + case "DOWN": + return "LEFT"; + + case "LEFT": + return "UP"; + + default: + return "RIGHT"; + } +} diff --git a/src/utils/getOutgoers.ts b/src/utils/getOutgoers.ts index 17c0c09..a5ae23d 100644 --- a/src/utils/getOutgoers.ts +++ b/src/utils/getOutgoers.ts @@ -1,5 +1,3 @@ -import { NodeData, EdgeData } from "reaflow/dist/types"; - export const getOutgoers = ( nodeId: string, nodes: NodeData[], diff --git a/src/utils/json-editor-parser.ts b/src/utils/json-editor-parser.ts deleted file mode 100644 index 94c5c3a..0000000 --- a/src/utils/json-editor-parser.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Copyright (C) 2022 Aykut SaraƧ - All Rights Reserved - */ -import toast from "react-hot-toast"; - -const filterChild = ([_, v]) => { - const isNull = v === null; - const isArray = Array.isArray(v) && v.length; - const isObject = v instanceof Object; - - return !isNull && (isArray || isObject); -}; - -const filterValues = ([k, v]) => { - if (Array.isArray(v) || v instanceof Object) return false; - - return true; -}; - -function generateChildren(object: Object, nextId: () => string) { - if (!(object instanceof Object)) object = [object]; - - return Object.entries(object) - .filter(filterChild) - .flatMap(([k, v]) => { - // const isObject = v instanceof Object && !Array.isArray(v); - - // if (isObject) { - // return [ - // { - // id: nextId(), - // text: k, - // parent: true, - // children: generateChildren(v, nextId), - // }, - // ]; - // } - - return [ - { - id: nextId(), - text: k, - parent: true, - children: extract(v, nextId), - }, - ]; - }); -} - -function generateNodeData(object: Object | number) { - const isObject = object instanceof Object; - - if (isObject) { - const entries = Object.entries(object).filter(filterValues); - return Object.fromEntries(entries); - } - - return String(object); -} - -const extract = ( - os: string[] | object[] | null, - nextId = ( - (id) => () => - String(++id) - )(0) -) => { - if (!os) return []; - - return [os].flat().map((o) => ({ - id: nextId(), - text: generateNodeData(o), - children: generateChildren(o, nextId), - parent: false, - })); -}; - -const flatten = (xs: { id: string; children: never[] }[]) => - xs.flatMap(({ children, ...rest }) => [rest, ...flatten(children)]); - -const relationships = (xs: { id: string; children: never[] }[]) => { - return xs.flatMap(({ id: from, children = [] }) => [ - ...children.map(({ id: to }) => ({ - id: `e${from}-${to}`, - from, - to, - })), - ...relationships(children), - ]); -}; - -export const parser = (input: string | string[]) => { - try { - if (!Array.isArray(input)) input = [input]; - - const mappedElements = extract(input); - const res = [...flatten(mappedElements), ...relationships(mappedElements)]; - - return res; - } catch (error) { - console.error(error); - toast.error("An error occured while parsing JSON data!"); - return []; - } -}; diff --git a/src/utils/jsonParser.ts b/src/utils/jsonParser.ts new file mode 100644 index 0000000..10c7205 --- /dev/null +++ b/src/utils/jsonParser.ts @@ -0,0 +1,159 @@ +import toast from "react-hot-toast"; + +const calculateSize = ( + text: string | [string, string][], + isParent = false, + isExpanded: boolean +) => { + let value = ""; + + if (typeof text === "string") value = text; + else value = text.map(([k, v]) => `${k}: ${v}`).join("\n"); + + const lineCount = value.split("\n"); + const lineLengths = lineCount.map((line) => line.length); + const longestLine = lineLengths.sort((a, b) => b - a)[0]; + + const getWidth = () => { + if (isExpanded) return 35 + longestLine * 8 + (isParent ? 60 : 0); + if (isParent) return 150; + return 200; + }; + + const getHeight = () => { + if (lineCount.length * 17.8 < 30) return 40; + return (lineCount.length + 1) * 18; + }; + + return { + width: getWidth(), + height: getHeight(), + }; +}; + +const filterChild = ([_, v]) => { + const isNull = v === null; + const isArray = Array.isArray(v) && v.length; + const isObject = v instanceof Object; + + return !isNull && (isArray || isObject); +}; + +const filterValues = ([k, v]) => { + if (Array.isArray(v) || v instanceof Object) return false; + return true; +}; + +function generateChildren( + object: Object, + isExpanded = true, + nextId: () => string +) { + if (!(object instanceof Object)) object = [object]; + + return Object.entries(object) + .filter(filterChild) + .flatMap(([key, v]) => { + const { width, height } = calculateSize(key, true, isExpanded); + const children = extract(v, isExpanded, nextId); + + return [ + { + id: nextId(), + text: key, + children, + width, + height, + data: { + isParent: true, + hasChild: !!children.length, + }, + }, + ]; + }); +} + +function generateNodeData(object: Object) { + if (object instanceof Object) { + const entries = Object.entries(object).filter(filterValues); + return entries; + } + + return String(object); +} + +const extract = ( + os: string[] | object[] | null, + isExpanded = true, + nextId = ( + (id) => () => + String(++id) + )(0) +) => { + if (!os) return []; + + return [os].flat().map((o) => { + const text = generateNodeData(o); + const { width, height } = calculateSize(text, false, isExpanded); + + return { + id: nextId(), + text, + width, + height, + children: generateChildren(o, isExpanded, nextId), + data: { + isParent: false, + hasChild: false, + }, + }; + }); +}; + +const flatten = (xs: { id: string; children: never[] }[]) => + xs.flatMap(({ children, ...rest }) => [rest, ...flatten(children)]); + +const relationships = (xs: { id: string; children: never[] }[]) => { + return xs.flatMap(({ id: from, children = [] }) => [ + ...children.map(({ id: to }) => ({ + id: `e${from}-${to}`, + from, + to, + })), + ...relationships(children), + ]); +}; + +export const parser = (jsonStr: string, isExpanded = true) => { + try { + let json = JSON.parse(jsonStr); + if (!Array.isArray(json)) json = [json]; + const nodes: NodeData[] = []; + const edges: EdgeData[] = []; + + const mappedElements = extract(json, isExpanded); + const res = [...flatten(mappedElements), ...relationships(mappedElements)]; + + res.forEach((data) => { + if (isNode(data)) { + nodes.push(data); + } else { + edges.push(data); + } + }); + + return { nodes, edges }; + } catch (error) { + console.error(error); + toast.error("An error occured while parsing JSON data!"); + return { + nodes: [], + edges: [], + }; + } +}; + +function isNode(element: NodeData | EdgeData) { + if ("text" in element) return true; + return false; +}