diff --git a/examples/react/src/examples/Basic/index.tsx b/examples/react/src/examples/Basic/index.tsx index bb53b309..5754fd62 100644 --- a/examples/react/src/examples/Basic/index.tsx +++ b/examples/react/src/examples/Basic/index.tsx @@ -146,6 +146,9 @@ const BasicFlow = () => { minZoom={0.2} maxZoom={4} fitView + fitViewOptions={{ + padding: { top: '100px', left: '0%', right: '10%', bottom: 0.1 }, + }} defaultEdgeOptions={defaultEdgeOptions} selectNodesOnDrag={false} elevateEdgesOnSelect diff --git a/packages/system/src/types/general.ts b/packages/system/src/types/general.ts index fc92bc7e..2a34e9ff 100644 --- a/packages/system/src/types/general.ts +++ b/packages/system/src/types/general.ts @@ -91,11 +91,25 @@ export type FitViewParamsBase = { maxZoom: number; }; +export type PaddingUnit = 'px' | '%'; +export type PaddingWithUnit = `${number}${PaddingUnit}` | number; + +export type Padding = + | PaddingWithUnit + | { + top?: PaddingWithUnit; + right?: PaddingWithUnit; + bottom?: PaddingWithUnit; + left?: PaddingWithUnit; + x?: PaddingWithUnit; + y?: PaddingWithUnit; + }; + /** * @inline */ export type FitViewOptionsBase = { - padding?: number; + padding?: Padding; includeHiddenNodes?: boolean; minZoom?: number; maxZoom?: number; diff --git a/packages/system/src/utils/general.ts b/packages/system/src/utils/general.ts index 32c339e5..5b4c6995 100644 --- a/packages/system/src/utils/general.ts +++ b/packages/system/src/utils/general.ts @@ -10,6 +10,8 @@ import type { Transform, InternalNodeBase, NodeLookup, + Padding, + PaddingWithUnit, } from '../types'; import { type Viewport } from '../types'; import { getNodePositionWithOrigin, isInternalNodeBase } from './graph'; @@ -173,6 +175,105 @@ export const rendererPointToPoint = ({ x, y }: XYPosition, [tx, ty, tScale]: Tra }; }; +/** + * Parses a single padding value to a number + * @internal + * @param padding - Padding to parse + * @param viewport - Width or height of the viewport + * @returns The padding in pixels + */ +function parsePadding(padding: PaddingWithUnit, viewport: number): number { + if (typeof padding === 'number') { + return Math.floor(viewport - viewport / (1 + padding)); + } + + if (typeof padding === 'string' && padding.endsWith('px')) { + const paddingValue = parseFloat(padding); + if (!Number.isNaN(paddingValue)) { + return Math.floor(paddingValue); + } + } + + if (typeof padding === 'string' && padding.endsWith('%')) { + const paddingValue = parseFloat(padding); + if (!Number.isNaN(paddingValue)) { + return Math.floor(viewport * paddingValue * 0.01); + } + } + + console.error( + `[React Flow] The padding value "${padding}" is invalid. Please provide a number or a string with a valid unit (px or %).` + ); + return 0; +} + +/** + * Parses the paddings to an object with top, right, bottom, left, x and y paddings + * @internal + * @param padding - Padding to parse + * @param width - Width of the viewport + * @param height - Height of the viewport + * @returns An object with the paddings in pixels + */ +function parsePaddings( + padding: Padding, + width: number, + height: number +): { top: number; bottom: number; left: number; right: number; x: number; y: number } { + if (typeof padding === 'string' || typeof padding === 'number') { + const paddingY = parsePadding(padding, height); + const paddingX = parsePadding(padding, width); + return { + top: paddingY, + right: paddingX, + bottom: paddingY, + left: paddingX, + x: paddingX * 2, + y: paddingY * 2, + }; + } + + if (typeof padding === 'object') { + const top = parsePadding(padding.top ?? padding.y ?? 0, height); + const bottom = parsePadding(padding.bottom ?? padding.y ?? 0, height); + const left = parsePadding(padding.left ?? padding.x ?? 0, width); + const right = parsePadding(padding.right ?? padding.x ?? 0, width); + return { top, right, bottom, left, x: left + right, y: top + bottom }; + } + + return { top: 0, right: 0, bottom: 0, left: 0, x: 0, y: 0 }; +} + +/** + * Calculates the resulting paddings if the new viewport is applied + * @internal + * @param bounds - Bounds to fit inside viewport + * @param x - X position of the viewport + * @param y - Y position of the viewport + * @param zoom - Zoom level of the viewport + * @param width - Width of the viewport + * @param height - Height of the viewport + * @returns An object with the minimum padding required to fit the bounds inside the viewport + */ +function calculateAppliedPaddings(bounds: Rect, x: number, y: number, zoom: number, width: number, height: number) { + const { x: left, y: top } = rendererPointToPoint(bounds, [x, y, zoom]); + + const { x: boundRight, y: boundBottom } = rendererPointToPoint( + { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + [x, y, zoom] + ); + + const right = width - boundRight; + const bottom = height - boundBottom; + + return { + left: Math.floor(left), + top: Math.floor(top), + right: Math.floor(right), + bottom: Math.floor(bottom), + }; +} + /** * Returns a viewport that encloses the given bounds with optional padding. * @public @@ -186,8 +287,8 @@ export const rendererPointToPoint = ({ x, y }: XYPosition, [tx, ty, tScale]: Tra * @returns A transforned {@link Viewport} that encloses the given bounds which you can pass to e.g. {@link setViewport} * @example * const { x, y, zoom } = getViewportForBounds( - *{ x: 0, y: 0, width: 100, height: 100}, - *1200, 800, 0.5, 2); + * { x: 0, y: 0, width: 100, height: 100}, + * 1200, 800, 0.5, 2); */ export const getViewportForBounds = ( bounds: Rect, @@ -195,18 +296,39 @@ export const getViewportForBounds = ( height: number, minZoom: number, maxZoom: number, - padding: number + padding: Padding ): Viewport => { - const xZoom = width / (bounds.width * (1 + padding)); - const yZoom = height / (bounds.height * (1 + padding)); + // First we resolve all the paddings to actual pixel values + const p = parsePaddings(padding, width, height); + + const xZoom = (width - p.x) / bounds.width; + const yZoom = (height - p.y) / bounds.height; + + // We calculate the new x, y, zoom for a centered view const zoom = Math.min(xZoom, yZoom); const clampedZoom = clamp(zoom, minZoom, maxZoom); + const boundsCenterX = bounds.x + bounds.width / 2; const boundsCenterY = bounds.y + bounds.height / 2; const x = width / 2 - boundsCenterX * clampedZoom; const y = height / 2 - boundsCenterY * clampedZoom; - return { x, y, zoom: clampedZoom }; + // Then we calculate the minimum padding, to respect asymmetric paddings + const newPadding = calculateAppliedPaddings(bounds, x, y, clampedZoom, width, height); + + // We only want to have an offset if the newPadding is smaller than the required padding + const offset = { + left: Math.min(newPadding.left - p.left, 0), + top: Math.min(newPadding.top - p.top, 0), + right: Math.min(newPadding.right - p.right, 0), + bottom: Math.min(newPadding.bottom - p.bottom, 0), + }; + + return { + x: x - offset.left + offset.right, + y: y - offset.top + offset.bottom, + zoom: clampedZoom, + }; }; export const isMacOs = () => typeof navigator !== 'undefined' && navigator?.userAgent?.indexOf('Mac') >= 0;