| import React from "react"; | |
| import dynamic from "next/dynamic"; | |
| import styled from "styled-components"; | |
| import { toast } from "react-hot-toast"; | |
| import { Space } from "react-zoomable-ui"; | |
| import { ElkRoot } from "reaflow/dist/layout/useLayout"; | |
| import { useLongPress } from "use-long-press"; | |
| import { CustomNode } from "src/containers/Views/GraphView/CustomNode"; | |
| import { ViewMode } from "src/enums/viewMode.enum"; | |
| import useToggleHide from "src/hooks/useToggleHide"; | |
| import { Loading } from "src/layout/Loading"; | |
| import useConfig from "src/store/useConfig"; | |
| import useGraph from "src/store/useGraph"; | |
| import useUser from "src/store/useUser"; | |
| import { NodeData } from "src/types/graph"; | |
| import { CustomEdge } from "./CustomEdge"; | |
| import { ErrorView } from "./ErrorView"; | |
| import { PremiumView } from "./PremiumView"; | |
| const Canvas = dynamic(() => import("reaflow").then(r => r.Canvas), { | |
| ssr: false, | |
| }); | |
| interface GraphProps { | |
| isWidget?: boolean; | |
| } | |
| const StyledEditorWrapper = styled.div<{ $widget: boolean; $showRulers: boolean }>` | |
| position: absolute; | |
| width: 100%; | |
| height: ${({ $widget }) => ($widget ? "calc(100vh - 40px)" : "calc(100vh - 67px)")}; | |
| --bg-color: ${({ theme }) => theme.GRID_BG_COLOR}; | |
| --line-color-1: ${({ theme }) => theme.GRID_COLOR_PRIMARY}; | |
| --line-color-2: ${({ theme }) => theme.GRID_COLOR_SECONDARY}; | |
| background-color: var(--bg-color); | |
| ${({ $showRulers }) => | |
| $showRulers && | |
| ` | |
| background-image: linear-gradient(var(--line-color-1) 1.5px, transparent 1.5px), | |
| linear-gradient(90deg, var(--line-color-1) 1.5px, transparent 1.5px), | |
| linear-gradient(var(--line-color-2) 1px, transparent 1px), | |
| linear-gradient(90deg, var(--line-color-2) 1px, transparent 1px); | |
| background-position: | |
| -1.5px -1.5px, | |
| -1.5px -1.5px, | |
| -1px -1px, | |
| -1px -1px; | |
| background-size: | |
| 100px 100px, | |
| 100px 100px, | |
| 20px 20px, | |
| 20px 20px; | |
| `}; | |
| .jsoncrack-space { | |
| cursor: url("/assets/cursor.svg"), auto; | |
| } | |
| :active { | |
| cursor: move; | |
| } | |
| .dragging, | |
| .dragging button { | |
| pointer-events: none; | |
| } | |
| rect { | |
| fill: ${({ theme }) => theme.BACKGROUND_NODE}; | |
| } | |
| @media only screen and (max-width: 768px) { | |
| height: ${({ $widget }) => ($widget ? "calc(100vh - 40px)" : "100vh")}; | |
| } | |
| @media only screen and (max-width: 320px) { | |
| height: 100vh; | |
| } | |
| `; | |
| const layoutOptions = { | |
| "elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH", | |
| "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", | |
| }; | |
| const PREMIUM_LIMIT = 200; | |
| const ERROR_LIMIT_TREE = 5_000; | |
| const ERROR_LIMIT = 10_000; | |
| const GraphCanvas = ({ isWidget }: GraphProps) => { | |
| const { validateHiddenNodes } = useToggleHide(); | |
| const setLoading = useGraph(state => state.setLoading); | |
| const centerView = useGraph(state => state.centerView); | |
| const direction = useGraph(state => state.direction); | |
| const nodes = useGraph(state => state.nodes); | |
| const edges = useGraph(state => state.edges); | |
| const [paneWidth, setPaneWidth] = React.useState(2000); | |
| const [paneHeight, setPaneHeight] = React.useState(2000); | |
| const onLayoutChange = React.useCallback( | |
| (layout: ElkRoot) => { | |
| if (layout.width && layout.height) { | |
| const areaSize = layout.width * layout.height; | |
| const changeRatio = Math.abs((areaSize * 100) / (paneWidth * paneHeight) - 100); | |
| setPaneWidth(layout.width + 50); | |
| setPaneHeight((layout.height as number) + 50); | |
| setTimeout(() => { | |
| validateHiddenNodes(); | |
| window.requestAnimationFrame(() => { | |
| if (changeRatio > 70 || isWidget) centerView(); | |
| setLoading(false); | |
| }); | |
| }); | |
| } | |
| }, | |
| [isWidget, paneHeight, paneWidth, centerView, setLoading, validateHiddenNodes] | |
| ); | |
| return ( | |
| <Canvas | |
| className="jsoncrack-canvas" | |
| onLayoutChange={onLayoutChange} | |
| node={p => <CustomNode {...p} />} | |
| edge={p => <CustomEdge {...p} />} | |
| nodes={nodes} | |
| edges={edges} | |
| maxHeight={paneHeight} | |
| maxWidth={paneWidth} | |
| height={paneHeight} | |
| width={paneWidth} | |
| direction={direction} | |
| layoutOptions={layoutOptions} | |
| key={direction} | |
| pannable={false} | |
| zoomable={false} | |
| animated={false} | |
| readonly={true} | |
| dragEdge={null} | |
| dragNode={null} | |
| fit={true} | |
| /> | |
| ); | |
| }; | |
| function getViewType(nodes: NodeData[]) { | |
| if (nodes.length > ERROR_LIMIT) return "error"; | |
| if (nodes.length > ERROR_LIMIT_TREE) return "tree"; | |
| if (nodes.length > PREMIUM_LIMIT) return "premium"; | |
| return "graph"; | |
| } | |
| export const Graph = ({ isWidget = false }: GraphProps) => { | |
| const setViewPort = useGraph(state => state.setViewPort); | |
| const loading = useGraph(state => state.loading); | |
| const isPremium = useUser(state => state.premium); | |
| const viewType = useGraph(state => getViewType(state.nodes)); | |
| const gesturesEnabled = useConfig(state => state.gesturesEnabled); | |
| const rulersEnabled = useConfig(state => state.rulersEnabled); | |
| const setViewMode = useConfig(state => state.setViewMode); | |
| const callback = React.useCallback(() => { | |
| const canvas = document.querySelector(".jsoncrack-canvas") as HTMLDivElement | null; | |
| canvas?.classList.add("dragging"); | |
| }, []); | |
| const bindLongPress = useLongPress(callback, { | |
| threshold: 150, | |
| onFinish: () => { | |
| const canvas = document.querySelector(".jsoncrack-canvas") as HTMLDivElement | null; | |
| canvas?.classList.remove("dragging"); | |
| }, | |
| }); | |
| const blurOnClick = React.useCallback(() => { | |
| if ("activeElement" in document) (document.activeElement as HTMLElement)?.blur(); | |
| }, []); | |
| if (viewType === "error") { | |
| return <ErrorView />; | |
| } | |
| if (viewType === "tree") { | |
| setViewMode(ViewMode.Tree); | |
| toast("This document is too large to display as a graph. Switching to tree view."); | |
| } | |
| if (viewType === "premium" && !isWidget) { | |
| if (!isPremium) return <PremiumView />; | |
| } | |
| return ( | |
| <> | |
| <Loading loading={loading} message="Painting graph..." /> | |
| <StyledEditorWrapper | |
| $widget={isWidget} | |
| onContextMenu={e => e.preventDefault()} | |
| onClick={blurOnClick} | |
| key={String(gesturesEnabled)} | |
| $showRulers={rulersEnabled} | |
| {...bindLongPress()} | |
| > | |
| <Space | |
| onCreate={setViewPort} | |
| onContextMenu={e => e.preventDefault()} | |
| treatTwoFingerTrackPadGesturesLikeTouch={gesturesEnabled} | |
| pollForElementResizing | |
| className="jsoncrack-space" | |
| > | |
| <GraphCanvas isWidget={isWidget} /> | |
| </Space> | |
| </StyledEditorWrapper> | |
| </> | |
| ); | |
| }; | |