Merge pull request #3323 from wbkd/v11.8-changes

xyflow: v11.8 changes
This commit is contained in:
Moritz Klack
2023-08-16 10:52:32 +02:00
committed by GitHub
30 changed files with 3388 additions and 2874 deletions

View File

@@ -21,7 +21,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0",
"zustand": "^4.3.1"
"zustand": "^4.4.1"
},
"devDependencies": {
"@cypress/skip-test": "^2.6.1",

View File

@@ -1,4 +1,4 @@
import { ReactFlow, Background, BackgroundVariant, Node, Edge, SelectionMode } from '@xyflow/react';
import { ReactFlow, Background, BackgroundVariant, Node, Edge, SelectionMode, Controls, Panel } from '@xyflow/react';
const MULTI_SELECT_KEY = ['Meta', 'Shift'];
@@ -21,6 +21,10 @@ const onPaneContextMenu = (e: any) => {
const panOnDrag = [1, 2];
const onMoveStart = (e: any) => console.log('move start', e);
const onMove = (e: any) => console.log('move', e);
const onMoveEnd = (e: any) => console.log('move end', e);
const BasicFlow = () => {
return (
<ReactFlow
@@ -36,11 +40,15 @@ const BasicFlow = () => {
fitView
selectNodesOnDrag={false}
onSelectionContextMenu={onPaneContextMenu}
onMoveStart={onMoveStart}
onMove={onMove}
onMoveEnd={onMoveEnd}
>
<Background variant={BackgroundVariant.Cross} />
<div style={{ position: 'absolute', right: 10, top: 10, zIndex: 4 }}>
<Controls />
<Panel position="top-right">
<input type={'text'} placeholder={'name'} />
</div>
</Panel>
</ReactFlow>
);
};

View File

@@ -16,8 +16,17 @@
],
"source": "src/index.ts",
"main": "dist/umd/index.js",
"module": "dist/esm/index.js",
"module": "dist/esm/index.mjs",
"types": "dist/esm/index.d.ts",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.mjs",
"require": "./dist/umd/index.js"
},
"./dist/base.css": "./dist/base.css",
"./dist/style.css": "./dist/style.css"
},
"sideEffects": [
"*.css"
],
@@ -48,7 +57,7 @@
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.3.3"
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",

View File

