Merge branch 'main' into refactor/promised-panzoom

This commit is contained in:
moklick
2024-07-04 22:44:45 +02:00
492 changed files with 30971 additions and 17811 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
import { CoordinateExtent, HandleType } from './types';
// @todo: update URLs to xyflow
export const errorMessages = {
error001: () =>
'[React Flow]: Seems like you have not used zustand provider as an ancestor. Help: https://reactflow.dev/error#001',
@@ -23,10 +22,10 @@ export const errorMessages = {
error011: (edgeType: string) => `Edge type "${edgeType}" not found. Using fallback type "default".`,
error012: (id: string) =>
`Node with id "${id}" does not exist, it may have been removed. This can happen when a node is deleted before the "onNodeClick" handler is called.`,
error013: (lib: string = 'react') =>
`It seems that you haven't loaded the styles. Please import '@xyflow/${lib}/dist/style.css' or base.css to make sure everything is working properly.`,
};
export const internalsSymbol = Symbol.for('internals');
export const infiniteExtent: CoordinateExtent = [
[Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
[Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY],
+1
View File
@@ -5,3 +5,4 @@ export * from './xydrag';
export * from './xyhandle';
export * from './xyminimap';
export * from './xypanzoom';
export * from './xyresizer';
+15 -10
View File
@@ -1,33 +1,38 @@
.xy-flow {
--node-border-default: 1px solid #bbb;
--node-border-selected-default: 1px solid #555;
--xy-node-border-default: 1px solid #bbb;
--xy-node-border-selected-default: 1px solid #555;
--handle-background-color-default: #333;
--xy-handle-background-color-default: #333;
--selection-background-color-default: rgba(150, 150, 180, 0.1);
--selection-border-default: 1px dotted rgba(155, 155, 155, 0.8);
--xy-selection-background-color-default: rgba(150, 150, 180, 0.1);
--xy-selection-border-default: 1px dotted rgba(155, 155, 155, 0.8);
}
.xy-flow.dark {
--xy-node-color-default: #f8f8f8;
}
.xy-flow__handle {
background-color: var(--handle-background-color, var(--handle-background-color-default));
background-color: var(--xy-handle-background-color, var(--xy-handle-background-color-default));
}
.xy-flow__node-input,
.xy-flow__node-default,
.xy-flow__node-output,
.xy-flow__node-group {
border: var(--node-border, var(--node-border-default));
border: var(--xy-node-border, var(--xy-node-border-default));
color: var(--xy-node-color, var(--xy-node-color-default));
&.selected,
&:focus,
&:focus-visible {
outline: none;
border: var(--node-border-selected, var(--node-border-selected-default));
border: var(--xy-node-border-selected, var(--xy-node-border-selected-default));
}
}
.xy-flow__nodesselection-rect,
.xy-flow__selection {
background: var(--selection-background-color, var(--selection-background-color-default));
border: var(--selection-border, var(--selection-border-default));
background: var(--xy-selection-background-color, var(--xy-selection-background-color-default));
border: var(--xy-selection-border, var(--xy-selection-border-default));
}
+160 -53
View File
@@ -1,20 +1,61 @@
/* these are the necessary styles for React/Svelte Flow, they get used by base.css and style.css */
.xy-flow {
--edge-stroke-default: #b1b1b7;
--edge-stroke-width-default: 1;
--edge-stroke-selected-default: #555;
direction: ltr;
--connectionline-stroke-default: #b1b1b7;
--connectionline-stroke-width-default: 1;
--xy-edge-stroke-default: #b1b1b7;
--xy-edge-stroke-width-default: 1;
--xy-edge-stroke-selected-default: #555;
--attribution-background-color-default: rgba(255, 255, 255, 0.5);
--xy-connectionline-stroke-default: #b1b1b7;
--xy-connectionline-stroke-width-default: 1;
--minimap-background-color-default: #fff;
--xy-attribution-background-color-default: rgba(255, 255, 255, 0.5);
--background-pattern-dot-color-default: #91919a;
--background-pattern-line-color-default: #eee;
--background-pattern-cross-color-default: #e2e2e2;
--xy-minimap-background-color-default: #fff;
--xy-minimap-mask-background-color-default: rgb(240, 240, 240, 0.6);
--xy-minimap-mask-stroke-color-default: transparent;
--xy-minimap-mask-stroke-width-default: 1;
--xy-minimap-node-background-color-default: #e2e2e2;
--xy-minimap-node-stroke-color-default: transparent;
--xy-minimap-node-stroke-width-default: 2;
--xy-background-color-default: transparent;
--xy-background-pattern-dots-color-default: #91919a;
--xy-background-pattern-lines-color-default: #eee;
--xy-background-pattern-cross-color-default: #e2e2e2;
}
.xy-flow.dark {
--xy-edge-stroke-default: #3e3e3e;
--xy-edge-stroke-width-default: 1;
--xy-edge-stroke-selected-default: #727272;
--xy-connectionline-stroke-default: #b1b1b7;
--xy-connectionline-stroke-width-default: 1;
--xy-attribution-background-color-default: rgba(150, 150, 150, 0.25);
--xy-minimap-background-color-default: #141414;
--xy-minimap-mask-background-color-default: rgb(60, 60, 60, 0.6);
--xy-minimap-mask-stroke-color-default: transparent;
--xy-minimap-mask-stroke-width-default: 1;
--xy-minimap-node-background-color-default: #2b2b2b;
--xy-minimap-node-stroke-color-default: transparent;
--xy-minimap-node-stroke-width-default: 2;
--xy-background-color-default: #141414;
--xy-background-pattern-dots-color-default: #777;
--xy-background-pattern-lines-color-default: #777;
--xy-background-pattern-cross-color-default: #777;
}
.xy-flow {
background-color: var(--xy-background-color, var(--xy-background-color-default));
}
.xy-flow__background {
background-color: var(--xy-background-color, var(--xy-background-color-props, var(--xy-background-color-default)));
}
.xy-flow__container {
@@ -27,14 +68,17 @@
.xy-flow__pane {
z-index: 1;
cursor: grab;
&.selection {
cursor: pointer;
}
&.dragging {
cursor: grabbing;
&.draggable {
cursor: grab;
&.dragging {
cursor: grabbing;
}
}
}
@@ -57,26 +101,34 @@
outline: none;
}
.xy-flow .xy-flow__edges {
pointer-events: none;
overflow: visible;
}
.xy-flow__edge-path {
stroke: var(--edge-stroke, var(--edge-stroke-default));
stroke-width: var(--edge-stroke-width, var(--edge-stroke-width-default));
stroke: var(--xy-edge-stroke, var(--xy-edge-stroke-default));
stroke-width: var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));
fill: none;
}
.xy-flow__connection-path {
stroke: var(--connectionline-stroke, var(--connectionline-stroke-default));
stroke-width: var(--connectionline-stroke-width, var(--connectionline-stroke-width-default));
stroke: var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));
stroke-width: var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));
fill: none;
}
.xy-flow__edges {
position: absolute;
svg {
overflow: visible;
position: absolute;
pointer-events: none;
}
}
.xy-flow__edge {
pointer-events: visibleStroke;
cursor: pointer;
&.selectable {
cursor: pointer;
}
&.animated path {
stroke-dasharray: 5;
@@ -99,25 +151,20 @@
}
&.selected .xy-flow__edge-path,
&:focus .xy-flow__edge-path,
&:focus-visible .xy-flow__edge-path {
stroke: var(--edge-stroke-selected, var(--edge-stroke-selected-default));
&.selectable:focus .xy-flow__edge-path,
&.selectable:focus-visible .xy-flow__edge-path {
stroke: var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default));
}
&-textwrapper {
pointer-events: all;
}
&-textbg {
fill: white;
}
.xy-flow__edge-text {
pointer-events: none;
user-select: none;
}
}
.xy-flow__connection {
pointer-events: none;
@@ -127,7 +174,7 @@
}
}
.xy-flow__connectionline {
svg.xy-flow__connectionline {
z-index: 1001;
overflow: visible;
position: absolute;
@@ -144,16 +191,19 @@
pointer-events: all;
transform-origin: 0 0;
box-sizing: border-box;
cursor: grab;
cursor: default;
&.dragging {
cursor: grabbing;
&.selectable {
cursor: pointer;
}
/* only used in Svelte Flow, should we remove it here? */
&.draggable {
cursor: grab;
pointer-events: all;
&.dragging {
cursor: grabbing;
}
}
}
@@ -175,6 +225,10 @@
min-width: 5px;
min-height: 5px;
&.connectingfrom {
pointer-events: all;
}
&.connectionindicator {
pointer-events: all;
cursor: crosshair;
@@ -183,26 +237,26 @@
&-bottom {
top: auto;
left: 50%;
bottom: -4px;
transform: translate(-50%, 0);
bottom: 0;
transform: translate(-50%, 50%);
}
&-top {
top: 0;
left: 50%;
top: -4px;
transform: translate(-50%, 0);
transform: translate(-50%, -50%);
}
&-left {
top: 50%;
left: -4px;
transform: translate(0, -50%);
left: 0;
transform: translate(-50%, -50%);
}
&-right {
right: -4px;
top: 50%;
transform: translate(0, -50%);
right: 0;
transform: translate(50%, -50%);
}
}
@@ -240,7 +294,7 @@
.xy-flow__attribution {
font-size: 10px;
background: var(--attribution-background-color, var(--attribution-background-color-default));
background: var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));
padding: 2px 3px;
margin: 0;
@@ -266,40 +320,92 @@
top: 0;
}
.xy-flow__viewport-portal {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
user-select: none;
}
.xy-flow__minimap {
background: var(--minimap-background-color, var(--minimap-background-color-default));
background: var(
--xy-minimap-background-color-props,
var(--xy-minimap-background-color, var(--xy-minimap-background-color-default))
);
&-svg {
display: block;
}
&-mask {
fill: var(
--xy-minimap-mask-background-color-props,
var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default))
);
stroke: var(
--xy-minimap-mask-stroke-color-props,
var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default))
);
stroke-width: var(
--xy-minimap-mask-stroke-width-props,
var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default))
);
}
&-node {
fill: var(
--xy-minimap-node-background-color-props,
var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default))
);
stroke: var(
--xy-minimap-node-stroke-color-props,
var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default))
);
stroke-width: var(
--xy-minimap-node-stroke-width-props,
var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default))
);
}
}
.xy-flow__background {
pointer-events: none;
z-index: -1;
background-color: var(--background-color-props, 'transparent');
}
.xy-flow__background-pattern {
&.dots {
fill: var(
--background-pattern-color-props,
var(--background-pattern-color, var(--background-pattern-dot-color-default))
--xy-background-pattern-color-props,
var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default))
);
}
&.lines {
stroke: var(
--background-pattern-color-props,
var(--background-pattern-color, var(--background-pattern-line-color-default))
--xy-background-pattern-color-props,
var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default))
);
}
&.cross {
stroke: var(
--background-pattern-color-props,
var(--background-pattern-color, var(--background-pattern-cross-color-default))
--xy-background-pattern-color-props,
var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default))
);
}
}
.xy-flow__controls {
display: flex;
flex-direction: column;
&.horizontal {
flex-direction: row;
}
&-button {
display: flex;
justify-content: center;
@@ -312,6 +418,7 @@
width: 100%;
max-width: 12px;
max-height: 12px;
fill: currentColor;
}
}
}
+6 -2
View File
@@ -1,3 +1,7 @@
.xy-flow {
--xy-resize-background-color-default: #3367d9;
}
.xy-flow__resize-control {
position: absolute;
}
@@ -28,7 +32,7 @@
height: 4px;
border: 1px solid #fff;
border-radius: 1px;
background-color: #3367d9;
background-color: var(--xy-resize-background-color, var(--xy-resize-background-color-default));
transform: translate(-50%, -50%);
}
@@ -63,7 +67,7 @@
/* line styles */
.xy-flow__resize-control.line {
border-color: #3367d9;
border-color: var(--xy-resize-background-color, var(--xy-resize-background-color-default));
border-width: 0;
border-style: solid;
}
+72 -36
View File
@@ -1,23 +1,52 @@
.xy-flow {
--node-color-default: inherit;
--node-border-default: 1px solid #1a192b;
--node-background-color-default: #fff;
--node-group-background-color-default: rgba(240, 240, 240, 0.25);
--node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, 0.08);
--node-boxshadow-selected-default: 0 0 0 0.5px #1a192b;
--xy-node-color-default: inherit;
--xy-node-border-default: 1px solid #1a192b;
--xy-node-background-color-default: #fff;
--xy-node-group-background-color-default: rgba(240, 240, 240, 0.25);
--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, 0.08);
--xy-node-boxshadow-selected-default: 0 0 0 0.5px #1a192b;
--xy-node-border-radius-default: 3px;
--handle-background-color-default: #1a192b;
--handle-border-color-default: #fff;
--xy-handle-background-color-default: #1a192b;
--xy-handle-border-color-default: #fff;
--selection-background-color-default: rgba(0, 89, 220, 0.08);
--selection-border-default: 1px dotted rgba(0, 89, 220, 0.8);
--xy-selection-background-color-default: rgba(0, 89, 220, 0.08);
--xy-selection-border-default: 1px dotted rgba(0, 89, 220, 0.8);
--controls-button-background-color-default: #fefefe;
--controls-button-background-color-hover-default: #f4f4f4;
--controls-button-color-default: inherit;
--controls-button-color-hover-default: inherit;
--controls-button-border-color-default: #eee;
--controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, 0.08);
--xy-controls-button-background-color-default: #fefefe;
--xy-controls-button-background-color-hover-default: #f4f4f4;
--xy-controls-button-color-default: inherit;
--xy-controls-button-color-hover-default: inherit;
--xy-controls-button-border-color-default: #eee;
--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, 0.08);
--xy-edge-label-background-color-default: #ffffff;
--xy-edge-label-color-default: inherit;
}
.xy-flow.dark {
--xy-node-color-default: #f8f8f8;
--xy-node-border-default: 1px solid #3c3c3c;
--xy-node-background-color-default: #1e1e1e;
--xy-node-group-background-color-default: rgba(240, 240, 240, 0.25);
--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, 0.08);
--xy-node-boxshadow-selected-default: 0 0 0 0.5px #999;
--xy-handle-background-color-default: #bebebe;
--xy-handle-border-color-default: #1e1e1e;
--xy-selection-background-color-default: rgba(200, 200, 220, 0.08);
--xy-selection-border-default: 1px dotted rgba(200, 200, 220, 0.8);
--xy-controls-button-background-color-default: #2b2b2b;
--xy-controls-button-background-color-hover-default: #3e3e3e;
--xy-controls-button-color-default: #f8f8f8;
--xy-controls-button-color-hover-default: #fff;
--xy-controls-button-border-color-default: #5b5b5b;
--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, 0.08);
--xy-edge-label-background-color-default: #141414;
--xy-edge-label-color-default: #f8f8f8;
}
.xy-flow__edge {
@@ -44,35 +73,35 @@
.xy-flow__node-output,
.xy-flow__node-group {
padding: 10px;
border-radius: 3px;
border-radius: var(--xy-node-border-radius, var(--xy-node-border-radius-default));
width: 150px;
font-size: 12px;
color: var(--node-color, var(--node-color-default));
color: var(--xy-node-color, var(--xy-node-color-default));
text-align: center;
border: var(--node-border, var(--node-border-default));
background-color: var(--node-background-color, var(--node-background-color-default));
border: var(--xy-node-border, var(--xy-node-border-default));
background-color: var(--xy-node-background-color, var(--xy-node-background-color-default));
&.selectable {
&:hover {
box-shadow: var(--node-boxshadow-hover, var(--node-boxshadow-hover-default));
box-shadow: var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default));
}
&.selected,
&:focus,
&:focus-visible {
box-shadow: var(--node-boxshadow-selected, var(--node-boxshadow-selected-default));
box-shadow: var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default));
}
}
}
.xy-flow__node-group {
background-color: var(--node-group-background-color, var(--node-group-background-color-default));
background-color: var(--xy-node-group-background-color, var(--xy-node-group-background-color-default));
}
.xy-flow__nodesselection-rect,
.xy-flow__selection {
background: var(--selection-background-color, var(--selection-background-color-default));
border: var(--selection-border, var(--selection-border-default));
background: var(--xy-selection-background-color, var(--xy-selection-background-color-default));
border: var(--xy-selection-border, var(--xy-selection-border-default));
&:focus,
&:focus-visible {
@@ -83,34 +112,37 @@
.xy-flow__handle {
width: 6px;
height: 6px;
background-color: var(--handle-background-color, var(--handle-background-color-default));
border: 1px solid var(--handle-border-color, var(--handle-border-color-default));
background-color: var(--xy-handle-background-color, var(--xy-handle-background-color-default));
border: 1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));
border-radius: 100%;
}
.xy-flow__controls {
box-shadow: var(--controls-box-shadow, var(--controls-box-shadow-default));
box-shadow: var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default));
&-button {
border: none;
background: var(--controls-button-background-color, var(--controls-button-background-color-default));
background: var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));
border-bottom: 1px solid
var(
--controls-button-border-color-props,
var(--controls-button-border-color, var(--controls-button-border-color-default))
--xy-controls-button-border-color-props,
var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default))
);
color: var(--controls-button-color-props, var(--controls-button-color, var(--controls-button-color-default)));
color: var(
--xy-controls-button-color-props,
var(--xy-controls-button-color, var(--xy-controls-button-color-default))
);
cursor: pointer;
user-select: none;
&:hover {
background: var(
--controls-button-background-color-hover-props,
var(--controls-button-background-color-hover, var(--controls-button-background-color-hover-default))
--xy-controls-button-background-color-hover-props,
var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default))
);
color: var(
--controls-button-color-hover-props,
var(--controls-button-hover-color, var(--controls-button-hover-color-default))
--xy-controls-button-color-hover-props,
var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default))
);
}
@@ -122,4 +154,8 @@
}
}
}
&-button:last-child {
border-bottom: none;
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { XYPosition, Dimensions, NodeBase, EdgeBase } from '.';
export type NodeDimensionChange = {
id: string;
type: 'dimensions';
dimensions?: Dimensions;
/* if this is true, the node is currently being resized via the NodeResizer */
resizing?: boolean;
/* if this is true, we will set width and height of the node and not just the measured dimensions */
setAttributes?: boolean;
};
export type NodePositionChange = {
id: string;
type: 'position';
position?: XYPosition;
positionAbsolute?: XYPosition;
dragging?: boolean;
};
export type NodeSelectionChange = {
id: string;
type: 'select';
selected: boolean;
};
export type NodeRemoveChange = {
id: string;
type: 'remove';
};
export type NodeAddChange<NodeType extends NodeBase = NodeBase> = {
item: NodeType;
type: 'add';
};
export type NodeReplaceChange<NodeType extends NodeBase = NodeBase> = {
id: string;
item: NodeType;
type: 'replace';
};
/**
* Union type of all possible node changes.
* @public
*/
export type NodeChange<NodeType extends NodeBase = NodeBase> =
| NodeDimensionChange
| NodePositionChange
| NodeSelectionChange
| NodeRemoveChange
| NodeAddChange<NodeType>
| NodeReplaceChange<NodeType>;
export type EdgeSelectionChange = NodeSelectionChange;
export type EdgeRemoveChange = NodeRemoveChange;
export type EdgeAddChange<EdgeType extends EdgeBase = EdgeBase> = {
item: EdgeType;
type: 'add';
};
export type EdgeReplaceChange<EdgeType extends EdgeBase = EdgeBase> = {
id: string;
item: EdgeType;
type: 'replace';
};
export type EdgeChange<EdgeType extends EdgeBase = EdgeBase> =
| EdgeSelectionChange
| EdgeRemoveChange
| EdgeAddChange<EdgeType>
| EdgeReplaceChange<EdgeType>;
+30 -5
View File
@@ -1,25 +1,44 @@
import { Position } from './utils';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type EdgeBase<EdgeData = any> = {
export type EdgeBase<
EdgeData extends Record<string, unknown> = Record<string, unknown>,
EdgeType extends string | undefined = string | undefined
> = {
/** Unique id of an edge */
id: string;
type?: string;
/** Type of an edge defined in edgeTypes */
type?: EdgeType;
/** Id of source node */
source: string;
/** Id of target node */
target: string;
/** Id of source handle
* only needed if there are multiple handles per node
*/
sourceHandle?: string | null;
/** Id of target handle
* only needed if there are multiple handles per node
*/
targetHandle?: string | null;
animated?: boolean;
hidden?: boolean;
deletable?: boolean;
selectable?: boolean;
/** Arbitrary data passed to an edge */
data?: EdgeData;
selected?: boolean;
/** Set the marker on the beginning of an edge
* @example 'arrow', 'arrowclosed' or custom marker
*/
markerStart?: EdgeMarkerType;
/** Set the marker on the end of an edge
* @example 'arrow', 'arrowclosed' or custom marker
*/
markerEnd?: EdgeMarkerType;
zIndex?: number;
ariaLabel?: string;
/** Padding around the edge where interaction is still possible */
interactionWidth?: number;
focusable?: boolean;
};
export type SmoothStepPathOptions = {
@@ -27,13 +46,17 @@ export type SmoothStepPathOptions = {
borderRadius?: number;
};
export type StepPathOptions = {
offset?: number;
};
export type BezierPathOptions = {
curvature?: number;
};
export type DefaultEdgeOptionsBase<EdgeType extends EdgeBase> = Omit<
EdgeType,
'id' | 'source' | 'target' | 'sourceHandle' | 'targetHandle' | 'sourceNode' | 'targetNode'
'id' | 'source' | 'target' | 'sourceHandle' | 'targetHandle' | 'selected'
>;
export enum ConnectionLineType {
@@ -73,3 +96,5 @@ export type EdgePosition = {
sourcePosition: Position;
targetPosition: Position;
};
export type EdgeLookup<EdgeType extends EdgeBase = EdgeBase> = Map<string, EdgeType>;
+78 -23
View File
@@ -1,9 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { D3DragEvent, Selection as D3Selection, SubjectPosition, ZoomBehavior } from 'd3';
import type { Selection as D3Selection } from 'd3-selection';
import type { D3DragEvent, SubjectPosition } from 'd3-drag';
import type { ZoomBehavior } from 'd3-zoom';
// this is needed for the Selection type to include the transition function :/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Transition } from 'd3-transition';
import type { XYPosition, Rect } from './utils';
import type { NodeBase, NodeDragItem, NodeOrigin } from './nodes';
import type { ConnectingHandle, HandleType } from './handles';
import type { XYPosition, Rect, Position } from './utils';
import type { InternalNodeBase, NodeBase, NodeDragItem } from './nodes';
import type { Handle, HandleType } from './handles';
import { PanZoomInstance } from './panzoom';
import { EdgeBase } from '..';
@@ -22,13 +27,15 @@ export type SetCenter = (x: number, y: number, options?: SetCenterOptions) => vo
export type FitBounds = (bounds: Rect, options?: FitBoundsOptions) => void;
export type Connection = {
source: string | null;
target: string | null;
source: string;
target: string;
sourceHandle: string | null;
targetHandle: string | null;
};
export type ConnectionStatus = 'valid' | 'invalid';
export type HandleConnection = Connection & {
edgeId: string;
};
export enum ConnectionMode {
Strict = 'strict',
@@ -48,22 +55,21 @@ export type OnConnectEnd = (event: MouseEvent | TouchEvent) => void;
export type IsValidConnection = (edge: EdgeBase | Connection) => boolean;
export type FitViewParamsBase<NodeType extends NodeBase> = {
nodes: NodeType[];
nodeLookup: Map<string, InternalNodeBase<NodeType>>;
width: number;
height: number;
panZoom: PanZoomInstance;
minZoom: number;
maxZoom: number;
nodeOrigin?: NodeOrigin;
};
export type FitViewOptionsBase<NodeType extends NodeBase> = {
export type FitViewOptionsBase<NodeType extends NodeBase = NodeBase> = {
padding?: number;
includeHiddenNodes?: boolean;
minZoom?: number;
maxZoom?: number;
duration?: number;
nodes?: (NodeType | { id: NodeType['id'] })[];
nodes?: (NodeType | { id: string })[];
};
export type Viewport = {
@@ -100,7 +106,7 @@ export type D3ZoomInstance = ZoomBehavior<Element, unknown>;
export type D3SelectionInstance = D3Selection<Element, unknown, null, undefined>;
export type D3ZoomHandler = (this: Element, event: any, d: unknown) => void;
export type UpdateNodeInternals = (nodeId: string) => void;
export type UpdateNodeInternals = (nodeId: string | string[]) => void;
export type PanelPosition = 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
@@ -123,16 +129,65 @@ export type SelectionRect = Rect & {
export type OnError = (id: string, message: string) => void;
export type UpdateNodePositions = (
dragItems: NodeDragItem[] | NodeBase[],
positionChanged?: boolean,
dragging?: boolean
) => void;
export type UpdateNodePositions = (dragItems: Map<string, NodeDragItem | InternalNodeBase>, dragging?: boolean) => void;
export type PanBy = (delta: XYPosition) => boolean;
export type UpdateConnection = (params: {
connectionPosition: XYPosition | null;
connectionStatus: ConnectionStatus | null;
connectionStartHandle: ConnectingHandle | null;
connectionEndHandle: ConnectingHandle | null;
}) => void;
export const initialConnection: NoConnection = {
inProgress: false,
isValid: null,
from: null,
fromHandle: null,
fromPosition: null,
fromNode: null,
to: null,
toHandle: null,
toPosition: null,
toNode: null,
};
export type NoConnection = {
inProgress: false;
isValid: null;
from: null;
fromHandle: null;
fromPosition: null;
fromNode: null;
to: null;
toHandle: null;
toPosition: null;
toNode: null;
};
export type ConnectionInProgress = {
inProgress: true;
isValid: boolean | null;
from: XYPosition;
fromHandle: Handle;
fromPosition: Position;
fromNode: NodeBase;
to: XYPosition;
toHandle: Handle | null;
toPosition: Position;
toNode: NodeBase | null;
};
export type ConnectionState = ConnectionInProgress | NoConnection;
export type UpdateConnection = (params: ConnectionState) => void;
export type ColorModeClass = 'light' | 'dark';
export type ColorMode = ColorModeClass | 'system';
export type ConnectionLookup = Map<string, Map<string, HandleConnection>>;
export type OnBeforeDeleteBase<NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase> = ({
nodes,
edges,
}: {
nodes: NodeType[];
edges: EdgeType[];
}) => Promise<boolean | { nodes: NodeType[]; edges: EdgeType[] }>;
+21 -19
View File
@@ -1,38 +1,40 @@
import type { Position, OnConnect, IsValidConnection } from '.';
import type { Position, IsValidConnection } from '.';
export type HandleType = 'source' | 'target';
export type HandleElement = {
export type Handle = {
id?: string | null;
nodeId: string;
x: number;
y: number;
position: Position;
type: HandleType;
width: number;
height: number;
position: Position;
type?: HandleType;
};
export type ConnectingHandle = {
nodeId: string;
type: HandleType;
handleId?: string | null;
};
export type ConnectionHandle = {
id: string | null;
type: HandleType;
nodeId: string;
x: number;
y: number;
};
export type HandleProps = {
/** Type of the handle
* @example HandleType.Source, HandleType.Target
*/
type: HandleType;
/** Position of the handle
* @example Position.TopLeft, Position.TopRight,
* Position.BottomLeft, Position.BottomRight
*/
position: Position;
/** Should you be able to connect to/from this handle */
isConnectable?: boolean;
/** Should you be able to connect from this handle */
isConnectableStart?: boolean;
/** Should you be able to connect to this handle */
isConnectableEnd?: boolean;
onConnect?: OnConnect;
/** Callback if connection is valid
* @remarks connection becomes an edge if isValidConnection returns true
*/
isValidConnection?: IsValidConnection;
/** Id of the handle
* @remarks optional if there is only one handle of this type
*/
id?: string;
};
+1
View File
@@ -1,3 +1,4 @@
export * from './changes';
export * from './general';
export * from './nodes';
export * from './edges';
+101 -43
View File
@@ -1,73 +1,124 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { internalsSymbol } from '../constants';
import type { XYPosition, Position, CoordinateExtent, HandleElement } from '.';
import type { XYPosition, Position, CoordinateExtent, Handle } from '.';
import { Optional } from '../utils/types';
// this is stuff that all nodes share independent of the framework
export type NodeBase<T = any, U extends string | undefined = string | undefined> = {
/**
* Framework independent node data structure.
*
* @typeParam NodeData - type of the node data
* @typeParam NodeType - type of the node
*/
export type NodeBase<
NodeData extends Record<string, unknown> = Record<string, unknown>,
NodeType extends string = string
> = {
/** Unique id of a node */
id: string;
/** Position of a node on the pane
* @example { x: 0, y: 0 }
*/
position: XYPosition;
data: T;
type?: U;
/** Arbitrary data passed to a node */
data: NodeData;
/** Type of node defined in nodeTypes */
type?: NodeType;
/** Only relevant for default, source, target nodeType. controls source position
* @example 'right', 'left', 'top', 'bottom'
*/
sourcePosition?: Position;
/** Only relevant for default, source, target nodeType. controls target position
* @example 'right', 'left', 'top', 'bottom'
*/
targetPosition?: Position;
hidden?: boolean;
selected?: boolean;
/** True, if node is being dragged */
dragging?: boolean;
draggable?: boolean;
selectable?: boolean;
connectable?: boolean;
deletable?: boolean;
dragHandle?: string;
width?: number | null;
height?: number | null;
parentNode?: string;
width?: number;
height?: number;
initialWidth?: number;
initialHeight?: number;
/** Parent node id, used for creating sub-flows */
parentId?: string;
zIndex?: number;
/** Boundary a node can be moved in
* @example 'parent' or [[0, 0], [100, 100]]
*/
extent?: 'parent' | CoordinateExtent;
expandParent?: boolean;
positionAbsolute?: XYPosition;
ariaLabel?: string;
focusable?: boolean;
/** Origin of the node relative to it's position
* @example
* [0.5, 0.5] // centers the node
* [0, 0] // top left
* [1, 1] // bottom right
*/
origin?: NodeOrigin;
handles?: NodeHandle[];
size?: {
measured?: {
width?: number;
height?: number;
};
};
// only used internally
[internalsSymbol]?: {
z?: number;
export type InternalNodeBase<NodeType extends NodeBase = NodeBase> = NodeType & {
measured: {
width?: number;
height?: number;
};
internals: {
positionAbsolute: XYPosition;
z: number;
/** Holds a reference to the original node object provided by the user.
* Used as an optimization to avoid certain operations. */
userNode: NodeType;
handleBounds?: NodeHandleBounds;
isParent?: boolean;
bounds?: NodeBounds;
};
};
// props that get passed to a custom node
export type NodeProps<T = any> = {
id: NodeBase['id'];
data: T;
dragHandle: NodeBase['dragHandle'];
type: NodeBase['type'];
selected: NodeBase['selected'];
isConnectable: NodeBase['connectable'];
zIndex: NodeBase['zIndex'];
xPos: number;
yPos: number;
dragging: boolean;
targetPosition?: Position;
sourcePosition?: Position;
};
/**
* The node data structure that gets used for the nodes prop.
*
* @public
*/
export type NodeProps<NodeType extends NodeBase> = Pick<
NodeType,
| 'id'
| 'data'
| 'width'
| 'height'
| 'sourcePosition'
| 'targetPosition'
| 'selected'
| 'dragHandle'
| 'selectable'
| 'deletable'
| 'draggable'
| 'parentId'
> &
Required<Pick<NodeType, 'type' | 'dragging' | 'zIndex'>> & {
/** whether a node is connectable or not */
isConnectable: boolean;
/** position absolute x value */
positionAbsoluteX: number;
/** position absolute x value */
positionAbsoluteY: number;
};
export type NodeHandleBounds = {
source: HandleElement[] | null;
target: HandleElement[] | null;
source: Handle[] | null;
target: Handle[] | null;
};
export type NodeDimensionUpdate = {
export type InternalNodeUpdate = {
id: string;
nodeElement: HTMLDivElement;
forceUpdate?: boolean;
force?: boolean;
};
export type NodeBounds = XYPosition & {
@@ -78,13 +129,17 @@ export type NodeBounds = XYPosition & {
export type NodeDragItem = {
id: string;
position: XYPosition;
positionAbsolute: XYPosition;
// distance from the mouse cursor to the node when start dragging
distance: XYPosition;
width?: number | null;
height?: number | null;
measured: {
width: number;
height: number;
};
internals: {
positionAbsolute: XYPosition;
};
extent?: 'parent' | CoordinateExtent;
parentNode?: string;
parentId?: string;
dragging?: boolean;
origin?: NodeOrigin;
expandParent?: boolean;
@@ -92,8 +147,11 @@ export type NodeDragItem = {
export type NodeOrigin = [number, number];
export type OnNodeDrag = (event: MouseEvent, node: NodeBase, nodes: NodeBase[]) => void;
export type OnSelectionDrag = (event: MouseEvent, nodes: NodeBase[]) => void;
export type NodeHandle = Optional<HandleElement, 'width' | 'height'>;
export type NodeHandle = Omit<Optional<Handle, 'width' | 'height'>, 'nodeId'>;
export type Align = 'center' | 'start' | 'end';
export type NodeLookup<NodeType extends InternalNodeBase = InternalNodeBase> = Map<string, NodeType>;
export type ParentLookup<NodeType extends InternalNodeBase = InternalNodeBase> = Map<string, Map<string, NodeType>>;
+7
View File
@@ -5,6 +5,13 @@ export enum Position {
Bottom = 'bottom',
}
export const oppositePosition = {
[Position.Left]: Position.Right,
[Position.Right]: Position.Left,
[Position.Top]: Position.Bottom,
[Position.Bottom]: Position.Top,
};
export type XYPosition = {
x: number;
y: number;
+57
View File
@@ -0,0 +1,57 @@
import { HandleConnection } from '../types';
/**
* @internal
*/
export function areConnectionMapsEqual(a?: Map<string, HandleConnection>, b?: Map<string, HandleConnection>) {
if (!a && !b) {
return true;
}
if (!a || !b || a.size !== b.size) {
return false;
}
if (!a.size && !b.size) {
return true;
}
for (const key of a.keys()) {
if (!b.has(key)) {
return false;
}
}
return true;
}
/**
* We call the callback for all connections in a that are not in b
*
* @internal
*/
export function handleConnectionChange(
a: Map<string, HandleConnection>,
b: Map<string, HandleConnection>,
cb?: (diff: HandleConnection[]) => void
) {
if (!cb) {
return;
}
const diff: HandleConnection[] = [];
a.forEach((connection, key) => {
if (!b?.has(key)) {
diff.push(connection);
}
});
if (diff.length) {
cb(diff);
}
}
export function getConnectionStatus(isValid: boolean | null) {
return isValid === null ? null : isValid ? 'valid' : 'invalid';
}
+16 -20
View File
@@ -1,4 +1,4 @@
import type { Transform, XYPosition, SnapGrid, Dimensions, NodeOrigin, HandleElement, Position } from '../types';
import type { Transform, XYPosition, SnapGrid, Dimensions, Position, Handle } from '../types';
import { snapPosition, pointToRendererPoint } from './general';
export type GetPointerPositionParams = {
@@ -13,7 +13,6 @@ export function getPointerPosition(
): XYPosition & { xSnapped: number; ySnapped: number } {
const { x, y } = getEventPosition(event);
const pointerPos = pointToRendererPoint({ x, y }, transform);
const { x: xSnapped, y: ySnapped } = snapToGrid ? snapPosition(pointerPos, snapGrid) : pointerPos;
// we need the snapped position in order to be able to skip unnecessary drag events
@@ -38,11 +37,9 @@ export function isInputDOMNode(event: KeyboardEvent): boolean {
// using composed path for handling shadow dom
const target = (event.composedPath?.()?.[0] || event.target) as HTMLElement;
const isInput = inputTags.includes(target?.nodeName) || target?.hasAttribute('contenteditable');
// we want to be able to do a multi selection event if we are in an input field
const isModifierKey = event.ctrlKey || event.metaKey || event.shiftKey;
// when an input field is focused we don't want to trigger deletion or movement of nodes
return (isInput && !isModifierKey) || !!target?.closest('.nokey');
return isInput || !!target?.closest('.nokey');
}
export const isMouseEvent = (event: MouseEvent | TouchEvent): event is MouseEvent => 'clientX' in event;
@@ -58,34 +55,33 @@ export const getEventPosition = (event: MouseEvent | TouchEvent, bounds?: DOMRec
};
};
// The handle bounds are calculated relative to the node element.
// We store them in the internals object of the node in order to avoid
// unnecessary recalculations.
export const getHandleBounds = (
selector: string,
type: 'source' | 'target',
nodeElement: HTMLDivElement,
nodeBounds: DOMRect,
zoom: number,
nodeOrigin: NodeOrigin = [0, 0]
): HandleElement[] | null => {
const handles = nodeElement.querySelectorAll(selector);
nodeId: string
): Handle[] | null => {
const handles = nodeElement.querySelectorAll(`.${type}`);
if (!handles || !handles.length) {
return null;
}
const handlesArray = Array.from(handles) as HTMLDivElement[];
const nodeBounds = nodeElement.getBoundingClientRect();
const nodeOffset = {
x: nodeBounds.width * nodeOrigin[0],
y: nodeBounds.height * nodeOrigin[1],
};
return handlesArray.map((handle): HandleElement => {
return Array.from(handles).map((handle): Handle => {
const handleBounds = handle.getBoundingClientRect();
return {
id: handle.getAttribute('data-handleid'),
type,
nodeId,
position: handle.getAttribute('data-handlepos') as unknown as Position,
x: (handleBounds.left - nodeBounds.left - nodeOffset.x) / zoom,
y: (handleBounds.top - nodeBounds.top - nodeOffset.y) / zoom,
...getDimensions(handle),
x: (handleBounds.left - nodeBounds.left) / zoom,
y: (handleBounds.top - nodeBounds.top) / zoom,
...getDimensions(handle as HTMLDivElement),
};
});
};
@@ -69,6 +69,29 @@ function getControlWithCurvature({ pos, x1, y1, x2, y2, c }: GetControlWithCurva
}
}
/**
* Get a bezier path from source to target handle
* @param params.sourceX - The x position of the source handle
* @param params.sourceY - The y position of the source handle
* @param params.sourcePosition - The position of the source handle (default: Position.Bottom)
* @param params.targetX - The x position of the target handle
* @param params.targetY - The y position of the target handle
* @param params.targetPosition - The position of the target handle (default: Position.Top)
* @param params.curvature - The curvature of the bezier edge
* @returns A path string you can use in an SVG, the labelX and labelY position (center of path) and offsetX, offsetY between source handle and label
* @example
* const source = { x: 0, y: 20 };
const target = { x: 150, y: 100 };
const [path, labelX, labelY, offsetX, offsetY] = getBezierPath({
sourceX: source.x,
sourceY: source.y,
sourcePosition: Position.Right,
targetX: target.x,
targetY: target.y,
targetPosition: Position.Left,
});
*/
export function getBezierPath({
sourceX,
sourceY,
+52 -63
View File
@@ -1,6 +1,5 @@
import { Connection, Transform, errorMessages, internalsSymbol, isEdgeBase } from '../..';
import { EdgeBase, NodeBase } from '../../types';
import { isNumeric, getOverlappingArea, boxToRect, nodeToBox, getBoundsOfBoxes, devWarn } from '../general';
import { Connection, InternalNodeBase, Transform, errorMessages, isEdgeBase, EdgeBase } from '../..';
import { getOverlappingArea, boxToRect, nodeToBox, getBoundsOfBoxes, devWarn } from '../general';
// this is used for straight edges and simple smoothstep edges (LTR, RTL, BTT, TTB)
export function getEdgeCenter({
@@ -23,68 +22,34 @@ export function getEdgeCenter({
return [centerX, centerY, xOffset, yOffset];
}
const defaultEdgeTree = [{ level: 0, isMaxLevel: true, edges: [] }];
export type GroupedEdges<EdgeType extends EdgeBase> = {
edges: EdgeType[];
level: number;
isMaxLevel: boolean;
export type GetEdgeZIndexParams = {
sourceNode: InternalNodeBase;
targetNode: InternalNodeBase;
selected?: boolean;
zIndex?: number;
elevateOnSelect?: boolean;
};
export function groupEdgesByZLevel<EdgeType extends EdgeBase>(
edges: EdgeType[],
nodes: NodeBase[],
elevateEdgesOnSelect = false
): GroupedEdges<EdgeType>[] {
let maxLevel = -1;
const levelLookup = edges.reduce<Record<string, EdgeType[]>>((tree, edge) => {
const hasZIndex = isNumeric(edge.zIndex);
let z = hasZIndex ? edge.zIndex! : 0;
if (elevateEdgesOnSelect) {
const targetNode = nodes.find((n) => n.id === edge.target);
const sourceNode = nodes.find((n) => n.id === edge.source);
const edgeOrConnectedNodeSelected = edge.selected || targetNode?.selected || sourceNode?.selected;
const selectedZIndex = Math.max(
sourceNode?.[internalsSymbol]?.z || 0,
targetNode?.[internalsSymbol]?.z || 0,
1000
);
z = (hasZIndex ? edge.zIndex! : 0) + (edgeOrConnectedNodeSelected ? selectedZIndex : 0);
}
if (tree[z]) {
tree[z].push(edge);
} else {
tree[z] = [edge];
}
maxLevel = z > maxLevel ? z : maxLevel;
return tree;
}, {});
const edgeTree = Object.entries(levelLookup).map(([key, edges]) => {
const level = +key;
return {
edges,
level,
isMaxLevel: level === maxLevel,
};
});
if (edgeTree.length === 0) {
return defaultEdgeTree;
export function getElevatedEdgeZIndex({
sourceNode,
targetNode,
selected = false,
zIndex = 0,
elevateOnSelect = false,
}: GetEdgeZIndexParams): number {
if (!elevateOnSelect) {
return zIndex;
}
return edgeTree;
const edgeOrConnectedNodeSelected = selected || targetNode.selected || sourceNode.selected;
const selectedZIndex = Math.max(sourceNode.internals.z || 0, targetNode.internals.z || 0, 1000);
return zIndex + (edgeOrConnectedNodeSelected ? selectedZIndex : 0);
}
type IsEdgeVisibleParams = {
sourceNode: NodeBase;
targetNode: NodeBase;
sourceNode: InternalNodeBase;
targetNode: InternalNodeBase;
width: number;
height: number;
transform: Transform;
@@ -112,7 +77,7 @@ export function isEdgeVisible({ sourceNode, targetNode, width, height, transform
}
const getEdgeId = ({ source, sourceHandle, target, targetHandle }: Connection | EdgeBase): string =>
`xyflow__edge-${source}${sourceHandle || ''}-${target}${targetHandle || ''}`;
`xy-edge__${source}${sourceHandle || ''}-${target}${targetHandle || ''}`;
const connectionExists = (edge: EdgeBase, edges: EdgeBase[]) => {
return edges.some(
@@ -124,7 +89,15 @@ const connectionExists = (edge: EdgeBase, edges: EdgeBase[]) => {
);
};
export const addEdgeBase = <EdgeType extends EdgeBase>(
/**
* This util is a convenience function to add a new Edge to an array of edges
* @remarks It also performs some validation to make sure you don't add an invalid edge or duplicate an existing one.
* @public
* @param edgeParams - Either an Edge or a Connection you want to add
* @param edges - The array of all current edges
* @returns A new array of edges with the new edge added
*/
export const addEdge = <EdgeType extends EdgeBase>(
edgeParams: EdgeType | Connection,
edges: EdgeType[]
): EdgeType[] => {
@@ -148,18 +121,34 @@ export const addEdgeBase = <EdgeType extends EdgeBase>(
return edges;
}
if (edge.sourceHandle === null) {
delete edge.sourceHandle;
}
if (edge.targetHandle === null) {
delete edge.targetHandle;
}
return edges.concat(edge);
};
export type UpdateEdgeOptions = {
export type ReconnectEdgeOptions = {
shouldReplaceId?: boolean;
};
export const updateEdgeBase = <EdgeType extends EdgeBase>(
/**
* A handy utility to reconnect an existing edge with new properties
* @param oldEdge - The edge you want to update
* @param newConnection - The new connection you want to update the edge with
* @param edges - The array of all current edges
* @param options.shouldReplaceId - should the id of the old edge be replaced with the new connection id
* @returns the updated edges array
*/
export const reconnectEdge = <EdgeType extends EdgeBase>(
oldEdge: EdgeType,
newConnection: Connection,
edges: EdgeType[],
options: UpdateEdgeOptions = { shouldReplaceId: true }
options: ReconnectEdgeOptions = { shouldReplaceId: true }
): EdgeType[] => {
const { id: oldEdgeId, ...rest } = oldEdge;
+71 -92
View File
@@ -1,37 +1,47 @@
import { EdgePosition } from '../../types/edges';
import { ConnectionMode, OnError } from '../../types/general';
import { NodeBase, NodeHandle, NodeHandleBounds } from '../../types/nodes';
import { Position, Rect, XYPosition } from '../../types/utils';
import { errorMessages, internalsSymbol } from '../../constants';
import { HandleElement } from '../../types';
import { InternalNodeBase, NodeHandle } from '../../types/nodes';
import { Position, XYPosition } from '../../types/utils';
import { errorMessages } from '../../constants';
import { Handle } from '../../types';
import { getNodeDimensions } from '../general';
export type GetEdgePositionParams = {
id: string;
sourceNode: NodeBase;
sourceNode: InternalNodeBase;
sourceHandle: string | null;
targetNode: NodeBase;
targetNode: InternalNodeBase;
targetHandle: string | null;
connectionMode: ConnectionMode;
onError?: OnError;
};
export function getEdgePosition(params: GetEdgePositionParams): EdgePosition | null {
const [sourceNodeRect, sourceHandleBounds, isSourceValid] = getHandleDataByNode(params.sourceNode);
const [targetNodeRect, targetHandleBounds, isTargetValid] = getHandleDataByNode(params.targetNode);
function isNodeInitialized(node: InternalNodeBase): boolean {
return (
node &&
!!(node.internals.handleBounds || node.handles?.length) &&
!!(node.measured.width || node.width || node.initialWidth)
);
}
if (!isSourceValid || !isTargetValid) {
export function getEdgePosition(params: GetEdgePositionParams): EdgePosition | null {
const { sourceNode, targetNode } = params;
if (!isNodeInitialized(sourceNode) || !isNodeInitialized(targetNode)) {
return null;
}
// when connection type is loose we can define all handles as sources and connect source -> source
const targetNodeHandles =
const sourceHandleBounds = sourceNode.internals.handleBounds || toHandleBounds(sourceNode.handles);
const targetHandleBounds = targetNode.internals.handleBounds || toHandleBounds(targetNode.handles);
const sourceHandle = getHandle(sourceHandleBounds?.source ?? [], params.sourceHandle);
const targetHandle = getHandle(
// when connection type is loose we can define all handles as sources and connect source -> source
params.connectionMode === ConnectionMode.Strict
? targetHandleBounds!.target
: (targetHandleBounds!.target ?? []).concat(targetHandleBounds!.source ?? []);
const sourceHandle = getHandle(sourceHandleBounds!.source!, params.sourceHandle);
const targetHandle = getHandle(targetNodeHandles!, params.targetHandle);
const sourcePosition = sourceHandle?.position || Position.Bottom;
const targetPosition = targetHandle?.position || Position.Top;
? targetHandleBounds?.target ?? []
: (targetHandleBounds?.target ?? []).concat(targetHandleBounds?.source ?? []),
params.targetHandle
);
if (!sourceHandle || !targetHandle) {
params.onError?.(
@@ -46,14 +56,16 @@ export function getEdgePosition(params: GetEdgePositionParams): EdgePosition | n
return null;
}
const { x: sourceX, y: sourceY } = getHandlePosition(sourcePosition, sourceNodeRect, sourceHandle);
const { x: targetX, y: targetY } = getHandlePosition(targetPosition, targetNodeRect, targetHandle);
const sourcePosition = sourceHandle?.position || Position.Bottom;
const targetPosition = targetHandle?.position || Position.Top;
const source = getHandlePosition(sourceNode, sourceHandle, sourcePosition);
const target = getHandlePosition(targetNode, targetHandle, targetPosition);
return {
sourceX,
sourceY,
targetX,
targetY,
sourceX: source.x,
sourceY: source.y,
targetX: target.x,
targetY: target.y,
sourcePosition,
targetPosition,
};
@@ -64,92 +76,59 @@ function toHandleBounds(handles?: NodeHandle[]) {
return null;
}
return handles.reduce<NodeHandleBounds>(
(res, item) => {
item.width = item.width || 1;
item.height = item.height || 1;
const source = [];
const target = [];
if (item.type === 'source') {
res.source?.push(item as HandleElement);
}
for (const handle of handles) {
handle.width = handle.width ?? 1;
handle.height = handle.height ?? 1;
if (item.type === 'target') {
res.target?.push(item as HandleElement);
}
return res;
},
{
source: [],
target: [],
if (handle.type === 'source') {
source.push(handle as Handle);
} else if (handle.type === 'target') {
target.push(handle as Handle);
}
);
}
return {
source,
target,
};
}
function getHandleDataByNode(node?: NodeBase): [Rect, NodeHandleBounds | null, boolean] {
const handleBounds = node?.[internalsSymbol]?.handleBounds || toHandleBounds(node?.handles) || null;
const nodeWidth = node?.width || node?.size?.width;
const nodeHeight = node?.height || node?.size?.height;
export function getHandlePosition(
node: InternalNodeBase,
handle: Handle | null,
fallbackPosition: Position = Position.Left,
center = false
): XYPosition {
const x = (handle?.x ?? 0) + node.internals.positionAbsolute.x;
const y = (handle?.y ?? 0) + node.internals.positionAbsolute.y;
const { width, height } = handle ?? getNodeDimensions(node);
const isValid =
handleBounds &&
nodeWidth &&
nodeHeight &&
typeof node?.positionAbsolute?.x !== 'undefined' &&
typeof node?.positionAbsolute?.y !== 'undefined';
if (center) {
return { x: x + width / 2, y: y + height / 2 };
}
return [
{
x: node?.positionAbsolute?.x || 0,
y: node?.positionAbsolute?.y || 0,
width: nodeWidth || 0,
height: nodeHeight || 0,
},
handleBounds,
!!isValid,
];
}
function getHandlePosition(position: Position, nodeRect: Rect, handle: HandleElement | null = null): XYPosition {
const x = (handle?.x || 0) + nodeRect.x;
const y = (handle?.y || 0) + nodeRect.y;
const width = handle?.width || nodeRect.width;
const height = handle?.height || nodeRect.height;
const position = handle?.position ?? fallbackPosition;
switch (position) {
case Position.Top:
return {
x: x + width / 2,
y,
};
return { x: x + width / 2, y };
case Position.Right:
return {
x: x + width,
y: y + height / 2,
};
return { x: x + width, y: y + height / 2 };
case Position.Bottom:
return {
x: x + width / 2,
y: y + height,
};
return { x: x + width / 2, y: y + height };
case Position.Left:
return {
x,
y: y + height / 2,
};
return { x, y: y + height / 2 };
}
}
function getHandle(bounds: HandleElement[], handleId?: string | null): HandleElement | null {
function getHandle(bounds: Handle[], handleId?: string | null): Handle | null {
if (!bounds) {
return null;
}
if (bounds.length === 1 || !handleId) {
return bounds[0];
} else if (handleId) {
return bounds.find((d) => d.id === handleId) || null;
}
return null;
// if no handleId is given, we use the first handle, otherwise we check for the id
return (!handleId ? bounds[0] : bounds.find((d) => d.id === handleId)) || null;
}
@@ -81,8 +81,8 @@ function getPoints({
// opposite handle positions, default case
if (sourceDir[dirAccessor] * targetDir[dirAccessor] === -1) {
centerX = center.x || defaultCenterX;
centerY = center.y || defaultCenterY;
centerX = center.x ?? defaultCenterX;
centerY = center.y ?? defaultCenterY;
// --->
// |
// >---
@@ -190,6 +190,28 @@ function getBend(a: XYPosition, b: XYPosition, c: XYPosition, size: number): str
return `L ${x},${y + bendSize * yDir}Q ${x},${y} ${x + bendSize * xDir},${y}`;
}
/**
* Get a smooth step path from source to target handle
* @param params.sourceX - The x position of the source handle
* @param params.sourceY - The y position of the source handle
* @param params.sourcePosition - The position of the source handle (default: Position.Bottom)
* @param params.targetX - The x position of the target handle
* @param params.targetY - The y position of the target handle
* @param params.targetPosition - The position of the target handle (default: Position.Top)
* @returns A path string you can use in an SVG, the labelX and labelY position (center of path) and offsetX, offsetY between source handle and label
* @example
* const source = { x: 0, y: 20 };
const target = { x: 150, y: 100 };
const [path, labelX, labelY, offsetX, offsetY] = getSmoothStepPath({
sourceX: source.x,
sourceY: source.y,
sourcePosition: Position.Right,
targetX: target.x,
targetY: target.y,
targetPosition: Position.Left,
});
*/
export function getSmoothStepPath({
sourceX,
sourceY,
@@ -7,6 +7,26 @@ export type GetStraightPathParams = {
targetY: number;
};
/**
* Get a straight path from source to target handle
* @param params.sourceX - The x position of the source handle
* @param params.sourceY - The y position of the source handle
* @param params.targetX - The x position of the target handle
* @param params.targetY - The y position of the target handle
* @returns A path string you can use in an SVG, the labelX and labelY position (center of path) and offsetX, offsetY between source handle and label
* @example
* const source = { x: 0, y: 20 };
const target = { x: 150, y: 100 };
const [path, labelX, labelY, offsetX, offsetY] = getStraightPath({
sourceX: source.x,
sourceY: source.y,
sourcePosition: Position.Right,
targetX: target.x,
targetY: target.y,
targetPosition: Position.Left,
});
*/
export function getStraightPath({
sourceX,
sourceY,
+117 -46
View File
@@ -8,8 +8,11 @@ import type {
NodeOrigin,
SnapGrid,
Transform,
InternalNodeBase,
NodeLookup,
} from '../types';
import { getNodePositionWithOrigin } from './graph';
import { type Viewport } from '../types';
import { getNodePositionWithOrigin, isInternalNodeBase } from './graph';
export const clamp = (val: number, min = 0, max = 1): number => Math.min(Math.max(val, min), max);
@@ -18,21 +21,32 @@ export const clampPosition = (position: XYPosition = { x: 0, y: 0 }, extent: Coo
y: clamp(position.y, extent[0][1], extent[1][1]),
});
// returns a number between 0 and 1 that represents the velocity of the movement
// when the mouse is close to the edge of the canvas
/**
* Calculates the velocity of panning when the mouse is close to the edge of the canvas
* @internal
* @param value - One dimensional poition of the mouse (x or y)
* @param min - Minimal position on canvas before panning starts
* @param max - Maximal position on canvas before panning starts
* @returns - A number between 0 and 1 that represents the velocity of panning
*/
const calcAutoPanVelocity = (value: number, min: number, max: number): number => {
if (value < min) {
return clamp(Math.abs(value - min), 1, 50) / 50;
return clamp(Math.abs(value - min), 1, min) / min;
} else if (value > max) {
return -clamp(Math.abs(value - max), 1, 50) / 50;
return -clamp(Math.abs(value - max), 1, min) / min;
}
return 0;
};
export const calcAutoPan = (pos: XYPosition, bounds: Dimensions): number[] => {
const xMovement = calcAutoPanVelocity(pos.x, 35, bounds.width - 35) * 20;
const yMovement = calcAutoPanVelocity(pos.y, 35, bounds.height - 35) * 20;
export const calcAutoPan = (
pos: XYPosition,
bounds: Dimensions,
speed: number = 15,
distance: number = 40
): number[] => {
const xMovement = calcAutoPanVelocity(pos.x, distance, bounds.width - distance) * speed;
const yMovement = calcAutoPanVelocity(pos.y, distance, bounds.height - distance) * speed;
return [xMovement, yMovement];
};
@@ -58,23 +72,29 @@ export const boxToRect = ({ x, y, x2, y2 }: Box): Rect => ({
height: y2 - y,
});
export const nodeToRect = (node: NodeBase, nodeOrigin: NodeOrigin = [0, 0]): Rect => {
const { positionAbsolute } = getNodePositionWithOrigin(node, node.origin || nodeOrigin);
export const nodeToRect = (node: InternalNodeBase | NodeBase, nodeOrigin: NodeOrigin = [0, 0]): Rect => {
const { x, y } = isInternalNodeBase(node)
? node.internals.positionAbsolute
: getNodePositionWithOrigin(node, nodeOrigin);
return {
...positionAbsolute,
width: node.width || 0,
height: node.height || 0,
x,
y,
width: node.measured?.width ?? node.width ?? node.initialWidth ?? 0,
height: node.measured?.height ?? node.height ?? node.initialHeight ?? 0,
};
};
export const nodeToBox = (node: NodeBase, nodeOrigin: NodeOrigin = [0, 0]): Box => {
const { positionAbsolute } = getNodePositionWithOrigin(node, node.origin || nodeOrigin);
export const nodeToBox = (node: InternalNodeBase | NodeBase, nodeOrigin: NodeOrigin = [0, 0]): Box => {
const { x, y } = isInternalNodeBase(node)
? node.internals.positionAbsolute
: getNodePositionWithOrigin(node, nodeOrigin);
return {
...positionAbsolute,
x2: positionAbsolute.x + (node.width || 0),
y2: positionAbsolute.y + (node.height || 0),
x,
y,
x2: x + (node.measured?.width ?? node.width ?? node.initialWidth ?? 0),
y2: y + (node.measured?.height ?? node.height ?? node.initialHeight ?? 0),
};
};
@@ -103,35 +123,12 @@ export const devWarn = (id: string, message: string) => {
}
};
export const getPositionWithOrigin = ({
x,
y,
width,
height,
origin = [0, 0],
}: {
x: number;
y: number;
width: number;
height: number;
origin?: NodeOrigin;
}): XYPosition => {
if (!width || !height || origin[0] < 0 || origin[1] < 0 || origin[0] > 1 || origin[1] > 1) {
return { x, y };
}
return {
x: x - width * origin[0],
y: y - height * origin[1],
};
};
export function snapPosition(position: XYPosition, snapGrid: SnapGrid = [1, 1]): XYPosition {
export const snapPosition = (position: XYPosition, snapGrid: SnapGrid = [1, 1]): XYPosition => {
return {
x: snapGrid[0] * Math.round(position.x / snapGrid[0]),
y: snapGrid[1] * Math.round(position.y / snapGrid[1]),
};
}
};
export const pointToRendererPoint = (
{ x, y }: XYPosition,
@@ -154,14 +151,30 @@ export const rendererPointToPoint = ({ x, y }: XYPosition, [tx, ty, tScale]: Tra
};
};
export const getTransformForBounds = (
/**
* Returns a viewport that encloses the given bounds with optional padding.
* @public
* @remarks You can determine bounds of nodes with {@link getNodesBounds} and {@link getBoundsOfRects}
* @param bounds - Bounds to fit inside viewport
* @param width - Width of the viewport
* @param height - Height of the viewport
* @param minZoom - Minimum zoom level of the resulting viewport
* @param maxZoom - Maximum zoom level of the resulting viewport
* @param padding - Optional padding around the bounds
* @returns A transforned {@link Viewport} that encloses the given bounds which you can pass to e.g. {@link setViewport}
* @example
* const { x, y, zoom } = getViewportForBounds(
{ x: 0, y: 0, width: 100, height: 100},
1200, 800, 0.5, 2);
*/
export const getViewportForBounds = (
bounds: Rect,
width: number,
height: number,
minZoom: number,
maxZoom: number,
padding: number
): Transform => {
): Viewport => {
const xZoom = width / (bounds.width * (1 + padding));
const yZoom = height / (bounds.height * (1 + padding));
const zoom = Math.min(xZoom, yZoom);
@@ -171,7 +184,65 @@ export const getTransformForBounds = (
const x = width / 2 - boundsCenterX * clampedZoom;
const y = height / 2 - boundsCenterY * clampedZoom;
return [x, y, clampedZoom];
return { x, y, zoom: clampedZoom };
};
export const isMacOs = () => typeof navigator !== 'undefined' && navigator?.userAgent?.indexOf('Mac') >= 0;
export function isCoordinateExtent(extent?: CoordinateExtent | 'parent'): extent is CoordinateExtent {
return extent !== undefined && extent !== 'parent';
}
export function getNodeDimensions(node: {
measured?: { width?: number; height?: number };
width?: number;
height?: number;
initialWidth?: number;
initialHeight?: number;
}): { width: number; height: number } {
return {
width: node.measured?.width ?? node.width ?? node.initialWidth ?? 0,
height: node.measured?.height ?? node.height ?? node.initialHeight ?? 0,
};
}
export function nodeHasDimensions<NodeType extends NodeBase = NodeBase>(node: NodeType): boolean {
return (
(node.measured?.width ?? node.width ?? node.initialWidth) !== undefined &&
(node.measured?.height ?? node.height ?? node.initialHeight) !== undefined
);
}
/**
* Convert child position to aboslute position
*
* @internal
* @param position
* @param parentId
* @param nodeLookup
* @param nodeOrigin
* @returns an internal node with an absolute position
*/
export function evaluateAbsolutePosition(
position: XYPosition,
dimensions: { width?: number; height?: number } = { width: 0, height: 0 },
parentId: string,
nodeLookup: NodeLookup,
nodeOrigin: NodeOrigin
): XYPosition {
let nextParentId: string | undefined = parentId;
const positionAbsolute = { ...position };
while (nextParentId) {
const parent = nodeLookup.get(nextParentId);
nextParentId = parent?.parentId;
if (parent) {
const origin = parent.origin || nodeOrigin;
positionAbsolute.x += parent.internals.positionAbsolute.x - (dimensions.width ?? 0) * origin[0];
positionAbsolute.y += parent.internals.positionAbsolute.y - (dimensions.height ?? 0) * origin[1];
}
}
return positionAbsolute;
}
+255 -150
View File
@@ -4,14 +4,14 @@ import {
clampPosition,
getBoundsOfBoxes,
getOverlappingArea,
isNumeric,
rectToBox,
nodeToRect,
pointToRendererPoint,
getTransformForBounds,
getViewportForBounds,
isCoordinateExtent,
getNodeDimensions,
nodeToBox,
} from './general';
import {
type Connection,
type Transform,
type XYPosition,
type Rect,
@@ -20,21 +20,48 @@ import {
type EdgeBase,
type FitViewParamsBase,
type FitViewOptionsBase,
NodeDragItem,
CoordinateExtent,
OnError,
OnBeforeDeleteBase,
NodeLookup,
InternalNodeBase,
NodeDragItem,
} from '../types';
import { errorMessages } from '../constants';
export const isEdgeBase = <NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(
element: NodeType | Connection | EdgeType
): element is EdgeType => 'id' in element && 'source' in element && 'target' in element;
/**
* Test whether an object is useable as an Edge
* @public
* @remarks In TypeScript this is a type guard that will narrow the type of whatever you pass in to Edge if it returns true
* @param element - The element to test
* @returns A boolean indicating whether the element is an Edge
*/
export const isEdgeBase = <EdgeType extends EdgeBase = EdgeBase>(element: any): element is EdgeType =>
'id' in element && 'source' in element && 'target' in element;
export const isNodeBase = <NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(
element: NodeType | Connection | EdgeType
): element is NodeType => 'id' in element && !('source' in element) && !('target' in element);
/**
* Test whether an object is useable as a Node
* @public
* @remarks In TypeScript this is a type guard that will narrow the type of whatever you pass in to Node if it returns true
* @param element - The element to test
* @returns A boolean indicating whether the element is an Node
*/
export const isNodeBase = <NodeType extends NodeBase = NodeBase>(element: any): element is NodeType =>
'id' in element && 'position' in element && !('source' in element) && !('target' in element);
export const getOutgoersBase = <NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(
export const isInternalNodeBase = <NodeType extends InternalNodeBase = InternalNodeBase>(
element: any
): element is NodeType => 'id' in element && 'internals' in element && !('source' in element) && !('target' in element);
/**
* Pass in a node, and get connected nodes where edge.source === node.id
* @public
* @param node - The node to get the connected nodes from
* @param nodes - The array of all nodes
* @param edges - The array of all edges
* @returns An array of nodes that are connected over eges where the source is the given node
*/
export const getOutgoers = <NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(
node: NodeType | { id: string },
nodes: NodeType[],
edges: EdgeType[]
@@ -53,7 +80,15 @@ export const getOutgoersBase = <NodeType extends NodeBase = NodeBase, EdgeType e
return nodes.filter((n) => outgoerIds.has(n.id));
};
export const getIncomersBase = <NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(
/**
* Pass in a node, and get connected nodes where edge.target === node.id
* @public
* @param node - The node to get the connected nodes from
* @param nodes - The array of all nodes
* @param edges - The array of all edges
* @returns An array of nodes that are connected over eges where the target is the given node
*/
export const getIncomers = <NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(
node: NodeType | { id: string },
nodes: NodeType[],
edges: EdgeType[]
@@ -71,57 +106,39 @@ export const getIncomersBase = <NodeType extends NodeBase = NodeBase, EdgeType e
return nodes.filter((n) => incomersIds.has(n.id));
};
export const getNodePositionWithOrigin = (
node: NodeBase | undefined,
nodeOrigin: NodeOrigin = [0, 0]
): XYPosition & { positionAbsolute: XYPosition } => {
if (!node) {
return {
x: 0,
y: 0,
positionAbsolute: {
x: 0,
y: 0,
},
};
}
export const getNodePositionWithOrigin = (node: NodeBase, nodeOrigin: NodeOrigin = [0, 0]): XYPosition => {
const { width, height } = getNodeDimensions(node);
const origin = node.origin ?? nodeOrigin;
const offsetX = width * origin[0];
const offsetY = height * origin[1];
const offsetX = (node.width ?? 0) * nodeOrigin[0];
const offsetY = (node.height ?? 0) * nodeOrigin[1];
const position: XYPosition = {
return {
x: node.position.x - offsetX,
y: node.position.y - offsetY,
};
return {
...position,
positionAbsolute: node.positionAbsolute
? {
x: node.positionAbsolute.x - offsetX,
y: node.positionAbsolute.y - offsetY,
}
: position,
};
};
export const getRectOfNodes = (nodes: NodeBase[], nodeOrigin: NodeOrigin = [0, 0]): Rect => {
export type GetNodesBoundsParams = {
nodeOrigin?: NodeOrigin;
};
/**
* Determines a bounding box that contains all given nodes in an array
* @public
* @remarks Useful when combined with {@link getViewportForBounds} to calculate the correct transform to fit the given nodes in a viewport.
* @param nodes - Nodes to calculate the bounds for
* @param params.nodeOrigin - Origin of the nodes: [0, 0] - top left, [0.5, 0.5] - center
* @returns Bounding box enclosing all nodes
*/
export const getNodesBounds = (nodes: NodeBase[], params: GetNodesBoundsParams = { nodeOrigin: [0, 0] }): Rect => {
if (nodes.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
const box = nodes.reduce(
(currBox, node) => {
const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute;
return getBoundsOfBoxes(
currBox,
rectToBox({
x,
y,
width: node.width || 0,
height: node.height || 0,
})
);
const nodeBox = nodeToBox(node, params.nodeOrigin);
return getBoundsOfBoxes(currBox, nodeBox);
},
{ x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity }
);
@@ -129,46 +146,82 @@ export const getRectOfNodes = (nodes: NodeBase[], nodeOrigin: NodeOrigin = [0, 0
return boxToRect(box);
};
export const getNodesInside = <NodeType extends NodeBase>(
nodes: NodeType[],
export type GetInternalNodesBoundsParams<NodeType> = {
useRelativePosition?: boolean;
filter?: (node: NodeType) => boolean;
};
/**
* Determines a bounding box that contains all given nodes in an array
* @internal
*/
export const getInternalNodesBounds = <NodeType extends InternalNodeBase | NodeDragItem>(
nodeLookup: Map<string, NodeType>,
params: GetInternalNodesBoundsParams<NodeType> = {}
): Rect => {
if (nodeLookup.size === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
let box = { x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity };
nodeLookup.forEach((node) => {
if (params.filter === undefined || params.filter(node)) {
const nodeBox = nodeToBox(node as InternalNodeBase);
box = getBoundsOfBoxes(box, nodeBox);
}
});
return boxToRect(box);
};
export const getNodesInside = <NodeType extends NodeBase = NodeBase>(
nodes: Map<string, InternalNodeBase<NodeType>>,
rect: Rect,
[tx, ty, tScale]: Transform = [0, 0, 1],
partially = false,
// set excludeNonSelectableNodes if you want to pay attention to the nodes "selectable" attribute
excludeNonSelectableNodes = false,
nodeOrigin: NodeOrigin = [0, 0]
): NodeType[] => {
excludeNonSelectableNodes = false
): InternalNodeBase<NodeType>[] => {
const paneRect = {
...pointToRendererPoint(rect, [tx, ty, tScale]),
width: rect.width / tScale,
height: rect.height / tScale,
};
const visibleNodes = nodes.reduce<NodeType[]>((res, node) => {
const { width, height, selectable = true, hidden = false } = node;
const visibleNodes: InternalNodeBase<NodeType>[] = [];
for (const [, node] of nodes) {
const { measured, selectable = true, hidden = false } = node;
const width = measured.width ?? node.width ?? node.initialWidth ?? null;
const height = measured.height ?? node.height ?? node.initialHeight ?? null;
if ((excludeNonSelectableNodes && !selectable) || hidden) {
return res;
continue;
}
const overlappingArea = getOverlappingArea(paneRect, nodeToRect(node, nodeOrigin));
const notInitialized = width === undefined || height === undefined || width === null || height === null;
const overlappingArea = getOverlappingArea(paneRect, nodeToRect(node));
const notInitialized = width === null || height === null;
const partiallyVisible = partially && overlappingArea > 0;
const area = (width || 0) * (height || 0);
const area = (width ?? 0) * (height ?? 0);
const isVisible = notInitialized || partiallyVisible || overlappingArea >= area;
if (isVisible || node.dragging) {
res.push(node);
visibleNodes.push(node);
}
return res;
}, []);
}
return visibleNodes;
};
export const getConnectedEdgesBase = <NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(
/**
* Get all connecting edges for a given set of nodes
* @param nodes - Nodes you want to get the connected edges for
* @param edges - All edges
* @returns Array of edges that connect any of the given nodes with each other
*/
export const getConnectedEdges = <NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(
nodes: NodeType[],
edges: EdgeType[]
): EdgeType[] => {
@@ -181,23 +234,24 @@ export const getConnectedEdgesBase = <NodeType extends NodeBase = NodeBase, Edge
};
export function fitView<Params extends FitViewParamsBase<NodeBase>, Options extends FitViewOptionsBase<NodeBase>>(
{ nodes, width, height, panZoom, minZoom, maxZoom, nodeOrigin = [0, 0] }: Params,
{ nodeLookup, width, height, panZoom, minZoom, maxZoom }: Params,
options?: Options
) {
const filteredNodes = nodes.filter((n) => {
const isVisible = n.width && n.height && (options?.includeHiddenNodes || !n.hidden);
const filteredNodes: Map<string, InternalNodeBase> = new Map();
const optionNodeIds = options?.nodes ? new Set(options.nodes.map((node) => node.id)) : null;
if (options?.nodes?.length) {
return isVisible && options?.nodes.some((optionNode) => optionNode.id === n.id);
nodeLookup.forEach((n) => {
const isVisible = n.measured.width && n.measured.height && (options?.includeHiddenNodes || !n.hidden);
if (isVisible && (!optionNodeIds || optionNodeIds.has(n.id))) {
filteredNodes.set(n.id, n);
}
return isVisible;
});
if (filteredNodes.length > 0) {
const bounds = getRectOfNodes(filteredNodes, nodeOrigin);
if (filteredNodes.size > 0) {
const bounds = getInternalNodesBounds(filteredNodes);
const [x, y, zoom] = getTransformForBounds(
const viewport = getViewportForBounds(
bounds,
width,
height,
@@ -206,7 +260,7 @@ export function fitView<Params extends FitViewParamsBase<NodeBase>, Options exte
options?.padding ?? 0.1
);
panZoom.setViewport({ x, y, zoom }, { duration: options?.duration });
panZoom.setViewport(viewport, { duration: options?.duration });
return true;
}
@@ -214,108 +268,159 @@ export function fitView<Params extends FitViewParamsBase<NodeBase>, Options exte
return false;
}
function clampNodeExtent(node: NodeDragItem | NodeBase, extent?: CoordinateExtent | 'parent') {
/**
* This function clamps the passed extend by the node's width and height.
* This is needed to prevent the node from being dragged outside of its extent.
*
* @param node
* @param extent
* @returns
*/
function clampNodeExtent<NodeType extends NodeBase>(
node: NodeType,
extent?: CoordinateExtent | 'parent'
): CoordinateExtent | 'parent' | undefined {
if (!extent || extent === 'parent') {
return extent;
}
return [extent[0], [extent[1][0] - (node.width || 0), extent[1][1] - (node.height || 0)]];
return [extent[0], [extent[1][0] - (node.measured?.width ?? 0), extent[1][1] - (node.measured?.height ?? 0)]];
}
export function calcNextPosition<NodeType extends NodeBase>(
node: NodeDragItem | NodeType,
nextPosition: XYPosition,
nodes: NodeType[],
nodeExtent?: CoordinateExtent,
nodeOrigin: NodeOrigin = [0, 0],
onError?: OnError
): { position: XYPosition; positionAbsolute: XYPosition } {
const clampedNodeExtent = clampNodeExtent(node, node.extent || nodeExtent);
let currentExtent = clampedNodeExtent;
let parentNode: NodeType | null = null;
let parentPos = { x: 0, y: 0 };
/**
* This function calculates the next position of a node, taking into account the node's extent, parent node, and origin.
*
* @internal
* @returns position, positionAbsolute
*/
export function calculateNodePosition<NodeType extends NodeBase>({
nodeId,
nextPosition,
nodeLookup,
nodeOrigin = [0, 0],
nodeExtent,
onError,
}: {
nodeId: string;
nextPosition: XYPosition;
nodeLookup: NodeLookup<InternalNodeBase<NodeType>>;
nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
onError?: OnError;
}): { position: XYPosition; positionAbsolute: XYPosition } {
const node = nodeLookup.get(nodeId)!;
const parentNode = node.parentId ? nodeLookup.get(node.parentId) : undefined;
const { x: parentX, y: parentY } = parentNode ? parentNode.internals.positionAbsolute : { x: 0, y: 0 };
const origin = node.origin ?? nodeOrigin;
if (node.parentNode) {
parentNode = nodes.find((n) => n.id === node.parentNode) || null;
parentPos = parentNode
? getNodePositionWithOrigin(parentNode, parentNode.origin || nodeOrigin).positionAbsolute
: parentPos;
}
let currentExtent = clampNodeExtent(node, node.extent || nodeExtent);
if (node.extent === 'parent' && !node.expandParent) {
if (node.parentNode && node.width && node.height) {
const currNodeOrigin = node.origin || nodeOrigin;
currentExtent =
parentNode && isNumeric(parentNode.width) && isNumeric(parentNode.height)
? [
[parentPos.x + node.width * currNodeOrigin[0], parentPos.y + node.height * currNodeOrigin[1]],
[
parentPos.x + parentNode.width - node.width + node.width * currNodeOrigin[0],
parentPos.y + parentNode.height - node.height + node.height * currNodeOrigin[1],
],
]
: currentExtent;
} else {
if (!parentNode) {
onError?.('005', errorMessages['error005']());
currentExtent = clampedNodeExtent;
} else {
const nodeWidth = node.measured.width;
const nodeHeight = node.measured.height;
const parentWidth = parentNode.measured.width;
const parentHeight = parentNode.measured.height;
if (nodeWidth && nodeHeight && parentWidth && parentHeight) {
currentExtent = [
[parentX, parentY],
[parentX + parentWidth - nodeWidth, parentY + parentHeight - nodeHeight],
];
}
}
} else if (node.extent && node.parentNode && node.extent !== 'parent') {
} else if (parentNode && isCoordinateExtent(node.extent)) {
currentExtent = [
[node.extent[0][0] + parentPos.x, node.extent[0][1] + parentPos.y],
[node.extent[1][0] + parentPos.x, node.extent[1][1] + parentPos.y],
[node.extent[0][0] + parentX, node.extent[0][1] + parentY],
[node.extent[1][0] + parentX, node.extent[1][1] + parentY],
];
}
const positionAbsolute =
currentExtent && currentExtent !== 'parent'
? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
const positionAbsolute = isCoordinateExtent(currentExtent)
? clampPosition(nextPosition, currentExtent)
: nextPosition;
return {
position: {
x: positionAbsolute.x - parentPos.x,
y: positionAbsolute.y - parentPos.y,
// TODO: is there a better way to do this?
x: positionAbsolute.x - parentX + node.measured.width! * origin[0],
y: positionAbsolute.y - parentY + node.measured.height! * origin[1],
},
positionAbsolute,
};
}
// helper function to get arrays of nodes and edges that can be deleted
// you can pass in a list of nodes and edges that should be deleted
// and the function only returns elements that are deletable and also handles connected nodes and child nodes
export function getElementsToRemove<NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>({
nodesToRemove,
edgesToRemove,
/**
* Pass in nodes & edges to delete, get arrays of nodes and edges that actually can be deleted
* @internal
* @param param.nodesToRemove - The nodes to remove
* @param param.edgesToRemove - The edges to remove
* @param param.nodes - All nodes
* @param param.edges - All edges
* @param param.onBeforeDelete - Callback to check which nodes and edges can be deleted
* @returns nodes: nodes that can be deleted, edges: edges that can be deleted
*/
export async function getElementsToRemove<NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>({
nodesToRemove = [],
edgesToRemove = [],
nodes,
edges,
onBeforeDelete,
}: {
nodesToRemove: Partial<NodeType>[];
edgesToRemove: Partial<EdgeType>[];
nodes: NodeType[];
edges: EdgeType[];
}): {
matchingNodes: NodeType[];
matchingEdges: EdgeType[];
} {
const nodeIds = nodesToRemove.map((node) => node.id);
const edgeIds = edgesToRemove.map((edge) => edge.id);
onBeforeDelete?: OnBeforeDeleteBase<NodeType, EdgeType>;
}): Promise<{
nodes: NodeType[];
edges: EdgeType[];
}> {
const nodeIds = new Set(nodesToRemove.map((node) => node.id));
const matchingNodes: NodeType[] = [];
const matchingNodes = nodes.reduce<NodeType[]>((res, node) => {
const parentHit = !nodeIds.includes(node.id) && node.parentNode && res.find((n) => n.id === node.parentNode);
const deletable = typeof node.deletable === 'boolean' ? node.deletable : true;
if (deletable && (nodeIds.includes(node.id) || parentHit)) {
res.push(node);
for (const node of nodes) {
if (node.deletable === false) {
continue;
}
return res;
}, []);
const deletableEdges = edges.filter((e) => (typeof e.deletable === 'boolean' ? e.deletable : true));
const initialHitEdges = deletableEdges.filter((e) => edgeIds.includes(e.id));
const connectedEdges = getConnectedEdgesBase<NodeType, EdgeType>(matchingNodes, deletableEdges);
const matchingEdges = [...initialHitEdges, ...connectedEdges];
const isIncluded = nodeIds.has(node.id);
const parentHit = !isIncluded && node.parentId && matchingNodes.find((n) => n.id === node.parentId);
return {
matchingEdges,
matchingNodes,
};
if (isIncluded || parentHit) {
matchingNodes.push(node);
}
}
const edgeIds = new Set(edgesToRemove.map((edge) => edge.id));
const deletableEdges = edges.filter((edge) => edge.deletable !== false);
const connectedEdges = getConnectedEdges(matchingNodes, deletableEdges);
const matchingEdges: EdgeType[] = connectedEdges;
for (const edge of deletableEdges) {
const isIncluded = edgeIds.has(edge.id);
if (isIncluded && !matchingEdges.find((e) => e.id === edge.id)) {
matchingEdges.push(edge);
}
}
if (!onBeforeDelete) {
return {
edges: matchingEdges,
nodes: matchingNodes,
};
}
const onBeforeDeleteResult = await onBeforeDelete({
nodes: matchingNodes,
edges: matchingEdges,
});
if (typeof onBeforeDeleteResult === 'boolean') {
return onBeforeDeleteResult ? { edges: matchingEdges, nodes: matchingNodes } : { edges: [], nodes: [] };
}
return onBeforeDeleteResult;
}
+4
View File
@@ -1,6 +1,10 @@
export * from './connections';
export * from './dom';
export * from './edges';
export * from './graph';
export * from './general';
export * from './marker';
export * from './node-toolbar';
export * from './store';
export * from './types';
export * from './shallow-node-data';
+16 -5
View File
@@ -19,21 +19,32 @@ export function getMarkerId(marker: EdgeMarkerType | undefined, id?: string | nu
export function createMarkerIds(
edges: EdgeBase[],
{ id, defaultColor }: { id?: string | null; defaultColor?: string }
{
id,
defaultColor,
defaultMarkerStart,
defaultMarkerEnd,
}: {
id?: string | null;
defaultColor?: string;
defaultMarkerStart?: EdgeMarkerType;
defaultMarkerEnd?: EdgeMarkerType;
}
) {
const ids: string[] = [];
const ids = new Set<string>();
return edges
.reduce<MarkerProps[]>((markers, edge) => {
[edge.markerStart, edge.markerEnd].forEach((marker) => {
[edge.markerStart || defaultMarkerStart, edge.markerEnd || defaultMarkerEnd].forEach((marker) => {
if (marker && typeof marker === 'object') {
const markerId = getMarkerId(marker, id);
if (!ids.includes(markerId)) {
if (!ids.has(markerId)) {
markers.push({ id: markerId, color: marker.color || defaultColor, ...marker });
ids.push(markerId);
ids.add(markerId);
}
}
});
return markers;
}, [])
.sort((a, b) => a.id.localeCompare(b.id));
+49
View File
@@ -0,0 +1,49 @@
import { Position, type Rect, type Viewport, type Align } from '../';
export function getNodeToolbarTransform(
nodeRect: Rect,
viewport: Viewport,
position: Position,
offset: number,
align: Align
): string {
let alignmentOffset = 0.5;
if (align === 'start') {
alignmentOffset = 0;
} else if (align === 'end') {
alignmentOffset = 1;
}
// position === Position.Top
// we set the x any y position of the toolbar based on the nodes position
let pos = [
(nodeRect.x + nodeRect.width * alignmentOffset) * viewport.zoom + viewport.x,
nodeRect.y * viewport.zoom + viewport.y - offset,
];
// and than shift it based on the alignment. The shift values are in %.
let shift = [-100 * alignmentOffset, -100];
switch (position) {
case Position.Right:
pos = [
(nodeRect.x + nodeRect.width) * viewport.zoom + viewport.x + offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * viewport.zoom + viewport.y,
];
shift = [0, -100 * alignmentOffset];
break;
case Position.Bottom:
pos[1] = (nodeRect.y + nodeRect.height) * viewport.zoom + viewport.y + offset;
shift[1] = 0;
break;
case Position.Left:
pos = [
nodeRect.x * viewport.zoom + viewport.x - offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * viewport.zoom + viewport.y,
];
shift = [-100, -100 * alignmentOffset];
break;
}
return `translate(${pos[0]}px, ${pos[1]}px) translate(${shift[0]}%, ${shift[1]}%)`;
}
@@ -0,0 +1,24 @@
import { NodeBase } from '../types';
type NodeData = Pick<NodeBase, 'id' | 'type' | 'data'>;
export function shallowNodeData(a: NodeData | NodeData[] | null, b: NodeData | NodeData[] | null) {
if (a === null || b === null) {
return false;
}
const _a = Array.isArray(a) ? a : [a];
const _b = Array.isArray(b) ? b : [b];
if (_a.length !== _b.length) {
return false;
}
for (let i = 0; i < _a.length; i++) {
if (_a[i].id !== _b[i].id || _a[i].type !== _b[i].type || !Object.is(_a[i].data, _b[i].data)) {
return false;
}
}
return true;
}
+286 -119
View File
@@ -1,181 +1,328 @@
import { internalsSymbol } from '../constants';
import {
NodeBase,
CoordinateExtent,
Dimensions,
NodeDimensionUpdate,
InternalNodeUpdate,
NodeOrigin,
PanZoomInstance,
Transform,
XYPosition,
XYZPosition,
ConnectionLookup,
EdgeBase,
EdgeLookup,
InternalNodeBase,
NodeLookup,
Rect,
NodeDimensionChange,
NodePositionChange,
ParentLookup,
} from '../types';
import { getDimensions, getHandleBounds } from './dom';
import { isNumeric } from './general';
import { getBoundsOfRects, getNodeDimensions, isNumeric, nodeToRect } from './general';
import { getNodePositionWithOrigin } from './graph';
import { ParentExpandChild } from './types';
type ParentNodes = Record<string, boolean>;
const defaultOptions = {
nodeOrigin: [0, 0] as NodeOrigin,
elevateNodesOnSelect: true,
defaults: {},
};
const adoptUserNodesDefaultOptions = {
...defaultOptions,
checkEquality: true,
};
export function updateAbsolutePositions<NodeType extends NodeBase>(
nodes: NodeType[],
nodeOrigin: NodeOrigin = [0, 0],
parentNodes?: ParentNodes
nodeLookup: NodeLookup<InternalNodeBase<NodeType>>,
parentLookup: ParentLookup<InternalNodeBase<NodeType>>,
options?: UpdateNodesOptions<NodeType>
) {
return nodes.map((node) => {
if (node.parentNode && !nodes.find((n) => n.id === node.parentNode)) {
throw new Error(`Parent node ${node.parentNode} not found`);
const _options = { ...defaultOptions, ...options };
for (const node of nodeLookup.values()) {
if (!node.parentId) {
continue;
}
if (node.parentNode || parentNodes?.[node.id]) {
const parentNode = node.parentNode ? nodes.find((n) => n.id === node.parentNode) : null;
const { x, y, z } = calculateXYZPosition(
node,
nodes,
{
...node.position,
z: node[internalsSymbol]?.z ?? 0,
},
parentNode?.origin || nodeOrigin
);
node.positionAbsolute = {
x,
y,
};
node[internalsSymbol]!.z = z;
if (parentNodes?.[node.id]) {
node[internalsSymbol]!.isParent = true;
}
}
return node;
});
updateChildPosition(node, nodeLookup, parentLookup, _options);
}
}
type UpdateNodesOptions<NodeType extends NodeBase> = {
nodeOrigin?: NodeOrigin;
elevateNodesOnSelect?: boolean;
defaults?: Partial<NodeType>;
checkEquality?: boolean;
};
export function updateNodes<NodeType extends NodeBase>(
export function adoptUserNodes<NodeType extends NodeBase>(
nodes: NodeType[],
storeNodes: NodeType[],
options: UpdateNodesOptions<NodeType> = {
nodeOrigin: [0, 0] as NodeOrigin,
elevateNodesOnSelect: true,
defaults: {},
}
): NodeType[] {
const parentNodes: ParentNodes = {};
nodeLookup: NodeLookup<InternalNodeBase<NodeType>>,
parentLookup: ParentLookup<InternalNodeBase<NodeType>>,
options?: UpdateNodesOptions<NodeType>
) {
const _options = { ...adoptUserNodesDefaultOptions, ...options };
const tmpLookup = new Map(nodeLookup);
nodeLookup.clear();
parentLookup.clear();
const selectedNodeZ: number = options?.elevateNodesOnSelect ? 1000 : 0;
const nextNodes = nodes.map((n) => {
const currentStoreNode = storeNodes.find((storeNode) => n.id === storeNode.id);
const node: NodeType = {
...options.defaults,
...n,
positionAbsolute: n.position,
width: n.width || currentStoreNode?.width,
height: n.height || currentStoreNode?.height,
};
const z = (isNumeric(n.zIndex) ? n.zIndex : 0) + (n.selected ? selectedNodeZ : 0);
const currInternals = n?.[internalsSymbol] || currentStoreNode?.[internalsSymbol];
if (node.parentNode) {
parentNodes[node.parentNode] = true;
for (const userNode of nodes) {
let internalNode = tmpLookup.get(userNode.id);
if (_options.checkEquality && userNode === internalNode?.internals.userNode) {
nodeLookup.set(userNode.id, internalNode);
} else {
internalNode = {
..._options.defaults,
...userNode,
measured: {
width: userNode.measured?.width,
height: userNode.measured?.height,
},
internals: {
positionAbsolute: getNodePositionWithOrigin(userNode, _options.nodeOrigin),
handleBounds: internalNode?.internals.handleBounds,
z: calculateZ(userNode, selectedNodeZ),
userNode,
},
};
nodeLookup.set(userNode.id, internalNode);
}
Object.defineProperty(node, internalsSymbol, {
enumerable: false,
value: {
handleBounds: currInternals?.handleBounds,
z,
},
});
return node;
});
const nodesWithPositions = updateAbsolutePositions(nextNodes, options.nodeOrigin, parentNodes);
return nodesWithPositions;
if (userNode.parentId) {
updateChildPosition(internalNode, nodeLookup, parentLookup, options);
}
}
}
function calculateXYZPosition<NodeType extends NodeBase>(
node: NodeType,
nodes: NodeType[],
result: XYZPosition,
nodeOrigin: NodeOrigin
): XYZPosition {
if (!node.parentNode) {
return result;
function updateChildPosition<NodeType extends NodeBase>(
node: InternalNodeBase<NodeType>,
nodeLookup: NodeLookup<InternalNodeBase<NodeType>>,
parentLookup: ParentLookup<InternalNodeBase<NodeType>>,
options?: UpdateNodesOptions<NodeType>
) {
const _options = { ...defaultOptions, ...options };
const parentId = node.parentId!;
const parentNode = nodeLookup.get(parentId);
if (!parentNode) {
throw new Error(`Parent node ${parentId} not found`);
}
const parentNode = nodes.find((n) => n.id === node.parentNode)!;
const parentNodePosition = getNodePositionWithOrigin(parentNode, parentNode?.origin || nodeOrigin);
// update the parentLookup
const childNodes = parentLookup.get(parentId);
if (childNodes) {
childNodes.set(node.id, node);
} else {
parentLookup.set(parentId, new Map([[node.id, node]]));
}
return calculateXYZPosition(
parentNode,
nodes,
{
x: (result.x ?? 0) + parentNodePosition.x,
y: (result.y ?? 0) + parentNodePosition.y,
z: (parentNode[internalsSymbol]?.z ?? 0) > (result.z ?? 0) ? parentNode[internalsSymbol]?.z ?? 0 : result.z ?? 0,
},
parentNode.origin || nodeOrigin
);
const selectedNodeZ: number = options?.elevateNodesOnSelect ? 1000 : 0;
const { x, y, z } = calculateChildXYZ(node, parentNode, _options.nodeOrigin!, selectedNodeZ);
const currPosition = node.internals.positionAbsolute;
const positionChanged = x !== currPosition.x || y !== currPosition.y;
if (positionChanged || z !== node.internals.z) {
node.internals = {
...node.internals,
positionAbsolute: positionChanged ? { x, y } : currPosition,
z,
};
}
}
export function updateNodeDimensions(
updates: NodeDimensionUpdate[],
nodes: NodeBase[],
function calculateZ(node: NodeBase, selectedNodeZ: number) {
return (isNumeric(node.zIndex) ? node.zIndex : 0) + (node.selected ? selectedNodeZ : 0);
}
function calculateChildXYZ<NodeType extends NodeBase>(
childNode: InternalNodeBase<NodeType>,
parentNode: InternalNodeBase<NodeType>,
nodeOrigin: NodeOrigin,
selectedNodeZ: number
) {
const position = getNodePositionWithOrigin(childNode, nodeOrigin);
const childZ = calculateZ(childNode, selectedNodeZ);
const parentZ = parentNode.internals.z ?? 0;
return {
x: parentNode.internals.positionAbsolute.x + position.x,
y: parentNode.internals.positionAbsolute.y + position.y,
z: parentZ > childZ ? parentZ : childZ,
};
}
export function handleExpandParent(
children: ParentExpandChild[],
nodeLookup: NodeLookup,
parentLookup: ParentLookup,
nodeOrigin: NodeOrigin = [0, 0]
): (NodeDimensionChange | NodePositionChange)[] {
const changes: (NodeDimensionChange | NodePositionChange)[] = [];
const parentExpansions = new Map<string, { expandedRect: Rect; parent: InternalNodeBase }>();
// determine the expanded rectangle the child nodes would take for each parent
for (const child of children) {
const parent = nodeLookup.get(child.parentId);
if (!parent) {
continue;
}
const parentRect = parentExpansions.get(child.parentId)?.expandedRect ?? nodeToRect(parent);
const expandedRect = getBoundsOfRects(parentRect, child.rect);
parentExpansions.set(child.parentId, { expandedRect, parent });
}
if (parentExpansions.size > 0) {
parentExpansions.forEach(({ expandedRect, parent }, parentId) => {
// determine the position & dimensions of the parent
const positionAbsolute = parent.internals.positionAbsolute;
const dimensions = getNodeDimensions(parent);
const origin = parent.origin ?? nodeOrigin;
// determine how much the parent expands in width and position
const xChange =
expandedRect.x < positionAbsolute.x ? Math.round(Math.abs(positionAbsolute.x - expandedRect.x)) : 0;
const yChange =
expandedRect.y < positionAbsolute.y ? Math.round(Math.abs(positionAbsolute.y - expandedRect.y)) : 0;
const newWidth = Math.max(dimensions.width, Math.round(expandedRect.width));
const newHeight = Math.max(dimensions.height, Math.round(expandedRect.height));
const widthChange = (newWidth - dimensions.width) * origin[0];
const heightChange = (newHeight - dimensions.height) * origin[1];
// We need to correct the position of the parent node if the origin is not [0,0]
if (xChange > 0 || yChange > 0 || widthChange || heightChange) {
changes.push({
id: parentId,
type: 'position',
position: {
x: parent.position.x - xChange + widthChange,
y: parent.position.y - yChange + heightChange,
},
});
// We move all child nodes in the oppsite direction
// so the x,y changes of the parent do not move the children
parentLookup.get(parentId)?.forEach((childNode) => {
if (!children.some((child) => child.id === childNode.id)) {
changes.push({
id: childNode.id,
type: 'position',
position: {
x: childNode.position.x + xChange,
y: childNode.position.y + yChange,
},
});
}
});
}
// We need to correct the dimensions of the parent node if the origin is not [0,0]
if (dimensions.width < expandedRect.width || dimensions.height < expandedRect.height || xChange || yChange) {
changes.push({
id: parentId,
type: 'dimensions',
setAttributes: true,
dimensions: {
width: newWidth + (xChange ? origin[0] * xChange - widthChange : 0),
height: newHeight + (yChange ? origin[1] * yChange - heightChange : 0),
},
});
}
});
}
return changes;
}
export function updateNodeInternals<NodeType extends InternalNodeBase>(
updates: Map<string, InternalNodeUpdate>,
nodeLookup: NodeLookup<NodeType>,
parentLookup: ParentLookup<NodeType>,
domNode: HTMLElement | null,
nodeOrigin?: NodeOrigin,
onUpdate?: (id: string, dimensions: Dimensions) => void
): NodeBase[] | null {
nodeOrigin?: NodeOrigin
): { changes: (NodeDimensionChange | NodePositionChange)[]; updatedInternals: boolean } {
const viewportNode = domNode?.querySelector('.xyflow__viewport');
let updatedInternals = false;
if (!viewportNode) {
return null;
return { changes: [], updatedInternals };
}
const changes: (NodeDimensionChange | NodePositionChange)[] = [];
const style = window.getComputedStyle(viewportNode);
const { m22: zoom } = new window.DOMMatrixReadOnly(style.transform);
// in this array we collect nodes, that might trigger changes (like expanding parent)
const parentExpandChildren: ParentExpandChild[] = [];
const nextNodes = nodes.map((node) => {
const update = updates.find((u) => u.id === node.id);
if (update) {
for (const update of updates.values()) {
const node = nodeLookup.get(update.id);
if (!node) {
continue;
}
if (node.hidden) {
node.internals = {
...node.internals,
handleBounds: undefined,
};
updatedInternals = true;
} else {
const dimensions = getDimensions(update.nodeElement);
const dimensionChanged = node.measured.width !== dimensions.width || node.measured.height !== dimensions.height;
const doUpdate = !!(
dimensions.width &&
dimensions.height &&
(node.width !== dimensions.width || node.height !== dimensions.height || update.forceUpdate)
(dimensionChanged || !node.internals.handleBounds || update.force)
);
if (doUpdate) {
onUpdate?.(node.id, dimensions);
const nodeBounds = update.nodeElement.getBoundingClientRect();
return {
...node,
...dimensions,
[internalsSymbol]: {
...node[internalsSymbol],
handleBounds: {
source: getHandleBounds('.source', update.nodeElement, zoom, node.origin || nodeOrigin),
target: getHandleBounds('.target', update.nodeElement, zoom, node.origin || nodeOrigin),
},
node.measured = dimensions;
node.internals = {
...node.internals,
positionAbsolute: getNodePositionWithOrigin(node, nodeOrigin),
handleBounds: {
source: getHandleBounds('source', update.nodeElement, nodeBounds, zoom, node.id),
target: getHandleBounds('target', update.nodeElement, nodeBounds, zoom, node.id),
},
};
if (node.parentId) {
updateChildPosition(node, nodeLookup, parentLookup, { nodeOrigin });
}
updatedInternals = true;
if (dimensionChanged) {
changes.push({
id: node.id,
type: 'dimensions',
dimensions,
});
if (node.expandParent && node.parentId) {
parentExpandChildren.push({
id: node.id,
parentId: node.parentId,
rect: nodeToRect(node, nodeOrigin),
});
}
}
}
}
}
return node;
});
if (parentExpandChildren.length > 0) {
const parentExpandChanges = handleExpandParent(parentExpandChildren, nodeLookup, parentLookup, nodeOrigin);
changes.push(...parentExpandChanges);
}
return nextNodes;
return { changes, updatedInternals };
}
export function panBy({
@@ -216,3 +363,23 @@ export function panBy({
return transformChanged;
}
export function updateConnectionLookup(connectionLookup: ConnectionLookup, edgeLookup: EdgeLookup, edges: EdgeBase[]) {
connectionLookup.clear();
edgeLookup.clear();
for (const edge of edges) {
const { source, target, sourceHandle = null, targetHandle = null } = edge;
const sourceKey = `${source}-source-${sourceHandle}`;
const targetKey = `${target}-target-${targetHandle}`;
const prevSource = connectionLookup.get(sourceKey) || new Map();
const prevTarget = connectionLookup.get(targetKey) || new Map();
const connection = { edgeId: edge.id, source, target, sourceHandle, targetHandle };
edgeLookup.set(edge.id, edge);
connectionLookup.set(sourceKey, prevSource.set(`${target}-${targetHandle}`, connection));
connectionLookup.set(targetKey, prevTarget.set(`${source}-${sourceHandle}`, connection));
}
}
+4
View File
@@ -1 +1,5 @@
import { Rect } from '../types';
export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
export type ParentExpandChild = { id: string; parentId: string; rect: Rect };
+110 -70
View File
@@ -1,16 +1,16 @@
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import { select, type Selection } from 'd3-selection';
import {
calcAutoPan,
getEventPosition,
getPointerPosition,
calcNextPosition,
calculateNodePosition,
snapPosition,
getRectOfNodes,
getInternalNodesBounds,
rectToBox,
} from '../utils';
import { getDragItems, getEventHandlerParams, hasSelector, wrapSelectionDragFunc } from './utils';
import { getDragItems, getEventHandlerParams, hasSelector } from './utils';
import type {
NodeBase,
NodeDragItem,
@@ -23,16 +23,22 @@ import type {
SnapGrid,
Transform,
PanBy,
OnNodeDrag,
OnSelectionDrag,
UpdateNodePositions,
Box,
InternalNodeBase,
} from '../types';
export type OnDrag = (event: MouseEvent, dragItems: NodeDragItem[], node: NodeBase, nodes: NodeBase[]) => void;
export type OnDrag = (
event: MouseEvent,
dragItems: Map<string, NodeDragItem>,
node: NodeBase,
nodes: NodeBase[]
) => void;
type StoreItems = {
type StoreItems<OnNodeDrag> = {
nodes: NodeBase[];
nodeLookup: Map<string, InternalNodeBase>;
edges: EdgeBase[];
nodeExtent: CoordinateExtent;
snapGrid: SnapGrid;
@@ -55,15 +61,16 @@ type StoreItems = {
onSelectionDrag?: OnSelectionDrag;
onSelectionDragStop?: OnSelectionDrag;
updateNodePositions: UpdateNodePositions;
autoPanSpeed?: number;
};
export type XYDragParams = {
domNode: Element;
getStoreItems: () => StoreItems;
export type XYDragParams<OnNodeDrag> = {
getStoreItems: () => StoreItems<OnNodeDrag>;
onDragStart?: OnDrag;
onDrag?: OnDrag;
onDragStop?: OnDrag;
onNodeMouseDown?: (id: string) => void;
autoPanSpeed?: number;
};
export type XYDragInstance = {
@@ -79,30 +86,30 @@ export type DragUpdateParams = {
domNode: Element;
};
export function XYDrag({
domNode,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function XYDrag<OnNodeDrag extends (e: any, nodes: any, node: any) => void | undefined>({
onNodeMouseDown,
getStoreItems,
onDragStart,
onDrag,
onDragStop,
}: XYDragParams): XYDragInstance {
}: XYDragParams<OnNodeDrag>): XYDragInstance {
let lastPos: { x: number | null; y: number | null } = { x: null, y: null };
let autoPanId = 0;
let dragItems: NodeDragItem[] = [];
let dragItems = new Map<string, NodeDragItem>();
let autoPanStarted = false;
let mousePosition: XYPosition = { x: 0, y: 0 };
let dragEvent: MouseEvent | null = null;
let containerBounds: DOMRect | null = null;
let dragStarted = false;
const d3Selection = select(domNode);
let d3Selection: Selection<Element, unknown, null, undefined> | null = null;
let abortDrag = false; // prevents unintentional dragging on multitouch
// public functions
function update({ noDragClassName, handleSelector, domNode, isSelectable, nodeId }: DragUpdateParams) {
function updateNodes({ x, y }: XYPosition) {
d3Selection = select(domNode);
function updateNodes({ x, y }: XYPosition, dragEvent: MouseEvent | null) {
const {
nodes,
nodeLookup,
nodeExtent,
snapGrid,
snapToGrid,
@@ -118,59 +125,73 @@ export function XYDrag({
let hasChange = false;
let nodesBox: Box = { x: 0, y: 0, x2: 0, y2: 0 };
if (dragItems.length > 1 && nodeExtent) {
const rect = getRectOfNodes(dragItems as unknown as NodeBase[], nodeOrigin);
if (dragItems.size > 1 && nodeExtent) {
const rect = getInternalNodesBounds(dragItems);
nodesBox = rectToBox(rect);
}
dragItems = dragItems.map((n) => {
let nextPosition = { x: x - n.distance.x, y: y - n.distance.y };
for (const [id, dragItem] of dragItems) {
let nextPosition = { x: x - dragItem.distance.x, y: y - dragItem.distance.y };
if (snapToGrid) {
nextPosition = snapPosition(nextPosition, snapGrid);
}
// if there is selection with multiple nodes and a node extent is set, we need to adjust the node extent for each node
// based on its position so that the node stays at it's position relative to the selection.
const adjustedNodeExtent: CoordinateExtent = [
let adjustedNodeExtent: CoordinateExtent = [
[nodeExtent[0][0], nodeExtent[0][1]],
[nodeExtent[1][0], nodeExtent[1][1]],
];
if (dragItems.length > 1 && nodeExtent && !n.extent) {
adjustedNodeExtent[0][0] = n.positionAbsolute.x - nodesBox.x + nodeExtent[0][0];
adjustedNodeExtent[1][0] = n.positionAbsolute.x + (n.width ?? 0) - nodesBox.x2 + nodeExtent[1][0];
if (dragItems.size > 1 && nodeExtent && !dragItem.extent) {
const { positionAbsolute } = dragItem.internals;
const x1 = positionAbsolute.x - nodesBox.x + nodeExtent[0][0];
const x2 = positionAbsolute.x + dragItem.measured.width - nodesBox.x2 + nodeExtent[1][0];
adjustedNodeExtent[0][1] = n.positionAbsolute.y - nodesBox.y + nodeExtent[0][1];
adjustedNodeExtent[1][1] = n.positionAbsolute.y + (n.height ?? 0) - nodesBox.y2 + nodeExtent[1][1];
const y1 = positionAbsolute.y - nodesBox.y + nodeExtent[0][1];
const y2 = positionAbsolute.y + dragItem.measured.height - nodesBox.y2 + nodeExtent[1][1];
adjustedNodeExtent = [
[x1, y1],
[x2, y2],
];
}
const updatedPos = calcNextPosition(n, nextPosition, nodes, adjustedNodeExtent, nodeOrigin, onError);
const { position, positionAbsolute } = calculateNodePosition({
nodeId: id,
nextPosition,
nodeLookup,
nodeExtent: adjustedNodeExtent,
nodeOrigin,
onError,
});
// we want to make sure that we only fire a change event when there is a change
hasChange = hasChange || n.position.x !== updatedPos.position.x || n.position.y !== updatedPos.position.y;
hasChange = hasChange || dragItem.position.x !== position.x || dragItem.position.y !== position.y;
n.position = updatedPos.position;
n.positionAbsolute = updatedPos.positionAbsolute;
return n;
});
dragItem.position = position;
dragItem.internals.positionAbsolute = positionAbsolute;
}
if (!hasChange) {
return;
}
updateNodePositions(dragItems, true, true);
const onNodeOrSelectionDrag = nodeId ? onNodeDrag : wrapSelectionDragFunc(onSelectionDrag);
updateNodePositions(dragItems, true);
if (dragEvent) {
if (dragEvent && (onDrag || onNodeDrag || (!nodeId && onSelectionDrag))) {
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodes,
nodeLookup,
});
onDrag?.(dragEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeOrSelectionDrag?.(dragEvent as MouseEvent, currentNode, currentNodes);
onDrag?.(dragEvent, dragItems, currentNode, currentNodes);
onNodeDrag?.(dragEvent, currentNode, currentNodes);
if (!nodeId) {
onSelectionDrag?.(dragEvent, currentNodes);
}
}
}
@@ -179,16 +200,16 @@ export function XYDrag({
return;
}
const [xMovement, yMovement] = calcAutoPan(mousePosition, containerBounds);
const { transform, panBy, autoPanSpeed } = getStoreItems();
const [xMovement, yMovement] = calcAutoPan(mousePosition, containerBounds, autoPanSpeed);
if (xMovement !== 0 || yMovement !== 0) {
const { transform, panBy } = getStoreItems();
lastPos.x = (lastPos.x ?? 0) - xMovement / transform[2];
lastPos.y = (lastPos.y ?? 0) - yMovement / transform[2];
if (panBy({ x: xMovement, y: yMovement })) {
updateNodes(lastPos as XYPosition);
updateNodes(lastPos as XYPosition, null);
}
}
autoPanId = requestAnimationFrame(autoPan);
@@ -196,7 +217,7 @@ export function XYDrag({
function startDrag(event: UseDragEvent) {
const {
nodes,
nodeLookup,
multiSelectionActive,
nodesDraggable,
transform,
@@ -211,7 +232,7 @@ export function XYDrag({
dragStarted = true;
if ((!selectNodesOnDrag || !isSelectable) && !multiSelectionActive && nodeId) {
if (!nodes.find((n) => n.id === nodeId)?.selected) {
if (!nodeLookup.get(nodeId)?.selected) {
// we need to reset selected nodes when selectNodesOnDrag=false
unselectNodesAndEdges();
}
@@ -223,18 +244,21 @@ export function XYDrag({
const pointerPos = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
lastPos = pointerPos;
dragItems = getDragItems(nodes, nodesDraggable, pointerPos, nodeId);
dragItems = getDragItems(nodeLookup, nodesDraggable, pointerPos, nodeId);
const onNodeOrSelectionDragStart = nodeId ? onNodeDragStart : wrapSelectionDragFunc(onSelectionDragStart);
if (dragItems) {
if (dragItems.size > 0 && (onDragStart || onNodeDragStart || (!nodeId && onSelectionDragStart))) {
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodes,
nodeLookup,
});
onDragStart?.(event.sourceEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeOrSelectionDragStart?.(event.sourceEvent as MouseEvent, currentNode, currentNodes);
onNodeDragStart?.(event.sourceEvent as MouseEvent, currentNode, currentNodes);
if (!nodeId) {
onSelectionDragStart?.(event.sourceEvent as MouseEvent, currentNodes);
}
}
}
@@ -242,6 +266,8 @@ export function XYDrag({
.on('start', (event: UseDragEvent) => {
const { domNode, nodeDragThreshold, transform, snapGrid, snapToGrid } = getStoreItems();
abortDrag = false;
if (nodeDragThreshold === 0) {
startDrag(event);
}
@@ -255,6 +281,14 @@ export function XYDrag({
const { autoPanOnNodeDrag, transform, snapGrid, snapToGrid, nodeDragThreshold } = getStoreItems();
const pointerPos = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
if (event.sourceEvent.type === 'touchmove' && event.sourceEvent.touches.length > 1) {
abortDrag = true;
}
if (abortDrag) {
return;
}
if (!autoPanStarted && autoPanOnNodeDrag && dragStarted) {
autoPanStarted = true;
autoPan();
@@ -272,14 +306,14 @@ export function XYDrag({
// skip events without movement
if ((lastPos.x !== pointerPos.xSnapped || lastPos.y !== pointerPos.ySnapped) && dragItems && dragStarted) {
dragEvent = event.sourceEvent as MouseEvent;
// dragEvent = event.sourceEvent as MouseEvent;
mousePosition = getEventPosition(event.sourceEvent, containerBounds!);
updateNodes(pointerPos);
updateNodes(pointerPos, event.sourceEvent as MouseEvent);
}
})
.on('end', (event: UseDragEvent) => {
if (!dragStarted) {
if (!dragStarted || abortDrag) {
return;
}
@@ -287,19 +321,25 @@ export function XYDrag({
dragStarted = false;
cancelAnimationFrame(autoPanId);
if (dragItems) {
const { nodes, updateNodePositions, onNodeDragStop, onSelectionDragStop } = getStoreItems();
const onNodeOrSelectionDragStop = nodeId ? onNodeDragStop : wrapSelectionDragFunc(onSelectionDragStop);
if (dragItems.size > 0) {
const { nodeLookup, updateNodePositions, onNodeDragStop, onSelectionDragStop } = getStoreItems();
updateNodePositions(dragItems, false, false);
updateNodePositions(dragItems, false);
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodes,
});
onDragStop?.(event.sourceEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeOrSelectionDragStop?.(event.sourceEvent as MouseEvent, currentNode, currentNodes);
if (onDragStop || onNodeDragStop || (!nodeId && onSelectionDragStop)) {
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodeLookup,
});
onDragStop?.(event.sourceEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeDragStop?.(event.sourceEvent as MouseEvent, currentNode, currentNodes);
if (!nodeId) {
onSelectionDragStop?.(event.sourceEvent as MouseEvent, currentNodes);
}
}
}
})
.filter((event: MouseEvent) => {
@@ -316,7 +356,7 @@ export function XYDrag({
}
function destroy() {
d3Selection.on('.drag', null);
d3Selection?.on('.drag', null);
}
return {
+71 -51
View File
@@ -1,15 +1,11 @@
import { type NodeDragItem, type XYPosition, NodeBase } from '../types';
import { type NodeDragItem, type XYPosition, InternalNodeBase, NodeBase, NodeLookup } from '../types';
export function wrapSelectionDragFunc(selectionFunc?: (event: MouseEvent, nodes: NodeBase[]) => void) {
return (event: MouseEvent, _: NodeBase, nodes: NodeBase[]) => selectionFunc?.(event, nodes);
}
export function isParentSelected<NodeType extends NodeBase>(node: NodeType, nodes: NodeType[]): boolean {
if (!node.parentNode) {
export function isParentSelected<NodeType extends NodeBase>(node: NodeType, nodeLookup: NodeLookup): boolean {
if (!node.parentId) {
return false;
}
const parentNode = nodes.find((node) => node.id === node.parentNode);
const parentNode = nodeLookup.get(node.parentId);
if (!parentNode) {
return false;
@@ -19,7 +15,7 @@ export function isParentSelected<NodeType extends NodeBase>(node: NodeType, node
return true;
}
return isParentSelected(parentNode, nodes);
return isParentSelected(parentNode, nodeLookup);
}
export function hasSelector(target: Element, selector: string, domNode: Element): boolean {
@@ -36,60 +32,84 @@ export function hasSelector(target: Element, selector: string, domNode: Element)
// looks for all selected nodes and created a NodeDragItem for each of them
export function getDragItems<NodeType extends NodeBase>(
nodes: NodeType[],
nodeLookup: Map<string, InternalNodeBase<NodeType>>,
nodesDraggable: boolean,
mousePos: XYPosition,
nodeId?: string
): NodeDragItem[] {
return nodes
.filter(
(n) =>
(n.selected || n.id === nodeId) &&
(!n.parentNode || !isParentSelected(n, nodes)) &&
(n.draggable || (nodesDraggable && typeof n.draggable === 'undefined'))
)
.map((n) => ({
id: n.id,
position: n.position || { x: 0, y: 0 },
positionAbsolute: n.positionAbsolute || { x: 0, y: 0 },
distance: {
x: mousePos.x - (n.positionAbsolute?.x ?? 0),
y: mousePos.y - (n.positionAbsolute?.y ?? 0),
},
delta: {
x: 0,
y: 0,
},
extent: n.extent,
parentNode: n.parentNode,
width: n.width,
height: n.height,
origin: n.origin,
expandParent: n.expandParent,
}));
): Map<string, NodeDragItem> {
const dragItems = new Map<string, NodeDragItem>();
for (const [id, node] of nodeLookup) {
if (
(node.selected || node.id === nodeId) &&
(!node.parentId || !isParentSelected(node, nodeLookup)) &&
(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined'))
) {
const internalNode = nodeLookup.get(id);
if (internalNode) {
dragItems.set(id, {
id,
position: internalNode.position || { x: 0, y: 0 },
distance: {
x: mousePos.x - internalNode.internals.positionAbsolute.x,
y: mousePos.y - internalNode.internals.positionAbsolute.y,
},
extent: internalNode.extent,
parentId: internalNode.parentId,
origin: internalNode.origin,
expandParent: internalNode.expandParent,
internals: {
positionAbsolute: internalNode.internals.positionAbsolute || { x: 0, y: 0 },
},
measured: {
width: internalNode.measured.width ?? 0,
height: internalNode.measured.height ?? 0,
},
});
}
}
}
return dragItems;
}
// returns two params:
// 1. the dragged node (or the first of the list, if we are dragging a node selection)
// 2. array of selected nodes (for multi selections)
export function getEventHandlerParams<NodeType extends NodeBase>({
export function getEventHandlerParams<NodeType extends InternalNodeBase>({
nodeId,
dragItems,
nodes,
nodeLookup,
}: {
nodeId?: string;
dragItems: NodeDragItem[];
nodes: NodeType[];
}): [NodeType, NodeType[]] {
const extentedDragItems: NodeType[] = dragItems.map((n) => {
const node = nodes.find((node) => node.id === n.id)!;
dragItems: Map<string, NodeDragItem>;
nodeLookup: Map<string, NodeType>;
}): [NodeBase, NodeBase[]] {
const nodesFromDragItems: NodeBase[] = [];
return {
for (const [id, dragItem] of dragItems) {
const node = nodeLookup.get(id)?.internals.userNode;
if (node) {
nodesFromDragItems.push({
...node,
position: dragItem.position,
});
}
}
if (!nodeId) {
return [nodesFromDragItems[0], nodesFromDragItems];
}
const node = nodeLookup.get(nodeId)!.internals.userNode;
return [
{
...node,
position: n.position,
positionAbsolute: n.positionAbsolute,
};
});
return [nodeId ? extentedDragItems.find((n) => n.id === nodeId)! : extentedDragItems[0], extentedDragItems];
position: dragItems.get(nodeId)?.position || node.position,
},
nodesFromDragItems,
];
}
+108 -121
View File
@@ -1,73 +1,25 @@
import { pointToRendererPoint, rendererPointToPoint, getHostForElement, calcAutoPan, getEventPosition } from '../utils';
import {
pointToRendererPoint,
getHostForElement,
calcAutoPan,
getEventPosition,
getHandlePosition,
rendererPointToPoint,
} from '../utils';
import {
ConnectionMode,
type OnConnect,
type OnConnectStart,
type HandleType,
Position,
oppositePosition,
ConnectionInProgress,
type Handle,
type Connection,
type PanBy,
type NodeBase,
type Transform,
type ConnectingHandle,
type OnConnectEnd,
type UpdateConnection,
type IsValidConnection,
type ConnectionHandle,
} from '../types';
import { getClosestHandle, getConnectionStatus, getHandleLookup, getHandleType, resetRecentHandle } from './utils';
export type OnPointerDownParams = {
autoPanOnConnect: boolean;
connectionMode: ConnectionMode;
connectionRadius: number;
domNode: HTMLDivElement | null;
handleId: string | null;
nodeId: string;
isTarget: boolean;
nodes: NodeBase[];
lib: string;
edgeUpdaterType?: HandleType;
updateConnection: UpdateConnection;
panBy: PanBy;
cancelConnection: () => void;
onConnectStart?: OnConnectStart;
onConnect?: OnConnect;
onConnectEnd?: OnConnectEnd;
isValidConnection?: IsValidConnection;
onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void;
getTransform: () => Transform;
};
export type IsValidParams = {
handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'> | null;
connectionMode: ConnectionMode;
fromNodeId: string;
fromHandleId: string | null;
fromType: HandleType;
isValidConnection?: IsValidConnection;
doc: Document | ShadowRoot;
lib: string;
};
export type XYHandleInstance = {
onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void;
isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result;
};
type Result = {
handleDomNode: Element | null;
isValid: boolean;
connection: Connection;
endHandle: ConnectingHandle | null;
};
const nullConnection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null };
import { getClosestHandle, isConnectionValid, getHandleLookup, getHandleType } from './utils';
import { IsValidParams, OnPointerDownParams, Result, XYHandleInstance } from './types';
const alwaysValid = () => true;
let connectionStartHandle: ConnectingHandle | null = null;
function onPointerDown(
event: MouseEvent | TouchEvent,
{
@@ -78,24 +30,27 @@ function onPointerDown(
edgeUpdaterType,
isTarget,
domNode,
nodes,
nodeLookup,
lib,
autoPanOnConnect,
flowId,
panBy,
cancelConnection,
onConnectStart,
onConnect,
onConnectEnd,
isValidConnection = alwaysValid,
onEdgeUpdateEnd,
onReconnectEnd,
updateConnection,
getTransform,
getFromHandle,
autoPanSpeed,
}: OnPointerDownParams
) {
// when xyflow is used inside a shadow root we can't use document
const doc = getHostForElement(event.target as HTMLElement);
let autoPanId = 0;
let closestHandle: ConnectionHandle | null;
let closestHandle: Handle | null;
const { x, y } = getEventPosition(event);
const clickedHandle = doc?.elementFromPoint(x, y);
@@ -106,15 +61,14 @@ function onPointerDown(
return;
}
let prevActiveHandle: Element;
let connectionPosition = getEventPosition(event, containerBounds);
let position = getEventPosition(event, containerBounds);
let autoPanStarted = false;
let connection: Connection | null = null;
let isValid = false;
let isValid: boolean | null = false;
let handleDomNode: Element | null = null;
const handleLookup = getHandleLookup({
nodes,
const [handleLookup, fromHandleInternal] = getHandleLookup({
nodeLookup,
nodeId,
handleId,
handleType,
@@ -125,34 +79,54 @@ function onPointerDown(
if (!autoPanOnConnect || !containerBounds) {
return;
}
const [x, y] = calcAutoPan(connectionPosition, containerBounds);
const [x, y] = calcAutoPan(position, containerBounds, autoPanSpeed);
panBy({ x, y });
autoPanId = requestAnimationFrame(autoPan);
}
// Stays the same for all consecutive pointermove events
connectionStartHandle = {
const fromHandle: Handle = {
...fromHandleInternal,
nodeId,
handleId,
type: handleType,
position: fromHandleInternal.position,
};
updateConnection({
connectionPosition,
connectionStatus: null,
// connectionNodeId etc will be removed in the next major in favor of connectionStartHandle
connectionStartHandle,
connectionEndHandle: null,
});
const fromNodeInternal = nodeLookup.get(nodeId)!;
const from = getHandlePosition(fromNodeInternal, fromHandle, Position.Left, true);
const newConnection: ConnectionInProgress = {
inProgress: true,
isValid: null,
from,
fromHandle,
fromPosition: fromHandle.position,
fromNode: fromNodeInternal.internals.userNode,
to: position,
toHandle: null,
toPosition: oppositePosition[fromHandle.position],
toNode: null,
};
updateConnection(newConnection);
let previousConnection: ConnectionInProgress = newConnection;
onConnectStart?.(event, { nodeId, handleId, handleType });
function onPointerMove(event: MouseEvent | TouchEvent) {
if (!getFromHandle() || !fromHandle) {
onPointerUp(event);
return;
}
const transform = getTransform();
connectionPosition = getEventPosition(event, containerBounds);
position = getEventPosition(event, containerBounds);
closestHandle = getClosestHandle(
pointToRendererPoint(connectionPosition, transform, false, [1, 1]),
pointToRendererPoint(position, transform, false, [1, 1]),
connectionRadius,
handleLookup
);
@@ -171,39 +145,43 @@ function onPointerDown(
isValidConnection,
doc,
lib,
flowId,
handleLookup,
});
handleDomNode = result.handleDomNode;
connection = result.connection;
isValid = result.isValid;
isValid = isConnectionValid(!!closestHandle, result.isValid);
updateConnection({
connectionStartHandle,
connectionPosition:
const newConnection: ConnectionInProgress = {
// from stays the same
...previousConnection,
isValid,
to:
closestHandle && isValid
? rendererPointToPoint(
{
x: closestHandle.x,
y: closestHandle.y,
},
transform
)
: connectionPosition,
connectionStatus: getConnectionStatus(!!closestHandle, isValid),
connectionEndHandle: result.endHandle,
});
? rendererPointToPoint({ x: closestHandle.x, y: closestHandle.y }, transform)
: position,
toHandle: result.toHandle,
toPosition: isValid && result.toHandle ? result.toHandle.position : oppositePosition[fromHandle.position],
toNode: result.toHandle ? nodeLookup.get(result.toHandle.nodeId)!.internals.userNode : null,
};
if (!closestHandle && !isValid && !handleDomNode) {
return resetRecentHandle(prevActiveHandle, lib);
// we don't want to trigger an update when the connection
// is snapped to the same handle as before
if (
isValid &&
closestHandle &&
previousConnection.toHandle &&
newConnection.toHandle &&
previousConnection.toHandle.type === newConnection.toHandle.type &&
previousConnection.toHandle.nodeId === newConnection.toHandle.nodeId &&
previousConnection.toHandle.id === newConnection.toHandle.id
) {
return;
}
if (connection.source !== connection.target && handleDomNode) {
resetRecentHandle(prevActiveHandle, lib);
prevActiveHandle = handleDomNode;
handleDomNode.classList.add('connecting', `${lib}-flow__handle-connecting`);
handleDomNode.classList.toggle('valid', isValid);
handleDomNode.classList.toggle(`${lib}-flow__handle-valid`, isValid);
}
updateConnection(newConnection);
previousConnection = newConnection;
}
function onPointerUp(event: MouseEvent | TouchEvent) {
@@ -216,17 +194,15 @@ function onPointerDown(
onConnectEnd?.(event);
if (edgeUpdaterType) {
onEdgeUpdateEnd?.(event);
onReconnectEnd?.(event);
}
resetRecentHandle(prevActiveHandle, lib);
cancelConnection();
cancelAnimationFrame(autoPanId);
autoPanStarted = false;
isValid = false;
connection = null;
handleDomNode = null;
connectionStartHandle = null;
doc.removeEventListener('mousemove', onPointerMove as EventListener);
doc.removeEventListener('mouseup', onPointerUp as EventListener);
@@ -253,13 +229,16 @@ function isValidHandle(
fromType,
doc,
lib,
flowId,
isValidConnection = alwaysValid,
handleLookup,
}: IsValidParams
) {
const isTarget = fromType === 'target';
const handleDomNode = doc.querySelector(
`.${lib}-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]`
);
const handleDomNode = handle
? doc.querySelector(`.${lib}-flow__handle[data-id="${flowId}-${handle?.nodeId}-${handle?.id}-${handle?.type}"]`)
: null;
const { x, y } = getEventPosition(event);
const handleBelow = doc.elementFromPoint(x, y);
// we always want to prioritize the handle below the mouse cursor over the closest distance handle,
@@ -269,8 +248,8 @@ function isValidHandle(
const result: Result = {
handleDomNode: handleToCheck,
isValid: false,
connection: nullConnection,
endHandle: null,
connection: null,
toHandle: null,
};
if (handleToCheck) {
@@ -280,6 +259,10 @@ function isValidHandle(
const connectable = handleToCheck.classList.contains('connectable');
const connectableEnd = handleToCheck.classList.contains('connectableend');
if (!handleNodeId) {
return result;
}
const connection: Connection = {
source: isTarget ? handleNodeId : fromNodeId,
sourceHandle: isTarget ? handleId : fromHandleId,
@@ -297,14 +280,18 @@ function isValidHandle(
? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target')
: handleNodeId !== fromNodeId || handleId !== fromHandleId);
if (isValid) {
result.endHandle = {
nodeId: handleNodeId as string,
handleId,
type: handleType as HandleType,
};
result.isValid = isValid && isValidConnection(connection);
result.isValid = isValidConnection(connection);
if (handleLookup) {
const toHandle = handleLookup.find(
(h) => h.id === handleId && h.nodeId === handleNodeId && h.type === handleType
);
if (toHandle) {
result.toHandle = {
...toHandle,
};
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import {
ConnectionMode,
type Connection,
type OnConnect,
type OnConnectStart,
type HandleType,
type PanBy,
type Transform,
type Handle,
type OnConnectEnd,
type UpdateConnection,
type IsValidConnection,
NodeLookup,
} from '../types';
export type OnPointerDownParams = {
autoPanOnConnect: boolean;
connectionMode: ConnectionMode;
connectionRadius: number;
domNode: HTMLDivElement | null;
handleId: string | null;
nodeId: string;
isTarget: boolean;
nodeLookup: NodeLookup;
lib: string;
flowId: string | null;
edgeUpdaterType?: HandleType;
updateConnection: UpdateConnection;
panBy: PanBy;
cancelConnection: () => void;
onConnectStart?: OnConnectStart;
onConnect?: OnConnect;
onConnectEnd?: OnConnectEnd;
isValidConnection?: IsValidConnection;
onReconnectEnd?: (evt: MouseEvent | TouchEvent) => void;
getTransform: () => Transform;
getFromHandle: () => Handle | null;
autoPanSpeed?: number;
};
export type IsValidParams = {
handle: Pick<Handle, 'nodeId' | 'id' | 'type'> | null;
connectionMode: ConnectionMode;
fromNodeId: string;
fromHandleId: string | null;
fromType: HandleType;
isValidConnection?: IsValidConnection;
doc: Document | ShadowRoot;
lib: string;
flowId: string | null;
handleLookup?: Handle[];
};
export type XYHandleInstance = {
onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void;
isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result;
};
export type Result = {
handleDomNode: Element | null;
isValid: boolean;
connection: Connection | null;
toHandle: Handle | null;
};
+45 -50
View File
@@ -1,44 +1,39 @@
import { getHandlePosition } from '../utils';
import {
ConnectionStatus,
type HandleType,
type NodeHandleBounds,
type XYPosition,
type NodeBase,
type ConnectionHandle,
type Handle,
InternalNodeBase,
NodeLookup,
} from '../types';
import { internalsSymbol } from '../constants';
// this functions collects all handles and adds an absolute position
// so that we can later find the closest handle to the mouse position
export function getHandles(
node: NodeBase,
function getHandles(
node: InternalNodeBase,
handleBounds: NodeHandleBounds,
type: HandleType,
currentHandle: string
): ConnectionHandle[] {
return (handleBounds[type] || []).reduce<ConnectionHandle[]>((res, h) => {
if (`${node.id}-${h.id}-${type}` !== currentHandle) {
res.push({
id: h.id || null,
type,
nodeId: node.id,
x: (node.positionAbsolute?.x ?? 0) + h.x + h.width / 2,
y: (node.positionAbsolute?.y ?? 0) + h.y + h.height / 2,
});
currentHandle: { nodeId: string; handleId: string | null; handleType: HandleType }
): [Handle[], Handle | null] {
let excludedHandle = null;
const handles = (handleBounds[type] || []).reduce<Handle[]>((res, handle) => {
if (node.id === currentHandle.nodeId && type === currentHandle.handleType && handle.id === currentHandle.handleId) {
excludedHandle = handle;
} else {
const handleXY = getHandlePosition(node, handle, handle.position, true);
res.push({ ...handle, ...handleXY });
}
return res;
}, []);
return [handles, excludedHandle];
}
export function getClosestHandle(
pos: XYPosition,
connectionRadius: number,
handles: ConnectionHandle[]
): ConnectionHandle | null {
let closestHandles: ConnectionHandle[] = [];
export function getClosestHandle(pos: XYPosition, connectionRadius: number, handles: Handle[]): Handle | null {
let closestHandles: Handle[] = [];
let minDistance = Infinity;
handles.forEach((handle) => {
for (const handle of handles) {
const distance = Math.sqrt(Math.pow(handle.x - pos.x, 2) + Math.pow(handle.y - pos.y, 2));
if (distance <= connectionRadius) {
if (distance < minDistance) {
@@ -49,7 +44,7 @@ export function getClosestHandle(
}
minDistance = distance;
}
});
}
if (!closestHandles.length) {
return null;
@@ -62,28 +57,32 @@ export function getClosestHandle(
}
type GetHandleLookupParams = {
nodes: NodeBase[];
nodeLookup: NodeLookup;
nodeId: string;
handleId: string | null;
handleType: string;
handleType: HandleType;
};
export function getHandleLookup({ nodes, nodeId, handleId, handleType }: GetHandleLookupParams) {
return nodes.reduce<ConnectionHandle[]>((res, node) => {
if (node[internalsSymbol]) {
const { handleBounds } = node[internalsSymbol];
let sourceHandles: ConnectionHandle[] = [];
let targetHandles: ConnectionHandle[] = [];
export function getHandleLookup({
nodeLookup,
nodeId,
handleId,
handleType,
}: GetHandleLookupParams): [Handle[], Handle] {
const connectionHandles: Handle[] = [];
const currentHandle = { nodeId, handleId, handleType };
let excludedHandle: Handle | null = null;
if (handleBounds) {
sourceHandles = getHandles(node, handleBounds, 'source', `${nodeId}-${handleId}-${handleType}`);
targetHandles = getHandles(node, handleBounds, 'target', `${nodeId}-${handleId}-${handleType}`);
}
res.push(...sourceHandles, ...targetHandles);
for (const node of nodeLookup.values()) {
if (node.internals.handleBounds) {
const [sourceHandles, excludedSource] = getHandles(node, node.internals.handleBounds, 'source', currentHandle);
const [targetHandles, excludedTarget] = getHandles(node, node.internals.handleBounds, 'target', currentHandle);
excludedHandle = excludedHandle ? excludedHandle : excludedSource ?? excludedTarget;
connectionHandles.push(...sourceHandles, ...targetHandles);
}
return res;
}, []);
}
return [connectionHandles, excludedHandle!];
}
export function getHandleType(
@@ -101,18 +100,14 @@ export function getHandleType(
return null;
}
export function resetRecentHandle(handleDomNode: Element, lib: string): void {
handleDomNode?.classList.remove('valid', 'connecting', `${lib}-flow__handle-valid`, `${lib}-flow__handle-connecting`);
}
export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleValid: boolean) {
let connectionStatus = null;
export function isConnectionValid(isInsideConnectionRadius: boolean, isHandleValid: boolean) {
let isValid: boolean | null = null;
if (isHandleValid) {
connectionStatus = 'valid';
isValid = true;
} else if (isInsideConnectionRadius && !isHandleValid) {
connectionStatus = 'invalid';
isValid = false;
}
return connectionStatus as ConnectionStatus;
return isValid;
}
+27 -5
View File
@@ -38,6 +38,7 @@ export function XYMinimap({ domNode, panZoom, getTransform, getViewScale }: XYMi
zoomable = true,
inversePan = false,
}: XYMinimapUpdate) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const zoomHandler = (event: D3ZoomEvent<SVGSVGElement, any>) => {
const transform = getTransform();
@@ -54,18 +55,36 @@ export function XYMinimap({ domNode, panZoom, getTransform, getViewScale }: XYMi
panZoom.scaleTo(nextZoom);
};
let panStart = [0, 0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const panStartHandler = (event: D3ZoomEvent<HTMLDivElement, any>) => {
if (event.sourceEvent.type === 'mousedown' || event.sourceEvent.type === 'touchstart') {
panStart = [
event.sourceEvent.clientX ?? event.sourceEvent.touches[0].clientX,
event.sourceEvent.clientY ?? event.sourceEvent.touches[0].clientY,
];
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const panHandler = (event: D3ZoomEvent<HTMLDivElement, any>) => {
const transform = getTransform();
if (event.sourceEvent.type !== 'mousemove' || !panZoom) {
if ((event.sourceEvent.type !== 'mousemove' && event.sourceEvent.type !== 'touchmove') || !panZoom) {
return;
}
// @TODO: how to calculate the correct next position? Math.max(1, transform[2]) is a workaround.
const moveScale = getViewScale() * Math.max(1, transform[2]) * (inversePan ? -1 : 1);
const panCurrent = [
event.sourceEvent.clientX ?? event.sourceEvent.touches[0].clientX,
event.sourceEvent.clientY ?? event.sourceEvent.touches[0].clientY,
];
const panDelta = [panCurrent[0] - panStart[0], panCurrent[1] - panStart[1]];
panStart = panCurrent;
const moveScale = getViewScale() * Math.max(transform[2], Math.log(transform[2])) * (inversePan ? -1 : 1);
const position = {
x: transform[0] - event.sourceEvent.movementX * moveScale,
y: transform[1] - event.sourceEvent.movementY * moveScale,
x: transform[0] - panDelta[0] * moveScale,
y: transform[1] - panDelta[1] * moveScale,
};
const extent: CoordinateExtent = [
[0, 0],
@@ -84,8 +103,11 @@ export function XYMinimap({ domNode, panZoom, getTransform, getViewScale }: XYMi
};
const zoomAndPanHandler = zoom()
.on('start', panStartHandler)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.on('zoom', pannable ? panHandler : null)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.on('zoom.wheel', zoomable ? zoomHandler : null);
@@ -69,6 +69,7 @@ export function XYPanZoom({
);
const d3ZoomHandler = d3Selection.on('wheel.zoom')!;
const d3DblClickZoomHandler = d3Selection.on('dblclick.zoom')!;
d3ZoomInstance.wheelDelta(wheelDelta);
function setTransform(transform: ZoomTransform, options?: PanZoomTransformOptions) {
@@ -172,6 +173,15 @@ export function XYPanZoom({
lib,
});
d3ZoomInstance.filter(filter);
// We cannot add zoomOnDoubleClick to the filter above because
// double tapping on touch screens circumvents the filter and
// dblclick.zoom is fired on the selection directly
if (zoomOnDoubleClick) {
d3Selection.on('dblclick.zoom', d3DblClickZoomHandler);
} else {
d3Selection.on('dblclick.zoom', null);
}
}
function destroy() {
@@ -211,6 +221,7 @@ export function XYPanZoom({
currentTransform.x !== viewport.x ||
currentTransform.y !== viewport.y
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
d3ZoomInstance?.transform(d3Selection, nextTransform, null, { sync: true });
}
@@ -78,10 +78,9 @@ export function createPanOnScrollHandler({
event.stopImmediatePropagation();
const currentZoom = d3Selection.property('__zoom').k || 1;
const _isMacOs = isMacOs();
// macos sets ctrlKey=true for pinch gesture on a trackpad
if (event.ctrlKey && zoomOnPinch && _isMacOs) {
if (event.ctrlKey && zoomOnPinch) {
const point = pointer(event);
const pinchDelta = wheelDelta(event);
const zoom = currentZoom * Math.pow(2, pinchDelta);
@@ -98,7 +97,7 @@ export function createPanOnScrollHandler({
let deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize;
// this enables vertical scrolling with shift + scroll on windows
if (!_isMacOs && event.shiftKey && panOnScrollMode !== PanOnScrollMode.Vertical) {
if (!isMacOs() && event.shiftKey && panOnScrollMode !== PanOnScrollMode.Vertical) {
deltaX = event.deltaY * deltaNormalize;
deltaY = 0;
}
@@ -138,7 +137,10 @@ export function createPanOnScrollHandler({
export function createZoomOnScrollHandler({ noWheelClassName, preventScrolling, d3ZoomHandler }: ZoomOnScrollParams) {
return function (this: Element, event: any, d: unknown) {
if (!preventScrolling || isWrappedWithClass(event, noWheelClassName)) {
// we still want to enable pinch zooming even if preventScrolling is set to false
const preventZoom = !preventScrolling && event.type === 'wheel' && !event.ctrlKey;
if (preventZoom || isWrappedWithClass(event, noWheelClassName)) {
return null;
}
@@ -205,6 +207,7 @@ export function createPanZoomEndHandler({
if (event.sourceEvent?.internal) {
return;
}
zoomPanValues.isZoomingOrPanning = false;
if (
+7 -11
View File
@@ -48,11 +48,6 @@ export function createFilter({
return false;
}
// if zoom on double click is disabled, we prevent the double click event
if (!zoomOnDoubleClick && event.type === 'dblclick') {
return false;
}
// if the target element is inside an element with the nowheel class, we prevent zooming
if (isWrappedWithClass(event, noWheelClassName) && event.type === 'wheel') {
return false;
@@ -61,7 +56,7 @@ export function createFilter({
// if the target element is inside an element with the nopan class, we prevent panning
if (
isWrappedWithClass(event, noPanClassName) &&
((!panOnScroll && event.type !== 'wheel') || (panOnScroll && event.type === 'wheel'))
(event.type !== 'wheel' || (panOnScroll && event.type === 'wheel' && !zoomActivationKeyPressed))
) {
return false;
}
@@ -70,6 +65,11 @@ export function createFilter({
return false;
}
if (!zoomOnPinch && event.type === 'touchstart' && event.touches?.length > 1) {
event.preventDefault(); // if you manage to start with 2 touches, we prevent native zoom
return false;
}
// when there is no scroll handling enabled, we prevent all wheel events
if (!zoomScroll && !panOnScroll && !pinchZoom && event.type === 'wheel') {
return false;
@@ -81,11 +81,7 @@ export function createFilter({
}
// if the pane is only movable using allowed clicks
if (
Array.isArray(panOnDrag) &&
!panOnDrag.includes(event.button) &&
(event.type === 'mousedown' || event.type === 'touchstart')
) {
if (Array.isArray(panOnDrag) && !panOnDrag.includes(event.button) && event.type === 'mousedown') {
return false;
}
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type ZoomTransform, zoomIdentity } from 'd3-zoom';
import { type D3SelectionInstance, type Viewport } from '../types';
+291
View File
@@ -0,0 +1,291 @@
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import { getControlDirection, getDimensionsAfterResize, getResizeDirection } from './utils';
import { getPointerPosition } from '../utils';
import type {
CoordinateExtent,
InternalNodeBase,
NodeBase,
NodeLookup,
NodeOrigin,
Transform,
XYPosition,
} from '../types';
import type { OnResize, OnResizeEnd, OnResizeStart, ResizeDragEvent, ShouldResize, ControlPosition } from './types';
const initPrevValues = { width: 0, height: 0, x: 0, y: 0 };
const initStartValues = {
...initPrevValues,
pointerX: 0,
pointerY: 0,
aspectRatio: 1,
};
export type XYResizerChange = {
x?: number;
y?: number;
width?: number;
height?: number;
};
export type XYResizerChildChange = {
id: string;
position: XYPosition;
extent?: 'parent' | CoordinateExtent;
};
type XYResizerParams = {
domNode: HTMLDivElement;
nodeId: string;
getStoreItems: () => {
nodeLookup: NodeLookup;
transform: Transform;
snapGrid?: [number, number];
snapToGrid: boolean;
nodeOrigin: NodeOrigin;
};
onChange: (changes: XYResizerChange, childChanges: XYResizerChildChange[]) => void;
onEnd?: () => void;
};
type XYResizerUpdateParams = {
controlPosition: ControlPosition;
boundaries: {
minWidth: number;
minHeight: number;
maxWidth: number;
maxHeight: number;
};
keepAspectRatio: boolean;
onResizeStart: OnResizeStart | undefined;
onResize: OnResize | undefined;
onResizeEnd: OnResizeEnd | undefined;
shouldResize: ShouldResize | undefined;
};
export type XYResizerInstance = {
update: (params: XYResizerUpdateParams) => void;
destroy: () => void;
};
function nodeToParentExtent(node: NodeBase): CoordinateExtent {
return [
[0, 0],
[node.measured!.width!, node.measured!.height!],
];
}
function nodeToChildExtent(child: NodeBase, parent: NodeBase, nodeOrigin: NodeOrigin): CoordinateExtent {
const x = parent.position.x + child.position.x;
const y = parent.position.y + child.position.y;
const width = child.measured!.width! ?? 0;
const height = child.measured!.height! ?? 0;
const originOffsetX = nodeOrigin[0] * width;
const originOffsetY = nodeOrigin[1] * height;
return [
[x - originOffsetX, y - originOffsetY],
[x + width - originOffsetX, y + height - originOffsetY],
];
}
export function XYResizer({ domNode, nodeId, getStoreItems, onChange, onEnd }: XYResizerParams): XYResizerInstance {
const selection = select(domNode);
function update({
controlPosition,
boundaries,
keepAspectRatio,
onResizeStart,
onResize,
onResizeEnd,
shouldResize,
}: XYResizerUpdateParams) {
let prevValues = { ...initPrevValues };
let startValues = { ...initStartValues };
const controlDirection = getControlDirection(controlPosition);
let node: InternalNodeBase | undefined = undefined;
let childNodes: XYResizerChildChange[] = [];
let parentNode: InternalNodeBase | undefined = undefined; // Needed to fix expandParent
let parentExtent: CoordinateExtent | undefined = undefined;
let childExtent: CoordinateExtent | undefined = undefined;
const dragHandler = drag<HTMLDivElement, unknown>()
.on('start', (event: ResizeDragEvent) => {
const { nodeLookup, transform, snapGrid, snapToGrid, nodeOrigin } = getStoreItems();
node = nodeLookup.get(nodeId);
if (!node) {
return;
}
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
prevValues = {
width: node.measured.width ?? 0,
height: node.measured.height ?? 0,
x: node.position.x ?? 0,
y: node.position.y ?? 0,
};
startValues = {
...prevValues,
pointerX: xSnapped,
pointerY: ySnapped,
aspectRatio: prevValues.width / prevValues.height,
};
parentNode = undefined;
if (node.parentId && (node.extent === 'parent' || node.expandParent)) {
parentNode = nodeLookup.get(node.parentId);
parentExtent = parentNode && node.extent === 'parent' ? nodeToParentExtent(parentNode) : undefined;
}
// Collect all child nodes to correct their relative positions when top/left changes
// Determine largest minimal extent the parent node is allowed to resize to
childNodes = [];
childExtent = undefined;
for (const [childId, child] of nodeLookup) {
if (child.parentId === nodeId) {
childNodes.push({
id: childId,
position: { ...child.position },
extent: child.extent,
});
if (child.extent === 'parent' || child.expandParent) {
const extent = nodeToChildExtent(child, node, child.origin ?? nodeOrigin);
if (childExtent) {
childExtent = [
[Math.min(extent[0][0], childExtent[0][0]), Math.min(extent[0][1], childExtent[0][1])],
[Math.max(extent[1][0], childExtent[1][0]), Math.max(extent[1][1], childExtent[1][1])],
];
} else {
childExtent = extent;
}
}
}
}
onResizeStart?.(event, { ...prevValues });
})
.on('drag', (event: ResizeDragEvent) => {
const { transform, snapGrid, snapToGrid, nodeOrigin: storeNodeOrigin } = getStoreItems();
const pointerPosition = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
const childChanges: XYResizerChildChange[] = [];
if (!node) {
return;
}
const { x: prevX, y: prevY, width: prevWidth, height: prevHeight } = prevValues;
const change: XYResizerChange = {};
const nodeOrigin = node.origin ?? storeNodeOrigin;
const { width, height, x, y } = getDimensionsAfterResize(
startValues,
controlDirection,
pointerPosition,
boundaries,
keepAspectRatio,
nodeOrigin,
parentExtent,
childExtent
);
const isWidthChange = width !== prevWidth;
const isHeightChange = height !== prevHeight;
const isXPosChange = x !== prevX && isWidthChange;
const isYPosChange = y !== prevY && isHeightChange;
if (!isXPosChange && !isYPosChange && !isWidthChange && !isHeightChange) {
return;
}
if (isXPosChange || isYPosChange || nodeOrigin[0] === 1 || nodeOrigin[1] === 1) {
change.x = isXPosChange ? x : prevValues.x;
change.y = isYPosChange ? y : prevValues.y;
prevValues.x = change.x;
prevValues.y = change.y;
// when top/left changes, correct the relative positions of child nodes
// so that they stay in the same position
if (childNodes.length > 0) {
const xChange = x - prevX;
const yChange = y - prevY;
for (const childNode of childNodes) {
childNode.position = {
x: childNode.position.x - xChange + nodeOrigin[0] * (width - prevWidth),
y: childNode.position.y - yChange + nodeOrigin[1] * (height - prevHeight),
};
childChanges.push(childNode);
}
}
}
if (isWidthChange || isHeightChange) {
change.width = isWidthChange ? width : prevValues.width;
change.height = isHeightChange ? height : prevValues.height;
prevValues.width = change.width;
prevValues.height = change.height;
}
// Fix expandParent when resizing from top/left
if (parentNode && node.expandParent) {
const xLimit = nodeOrigin[0] * (change.width ?? 0);
if (change.x && change.x < xLimit) {
prevValues.x = xLimit;
startValues.x = startValues.x - (change.x - xLimit);
}
const yLimit = nodeOrigin[1] * (change.height ?? 0);
if (change.y && change.y < yLimit) {
prevValues.y = yLimit;
startValues.y = startValues.y - (change.y - yLimit);
}
}
const direction = getResizeDirection({
width: prevValues.width,
prevWidth,
height: prevValues.height,
prevHeight,
affectsX: controlDirection.affectsX,
affectsY: controlDirection.affectsY,
});
const nextValues = { ...prevValues, direction };
const callResize = shouldResize?.(event, nextValues);
if (callResize === false) {
return;
}
onResize?.(event, nextValues);
onChange(change, childChanges);
})
.on('end', (event: ResizeDragEvent) => {
onResizeEnd?.(event, { ...prevValues });
onEnd?.();
});
selection.call(dragHandler);
}
function destroy() {
selection.on('.drag', null);
}
return {
update,
destroy,
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from './types';
export * from './XYResizer';
+32
View File
@@ -0,0 +1,32 @@
import type { D3DragEvent, SubjectPosition } from 'd3-drag';
export type XYResizerParams = {
x: number;
y: number;
width: number;
height: number;
};
export type XYResizerParamsWithDirection = XYResizerParams & {
direction: number[];
};
export type ControlLinePosition = 'top' | 'bottom' | 'left' | 'right';
export type ControlPosition = ControlLinePosition | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export enum ResizeControlVariant {
Line = 'line',
Handle = 'handle',
}
export const XY_RESIZER_HANDLE_POSITIONS: ControlPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
export const XY_RESIZER_LINE_POSITIONS: ControlLinePosition[] = ['top', 'right', 'bottom', 'left'];
type OnResizeHandler<Params = XYResizerParams, Result = void> = (event: ResizeDragEvent, params: Params) => Result;
export type ResizeDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;
export type ShouldResize = OnResizeHandler<XYResizerParamsWithDirection, boolean>;
export type OnResizeStart = OnResizeHandler;
export type OnResize = OnResizeHandler<XYResizerParamsWithDirection>;
export type OnResizeEnd = OnResizeHandler;
+280
View File
@@ -0,0 +1,280 @@
import { CoordinateExtent, NodeOrigin } from '../types';
import { getPointerPosition } from '../utils';
import { ControlPosition } from './types';
type GetResizeDirectionParams = {
width: number;
prevWidth: number;
height: number;
prevHeight: number;
affectsX: boolean;
affectsY: boolean;
};
/**
* Get all connecting edges for a given set of nodes
* @param width - new width of the node
* @param prevWidth - previous width of the node
* @param height - new height of the node
* @param prevHeight - previous height of the node
* @param affectsX - whether to invert the resize direction for the x axis
* @param affectsY - whether to invert the resize direction for the y axis
* @returns array of two numbers representing the direction of the resize for each axis, 0 = no change, 1 = increase, -1 = decrease
*/
export function getResizeDirection({
width,
prevWidth,
height,
prevHeight,
affectsX,
affectsY,
}: GetResizeDirectionParams) {
const deltaWidth = width - prevWidth;
const deltaHeight = height - prevHeight;
const direction = [deltaWidth > 0 ? 1 : deltaWidth < 0 ? -1 : 0, deltaHeight > 0 ? 1 : deltaHeight < 0 ? -1 : 0];
if (deltaWidth && affectsX) {
direction[0] = direction[0] * -1;
}
if (deltaHeight && affectsY) {
direction[1] = direction[1] * -1;
}
return direction;
}
/**
* Parses the control position that is being dragged to dimensions that are being resized
* @param controlPosition - position of the control that is being dragged
* @returns isHorizontal, isVertical, affectsX, affectsY,
*/
export function getControlDirection(controlPosition: ControlPosition) {
const isHorizontal = controlPosition.includes('right') || controlPosition.includes('left');
const isVertical = controlPosition.includes('bottom') || controlPosition.includes('top');
const affectsX = controlPosition.includes('left');
const affectsY = controlPosition.includes('top');
return {
isHorizontal,
isVertical,
affectsX,
affectsY,
};
}
type PrevValues = {
width: number;
height: number;
x: number;
y: number;
};
type StartValues = PrevValues & {
pointerX: number;
pointerY: number;
aspectRatio: number;
};
function getLowerExtentClamp(lowerExtent: number, lowerBound: number) {
return Math.max(0, lowerBound - lowerExtent);
}
function getUpperExtentClamp(upperExtent: number, upperBound: number) {
return Math.max(0, upperExtent - upperBound);
}
function getSizeClamp(size: number, minSize: number, maxSize: number) {
return Math.max(0, minSize - size, size - maxSize);
}
function xor(a: boolean, b: boolean) {
return a ? !b : b;
}
/**
* Calculates new width & height and x & y of node after resize based on pointer position
* @description - Buckle up, this is a chunky one... If you want to determine the new dimensions of a node after a resize,
* you have to account for all possible restrictions: min/max width/height of the node, the maximum extent the node is allowed
* to move in (in this case: resize into) determined by the parent node, the minimal extent determined by child nodes
* with expandParent or extent: 'parent' set and oh yeah, these things also have to work with keepAspectRatio!
* The way this is done is by determining how much each of these restricting actually restricts the resize and then applying the
* strongest restriction. Because the resize affects x, y and width, height and width, height of a opposing side with keepAspectRatio,
* the resize amount is always kept in distX & distY amount (the distance in mouse movement)
* Instead of clamping each value, we first calculate the biggest 'clamp' (for the lack of a better name) and then apply it to all values.
* To complicate things nodeOrigin has to be taken into account as well. This is done by offsetting the nodes as if their origin is [0, 0],
* then calculating the restrictions as usual
* @param startValues - starting values of resize
* @param controlDirection - dimensions affected by the resize
* @param pointerPosition - the current pointer position corrected for snapping
* @param boundaries - minimum and maximum dimensions of the node
* @param keepAspectRatio - prevent changes of asprect ratio
* @returns x, y, width and height of the node after resize
*/
export function getDimensionsAfterResize(
startValues: StartValues,
controlDirection: ReturnType<typeof getControlDirection>,
pointerPosition: ReturnType<typeof getPointerPosition>,
boundaries: { minWidth: number; maxWidth: number; minHeight: number; maxHeight: number },
keepAspectRatio: boolean,
nodeOrigin: NodeOrigin,
extent?: CoordinateExtent,
childExtent?: CoordinateExtent
) {
let { affectsX, affectsY } = controlDirection;
const { isHorizontal, isVertical } = controlDirection;
const isDiagonal = isHorizontal && isVertical;
const { xSnapped, ySnapped } = pointerPosition;
const { minWidth, maxWidth, minHeight, maxHeight } = boundaries;
const { x: startX, y: startY, width: startWidth, height: startHeight, aspectRatio } = startValues;
let distX = Math.floor(isHorizontal ? xSnapped - startValues.pointerX : 0);
let distY = Math.floor(isVertical ? ySnapped - startValues.pointerY : 0);
const newWidth = startWidth + (affectsX ? -distX : distX);
const newHeight = startHeight + (affectsY ? -distY : distY);
const originOffsetX = -nodeOrigin[0] * startWidth;
const originOffsetY = -nodeOrigin[1] * startHeight;
// Check if maxWidth, minWWidth, maxHeight, minHeight are restricting the resize
let clampX = getSizeClamp(newWidth, minWidth, maxWidth);
let clampY = getSizeClamp(newHeight, minHeight, maxHeight);
// Check if extent is restricting the resize
if (extent) {
let xExtentClamp = 0;
let yExtentClamp = 0;
if (affectsX && distX < 0) {
xExtentClamp = getLowerExtentClamp(startX + distX + originOffsetX, extent[0][0]);
} else if (!affectsX && distX > 0) {
xExtentClamp = getUpperExtentClamp(startX + newWidth + originOffsetX, extent[1][0]);
}
if (affectsY && distY < 0) {
yExtentClamp = getLowerExtentClamp(startY + distY + originOffsetY, extent[0][1]);
} else if (!affectsY && distY > 0) {
yExtentClamp = getUpperExtentClamp(startY + newHeight + originOffsetY, extent[1][1]);
}
clampX = Math.max(clampX, xExtentClamp);
clampY = Math.max(clampY, yExtentClamp);
}
// Check if the child extent is restricting the resize
if (childExtent) {
let xExtentClamp = 0;
let yExtentClamp = 0;
if (affectsX && distX > 0) {
xExtentClamp = getUpperExtentClamp(startX + distX, childExtent[0][0]);
} else if (!affectsX && distX < 0) {
xExtentClamp = getLowerExtentClamp(startX + newWidth, childExtent[1][0]);
}
if (affectsY && distY > 0) {
yExtentClamp = getUpperExtentClamp(startY + distY, childExtent[0][1]);
} else if (!affectsY && distY < 0) {
yExtentClamp = getLowerExtentClamp(startY + newHeight, childExtent[1][1]);
}
clampX = Math.max(clampX, xExtentClamp);
clampY = Math.max(clampY, yExtentClamp);
}
// Check if the aspect ratio resizing of the other side is restricting the resize
if (keepAspectRatio) {
if (isHorizontal) {
// Check if the max dimensions might be restricting the resize
const aspectHeightClamp = getSizeClamp(newWidth / aspectRatio, minHeight, maxHeight) * aspectRatio;
clampX = Math.max(clampX, aspectHeightClamp);
// Check if the extent is restricting the resize
if (extent) {
let aspectExtentClamp = 0;
if ((!affectsX && !affectsY) || (affectsX && !affectsY && isDiagonal)) {
aspectExtentClamp =
getUpperExtentClamp(startY + originOffsetY + newWidth / aspectRatio, extent[1][1]) * aspectRatio;
} else {
aspectExtentClamp =
getLowerExtentClamp(startY + originOffsetY + (affectsX ? distX : -distX) / aspectRatio, extent[0][1]) *
aspectRatio;
}
clampX = Math.max(clampX, aspectExtentClamp);
}
// Check if the child extent is restricting the resize
if (childExtent) {
let aspectExtentClamp = 0;
if ((!affectsX && !affectsY) || (affectsX && !affectsY && isDiagonal)) {
aspectExtentClamp = getLowerExtentClamp(startY + newWidth / aspectRatio, childExtent[1][1]) * aspectRatio;
} else {
aspectExtentClamp =
getUpperExtentClamp(startY + (affectsX ? distX : -distX) / aspectRatio, childExtent[0][1]) * aspectRatio;
}
clampX = Math.max(clampX, aspectExtentClamp);
}
}
// Do the same thing for vertical resizing
if (isVertical) {
const aspectWidthClamp = getSizeClamp(newHeight * aspectRatio, minWidth, maxWidth) / aspectRatio;
clampY = Math.max(clampY, aspectWidthClamp);
if (extent) {
let aspectExtentClamp = 0;
if ((!affectsX && !affectsY) || (affectsY && !affectsX && isDiagonal)) {
aspectExtentClamp =
getUpperExtentClamp(startX + newHeight * aspectRatio + originOffsetX, extent[1][0]) / aspectRatio;
} else {
aspectExtentClamp =
getLowerExtentClamp(startX + (affectsY ? distY : -distY) * aspectRatio + originOffsetX, extent[0][0]) /
aspectRatio;
}
clampY = Math.max(clampY, aspectExtentClamp);
}
if (childExtent) {
let aspectExtentClamp = 0;
if ((!affectsX && !affectsY) || (affectsY && !affectsX && isDiagonal)) {
aspectExtentClamp = getLowerExtentClamp(startX + newHeight * aspectRatio, childExtent[1][0]) / aspectRatio;
} else {
aspectExtentClamp =
getUpperExtentClamp(startX + (affectsY ? distY : -distY) * aspectRatio, childExtent[0][0]) / aspectRatio;
}
clampY = Math.max(clampY, aspectExtentClamp);
}
}
}
distY = distY + (distY < 0 ? clampY : -clampY);
distX = distX + (distX < 0 ? clampX : -clampX);
if (keepAspectRatio) {
if (isDiagonal) {
if (newWidth > newHeight * aspectRatio) {
distY = (xor(affectsX, affectsY) ? -distX : distX) / aspectRatio;
} else {
distX = (xor(affectsX, affectsY) ? -distY : distY) * aspectRatio;
}
} else {
if (isHorizontal) {
distY = distX / aspectRatio;
affectsY = affectsX;
} else {
distX = distY * aspectRatio;
affectsX = affectsY;
}
}
}
const x = affectsX ? startX + distX : startX;
const y = affectsY ? startY + distY : startY;
return {
width: startWidth + (affectsX ? -distX : distX),
height: startHeight + (affectsY ? -distY : distY),
x: nodeOrigin[0] * distX * (!affectsX ? 1 : -1) + x,
y: nodeOrigin[1] * distY * (!affectsY ? 1 : -1) + y,
};
}