Refactor fitView

This commit is contained in:
peterkogo
2025-03-05 13:45:28 +01:00
parent 8017792542
commit ee1ef205eb
11 changed files with 99 additions and 151 deletions

View File

@@ -56,8 +56,18 @@ const initialEdges: Edge[] = [
const defaultEdgeOptions = {};
const BasicFlow = () => {
const { addNodes, setNodes, getNodes, setEdges, getEdges, deleteElements, updateNodeData, toObject, setViewport } =
useReactFlow();
const {
addNodes,
setNodes,
getNodes,
setEdges,
getEdges,
deleteElements,
updateNodeData,
toObject,
setViewport,
fitView,
} = useReactFlow();
const updatePos = () => {
setNodes((nodes) =>
@@ -104,6 +114,7 @@ const BasicFlow = () => {
]);
setEdges([{ id: 'a-b', source: 'a', target: 'b' }]);
fitView();
};
const onUpdateNode = () => {
@@ -117,6 +128,7 @@ const BasicFlow = () => {
position: { x: Math.random() * 300, y: Math.random() * 300 },
className: 'light',
});
fitView();
};
return (

View File

@@ -154,8 +154,8 @@ export function StoreUpdater<NodeType extends Node = Node, EdgeType extends Edge
else if (fieldName === 'nodeExtent') setNodeExtent(fieldValue as CoordinateExtent);
else if (fieldName === 'paneClickDistance') setPaneClickDistance(fieldValue as number);
// Renamed fields
else if (fieldName === 'fitView') store.setState({ fitViewOnInit: fieldValue as boolean });
else if (fieldName === 'fitViewOptions') store.setState({ fitViewOnInitOptions: fieldValue as FitViewOptions });
else if (fieldName === 'fitView') store.setState({ fitViewQueued: fieldValue as boolean });
else if (fieldName === 'fitViewOptions') store.setState({ fitViewOptions: fieldValue as FitViewOptions });
// General case
else store.setState({ [fieldName]: fieldValue });
}

View File

@@ -15,7 +15,15 @@ import useViewportHelper from './useViewportHelper';
import { useStore, useStoreApi } from './useStore';
import { useBatchContext } from '../components/BatchProvider';
import { elementToRemoveChange, isEdge, isNode } from '../utils';
import type { ReactFlowInstance, Node, Edge, InternalNode, ReactFlowState, GeneralHelpers } from '../types';
import type {
ReactFlowInstance,
Node,
Edge,
InternalNode,
ReactFlowState,
GeneralHelpers,
FitViewOptions,
} from '../types';
const selector = (s: ReactFlowState) => !!s.panZoom;
@@ -271,6 +279,10 @@ export function useReactFlow<NodeType extends Node = Node, EdgeType extends Edge
.connectionLookup.get(`${nodeId}${type ? (handleId ? `-${type}-${handleId}` : `-${type}`) : ''}`)
?.values() ?? []
),
fitView: (options: FitViewOptions<NodeType> | undefined) => {
store.setState({ fitViewQueued: true, fitViewOptions: options });
batchContext.nodeQueue.push((nodes) => [...nodes]);
},
};
}, []);

View File

