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
+1 -1
View File
@@ -21,7 +21,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"zustand": "^4.3.1" "zustand": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@cypress/skip-test": "^2.6.1", "@cypress/skip-test": "^2.6.1",
+11 -3
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']; const MULTI_SELECT_KEY = ['Meta', 'Shift'];
@@ -21,6 +21,10 @@ const onPaneContextMenu = (e: any) => {
const panOnDrag = [1, 2]; 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 = () => { const BasicFlow = () => {
return ( return (
<ReactFlow <ReactFlow
@@ -36,11 +40,15 @@ const BasicFlow = () => {
fitView fitView
selectNodesOnDrag={false} selectNodesOnDrag={false}
onSelectionContextMenu={onPaneContextMenu} onSelectionContextMenu={onPaneContextMenu}
onMoveStart={onMoveStart}
onMove={onMove}
onMoveEnd={onMoveEnd}
> >
<Background variant={BackgroundVariant.Cross} /> <Background variant={BackgroundVariant.Cross} />
<div style={{ position: 'absolute', right: 10, top: 10, zIndex: 4 }}> <Controls />
<Panel position="top-right">
<input type={'text'} placeholder={'name'} /> <input type={'text'} placeholder={'name'} />
</div> </Panel>
</ReactFlow> </ReactFlow>
); );
}; };
+11 -2
View File
@@ -16,8 +16,17 @@
], ],
"source": "src/index.ts", "source": "src/index.ts",
"main": "dist/umd/index.js", "main": "dist/umd/index.js",
"module": "dist/esm/index.js", "module": "dist/esm/index.mjs",
"types": "dist/esm/index.d.ts", "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": [ "sideEffects": [
"*.css" "*.css"
], ],
@@ -48,7 +57,7 @@
"d3-drag": "^3.0.0", "d3-drag": "^3.0.0",
"d3-selection": "^3.0.0", "d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0", "d3-zoom": "^3.0.0",
"zustand": "^4.3.3" "zustand": "^4.4.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=17", "react": ">=17",
@@ -58,7 +58,8 @@ function MiniMap({
zoomable = false, zoomable = false,
ariaLabel = 'React Flow mini map', ariaLabel = 'React Flow mini map',
inversePan, inversePan,
zoomStep, zoomStep = 10,
offsetScale = 5,
}: MiniMapProps) { }: MiniMapProps) {
const store = useStoreApi(); const store = useStoreApi();
const svg = useRef<SVGSVGElement>(null); const svg = useRef<SVGSVGElement>(null);
@@ -70,7 +71,7 @@ function MiniMap({
const viewScale = Math.max(scaledWidth, scaledHeight); const viewScale = Math.max(scaledWidth, scaledHeight);
const viewWidth = viewScale * elementWidth; const viewWidth = viewScale * elementWidth;
const viewHeight = viewScale * elementHeight; const viewHeight = viewScale * elementHeight;
const offset = 5 * viewScale; const offset = offsetScale * viewScale;
const x = boundingRect.x - (viewWidth - boundingRect.width) / 2 - offset; const x = boundingRect.x - (viewWidth - boundingRect.width) / 2 - offset;
const y = boundingRect.y - (viewHeight - boundingRect.height) / 2 - offset; const y = boundingRect.y - (viewHeight - boundingRect.height) / 2 - offset;
const width = viewWidth + offset * 2; const width = viewWidth + offset * 2;
@@ -16,6 +16,7 @@ function MiniMapNode({
className, className,
borderRadius, borderRadius,
shapeRendering, shapeRendering,
selected,
onClick, onClick,
}: MiniMapNodeProps) { }: MiniMapNodeProps) {
const { background, backgroundColor } = style || {}; const { background, backgroundColor } = style || {};
@@ -23,7 +24,7 @@ function MiniMapNode({
return ( return (
<rect <rect
className={cc(['react-flow__minimap-node', className])} className={cc(['react-flow__minimap-node', { selected }, className])}
x={x} x={x}
y={y} y={y}
rx={borderRadius} rx={borderRadius}
@@ -47,6 +47,7 @@ function MiniMapNodes({
width={node.width!} width={node.width!}
height={node.height!} height={node.height!}
style={node.style} style={node.style}
selected={!!node.selected}
className={nodeClassNameFunc(node)} className={nodeClassNameFunc(node)}
color={nodeColorFunc(node)} color={nodeColorFunc(node)}
borderRadius={nodeBorderRadius} borderRadius={nodeBorderRadius}
@@ -24,6 +24,7 @@ export type MiniMapProps<NodeData = any> = Omit<HTMLAttributes<SVGSVGElement>, '
ariaLabel?: string | null; ariaLabel?: string | null;
inversePan?: boolean; inversePan?: boolean;
zoomStep?: number; zoomStep?: number;
offsetScale?: number;
}; };
export type MiniMapNodes = Pick< export type MiniMapNodes = Pick<
@@ -46,5 +47,6 @@ export type MiniMapNodeProps = {
strokeColor: string; strokeColor: string;
strokeWidth: number; strokeWidth: number;
style?: CSSProperties; style?: CSSProperties;
selected: boolean;
onClick?: (event: MouseEvent, id: string) => void; onClick?: (event: MouseEvent, id: string) => void;
}; };
@@ -202,7 +202,7 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
onMouseLeave={onEdgeMouseLeave} onMouseLeave={onEdgeMouseLeave}
onKeyDown={isFocusable ? onKeyDown : undefined} onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined} tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : undefined} role={isFocusable ? 'button' : 'img'}
data-testid={`rf__edge-${id}`} data-testid={`rf__edge-${id}`}
aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`} aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`}
aria-describedby={isFocusable ? `${ARIA_EDGE_DESC_KEY}-${rfId}` : undefined} aria-describedby={isFocusable ? `${ARIA_EDGE_DESC_KEY}-${rfId}` : undefined}
+7 -1
View File
@@ -2,6 +2,7 @@ import type { MouseEvent, RefObject } from 'react';
import type { StoreApi } from 'zustand'; import type { StoreApi } from 'zustand';
import type { Node, ReactFlowState } from '../../types'; import type { Node, ReactFlowState } from '../../types';
import { errorMessages } from '@xyflow/system';
export function getMouseHandler( export function getMouseHandler(
id: string, id: string,
@@ -34,9 +35,14 @@ export function handleNodeClick({
unselect?: boolean; unselect?: boolean;
nodeRef?: RefObject<HTMLDivElement>; 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)!; const node = nodes.find((n) => n.id === id)!;
if (!node) {
onError?.('012', errorMessages['error012'](id));
return;
}
store.setState({ nodesSelectionActive: false }); store.setState({ nodesSelectionActive: false });
if (!node.selected) { if (!node.selected) {
@@ -1,12 +1,13 @@
import { useRef, type FC, type PropsWithChildren } from 'react'; import { useRef, type FC, type PropsWithChildren } from 'react';
import { type StoreApi } from 'zustand'; import { type StoreApi } from 'zustand';
import { UseBoundStoreWithEqualityFn } from 'zustand/traditional';
import { Provider } from '../../contexts/RFStoreContext'; import { Provider } from '../../contexts/RFStoreContext';
import { createRFStore } from '../../store'; import { createRFStore } from '../../store';
import type { ReactFlowState } from '../../types'; import type { ReactFlowState } from '../../types';
const ReactFlowProvider: FC<PropsWithChildren<unknown>> = ({ children }) => { const ReactFlowProvider: FC<PropsWithChildren<unknown>> = ({ children }) => {
const storeRef = useRef<StoreApi<ReactFlowState> | null>(null); const storeRef = useRef<UseBoundStoreWithEqualityFn<StoreApi<ReactFlowState>> | null>(null);
if (!storeRef.current) { if (!storeRef.current) {
storeRef.current = createRFStore(); storeRef.current = createRFStore();
@@ -7,11 +7,10 @@ import { useStore } from '../../hooks/useStore';
import useVisibleEdges from '../../hooks/useVisibleEdges'; import useVisibleEdges from '../../hooks/useVisibleEdges';
import MarkerDefinitions from './MarkerDefinitions'; import MarkerDefinitions from './MarkerDefinitions';
import { GraphViewProps } from '../GraphView'; import { GraphViewProps } from '../GraphView';
import type { ReactFlowState } from '../../types'; import type { EdgeTypesWrapped, ReactFlowState } from '../../types';
type EdgeRendererProps = Pick< type EdgeRendererProps = Pick<
GraphViewProps, GraphViewProps,
| 'edgeTypes'
| 'onEdgeClick' | 'onEdgeClick'
| 'onEdgeDoubleClick' | 'onEdgeDoubleClick'
| 'defaultMarkerColor' | 'defaultMarkerColor'
@@ -29,6 +28,7 @@ type EdgeRendererProps = Pick<
| 'rfId' | 'rfId'
| 'disableKeyboardA11y' | 'disableKeyboardA11y'
> & { > & {
edgeTypes: EdgeTypesWrapped;
elevateEdgesOnSelect: boolean; elevateEdgesOnSelect: boolean;
children: ReactNode; children: ReactNode;
}; };
@@ -6,7 +6,10 @@ import EdgeRenderer from '../EdgeRenderer';
import ViewportWrapper from '../Viewport'; import ViewportWrapper from '../Viewport';
import useOnInitHandler from '../../hooks/useOnInitHandler'; import useOnInitHandler from '../../hooks/useOnInitHandler';
import ConnectionLine from '../../components/ConnectionLine'; 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< export type GraphViewProps = Omit<
ReactFlowProps, ReactFlowProps,
@@ -15,6 +18,8 @@ export type GraphViewProps = Omit<
Required< Required<
Pick< Pick<
ReactFlowProps, ReactFlowProps,
| 'nodeTypes'
| 'edgeTypes'
| 'selectionKeyCode' | 'selectionKeyCode'
| 'deleteKeyCode' | 'deleteKeyCode'
| 'multiSelectionKeyCode' | 'multiSelectionKeyCode'
@@ -33,8 +38,6 @@ export type GraphViewProps = Omit<
| 'nodeOrigin' | 'nodeOrigin'
> >
> & { > & {
nodeTypes: NodeTypesWrapped;
edgeTypes: EdgeTypesWrapped;
rfId: string; rfId: string;
}; };
@@ -102,6 +105,9 @@ const GraphView = ({
nodeExtent, nodeExtent,
rfId, rfId,
}: GraphViewProps) => { }: GraphViewProps) => {
const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes);
const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes);
useOnInitHandler(onInit); useOnInitHandler(onInit);
return ( return (
@@ -142,7 +148,7 @@ const GraphView = ({
> >
<ViewportWrapper> <ViewportWrapper>
<EdgeRenderer <EdgeRenderer
edgeTypes={edgeTypes} edgeTypes={edgeTypesWrapped}
onEdgeClick={onEdgeClick} onEdgeClick={onEdgeClick}
onEdgeDoubleClick={onEdgeDoubleClick} onEdgeDoubleClick={onEdgeDoubleClick}
onEdgeUpdate={onEdgeUpdate} onEdgeUpdate={onEdgeUpdate}
@@ -170,7 +176,7 @@ const GraphView = ({
<div className="react-flow__edgelabel-renderer" /> <div className="react-flow__edgelabel-renderer" />
<NodeRenderer <NodeRenderer
nodeTypes={nodeTypes} nodeTypes={nodeTypesWrapped}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick} onNodeDoubleClick={onNodeDoubleClick}
onNodeMouseEnter={onNodeMouseEnter} onNodeMouseEnter={onNodeMouseEnter}
@@ -1,22 +1,24 @@
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { errorMessages, devWarn } from '@xyflow/system'; import { errorMessages } from '@xyflow/system';
import { CreateEdgeTypes } from '../EdgeRenderer/utils'; import { CreateEdgeTypes } from '../EdgeRenderer/utils';
import { CreateNodeTypes } from '../NodeRenderer/utils'; import { CreateNodeTypes } from '../NodeRenderer/utils';
import type { EdgeTypes, EdgeTypesWrapped, NodeTypes, NodeTypesWrapped } from '../../types'; 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: NodeTypes, createTypes: CreateNodeTypes): NodeTypesWrapped;
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: EdgeTypes, createTypes: CreateEdgeTypes): EdgeTypesWrapped; export function useNodeOrEdgeTypes(nodeOrEdgeTypes: EdgeTypes, createTypes: CreateEdgeTypes): EdgeTypesWrapped;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: any, createTypes: any): any { export function useNodeOrEdgeTypes(nodeOrEdgeTypes: any, createTypes: any): any {
const typesKeysRef = useRef<string[] | null>(null); const typesKeysRef = useRef<string[] | null>(null);
const store = useStoreApi();
const typesParsed = useMemo(() => { const typesParsed = useMemo(() => {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
const typeKeys = Object.keys(nodeOrEdgeTypes); const typeKeys = Object.keys(nodeOrEdgeTypes);
if (shallow(typesKeysRef.current, typeKeys)) { if (shallow(typesKeysRef.current, typeKeys)) {
devWarn('002', errorMessages['error002']()); store.getState().onError?.('002', errorMessages['error002']());
} }
typesKeysRef.current = typeKeys; typesKeysRef.current = typeKeys;
@@ -6,11 +6,10 @@ import useVisibleNodes from '../../hooks/useVisibleNodes';
import { useStore } from '../../hooks/useStore'; import { useStore } from '../../hooks/useStore';
import { containerStyle } from '../../styles'; import { containerStyle } from '../../styles';
import { GraphViewProps } from '../GraphView'; import { GraphViewProps } from '../GraphView';
import type { ReactFlowState, WrapNodeProps } from '../../types'; import type { NodeTypesWrapped, ReactFlowState, WrapNodeProps } from '../../types';
type NodeRendererProps = Pick< type NodeRendererProps = Pick<
GraphViewProps, GraphViewProps,
| 'nodeTypes'
| 'onNodeClick' | 'onNodeClick'
| 'onNodeDoubleClick' | 'onNodeDoubleClick'
| 'onNodeMouseEnter' | 'onNodeMouseEnter'
@@ -24,7 +23,9 @@ type NodeRendererProps = Pick<
| 'disableKeyboardA11y' | 'disableKeyboardA11y'
| 'nodeOrigin' | 'nodeOrigin'
| 'nodeExtent' | 'nodeExtent'
>; > & {
nodeTypes: NodeTypesWrapped;
};
const selector = (s: ReactFlowState) => ({ const selector = (s: ReactFlowState) => ({
nodesDraggable: s.nodesDraggable, nodesDraggable: s.nodesDraggable,
@@ -8,6 +8,7 @@ import {
infiniteExtent, infiniteExtent,
type NodeOrigin, type NodeOrigin,
type Viewport, type Viewport,
isMacOs,
} from '@xyflow/system'; } from '@xyflow/system';
import Attribution from '../../components/Attribution'; import Attribution from '../../components/Attribution';
@@ -19,19 +20,9 @@ import GroupNode from '../../components/Nodes/GroupNode';
import SelectionListener from '../../components/SelectionListener'; import SelectionListener from '../../components/SelectionListener';
import StoreUpdater from '../../components/StoreUpdater'; import StoreUpdater from '../../components/StoreUpdater';
import A11yDescriptions from '../../components/A11yDescriptions'; import A11yDescriptions from '../../components/A11yDescriptions';
import { createEdgeTypes } from '../EdgeRenderer/utils';
import { createNodeTypes } from '../NodeRenderer/utils';
import GraphView from '../GraphView'; import GraphView from '../GraphView';
import Wrapper from './Wrapper'; import Wrapper from './Wrapper';
import { useNodeOrEdgeTypes } from './utils'; import type { EdgeTypes, NodeTypes, ReactFlowProps, ReactFlowRefType } from '../../types';
import type {
EdgeTypes,
EdgeTypesWrapped,
NodeTypes,
NodeTypesWrapped,
ReactFlowProps,
ReactFlowRefType,
} from '../../types';
const defaultNodeTypes: NodeTypes = { const defaultNodeTypes: NodeTypes = {
input: InputNode, input: InputNode,
@@ -108,8 +99,8 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
selectionOnDrag = false, selectionOnDrag = false,
selectionMode = SelectionMode.Full, selectionMode = SelectionMode.Full,
panActivationKeyCode = 'Space', panActivationKeyCode = 'Space',
multiSelectionKeyCode = 'Meta', multiSelectionKeyCode = isMacOs() ? 'Meta' : 'Control',
zoomActivationKeyCode = 'Meta', zoomActivationKeyCode = isMacOs() ? 'Meta' : 'Control',
snapToGrid = false, snapToGrid = false,
snapGrid = initSnapGrid, snapGrid = initSnapGrid,
onlyRenderVisibleElements = false, onlyRenderVisibleElements = false,
@@ -176,8 +167,6 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
}, },
ref ref
) => { ) => {
const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes) as NodeTypesWrapped;
const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes) as EdgeTypesWrapped;
const rfId = id || '1'; const rfId = id || '1';
return ( return (
@@ -199,8 +188,8 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
onNodeMouseLeave={onNodeMouseLeave} onNodeMouseLeave={onNodeMouseLeave}
onNodeContextMenu={onNodeContextMenu} onNodeContextMenu={onNodeContextMenu}
onNodeDoubleClick={onNodeDoubleClick} onNodeDoubleClick={onNodeDoubleClick}
nodeTypes={nodeTypesWrapped} nodeTypes={nodeTypes}
edgeTypes={edgeTypesWrapped} edgeTypes={edgeTypes}
connectionLineType={connectionLineType} connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle} connectionLineStyle={connectionLineStyle}
connectionLineComponent={connectionLineComponent} connectionLineComponent={connectionLineComponent}
+2 -1
View File
@@ -1,5 +1,6 @@
import { useContext, useMemo } from 'react'; 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 { errorMessages } from '@xyflow/system';
import StoreContext from '../contexts/RFStoreContext'; import StoreContext from '../contexts/RFStoreContext';
+248 -245
View File
@@ -1,4 +1,4 @@
import { createStore } from 'zustand'; import { createWithEqualityFn } from 'zustand/traditional';
import { import {
clampPosition, clampPosition,
fitView as fitViewSystem, fitView as fitViewSystem,
@@ -25,261 +25,264 @@ import type {
} from '../types'; } from '../types';
const createRFStore = () => const createRFStore = () =>
createStore<ReactFlowState>((set, get) => ({ createWithEqualityFn<ReactFlowState>(
...initialState, (set, get) => ({
setNodes: (nodes: Node[]) => { ...initialState,
const { nodes: storeNodes, nodeOrigin, elevateNodesOnSelect } = get(); setNodes: (nodes: Node[]) => {
const nextNodes = updateNodes(nodes, storeNodes, { nodeOrigin, elevateNodesOnSelect }); const { nodes: storeNodes, nodeOrigin, elevateNodesOnSelect } = get();
const nextNodes = updateNodes(nodes, storeNodes, { nodeOrigin, elevateNodesOnSelect });
set({ nodes: nextNodes }); set({ nodes: nextNodes });
}, },
getNodes: () => { getNodes: () => {
return get().nodes; return get().nodes;
}, },
setEdges: (edges: Edge[]) => { setEdges: (edges: Edge[]) => {
const { defaultEdgeOptions = {} } = get(); const { defaultEdgeOptions = {} } = get();
set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) }); set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) });
}, },
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => { setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => {
const hasDefaultNodes = typeof nodes !== 'undefined'; const hasDefaultNodes = typeof nodes !== 'undefined';
const hasDefaultEdges = typeof edges !== 'undefined'; const hasDefaultEdges = typeof edges !== 'undefined';
const nextNodes = hasDefaultNodes const nextNodes = hasDefaultNodes
? updateNodes(nodes, [], { ? updateNodes(nodes, [], {
nodeOrigin: get().nodeOrigin, nodeOrigin: get().nodeOrigin,
elevateNodesOnSelect: get().elevateNodesOnSelect, elevateNodesOnSelect: get().elevateNodesOnSelect,
}) })
: []; : [];
const nextEdges = hasDefaultEdges ? edges : []; const nextEdges = hasDefaultEdges ? edges : [];
set({ nodes: nextNodes, edges: nextEdges, hasDefaultNodes, hasDefaultEdges }); set({ nodes: nextNodes, edges: nextEdges, hasDefaultNodes, hasDefaultEdges });
}, },
updateNodeDimensions: (updates) => { updateNodeDimensions: (updates) => {
const { onNodesChange, fitView, nodes, fitViewOnInit, fitViewDone, fitViewOnInitOptions, domNode, nodeOrigin } = const { onNodesChange, fitView, nodes, fitViewOnInit, fitViewDone, fitViewOnInitOptions, domNode, nodeOrigin } =
get(); get();
const changes: NodeDimensionChange[] = []; const changes: NodeDimensionChange[] = [];
const updatedNodes = updateNodeDimensionsSystem( const updatedNodes = updateNodeDimensionsSystem(
updates, updates,
nodes, nodes,
domNode, domNode,
nodeOrigin, nodeOrigin,
(id: string, dimensions: Dimensions) => { (id: string, dimensions: Dimensions) => {
changes.push({ changes.push({
id: id, id: id,
type: 'dimensions', type: 'dimensions',
dimensions, dimensions,
}); });
}
);
if (!updatedNodes) {
return;
} }
);
if (!updatedNodes) { const nextNodes = updateAbsolutePositions(updatedNodes, nodeOrigin);
return;
}
const nextNodes = updateAbsolutePositions(updatedNodes, nodeOrigin); const nextFitViewDone =
fitViewDone ||
(fitViewOnInit &&
fitView({
...fitViewOnInitOptions,
nodes: nextNodes,
}));
const nextFitViewDone = set({ nodes: nextNodes, fitViewDone: nextFitViewDone });
fitViewDone ||
(fitViewOnInit &&
fitView({
...fitViewOnInitOptions,
nodes: nextNodes,
}));
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) { if (positionChanged) {
onNodesChange?.(changes); change.positionAbsolute = node.positionAbsolute;
} change.position = node.position;
}, }
updateNodePositions: (nodeDragItems, positionChanged = true, dragging = false) => {
const changes = nodeDragItems.map((node) => { return change;
const change: NodePositionChange = { });
id: node.id,
type: 'position', get().triggerNodeChanges(changes);
dragging, },
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) { set(currentConnection);
change.positionAbsolute = node.positionAbsolute; },
change.position = node.position; reset: () => set({ ...initialState }),
} }),
Object.is
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 }),
}));
export { createRFStore }; export { createRFStore };
+5 -5
View File
@@ -39,8 +39,8 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^2.1.0", "@sveltejs/adapter-auto": "^2.1.0",
"@sveltejs/kit": "^1.20.5", "@sveltejs/kit": "^1.22.6",
"@sveltejs/package": "^2.1.0", "@sveltejs/package": "^2.2.1",
"@typescript-eslint/eslint-plugin": "^5.60.0", "@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0", "@typescript-eslint/parser": "^5.60.0",
"eslint": "^8.43.0", "eslint": "^8.43.0",
@@ -48,9 +48,9 @@
"eslint-plugin-svelte": "^2.31.1", "eslint-plugin-svelte": "^2.31.1",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"prettier-plugin-svelte": "^2.10.1", "prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.0", "svelte": "^4.2.0",
"svelte-check": "^3.4.4", "svelte-check": "^3.5.0",
"svelte-eslint-parser": "^0.31.0", "svelte-eslint-parser": "^0.32.2",
"tslib": "^2.5.3", "tslib": "^2.5.3",
"typescript": "5.1.3", "typescript": "5.1.3",
"vite": "^4.3.9" "vite": "^4.3.9"
@@ -30,6 +30,7 @@
export let targetY: $$Props['targetY']; export let targetY: $$Props['targetY'];
export let sourcePosition: $$Props['sourcePosition']; export let sourcePosition: $$Props['sourcePosition'];
export let targetPosition: $$Props['targetPosition']; export let targetPosition: $$Props['targetPosition'];
export let ariaLabel: $$Props['ariaLabel'] = undefined;
// @ todo: support edge updates // @ todo: support edge updates
const { edges, edgeTypes, flowId, addSelectedEdges } = useStore(); const { edges, edgeTypes, flowId, addSelectedEdges } = useStore();
@@ -51,7 +52,19 @@
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- 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 <svelte:component
this={edgeComponent} this={edgeComponent}
{id} {id}
+1
View File
@@ -67,6 +67,7 @@ export type EdgeLayouted = Pick<
| 'markerEnd' | 'markerEnd'
| 'sourceHandle' | 'sourceHandle'
| 'targetHandle' | 'targetHandle'
| 'ariaLabel'
> & > &
EdgePosition & { EdgePosition & {
sourceHandleId?: string | null; sourceHandleId?: string | null;
+8 -1
View File
@@ -16,8 +16,15 @@
], ],
"source": "src/index.ts", "source": "src/index.ts",
"main": "dist/umd/index.js", "main": "dist/umd/index.js",
"module": "dist/esm/index.js", "module": "dist/esm/index.mjs",
"types": "dist/esm/index.d.ts", "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, "sideEffects": false,
"license": "MIT", "license": "MIT",
"publishConfig": { "publishConfig": {
+2
View File
@@ -21,6 +21,8 @@ export const errorMessages = {
}", edge id: ${id}.`, }", edge id: ${id}.`,
error010: () => 'Handle: No node id found. Make sure to only use a Handle inside a custom Node.', 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".`, 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'); export const internalsSymbol = Symbol.for('internals');
@@ -69,6 +69,9 @@ function getPoints({
let points: XYPosition[] = []; let points: XYPosition[] = [];
let centerX, centerY; let centerX, centerY;
const sourceGapOffset = { x: 0, y: 0 };
const targetGapOffset = { x: 0, y: 0 };
const [defaultCenterX, defaultCenterY, defaultOffsetX, defaultOffsetY] = getEdgeCenter({ const [defaultCenterX, defaultCenterY, defaultOffsetX, defaultOffsetY] = getEdgeCenter({
sourceX: source.x, sourceX: source.x,
sourceY: source.y, sourceY: source.y,
@@ -111,6 +114,20 @@ function getPoints({
points = sourceDir.y === currDir ? sourceTarget : targetSource; 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 // these are conditions for handling mixed handle positions like Right -> Bottom for example
if (sourcePosition !== targetPosition) { if (sourcePosition !== targetPosition) {
const dirAccessorOpposite = dirAccessor === 'x' ? 'y' : 'x'; const dirAccessorOpposite = dirAccessor === 'x' ? 'y' : 'x';
@@ -126,11 +143,28 @@ function getPoints({
} }
} }
centerX = points[0].x; const sourceGapPoint = { x: sourceGapped.x - sourceGapOffset.x, y: sourceGapped.y - sourceGapOffset.y };
centerY = points[0].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]; return [pathPoints, centerX, centerY, defaultOffsetX, defaultOffsetY];
} }
+2
View File
@@ -173,3 +173,5 @@ export const getTransformForBounds = (
return [x, y, clampedZoom]; return [x, y, clampedZoom];
}; };
export const isMacOs = () => typeof navigator !== 'undefined' && navigator?.userAgent?.indexOf('Mac') >= 0;
+4 -3
View File
@@ -254,9 +254,10 @@ export function calcNextPosition<NodeType extends NodeBase>(
]; ];
} }
const positionAbsolute = currentExtent const positionAbsolute =
? clampPosition(nextPosition, currentExtent as CoordinateExtent) currentExtent && currentExtent !== 'parent'
: nextPosition; ? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
return { return {
position: { position: {
+33 -3
View File
@@ -1,7 +1,15 @@
import { drag } from 'd3-drag'; import { drag } from 'd3-drag';
import { select } from 'd3-selection'; 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 { getDragItems, getEventHandlerParams, hasSelector, wrapSelectionDragFunc } from './utils';
import type { import type {
NodeBase, NodeBase,
@@ -18,6 +26,7 @@ import type {
OnNodeDrag, OnNodeDrag,
OnSelectionDrag, OnSelectionDrag,
UpdateNodePositions, UpdateNodePositions,
Box,
} from '../types'; } from '../types';
export type OnDrag = (event: MouseEvent, dragItems: NodeDragItem[], node: NodeBase, nodes: NodeBase[]) => void; export type OnDrag = (event: MouseEvent, dragItems: NodeDragItem[], node: NodeBase, nodes: NodeBase[]) => void;
@@ -105,6 +114,12 @@ export function XYDrag({
lastPos = { x, y }; lastPos = { x, y };
let hasChange = false; 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) => { dragItems = dragItems.map((n) => {
let nextPosition = { x: x - n.distance.x, y: y - n.distance.y }; let nextPosition = { x: x - n.distance.x, y: y - n.distance.y };
@@ -113,9 +128,24 @@ export function XYDrag({
nextPosition = snapPosition(nextPosition, snapGrid); 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; hasChange = hasChange || n.position.x !== updatedPos.position.x || n.position.y !== updatedPos.position.y;
n.position = updatedPos.position; n.position = updatedPos.position;
+10 -1
View File
@@ -10,7 +10,7 @@ import {
PanZoomInstance, PanZoomInstance,
} from '../types'; } from '../types';
import { clamp } from '../utils'; import { clamp } from '../utils';
import { getD3Transition, viewportToTransform } from './utils'; import { getD3Transition, viewportToTransform, wheelDelta } from './utils';
import { import {
createPanOnScrollHandler, createPanOnScrollHandler,
createPanZoomEndHandler, createPanZoomEndHandler,
@@ -26,6 +26,8 @@ export type ZoomPanValues = {
prevViewport: Viewport; prevViewport: Viewport;
mouseButton: number; mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined; timerId: ReturnType<typeof setTimeout> | undefined;
panScrollTimeout: ReturnType<typeof setTimeout> | undefined;
isPanScrolling: boolean;
}; };
export function XYPanZoom({ export function XYPanZoom({
@@ -46,6 +48,8 @@ export function XYPanZoom({
prevViewport: { x: 0, y: 0, zoom: 0 }, prevViewport: { x: 0, y: 0, zoom: 0 },
mouseButton: 0, mouseButton: 0,
timerId: undefined, timerId: undefined,
panScrollTimeout: undefined,
isPanScrolling: false,
}; };
const bbox = domNode.getBoundingClientRect(); const bbox = domNode.getBoundingClientRect();
const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent); const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent);
@@ -65,6 +69,7 @@ export function XYPanZoom({
); );
const d3ZoomHandler = d3Selection.on('wheel.zoom')!; const d3ZoomHandler = d3Selection.on('wheel.zoom')!;
d3ZoomInstance.wheelDelta(wheelDelta);
function setTransform(transform: ZoomTransform, options?: PanZoomTransformOptions) { function setTransform(transform: ZoomTransform, options?: PanZoomTransformOptions) {
if (d3Selection) { if (d3Selection) {
@@ -97,12 +102,16 @@ export function XYPanZoom({
const wheelHandler = isPanOnScroll const wheelHandler = isPanOnScroll
? createPanOnScrollHandler({ ? createPanOnScrollHandler({
zoomPanValues,
noWheelClassName, noWheelClassName,
d3Selection, d3Selection,
d3Zoom: d3ZoomInstance, d3Zoom: d3ZoomInstance,
panOnScrollMode, panOnScrollMode,
panOnScrollSpeed, panOnScrollSpeed,
zoomOnPinch, zoomOnPinch,
onPanZoomStart,
onPanZoom,
onPanZoomEnd,
}) })
: createZoomOnScrollHandler({ : createZoomOnScrollHandler({
noWheelClassName, noWheelClassName,
+64 -22
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 type { D3ZoomEvent } from 'd3-zoom';
import { pointer } from 'd3-selection'; import { pointer } from 'd3-selection';
@@ -7,27 +9,24 @@ import {
type D3ZoomHandler, type D3ZoomHandler,
type D3ZoomInstance, type D3ZoomInstance,
type OnPanZoom, type OnPanZoom,
type Viewport,
type OnDraggingChange, type OnDraggingChange,
type OnTransformChange, type OnTransformChange,
} from '../types'; } from '../types';
import { isRightClickPan, isWrappedWithClass, transformToViewport, viewChanged } from './utils'; import { isRightClickPan, isWrappedWithClass, transformToViewport, viewChanged, wheelDelta } from './utils';
import { isMacOs } from '../utils';
export type ZoomPanValues = { import { ZoomPanValues } from './XYPanZoom';
isZoomingOrPanning: boolean;
usedRightMouseButton: boolean;
prevViewport: Viewport;
mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined;
};
export type PanOnScrollParams = { export type PanOnScrollParams = {
zoomPanValues: ZoomPanValues;
noWheelClassName: string; noWheelClassName: string;
d3Selection: D3SelectionInstance; d3Selection: D3SelectionInstance;
d3Zoom: D3ZoomInstance; d3Zoom: D3ZoomInstance;
panOnScrollMode: PanOnScrollMode; panOnScrollMode: PanOnScrollMode;
panOnScrollSpeed: number; panOnScrollSpeed: number;
zoomOnPinch: boolean; zoomOnPinch: boolean;
onPanZoomStart?: OnPanZoom;
onPanZoom?: OnPanZoom;
onPanZoomEnd?: OnPanZoom;
}; };
export type ZoomOnScrollParams = { export type ZoomOnScrollParams = {
@@ -60,12 +59,16 @@ export type PanZoomEndParams = {
}; };
export function createPanOnScrollHandler({ export function createPanOnScrollHandler({
zoomPanValues,
noWheelClassName, noWheelClassName,
d3Selection, d3Selection,
d3Zoom, d3Zoom,
panOnScrollMode, panOnScrollMode,
panOnScrollSpeed, panOnScrollSpeed,
zoomOnPinch, zoomOnPinch,
onPanZoomStart,
onPanZoom,
onPanZoomEnd,
}: PanOnScrollParams) { }: PanOnScrollParams) {
return (event: any) => { return (event: any) => {
if (isWrappedWithClass(event, noWheelClassName)) { if (isWrappedWithClass(event, noWheelClassName)) {
@@ -75,13 +78,15 @@ export function createPanOnScrollHandler({
event.stopImmediatePropagation(); event.stopImmediatePropagation();
const currentZoom = d3Selection.property('__zoom').k || 1; 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); const point = pointer(event);
// taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js const pinchDelta = wheelDelta(event);
const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10;
const zoom = currentZoom * Math.pow(2, pinchDelta); const zoom = currentZoom * Math.pow(2, pinchDelta);
d3Zoom.scaleTo(d3Selection, zoom, point); // @ts-ignore
d3Zoom.scaleTo(d3Selection, zoom, point, event);
return; return;
} }
@@ -89,14 +94,45 @@ export function createPanOnScrollHandler({
// increase scroll speed in firefox // increase scroll speed in firefox
// firefox: deltaMode === 1; chrome: deltaMode === 0 // firefox: deltaMode === 1; chrome: deltaMode === 0
const deltaNormalize = event.deltaMode === 1 ? 20 : 1; const deltaNormalize = event.deltaMode === 1 ? 20 : 1;
const deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize; let deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize;
const deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * 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( d3Zoom.translateBy(
d3Selection, d3Selection,
-(deltaX / currentZoom) * panOnScrollSpeed, -(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) { export function createPanZoomStartHandler({ zoomPanValues, onDraggingChange, onPanZoomStart }: PanZoomStartParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => { 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 // we need to remember it here, because it's always 0 in the "zoom" event
zoomPanValues.mouseButton = event.sourceEvent?.button || 0; zoomPanValues.mouseButton = event.sourceEvent?.button || 0;
zoomPanValues.isZoomingOrPanning = true; zoomPanValues.isZoomingOrPanning = true;
zoomPanValues.prevViewport = viewport;
if (event.sourceEvent?.type === 'mousedown') { if (event.sourceEvent?.type === 'mousedown') {
onDraggingChange(true); onDraggingChange(true);
} }
if (onPanZoomStart) { if (onPanZoomStart) {
const viewport = transformToViewport(event.transform);
zoomPanValues.prevViewport = viewport;
onPanZoomStart?.(event.sourceEvent as MouseEvent | TouchEvent, 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]); 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)); onPanZoom?.(event.sourceEvent as MouseEvent | TouchEvent, transformToViewport(event.transform));
} }
}; };
@@ -161,6 +200,9 @@ export function createPanZoomEndHandler({
onPaneContextMenu, onPaneContextMenu,
}: PanZoomEndParams) { }: PanZoomEndParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => { return (event: D3ZoomEvent<HTMLDivElement, any>) => {
if (event.sourceEvent?.internal) {
return;
}
zoomPanValues.isZoomingOrPanning = false; zoomPanValues.isZoomingOrPanning = false;
if ( if (
+7
View File
@@ -1,6 +1,7 @@
import { type ZoomTransform, zoomIdentity } from 'd3-zoom'; import { type ZoomTransform, zoomIdentity } from 'd3-zoom';
import { type D3SelectionInstance, type Viewport } from '../types'; import { type D3SelectionInstance, type Viewport } from '../types';
import { isMacOs } from '../utils';
export const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean => export const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean =>
prevViewport.x !== eventViewport.x || prevViewport.y !== eventViewport.y || prevViewport.zoom !== eventViewport.k; 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) => export const getD3Transition = (selection: D3SelectionInstance, duration = 0) =>
typeof duration === 'number' && duration > 0 ? selection.transition().duration(duration) : selection; 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;
};
+2883 -2548
View File
File diff suppressed because it is too large Load Diff