= ({
27 | prefix = 'graph-editor-background',
28 | type = BackgroundType.Dots,
29 | gap = 15,
30 | size = 0.4,
31 | color,
32 | background = '#fff',
33 | style,
34 | className,
35 | }) => {
36 | const [global] = useGlobal()
37 | const { x, y, k } = global.transform
38 | // when there are multiple flows on a page we need to make sure that every background gets its own pattern.
39 | const patternId = useMemo(() => `pattern-${Math.floor(Math.random() * 100000)}`, [])
40 |
41 | const scaledGap = gap * k
42 | const xOffset = x % scaledGap
43 | const yOffset = y % scaledGap
44 |
45 | const isLines = type === BackgroundType.Lines
46 | const bgColor = color || defaultColors[type]
47 | const path = isLines
48 | ? createGridLinesPath(scaledGap, size, bgColor)
49 | : createGridDotsPath(size * k, bgColor)
50 |
51 | return (
52 |
61 |
69 | {path}
70 |
71 |
72 |
73 | )
74 | }
75 |
76 | Background.displayName = 'Background'
77 |
78 | export default memo(Background)
79 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@ols-scripts/graph-editor",
3 | "version": "0.0.3",
4 | "description": "节点编辑器",
5 | "author": "mochen.du",
6 | "license": "ISC",
7 | "scripts": {
8 | "start": "npm run dev",
9 | "dev": "ols dev",
10 | "docs:build": "ols build --docs",
11 | "build": "ols build",
12 | "deploy": "ols deploy",
13 | "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"",
14 | "test": "ols test",
15 | "test:coverage": "ols test --coverage",
16 | "prepublishOnly": "yarn build"
17 | },
18 | "files": [
19 | "dist"
20 | ],
21 | "main": "dist/index.js",
22 | "module": "dist/index.esm.js",
23 | "typings": "dist/index.d.ts",
24 | "gitHooks": {
25 | "pre-commit": "lint-staged"
26 | },
27 | "lint-staged": {
28 | "*.{js,jsx,less,md,json}": [
29 | "prettier --write"
30 | ],
31 | "*.ts?(x)": [
32 | "prettier --parser=typescript --write"
33 | ]
34 | },
35 | "peerDependencies": {
36 | "react": ">=16.9.0",
37 | "react-dom": ">=16.9.0"
38 | },
39 | "dependencies": {
40 | "classnames": "^2.3.1",
41 | "d3-selection": "^2.0.0",
42 | "d3-zoom": "^2.0.0",
43 | "dagre": "^0.8.5",
44 | "lodash": "^4.17.21",
45 | "react-draggable": "^4.4.3"
46 | },
47 | "devDependencies": {
48 | "@svgr/webpack": "^5.5.0",
49 | "@ols-scripts/cli": "^0.0.1",
50 | "@ols-scripts/component-theme-one": "^0.0.1",
51 | "@ols-scripts/eslint-config": "^0.0.1",
52 | "@types/d3": "^6.3.0",
53 | "@types/dagre": "^0.7.44",
54 | "@types/enzyme": "^3.10.8",
55 | "@types/enzyme-adapter-react-16": "^1.0.6",
56 | "@types/jest": "^26.0.19",
57 | "@types/react": "^16.9.22",
58 | "@types/react-dom": "^16.9.5",
59 | "@types/resize-observer-browser": "^0.1.5",
60 | "enzyme": "^3.11.0",
61 | "enzyme-adapter-react-16": "^1.15.5",
62 | "eslint-config-prettier": "^6.0.0",
63 | "eslint-plugin-prettier": "^3.3.1",
64 | "jest": "^26.6.3",
65 | "lint-staged": "^10.0.7",
66 | "prettier": "^1.19.1",
67 | "react": "^17.0.1",
68 | "react-dom": "^17.0.1",
69 | "rollup-plugin-commonjs": "^10.1.0",
70 | "typescript": "^3.8.2"
71 | },
72 | "publishConfig": {
73 | "access": "public"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/docs/demo/orientation.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Orientation
3 | order: 5
4 | # toc: menu
5 | ---
6 |
7 | ## Orientation
8 |
9 | ```tsx live
10 | import React, { useState } from 'react';
11 | import GraphEditor, { Controls, Background, MiniMap } from '@ols-scripts/graph-editor';
12 |
13 | const initElements = {
14 | nodes: [{
15 | id: '1',
16 | data: {
17 | label: (
18 | <>
19 | Welcome to React Flow!
20 | >
21 | ),
22 | },
23 | position: { x: 40, y: 200 },
24 | },
25 | {
26 | id: '2',
27 | data: {
28 | label: (
29 | <>
30 | This is a default node
31 | >
32 | ),
33 | },
34 | position: { x: 400, y: 40 },
35 | },
36 | {
37 | id: '3',
38 | data: {
39 | label: (
40 | <>
41 | This is a default node
42 | >
43 | ),
44 | },
45 | position: { x: 400, y: 300 },
46 | // connectable: false
47 | }],
48 | edges: [
49 | {
50 | source: '1',
51 | target: '2',
52 | label: 'I am label',
53 | // arrowType: 'arrowClosed'
54 | }
55 | ]
56 | }
57 |
58 | const Demo = () => {
59 | const [orientation, setOrientation] = useState('LR')
60 | const [elements, setEl] = useState(initElements)
61 |
62 | return (
63 |
64 | { setOrientation(e.target.value) }}>
65 | Left To Right
66 | Right To Left
67 | Top To Bottom
68 | Bottom To Top
69 |
70 | {
71 | setEl({
72 | nodes: elements.nodes.slice(0, 2),
73 | edges: elements.edges
74 | })
75 | }}>删除节点
76 |
77 | {
82 | setEl(element)
83 | }}
84 | connectLine={{
85 | lineType: 'smooth', // smooth, bezier, straight
86 | arrowType: 'arrowClosed'
87 | }}
88 | >
89 |
90 |
91 |
92 |
93 |
94 | );
95 | };
96 |
97 | ReactDOM.render( , mountNode);
98 | ```
99 |
--------------------------------------------------------------------------------
/src/container/EdgeType/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, useMemo } from 'react'
2 | import { getBezierPath, getSmoothStepPath, getMarkerEnd, getCenter } from '@/utils/graph'
3 | import { XYPosition, Orientation, Edge } from '@/types'
4 | import EdgeLabel from './EdgeLabel'
5 |
6 | import './index.less'
7 |
8 | export type LineProps = Edge & {
9 | prefix?: string
10 | source: XYPosition
11 | target: XYPosition
12 | isSelected?: boolean
13 | orientation?: Orientation
14 | }
15 |
16 | export const EdgeBezier = memo(
17 | ({
18 | prefix = 'graph-editor-edge-wrapper',
19 | source,
20 | target,
21 | arrowType,
22 | isSelected,
23 | orientation,
24 | label,
25 | }: LineProps) => {
26 | const [centerX, centerY] = getCenter({ source, target })
27 | const dAttr = useMemo(() => getBezierPath({ source, target, orientation }), [
28 | source,
29 | target,
30 | orientation,
31 | ])
32 |
33 | return (
34 | <>
35 |
40 |
41 | >
42 | )
43 | },
44 | )
45 |
46 | export const EdgeStraight = memo(
47 | ({ prefix = 'graph-editor-edge-wrapper', source, target, arrowType, isSelected, label }: LineProps) => {
48 | const [centerX, centerY] = getCenter({ source, target })
49 | const dAttr = useMemo(() => `M${source.x},${source.y} ${target.x},${target.y}`, [source, target])
50 |
51 | return (
52 | <>
53 |
58 |
59 | >
60 | )
61 | },
62 | )
63 |
64 | export const EdgeSmooth = memo(
65 | ({
66 | prefix = 'graph-editor-edge-wrapper',
67 | source,
68 | target,
69 | arrowType,
70 | isSelected,
71 | label,
72 | orientation,
73 | }: LineProps) => {
74 | const [centerX, centerY] = getCenter({ source, target })
75 | const dAttr = useMemo(() => getSmoothStepPath({ source, target, orientation }), [
76 | source,
77 | target,
78 | orientation,
79 | ])
80 |
81 | return (
82 | <>
83 |
88 |
89 | >
90 | )
91 | },
92 | )
93 |
--------------------------------------------------------------------------------
/docs/demo/event.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Event
3 | order: 2
4 | # toc: menu
5 | ---
6 |
7 | ## Event
8 |
9 | ```tsx live
10 | import React from 'react';
11 | import GraphEditor, { Controls, Background } from '@ols-scripts/graph-editor';
12 |
13 | const initElements = {
14 | nodes: [{
15 | id: '1',
16 | data: {
17 | label: (
18 | <>
19 | Welcome to React Flow!
20 | >
21 | ),
22 | },
23 | position: { x: 250, y: 100 },
24 | },
25 | {
26 | id: '2',
27 | data: {
28 | label: (
29 | <>
30 | This is a default node
31 | >
32 | ),
33 | },
34 | position: { x: 100, y: 240 },
35 | },
36 | {
37 | id: '3',
38 | data: {
39 | label: (
40 | <>
41 | This is a default node
42 | >
43 | ),
44 | },
45 | position: { x: 200, y: 400 },
46 | }],
47 | edges: [
48 | {
49 | source: '1',
50 | target: '2',
51 | lineType: 'smooth',
52 | arrowType: 'arrow'
53 | }
54 | ]
55 | }
56 |
57 | const Demo = () => {
58 | return (
59 |
60 | {
64 | console.log('node click', e, node)
65 | }}
66 | onNodeDoubleClick={(e, node) => {
67 | console.log('node double click', e, node)
68 | }}
69 | onNodeMouseEnter={(e, node) => {
70 | console.log('node mouse enter', e, node)
71 | }}
72 | onNodeMouseMove={(e, node) => {
73 | console.log('node mouse move', e, node)
74 | }}
75 | onNodeMouseLeave={(e, node) => {
76 | console.log('node mouse leave', e, node)
77 | }}
78 | onEdgeClick={(e, edge) => {
79 | console.log('edge click', e, edge)
80 | }}
81 | onEdgeDoubleClick={(e, edge) => {
82 | console.log('edge double click', e, edge)
83 | }}
84 | onEdgeMouseEnter={(e, node) => {
85 | console.log('edge mouse enter', e, node)
86 | }}
87 | onEdgeMouseMove={(e, node) => {
88 | console.log('edge mouse move', e, node)
89 | }}
90 | onEdgeMouseLeave={(e, node) => {
91 | console.log('edge mouse leave', e, node)
92 | }}
93 | onNodeDelete={(e, node) => {
94 | console.log('node delete', e, node)
95 | }}
96 | >
97 |
98 |
99 |
100 |
101 | );
102 | };
103 |
104 | ReactDOM.render( , mountNode);
105 | ```
106 |
--------------------------------------------------------------------------------
/src/hooks/useNodeResizeObserver.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useMemo } from 'react'
2 | import { getDimensions } from '@/utils/graph'
3 | import useGlobal from '@/hooks/useGlobal'
4 | import cloneDeep from 'lodash/cloneDeep'
5 | import { Node, ElementsEventHandler, NodeRect } from '@/types'
6 |
7 | export default function useNodeResizeObserver() {
8 | const [global, setGlobal] = useGlobal()
9 | const observerRef = useRef(null)
10 | const elementRefs = useRef([])
11 | const dependencyRef = useRef<{
12 | nodes?: Node[]
13 | nodeRects?: NodeRect[]
14 | setGlobal?: (p: any) => void
15 | onElementChange?: ElementsEventHandler
16 | }>({})
17 |
18 | useMemo(() => {
19 | dependencyRef.current = {
20 | nodes: global.nodes,
21 | nodeRects: global.nodeRects,
22 | setGlobal,
23 | onElementChange: global.onElementChange,
24 | }
25 | }, [global.nodes, global.nodeRects, setGlobal, global.onElementChange])
26 |
27 | useEffect(() => {
28 | observerRef.current = new ResizeObserver((entries: ResizeObserverEntry[]) => {
29 | const { nodes, nodeRects, setGlobal, onElementChange } = dependencyRef.current
30 | const nextNodes = cloneDeep(nodes)
31 |
32 | entries.forEach((entry: ResizeObserverEntry) => {
33 | const { width, height } = getDimensions(entry.target as HTMLElement)
34 | const nodeId = entry.target?.getAttribute('data-node-id')
35 | const findIndex = nodes.findIndex((item) => item.id === nodeId)
36 | const findReactIndex = nodeRects?.findIndex((item) => item.id === nodeId)
37 | const curNodeRect: NodeRect = { id: nodeId, width, height }
38 |
39 | // remove node
40 | if (width === height && height === 0) {
41 | return
42 | }
43 |
44 | if (
45 | nodeRects[findReactIndex] &&
46 | nodeRects[findReactIndex].width === width &&
47 | nodeRects[findReactIndex].height === height
48 | ) {
49 | return
50 | }
51 |
52 | nodeRects?.[findReactIndex]
53 | ? (nodeRects[findReactIndex] = curNodeRect)
54 | : nodeRects.push(curNodeRect)
55 |
56 | nextNodes[findIndex] = {
57 | ...nextNodes[findIndex],
58 | __extra: {
59 | width,
60 | height,
61 | },
62 | }
63 | })
64 |
65 | onElementChange({
66 | nodes: nextNodes,
67 | })
68 | setGlobal({ nodeRects: [...nodeRects] })
69 | })
70 | }, [])
71 |
72 | useEffect(() => {
73 | return () => {
74 | observerRef.current?.disconnect?.()
75 | observerRef.current?.unobserve?.()
76 | observerRef.current = null
77 | }
78 | }, [])
79 |
80 | return (element, options = { box: 'border-box' }) => {
81 | if (elementRefs.current.includes(element)) {
82 | return
83 | }
84 | observerRef.current?.observe?.(element, options)
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/container/GraphRenderer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, memo, PropsWithChildren, useRef } from 'react'
2 | import ZoomWrapper from '@/container/ZoomWrapper'
3 | import NodeRenderer from '@/container/NodeRenderer'
4 | import EdgeRenderer from '@/container/EdgeRenderer'
5 | import { GraphEditorProps } from '@/container/GraphEditor'
6 | import { useBatchUpdateDimension } from '@/hooks/useDimension'
7 | import useDelete from '@/hooks/useDelete'
8 |
9 | export type GraphRendererProps = PropsWithChildren>
10 |
11 | const GraphRenderer: FC = ({
12 | prefix,
13 | children,
14 | nodeTypes,
15 | edgeTypes,
16 | onNodeMouseEnter,
17 | onNodeMouseMove,
18 | onNodeMouseLeave,
19 | onNodeClick,
20 | onNodeDoubleClick,
21 | onNodeDrag,
22 | onNodeDragStart,
23 | onNodeDragStop,
24 | onConnect,
25 | onConnectStart,
26 | onConnectStop,
27 | onConnectEnd,
28 | onEdgeClick,
29 | onEdgeDoubleClick,
30 | onEdgeMouseEnter,
31 | onEdgeMouseMove,
32 | onEdgeMouseLeave,
33 | onEdgeDelete,
34 | onNodeDelete,
35 | connectLine,
36 | orientation,
37 | }) => {
38 | const placeholderRef = useRef()
39 |
40 | useBatchUpdateDimension(placeholderRef)
41 |
42 | useDelete({ onNodeDelete, onEdgeDelete })
43 |
44 | return (
45 | <>
46 |
47 | {({ transform, transformStyle }) => (
48 | <>
49 |
61 |
79 |
80 | >
81 | )}
82 |
83 | {children}
84 | >
85 | )
86 | }
87 |
88 | export default memo(GraphRenderer)
89 |
--------------------------------------------------------------------------------
/src/container/EdgeRenderer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, memo, useMemo } from 'react'
2 | import ConnectLineRenderer from '@/container/ConnectLineRenderer'
3 | import { GraphRendererProps } from '@/container/GraphRenderer'
4 | import EdgeComponent from '@/components/Edge'
5 | import useGlobal from '@/hooks/useGlobal'
6 | import { mergeEdgeTypes } from '@/utils/graph'
7 | import { ZoomTransform, Edge } from '@/types'
8 | import MarkerDefinitions from '@/components/MarkerDefinitions'
9 |
10 | import './index.less'
11 |
12 | export type EdgeRendererProps = Pick<
13 | GraphRendererProps,
14 | | 'prefix'
15 | | 'edgeTypes'
16 | | 'onEdgeClick'
17 | | 'onEdgeDoubleClick'
18 | | 'onEdgeMouseEnter'
19 | | 'onEdgeMouseMove'
20 | | 'onEdgeMouseLeave'
21 | | 'onElementChange'
22 | | 'connectLine'
23 | | 'orientation'
24 | > & {
25 | transform: ZoomTransform
26 | transformStyle: string
27 | }
28 |
29 | const EdgeRenderer: FC = ({
30 | prefix = 'graph-editor-edge-renderer',
31 | edgeTypes,
32 | transform,
33 | onEdgeClick,
34 | onEdgeDoubleClick,
35 | onEdgeMouseEnter,
36 | onEdgeMouseMove,
37 | onEdgeMouseLeave,
38 | connectLine = {},
39 | orientation,
40 | }) => {
41 | const [{ edges, nodes }] = useGlobal()
42 |
43 | const mergedEdgeTypes = useMemo(() => mergeEdgeTypes(edgeTypes), [edgeTypes])
44 |
45 | return (
46 |
47 |
48 |
49 |
50 | {edges.map((edge: Edge) => {
51 | const Component = mergedEdgeTypes[edge.type] || mergedEdgeTypes.default
52 | const id = edge.id || `${edge.source}_${edge.target}`
53 |
54 | if (
55 | !(
56 | nodes.find((node) => node.id === edge.source) &&
57 | nodes.find((node) => node.id === edge.target)
58 | )
59 | ) {
60 | return null
61 | }
62 |
63 | return (
64 |
86 | )
87 | })}
88 |
89 |
90 | )
91 | }
92 |
93 | EdgeRenderer.displayName = 'EdgeRenderer'
94 |
95 | export default memo(EdgeRenderer)
96 |
--------------------------------------------------------------------------------
/src/container/Controls/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, memo, useCallback, useState, HTMLAttributes, CSSProperties } from 'react'
2 | import cn from 'classnames'
3 | import { useZoomHelper } from '@/hooks/useZoom'
4 | import useGlobal from '@/hooks/useGlobal'
5 | import fullscreen from '@/utils/fullscreen'
6 | import Icon from '@/components/Icon'
7 | import ControlButton from './ControlButton'
8 |
9 | import './index.less'
10 |
11 | export interface ControlProps extends HTMLAttributes {
12 | prefix?: string
13 | className?: string
14 | style?: CSSProperties
15 | /**
16 | * Control the display of zoom buttons
17 | * showZoom === false will hidden zoom buttons
18 | */
19 | showZoom?: boolean
20 | /**
21 | * Control the editor fullscreen
22 | * showFullscreen === false will hidden fullscreen button
23 | */
24 | showFullscreen?: boolean
25 | /**
26 | * Control the display of fitView button
27 | * showFitView === false will hidden scale button
28 | */
29 | showFitView?: boolean
30 | isLayout?: boolean
31 | /**
32 | * Control is it operational
33 | */
34 | showInteractive?: boolean
35 | }
36 |
37 | const Controls: FC = ({
38 | prefix = 'graph-editor-controls',
39 | className = '',
40 | style = {},
41 | showZoom = true,
42 | showFullscreen = true,
43 | showFitView = true,
44 | showInteractive = true,
45 | isLayout = false,
46 | children,
47 | }) => {
48 | const [global, setGlobal] = useGlobal()
49 | const [lock, setLock] = useState(false)
50 | const { zoomIn, zoomOut, fitView } = useZoomHelper()
51 |
52 | const onZoomInHandler = useCallback(() => {
53 | zoomIn()
54 | }, [zoomIn])
55 |
56 | const onZoomOutHandler = useCallback(() => {
57 | zoomOut()
58 | }, [zoomOut])
59 |
60 | const onFitView = useCallback(() => {
61 | fitView(isLayout)
62 | }, [fitView, isLayout])
63 |
64 | const onInteractive = useCallback(() => {
65 | setLock(!lock)
66 | setGlobal({
67 | draggable: lock,
68 | selectable: lock,
69 | connectable: lock,
70 | })
71 | }, [lock, setGlobal])
72 |
73 | const onFullscreen = useCallback(() => {
74 | if (global.graphElement) {
75 | fullscreen(global.graphElement)
76 | }
77 | }, [global.graphElement])
78 |
79 | return (
80 |
81 | {showZoom && (
82 | <>
83 |
84 |
85 |
86 |
87 |
88 |
89 | >
90 | )}
91 | {showFitView && (
92 |
93 |
94 |
95 | )}
96 | {showInteractive && (
97 |
98 |
99 |
100 | )}
101 | {showFullscreen && (
102 |
103 |
104 |
105 | )}
106 | {children}
107 |
108 | )
109 | }
110 |
111 | Controls.displayName = 'Controls'
112 |
113 | export default memo(Controls)
114 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties, ReactNode, MouseEvent, RefObject, ComponentType } from 'react'
2 | import dagre from 'dagre'
3 |
4 | export type ElementId = string
5 |
6 | export type Elements = {
7 | nodes?: Array>
8 | edges?: Array>
9 | }
10 |
11 | export type NodeTypes = Record>
12 | export type EdgeTypes = Record>
13 |
14 | export type NodeEventHandler = (event: MouseEvent, node?: Node) => void
15 | export type ElementsEventHandler = (element?: Elements) => void
16 | export type EdgeEventHandler = (event: MouseEvent, edge?: Edge) => void
17 | export type ConnectEventHandler = (event: MouseEvent, connection?: Connection) => void
18 |
19 | export type noop = () => void
20 |
21 | export interface XYPosition {
22 | x?: number
23 | y?: number
24 | }
25 |
26 | export interface ZoomTransform {
27 | x: number
28 | y: number
29 | k: number
30 | }
31 |
32 | export interface ZoomOptions {
33 | minZoom?: number
34 | maxZoom?: number
35 | trigger: RefObject
36 | effect?: RefObject
37 | }
38 |
39 | export interface ZoomHelper {
40 | zoomIn: noop
41 | zoomOut: noop
42 | zoomTo: (zoomLevel: number) => void
43 | transform: (transform: ZoomTransform) => void
44 | fitView: (isLayout: boolean) => void
45 | getGraph: () => dagre.graphlib.Graph
46 | }
47 |
48 | export type NodeExtra = {
49 | width: number
50 | height: number
51 | }
52 |
53 | export type NodeRect = {
54 | id: string
55 | } & NodeExtra
56 |
57 | export interface Node {
58 | id: ElementId
59 | position: XYPosition
60 | type?: string
61 | data?: T
62 | style?: CSSProperties
63 | className?: string
64 | visible?: boolean
65 | draggable?: boolean
66 | selectable?: boolean
67 | connectable?: boolean
68 | deletable?: boolean
69 | cancel?: string[]
70 | __extra?: NodeExtra
71 | }
72 |
73 | export enum ArrowType {
74 | arrow = 'arrow',
75 | arrowClosed = 'arrowclosed',
76 | }
77 |
78 | export interface Edge {
79 | id?: ElementId // edge need id?? or id === source_target
80 | type?: string
81 | source: ElementId
82 | target: ElementId
83 | label?: string | ReactNode
84 | style?: CSSProperties
85 | visible?: boolean
86 | data?: T
87 | className?: string
88 | lineType?: ConnectionLineType
89 | arrowType?: ArrowType
90 | selectable?: boolean
91 | deletable?: boolean
92 | }
93 |
94 | export enum PointerType {
95 | Input = 'input',
96 | Output = 'output',
97 | }
98 |
99 | export enum PointerPosition {
100 | Top = 'top',
101 | Left = 'left',
102 | Right = 'right',
103 | Bottom = 'bottom',
104 | }
105 |
106 | export enum ConnectionLineType {
107 | Bezier = 'bezier',
108 | Straight = 'straight',
109 | Smooth = 'smooth',
110 | }
111 |
112 | export interface Connection {
113 | source?: ElementId | null
114 | target?: ElementId | null
115 | }
116 |
117 | export type Dimensions = {
118 | width: number
119 | height: number
120 | }
121 |
122 | export type ConnectLine = {
123 | lineType?: ConnectionLineType
124 | arrowType?: ArrowType
125 | }
126 |
127 | export enum Orientation {
128 | LeftToRight = 'LR',
129 | RightToLeft = 'RL',
130 | TopToBottom = 'TB',
131 | BottomToTop = 'BT',
132 | }
133 |
134 | export type Rect = {
135 | width?: number
136 | height?: number
137 | left?: number
138 | top?: number
139 | }
140 |
141 | export enum Theme {
142 | Normal = 'normal',
143 | Primary = 'primary',
144 | }
145 |
--------------------------------------------------------------------------------
/src/container/MiniMap/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, CSSProperties, useRef, useMemo } from 'react'
2 | import useGlobal from '@/hooks/useGlobal'
3 | import { getFringeRect, getRectOfNodes } from '@/utils/dom'
4 | import cn from 'classnames'
5 | import { zoomIdentity } from 'd3-zoom'
6 | import { DraggableCore, DraggableEvent, DraggableData } from 'react-draggable'
7 | import { Node } from '@/types'
8 |
9 | import './index.less'
10 |
11 | const defaultWidth = 200
12 | const defaultHeight = 200
13 |
14 | export interface MiniMapProps {
15 | prefix?: string
16 | className?: string
17 | style?: CSSProperties
18 | draggable?: boolean
19 | minMapScale?: number
20 | }
21 |
22 | const MiniMap: FC = ({
23 | prefix = 'graph-editor-minimap',
24 | className = '',
25 | draggable = false,
26 | style = {},
27 | minMapScale = 2.5,
28 | }) => {
29 | const [global] = useGlobal()
30 | const operateRef = useRef()
31 | const graphRect = useMemo(() => getRectOfNodes(global.nodes, global.nodeRects), [
32 | global.nodes,
33 | global.nodeRects,
34 | ])
35 |
36 | if (!global?.container) {
37 | return null
38 | }
39 |
40 | const elementWidth = (style?.width || defaultWidth)! as number
41 | const elementHeight = (style?.height || defaultHeight)! as number
42 |
43 | const { width, height } = global.container
44 | const {
45 | viewWidth,
46 | viewHeight,
47 | offsetX,
48 | offsetY,
49 | wrapperHeight,
50 | wrapperWidth,
51 | graphScale,
52 | wrapperX,
53 | wrapperY,
54 | } = getFringeRect({
55 | minMapScale,
56 | width,
57 | height,
58 | elementWidth,
59 | elementHeight,
60 | graphRect,
61 | ...global,
62 | })
63 |
64 | return (
65 |
72 |
{
76 | const { deltaX, deltaY } = draggableData
77 |
78 | if (global.zoomInstance && global.zoomSelection) {
79 | const nextTransform = zoomIdentity
80 | .translate(
81 | global.transform.x - deltaX / graphScale,
82 | global.transform.y - deltaY / graphScale,
83 | )
84 | .scale(global.transform.k)
85 | global.zoomInstance.transform(global.zoomSelection, nextTransform)
86 | }
87 | }}
88 | grid={[1, 1]}
89 | >
90 |
100 |
101 |
102 |
107 | {global.nodes.map(({ position, id }: Node) => {
108 | const nodeRect = global.nodeRects?.find((rect) => rect.id === id)
109 | return (
110 |
120 | )
121 | })}
122 |
123 |
124 |
125 | )
126 | }
127 |
128 | MiniMap.displayName = 'MiniMap'
129 |
130 | export default MiniMap
131 |
--------------------------------------------------------------------------------
/src/container/NodeRenderer/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, memo, useCallback, useMemo, useRef } from 'react'
2 | import { GraphRendererProps } from '@/container/GraphRenderer'
3 | import NodeComponent from '@/components/Node'
4 | import { getVisibleNodes, mergeNodeTypes } from '@/utils/graph'
5 | import useGlobal from '@/hooks/useGlobal'
6 | import useNodeResizeObserver from '@/hooks/useNodeResizeObserver'
7 | import cloneDeep from 'lodash/cloneDeep'
8 | import { ZoomTransform, Node } from '@/types'
9 |
10 | import './index.less'
11 |
12 | export type NodeRendererProps = Pick<
13 | GraphRendererProps,
14 | | 'prefix'
15 | | 'nodeTypes'
16 | | 'onNodeClick'
17 | | 'onNodeDoubleClick'
18 | | 'onNodeDrag'
19 | | 'onNodeDragStart'
20 | | 'onNodeDragStop'
21 | | 'onNodeMouseEnter'
22 | | 'onNodeMouseLeave'
23 | | 'onNodeMouseMove'
24 | | 'onConnect'
25 | | 'onConnectStart'
26 | | 'onConnectStop'
27 | | 'onConnectEnd'
28 | | 'onElementChange'
29 | | 'orientation'
30 | > & {
31 | transform: ZoomTransform
32 | transformStyle: string
33 | }
34 |
35 | const NodeRenderer: FC = ({
36 | prefix = 'graph-editor-node-renderer',
37 | nodeTypes,
38 | onNodeClick,
39 | onNodeDoubleClick,
40 | onNodeDrag,
41 | onNodeDragStart,
42 | onNodeDragStop,
43 | onNodeMouseEnter,
44 | onNodeMouseLeave,
45 | onNodeMouseMove,
46 | onConnect,
47 | onConnectStart,
48 | onConnectStop,
49 | onConnectEnd,
50 | transformStyle,
51 | orientation,
52 | }) => {
53 | const observerRef = useRef<(element: HTMLElement) => void>()
54 | const [{ nodes, onElementChange }] = useGlobal()
55 |
56 | const visibleNodes = useMemo(() => getVisibleNodes(nodes), [nodes])
57 |
58 | const mergedNodeTypes = useMemo(() => mergeNodeTypes(nodeTypes), [nodeTypes])
59 |
60 | observerRef.current = useNodeResizeObserver()
61 |
62 | const onNodeChange = useCallback(
63 | (id, newValue, force = true) => {
64 | const cloneNodes: Node[] = cloneDeep(nodes)
65 | const findIndex = cloneNodes.findIndex((node) => node.id === id)
66 |
67 | if (force) {
68 | cloneNodes[findIndex].data = newValue
69 | } else {
70 | cloneNodes[findIndex].data = Object.assign(cloneNodes[findIndex].data, newValue)
71 | }
72 |
73 | onElementChange({
74 | nodes: cloneNodes,
75 | })
76 | },
77 | [nodes, onElementChange],
78 | )
79 |
80 | return (
81 |
82 | {visibleNodes.map((node, index) => {
83 | const Component = mergedNodeTypes[node.type] || mergedNodeTypes.default
84 |
85 | return (
86 | {
114 | onNodeChange(node.id, ...args)
115 | }}
116 | orientation={orientation}
117 | observer={observerRef.current}
118 | />
119 | )
120 | })}
121 |
122 | )
123 | }
124 |
125 | NodeRenderer.displayName = 'NodeRenderer'
126 |
127 | export default memo(NodeRenderer)
128 |
--------------------------------------------------------------------------------
/src/components/Pointer/Wrapper.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | FC,
3 | useMemo,
4 | useRef,
5 | useCallback,
6 | MouseEvent,
7 | useEffect,
8 | memo,
9 | PropsWithChildren,
10 | } from 'react'
11 | import useGlobal from '@/hooks/useGlobal'
12 | import useTheme from '@/hooks/useTheme'
13 | import cn from 'classnames'
14 | import { getDocument } from '@/utils/graph'
15 | import { addEventListener, checkLineEndPoint } from '@/utils/dom'
16 | import { PointerProps } from '@/components/Pointer'
17 | import { XYPosition } from '@/types'
18 |
19 | export type PointerWrapperProps = PropsWithChildren>
20 |
21 | /**
22 | * Give the ability to drag
23 | */
24 | const Wrapper: FC = ({
25 | prefix = 'graph-editor-pointer-wrapper',
26 | children,
27 | position,
28 | type,
29 | nodePosition,
30 | node,
31 | onConnect,
32 | onConnectStart,
33 | onConnectEnd,
34 | onConnectStop,
35 | connectable,
36 | }) => {
37 | const [global, setGlobal] = useGlobal()
38 | const theme = useTheme()
39 | const sourceRef = useRef({})
40 | const mousemoveListener = useRef({ remove: () => {} })
41 | const mouseupListener = useRef({ remove: () => {} })
42 |
43 | const onDestoryListener = useCallback(() => {
44 | mousemoveListener.current?.remove()
45 | mouseupListener.current?.remove()
46 | }, [])
47 |
48 | const onMouseDown = useCallback(
49 | (e: MouseEvent) => {
50 | if (!connectable) {
51 | return
52 | }
53 |
54 | const doc = getDocument(e.target as HTMLElement)
55 | if (!doc) {
56 | return
57 | }
58 |
59 | const nodeTarget = (e.target as Element).closest('.graph-editor-node-wrapper')
60 | const nodeBounds = nodeTarget.getBoundingClientRect()
61 |
62 | sourceRef.current = {
63 | x: nodePosition.x + (e.clientX - nodeBounds.left) / global.transform.k,
64 | y: nodePosition.y + (e.clientY - nodeBounds.top) / global.transform.k,
65 | }
66 |
67 | setGlobal({
68 | connectLine: {
69 | isConnecting: false,
70 | source: sourceRef.current,
71 | target: {},
72 | },
73 | })
74 |
75 | onConnectStart?.(e, { source: node.id })
76 |
77 | function onMouseMove(ee) {
78 | setGlobal({
79 | connectLine: {
80 | isConnecting: true,
81 | source: sourceRef.current,
82 | target: {
83 | x: nodePosition.x + (ee.clientX - nodeBounds.left) / global.transform.k,
84 | y: nodePosition.y + (ee.clientY - nodeBounds.top) / global.transform.k,
85 | },
86 | },
87 | })
88 | }
89 |
90 | function onMouseUp(ee) {
91 | setGlobal({
92 | connectLine: {
93 | isConnecting: false,
94 | source: {},
95 | target: {},
96 | },
97 | })
98 | sourceRef.current = {}
99 | const { isHit, target } = checkLineEndPoint({
100 | e: ee,
101 | doc,
102 | node,
103 | edges: global.edges,
104 | })
105 |
106 | onConnectStop?.(ee, { source: node.id })
107 |
108 | if (isHit && target) {
109 | onConnect?.(ee, { source: node.id, target })
110 | global.onElementChange({
111 | edges: [...global.edges, { source: node.id, target }],
112 | })
113 | }
114 |
115 | onConnectEnd?.(ee, { source: node.id })
116 |
117 | onDestoryListener()
118 | }
119 |
120 | mousemoveListener.current = addEventListener(doc, 'mousemove', onMouseMove)
121 | mouseupListener.current = addEventListener(doc, 'mouseup', onMouseUp)
122 | },
123 | [
124 | connectable,
125 | onDestoryListener,
126 | setGlobal,
127 | nodePosition.x,
128 | nodePosition.y,
129 | node,
130 | global,
131 | onConnect,
132 | onConnectStart,
133 | onConnectStop,
134 | onConnectEnd,
135 | ],
136 | )
137 |
138 | useEffect(() => {
139 | return onDestoryListener
140 | }, [onDestoryListener])
141 |
142 | const handleClassName = useMemo(() => {
143 | return cn([prefix, theme, 'nodrag', `${prefix}-${position}`, `${prefix}-${type}`], { connectable })
144 | }, [prefix, position, type, connectable, theme])
145 |
146 | return (
147 |
155 | {children}
156 |
157 | )
158 | }
159 |
160 | export default memo(Wrapper)
161 |
--------------------------------------------------------------------------------
/src/components/Edge/Wrapper.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | FC,
3 | CSSProperties,
4 | ReactElement,
5 | Children,
6 | cloneElement,
7 | memo,
8 | PropsWithChildren,
9 | useCallback,
10 | useMemo,
11 | } from 'react'
12 | import { EdgeComponentProps } from '@/components/Edge'
13 | import usePosition from '@/hooks/usePosition'
14 | import useDimension from '@/hooks/useDimension'
15 | import useClickPreventionOnDoubleClick from '@/hooks/useClickPreventionOnDoubleClick'
16 | import useGlobal from '@/hooks/useGlobal'
17 | import useTheme from '@/hooks/useTheme'
18 | import cn from 'classnames'
19 | import { getPositionByOrientation } from '@/utils/graph'
20 |
21 | export type WrapperProps = PropsWithChildren>
22 |
23 | const Wrapper: FC = ({
24 | prefix = 'graph-editor-edge-wrapper',
25 | edge,
26 | style = {},
27 | className = '',
28 | selectable = true,
29 | children,
30 | onEdgeClick,
31 | onEdgeDoubleClick,
32 | onEdgeMouseEnter,
33 | onEdgeMouseMove,
34 | onEdgeMouseLeave,
35 | arrowType,
36 | orientation,
37 | }) => {
38 | const [global, setGlobal] = useGlobal()
39 | const theme = useTheme()
40 | const { source, target } = edge
41 |
42 | const sourcePosition = usePosition(source)
43 | const sourceDimension = useDimension(source)
44 | const targetPosition = usePosition(target)
45 | const targetDimension = useDimension(target)
46 |
47 | const onClick = useCallback(
48 | (e) => {
49 | e.stopPropagation()
50 | onEdgeClick?.(e, edge)
51 | if (
52 | selectable &&
53 | global.selectable &&
54 | (global.selectEdge?.source !== edge.source || global.selectEdge?.target !== edge.target)
55 | ) {
56 | setGlobal({
57 | selectEdge: edge,
58 | selectNode: {},
59 | })
60 | }
61 | },
62 | [onEdgeClick, edge, global.selectEdge, setGlobal, selectable, global.selectable],
63 | )
64 |
65 | const onDoubleClick = useCallback(
66 | (e) => {
67 | e.stopPropagation()
68 | onEdgeDoubleClick?.(e, edge)
69 | },
70 | [onEdgeDoubleClick, edge],
71 | )
72 |
73 | const onMouseEnter = useCallback(
74 | (e) => {
75 | e.stopPropagation()
76 | onEdgeMouseEnter?.(e, edge)
77 | },
78 | [onEdgeMouseEnter, edge],
79 | )
80 |
81 | const onMouseMove = useCallback(
82 | (e) => {
83 | e.stopPropagation()
84 | onEdgeMouseMove?.(e, edge)
85 | },
86 | [onEdgeMouseMove, edge],
87 | )
88 |
89 | const onMouseLeave = useCallback(
90 | (e) => {
91 | e.stopPropagation()
92 | onEdgeMouseLeave?.(e, edge)
93 | },
94 | [onEdgeMouseLeave, edge],
95 | )
96 |
97 | const edgeStyle = useMemo(
98 | () => ({
99 | ...style,
100 | pointerEvents:
101 | (selectable && global.selectable) ||
102 | onEdgeClick ||
103 | onEdgeDoubleClick ||
104 | onEdgeMouseEnter ||
105 | onEdgeMouseMove ||
106 | onEdgeMouseLeave
107 | ? 'all'
108 | : 'none',
109 | }),
110 | [
111 | selectable,
112 | global.selectable,
113 | style,
114 | onEdgeClick,
115 | onEdgeDoubleClick,
116 | onEdgeMouseEnter,
117 | onEdgeMouseMove,
118 | onEdgeMouseLeave,
119 | ],
120 | )
121 |
122 | const [handleClick, handleDoubleClick] = useClickPreventionOnDoubleClick(onClick, onDoubleClick)
123 |
124 | const isSelected = useMemo(
125 | () => global.selectEdge?.source === source && global.selectEdge?.target === target,
126 | [global.selectEdge, source, target],
127 | )
128 |
129 | if (
130 | !(sourceDimension.width && sourceDimension.height) ||
131 | !(targetDimension.width && targetDimension.height)
132 | ) {
133 | return null
134 | }
135 |
136 | return (
137 |
149 | {cloneElement(Children.only(children) as ReactElement, {
150 | ...getPositionByOrientation({
151 | sourcePosition,
152 | sourceDimension,
153 | targetPosition,
154 | targetDimension,
155 | arrowType,
156 | orientation,
157 | }),
158 | isSelected,
159 | })}
160 |
161 | )
162 | }
163 |
164 | Wrapper.displayName = 'EdgeWrapper'
165 |
166 | export default memo(Wrapper)
167 |
--------------------------------------------------------------------------------
/src/hooks/useZoom.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useCallback } from 'react'
2 | import { zoom, zoomIdentity } from 'd3-zoom'
3 | import { select } from 'd3-selection'
4 | import dagre from 'dagre'
5 | import useGlobal from '@/hooks/useGlobal'
6 | import { getRectOfNodes } from '@/utils/dom'
7 | import { ZoomOptions, ZoomTransform, ZoomHelper } from '@/types'
8 |
9 | const DEFAULT_MIN_ZOOM = 0.5
10 | const DEFAULT_MAX_ZOOM = 2
11 |
12 | export type UseZoomProps = {
13 | transform: ZoomTransform
14 | transformStyle: string
15 | }
16 |
17 | export default function useZoom(zoomOption?: ZoomOptions): UseZoomProps {
18 | const { minZoom, maxZoom, trigger, effect } = zoomOption || {}
19 | const [global, setGlobal] = useGlobal()
20 |
21 | useEffect(() => {
22 | if (trigger && trigger.current) {
23 | const zoomInstance = zoom()
24 | // set scale range
25 | .scaleExtent([minZoom || DEFAULT_MIN_ZOOM, maxZoom || DEFAULT_MAX_ZOOM])
26 | // bind event
27 | .on('zoom', ({ transform }) => {
28 | setGlobal({ transform })
29 | })
30 |
31 | const zoomSelection = select(trigger.current as Element).call(zoomInstance)
32 | setGlobal({ zoomInstance, zoomSelection })
33 | }
34 | }, [minZoom, maxZoom, trigger, setGlobal])
35 |
36 | useEffect(() => {
37 | if (effect && effect.current) {
38 | const { x, y, k } = global.transform
39 | effect.current.style.transform = `translate(${x}px,${y}px) scale(${k})`
40 | }
41 | }, [global.transform, effect])
42 |
43 | return {
44 | transform: global.transform,
45 | transformStyle: `translate(${global.transform.x}px,${global.transform.y}px) scale(${global.transform.k})`,
46 | }
47 | }
48 |
49 | export function useZoomHelper(): ZoomHelper {
50 | const [global, setGlobal] = useGlobal()
51 | const { zoomInstance, zoomSelection, nodes, container, nodeRects } = global
52 |
53 | const updateTransform = useCallback(
54 | (transform: ZoomTransform) => {
55 | if (zoomInstance && zoomSelection) {
56 | const nextTransform = zoomIdentity.translate(transform.x, transform.y).scale(transform.k)
57 | zoomInstance.transform(zoomSelection, nextTransform)
58 | }
59 | },
60 | [zoomInstance, zoomSelection],
61 | )
62 |
63 | const getGraph = useCallback(() => {
64 | const graph = new dagre.graphlib.Graph()
65 | graph.setDefaultEdgeLabel(() => ({}))
66 | graph.setGraph({ rankdir: global.orientation })
67 |
68 | global.nodes?.forEach(({ id, __extra }) => {
69 | graph.setNode(id, __extra)
70 | })
71 |
72 | global.edges?.forEach(({ source, target }) => {
73 | graph.setEdge(source, target)
74 | })
75 |
76 | return graph
77 | }, [global.orientation, global.nodes, global.edges])
78 |
79 | const helper = useMemo(() => {
80 | if (zoomInstance && zoomSelection) {
81 | return {
82 | zoomIn: () => zoomInstance.scaleBy(zoomSelection, 1.2),
83 | zoomOut: () => zoomInstance.scaleBy(zoomSelection, 1 / 1.2),
84 | zoomTo: (zoomLevel: number) => zoomInstance.scaleTo(zoomSelection, zoomLevel),
85 | transform: updateTransform,
86 | getGraph,
87 | fitView: (isLayout: boolean) => {
88 | if (!(nodes && nodes.length)) {
89 | return
90 | }
91 |
92 | let layoutNodes = nodes
93 |
94 | if (isLayout) {
95 | const graph = getGraph()
96 | dagre.layout(graph)
97 | layoutNodes = nodes.map((node) => {
98 | const graphPosition = graph.node(node.id)
99 | return {
100 | ...node,
101 | position: {
102 | x: graphPosition.x,
103 | y: graphPosition.y,
104 | },
105 | __extra: graphPosition,
106 | }
107 | })
108 | setGlobal({ nodes: layoutNodes })
109 | }
110 |
111 | const graphRect = getRectOfNodes(layoutNodes, nodeRects)
112 |
113 | const scale =
114 | Math.min(container.width / graphRect.width, container.height / graphRect.height) / 1.2
115 | const realGraphWidth = graphRect.width * scale
116 | const realGraphHeight = graphRect.height * scale
117 |
118 | const offsetX = graphRect.left * scale + (realGraphWidth - container.width) / 2
119 | const offsetY = graphRect.top * scale + (realGraphHeight - container.height) / 2
120 |
121 | updateTransform({ x: -offsetX, y: -offsetY, k: scale })
122 | },
123 | }
124 | }
125 |
126 | return {
127 | zoomIn: () => {},
128 | zoomOut: () => {},
129 | zoomTo: () => {},
130 | transform: () => {},
131 | fitView: () => {},
132 | getGraph: () => null,
133 | }
134 | }, [
135 | zoomInstance,
136 | zoomSelection,
137 | nodes,
138 | updateTransform,
139 | container?.width,
140 | container?.height,
141 | nodeRects,
142 | getGraph,
143 | setGlobal,
144 | ])
145 |
146 | return helper
147 | }
148 |
--------------------------------------------------------------------------------
/src/utils/dom.ts:
--------------------------------------------------------------------------------
1 | import { MouseEvent } from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { Node, PointerType, Edge, ZoomTransform, Rect, NodeRect } from '@/types'
4 |
5 | export function addEventListener(
6 | target: HTMLElement | Document | ShadowRoot | Window,
7 | eventType: keyof HTMLElementEventMap,
8 | cb: EventListener,
9 | option?,
10 | ) {
11 | const callback: EventListener = ReactDOM.unstable_batchedUpdates
12 | ? function run(e) {
13 | ReactDOM.unstable_batchedUpdates(cb, e)
14 | }
15 | : cb
16 | if (target.addEventListener) {
17 | target.addEventListener(eventType, callback, option)
18 | }
19 | return {
20 | remove: () => {
21 | if (target.removeEventListener) {
22 | target.removeEventListener(eventType, callback)
23 | }
24 | },
25 | }
26 | }
27 |
28 | export type CheckLineEndPointParams = {
29 | doc: Document | ShadowRoot
30 | e: MouseEvent
31 | node: Node
32 | edges: Edge[]
33 | }
34 |
35 | export function checkLineEndPoint({ doc, e, node, edges }: CheckLineEndPointParams) {
36 | const targetElements = (doc as Document).elementFromPoint(e.clientX, e.clientY)
37 | const targetType = targetElements.getAttribute('data-type')
38 | const targetNodeId = targetElements.getAttribute('data-node-id')
39 | const connectable = targetElements.getAttribute('data-node-connectable') === '1'
40 |
41 | // does't hit
42 | if (targetType !== PointerType.Input) {
43 | return {
44 | isHit: false,
45 | }
46 | }
47 |
48 | // connectable = false
49 | if (!connectable) {
50 | return {
51 | isHit: false,
52 | }
53 | }
54 |
55 | // hit self
56 | if (!targetNodeId || node.id === targetNodeId) {
57 | return {
58 | isHit: false,
59 | }
60 | }
61 |
62 | // hit other but is being connected, can't connect again
63 | const isConnected = edges.some(
64 | ({ source, target }) =>
65 | (source === node.id && target === targetNodeId) || (target === node.id && source === targetNodeId),
66 | )
67 | if (isConnected) {
68 | return {
69 | isHit: false,
70 | }
71 | }
72 |
73 | return {
74 | isHit: true,
75 | target: targetNodeId,
76 | }
77 | }
78 |
79 | // export type GetFringeRectParams = {
80 | // nodes: Node[]
81 | // transform: ZoomTransform
82 | // width: number
83 | // height: number
84 | // elementWidth: number
85 | // elementHeight: number
86 | // }
87 |
88 | export type GetFringeRectParams = {
89 | nodes: Node[]
90 | transform: ZoomTransform
91 | width: number
92 | height: number
93 | elementWidth: number
94 | elementHeight: number
95 | minMapScale: number
96 | graphRect: Rect & { right?: number; bottom?: number }
97 | }
98 |
99 | export function getFringeRect({
100 | transform,
101 | width,
102 | height,
103 | elementWidth,
104 | elementHeight,
105 | minMapScale,
106 | graphRect,
107 | }: GetFringeRectParams) {
108 | const viewWidth = width * minMapScale
109 | const viewHeight = height * minMapScale
110 | const scale = Math.min(elementWidth / viewWidth, elementHeight / viewHeight)
111 | const viewOffsetX = (elementWidth - viewWidth * scale) / 2
112 | const viewOffsetY = (elementHeight - viewHeight * scale) / 2
113 | const graphScale = scale / transform.k
114 | const wrapperWidth = (width * scale) / transform.k
115 | const wrapperHeight = (height * scale) / transform.k
116 | const offsetX = (viewWidth - graphRect.width) / 2
117 | const offsetY = (viewHeight - graphRect.height) / 2
118 | const wrapperX = (offsetX - graphRect.left) * scale - transform.x * graphScale + viewOffsetX
119 | const wrapperY = (offsetY - graphRect.top) * scale - transform.y * graphScale + viewOffsetY
120 |
121 | return {
122 | viewWidth,
123 | viewHeight,
124 | wrapperWidth,
125 | wrapperHeight,
126 | wrapperX,
127 | wrapperY,
128 | offsetX,
129 | offsetY,
130 | graphScale,
131 | scale,
132 | }
133 | }
134 |
135 | const DEFAULT_GRAPH_RECT = {
136 | top: Infinity,
137 | left: Infinity,
138 | right: -Infinity,
139 | bottom: -Infinity,
140 | }
141 |
142 | export function getRectOfNodes(
143 | nodes: Node[],
144 | nodeRects: NodeRect[],
145 | ): Rect & { right?: number; bottom?: number } {
146 | if (!(nodes && nodes.length)) {
147 | return {
148 | top: 0,
149 | left: 0,
150 | width: 0,
151 | height: 0,
152 | }
153 | }
154 |
155 | const fringePoints = nodes?.reduce(
156 | (pre, curv: Node) => ({
157 | top: Math.min(pre.top, curv.position?.y || 0),
158 | bottom: Math.max(
159 | pre.bottom,
160 | (curv.position?.y || 0) + (nodeRects?.find((rect) => rect.id === curv.id)?.height || 0),
161 | ),
162 | left: Math.min(pre.left, curv.position?.x || 0),
163 | right: Math.max(
164 | pre.right,
165 | (curv.position?.x || 0) + (nodeRects?.find((rect) => rect.id === curv.id)?.width || 0),
166 | ),
167 | }),
168 | DEFAULT_GRAPH_RECT,
169 | )
170 |
171 | return {
172 | ...fringePoints,
173 | width: fringePoints.right - fringePoints.left,
174 | height: fringePoints.bottom - fringePoints.top,
175 | }
176 | }
177 |
178 | export function getBoundsofRects(rects: Rect[]) {
179 | const left = Math.min(...rects.map((item) => item.left))
180 | const top = Math.min(...rects.map((item) => item.top))
181 | const right = Math.max(...rects.map((item) => item.left + item.width))
182 | const bottom = Math.max(...rects.map((item) => item.height))
183 |
184 | return {
185 | left,
186 | top,
187 | width: right - left,
188 | height: bottom - top,
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/src/container/GraphEditor/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef, useImperativeHandle, CSSProperties, PropsWithChildren } from 'react'
2 | import cn from 'classnames'
3 | import GlobalProvider from '@/container/GlobalProvider'
4 | import GraphRenderer from '@/container/GraphRenderer'
5 | import { useZoomHelper } from '@/hooks/useZoom'
6 | import {
7 | Elements,
8 | NodeTypes,
9 | EdgeTypes,
10 | NodeEventHandler,
11 | ConnectEventHandler,
12 | EdgeEventHandler,
13 | ConnectLine,
14 | Orientation,
15 | Theme,
16 | ElementsEventHandler,
17 | ZoomHelper,
18 | } from '@/types'
19 |
20 | import './index.less'
21 | import '../../styles/index.less'
22 |
23 | export interface GraphEditorProps {
24 | prefix?: string
25 | style?: CSSProperties
26 | className?: string
27 | defaultElements?: Elements
28 | /**
29 | * elements,collect by nodes and edges
30 | * @example
31 | * ```
32 | * const elements = {
33 | * nodes: [],
34 | * edges: []
35 | * }
36 | * ```
37 | */
38 | elements?: Elements
39 | /**
40 | * nodeType collect
41 | * @example
42 | * ```
43 | * import CustomNode from '../CustomNode'
44 | * const nodeTypes = {
45 | * nodeType1: CustomNode,
46 | * ...other
47 | * }
48 | * ```
49 | */
50 | nodeTypes?: NodeTypes
51 | /**
52 | * lineType collect
53 | * @example
54 | * ```
55 | * import CustomEdge from '../CustomEdge'
56 | * const edgeTypes = {
57 | * edgeType1: CustomEdge,
58 | * ...other
59 | * }
60 | * ```
61 | */
62 | edgeTypes?: EdgeTypes
63 | /**
64 | * draggable
65 | */
66 | draggable?: boolean
67 | /**
68 | * selectable
69 | */
70 | selectable?: boolean
71 | /**
72 | * connectable
73 | */
74 | connectable?: boolean
75 | /**
76 | * deletable
77 | */
78 | deletable?: boolean
79 | /**
80 | * orientation
81 | * @enum 'lr' | 'rl' | 'tb' | 'bt'
82 | */
83 | orientation?: Orientation
84 | /**
85 | * theme
86 | * @enum 'normal' | 'primary'
87 | */
88 | theme?: Theme
89 | /**
90 | * node event: onMouseEnter
91 | */
92 | onNodeMouseEnter?: NodeEventHandler
93 | /**
94 | * node event: onMouseMove
95 | */
96 | onNodeMouseMove?: NodeEventHandler
97 | /**
98 | * node event: onMouseLeave
99 | */
100 | onNodeMouseLeave?: NodeEventHandler
101 | /**
102 | * node event: onClick
103 | */
104 | onNodeClick?: NodeEventHandler
105 | /**
106 | * node event: onDoubleClick
107 | */
108 | onNodeDoubleClick?: NodeEventHandler
109 | /**
110 | * node event: onDragStart
111 | */
112 | onNodeDragStart?: NodeEventHandler
113 | /**
114 | * node event: onDrag
115 | */
116 | onNodeDrag?: NodeEventHandler
117 | /**
118 | * node event: onDragStop
119 | */
120 | onNodeDragStop?: NodeEventHandler
121 | /**
122 | * node event: onDelete
123 | */
124 | onNodeDelete?: NodeEventHandler
125 | /**
126 | * connect line props
127 | */
128 | connectLine?: ConnectLine
129 | /**
130 | * connect event: onConnect
131 | */
132 | onConnect?: ConnectEventHandler
133 | /**
134 | * connect event: onStart
135 | */
136 | onConnectStart?: ConnectEventHandler
137 | /**
138 | * connect event: onStop
139 | */
140 | onConnectStop?: ConnectEventHandler
141 | /**
142 | * connect event: onEnd
143 | */
144 | onConnectEnd?: ConnectEventHandler
145 | /**
146 | * edge event: onClick
147 | */
148 | onEdgeClick?: EdgeEventHandler
149 | /**
150 | * edge event: onDoubleClick
151 | */
152 | onEdgeDoubleClick?: EdgeEventHandler
153 | /**
154 | * edge event: onMouseEnter
155 | */
156 | onEdgeMouseEnter?: EdgeEventHandler
157 | /**
158 | * edge event: onMouseMove
159 | */
160 | onEdgeMouseMove?: EdgeEventHandler
161 | /**
162 | * edge event: onMouseLeave
163 | */
164 | onEdgeMouseLeave?: EdgeEventHandler
165 | /**
166 | * edge event: onDelete
167 | */
168 | onEdgeDelete?: EdgeEventHandler
169 | /**
170 | * element event: onChange
171 | */
172 | onElementChange?: ElementsEventHandler
173 | }
174 |
175 | export type GraphEditorRefProps = Pick
176 |
177 | const GraphEditor = forwardRef>(
178 | (
179 | {
180 | prefix = 'graph-editor',
181 | style = {},
182 | className,
183 | defaultElements = {},
184 | elements,
185 | nodeTypes,
186 | edgeTypes,
187 | onNodeMouseEnter,
188 | onNodeMouseMove,
189 | onNodeMouseLeave,
190 | onNodeClick,
191 | onNodeDoubleClick,
192 | onNodeDragStart,
193 | onNodeDrag,
194 | onNodeDragStop,
195 | onNodeDelete,
196 | onConnect,
197 | onConnectStart,
198 | onConnectStop,
199 | onConnectEnd,
200 | onEdgeClick,
201 | onEdgeDoubleClick,
202 | onEdgeMouseEnter,
203 | onEdgeMouseMove,
204 | onEdgeMouseLeave,
205 | onElementChange,
206 | onEdgeDelete,
207 | draggable = true,
208 | selectable = true,
209 | connectable = true,
210 | deletable = true,
211 | orientation = Orientation.TopToBottom,
212 | connectLine,
213 | theme = Theme.Primary,
214 | children,
215 | },
216 | ref,
217 | ) => {
218 | const { getGraph } = useZoomHelper()
219 |
220 | useImperativeHandle(
221 | ref,
222 | () => ({
223 | getGraph,
224 | }),
225 | [getGraph],
226 | )
227 |
228 | return (
229 |
230 |
246 |
273 | {children}
274 |
275 |
276 |
277 | )
278 | },
279 | )
280 |
281 | GraphEditor.displayName = 'GraphEditor'
282 |
283 | export default GraphEditor
284 |
--------------------------------------------------------------------------------
/src/components/Node/Wrapper.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | FC,
3 | useMemo,
4 | useRef,
5 | CSSProperties,
6 | useCallback,
7 | MouseEvent,
8 | memo,
9 | PropsWithChildren,
10 | useEffect,
11 | } from 'react'
12 | import { DraggableCore, DraggableEvent, DraggableData } from 'react-draggable'
13 | import useGlobal from '@/hooks/useGlobal'
14 | import Pointer from '@/components/Pointer'
15 | import { updateNodePosition, getPointerPositionByOrientation } from '@/utils/graph'
16 | import useDimension from '@/hooks/useDimension'
17 | import useClickPreventionOnDoubleClick from '@/hooks/useClickPreventionOnDoubleClick'
18 | import useTheme from '@/hooks/useTheme'
19 | import { NodeComponentProps } from '@/components/Node'
20 | import cn from 'classnames'
21 | import { PointerType } from '@/types'
22 |
23 | import './index.less'
24 |
25 | export type NodeWrapperProps = PropsWithChildren>
26 |
27 | /**
28 | * Give the ability to drag
29 | */
30 | const Wrapper: FC = ({
31 | prefix = 'graph-editor-node-wrapper',
32 | id,
33 | node,
34 | children,
35 | draggable = true,
36 | selectable = true,
37 | connectable = true,
38 | onNodeClick,
39 | onNodeDoubleClick,
40 | onNodeMouseEnter,
41 | onNodeMouseMove,
42 | onNodeMouseLeave,
43 | onNodeDrag,
44 | onNodeDragStart,
45 | onNodeDragStop,
46 | onConnect,
47 | onConnectStart,
48 | onConnectStop,
49 | onConnectEnd,
50 | orientation,
51 | style = {},
52 | cancel = [],
53 | observer,
54 | }) => {
55 | const theme = useTheme()
56 | const nodeRef = useRef(null)
57 | const [global, setGlobal] = useGlobal()
58 | const nodeDimension = useDimension(id)
59 |
60 | const onClick = useCallback(
61 | (e) => {
62 | if (selectable && global.selectable && global.selectNode?.id !== node.id) {
63 | setGlobal({
64 | selectNode: node,
65 | selectEdge: {},
66 | })
67 | }
68 | onNodeClick?.(e, node)
69 | },
70 | [selectable, global.selectable, global.selectNode?.id, node, onNodeClick, setGlobal],
71 | )
72 |
73 | const onDragStart = useCallback(
74 | (event: DraggableEvent) => {
75 | onNodeDragStart?.(event as MouseEvent, node)
76 | onClick?.(event as MouseEvent)
77 | },
78 | [onNodeDragStart, node, onClick],
79 | )
80 |
81 | const onDrag = useCallback(
82 | (event: DraggableEvent, draggableData: DraggableData) => {
83 | // todo don't handle onClick
84 | if (onNodeDrag) {
85 | onNodeDrag(event as MouseEvent, {
86 | ...node,
87 | position: {
88 | x: node.position.x + draggableData.deltaX,
89 | y: node.position.y + draggableData.deltaY,
90 | },
91 | })
92 | }
93 |
94 | const nodes = updateNodePosition({
95 | id,
96 | nodes: global.nodes,
97 | diff: {
98 | x: draggableData.deltaX,
99 | y: draggableData.deltaY,
100 | },
101 | })
102 |
103 | global.onElementChange({ nodes })
104 | },
105 | [onNodeDrag, id, global, node],
106 | )
107 |
108 | const onDragStop = useCallback(
109 | (event: DraggableEvent) => {
110 | onNodeDragStop?.(event as MouseEvent, node)
111 | },
112 | [onNodeDragStop, node],
113 | )
114 |
115 | const onDoubleClick = useCallback(
116 | (e: MouseEvent) => {
117 | e.stopPropagation()
118 | onNodeDoubleClick?.(e, node)
119 | },
120 | [onNodeDoubleClick, node],
121 | )
122 |
123 | const onMouseEnter = useCallback(
124 | (e: MouseEvent) => {
125 | e.stopPropagation()
126 | onNodeMouseEnter?.(e, node)
127 | },
128 | [onNodeMouseEnter, node],
129 | )
130 |
131 | const onMouseMove = useCallback(
132 | (e: MouseEvent) => {
133 | onNodeMouseMove?.(e, node)
134 | },
135 | [onNodeMouseMove, node],
136 | )
137 |
138 | const onMouseLeave = useCallback(
139 | (e: MouseEvent) => {
140 | e.stopPropagation()
141 | onNodeMouseLeave?.(e, node)
142 | },
143 | [onNodeMouseLeave, node],
144 | )
145 |
146 | const nodeStyle: CSSProperties = useMemo(
147 | () => ({
148 | opacity: nodeDimension.width && nodeDimension.height ? '1' : '0',
149 | transform: `translate(${node.position.x}px,${node.position.y}px)`,
150 | pointerEvents:
151 | (selectable && global.selectable) ||
152 | (connectable && global.connectable) ||
153 | (draggable && global.draggable) ||
154 | onNodeClick ||
155 | onNodeDoubleClick ||
156 | onNodeMouseEnter ||
157 | onNodeMouseMove ||
158 | onNodeMouseLeave
159 | ? 'all'
160 | : 'none',
161 | zIndex: global.selectNode?.id === node.id ? 1 : 'unset',
162 | ...style,
163 | }),
164 | [
165 | global.selectNode?.id,
166 | node.id,
167 | nodeDimension.width,
168 | nodeDimension.height,
169 | node.position,
170 | style,
171 | selectable,
172 | global.selectable,
173 | draggable,
174 | global.draggable,
175 | connectable,
176 | global.connectable,
177 | onNodeClick,
178 | onNodeDoubleClick,
179 | onNodeMouseEnter,
180 | onNodeMouseMove,
181 | onNodeMouseLeave,
182 | ],
183 | )
184 |
185 | const [handleClick, handleDoubleClick] = useClickPreventionOnDoubleClick(onClick, onDoubleClick)
186 |
187 | useEffect(() => {
188 | observer(nodeRef.current)
189 | }, [observer])
190 |
191 | const pointerPositions = getPointerPositionByOrientation(orientation)
192 |
193 | return (
194 |
205 | {
215 | if (draggable && global.draggable) {
216 | return
217 | }
218 | handleClick(e)
219 | }}
220 | onDoubleClick={handleDoubleClick}
221 | onMouseEnter={onMouseEnter}
222 | onMouseMove={onMouseMove}
223 | onMouseLeave={onMouseLeave}
224 | data-node-id={node.id}
225 | >
226 | {children}
227 | {connectable && global.connectable && (
228 | <>
229 |
241 |
253 | >
254 | )}
255 |
256 |
257 | )
258 | }
259 |
260 | export default memo(Wrapper)
261 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### Getting Started
2 |
3 | Before you can start to use Graph Editor you need to install `@ols-scripts/graph-editor`:
4 |
5 | ### Installation
6 |
7 | **npm:**
8 |
9 | ```bash
10 | npm install @ols-scripts/graph-editor
11 | ```
12 |
13 | **yarn:**
14 |
15 | ```bash
16 | yarn add @ols-scripts/graph-editor
17 | ```
18 |
19 | ### Usage
20 |
21 | This is a very basic example of how to use Graph Editor. A flow consists of nodes and edges (or just nodes). Together we call them elements. You can pass a set of elements as a prop to the GraphEditor component. Hereby all elements need unique ids. A node needs a position and a label and an edge needs a source (node id) and a target (node id). This is the most basic for a flow. A simple flow could look like this:
22 |
23 | ```tsx
24 | import React, { useState, useCallback } from 'react';
25 | import GraphEditor, { Controls, Background } from '@ols-scripts/graph-editor';
26 |
27 | const initElements = {
28 | nodes: [{
29 | id: '1',
30 | type: 'CustomNode',
31 | data: {
32 | label: (
33 | <>
34 | This is a Custom Node!
35 | >
36 | ),
37 | },
38 | cancel: ['textarea'],
39 | style: {
40 | width: '200px'
41 | },
42 | position: { x: 250, y: 20 },
43 | },
44 | {
45 | id: '2',
46 | data: {
47 | label: (
48 | <>
49 | This is a default node
50 | >
51 | ),
52 | },
53 | position: { x: 200, y: 400 },
54 | }],
55 | edges: [
56 | {
57 | source: '1',
58 | target: '2',
59 | }
60 | ]
61 | }
62 |
63 | const CustomNode = () => {
64 | const [content, setContent] = useState('我我我我')
65 | const onInputChange = useCallback((e) => {
66 | setContent(e.target.value)
67 | }, [])
68 |
69 | return (
70 | <>
71 |
72 | {content}
73 | >
74 | )
75 | }
76 |
77 | const Demo = () => {
78 | return (
79 |
83 |
84 |
85 |
86 | );
87 | };
88 |
89 | ReactDOM.render( , mountNode);
90 | ```
91 |
92 | ### Style
93 |
94 | Since we are rendering DOM nodes you can simply overwrite the styles with your own CSS rules. The Graph Editor wrapper has the className graph editor. If you want to change the graph background for example you can do:
95 |
96 | ```css
97 | .graph-editor {
98 | background: red;
99 | }
100 | ```
101 |
102 | Graph Editor Class Names
103 |
104 | ```bash
105 | .graph-editor # container
106 | .graph-editor-zoom-wrapper # Zoom wrapper
107 | .graph-editor-edge-renderer # Edges renderer
108 | .graph-editor-node-renderer # Nodes renderer
109 | .graph-editor-edge-wrapper # Edges wrapper
110 | .graph-editor-node-wrapper # Nodes wrapper
111 | .graph-editor-pointer-wrapper # Pointers wrapper
112 | .graph-editor-controls # Controls
113 | .graph-editor-background # Background
114 | .graph-editor-minimap # Minimap
115 | .graph-editor-edge-label # Edge label
116 | .graph-editor-marker-definitions-container # Marker definitions
117 | ```
118 |
119 | You could achieve the same effect by passing a style prop to the GraphEditor component:
120 |
121 | ```tsx
122 | const style = {
123 | background: 'red',
124 | width: '100%',
125 | height: 300,
126 | };
127 |
128 | return ;
129 | ```
130 |
131 | ### Prop Types
132 |
133 | This is the list of prop types you can pass to the main GraphEditor component.
134 |
135 | ```ts
136 | import GraphEditor from '@ols-scripts/graph-editor';
137 | ```
138 |
139 | #### Basic Props
140 |
141 | - `prefix`: style prefix
142 | - `elements`: array of nodes and edges (required)
143 | - `style`: css properties
144 | - `className`: additional class name
145 | - `children`: children nodes
146 | - `theme`: style theme, enumerated value is `primary`/`normal`
147 |
148 | #### Event Handlers
149 |
150 | - `onNodeDragStart(event, node)`: node drag start
151 | - `onNodeDrag(event, node)`: node drag
152 | - `onNodeDragStop(event, node)`: node drag stop
153 | - `onNodeMouseEnter(event, node)`: node mouse enter
154 | - `onNodeMouseMove(event, node)`: node mouse move
155 | - `onNodeMouseLeave(event, node)`: node mouse leave
156 | - `onNodeClick(event, node)`: node click
157 | - `onNodeDoubleClick(event, node)`: node double click
158 | - `onConnect({ source, target })`: called when user connects two nodes
159 | - `onConnectStart(event, { nodeId, handleType })`: called when user starts to drag connection line
160 | - `onConnectStop(event)`: called when user stops to drag connection line
161 | - `onConnectEnd(event)`: called after user stops or connects nodes
162 | - `onEdgeMouseEnter(event, edge)`: edge mouse enter
163 | - `onEdgeMouseMove(event, edge)`: edge mouse move
164 | - `onEdgeMouseLeave(event, edge)`: edge mouse leave
165 | - `onEdgeClick(event, edge)`: edge click,
166 | - `onEdgeDoubleClick(event, edge)`: edge double click,
167 | - `onZoom(event, transform)`: zoom change,
168 |
169 | #### Interaction
170 |
171 | - `draggable`: default = true; This applies to all nodes. You can also change the behavior of a specific node with the draggable node option. If this option is set to false and you have clickable elements inside your node, you need to set pointer-events:all explicitly for these elements
172 | - `connectable`: default = true; This applies to all nodes. You can also change the behavior of a specific node with the connectable node option
173 | - `selectable`: default: true; This applies to all elements. You can also change the behavior of a specific node with the selectable node option. If this option is set to false and you have clickable elements inside your node, you need to set pointer-events:all explicitly for these elements
174 |
175 | #### Element Customization
176 |
177 | - `nodeTypes`: object with node types
178 | - `edgeTypes`: object with edge types
179 |
180 | #### Connection Line Options
181 |
182 | - `connectLine.lineType`: The lineType = `'bezier' | 'straight' | 'smooth'`
183 | - `orientation`: The orientation of connection line = `'LR' | 'RL' | 'TB' | 'BT'`
184 |
185 | **Typescript:** The interface of the GraphEditor Prop Types are exported as GraphEditorProps. You can use it in your code as follows:
186 |
187 | ```tsx
188 | import { GraphEditorProps } from '@ols-scripts/graph-editor';
189 | ```
190 |
191 | ### Node Options
192 |
193 | You create nodes by adding them to the elements array of the GraphEditor component.
194 |
195 | #### Node example
196 |
197 | ```ts
198 | {
199 | id: '1',
200 | type: 'input',
201 | data: { label: 'Node 1' },
202 | position: { x: 250, y: 5 }
203 | }
204 | ```
205 |
206 | #### Options
207 |
208 | - `id`: string (required)
209 | - `position`: { x: number, y: number } (required)
210 | - `data`: {} (required if you are using a standard type, otherwise depends on your implementation)
211 | - `type`: 'default' or a custom one you implemented
212 | - `style`: css properties
213 | - `className`: additional class name
214 | - `orientation`: The orientation of connection line = `'LR' | 'RL' | 'TB' | 'BT'`
215 | - `draggable`: boolean - if option is not set, the node is draggable (overwrites general nodesDraggable option)
216 | - `connectable`: boolean - if option is not set, the node is connectable (overwrites general nodesConnectable option)
217 | - `selectable`: boolean - if option is not set, the node is selectable (overwrites general elementsSelectable option)
218 |
219 | ### Edge Options
220 |
221 | You create edges by adding them to your elements array of the GraphEditor component.
222 |
223 | #### Edge example
224 |
225 | ```ts
226 | {
227 | id: 'e1-2',
228 | type: 'straight',
229 | source: '1',
230 | target: '2',
231 | animated: true,
232 | label: 'edge label'
233 | }
234 | ```
235 |
236 | If you wanted to display this edge, you would need a node with `id` = 1 (source node) and another one with `id` = 2 (target node).
237 |
238 | #### Options
239 |
240 | - `id`: string
241 | - `source`: string (an id of a node) (required)
242 | - `target`: string (an id of a node) (required)
243 | - `lineType`: 'default' (`bezier`), `straight`, `smooth` or a custom one depending on your implementation
244 | - `style`: css properties for the edge line path
245 | - `className`: additional class name
246 | - `label`: string
247 | - `arrowType`: `arrow` or `arrowClosed`
248 | - `data`: `{}` you can use this to pass data to your custom edges.
249 |
250 | ### Background
251 |
252 | Graph Editor comes with two background variants: dots and lines. You can use it by passing it as a children to the GraphEditor component
253 |
254 | #### Usage
255 |
256 | ```tsx
257 | import GraphEditor, { Background } from '@ols-scripts/graph-editor';
258 |
259 | return (
260 |
261 |
266 |
267 | );
268 | ```
269 |
270 | #### Prop Types
271 |
272 | - variant: string - has to be 'dots' or 'lines' - default: dots
273 | - gap: number - the gap between the dots or lines - default: 16
274 | - size: number - the radius of the dots or the stroke width of the lines - default: 0.5
275 | - color: string - the color of the dots or lines - default: #81818a for dots, #eee for lines
276 | - style: css properties
277 | - className: additional class name
278 |
279 | **Typescript:** The interface of the Background Prop Types are exported as BackgroundProps.
280 |
281 | ### Mini Map
282 |
283 | You can use the mini map plugin by passing it as a children to the GraphEditor component
284 |
285 | #### Usage
286 |
287 | ```tsx
288 | import GraphEditor, { MiniMap } from '@ols-scripts/graph-editor';
289 |
290 | return (
291 |
292 |
293 |
294 | );
295 | ```
296 |
297 | #### Prop Types
298 |
299 | - `style`: css properties
300 | - `className`: additional class name
301 | - `draggable`: default = false
302 |
303 | **Typescript:** The interface of the MiniMap Prop Types are exported as MiniMapProps
304 |
305 | ### Control
306 |
307 | You can use control to change operation status easier
308 |
309 | #### Usage
310 |
311 | ```tsx
312 | import GraphEditor, { MiniMap } from '@ols-scripts/graph-editor';
313 |
314 | return (
315 |
316 |
323 |
324 | )
325 | ```
326 |
327 | #### Prop Types
328 |
329 | - `style`: css properties
330 | - `className`: additional class name
331 | - `draggable`: default = false
332 | - `showZoom`: show zoom button
333 | - `showFullscreen`: show fullscreen button
334 | - `showFitView`: show fitView button
335 | - `showInteractive`: show interactive button
336 | - `isLayout`: control graphlib layout
337 |
338 | **Typescript:** The interface of the Control Prop Types are exported as ControlProps
339 |
340 | ### ZoomWrapper
341 |
342 | ```tsx
343 | import GraphEditor, { ZoomWrapper } from '@ols-scripts/graph-editor';
344 |
345 |
346 | {({ transform, transformStyle }) => (
347 |
348 | )}
349 |
350 | ```
351 |
352 | ### GlobalWrapper
353 |
354 | ```tsx
355 | import GraphEditor, { GlobalWrapper } from '@ols-scripts/graph-editor';
356 |
357 |
368 | container
369 |
370 | ```
371 |
372 | ### Helper
373 |
374 | ### Hooks
375 |
376 | ### Todo
377 |
378 | - [ ] window resize listener
379 | - [ ] on node support wheel
380 | - [ ] drag performance optimization
381 | - [x] node delete
382 | - [x] showFitView
383 | - [x] dynamic node [calc offset and update position online]
384 | - [x] add edge label
385 | - [x] horizontal edge
386 | - [x] miniMap
387 | - [x] full screen
388 | - [x] connectline position bug
389 | - [x] more event
390 | - [x] node select
391 | - [x] edge select
392 | - [x] add arrow (arrowType)
393 |
--------------------------------------------------------------------------------
/src/utils/graph.ts:
--------------------------------------------------------------------------------
1 | import { ComponentType } from 'react'
2 | import DefaultNode from '@/components/Node/Default'
3 | import DefaultEdge from '@/components/Edge/Default'
4 | import cloneDeep from 'lodash/cloneDeep'
5 | import {
6 | Node,
7 | NodeTypes,
8 | EdgeTypes,
9 | Dimensions,
10 | XYPosition,
11 | PointerPosition,
12 | ArrowType,
13 | Orientation,
14 | } from '@/types'
15 |
16 | /**
17 | * get visible nodes
18 | * only visible === false will hidden
19 | * @param nodes
20 | * @returns visible nodes
21 | */
22 | export function getVisibleNodes(nodes: Node[]) {
23 | if (!(nodes && nodes.length)) {
24 | return []
25 | }
26 |
27 | return nodes.filter((item) => item.visible !== false)
28 | }
29 |
30 | /**
31 | * merge node types [default]
32 | * @param nodeTypes
33 | * @returns merged node types
34 | */
35 | export function mergeNodeTypes(nodeTypes: NodeTypes = {}) {
36 | const standardTypes: NodeTypes = {
37 | default: DefaultNode as ComponentType
,
38 | }
39 |
40 | const standardKeys = Object.keys(standardTypes)
41 |
42 | const specialTypes: NodeTypes = Object.keys(nodeTypes)
43 | .filter((key) => !standardKeys.includes(key))
44 | .reduce(
45 | (res, key) => ({
46 | ...res,
47 | [key]: nodeTypes[key],
48 | }),
49 | {},
50 | )
51 |
52 | return {
53 | ...standardTypes,
54 | ...specialTypes,
55 | }
56 | }
57 |
58 | /**
59 | * merge node types [default]
60 | * @param edgeTypes
61 | * @returns merged node types
62 | */
63 | export function mergeEdgeTypes(edgeTypes: EdgeTypes = {}) {
64 | const standardTypes: EdgeTypes = {
65 | default: DefaultEdge as ComponentType,
66 | }
67 |
68 | const standardKeys = Object.keys(standardTypes)
69 |
70 | const specialTypes: EdgeTypes = Object.keys(edgeTypes)
71 | .filter((key) => !standardKeys.includes(key))
72 | .reduce(
73 | (res, key) => ({
74 | ...res,
75 | [key]: edgeTypes[key],
76 | }),
77 | {},
78 | )
79 |
80 | return {
81 | ...standardTypes,
82 | ...specialTypes,
83 | }
84 | }
85 |
86 | /**
87 | * update nodes position
88 | * @param param0 id
89 | * @param param1 node
90 | * @param param2 nodes
91 | * @param param3 diff
92 | */
93 | export function updateNodePosition({ id, nodes, diff }): Node[] {
94 | const findIndex = nodes.findIndex((item) => item.id === id)
95 | const nextNodes = cloneDeep(nodes)
96 | findIndex >= 0 &&
97 | (nextNodes[findIndex] = {
98 | ...nextNodes[findIndex],
99 | position: {
100 | x: nextNodes[findIndex].position.x + diff.x,
101 | y: nextNodes[findIndex].position.y + diff.y,
102 | },
103 | })
104 |
105 | return nextNodes
106 | }
107 |
108 | /**
109 | * get document
110 | * @param element
111 | * @returns document
112 | */
113 | export function getDocument(element: HTMLElement): Document | ShadowRoot {
114 | return (element.getRootNode?.() as Document | ShadowRoot) || window?.document
115 | }
116 |
117 | export const getDimensions = (node: HTMLElement): Dimensions => ({
118 | width: node.offsetWidth,
119 | height: node.offsetHeight,
120 | })
121 |
122 | export interface GetCenterParams {
123 | source: XYPosition
124 | target: XYPosition
125 | }
126 |
127 | export function getCenter({ source, target }: GetCenterParams): [number, number, number, number] {
128 | const xOffset = Math.abs(target.x - source.x) / 2
129 | const centerX = target.x < source.x ? target.x + xOffset : target.x - xOffset
130 |
131 | const yOffset = Math.abs(target.y - source.y) / 2
132 | const centerY = target.y < source.y ? target.y + yOffset : target.y - yOffset
133 |
134 | return [centerX, centerY, xOffset, yOffset]
135 | }
136 |
137 | interface GetBezierPathParams {
138 | source: XYPosition
139 | target: XYPosition
140 | orientation: Orientation
141 | }
142 |
143 | /**
144 | * getBezierPath
145 | * @param source source positions
146 | * @param target target positions
147 | * @returns bezier path
148 | */
149 | export function getBezierPath({ source, target, orientation }: GetBezierPathParams) {
150 | const [
151 | sourcePosition = PointerPosition.Bottom,
152 | targetPosition = PointerPosition.Top,
153 | ] = getPointerPositionByOrientation(orientation)
154 | const leftAndRight = [PointerPosition.Left, PointerPosition.Right]
155 | const [centerX, centerY] = getCenter({ source, target })
156 |
157 | let path = `M${source.x},${source.y} C${source.x},${centerY} ${target.x},${centerY} ${target.x},${target.y}`
158 |
159 | if (leftAndRight.includes(sourcePosition) && leftAndRight.includes(targetPosition)) {
160 | path = `M${source.x},${source.y} C${centerX},${source.y} ${centerX},${target.y} ${target.x},${target.y}`
161 | } else if (leftAndRight.includes(targetPosition)) {
162 | path = `M${source.x},${source.y} C${source.x},${target.y} ${source.x},${target.y} ${target.x},${target.y}`
163 | } else if (leftAndRight.includes(sourcePosition)) {
164 | path = `M${source.x},${source.y} C${target.x},${source.y} ${target.x},${source.y} ${target.x},${target.y}`
165 | }
166 |
167 | return path
168 | }
169 |
170 | // These are some helper methods for drawing the round corners
171 | // The name indicates the direction of the path. "bottomLeftCorner" goes
172 | // from bottom to the left and "leftBottomCorner" goes from left to the bottom.
173 | // We have to consider the direction of the paths because of the animated lines.
174 | const bottomLeftCorner = (x: number, y: number, size: number): string =>
175 | `L ${x},${y - size}Q ${x},${y} ${x + size},${y}`
176 | const leftBottomCorner = (x: number, y: number, size: number): string =>
177 | `L ${x + size},${y}Q ${x},${y} ${x},${y - size}`
178 | const bottomRightCorner = (x: number, y: number, size: number): string =>
179 | `L ${x},${y - size}Q ${x},${y} ${x - size},${y}`
180 | const rightBottomCorner = (x: number, y: number, size: number): string =>
181 | `L ${x - size},${y}Q ${x},${y} ${x},${y - size}`
182 | const leftTopCorner = (x: number, y: number, size: number): string =>
183 | `L ${x + size},${y}Q ${x},${y} ${x},${y + size}`
184 | const topLeftCorner = (x: number, y: number, size: number): string =>
185 | `L ${x},${y + size}Q ${x},${y} ${x + size},${y}`
186 | const topRightCorner = (x: number, y: number, size: number): string =>
187 | `L ${x},${y + size}Q ${x},${y} ${x - size},${y}`
188 | const rightTopCorner = (x: number, y: number, size: number): string =>
189 | `L ${x - size},${y}Q ${x},${y} ${x},${y + size}`
190 |
191 | interface GetSmoothStepPathParams {
192 | source: XYPosition
193 | sourcePosition?: PointerPosition
194 | target: XYPosition
195 | targetPosition?: PointerPosition
196 | borderRadius?: number
197 | orientation: Orientation
198 | }
199 |
200 | export function getSmoothStepPath({
201 | source,
202 | target,
203 | borderRadius = 5,
204 | orientation,
205 | }: GetSmoothStepPathParams): string {
206 | const [
207 | sourcePosition = PointerPosition.Bottom,
208 | targetPosition = PointerPosition.Top,
209 | ] = getPointerPositionByOrientation(orientation)
210 | const { x: sourceX, y: sourceY } = source
211 | const { x: targetX, y: targetY } = target
212 | const [_centerX, _centerY, offsetX, offsetY] = getCenter({ source, target })
213 | const cornerWidth = Math.min(borderRadius, Math.abs(targetX - sourceX))
214 | const cornerHeight = Math.min(borderRadius, Math.abs(targetY - sourceY))
215 | const cornerSize = Math.min(cornerWidth, cornerHeight, offsetX, offsetY)
216 | const leftAndRight = [PointerPosition.Left, PointerPosition.Right]
217 |
218 | const cX = _centerX
219 | const cY = _centerY
220 |
221 | let firstCornerPath = null
222 | let secondCornerPath = null
223 |
224 | if (sourceX <= targetX) {
225 | firstCornerPath =
226 | sourceY <= targetY
227 | ? bottomLeftCorner(sourceX, cY, cornerSize)
228 | : topLeftCorner(sourceX, cY, cornerSize)
229 | secondCornerPath =
230 | sourceY <= targetY
231 | ? rightTopCorner(targetX, cY, cornerSize)
232 | : rightBottomCorner(targetX, cY, cornerSize)
233 | } else {
234 | firstCornerPath =
235 | sourceY < targetY
236 | ? bottomRightCorner(sourceX, cY, cornerSize)
237 | : topRightCorner(sourceX, cY, cornerSize)
238 | secondCornerPath =
239 | sourceY < targetY
240 | ? leftTopCorner(targetX, cY, cornerSize)
241 | : leftBottomCorner(targetX, cY, cornerSize)
242 | }
243 |
244 | if (leftAndRight.includes(sourcePosition) && leftAndRight.includes(targetPosition)) {
245 | if (sourceX <= targetX) {
246 | firstCornerPath =
247 | sourceY <= targetY
248 | ? rightTopCorner(cX, sourceY, cornerSize)
249 | : rightBottomCorner(cX, sourceY, cornerSize)
250 | secondCornerPath =
251 | sourceY <= targetY
252 | ? bottomLeftCorner(cX, targetY, cornerSize)
253 | : topLeftCorner(cX, targetY, cornerSize)
254 | }
255 | } else if (leftAndRight.includes(sourcePosition) && !leftAndRight.includes(targetPosition)) {
256 | if (sourceX <= targetX) {
257 | firstCornerPath =
258 | sourceY <= targetY
259 | ? rightTopCorner(targetX, sourceY, cornerSize)
260 | : rightBottomCorner(targetX, sourceY, cornerSize)
261 | } else {
262 | firstCornerPath =
263 | sourceY <= targetY
264 | ? leftTopCorner(targetX, sourceY, cornerSize)
265 | : leftBottomCorner(targetX, sourceY, cornerSize)
266 | }
267 | secondCornerPath = ''
268 | } else if (!leftAndRight.includes(sourcePosition) && leftAndRight.includes(targetPosition)) {
269 | if (sourceX <= targetX) {
270 | firstCornerPath =
271 | sourceY <= targetY
272 | ? bottomLeftCorner(sourceX, targetY, cornerSize)
273 | : topLeftCorner(sourceX, targetY, cornerSize)
274 | } else {
275 | firstCornerPath =
276 | sourceY <= targetY
277 | ? bottomRightCorner(sourceX, targetY, cornerSize)
278 | : topRightCorner(sourceX, targetY, cornerSize)
279 | }
280 | secondCornerPath = ''
281 | }
282 |
283 | return `M ${sourceX},${sourceY}${firstCornerPath}${secondCornerPath}L ${targetX},${targetY}`
284 | }
285 |
286 | export type GetMarkerEndParams = {
287 | arrowType?: ArrowType
288 | markerEndId?: string
289 | isSelected?: boolean
290 | }
291 |
292 | export function getMarkerEnd({ arrowType, markerEndId, isSelected }: GetMarkerEndParams): string {
293 | if (typeof markerEndId !== 'undefined' && markerEndId) {
294 | return `url(#${markerEndId})`
295 | }
296 |
297 | return typeof arrowType !== 'undefined'
298 | ? `url(#graph-editor-marker-definitions__${ArrowType[arrowType]}${isSelected ? '__selected' : ''})`
299 | : 'none'
300 | }
301 |
302 | export function getArrowEndOffset(arrowType: ArrowType): number {
303 | return arrowType ? -3 : 0
304 | }
305 |
306 | export type GetPositionByOrientationParams = {
307 | sourcePosition: XYPosition
308 | targetPosition: XYPosition
309 | sourceDimension: Dimensions
310 | targetDimension: Dimensions
311 | arrowType?: ArrowType
312 | orientation: Orientation
313 | }
314 |
315 | export function getPositionByOrientation({
316 | sourcePosition,
317 | targetPosition,
318 | sourceDimension,
319 | targetDimension,
320 | arrowType,
321 | orientation,
322 | }: GetPositionByOrientationParams): { source: XYPosition; target: XYPosition } {
323 | switch (orientation) {
324 | case Orientation.LeftToRight:
325 | return {
326 | source: {
327 | x: sourcePosition.x + sourceDimension.width,
328 | y: sourcePosition.y + sourceDimension.height / 2,
329 | },
330 | target: {
331 | x: targetPosition.x + getArrowEndOffset(arrowType),
332 | y: targetPosition.y + targetDimension.height / 2,
333 | },
334 | }
335 | case Orientation.RightToLeft:
336 | return {
337 | source: {
338 | x: sourcePosition.x,
339 | y: sourcePosition.y + sourceDimension.height / 2,
340 | },
341 | target: {
342 | x: targetPosition.x + targetDimension.width - getArrowEndOffset(arrowType),
343 | y: targetPosition.y + targetDimension.height / 2,
344 | },
345 | }
346 | case Orientation.BottomToTop:
347 | return {
348 | source: {
349 | x: sourcePosition.x + sourceDimension.width / 2,
350 | y: sourcePosition.y,
351 | },
352 | target: {
353 | x: targetPosition.x + targetDimension.width / 2,
354 | y: targetPosition.y + targetDimension.height - getArrowEndOffset(arrowType),
355 | },
356 | }
357 | case Orientation.TopToBottom:
358 | default:
359 | return {
360 | source: {
361 | x: sourcePosition.x + sourceDimension.width / 2,
362 | y: sourcePosition.y + sourceDimension.height,
363 | },
364 | target: {
365 | x: targetPosition.x + targetDimension.width / 2,
366 | y: targetPosition.y + getArrowEndOffset(arrowType),
367 | },
368 | }
369 | }
370 | }
371 |
372 | export function getPointerPositionByOrientation(
373 | orientation: Orientation,
374 | ): [PointerPosition, PointerPosition] {
375 | switch (orientation) {
376 | case Orientation.LeftToRight:
377 | return [PointerPosition.Left, PointerPosition.Right]
378 | case Orientation.RightToLeft:
379 | return [PointerPosition.Right, PointerPosition.Left]
380 | case Orientation.BottomToTop:
381 | return [PointerPosition.Bottom, PointerPosition.Top]
382 | case Orientation.TopToBottom:
383 | default:
384 | return [PointerPosition.Top, PointerPosition.Bottom]
385 | }
386 | }
387 |
--------------------------------------------------------------------------------