@@ -2,11 +2,8 @@ import { useMemo } from 'react';
import {
pointToRendererPoint,
getViewportForBounds,
getFitViewNodes,
fitView,
type XYPosition,
rendererPointToPoint,
getDimensions,
SnapGrid,
} from '@xyflow/system';
@@ -65,28 +62,6 @@ const useViewportHelper = (): ViewportHelperFunctions => {
const [x, y, zoom] = store.getState().transform;
return { x, y, zoom };
},
fitView: (options) => {
const { nodeLookup, minZoom, maxZoom, panZoom, domNode } = store.getState();
if (!panZoom || !domNode) {
return Promise.resolve(false);
}
const fitViewNodes = getFitViewNodes(nodeLookup, options);
const { width, height } = getDimensions(domNode);
return fitView(
{
nodes: fitViewNodes,
width,
height,
minZoom,
maxZoom,
panZoom,
},
options
);
},
setCenter: async (x, y, options) => {
const { width, height, maxZoom, panZoom } = store.getState();
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : maxZoom;

View File

@@ -1,7 +1,5 @@
import { createWithEqualityFn } from 'zustand/traditional';
import {
getFitViewNodes,
fitView as fitViewSystem,
adoptUserNodes,
updateAbsolutePositions,
panBy as panBySystem,
@@ -15,11 +13,12 @@ import {
initialConnection,
NodeOrigin,
CoordinateExtent,
fitViewport,
} from '@xyflow/system';
import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes';
import getInitialState from './initialState';
import type { ReactFlowState, Node, Edge, UnselectNodesAndEdgesParams, FitViewOptions } from '../types';
import type { ReactFlowState, Node, Edge, UnselectNodesAndEdgesParams } from '../types';
const createStore = ({
nodes,
@@ -46,7 +45,7 @@ const createStore = ({
(set, get) => ({
...getInitialState({ nodes, edges, width, height, fitView, nodeOrigin, nodeExtent, defaultNodes, defaultEdges }),
setNodes: (nodes: Node[]) => {
const { nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect } = get();
const { nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect, fitViewQueued, panZoom } = get();
/*
* setNodes() is called exclusively in response to user actions:
* - either when the `<ReactFlow nodes>` prop is updated in the controlled ReactFlow setup,
@@ -55,14 +54,35 @@ const createStore = ({
* When this happens, we take the note objects passed by the user and extend them with fields
* relevant for internal React Flow operations.
*/
adoptUserNodes(nodes, nodeLookup, parentLookup, {
const nodesInitialized = adoptUserNodes(nodes, nodeLookup, parentLookup, {
nodeOrigin,
nodeExtent,
elevateNodesOnSelect,
checkEquality: true,
});
set({ nodes });
let viewportFitted = false;
if (fitViewQueued && nodesInitialized && panZoom) {
const { fitViewOptions, width, height, minZoom, maxZoom } = get();
viewportFitted = fitViewport(
{
nodes: nodeLookup,
width,
height,
panZoom,
minZoom,
maxZoom,
},
fitViewOptions
);
}
if (viewportFitted) {
set({ nodes, fitViewQueued: false, fitViewOptions: undefined });
} else {
set({ nodes });
}
},
setEdges: (edges: Edge[]) => {
const { connectionLookup, edgeLookup } = get();
@@ -88,20 +108,8 @@ const createStore = ({
* changes its dimensions, this function is called to measure the
* new dimensions and update the nodes.
*/
updateNodeInternals: (updates, params = { triggerFitView: true }) => {
const {
triggerNodeChanges,
nodeLookup,
parentLookup,
fitViewOnInit,
fitViewDone,
fitViewOnInitOptions,
domNode,
nodeOrigin,
nodeExtent,
debug,
fitViewSync,
} = get();
updateNodeInternals: (updates) => {
const { triggerNodeChanges, nodeLookup, parentLookup, domNode, nodeOrigin, nodeExtent, debug } = get();
const { changes, updatedInternals } = updateNodeInternalsSystem(
updates,
@@ -118,29 +126,8 @@ const createStore = ({
updateAbsolutePositions(nodeLookup, parentLookup, { nodeOrigin, nodeExtent });
if (params.triggerFitView) {
// we call fitView once initially after all dimensions are set
let nextFitViewDone = fitViewDone;
if (!fitViewDone && fitViewOnInit) {
nextFitViewDone = fitViewSync({
...fitViewOnInitOptions,
nodes: fitViewOnInitOptions?.nodes,
});
}
/*
* here we are cirmumventing the onNodesChange handler
* in order to be able to display nodes even if the user
* has not provided an onNodesChange handler.
* Nodes are only rendered if they have a width and height
* attribute which they get from this handler.
*/
set({ fitViewDone: nextFitViewDone });
} else {
// we always want to trigger useStore calls whenever updateNodeInternals is called
set({});
}
// we always want to trigger useStore calls whenever updateNodeInternals is called
set({});
if (changes?.length > 0) {
if (debug) {
@@ -332,54 +319,6 @@ const createStore = ({
return panBySystem({ delta, panZoom, transform, translateExtent, width, height });
},
fitView: (options?: FitViewOptions): Promise<boolean> => {
const { panZoom, width, height, minZoom, maxZoom, nodeLookup } = get();
if (!panZoom) {
return Promise.resolve(false);
}
const fitViewNodes = getFitViewNodes(nodeLookup, options);
return fitViewSystem(
{
nodes: fitViewNodes,
width,
height,
panZoom,
minZoom,
maxZoom,
},
options
);
},
/*
* we can't call an asnychronous function in updateNodeInternals
* for that we created this sync version of fitView
*/
fitViewSync: (options?: FitViewOptions): boolean => {
const { panZoom, width, height, minZoom, maxZoom, nodeLookup } = get();
if (!panZoom) {
return false;
}
const fitViewNodes = getFitViewNodes(nodeLookup, options);
fitViewSystem(
{
nodes: fitViewNodes,
width,
height,
panZoom,
minZoom,
maxZoom,
},
options
);
return fitViewNodes.size > 0;
},
cancelConnection: () => {
set({
connection: { ...initialConnection },

View File

@@ -104,13 +104,13 @@ const getInitialState = ({
elementsSelectable: true,
elevateNodesOnSelect: true,
elevateEdgesOnSelect: false,
fitViewOnInit: false,
fitViewDone: false,
fitViewOnInitOptions: undefined,
selectNodesOnDrag: true,
multiSelectionActive: false,
fitViewQueued: false,
fitViewOptions: undefined,
connection: { ...initialConnection },
connectionClickStartHandle: null,
connectOnClick: true,

View File

@@ -109,7 +109,7 @@ export type FitViewParams<NodeType extends Node = Node> = FitViewParamsBase<Node
* @public
*/
export type FitViewOptions<NodeType extends Node = Node> = FitViewOptionsBase<NodeType>;
export type FitView = (fitViewOptions?: FitViewOptions) => Promise<boolean>;
export type FitView<NodeType extends Node = Node> = (fitViewOptions?: FitViewOptions<NodeType>) => void;
export type OnInit<NodeType extends Node = Node, EdgeType extends Edge = Edge> = (
reactFlowInstance: ReactFlowInstance<NodeType, EdgeType>
) => void;
@@ -156,17 +156,6 @@ export type ViewportHelperFunctions = {
* @returns Viewport
*/
getViewport: GetViewport;
/**
* Fits the view.
*
* @param options.padding - optional padding
* @param options.includeHiddenNodes - optional includeHiddenNodes
* @param options.minZoom - optional minZoom
* @param options.maxZoom - optional maxZoom
* @param options.duration - optional duration. If set, a transition will be applied
* @param options.nodes - optional nodes to fit the view to
*/
fitView: FitView;
/**
* Sets the center of the view to the given position.
*

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-namespace */
import type { HandleConnection, HandleType, NodeConnection, Rect, Viewport } from '@xyflow/system';
import type { Node, Edge, ViewportHelperFunctions, InternalNode } from '.';
import type { Node, Edge, ViewportHelperFunctions, InternalNode, FitView } from '.';
export type ReactFlowJsonObject<NodeType extends Node = Node, EdgeType extends Edge = Edge> = {
nodes: NodeType[];
@@ -217,6 +217,17 @@ export type GeneralHelpers<NodeType extends Node = Node, EdgeType extends Edge =
nodeId: string;
handleId?: string | null;
}) => NodeConnection[];
// /**
// * Fits the view.
// *
// * @param options.padding - optional padding
// * @param options.includeHiddenNodes - optional includeHiddenNodes
// * @param options.minZoom - optional minZoom
// * @param options.maxZoom - optional maxZoom
// * @param options.duration - optional duration. If set, a transition will be applied
// * @param options.nodes - optional nodes to fit the view to
// */
fitView: FitView<NodeType>;
};
/**
* The `ReactFlowInstance` provides a collection of methods to query and manipulate

View File

@@ -119,9 +119,8 @@ export type ReactFlowStore<NodeType extends Node = Node, EdgeType extends Edge =
connectOnClick: boolean;
defaultEdgeOptions?: DefaultEdgeOptions;
fitViewOnInit: boolean;
fitViewDone: boolean;
fitViewOnInitOptions: FitViewOptions | undefined;
fitViewQueued: boolean;
fitViewOptions: FitViewOptions | undefined;
onNodesDelete?: OnNodesDelete<NodeType>;
onEdgesDelete?: OnEdgesDelete<EdgeType>;
@@ -168,8 +167,6 @@ export type ReactFlowActions<NodeType extends Node, EdgeType extends Edge> = {
triggerNodeChanges: (changes: NodeChange<NodeType>[]) => void;
triggerEdgeChanges: (changes: EdgeChange<EdgeType>[]) => void;
panBy: PanBy;
fitView: (options?: FitViewOptions) => Promise<boolean>;
fitViewSync: (options?: FitViewOptions) => boolean;
setPaneClickDistance: (distance: number) => void;
};

View File

@@ -333,10 +333,10 @@ export const getConnectedEdges = <NodeType extends NodeBase = NodeBase, EdgeType
return edges.filter((edge) => nodeIds.has(edge.source) || nodeIds.has(edge.target));
};
export function getFitViewNodes<
function getFitViewNodes<
Params extends NodeLookup<InternalNodeBase<NodeBase>>,
Options extends FitViewOptionsBase<NodeBase>
>(nodeLookup: Params, options?: Pick<Options, 'nodes' | 'includeHiddenNodes'>) {
>(nodeLookup: Params, options?: Options) {
const fitViewNodes: NodeLookup = new Map();
const optionNodeIds = options?.nodes ? new Set(options.nodes.map((node) => node.id)) : null;
@@ -351,15 +351,21 @@ export function getFitViewNodes<
return fitViewNodes;
}
export async function fitView<Params extends FitViewParamsBase<NodeBase>, Options extends FitViewOptionsBase<NodeBase>>(
export function fitViewport<Params extends FitViewParamsBase<NodeBase>, Options extends FitViewOptionsBase<NodeBase>>(
{ nodes, width, height, panZoom, minZoom, maxZoom }: Params,
options?: Omit<Options, 'nodes' | 'includeHiddenNodes'>
): Promise<boolean> {
): boolean {
if (nodes.size === 0) {
return Promise.resolve(false);
return true;
}
const bounds = getInternalNodesBounds(nodes);
if (!panZoom) {
return false;
}
const nodesToFit = getFitViewNodes(nodes, options);
const bounds = getInternalNodesBounds(nodesToFit);
const viewport = getViewportForBounds(
bounds,
@@ -370,9 +376,9 @@ export async function fitView<Params extends FitViewParamsBase<NodeBase>, Option
options?.padding ?? 0.1
);
await panZoom.setViewport(viewport, { duration: options?.duration });
panZoom.setViewport(viewport, { duration: options?.duration });
return Promise.resolve(true);
return true;
}
/**

View File

@@ -85,9 +85,10 @@ export function adoptUserNodes<NodeType extends NodeBase>(
nodeLookup: NodeLookup<InternalNodeBase<NodeType>>,
parentLookup: ParentLookup<InternalNodeBase<NodeType>>,
options?: UpdateNodesOptions<NodeType>
) {
): boolean {
const _options = mergeObjects(adoptUserNodesDefaultOptions, options);
let nodesInitialized = true;
const tmpLookup = new Map(nodeLookup);
const selectedNodeZ: number = _options?.elevateNodesOnSelect ? 1000 : 0;
@@ -123,10 +124,16 @@ export function adoptUserNodes<NodeType extends NodeBase>(
nodeLookup.set(userNode.id, internalNode);
}
if (!internalNode.measured || !internalNode.measured.width || !internalNode.measured.height) {
nodesInitialized = false;
}
if (userNode.parentId) {
updateChildNode(internalNode, nodeLookup, parentLookup, options);
}
}
return nodesInitialized;
}
function updateParentLookup<NodeType extends NodeBase>(