Skip to content

Commit 4c2a53f

Browse files
committed
Add user resizable nodes
Fix EdgePath.getNodeIntersection incorrectly calculating whether 2 nodes intersect
1 parent cac5cae commit 4c2a53f

14 files changed

+259
-153
lines changed

.eslintignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
node_modules
2-
dist
2+
dist
3+
examples

src/components/BaseNode.tsx

-128
This file was deleted.

src/components/BaseNode/BaseNode.tsx

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React, {useContext} from "react";
2+
import styled from "@emotion/styled";
3+
import {cx} from "@emotion/css";
4+
import {NODE_CLASS, Z_INDEX_GRABBED_NODE} from "../../util/constants";
5+
import {BoundsContext} from "../../context/BoundsContext";
6+
import {Node} from "../../data/Node";
7+
import {useBaseNode} from "./useBaseNode";
8+
// Used by documentation
9+
// eslint-disable-next-line
10+
import {BaseResizableNode} from "./BaseResizableNode";
11+
import {Property} from "csstype";
12+
13+
export interface BaseNodeProps {
14+
/**
15+
* ID of the node
16+
*/
17+
id: string
18+
/**
19+
* CSS classes to be added to the node element
20+
*/
21+
classes: string[]
22+
/**
23+
* Absolute position of this node
24+
* TODO This shouldn't be recalculated every render
25+
*/
26+
absolutePosition: DOMPoint
27+
/**
28+
* Whether this node is grabbed (being moved)
29+
*/
30+
grabbed: boolean
31+
/**
32+
* Whether this node is selected
33+
*/
34+
selected: boolean
35+
/**
36+
* Contents of your node should be placed here
37+
*/
38+
children: React.ReactNode
39+
}
40+
41+
export interface NodeProps<T> extends Omit<BaseNodeProps, "children"> {
42+
/**
43+
* Custom node data
44+
*/
45+
data: T
46+
/**
47+
* Parent of this node, if it exists
48+
*/
49+
parent?: Node<unknown> | null
50+
/**
51+
* Position relative to parent, if parent is not null, or same as {@link absolutePosition} if it is null
52+
*/
53+
position: DOMPoint
54+
/**
55+
* Spacing between this node and the edges that connect to it. This space is *automatically taken into consideration* for the calculation of edges.
56+
*/
57+
edgeMargin: number
58+
/**
59+
* Whether this node wants to be user-resizable, as set in the {@link Node Node's} properties. In general, you can (and should) ignore this and use the non-resizable
60+
* {@link BaseNode}, instead of {@link BaseResizableNode}, unless you expect this value to change dynamically.
61+
*/
62+
resize: Property.Resize
63+
}
64+
65+
const BaseDiv = styled.div<{ baseZIndex: number, grabbed: boolean }>`
66+
position: absolute;
67+
transform: translate(-50%, -50%);
68+
z-index: ${props => props.grabbed ? Z_INDEX_GRABBED_NODE : props.baseZIndex};
69+
`
70+
71+
export function BaseNode({id, classes, absolutePosition, grabbed, selected, children}: BaseNodeProps) {
72+
const [grapherContext, ref] = useBaseNode(id)
73+
const bounds = useContext(BoundsContext)
74+
75+
return <BaseDiv ref={ref} id={`${grapherContext.id}n-${id}`} baseZIndex={grapherContext.nodeZIndex} grabbed={grabbed}
76+
className={cx(classes, NODE_CLASS)} data-grabbed={grabbed} data-selected={selected} style={{
77+
left: absolutePosition.x - bounds.x,
78+
top: absolutePosition.y - bounds.y,
79+
}}>
80+
{children}
81+
</BaseDiv>
82+
}
83+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {useBaseNode} from "./useBaseNode";
2+
import React, {useContext, useEffect} from "react";
3+
import {BoundsContext} from "../../context/BoundsContext";
4+
import {NODE_CLASS, NODE_RESIZABLE_WRAPPER, Z_INDEX_GRABBED_NODE} from "../../util/constants";
5+
import {cx} from "@emotion/css";
6+
import {Property} from "csstype";
7+
import {BaseNodeProps} from "./BaseNode";
8+
import styled from "@emotion/styled";
9+
10+
const ResizableDiv = styled.div<{ baseZIndex: number, grabbed: boolean, resize?: Property.Resize }>`
11+
position: absolute;
12+
transform: translate(-50%, -50%);
13+
z-index: ${props => props.grabbed ? Z_INDEX_GRABBED_NODE : props.baseZIndex};
14+
resize: ${props => props.resize};
15+
overflow: auto;
16+
`
17+
18+
const ContentDiv = styled.div`
19+
box-sizing: border-box;
20+
width: 100%;
21+
height: 100%;
22+
`
23+
24+
export interface BaseResizableNodeProps extends BaseNodeProps {
25+
/**
26+
* Direction of allowed user resizing.
27+
*/
28+
resize: Property.Resize
29+
}
30+
31+
export function BaseResizableNode({id, classes, absolutePosition, grabbed, selected, children, resize}: BaseResizableNodeProps) {
32+
const [grapherContext, ref] = useBaseNode(id)
33+
const bounds = useContext(BoundsContext)
34+
35+
useEffect(() => {
36+
if (ref.current == null) return
37+
const parent = ref.current.parentElement
38+
if (parent == null) return
39+
40+
const onResizeStart = grapherContext.onResizeStart
41+
42+
parent.addEventListener("pointerdown", onResizeStart)
43+
return () => parent.removeEventListener("pointerdown", onResizeStart)
44+
}, [grapherContext.onResizeStart, ref])
45+
46+
return <ResizableDiv className={NODE_RESIZABLE_WRAPPER} baseZIndex={grapherContext.nodeZIndex} grabbed={grabbed} resize={resize} style={{
47+
left: absolutePosition.x - bounds.x,
48+
top: absolutePosition.y - bounds.y,
49+
}}>
50+
<ContentDiv id={`${grapherContext.id}n-${id}`} ref={ref} className={cx(classes, NODE_CLASS)} data-grabbed={grabbed} data-selected={selected}>
51+
{children}
52+
</ContentDiv>
53+
</ResizableDiv>
54+
}