@@ -58,7 +58,8 @@ function MiniMap({
zoomable = false,
ariaLabel = 'React Flow mini map',
inversePan,
zoomStep,
zoomStep = 10,
offsetScale = 5,
}: MiniMapProps) {
const store = useStoreApi();
const svg = useRef<SVGSVGElement>(null);
@@ -70,7 +71,7 @@ function MiniMap({
const viewScale = Math.max(scaledWidth, scaledHeight);
const viewWidth = viewScale * elementWidth;
const viewHeight = viewScale * elementHeight;
const offset = 5 * viewScale;
const offset = offsetScale * viewScale;
const x = boundingRect.x - (viewWidth - boundingRect.width) / 2 - offset;
const y = boundingRect.y - (viewHeight - boundingRect.height) / 2 - offset;
const width = viewWidth + offset * 2;

View File

@@ -16,6 +16,7 @@ function MiniMapNode({
className,
borderRadius,
shapeRendering,
selected,
onClick,
}: MiniMapNodeProps) {
const { background, backgroundColor } = style || {};
@@ -23,7 +24,7 @@ function MiniMapNode({
return (
<rect
className={cc(['react-flow__minimap-node', className])}
className={cc(['react-flow__minimap-node', { selected }, className])}
x={x}
y={y}
rx={borderRadius}

View File

@@ -47,6 +47,7 @@ function MiniMapNodes({
width={node.width!}
height={node.height!}
style={node.style}
selected={!!node.selected}
className={nodeClassNameFunc(node)}
color={nodeColorFunc(node)}
borderRadius={nodeBorderRadius}

View File

@@ -24,6 +24,7 @@ export type MiniMapProps<NodeData = any> = Omit<HTMLAttributes<SVGSVGElement>, '
ariaLabel?: string | null;
inversePan?: boolean;
zoomStep?: number;
offsetScale?: number;
};
export type MiniMapNodes = Pick<
@@ -46,5 +47,6 @@ export type MiniMapNodeProps = {
strokeColor: string;
strokeWidth: number;
style?: CSSProperties;
selected: boolean;
onClick?: (event: MouseEvent, id: string) => void;
};

View File

@@ -202,7 +202,7 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
onMouseLeave={onEdgeMouseLeave}
onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : undefined}
role={isFocusable ? 'button' : 'img'}
data-testid={`rf__edge-${id}`}
aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`}
aria-describedby={isFocusable ? `${ARIA_EDGE_DESC_KEY}-${rfId}` : undefined}

View File

@@ -2,6 +2,7 @@ import type { MouseEvent, RefObject } from 'react';
import type { StoreApi } from 'zustand';
import type { Node, ReactFlowState } from '../../types';
import { errorMessages } from '@xyflow/system';
export function getMouseHandler(
id: string,
@@ -34,9 +35,14 @@ export function handleNodeClick({
unselect?: boolean;
nodeRef?: RefObject<HTMLDivElement>;
}) {
const { addSelectedNodes, unselectNodesAndEdges, multiSelectionActive, nodes } = store.getState();
const { addSelectedNodes, unselectNodesAndEdges, multiSelectionActive, nodes, onError } = store.getState();
const node = nodes.find((n) => n.id === id)!;
if (!node) {
onError?.('012', errorMessages['error012'](id));
return;
}
store.setState({ nodesSelectionActive: false });
if (!node.selected) {

View File

@@ -1,12 +1,13 @@
import { useRef, type FC, type PropsWithChildren } from 'react';
import { type StoreApi } from 'zustand';
import { UseBoundStoreWithEqualityFn } from 'zustand/traditional';
import { Provider } from '../../contexts/RFStoreContext';
import { createRFStore } from '../../store';
import type { ReactFlowState } from '../../types';
const ReactFlowProvider: FC<PropsWithChildren<unknown>> = ({ children }) => {
const storeRef = useRef<StoreApi<ReactFlowState> | null>(null);
const storeRef = useRef<UseBoundStoreWithEqualityFn<StoreApi<ReactFlowState>> | null>(null);
if (!storeRef.current) {
storeRef.current = createRFStore();

View File

@@ -7,11 +7,10 @@ import { useStore } from '../../hooks/useStore';
import useVisibleEdges from '../../hooks/useVisibleEdges';
import MarkerDefinitions from './MarkerDefinitions';
import { GraphViewProps } from '../GraphView';
import type { ReactFlowState } from '../../types';
import type { EdgeTypesWrapped, ReactFlowState } from '../../types';
type EdgeRendererProps = Pick<
GraphViewProps,
| 'edgeTypes'
| 'onEdgeClick'
| 'onEdgeDoubleClick'
| 'defaultMarkerColor'
@@ -29,6 +28,7 @@ type EdgeRendererProps = Pick<
| 'rfId'
| 'disableKeyboardA11y'
> & {
edgeTypes: EdgeTypesWrapped;
elevateEdgesOnSelect: boolean;
children: ReactNode;
};

View File

@@ -6,7 +6,10 @@ import EdgeRenderer from '../EdgeRenderer';
import ViewportWrapper from '../Viewport';
import useOnInitHandler from '../../hooks/useOnInitHandler';
import ConnectionLine from '../../components/ConnectionLine';
import type { EdgeTypesWrapped, NodeTypesWrapped, ReactFlowProps } from '../../types';
import type { NodeTypesWrapped, ReactFlowProps } from '../../types';
import { createNodeTypes } from '../NodeRenderer/utils';
import { createEdgeTypes } from '../EdgeRenderer/utils';
import { useNodeOrEdgeTypes } from './utils';
export type GraphViewProps = Omit<
ReactFlowProps,
@@ -15,6 +18,8 @@ export type GraphViewProps = Omit<
Required<
Pick<
ReactFlowProps,
| 'nodeTypes'
| 'edgeTypes'
| 'selectionKeyCode'
| 'deleteKeyCode'
| 'multiSelectionKeyCode'
@@ -33,8 +38,6 @@ export type GraphViewProps = Omit<
| 'nodeOrigin'
>
> & {
nodeTypes: NodeTypesWrapped;
edgeTypes: EdgeTypesWrapped;
rfId: string;
};
@@ -102,6 +105,9 @@ const GraphView = ({
nodeExtent,
rfId,
}: GraphViewProps) => {
const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes);
const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes);
useOnInitHandler(onInit);
return (
@@ -142,7 +148,7 @@ const GraphView = ({
>
<ViewportWrapper>
<EdgeRenderer
edgeTypes={edgeTypes}
edgeTypes={edgeTypesWrapped}
onEdgeClick={onEdgeClick}
onEdgeDoubleClick={onEdgeDoubleClick}
onEdgeUpdate={onEdgeUpdate}
@@ -170,7 +176,7 @@ const GraphView = ({
<div className="react-flow__edgelabel-renderer" />
<NodeRenderer
nodeTypes={nodeTypes}
nodeTypes={nodeTypesWrapped}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeMouseEnter={onNodeMouseEnter}

View File

@@ -1,22 +1,24 @@
import { useMemo, useRef } from 'react';
import { shallow } from 'zustand/shallow';
import { errorMessages, devWarn } from '@xyflow/system';
import { errorMessages } from '@xyflow/system';
import { CreateEdgeTypes } from '../EdgeRenderer/utils';
import { CreateNodeTypes } from '../NodeRenderer/utils';
import type { EdgeTypes, EdgeTypesWrapped, NodeTypes, NodeTypesWrapped } from '../../types';
import { useStoreApi } from '../../hooks/useStore';
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: NodeTypes, createTypes: CreateNodeTypes): NodeTypesWrapped;
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: EdgeTypes, createTypes: CreateEdgeTypes): EdgeTypesWrapped;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: any, createTypes: any): any {
const typesKeysRef = useRef<string[] | null>(null);
const store = useStoreApi();
const typesParsed = useMemo(() => {
if (process.env.NODE_ENV === 'development') {
const typeKeys = Object.keys(nodeOrEdgeTypes);
if (shallow(typesKeysRef.current, typeKeys)) {
devWarn('002', errorMessages['error002']());
store.getState().onError?.('002', errorMessages['error002']());
}
typesKeysRef.current = typeKeys;

View File

@@ -6,11 +6,10 @@ import useVisibleNodes from '../../hooks/useVisibleNodes';
import { useStore } from '../../hooks/useStore';
import { containerStyle } from '../../styles';
import { GraphViewProps } from '../GraphView';
import type { ReactFlowState, WrapNodeProps } from '../../types';
import type { NodeTypesWrapped, ReactFlowState, WrapNodeProps } from '../../types';
type NodeRendererProps = Pick<
GraphViewProps,
| 'nodeTypes'
| 'onNodeClick'
| 'onNodeDoubleClick'
| 'onNodeMouseEnter'
@@ -24,7 +23,9 @@ type NodeRendererProps = Pick<
| 'disableKeyboardA11y'
| 'nodeOrigin'
| 'nodeExtent'
>;
> & {
nodeTypes: NodeTypesWrapped;
};
const selector = (s: ReactFlowState) => ({
nodesDraggable: s.nodesDraggable,

View File

@@ -8,6 +8,7 @@ import {
infiniteExtent,
type NodeOrigin,
type Viewport,
isMacOs,
} from '@xyflow/system';
import Attribution from '../../components/Attribution';
@@ -19,19 +20,9 @@ import GroupNode from '../../components/Nodes/GroupNode';
import SelectionListener from '../../components/SelectionListener';
import StoreUpdater from '../../components/StoreUpdater';
import A11yDescriptions from '../../components/A11yDescriptions';
import { createEdgeTypes } from '../EdgeRenderer/utils';
import { createNodeTypes } from '../NodeRenderer/utils';
import GraphView from '../GraphView';
import Wrapper from './Wrapper';
import { useNodeOrEdgeTypes } from './utils';
import type {
EdgeTypes,
EdgeTypesWrapped,
NodeTypes,
NodeTypesWrapped,
ReactFlowProps,
ReactFlowRefType,
} from '../../types';
import type { EdgeTypes, NodeTypes, ReactFlowProps, ReactFlowRefType } from '../../types';
const defaultNodeTypes: NodeTypes = {
input: InputNode,
@@ -108,8 +99,8 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
selectionOnDrag = false,
selectionMode = SelectionMode.Full,
panActivationKeyCode = 'Space',
multiSelectionKeyCode = 'Meta',
zoomActivationKeyCode = 'Meta',
multiSelectionKeyCode = isMacOs() ? 'Meta' : 'Control',
zoomActivationKeyCode = isMacOs() ? 'Meta' : 'Control',
snapToGrid = false,
snapGrid = initSnapGrid,
onlyRenderVisibleElements = false,
@@ -176,8 +167,6 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
},
ref
) => {
const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes) as NodeTypesWrapped;
const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes) as EdgeTypesWrapped;
const rfId = id || '1';
return (
@@ -199,8 +188,8 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
onNodeMouseLeave={onNodeMouseLeave}
onNodeContextMenu={onNodeContextMenu}
onNodeDoubleClick={onNodeDoubleClick}
nodeTypes={nodeTypesWrapped}
edgeTypes={edgeTypesWrapped}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle}
connectionLineComponent={connectionLineComponent}

View File

@@ -1,5 +1,6 @@
import { useContext, useMemo } from 'react';
import { useStore as useZustandStore, type StoreApi } from 'zustand';
import { useStoreWithEqualityFn as useZustandStore } from 'zustand/traditional';
import type { StoreApi } from 'zustand';
import { errorMessages } from '@xyflow/system';
import StoreContext from '../contexts/RFStoreContext';

View File

@@ -1,4 +1,4 @@
import { createStore } from 'zustand';
import { createWithEqualityFn } from 'zustand/traditional';
import {
clampPosition,
fitView as fitViewSystem,
@@ -25,261 +25,264 @@ import type {
} from '../types';
const createRFStore = () =>
createStore<ReactFlowState>((set, get) => ({
...initialState,
setNodes: (nodes: Node[]) => {
const { nodes: storeNodes, nodeOrigin, elevateNodesOnSelect } = get();
const nextNodes = updateNodes(nodes, storeNodes, { nodeOrigin, elevateNodesOnSelect });
createWithEqualityFn<ReactFlowState>(
(set, get) => ({
...initialState,
setNodes: (nodes: Node[]) => {
const { nodes: storeNodes, nodeOrigin, elevateNodesOnSelect } = get();
const nextNodes = updateNodes(nodes, storeNodes, { nodeOrigin, elevateNodesOnSelect });
set({ nodes: nextNodes });
},
getNodes: () => {
return get().nodes;
},
setEdges: (edges: Edge[]) => {
const { defaultEdgeOptions = {} } = get();
set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) });
},
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => {
const hasDefaultNodes = typeof nodes !== 'undefined';
const hasDefaultEdges = typeof edges !== 'undefined';
set({ nodes: nextNodes });
},
getNodes: () => {
return get().nodes;
},
setEdges: (edges: Edge[]) => {
const { defaultEdgeOptions = {} } = get();
set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) });
},
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => {
const hasDefaultNodes = typeof nodes !== 'undefined';
const hasDefaultEdges = typeof edges !== 'undefined';
const nextNodes = hasDefaultNodes
? updateNodes(nodes, [], {
nodeOrigin: get().nodeOrigin,
elevateNodesOnSelect: get().elevateNodesOnSelect,
})
: [];
const nextEdges = hasDefaultEdges ? edges : [];
const nextNodes = hasDefaultNodes
? updateNodes(nodes, [], {
nodeOrigin: get().nodeOrigin,
elevateNodesOnSelect: get().elevateNodesOnSelect,
})
: [];
const nextEdges = hasDefaultEdges ? edges : [];
set({ nodes: nextNodes, edges: nextEdges, hasDefaultNodes, hasDefaultEdges });
},
updateNodeDimensions: (updates) => {
const { onNodesChange, fitView, nodes, fitViewOnInit, fitViewDone, fitViewOnInitOptions, domNode, nodeOrigin } =
get();
const changes: NodeDimensionChange[] = [];
set({ nodes: nextNodes, edges: nextEdges, hasDefaultNodes, hasDefaultEdges });
},
updateNodeDimensions: (updates) => {
const { onNodesChange, fitView, nodes, fitViewOnInit, fitViewDone, fitViewOnInitOptions, domNode, nodeOrigin } =
get();
const changes: NodeDimensionChange[] = [];
const updatedNodes = updateNodeDimensionsSystem(
updates,
nodes,
domNode,
nodeOrigin,
(id: string, dimensions: Dimensions) => {
changes.push({
id: id,
type: 'dimensions',
dimensions,
});
const updatedNodes = updateNodeDimensionsSystem(
updates,
nodes,
domNode,
nodeOrigin,
(id: string, dimensions: Dimensions) => {
changes.push({
id: id,
type: 'dimensions',
dimensions,
});
}
);
if (!updatedNodes) {
return;
}
);
if (!updatedNodes) {
return;
}
const nextNodes = updateAbsolutePositions(updatedNodes, nodeOrigin);
const nextNodes = updateAbsolutePositions(updatedNodes, nodeOrigin);
const nextFitViewDone =
fitViewDone ||
(fitViewOnInit &&
fitView({
...fitViewOnInitOptions,
nodes: nextNodes,
}));
const nextFitViewDone =
fitViewDone ||
(fitViewOnInit &&
fitView({
...fitViewOnInitOptions,
nodes: nextNodes,
}));
set({ nodes: nextNodes, fitViewDone: nextFitViewDone });
set({ nodes: nextNodes, fitViewDone: nextFitViewDone });
if (changes?.length > 0) {
onNodesChange?.(changes);
}
},
updateNodePositions: (nodeDragItems, positionChanged = true, dragging = false) => {
const changes = nodeDragItems.map((node) => {
const change: NodePositionChange = {
id: node.id,
type: 'position',
dragging,
};
if (changes?.length > 0) {
onNodesChange?.(changes);
}
},
updateNodePositions: (nodeDragItems, positionChanged = true, dragging = false) => {
const changes = nodeDragItems.map((node) => {
const change: NodePositionChange = {
id: node.id,
type: 'position',
dragging,
if (positionChanged) {
change.positionAbsolute = node.positionAbsolute;
change.position = node.position;
}
return change;
});
get().triggerNodeChanges(changes);
},
triggerNodeChanges: (changes) => {
const { onNodesChange, nodes, hasDefaultNodes, nodeOrigin, elevateNodesOnSelect } = get();
if (changes?.length) {
if (hasDefaultNodes) {
const updatedNodes = applyNodeChanges(changes, nodes);
const nextNodes = updateNodes(updatedNodes, nodes, {
nodeOrigin,
elevateNodesOnSelect,
});
set({ nodes: nextNodes });
}
onNodesChange?.(changes);
}
},
addSelectedNodes: (selectedNodeIds) => {
const { multiSelectionActive, edges, nodes } = get();
let changedNodes: NodeSelectionChange[];
let changedEdges: EdgeSelectionChange[] | null = null;
if (multiSelectionActive) {
changedNodes = selectedNodeIds.map((nodeId) => createSelectionChange(nodeId, true)) as NodeSelectionChange[];
} else {
changedNodes = getSelectionChanges(nodes, selectedNodeIds);
changedEdges = getSelectionChanges(edges, []);
}
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
addSelectedEdges: (selectedEdgeIds) => {
const { multiSelectionActive, edges, nodes } = get();
let changedEdges: EdgeSelectionChange[];
let changedNodes: NodeSelectionChange[] | null = null;
if (multiSelectionActive) {
changedEdges = selectedEdgeIds.map((edgeId) => createSelectionChange(edgeId, true)) as EdgeSelectionChange[];
} else {
changedEdges = getSelectionChanges(edges, selectedEdgeIds);
changedNodes = getSelectionChanges(nodes, []);
}
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
unselectNodesAndEdges: ({ nodes, edges }: UnselectNodesAndEdgesParams = {}) => {
const { edges: storeEdges, nodes: storeNodes } = get();
const nodesToUnselect = nodes ? nodes : storeNodes;
const edgesToUnselect = edges ? edges : storeEdges;
const changedNodes = nodesToUnselect.map((n) => {
n.selected = false;
return createSelectionChange(n.id, false);
}) as NodeSelectionChange[];
const changedEdges = edgesToUnselect.map((edge) =>
createSelectionChange(edge.id, false)
) as EdgeSelectionChange[];
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
setMinZoom: (minZoom) => {
const { panZoom, maxZoom } = get();
panZoom?.setScaleExtent([minZoom, maxZoom]);
set({ minZoom });
},
setMaxZoom: (maxZoom) => {
const { panZoom, minZoom } = get();
panZoom?.setScaleExtent([minZoom, maxZoom]);
set({ maxZoom });
},
setTranslateExtent: (translateExtent) => {
get().panZoom?.setTranslateExtent(translateExtent);
set({ translateExtent });
},
resetSelectedElements: () => {
const { edges, nodes } = get();
const nodesToUnselect = nodes
.filter((e) => e.selected)
.map((n) => createSelectionChange(n.id, false)) as NodeSelectionChange[];
const edgesToUnselect = edges
.filter((e) => e.selected)
.map((e) => createSelectionChange(e.id, false)) as EdgeSelectionChange[];
updateNodesAndEdgesSelections({
changedNodes: nodesToUnselect,
changedEdges: edgesToUnselect,
get,
set,
});
},
setNodeExtent: (nodeExtent) => {
const { nodes } = get();
set({
nodeExtent,
nodes: nodes.map((node) => {
const positionAbsolute = clampPosition(node.position, nodeExtent);
return {
...node,
positionAbsolute,
};
}),
});
},
panBy: (delta): boolean => {
const { transform, width, height, panZoom, translateExtent } = get();
return panBySystem({ delta, panZoom, transform, translateExtent, width, height });
},
fitView: (options?: FitViewOptions): boolean => {
const { panZoom, nodes, width, height, minZoom, maxZoom, nodeOrigin } = get();
const fitViewNodes = options?.nodes || nodes;
if (!panZoom) {
return false;
}
return fitViewSystem(
{
nodes: fitViewNodes as Node[],
width,
height,
panZoom,
minZoom,
maxZoom,
nodeOrigin,
},
options
);
},
cancelConnection: () =>
set({
connectionStatus: initialState.connectionStatus,
connectionStartHandle: initialState.connectionStartHandle,
connectionEndHandle: initialState.connectionEndHandle,
}),
updateConnection: (params) => {
const { connectionStatus, connectionStartHandle, connectionEndHandle, connectionPosition } = get();
const currentConnection = {
connectionPosition: params.connectionPosition ?? connectionPosition,
connectionStatus: params.connectionStatus ?? connectionStatus,
connectionStartHandle: params.connectionStartHandle ?? connectionStartHandle,
connectionEndHandle: params.connectionEndHandle ?? connectionEndHandle,
};
if (positionChanged) {
change.positionAbsolute = node.positionAbsolute;
change.position = node.position;
}
return change;
});
get().triggerNodeChanges(changes);
},
triggerNodeChanges: (changes) => {
const { onNodesChange, nodes, hasDefaultNodes, nodeOrigin, elevateNodesOnSelect } = get();
if (changes?.length) {
if (hasDefaultNodes) {
const updatedNodes = applyNodeChanges(changes, nodes);
const nextNodes = updateNodes(updatedNodes, nodes, {
nodeOrigin,
elevateNodesOnSelect,
});
set({ nodes: nextNodes });
}
onNodesChange?.(changes);
}
},
addSelectedNodes: (selectedNodeIds) => {
const { multiSelectionActive, edges, nodes } = get();
let changedNodes: NodeSelectionChange[];
let changedEdges: EdgeSelectionChange[] | null = null;
if (multiSelectionActive) {
changedNodes = selectedNodeIds.map((nodeId) => createSelectionChange(nodeId, true)) as NodeSelectionChange[];
} else {
changedNodes = getSelectionChanges(nodes, selectedNodeIds);
changedEdges = getSelectionChanges(edges, []);
}
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
addSelectedEdges: (selectedEdgeIds) => {
const { multiSelectionActive, edges, nodes } = get();
let changedEdges: EdgeSelectionChange[];
let changedNodes: NodeSelectionChange[] | null = null;
if (multiSelectionActive) {
changedEdges = selectedEdgeIds.map((edgeId) => createSelectionChange(edgeId, true)) as EdgeSelectionChange[];
} else {
changedEdges = getSelectionChanges(edges, selectedEdgeIds);
changedNodes = getSelectionChanges(nodes, []);
}
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
unselectNodesAndEdges: ({ nodes, edges }: UnselectNodesAndEdgesParams = {}) => {
const { edges: storeEdges, nodes: storeNodes } = get();
const nodesToUnselect = nodes ? nodes : storeNodes;
const edgesToUnselect = edges ? edges : storeEdges;
const changedNodes = nodesToUnselect.map((n) => {
n.selected = false;
return createSelectionChange(n.id, false);
}) as NodeSelectionChange[];
const changedEdges = edgesToUnselect.map((edge) =>
createSelectionChange(edge.id, false)
) as EdgeSelectionChange[];
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
setMinZoom: (minZoom) => {
const { panZoom, maxZoom } = get();
panZoom?.setScaleExtent([minZoom, maxZoom]);
set({ minZoom });
},
setMaxZoom: (maxZoom) => {
const { panZoom, minZoom } = get();
panZoom?.setScaleExtent([minZoom, maxZoom]);
set({ maxZoom });
},
setTranslateExtent: (translateExtent) => {
get().panZoom?.setTranslateExtent(translateExtent);
set({ translateExtent });
},
resetSelectedElements: () => {
const { edges, nodes } = get();
const nodesToUnselect = nodes
.filter((e) => e.selected)
.map((n) => createSelectionChange(n.id, false)) as NodeSelectionChange[];
const edgesToUnselect = edges
.filter((e) => e.selected)
.map((e) => createSelectionChange(e.id, false)) as EdgeSelectionChange[];
updateNodesAndEdgesSelections({
changedNodes: nodesToUnselect,
changedEdges: edgesToUnselect,
get,
set,
});
},
setNodeExtent: (nodeExtent) => {
const { nodes } = get();
set({
nodeExtent,
nodes: nodes.map((node) => {
const positionAbsolute = clampPosition(node.position, nodeExtent);
return {
...node,
positionAbsolute,
};
}),
});
},
panBy: (delta): boolean => {
const { transform, width, height, panZoom, translateExtent } = get();
return panBySystem({ delta, panZoom, transform, translateExtent, width, height });
},
fitView: (options?: FitViewOptions): boolean => {
const { panZoom, nodes, width, height, minZoom, maxZoom, nodeOrigin } = get();
const fitViewNodes = options?.nodes || nodes;
if (!panZoom) {
return false;
}
return fitViewSystem(
{
nodes: fitViewNodes as Node[],
width,
height,
panZoom,
minZoom,
maxZoom,
nodeOrigin,
},
options
);
},
cancelConnection: () =>
set({
connectionStatus: initialState.connectionStatus,
connectionStartHandle: initialState.connectionStartHandle,
connectionEndHandle: initialState.connectionEndHandle,
}),
updateConnection: (params) => {
const { connectionStatus, connectionStartHandle, connectionEndHandle, connectionPosition } = get();
const currentConnection = {
connectionPosition: params.connectionPosition ?? connectionPosition,
connectionStatus: params.connectionStatus ?? connectionStatus,
connectionStartHandle: params.connectionStartHandle ?? connectionStartHandle,
connectionEndHandle: params.connectionEndHandle ?? connectionEndHandle,
};
set(currentConnection);
},
reset: () => set({ ...initialState }),
}));
set(currentConnection);
},
reset: () => set({ ...initialState }),
}),
Object.is
);
export { createRFStore };

View File

@@ -39,8 +39,8 @@
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.1.0",
"@sveltejs/kit": "^1.20.5",
"@sveltejs/package": "^2.1.0",
"@sveltejs/kit": "^1.22.6",
"@sveltejs/package": "^2.2.1",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0",
"eslint": "^8.43.0",
@@ -48,9 +48,9 @@
"eslint-plugin-svelte": "^2.31.1",
"prettier": "^2.8.8",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.0",
"svelte-check": "^3.4.4",
"svelte-eslint-parser": "^0.31.0",
"svelte": "^4.2.0",
"svelte-check": "^3.5.0",
"svelte-eslint-parser": "^0.32.2",
"tslib": "^2.5.3",
"typescript": "5.1.3",
"vite": "^4.3.9"

View File

@@ -30,6 +30,7 @@
export let targetY: $$Props['targetY'];
export let sourcePosition: $$Props['sourcePosition'];
export let targetPosition: $$Props['targetPosition'];
export let ariaLabel: $$Props['ariaLabel'] = undefined;
// @ todo: support edge updates
const { edges, edgeTypes, flowId, addSelectedEdges } = useStore();
@@ -51,7 +52,19 @@
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<g class="svelte-flow__edge" class:animated class:selected data-id={id} on:click={onClick}>
<g
class="svelte-flow__edge"
class:animated
class:selected
data-id={id}
on:click={onClick}
aria-label={ariaLabel === null
? undefined
: ariaLabel
? ariaLabel
: `Edge from ${source} to ${target}`}
role="img"
>
<svelte:component
this={edgeComponent}
{id}

View File

@@ -67,6 +67,7 @@ export type EdgeLayouted = Pick<
| 'markerEnd'
| 'sourceHandle'
| 'targetHandle'
| 'ariaLabel'
> &
EdgePosition & {
sourceHandleId?: string | null;

View File

@@ -16,8 +16,15 @@
],
"source": "src/index.ts",
"main": "dist/umd/index.js",
"module": "dist/esm/index.js",
"module": "dist/esm/index.mjs",
"types": "dist/esm/index.d.ts",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.mjs",
"require": "./dist/umd/index.js"
}
},
"sideEffects": false,
"license": "MIT",
"publishConfig": {

View File

@@ -21,6 +21,8 @@ export const errorMessages = {
}", edge id: ${id}.`,
error010: () => 'Handle: No node id found. Make sure to only use a Handle inside a custom Node.',
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.`,
};
export const internalsSymbol = Symbol.for('internals');

View File

@@ -69,6 +69,9 @@ function getPoints({
let points: XYPosition[] = [];
let centerX, centerY;
const sourceGapOffset = { x: 0, y: 0 };
const targetGapOffset = { x: 0, y: 0 };
const [defaultCenterX, defaultCenterY, defaultOffsetX, defaultOffsetY] = getEdgeCenter({
sourceX: source.x,
sourceY: source.y,
@@ -111,6 +114,20 @@ function getPoints({
points = sourceDir.y === currDir ? sourceTarget : targetSource;
}
if (sourcePosition === targetPosition) {
const diff = Math.abs(source[dirAccessor] - target[dirAccessor]);
// if an edge goes from right to right for example (sourcePosition === targetPosition) and the distance between source.x and target.x is less than the offset, the added point and the gapped source/target will overlap. This leads to a weird edge path. To avoid this we add a gapOffset to the source/target
if (diff <= offset) {
const gapOffset = Math.min(offset - 1, offset - diff);
if (sourceDir[dirAccessor] === currDir) {
sourceGapOffset[dirAccessor] = gapOffset;
} else {
targetGapOffset[dirAccessor] = gapOffset;
}
}
}
// these are conditions for handling mixed handle positions like Right -> Bottom for example
if (sourcePosition !== targetPosition) {
const dirAccessorOpposite = dirAccessor === 'x' ? 'y' : 'x';
@@ -126,11 +143,28 @@ function getPoints({
}
}
centerX = points[0].x;
centerY = points[0].y;
const sourceGapPoint = { x: sourceGapped.x - sourceGapOffset.x, y: sourceGapped.y - sourceGapOffset.y };
const targetGapPoint = { x: targetGapped.x - targetGapOffset.x, y: targetGapped.y - targetGapOffset.y };
const maxXDistance = Math.max(Math.abs(sourceGapPoint.x - points[0].x), Math.abs(targetGapPoint.x - points[0].x));
const maxYDistance = Math.max(Math.abs(sourceGapPoint.y - points[0].y), Math.abs(targetGapPoint.y - points[0].y));
// we want to place the label on the longest segment of the edge
if (maxXDistance >= maxYDistance) {
centerX = (sourceGapPoint.x + targetGapPoint.x) / 2;
centerY = points[0].y;
} else {
centerX = points[0].x;
centerY = (sourceGapPoint.y + targetGapPoint.y) / 2;
}
}
const pathPoints = [source, sourceGapped, ...points, targetGapped, target];
const pathPoints = [
source,
{ x: sourceGapped.x - sourceGapOffset.x, y: sourceGapped.y - sourceGapOffset.y },
...points,
{ x: targetGapped.x - targetGapOffset.x, y: targetGapped.y - targetGapOffset.y },
target,
];
return [pathPoints, centerX, centerY, defaultOffsetX, defaultOffsetY];
}

View File

@@ -173,3 +173,5 @@ export const getTransformForBounds = (
return [x, y, clampedZoom];
};
export const isMacOs = () => typeof navigator !== 'undefined' && navigator?.userAgent?.indexOf('Mac') >= 0;

View File

@@ -254,9 +254,10 @@ export function calcNextPosition<NodeType extends NodeBase>(
];
}
const positionAbsolute = currentExtent
? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
const positionAbsolute =
currentExtent && currentExtent !== 'parent'
? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
return {
position: {

View File

@@ -1,7 +1,15 @@
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import { calcAutoPan, getEventPosition, getPointerPosition, calcNextPosition, snapPosition } from '../utils';
import {
calcAutoPan,
getEventPosition,
getPointerPosition,
calcNextPosition,
snapPosition,
getRectOfNodes,
rectToBox,
} from '../utils';
import { getDragItems, getEventHandlerParams, hasSelector, wrapSelectionDragFunc } from './utils';
import type {
NodeBase,
@@ -18,6 +26,7 @@ import type {
OnNodeDrag,
OnSelectionDrag,
UpdateNodePositions,
Box,
} from '../types';
export type OnDrag = (event: MouseEvent, dragItems: NodeDragItem[], node: NodeBase, nodes: NodeBase[]) => void;
@@ -105,6 +114,12 @@ export function XYDrag({
lastPos = { x, y };
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);
nodesBox = rectToBox(rect);
}
dragItems = dragItems.map((n) => {
let nextPosition = { x: x - n.distance.x, y: y - n.distance.y };
@@ -113,9 +128,24 @@ export function XYDrag({
nextPosition = snapPosition(nextPosition, snapGrid);
}
const updatedPos = calcNextPosition(n, nextPosition, nodes, nodeExtent, nodeOrigin, onError);
// 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 = [
[nodeExtent[0][0], nodeExtent[0][1]],
[nodeExtent[1][0], nodeExtent[1][1]],
];
// we want to make sure that we only fire a change event when there is a changes
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];
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 updatedPos = calcNextPosition(n, nextPosition, nodes, 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;
n.position = updatedPos.position;

View File

@@ -10,7 +10,7 @@ import {
PanZoomInstance,
} from '../types';
import { clamp } from '../utils';
import { getD3Transition, viewportToTransform } from './utils';
import { getD3Transition, viewportToTransform, wheelDelta } from './utils';
import {
createPanOnScrollHandler,
createPanZoomEndHandler,
@@ -26,6 +26,8 @@ export type ZoomPanValues = {
prevViewport: Viewport;
mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined;
panScrollTimeout: ReturnType<typeof setTimeout> | undefined;
isPanScrolling: boolean;
};
export function XYPanZoom({
@@ -46,6 +48,8 @@ export function XYPanZoom({
prevViewport: { x: 0, y: 0, zoom: 0 },
mouseButton: 0,
timerId: undefined,
panScrollTimeout: undefined,
isPanScrolling: false,
};
const bbox = domNode.getBoundingClientRect();
const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent);
@@ -65,6 +69,7 @@ export function XYPanZoom({
);
const d3ZoomHandler = d3Selection.on('wheel.zoom')!;
d3ZoomInstance.wheelDelta(wheelDelta);
function setTransform(transform: ZoomTransform, options?: PanZoomTransformOptions) {
if (d3Selection) {
@@ -97,12 +102,16 @@ export function XYPanZoom({
const wheelHandler = isPanOnScroll
? createPanOnScrollHandler({
zoomPanValues,
noWheelClassName,
d3Selection,
d3Zoom: d3ZoomInstance,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
onPanZoomStart,
onPanZoom,
onPanZoomEnd,
})
: createZoomOnScrollHandler({
noWheelClassName,

View File

@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import type { D3ZoomEvent } from 'd3-zoom';
import { pointer } from 'd3-selection';
@@ -7,27 +9,24 @@ import {
type D3ZoomHandler,
type D3ZoomInstance,
type OnPanZoom,
type Viewport,
type OnDraggingChange,
type OnTransformChange,
} from '../types';
import { isRightClickPan, isWrappedWithClass, transformToViewport, viewChanged } from './utils';
export type ZoomPanValues = {
isZoomingOrPanning: boolean;
usedRightMouseButton: boolean;
prevViewport: Viewport;
mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined;
};
import { isRightClickPan, isWrappedWithClass, transformToViewport, viewChanged, wheelDelta } from './utils';
import { isMacOs } from '../utils';
import { ZoomPanValues } from './XYPanZoom';
export type PanOnScrollParams = {
zoomPanValues: ZoomPanValues;
noWheelClassName: string;
d3Selection: D3SelectionInstance;
d3Zoom: D3ZoomInstance;
panOnScrollMode: PanOnScrollMode;
panOnScrollSpeed: number;
zoomOnPinch: boolean;
onPanZoomStart?: OnPanZoom;
onPanZoom?: OnPanZoom;
onPanZoomEnd?: OnPanZoom;
};
export type ZoomOnScrollParams = {
@@ -60,12 +59,16 @@ export type PanZoomEndParams = {
};
export function createPanOnScrollHandler({
zoomPanValues,
noWheelClassName,
d3Selection,
d3Zoom,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
onPanZoomStart,
onPanZoom,
onPanZoomEnd,
}: PanOnScrollParams) {
return (event: any) => {
if (isWrappedWithClass(event, noWheelClassName)) {
@@ -75,13 +78,15 @@ export function createPanOnScrollHandler({
event.stopImmediatePropagation();
const currentZoom = d3Selection.property('__zoom').k || 1;
const _isMacOs = isMacOs();
if (event.ctrlKey && zoomOnPinch) {
// macos sets ctrlKey=true for pinch gesture on a trackpad
if (event.ctrlKey && zoomOnPinch && _isMacOs) {
const point = pointer(event);
// taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js
const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10;
const pinchDelta = wheelDelta(event);
const zoom = currentZoom * Math.pow(2, pinchDelta);
d3Zoom.scaleTo(d3Selection, zoom, point);
// @ts-ignore
d3Zoom.scaleTo(d3Selection, zoom, point, event);
return;
}
@@ -89,14 +94,45 @@ export function createPanOnScrollHandler({
// increase scroll speed in firefox
// firefox: deltaMode === 1; chrome: deltaMode === 0
const deltaNormalize = event.deltaMode === 1 ? 20 : 1;
const deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize;
const deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize;
let deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize;
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) {
deltaX = event.deltaY * deltaNormalize;
deltaY = 0;
}
d3Zoom.translateBy(
d3Selection,
-(deltaX / currentZoom) * panOnScrollSpeed,
-(deltaY / currentZoom) * panOnScrollSpeed
-(deltaY / currentZoom) * panOnScrollSpeed,
// @ts-ignore
{ internal: true }
);
const nextViewport = transformToViewport(d3Selection.property('__zoom'));
clearTimeout(zoomPanValues.panScrollTimeout);
// for pan on scroll we need to handle the event calls on our own
// we can't use the start, zoom and end events from d3-zoom
// because start and move gets called on every scroll event and not once at the beginning
if (!zoomPanValues.isPanScrolling) {
zoomPanValues.isPanScrolling = true;
onPanZoomStart?.(event, nextViewport);
}
if (zoomPanValues.isPanScrolling) {
onPanZoom?.(event, nextViewport);
zoomPanValues.panScrollTimeout = setTimeout(() => {
onPanZoomEnd?.(event, nextViewport);
zoomPanValues.isPanScrolling = false;
}, 150);
}
};
}
@@ -114,19 +150,22 @@ export function createZoomOnScrollHandler({ noWheelClassName, preventScrolling,
export function createPanZoomStartHandler({ zoomPanValues, onDraggingChange, onPanZoomStart }: PanZoomStartParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
if (event.sourceEvent?.internal) {
return;
}
const viewport = transformToViewport(event.transform);
// we need to remember it here, because it's always 0 in the "zoom" event
zoomPanValues.mouseButton = event.sourceEvent?.button || 0;
zoomPanValues.isZoomingOrPanning = true;
zoomPanValues.prevViewport = viewport;
if (event.sourceEvent?.type === 'mousedown') {
onDraggingChange(true);
}
if (onPanZoomStart) {
const viewport = transformToViewport(event.transform);
zoomPanValues.prevViewport = viewport;
onPanZoomStart?.(event.sourceEvent as MouseEvent | TouchEvent, viewport);
}
};
@@ -146,7 +185,7 @@ export function createPanZoomHandler({
onTransformChange([event.transform.x, event.transform.y, event.transform.k]);
if (onPanZoom) {
if (onPanZoom && !event.sourceEvent?.internal) {
onPanZoom?.(event.sourceEvent as MouseEvent | TouchEvent, transformToViewport(event.transform));
}
};
@@ -161,6 +200,9 @@ export function createPanZoomEndHandler({
onPaneContextMenu,
}: PanZoomEndParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
if (event.sourceEvent?.internal) {
return;
}
zoomPanValues.isZoomingOrPanning = false;
if (

View File

@@ -1,6 +1,7 @@
import { type ZoomTransform, zoomIdentity } from 'd3-zoom';
import { type D3SelectionInstance, type Viewport } from '../types';
import { isMacOs } from '../utils';
export const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean =>
prevViewport.x !== eventViewport.x || prevViewport.y !== eventViewport.y || prevViewport.zoom !== eventViewport.k;
@@ -21,3 +22,9 @@ export const isRightClickPan = (panOnDrag: boolean | number[], usedButton: numbe
export const getD3Transition = (selection: D3SelectionInstance, duration = 0) =>
typeof duration === 'number' && duration > 0 ? selection.transition().duration(duration) : selection;
export const wheelDelta = (event: any) => {
const factor = event.ctrlKey && isMacOs() ? 10 : 1;
return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * factor;
};

5431
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff