diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 420bf30af..84f105e5e 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @vue-flow/core +## 1.47.0 + +### Minor Changes + +- [#1967](https://github.com/bcakmakoglu/vue-flow/pull/1967) [`7828f4a`](https://github.com/bcakmakoglu/vue-flow/commit/7828f4a40b226d6032472be635dc28b0be91c487) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - Replace the existing `offset` option for fitView with a more expressive `padding` option allowing users to define padding per sides. + ## 1.46.5 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 24b583139..f48c3289b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@vue-flow/core", - "version": "1.46.5", + "version": "1.47.0", "private": false, "license": "MIT", "author": "Burak Cakmakoglu<78412429+bcakmakoglu@users.noreply.github.com>", diff --git a/packages/core/src/composables/useViewportHelper.ts b/packages/core/src/composables/useViewportHelper.ts index 417e95d4b..85b6eb756 100644 --- a/packages/core/src/composables/useViewportHelper.ts +++ b/packages/core/src/composables/useViewportHelper.ts @@ -160,7 +160,6 @@ export function useViewportHelper(state: State) { options.minZoom ?? state.minZoom, options.maxZoom ?? state.maxZoom, options.padding ?? DEFAULT_PADDING, - options.offset, ) return transformViewport(x, y, zoom, options) @@ -179,7 +178,7 @@ export function useViewportHelper(state: State) { state.dimensions.height, state.minZoom, state.maxZoom, - options.padding, + options.padding ?? DEFAULT_PADDING, ) return transformViewport(x, y, zoom, options) diff --git a/packages/core/src/types/zoom.ts b/packages/core/src/types/zoom.ts index 028dfa02c..aefa4a6aa 100644 --- a/packages/core/src/types/zoom.ts +++ b/packages/core/src/types/zoom.ts @@ -22,15 +22,25 @@ export interface TransitionOptions { interpolate?: 'smooth' | 'linear' } +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 + } + export type FitViewParams = { - padding?: number + padding?: Padding includeHiddenNodes?: boolean minZoom?: number maxZoom?: number - offset?: { - x?: number - y?: number - } nodes?: string[] } & TransitionOptions @@ -45,7 +55,7 @@ export type SetCenterOptions = TransitionOptions & { } export type FitBoundsOptions = TransitionOptions & { - padding?: number + padding?: Padding } /** Fit the viewport around visible nodes */ diff --git a/packages/core/src/utils/graph.ts b/packages/core/src/utils/graph.ts index fd8764c42..78ef9bf09 100644 --- a/packages/core/src/utils/graph.ts +++ b/packages/core/src/utils/graph.ts @@ -16,6 +16,8 @@ import type { MaybeElement, Node, NodeLookup, + Padding, + PaddingWithUnit, Rect, ViewportTransform, XYPosition, @@ -439,28 +441,163 @@ export function getConnectedNodes(node return nodes.filter((node) => connectedNodeIds.has(typeof node === 'string' ? node : node.id)) } +/** + * 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)) * 0.5) + } + + if (typeof padding === 'string' && padding.endsWith('px')) { + const paddingValue = Number.parseFloat(padding) + if (!Number.isNaN(paddingValue)) { + return Math.floor(paddingValue) + } + } + + if (typeof padding === 'string' && padding.endsWith('%')) { + const paddingValue = Number.parseFloat(padding) + if (!Number.isNaN(paddingValue)) { + return Math.floor(viewport * paddingValue * 0.01) + } + } + + warn(`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 padding. + * @public + * @remarks You can determine bounds of nodes with {@link getNodesBounds} and {@link getBoundsOfRects} + * @param bounds - Bounds to fit inside viewport. + * @param width - Width of the viewport. + * @param height - Height of the viewport. + * @param minZoom - Minimum zoom level of the resulting viewport. + * @param maxZoom - Maximum zoom level of the resulting viewport. + * @param padding - Padding around the bounds. + * @returns A transformed {@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); + */ export function getTransformForBounds( bounds: Rect, width: number, height: number, minZoom: number, maxZoom: number, - padding = 0.1, - offset: { - x?: number - y?: number - } = { x: 0, y: 0 }, + padding: Padding = 0.1, ): ViewportTransform { - 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 + (offset.x ?? 0) - const y = height / 2 - boundsCenterY * clampedZoom + (offset.y ?? 0) + const x = width / 2 - boundsCenterX * clampedZoom + const y = height / 2 - boundsCenterY * 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, y, zoom: clampedZoom } + return { + x: x - offset.left + offset.right, + y: y - offset.top + offset.bottom, + zoom: clampedZoom, + } } export function getXYZPos(parentPos: XYZPosition, computedPosition: XYZPosition): XYZPosition {