src/components/BaseNode/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import {BaseNode, BaseNodeProps, NodeProps} from "./BaseNode";
2+
import {BaseResizableNode, BaseResizableNodeProps} from "./BaseResizableNode";
3+
4+
export {BaseNode, BaseNodeProps, NodeProps}
5+
export {BaseResizableNode, BaseResizableNodeProps}
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {GrapherContext, GrapherContextValue} from "../../context/GrapherContext";
2+
import React, {useCallback, useContext, useEffect, useRef} from "react";
3+
import {CallbacksContext} from "../../context/CallbacksContext";
4+
import {errorUnknownNode} from "../../util/log";
5+
import {resolveValues} from "../../util/utils";
6+
7+
// Common code of BaseNode and BaseNodeResizable
8+
export function useBaseNode(id: string): [GrapherContextValue, React.RefObject<HTMLDivElement>] {
9+
const grapherContext = useContext(GrapherContext)
10+
const listeners = useContext(CallbacksContext)
11+
12+
const ref = useRef<HTMLDivElement>(null)
13+
const node = grapherContext.getNode(id)
14+
if (node == null) errorUnknownNode(id)
15+
16+
// Function to notify ReactGrapher of changes to this node (size, border radius)
17+
const recalculateNode = useCallback(() => {
18+
const elem = ref.current
19+
if (elem == null || node == null) return
20+
// Update node size
21+
if (Math.abs(node.width - elem.offsetWidth) > 3) {
22+
node.width = elem.offsetWidth
23+
grapherContext.rerenderEdges()
24+
grapherContext.recalculateBounds()
25+
}
26+
if (Math.abs(node.height - elem.offsetHeight) > 3) {
27+
node.height = elem.offsetHeight
28+
grapherContext.rerenderEdges()
29+
grapherContext.recalculateBounds()
30+
}
31+
32+
// Update border radius
33+
let borderChanged = false
34+
const style = getComputedStyle(elem)
35+
/* border[pos][axis] - border radius for every corner
36+
pos = 0 (top-left) / 1 (top-right) / 2 (bottom-right) / 3 (bottom-left)
37+
axis = 0 (x-axis) / 1 (y-axis)
38+
*/
39+
const border: [[number, number], [number, number], [number, number], [number, number]] = [
40+
resolveValues(style.borderTopLeftRadius, node.width, node.height),
41+
resolveValues(style.borderTopRightRadius, node.width, node.height),
42+
resolveValues(style.borderBottomRightRadius, node.width, node.height),
43+
resolveValues(style.borderBottomLeftRadius, node.width, node.height),
44+
]
45+
for (let i = 0; i < 4; ++i) if (Math.abs(border[i][0] - node.borderRadius[i][0]) > 3 || Math.abs(border[i][1] - node.borderRadius[i][1]) > 3) {
46+
borderChanged = true
47+
break
48+
}
49+
if (borderChanged) {
50+
node.borderRadius = border
51+
grapherContext.rerenderEdges()
52+
}
53+
}, [grapherContext, node])
54+
55+
// Set listeners
56+
useEffect(() => {
57+
const elem = ref.current
58+
if (elem == null || node == null || grapherContext.static) return
59+
60+
elem.addEventListener("pointerdown", listeners.onObjectPointerDown)
61+
elem.addEventListener("pointerup", listeners.onObjectPointerUp)
62+
const observer = new ResizeObserver(recalculateNode)
63+
observer.observe(elem)
64+
return () => {
65+
elem.removeEventListener("pointerdown", listeners.onObjectPointerDown)
66+
elem.removeEventListener("pointerup", listeners.onObjectPointerUp)
67+
observer.disconnect()
68+
}
69+
}, [grapherContext, listeners, node, recalculateNode])
70+
71+
return [grapherContext, ref]
72+
}

0 commit comments

Comments
 (0)