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
+14 -2
View File
@@ -56,8 +56,18 @@ const initialEdges: Edge[] = [
const defaultEdgeOptions = {}; const defaultEdgeOptions = {};
const BasicFlow = () => { const BasicFlow = () => {
const { addNodes, setNodes, getNodes, setEdges, getEdges, deleteElements, updateNodeData, toObject, setViewport } = const {
useReactFlow(); addNodes,
setNodes,
getNodes,
setEdges,
getEdges,
deleteElements,
updateNodeData,
toObject,
setViewport,
fitView,
} = useReactFlow();
const updatePos = () => { const updatePos = () => {
setNodes((nodes) => setNodes((nodes) =>
@@ -104,6 +114,7 @@ const BasicFlow = () => {
]); ]);
setEdges([{ id: 'a-b', source: 'a', target: 'b' }]); setEdges([{ id: 'a-b', source: 'a', target: 'b' }]);
fitView();
}; };
const onUpdateNode = () => { const onUpdateNode = () => {
@@ -117,6 +128,7 @@ const BasicFlow = () => {
position: { x: Math.random() * 300, y: Math.random() * 300 }, position: { x: Math.random() * 300, y: Math.random() * 300 },
className: 'light', className: 'light',
}); });
fitView();
}; };
return ( return (
@@ -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 === 'nodeExtent') setNodeExtent(fieldValue as CoordinateExtent);
else if (fieldName === 'paneClickDistance') setPaneClickDistance(fieldValue as number); else if (fieldName === 'paneClickDistance') setPaneClickDistance(fieldValue as number);
// Renamed fields // Renamed fields
else if (fieldName === 'fitView') store.setState({ fitViewOnInit: fieldValue as boolean }); else if (fieldName === 'fitView') store.setState({ fitViewQueued: fieldValue as boolean });
else if (fieldName === 'fitViewOptions') store.setState({ fitViewOnInitOptions: fieldValue as FitViewOptions }); else if (fieldName === 'fitViewOptions') store.setState({ fitViewOptions: fieldValue as FitViewOptions });
// General case // General case
else store.setState({ [fieldName]: fieldValue }); else store.setState({ [fieldName]: fieldValue });
} }
+13 -1
View File
@@ -15,7 +15,15 @@ import useViewportHelper from './useViewportHelper';
import { useStore, useStoreApi } from './useStore'; import { useStore, useStoreApi } from './useStore';
import { useBatchContext } from '../components/BatchProvider'; import { useBatchContext } from '../components/BatchProvider';
import { elementToRemoveChange, isEdge, isNode } from '../utils'; 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; 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}`) : ''}`) .connectionLookup.get(`${nodeId}${type ? (handleId ? `-${type}-${handleId}` : `-${type}`) : ''}`)
?.values() ?? [] ?.values() ?? []
), ),
fitView: (options: FitViewOptions<NodeType> | undefined) => {
store.setState({ fitViewQueued: true, fitViewOptions: options });
batchContext.nodeQueue.push((nodes) => [...nodes]);
},
}; };
}, []); }, []);
@@ -2,11 +2,8 @@ import { useMemo } from 'react';
import { import {
pointToRendererPoint, pointToRendererPoint,
getViewportForBounds, getViewportForBounds,
getFitViewNodes,
fitView,
type XYPosition, type XYPosition,
rendererPointToPoint, rendererPointToPoint,
getDimensions,
SnapGrid, SnapGrid,
} from '@xyflow/system'; } from '@xyflow/system';
@@ -65,28 +62,6 @@ const useViewportHelper = (): ViewportHelperFunctions => {
const [x, y, zoom] = store.getState().transform; const [x, y, zoom] = store.getState().transform;
return { x, y, zoom }; 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) => { setCenter: async (x, y, options) => {
const { width, height, maxZoom, panZoom } = store.getState(); const { width, height, maxZoom, panZoom } = store.getState();
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : maxZoom; const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : maxZoom;
+30 -91
View File
@@ -1,7 +1,5 @@
import { createWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn } from 'zustand/traditional';
import { import {
getFitViewNodes,
fitView as fitViewSystem,
adoptUserNodes, adoptUserNodes,
updateAbsolutePositions, updateAbsolutePositions,
panBy as panBySystem, panBy as panBySystem,
@@ -15,11 +13,12 @@ import {
initialConnection, initialConnection,
NodeOrigin, NodeOrigin,
CoordinateExtent, CoordinateExtent,
fitViewport,
} from '@xyflow/system'; } from '@xyflow/system';
import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes';
import getInitialState from './initialState'; import getInitialState from './initialState';
import type { ReactFlowState, Node, Edge, UnselectNodesAndEdgesParams, FitViewOptions } from '../types'; import type { ReactFlowState, Node, Edge, UnselectNodesAndEdgesParams } from '../types';
const createStore = ({ const createStore = ({
nodes, nodes,
@@ -46,7 +45,7 @@ const createStore = ({
(set, get) => ({ (set, get) => ({
...getInitialState({ nodes, edges, width, height, fitView, nodeOrigin, nodeExtent, defaultNodes, defaultEdges }), ...getInitialState({ nodes, edges, width, height, fitView, nodeOrigin, nodeExtent, defaultNodes, defaultEdges }),
setNodes: (nodes: Node[]) => { 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: * setNodes() is called exclusively in response to user actions:
* - either when the `<ReactFlow nodes>` prop is updated in the controlled ReactFlow setup, * - 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 * When this happens, we take the note objects passed by the user and extend them with fields
* relevant for internal React Flow operations. * relevant for internal React Flow operations.
*/ */
adoptUserNodes(nodes, nodeLookup, parentLookup, {
const nodesInitialized = adoptUserNodes(nodes, nodeLookup, parentLookup, {
nodeOrigin, nodeOrigin,
nodeExtent, nodeExtent,
elevateNodesOnSelect, elevateNodesOnSelect,
checkEquality: true, 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[]) => { setEdges: (edges: Edge[]) => {
const { connectionLookup, edgeLookup } = get(); const { connectionLookup, edgeLookup } = get();
@@ -88,20 +108,8 @@ const createStore = ({
* changes its dimensions, this function is called to measure the * changes its dimensions, this function is called to measure the
* new dimensions and update the nodes. * new dimensions and update the nodes.
*/ */
updateNodeInternals: (updates, params = { triggerFitView: true }) => { updateNodeInternals: (updates) => {
const { const { triggerNodeChanges, nodeLookup, parentLookup, domNode, nodeOrigin, nodeExtent, debug } = get();
triggerNodeChanges,
nodeLookup,
parentLookup,
fitViewOnInit,
fitViewDone,
fitViewOnInitOptions,
domNode,
nodeOrigin,
nodeExtent,
debug,
fitViewSync,
} = get();
const { changes, updatedInternals } = updateNodeInternalsSystem( const { changes, updatedInternals } = updateNodeInternalsSystem(
updates, updates,
@@ -118,29 +126,8 @@ const createStore = ({
updateAbsolutePositions(nodeLookup, parentLookup, { nodeOrigin, nodeExtent }); updateAbsolutePositions(nodeLookup, parentLookup, { nodeOrigin, nodeExtent });
if (params.triggerFitView) { // we always want to trigger useStore calls whenever updateNodeInternals is called
// we call fitView once initially after all dimensions are set 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({});
}
if (changes?.length > 0) { if (changes?.length > 0) {
if (debug) { if (debug) {
@@ -332,54 +319,6 @@ const createStore = ({
return panBySystem({ delta, panZoom, transform, translateExtent, width, height }); 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: () => { cancelConnection: () => {
set({ set({
connection: { ...initialConnection }, connection: { ...initialConnection },
+3 -3
View File
@@ -104,13 +104,13 @@ const getInitialState = ({
elementsSelectable: true, elementsSelectable: true,
elevateNodesOnSelect: true, elevateNodesOnSelect: true,
elevateEdgesOnSelect: false, elevateEdgesOnSelect: false,
fitViewOnInit: false,
fitViewDone: false,
fitViewOnInitOptions: undefined,
selectNodesOnDrag: true, selectNodesOnDrag: true,
multiSelectionActive: false, multiSelectionActive: false,
fitViewQueued: false,
fitViewOptions: undefined,
connection: { ...initialConnection }, connection: { ...initialConnection },
connectionClickStartHandle: null, connectionClickStartHandle: null,
connectOnClick: true, connectOnClick: true,
+1 -12
View File
@@ -109,7 +109,7 @@ export type FitViewParams<NodeType extends Node = Node> = FitViewParamsBase<Node
* @public * @public
*/ */
export type FitViewOptions<NodeType extends Node = Node> = FitViewOptionsBase<NodeType>; 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> = ( export type OnInit<NodeType extends Node = Node, EdgeType extends Edge = Edge> = (
reactFlowInstance: ReactFlowInstance<NodeType, EdgeType> reactFlowInstance: ReactFlowInstance<NodeType, EdgeType>
) => void; ) => void;
@@ -156,17 +156,6 @@ export type ViewportHelperFunctions = {
* @returns Viewport * @returns Viewport
*/ */
getViewport: GetViewport; 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. * Sets the center of the view to the given position.
* *
+12 -1
View File
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-namespace */ /* eslint-disable @typescript-eslint/no-namespace */
import type { HandleConnection, HandleType, NodeConnection, Rect, Viewport } from '@xyflow/system'; 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> = { export type ReactFlowJsonObject<NodeType extends Node = Node, EdgeType extends Edge = Edge> = {
nodes: NodeType[]; nodes: NodeType[];
@@ -217,6 +217,17 @@ export type GeneralHelpers<NodeType extends Node = Node, EdgeType extends Edge =
nodeId: string; nodeId: string;
handleId?: string | null; handleId?: string | null;
}) => NodeConnection[]; }) => 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 * The `ReactFlowInstance` provides a collection of methods to query and manipulate
+2 -5
View File
@@ -119,9 +119,8 @@ export type ReactFlowStore<NodeType extends Node = Node, EdgeType extends Edge =
connectOnClick: boolean; connectOnClick: boolean;
defaultEdgeOptions?: DefaultEdgeOptions; defaultEdgeOptions?: DefaultEdgeOptions;
fitViewOnInit: boolean; fitViewQueued: boolean;
fitViewDone: boolean; fitViewOptions: FitViewOptions | undefined;
fitViewOnInitOptions: FitViewOptions | undefined;
onNodesDelete?: OnNodesDelete<NodeType>; onNodesDelete?: OnNodesDelete<NodeType>;
onEdgesDelete?: OnEdgesDelete<EdgeType>; onEdgesDelete?: OnEdgesDelete<EdgeType>;
@@ -168,8 +167,6 @@ export type ReactFlowActions<NodeType extends Node, EdgeType extends Edge> = {
triggerNodeChanges: (changes: NodeChange<NodeType>[]) => void; triggerNodeChanges: (changes: NodeChange<NodeType>[]) => void;
triggerEdgeChanges: (changes: EdgeChange<EdgeType>[]) => void; triggerEdgeChanges: (changes: EdgeChange<EdgeType>[]) => void;
panBy: PanBy; panBy: PanBy;
fitView: (options?: FitViewOptions) => Promise<boolean>;
fitViewSync: (options?: FitViewOptions) => boolean;
setPaneClickDistance: (distance: number) => void; setPaneClickDistance: (distance: number) => void;
}; };
+14 -8
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)); return edges.filter((edge) => nodeIds.has(edge.source) || nodeIds.has(edge.target));
}; };
export function getFitViewNodes< function getFitViewNodes<
Params extends NodeLookup<InternalNodeBase<NodeBase>>, Params extends NodeLookup<InternalNodeBase<NodeBase>>,
Options extends FitViewOptionsBase<NodeBase> Options extends FitViewOptionsBase<NodeBase>
>(nodeLookup: Params, options?: Pick<Options, 'nodes' | 'includeHiddenNodes'>) { >(nodeLookup: Params, options?: Options) {
const fitViewNodes: NodeLookup = new Map(); const fitViewNodes: NodeLookup = new Map();
const optionNodeIds = options?.nodes ? new Set(options.nodes.map((node) => node.id)) : null; const optionNodeIds = options?.nodes ? new Set(options.nodes.map((node) => node.id)) : null;
@@ -351,15 +351,21 @@ export function getFitViewNodes<
return fitViewNodes; 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, { nodes, width, height, panZoom, minZoom, maxZoom }: Params,
options?: Omit<Options, 'nodes' | 'includeHiddenNodes'> options?: Omit<Options, 'nodes' | 'includeHiddenNodes'>
): Promise<boolean> { ): boolean {
if (nodes.size === 0) { 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( const viewport = getViewportForBounds(
bounds, bounds,
@@ -370,9 +376,9 @@ export async function fitView<Params extends FitViewParamsBase<NodeBase>, Option
options?.padding ?? 0.1 options?.padding ?? 0.1
); );
await panZoom.setViewport(viewport, { duration: options?.duration }); panZoom.setViewport(viewport, { duration: options?.duration });
return Promise.resolve(true); return true;
} }
/** /**
+8 -1
View File
@@ -85,9 +85,10 @@ export function adoptUserNodes<NodeType extends NodeBase>(
nodeLookup: NodeLookup<InternalNodeBase<NodeType>>, nodeLookup: NodeLookup<InternalNodeBase<NodeType>>,
parentLookup: ParentLookup<InternalNodeBase<NodeType>>, parentLookup: ParentLookup<InternalNodeBase<NodeType>>,
options?: UpdateNodesOptions<NodeType> options?: UpdateNodesOptions<NodeType>
) { ): boolean {
const _options = mergeObjects(adoptUserNodesDefaultOptions, options); const _options = mergeObjects(adoptUserNodesDefaultOptions, options);
let nodesInitialized = true;
const tmpLookup = new Map(nodeLookup); const tmpLookup = new Map(nodeLookup);
const selectedNodeZ: number = _options?.elevateNodesOnSelect ? 1000 : 0; const selectedNodeZ: number = _options?.elevateNodesOnSelect ? 1000 : 0;
@@ -123,10 +124,16 @@ export function adoptUserNodes<NodeType extends NodeBase>(
nodeLookup.set(userNode.id, internalNode); nodeLookup.set(userNode.id, internalNode);
} }
if (!internalNode.measured || !internalNode.measured.width || !internalNode.measured.height) {
nodesInitialized = false;
}
if (userNode.parentId) { if (userNode.parentId) {
updateChildNode(internalNode, nodeLookup, parentLookup, options); updateChildNode(internalNode, nodeLookup, parentLookup, options);
} }
} }
return nodesInitialized;
} }
function updateParentLookup<NodeType extends NodeBase>( function updateParentLookup<NodeType extends NodeBase>(