refactor(packages): change package structure

This commit is contained in:
moklick
2023-06-05 15:40:18 +02:00
parent b2e296901e
commit 8354759f57
332 changed files with 980 additions and 3244 deletions
+2
View File
@@ -18,3 +18,5 @@ export const errorMessages = {
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".`,
};
export const internalsSymbol = Symbol.for('internals');
+5 -3
View File
@@ -1,4 +1,6 @@
export * from './constants';
export * from './types';
export { errorMessages } from './constants';
export const internalsSymbol = Symbol.for('internals');
export * from './utils';
export * from './xydrag';
export * from './xyhandle';
export * from './xypanzoom';
+1 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { internalsSymbol } from '../';
import { internalsSymbol } from '../constants';
import type { XYPosition, Position, CoordinateExtent, HandleElement } from '.';
// this is stuff that all nodes share independent of the framework
+2
View File
@@ -1,4 +1,5 @@
import { ZoomTransform } from 'd3-zoom';
import { CoordinateExtent, PanOnScrollMode, Transform, Viewport } from './';
export type OnDraggingChange = (dragging: boolean) => void;
@@ -37,6 +38,7 @@ export type PanZoomUpdateOptions = {
zoomOnScroll: boolean;
zoomOnDoubleClick: boolean;
zoomActivationKeyPressed: boolean;
lib: string;
};
export type PanZoomInstance = {
@@ -0,0 +1,115 @@
import { Position } from '../../types';
export type GetBezierPathParams = {
sourceX: number;
sourceY: number;
sourcePosition?: Position;
targetX: number;
targetY: number;
targetPosition?: Position;
curvature?: number;
};
export type GetControlWithCurvatureParams = {
pos: Position;
x1: number;
y1: number;
x2: number;
y2: number;
c: number;
};
export function getBezierEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
sourceControlX,
sourceControlY,
targetControlX,
targetControlY,
}: {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
sourceControlX: number;
sourceControlY: number;
targetControlX: number;
targetControlY: number;
}): [number, number, number, number] {
// cubic bezier t=0.5 mid point, not the actual mid point, but easy to calculate
// https://stackoverflow.com/questions/67516101/how-to-find-distance-mid-point-of-bezier-curve
const centerX = sourceX * 0.125 + sourceControlX * 0.375 + targetControlX * 0.375 + targetX * 0.125;
const centerY = sourceY * 0.125 + sourceControlY * 0.375 + targetControlY * 0.375 + targetY * 0.125;
const offsetX = Math.abs(centerX - sourceX);
const offsetY = Math.abs(centerY - sourceY);
return [centerX, centerY, offsetX, offsetY];
}
function calculateControlOffset(distance: number, curvature: number): number {
if (distance >= 0) {
return 0.5 * distance;
}
return curvature * 25 * Math.sqrt(-distance);
}
function getControlWithCurvature({ pos, x1, y1, x2, y2, c }: GetControlWithCurvatureParams): [number, number] {
switch (pos) {
case Position.Left:
return [x1 - calculateControlOffset(x1 - x2, c), y1];
case Position.Right:
return [x1 + calculateControlOffset(x2 - x1, c), y1];
case Position.Top:
return [x1, y1 - calculateControlOffset(y1 - y2, c)];
case Position.Bottom:
return [x1, y1 + calculateControlOffset(y2 - y1, c)];
}
}
export function getBezierPath({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
curvature = 0.25,
}: GetBezierPathParams): [path: string, labelX: number, labelY: number, offsetX: number, offsetY: number] {
const [sourceControlX, sourceControlY] = getControlWithCurvature({
pos: sourcePosition,
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
c: curvature,
});
const [targetControlX, targetControlY] = getControlWithCurvature({
pos: targetPosition,
x1: targetX,
y1: targetY,
x2: sourceX,
y2: sourceY,
c: curvature,
});
const [labelX, labelY, offsetX, offsetY] = getBezierEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
sourceControlX,
sourceControlY,
targetControlX,
targetControlY,
});
return [
`M${sourceX},${sourceY} C${sourceControlX},${sourceControlY} ${targetControlX},${targetControlY} ${targetX},${targetY}`,
labelX,
labelY,
offsetX,
offsetY,
];
}
@@ -0,0 +1,74 @@
import { Position, type HandleElement, type MarkerType, type Rect, type XYPosition } from '../../types';
// this is used for straight edges and simple smoothstep edges (LTR, RTL, BTT, TTB)
export function getEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
}: {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
}): [number, number, number, number] {
const xOffset = Math.abs(targetX - sourceX) / 2;
const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset;
const yOffset = Math.abs(targetY - sourceY) / 2;
const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
return [centerX, centerY, xOffset, yOffset];
}
export const getMarkerEnd = (markerType?: MarkerType, markerEndId?: string): string => {
if (typeof markerEndId !== 'undefined' && markerEndId) {
return `url(#${markerEndId})`;
}
return typeof markerType !== 'undefined' ? `url(#react-flow__${markerType})` : 'none';
};
export function getHandlePosition(position: Position, nodeRect: Rect, handle: HandleElement | null = null): XYPosition {
const x = (handle?.x || 0) + nodeRect.x;
const y = (handle?.y || 0) + nodeRect.y;
const width = handle?.width || nodeRect.width;
const height = handle?.height || nodeRect.height;
switch (position) {
case Position.Top:
return {
x: x + width / 2,
y,
};
case Position.Right:
return {
x: x + width,
y: y + height / 2,
};
case Position.Bottom:
return {
x: x + width / 2,
y: y + height,
};
case Position.Left:
return {
x,
y: y + height / 2,
};
}
}
export function getHandle(bounds: HandleElement[], handleId?: string | null): HandleElement | null {
if (!bounds) {
return null;
}
if (bounds.length === 1 || !handleId) {
return bounds[0];
} else if (handleId) {
return bounds.find((d) => d.id === handleId) || null;
}
return null;
}
+4
View File
@@ -0,0 +1,4 @@
export * from './bezier-edge';
export * from './straight-edge';
export * from './smoothstep-edge';
export * from './general';
@@ -0,0 +1,195 @@
import { getEdgeCenter } from './general';
import { Position, type XYPosition } from '../../types';
export interface GetSmoothStepPathParams {
sourceX: number;
sourceY: number;
sourcePosition?: Position;
targetX: number;
targetY: number;
targetPosition?: Position;
borderRadius?: number;
centerX?: number;
centerY?: number;
offset?: number;
}
const handleDirections = {
[Position.Left]: { x: -1, y: 0 },
[Position.Right]: { x: 1, y: 0 },
[Position.Top]: { x: 0, y: -1 },
[Position.Bottom]: { x: 0, y: 1 },
};
const getDirection = ({
source,
sourcePosition = Position.Bottom,
target,
}: {
source: XYPosition;
sourcePosition: Position;
target: XYPosition;
}): XYPosition => {
if (sourcePosition === Position.Left || sourcePosition === Position.Right) {
return source.x < target.x ? { x: 1, y: 0 } : { x: -1, y: 0 };
}
return source.y < target.y ? { x: 0, y: 1 } : { x: 0, y: -1 };
};
const distance = (a: XYPosition, b: XYPosition) => Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2));
// ith this function we try to mimic a orthogonal edge routing behaviour
// It's not as good as a real orthogonal edge routing but it's faster and good enough as a default for step and smooth step edges
function getPoints({
source,
sourcePosition = Position.Bottom,
target,
targetPosition = Position.Top,
center,
offset,
}: {
source: XYPosition;
sourcePosition: Position;
target: XYPosition;
targetPosition: Position;
center: Partial<XYPosition>;
offset: number;
}): [XYPosition[], number, number, number, number] {
const sourceDir = handleDirections[sourcePosition];
const targetDir = handleDirections[targetPosition];
const sourceGapped: XYPosition = { x: source.x + sourceDir.x * offset, y: source.y + sourceDir.y * offset };
const targetGapped: XYPosition = { x: target.x + targetDir.x * offset, y: target.y + targetDir.y * offset };
const dir = getDirection({
source: sourceGapped,
sourcePosition,
target: targetGapped,
});
const dirAccessor = dir.x !== 0 ? 'x' : 'y';
const currDir = dir[dirAccessor];
let points: XYPosition[] = [];
let centerX, centerY;
const [defaultCenterX, defaultCenterY, defaultOffsetX, defaultOffsetY] = getEdgeCenter({
sourceX: source.x,
sourceY: source.y,
targetX: target.x,
targetY: target.y,
});
// opposite handle positions, default case
if (sourceDir[dirAccessor] * targetDir[dirAccessor] === -1) {
centerX = center.x || defaultCenterX;
centerY = center.y || defaultCenterY;
// --->
// |
// >---
const verticalSplit: XYPosition[] = [
{ x: centerX, y: sourceGapped.y },
{ x: centerX, y: targetGapped.y },
];
// |
// ---
// |
const horizontalSplit: XYPosition[] = [
{ x: sourceGapped.x, y: centerY },
{ x: targetGapped.x, y: centerY },
];
if (sourceDir[dirAccessor] === currDir) {
points = dirAccessor === 'x' ? verticalSplit : horizontalSplit;
} else {
points = dirAccessor === 'x' ? horizontalSplit : verticalSplit;
}
} else {
// sourceTarget means we take x from source and y from target, targetSource is the opposite
const sourceTarget: XYPosition[] = [{ x: sourceGapped.x, y: targetGapped.y }];
const targetSource: XYPosition[] = [{ x: targetGapped.x, y: sourceGapped.y }];
// this handles edges with same handle positions
if (dirAccessor === 'x') {
points = sourceDir.x === currDir ? targetSource : sourceTarget;
} else {
points = sourceDir.y === currDir ? sourceTarget : targetSource;
}
// these are conditions for handling mixed handle positions like Right -> Bottom for example
if (sourcePosition !== targetPosition) {
const dirAccessorOpposite = dirAccessor === 'x' ? 'y' : 'x';
const isSameDir = sourceDir[dirAccessor] === targetDir[dirAccessorOpposite];
const sourceGtTargetOppo = sourceGapped[dirAccessorOpposite] > targetGapped[dirAccessorOpposite];
const sourceLtTargetOppo = sourceGapped[dirAccessorOpposite] < targetGapped[dirAccessorOpposite];
const flipSourceTarget =
(sourceDir[dirAccessor] === 1 && ((!isSameDir && sourceGtTargetOppo) || (isSameDir && sourceLtTargetOppo))) ||
(sourceDir[dirAccessor] !== 1 && ((!isSameDir && sourceLtTargetOppo) || (isSameDir && sourceGtTargetOppo)));
if (flipSourceTarget) {
points = dirAccessor === 'x' ? sourceTarget : targetSource;
}
}
centerX = points[0].x;
centerY = points[0].y;
}
const pathPoints = [source, sourceGapped, ...points, targetGapped, target];
return [pathPoints, centerX, centerY, defaultOffsetX, defaultOffsetY];
}
function getBend(a: XYPosition, b: XYPosition, c: XYPosition, size: number): string {
const bendSize = Math.min(distance(a, b) / 2, distance(b, c) / 2, size);
const { x, y } = b;
// no bend
if ((a.x === x && x === c.x) || (a.y === y && y === c.y)) {
return `L${x} ${y}`;
}
// first segment is horizontal
if (a.y === y) {
const xDir = a.x < c.x ? -1 : 1;
const yDir = a.y < c.y ? 1 : -1;
return `L ${x + bendSize * xDir},${y}Q ${x},${y} ${x},${y + bendSize * yDir}`;
}
const xDir = a.x < c.x ? 1 : -1;
const yDir = a.y < c.y ? -1 : 1;
return `L ${x},${y + bendSize * yDir}Q ${x},${y} ${x + bendSize * xDir},${y}`;
}
export function getSmoothStepPath({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
borderRadius = 5,
centerX,
centerY,
offset = 20,
}: GetSmoothStepPathParams): [path: string, labelX: number, labelY: number, offsetX: number, offsetY: number] {
const [points, labelX, labelY, offsetX, offsetY] = getPoints({
source: { x: sourceX, y: sourceY },
sourcePosition,
target: { x: targetX, y: targetY },
targetPosition,
center: { x: centerX, y: centerY },
offset,
});
const path = points.reduce<string>((res, p, i) => {
let segment = '';
if (i > 0 && i < points.length - 1) {
segment = getBend(points[i - 1], p, points[i + 1], borderRadius);
} else {
segment = `${i === 0 ? 'M' : 'L'}${p.x} ${p.y}`;
}
res += segment;
return res;
}, '');
return [path, labelX, labelY, offsetX, offsetY];
}
@@ -0,0 +1,24 @@
import { getEdgeCenter } from './general';
export type GetStraightPathParams = {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
};
export function getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
}: GetStraightPathParams): [path: string, labelX: number, labelY: number, offsetX: number, offsetY: number] {
const [labelX, labelY, offsetX, offsetY] = getEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
});
return [`M ${sourceX},${sourceY}L ${targetX},${targetY}`, labelX, labelY, offsetX, offsetY];
}
+427
View File
@@ -0,0 +1,427 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
boxToRect,
clamp,
clampPosition,
devWarn,
getBoundsOfBoxes,
getOverlappingArea,
isNumeric,
rectToBox,
} from './utils';
import {
type Connection,
type Transform,
type XYPosition,
type Rect,
type NodeOrigin,
type BaseNode,
type BaseEdge,
type FitViewParamsBase,
type FitViewOptionsBase,
SnapGrid,
NodeDragItem,
CoordinateExtent,
OnError,
} from '../types';
import { errorMessages } from '../constants';
export const isEdgeBase = <NodeType extends BaseNode = BaseNode, EdgeType extends BaseEdge = BaseEdge>(
element: NodeType | Connection | EdgeType
): element is EdgeType => 'id' in element && 'source' in element && 'target' in element;
export const isNodeBase = <NodeType extends BaseNode = BaseNode, EdgeType extends BaseEdge = BaseEdge>(
element: NodeType | Connection | EdgeType
): element is NodeType => 'id' in element && !('source' in element) && !('target' in element);
export const getOutgoersBase = <NodeType extends BaseNode = BaseNode, EdgeType extends BaseEdge = BaseEdge>(
node: NodeType,
nodes: NodeType[],
edges: EdgeType[]
): NodeType[] => {
if (!isNodeBase(node)) {
return [];
}
const outgoerIds = edges.filter((e) => e.source === node.id).map((e) => e.target);
return nodes.filter((n) => outgoerIds.includes(n.id));
};
export const getIncomersBase = <NodeType extends BaseNode = BaseNode, EdgeType extends BaseEdge = BaseEdge>(
node: NodeType,
nodes: NodeType[],
edges: EdgeType[]
): NodeType[] => {
if (!isNodeBase(node)) {
return [];
}
const incomersIds = edges.filter((e) => e.target === node.id).map((e) => e.source);
return nodes.filter((n) => incomersIds.includes(n.id));
};
const getEdgeId = ({ source, sourceHandle, target, targetHandle }: Connection | BaseEdge): string =>
`reactflow__edge-${source}${sourceHandle || ''}-${target}${targetHandle || ''}`;
const connectionExists = (edge: BaseEdge, edges: BaseEdge[]) => {
return edges.some(
(el) =>
el.source === edge.source &&
el.target === edge.target &&
(el.sourceHandle === edge.sourceHandle || (!el.sourceHandle && !edge.sourceHandle)) &&
(el.targetHandle === edge.targetHandle || (!el.targetHandle && !edge.targetHandle))
);
};
export const addEdgeBase = <EdgeType extends BaseEdge>(
edgeParams: EdgeType | Connection,
edges: EdgeType[]
): EdgeType[] => {
if (!edgeParams.source || !edgeParams.target) {
devWarn('006', errorMessages['error006']());
return edges;
}
let edge: EdgeType;
if (isEdgeBase(edgeParams)) {
edge = { ...edgeParams };
} else {
edge = {
...edgeParams,
id: getEdgeId(edgeParams),
} as EdgeType;
}
if (connectionExists(edge, edges)) {
return edges;
}
return edges.concat(edge);
};
export type UpdateEdgeOptions = {
shouldReplaceId?: boolean;
};
export const updateEdgeBase = <EdgeType extends BaseEdge>(
oldEdge: EdgeType,
newConnection: Connection,
edges: EdgeType[],
options: UpdateEdgeOptions = { shouldReplaceId: true }
): EdgeType[] => {
const { id: oldEdgeId, ...rest } = oldEdge;
if (!newConnection.source || !newConnection.target) {
devWarn('006', errorMessages['error006']());
return edges;
}
const foundEdge = edges.find((e) => e.id === oldEdge.id) as EdgeType;
if (!foundEdge) {
devWarn('007', errorMessages['error007'](oldEdgeId));
return edges;
}
// Remove old edge and create the new edge with parameters of old edge.
const edge = {
...rest,
id: options.shouldReplaceId ? getEdgeId(newConnection) : oldEdgeId,
source: newConnection.source,
target: newConnection.target,
sourceHandle: newConnection.sourceHandle,
targetHandle: newConnection.targetHandle,
} as EdgeType;
return edges.filter((e) => e.id !== oldEdgeId).concat(edge);
};
export const pointToRendererPoint = (
{ x, y }: XYPosition,
[tx, ty, tScale]: Transform,
snapToGrid: boolean,
[snapX, snapY]: [number, number]
): XYPosition => {
const position: XYPosition = {
x: (x - tx) / tScale,
y: (y - ty) / tScale,
};
if (snapToGrid) {
return {
x: snapX * Math.round(position.x / snapX),
y: snapY * Math.round(position.y / snapY),
};
}
return position;
};
export const rendererPointToPoint = ({ x, y }: XYPosition, [tx, ty, tScale]: Transform): XYPosition => {
return {
x: x * tScale + tx,
y: y * tScale + ty,
};
};
export const getNodePositionWithOrigin = (
node: BaseNode | undefined,
nodeOrigin: NodeOrigin = [0, 0]
): XYPosition & { positionAbsolute: XYPosition } => {
if (!node) {
return {
x: 0,
y: 0,
positionAbsolute: {
x: 0,
y: 0,
},
};
}
const offsetX = (node.width ?? 0) * nodeOrigin[0];
const offsetY = (node.height ?? 0) * nodeOrigin[1];
const position: XYPosition = {
x: node.position.x - offsetX,
y: node.position.y - offsetY,
};
return {
...position,
positionAbsolute: node.positionAbsolute
? {
x: node.positionAbsolute.x - offsetX,
y: node.positionAbsolute.y - offsetY,
}
: position,
};
};
export const getRectOfNodes = (nodes: BaseNode[], nodeOrigin: NodeOrigin = [0, 0]): Rect => {
if (nodes.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
const box = nodes.reduce(
(currBox, node) => {
const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute;
return getBoundsOfBoxes(
currBox,
rectToBox({
x,
y,
width: node.width || 0,
height: node.height || 0,
})
);
},
{ x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity }
);
return boxToRect(box);
};
export const getNodesInside = <NodeType extends BaseNode>(
nodes: NodeType[],
rect: Rect,
[tx, ty, tScale]: Transform = [0, 0, 1],
partially = false,
// set excludeNonSelectableNodes if you want to pay attention to the nodes "selectable" attribute
excludeNonSelectableNodes = false,
nodeOrigin: NodeOrigin = [0, 0]
): NodeType[] => {
const paneRect = {
x: (rect.x - tx) / tScale,
y: (rect.y - ty) / tScale,
width: rect.width / tScale,
height: rect.height / tScale,
};
const visibleNodes = nodes.reduce<NodeType[]>((res, node) => {
const { width, height, selectable = true, hidden = false } = node;
if ((excludeNonSelectableNodes && !selectable) || hidden) {
return res;
}
const { positionAbsolute } = getNodePositionWithOrigin(node, node.origin || nodeOrigin);
const nodeRect = {
x: positionAbsolute.x,
y: positionAbsolute.y,
width: width || 0,
height: height || 0,
};
const overlappingArea = getOverlappingArea(paneRect, nodeRect);
const notInitialized =
typeof width === 'undefined' || typeof height === 'undefined' || width === null || height === null;
const partiallyVisible = partially && overlappingArea > 0;
const area = (width || 0) * (height || 0);
const isVisible = notInitialized || partiallyVisible || overlappingArea >= area;
if (isVisible || node.dragging) {
res.push(node);
}
return res;
}, []);
return visibleNodes;
};
export const getConnectedEdgesBase = <NodeType extends BaseNode = BaseNode, EdgeType extends BaseEdge = BaseEdge>(
nodes: NodeType[],
edges: EdgeType[]
): EdgeType[] => {
const nodeIds = nodes.map((node) => node.id);
return edges.filter((edge) => nodeIds.includes(edge.source) || nodeIds.includes(edge.target));
};
export const getTransformForBounds = (
bounds: Rect,
width: number,
height: number,
minZoom: number,
maxZoom: number,
padding = 0.1
): Transform => {
const xZoom = width / (bounds.width * (1 + padding));
const yZoom = height / (bounds.height * (1 + padding));
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, clampedZoom];
};
export function fitView<Params extends FitViewParamsBase<BaseNode>, Options extends FitViewOptionsBase<BaseNode>>(
{ nodes, width, height, panZoom, minZoom, maxZoom, nodeOrigin = [0, 0] }: Params,
options?: Options
) {
const filteredNodes = nodes.filter((n) => {
const isVisible = options?.includeHiddenNodes ? n.width && n.height : !n.hidden;
if (options?.nodes?.length) {
return isVisible && options?.nodes.some((optionNode) => optionNode.id === n.id);
}
return isVisible;
});
const nodesInitialized = filteredNodes.every((n) => n.width && n.height);
if (nodes.length > 0 && nodesInitialized) {
const bounds = getRectOfNodes(nodes, nodeOrigin);
const [x, y, zoom] = getTransformForBounds(
bounds,
width,
height,
options?.minZoom ?? minZoom,
options?.maxZoom ?? maxZoom,
options?.padding ?? 0.1
);
panZoom.setViewport({ x, y, zoom }, { duration: options?.duration });
return true;
}
return false;
}
export type GetPointerPositionParams = {
transform: Transform;
snapGrid?: SnapGrid;
snapToGrid?: boolean;
};
export function getPointerPosition(
event: MouseEvent | TouchEvent,
{ snapGrid = [0, 0], snapToGrid = false, transform }: GetPointerPositionParams
): XYPosition & { xSnapped: number; ySnapped: number } {
const x = 'touches' in event ? event.touches[0].clientX : event.clientX;
const y = 'touches' in event ? event.touches[0].clientY : event.clientY;
const pointerPos = {
x: (x - transform[0]) / transform[2],
y: (y - transform[1]) / transform[2],
};
// we need the snapped position in order to be able to skip unnecessary drag events
return {
xSnapped: snapToGrid ? snapGrid[0] * Math.round(pointerPos.x / snapGrid[0]) : pointerPos.x,
ySnapped: snapToGrid ? snapGrid[1] * Math.round(pointerPos.y / snapGrid[1]) : pointerPos.y,
...pointerPos,
};
}
export function calcNextPosition<NodeType extends BaseNode>(
node: NodeDragItem | NodeType,
nextPosition: XYPosition,
nodes: NodeType[],
nodeExtent?: CoordinateExtent,
nodeOrigin: NodeOrigin = [0, 0],
onError?: OnError
): { position: XYPosition; positionAbsolute: XYPosition } {
let currentExtent = node.extent || nodeExtent;
if (node.extent === 'parent') {
if (node.parentNode && node.width && node.height) {
const parent = nodes.find((n) => n.id === node.parentNode);
const parentOrigin = parent?.origin || nodeOrigin;
const currNodeOrigin = node.origin || nodeOrigin;
const { x: parentX, y: parentY } = getNodePositionWithOrigin(parent, parentOrigin).positionAbsolute;
currentExtent =
parent && isNumeric(parentX) && isNumeric(parentY) && isNumeric(parent.width) && isNumeric(parent.height)
? [
[parentX + node.width * currNodeOrigin[0], parentY + node.height * currNodeOrigin[1]],
[
parentX + parent.width - node.width + node.width * currNodeOrigin[0],
parentY + parent.height - node.height + node.height * currNodeOrigin[1],
],
]
: currentExtent;
} else {
onError?.('005', errorMessages['error005']());
currentExtent = nodeExtent;
}
} else if (node.extent && node.parentNode) {
const parent = nodes.find((n) => n.id === node.parentNode);
const { x: parentX, y: parentY } = getNodePositionWithOrigin(parent, parent?.origin || nodeOrigin).positionAbsolute;
currentExtent = [
[node.extent[0][0] + parentX, node.extent[0][1] + parentY],
[node.extent[1][0] + parentX, node.extent[1][1] + parentY],
];
}
let parentPosition = { x: 0, y: 0 };
if (node.parentNode) {
const parentNode = nodes.find((n) => n.id === node.parentNode);
parentPosition = getNodePositionWithOrigin(parentNode, parentNode?.origin || nodeOrigin).positionAbsolute;
}
const positionAbsolute = currentExtent
? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
return {
position: {
x: positionAbsolute.x - parentPosition.x,
y: positionAbsolute.y - parentPosition.y,
},
positionAbsolute,
};
}
+4
View File
@@ -0,0 +1,4 @@
export * from './graph';
export * from './utils';
export * from './marker';
export * from './edges';
+40
View File
@@ -0,0 +1,40 @@
import type { BaseEdge, EdgeMarker, EdgeMarkerType, MarkerProps } from '../types';
export function getMarkerId(marker: EdgeMarkerType | undefined, id?: string | null): string {
if (!marker) {
return '';
}
if (typeof marker === 'string') {
return marker;
}
const idPrefix = id ? `${id}__` : '';
return `${idPrefix}${Object.keys(marker)
.sort()
.map((key) => `${key}=${marker[key as keyof EdgeMarker]}`)
.join('&')}`;
}
export function createMarkerIds(
edges: BaseEdge[],
{ id, defaultColor }: { id?: string | null; defaultColor?: string }
) {
const ids: string[] = [];
return edges
.reduce<MarkerProps[]>((markers, edge) => {
[edge.markerStart, edge.markerEnd].forEach((marker) => {
if (marker && typeof marker === 'object') {
const markerId = getMarkerId(marker, id);
if (!ids.includes(markerId)) {
markers.push({ id: markerId, color: marker.color || defaultColor, ...marker });
ids.push(markerId);
}
}
});
return markers;
}, [])
.sort((a, b) => a.id.localeCompare(b.id));
}
+229
View File
@@ -0,0 +1,229 @@
import type {
Dimensions,
XYPosition,
CoordinateExtent,
Box,
Rect,
BaseNode,
BaseEdge,
NodeOrigin,
HandleElement,
Position,
} from '../types';
import { getConnectedEdgesBase } from './graph';
export const getDimensions = (node: HTMLDivElement): Dimensions => ({
width: node.offsetWidth,
height: node.offsetHeight,
});
export const clamp = (val: number, min = 0, max = 1): number => Math.min(Math.max(val, min), max);
export const clampPosition = (position: XYPosition = { x: 0, y: 0 }, extent: CoordinateExtent) => ({
x: clamp(position.x, extent[0][0], extent[1][0]),
y: clamp(position.y, extent[0][1], extent[1][1]),
});
// returns a number between 0 and 1 that represents the velocity of the movement
// when the mouse is close to the edge of the canvas
const calcAutoPanVelocity = (value: number, min: number, max: number): number => {
if (value < min) {
return clamp(Math.abs(value - min), 1, 50) / 50;
} else if (value > max) {
return -clamp(Math.abs(value - max), 1, 50) / 50;
}
return 0;
};
export const calcAutoPan = (pos: XYPosition, bounds: Dimensions): number[] => {
const xMovement = calcAutoPanVelocity(pos.x, 35, bounds.width - 35) * 20;
const yMovement = calcAutoPanVelocity(pos.y, 35, bounds.height - 35) * 20;
return [xMovement, yMovement];
};
export const getHostForElement = (element: HTMLElement): Document | ShadowRoot =>
(element.getRootNode?.() as Document | ShadowRoot) || window?.document;
export const getBoundsOfBoxes = (box1: Box, box2: Box): Box => ({
x: Math.min(box1.x, box2.x),
y: Math.min(box1.y, box2.y),
x2: Math.max(box1.x2, box2.x2),
y2: Math.max(box1.y2, box2.y2),
});
export const rectToBox = ({ x, y, width, height }: Rect): Box => ({
x,
y,
x2: x + width,
y2: y + height,
});
export const boxToRect = ({ x, y, x2, y2 }: Box): Rect => ({
x,
y,
width: x2 - x,
height: y2 - y,
});
export const nodeToRect = (node: BaseNode): Rect => ({
...(node.positionAbsolute || { x: 0, y: 0 }),
width: node.width || 0,
height: node.height || 0,
});
export const getBoundsOfRects = (rect1: Rect, rect2: Rect): Rect =>
boxToRect(getBoundsOfBoxes(rectToBox(rect1), rectToBox(rect2)));
export const getOverlappingArea = (rectA: Rect, rectB: Rect): number => {
const xOverlap = Math.max(0, Math.min(rectA.x + rectA.width, rectB.x + rectB.width) - Math.max(rectA.x, rectB.x));
const yOverlap = Math.max(0, Math.min(rectA.y + rectA.height, rectB.y + rectB.height) - Math.max(rectA.y, rectB.y));
return Math.ceil(xOverlap * yOverlap);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isRectObject = (obj: any): obj is Rect =>
isNumeric(obj.width) && isNumeric(obj.height) && isNumeric(obj.x) && isNumeric(obj.y);
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const isNumeric = (n: any): n is number => !isNaN(n) && isFinite(n);
// used for a11y key board controls for nodes and edges
export const elementSelectionKeys = ['Enter', ' ', 'Escape'];
export const devWarn = (id: string, message: string) => {
if (process.env.NODE_ENV === 'development') {
console.warn(`[React Flow]: ${message} Help: https://reactflow.dev/error#${id}`);
}
};
export function isInputDOMNode(event: KeyboardEvent): boolean {
// using composed path for handling shadow dom
const target = (event.composedPath?.()?.[0] || event.target) as HTMLElement;
const isInput = ['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.nodeName) || target?.hasAttribute('contenteditable');
// we want to be able to do a multi selection event if we are in an input field
const isModifierKey = event.ctrlKey || event.metaKey || event.shiftKey;
// when an input field is focused we don't want to trigger deletion or movement of nodes
return (isInput && !isModifierKey) || !!target?.closest('.nokey');
}
export const isMouseEvent = (event: MouseEvent | TouchEvent): event is MouseEvent => 'clientX' in event;
export const getEventPosition = (event: MouseEvent | TouchEvent, bounds?: DOMRect) => {
const isMouseTriggered = isMouseEvent(event);
const evtX = isMouseTriggered ? event.clientX : event.touches?.[0].clientX;
const evtY = isMouseTriggered ? event.clientY : event.touches?.[0].clientY;
return {
x: evtX - (bounds?.left ?? 0),
y: evtY - (bounds?.top ?? 0),
};
};
export const infiniteExtent: CoordinateExtent = [
[Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
[Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY],
];
// helper function to get arrays of nodes and edges that can be deleted
// you can pass in a list of nodes and edges that should be deleted
// and the function only returns elements that are deletable and also handles connected nodes and child nodes
export function getElementsToRemove<NodeType extends BaseNode = BaseNode, EdgeType extends BaseEdge = BaseEdge>({
nodesToRemove,
edgesToRemove,
nodes,
edges,
}: {
nodesToRemove: Partial<NodeType>[];
edgesToRemove: Partial<EdgeType>[];
nodes: NodeType[];
edges: EdgeType[];
}): {
matchingNodes: NodeType[];
matchingEdges: EdgeType[];
} {
const nodeIds = nodesToRemove.map((node) => node.id);
const edgeIds = edgesToRemove.map((edge) => edge.id);
const matchingNodes = nodes.reduce<NodeType[]>((res, node) => {
const parentHit = !nodeIds.includes(node.id) && node.parentNode && res.find((n) => n.id === node.parentNode);
const deletable = typeof node.deletable === 'boolean' ? node.deletable : true;
if (deletable && (nodeIds.includes(node.id) || parentHit)) {
res.push(node);
}
return res;
}, []);
const deletableEdges = edges.filter((e) => (typeof e.deletable === 'boolean' ? e.deletable : true));
const initialHitEdges = deletableEdges.filter((e) => edgeIds.includes(e.id));
const connectedEdges = getConnectedEdgesBase<NodeType, EdgeType>(matchingNodes, deletableEdges);
const matchingEdges = [...initialHitEdges, ...connectedEdges];
return {
matchingEdges,
matchingNodes,
};
}
export const getPositionWithOrigin = ({
x,
y,
width,
height,
origin = [0, 0],
}: {
x: number;
y: number;
width: number;
height: number;
origin?: NodeOrigin;
}): XYPosition => {
if (!width || !height) {
return { x, y };
}
if (origin[0] < 0 || origin[1] < 0 || origin[0] > 1 || origin[1] > 1) {
return { x, y };
}
return {
x: x - width * origin[0],
y: y - height * origin[1],
};
};
export const getHandleBounds = (
selector: string,
nodeElement: HTMLDivElement,
zoom: number,
nodeOrigin: NodeOrigin = [0, 0]
): HandleElement[] | null => {
const handles = nodeElement.querySelectorAll(selector);
if (!handles || !handles.length) {
return null;
}
const handlesArray = Array.from(handles) as HTMLDivElement[];
const nodeBounds = nodeElement.getBoundingClientRect();
const nodeOffset = {
x: nodeBounds.width * nodeOrigin[0],
y: nodeBounds.height * nodeOrigin[1],
};
return handlesArray.map((handle): HandleElement => {
const handleBounds = handle.getBoundingClientRect();
return {
id: handle.getAttribute('data-handleid'),
position: handle.getAttribute('data-handlepos') as unknown as Position,
x: (handleBounds.left - nodeBounds.left - nodeOffset.x) / zoom,
y: (handleBounds.top - nodeBounds.top - nodeOffset.y) / zoom,
...getDimensions(handle),
};
});
};
+269
View File
@@ -0,0 +1,269 @@
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import { calcAutoPan, getEventPosition, getPointerPosition, calcNextPosition } from '../utils';
import { getDragItems, getEventHandlerParams, hasSelector, wrapSelectionDragFunc } from './utils';
import type {
BaseNode,
NodeDragItem,
UseDragEvent,
XYPosition,
BaseEdge,
CoordinateExtent,
NodeOrigin,
OnError,
SnapGrid,
Transform,
PanBy,
OnNodeDrag,
OnSelectionDrag,
UpdateNodePositions,
} from '../types';
export type OnDrag = (event: MouseEvent, dragItems: NodeDragItem[], node: BaseNode, nodes: BaseNode[]) => void;
type StoreItems = {
nodes: BaseNode[];
edges: BaseEdge[];
nodeExtent: CoordinateExtent;
snapGrid: SnapGrid;
snapToGrid: boolean;
nodeOrigin: NodeOrigin;
multiSelectionActive: boolean;
domNode?: Element | null;
transform: Transform;
autoPanOnNodeDrag: boolean;
nodesDraggable: boolean;
selectNodesOnDrag: boolean;
panBy: PanBy;
unselectNodesAndEdges: () => void;
onError?: OnError;
onNodeDragStart?: OnNodeDrag;
onNodeDrag?: OnNodeDrag;
onNodeDragStop?: OnNodeDrag;
onSelectionDragStart?: OnSelectionDrag;
onSelectionDrag?: OnSelectionDrag;
onSelectionDragStop?: OnSelectionDrag;
updateNodePositions: UpdateNodePositions;
};
export type XYDragParams = {
domNode: Element;
getStoreItems: () => StoreItems;
onDragStart?: OnDrag;
onDrag?: OnDrag;
onDragStop?: OnDrag;
onNodeClick?: () => void;
};
export type XYDragInstance = {
update: (params: DragUpdateParams) => void;
destroy: () => void;
};
export type DragUpdateParams = {
noDragClassName?: string;
handleSelector?: string;
isSelectable?: boolean;
nodeId?: string;
domNode: Element;
};
export function XYDrag({
domNode,
onNodeClick,
getStoreItems,
onDragStart,
onDrag,
onDragStop,
}: XYDragParams): XYDragInstance {
let lastPos: { x: number | null; y: number | null } = { x: null, y: null };
let autoPanId = 0;
let dragItems: NodeDragItem[] = [];
let autoPanStarted = false;
let mousePosition: XYPosition = { x: 0, y: 0 };
let dragEvent: MouseEvent | null = null;
let containerBounds: DOMRect | null = null;
const d3Selection = select(domNode);
// public functions
function update({ noDragClassName, handleSelector, domNode, isSelectable, nodeId }: DragUpdateParams) {
function updateNodes({ x, y }: XYPosition) {
const {
nodes,
nodeExtent,
snapGrid,
snapToGrid,
nodeOrigin,
onNodeDrag,
onSelectionDrag,
onError,
updateNodePositions,
} = getStoreItems();
lastPos = { x, y };
let hasChange = false;
dragItems = dragItems.map((n) => {
const nextPosition = { x: x - n.distance.x, y: y - n.distance.y };
if (snapToGrid) {
nextPosition.x = snapGrid[0] * Math.round(nextPosition.x / snapGrid[0]);
nextPosition.y = snapGrid[1] * Math.round(nextPosition.y / snapGrid[1]);
}
const updatedPos = calcNextPosition(n, nextPosition, nodes, nodeExtent, nodeOrigin, onError);
// we want to make sure that we only fire a change event when there is a changes
hasChange = hasChange || n.position.x !== updatedPos.position.x || n.position.y !== updatedPos.position.y;
n.position = updatedPos.position;
n.positionAbsolute = updatedPos.positionAbsolute;
return n;
});
if (!hasChange) {
return;
}
updateNodePositions(dragItems, true, true);
const onNodeOrSelectionDrag = nodeId ? onNodeDrag : wrapSelectionDragFunc(onSelectionDrag);
if (dragEvent) {
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodes,
});
onDrag?.(dragEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeOrSelectionDrag?.(dragEvent as MouseEvent, currentNode, currentNodes);
}
}
function autoPan() {
if (!containerBounds) {
return;
}
const [xMovement, yMovement] = calcAutoPan(mousePosition, containerBounds);
if (xMovement !== 0 || yMovement !== 0) {
const { transform, panBy } = getStoreItems();
lastPos.x = (lastPos.x ?? 0) - xMovement / transform[2];
lastPos.y = (lastPos.y ?? 0) - yMovement / transform[2];
if (panBy({ x: xMovement, y: yMovement })) {
updateNodes(lastPos as XYPosition);
}
}
autoPanId = requestAnimationFrame(autoPan);
}
const d3DragInstance = drag()
.on('start', (event: UseDragEvent) => {
const {
nodes,
multiSelectionActive,
domNode,
nodesDraggable,
transform,
snapGrid,
snapToGrid,
onNodeDragStart,
onSelectionDragStart,
unselectNodesAndEdges,
selectNodesOnDrag,
} = getStoreItems();
if (!selectNodesOnDrag && !multiSelectionActive && nodeId) {
if (!nodes.find((n) => n.id === nodeId)?.selected) {
// we need to reset selected nodes when selectNodesOnDrag=false
unselectNodesAndEdges();
}
}
if (isSelectable && selectNodesOnDrag) {
onNodeClick?.();
}
const pointerPos = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
lastPos = pointerPos;
dragItems = getDragItems(nodes, nodesDraggable, pointerPos, nodeId);
const onNodeOrSelectionDragStart = nodeId ? onNodeDragStart : wrapSelectionDragFunc(onSelectionDragStart);
if (dragItems) {
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodes,
});
onDragStart?.(event.sourceEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeOrSelectionDragStart?.(event.sourceEvent as MouseEvent, currentNode, currentNodes);
}
containerBounds = domNode?.getBoundingClientRect() || null;
mousePosition = getEventPosition(event.sourceEvent, containerBounds!);
})
.on('drag', (event: UseDragEvent) => {
const { autoPanOnNodeDrag, transform, snapGrid, snapToGrid } = getStoreItems();
const pointerPos = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
if (!autoPanStarted && autoPanOnNodeDrag) {
autoPanStarted = true;
autoPan();
}
// skip events without movement
if ((lastPos.x !== pointerPos.xSnapped || lastPos.y !== pointerPos.ySnapped) && dragItems) {
dragEvent = event.sourceEvent as MouseEvent;
mousePosition = getEventPosition(event.sourceEvent, containerBounds!);
updateNodes(pointerPos);
}
})
.on('end', (event: UseDragEvent) => {
autoPanStarted = false;
cancelAnimationFrame(autoPanId);
if (dragItems) {
const { nodes, updateNodePositions, onNodeDragStop, onSelectionDragStop } = getStoreItems();
const onNodeOrSelectionDragStop = nodeId ? onNodeDragStop : wrapSelectionDragFunc(onSelectionDragStop);
updateNodePositions(dragItems, false, false);
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodes,
});
onDragStop?.(event.sourceEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeOrSelectionDragStop?.(event.sourceEvent as MouseEvent, currentNode, currentNodes);
}
})
.filter((event: MouseEvent) => {
const target = event.target as HTMLDivElement;
const isDraggable =
!event.button &&
(!noDragClassName || !hasSelector(target, `.${noDragClassName}`, domNode)) &&
(!handleSelector || hasSelector(target, handleSelector, domNode));
return isDraggable;
});
d3Selection.call(d3DragInstance);
}
function destroy() {
d3Selection.on('.drag', null);
}
return {
update,
destroy,
};
}
+1
View File
@@ -0,0 +1 @@
export * from './XYDrag';
+94
View File
@@ -0,0 +1,94 @@
import { type NodeDragItem, type XYPosition, BaseNode } from '../types';
export function wrapSelectionDragFunc(selectionFunc?: (event: MouseEvent, nodes: BaseNode[]) => void) {
return (event: MouseEvent, _: BaseNode, nodes: BaseNode[]) => selectionFunc?.(event, nodes);
}
export function isParentSelected<NodeType extends BaseNode>(node: NodeType, nodes: NodeType[]): boolean {
if (!node.parentNode) {
return false;
}
const parentNode = nodes.find((node) => node.id === node.parentNode);
if (!parentNode) {
return false;
}
if (parentNode.selected) {
return true;
}
return isParentSelected(parentNode, nodes);
}
export function hasSelector(target: Element, selector: string, domNode: Element): boolean {
let current = target;
do {
if (current?.matches(selector)) return true;
if (current === domNode) return false;
current = current.parentElement as Element;
} while (current);
return false;
}
// looks for all selected nodes and created a NodeDragItem for each of them
export function getDragItems<NodeType extends BaseNode>(
nodes: NodeType[],
nodesDraggable: boolean,
mousePos: XYPosition,
nodeId?: string
): NodeDragItem[] {
return nodes
.filter(
(n) =>
(n.selected || n.id === nodeId) &&
(!n.parentNode || !isParentSelected(n, nodes)) &&
(n.draggable || (nodesDraggable && typeof n.draggable === 'undefined'))
)
.map((n) => ({
id: n.id,
position: n.position || { x: 0, y: 0 },
positionAbsolute: n.positionAbsolute || { x: 0, y: 0 },
distance: {
x: mousePos.x - (n.positionAbsolute?.x ?? 0),
y: mousePos.y - (n.positionAbsolute?.y ?? 0),
},
delta: {
x: 0,
y: 0,
},
extent: n.extent,
parentNode: n.parentNode,
width: n.width,
height: n.height,
origin: n.origin,
}));
}
// returns two params:
// 1. the dragged node (or the first of the list, if we are dragging a node selection)
// 2. array of selected nodes (for multi selections)
export function getEventHandlerParams<NodeType extends BaseNode>({
nodeId,
dragItems,
nodes,
}: {
nodeId?: string;
dragItems: NodeDragItem[];
nodes: NodeType[];
}): [NodeType, NodeType[]] {
const extentedDragItems: NodeType[] = dragItems.map((n) => {
const node = nodes.find((node) => node.id === n.id)!;
return {
...node,
position: n.position,
positionAbsolute: n.positionAbsolute,
};
});
return [nodeId ? extentedDragItems.find((n) => n.id === nodeId)! : extentedDragItems[0], extentedDragItems];
}
+311
View File
@@ -0,0 +1,311 @@
import { pointToRendererPoint, rendererPointToPoint, getHostForElement, calcAutoPan, getEventPosition } from '../utils';
import {
ConnectionMode,
type OnConnect,
type OnConnectStart,
type HandleType,
type Connection,
type PanBy,
type BaseNode,
type Transform,
type ConnectingHandle,
type OnConnectEnd,
type UpdateConnection,
type IsValidConnection,
type ConnectionHandle,
} from '../types';
import { getClosestHandle, getConnectionStatus, getHandleLookup, getHandleType, resetRecentHandle } from './utils';
export type OnPointerDownParams = {
autoPanOnConnect: boolean;
connectionMode: ConnectionMode;
connectionRadius: number;
domNode: HTMLDivElement | null;
handleId: string | null;
nodeId: string;
isTarget: boolean;
nodes: BaseNode[];
lib: string;
edgeUpdaterType?: HandleType;
updateConnection: UpdateConnection;
panBy: PanBy;
cancelConnection: () => void;
onConnectStart?: OnConnectStart;
onConnect?: OnConnect;
onConnectEnd?: OnConnectEnd;
isValidConnection?: IsValidConnection;
onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void;
getTransform: () => Transform;
};
export type IsValidParams = {
handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'> | null;
connectionMode: ConnectionMode;
fromNodeId: string;
fromHandleId: string | null;
fromType: HandleType;
isValidConnection?: IsValidConnection;
doc: Document | ShadowRoot;
lib: string;
};
export type XYHandleInstance = {
onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void;
isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result;
};
type Result = {
handleDomNode: Element | null;
isValid: boolean;
connection: Connection;
endHandle: ConnectingHandle | null;
};
const nullConnection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null };
const alwaysValid = () => true;
function onPointerDown(
event: MouseEvent | TouchEvent,
{
connectionMode,
connectionRadius,
handleId,
nodeId,
edgeUpdaterType,
isTarget,
domNode,
nodes,
lib,
autoPanOnConnect,
panBy,
cancelConnection,
onConnectStart,
onConnect,
onConnectEnd,
isValidConnection = alwaysValid,
onEdgeUpdateEnd,
updateConnection,
getTransform,
}: OnPointerDownParams
) {
// when react-flow is used inside a shadow root we can't use document
const doc = getHostForElement(event.target as HTMLElement);
let autoPanId = 0;
let closestHandle: ConnectionHandle | null;
const { x, y } = getEventPosition(event);
const clickedHandle = doc?.elementFromPoint(x, y);
const handleType = getHandleType(edgeUpdaterType, clickedHandle);
const containerBounds = domNode?.getBoundingClientRect();
if (!containerBounds || !handleType) {
return;
}
let prevActiveHandle: Element;
let connectionPosition = getEventPosition(event, containerBounds);
let autoPanStarted = false;
let connection: Connection | null = null;
let isValid = false;
let handleDomNode: Element | null = null;
const handleLookup = getHandleLookup({
nodes,
nodeId,
handleId,
handleType,
});
// when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas
function autoPan(): void {
if (!autoPanOnConnect || !containerBounds) {
return;
}
const [x, y] = calcAutoPan(connectionPosition, containerBounds);
panBy({ x, y });
autoPanId = requestAnimationFrame(autoPan);
}
updateConnection({
connectionPosition,
connectionStatus: null,
// connectionNodeId etc will be removed in the next major in favor of connectionStartHandle
connectionStartHandle: {
nodeId,
handleId,
type: handleType,
},
connectionEndHandle: null,
});
onConnectStart?.(event, { nodeId, handleId, handleType });
function onPointerMove(event: MouseEvent | TouchEvent) {
const transform = getTransform();
connectionPosition = getEventPosition(event, containerBounds);
closestHandle = getClosestHandle(
pointToRendererPoint(connectionPosition, transform, false, [1, 1]),
connectionRadius,
handleLookup
);
if (!autoPanStarted) {
autoPan();
autoPanStarted = true;
}
const result = isValidHandle(event, {
handle: closestHandle,
connectionMode,
fromNodeId: nodeId,
fromHandleId: handleId,
fromType: isTarget ? 'target' : 'source',
isValidConnection,
doc,
lib,
});
handleDomNode = result.handleDomNode;
connection = result.connection;
isValid = result.isValid;
updateConnection({
connectionPosition:
closestHandle && isValid
? rendererPointToPoint(
{
x: closestHandle.x,
y: closestHandle.y,
},
transform
)
: connectionPosition,
connectionStatus: getConnectionStatus(!!closestHandle, isValid),
connectionEndHandle: result.endHandle,
});
if (!closestHandle && !isValid && !handleDomNode) {
return resetRecentHandle(prevActiveHandle, lib);
}
if (connection.source !== connection.target && handleDomNode) {
resetRecentHandle(prevActiveHandle, lib);
prevActiveHandle = handleDomNode;
// @todo: remove the old class names "react-flow__handle-" in the next major version
handleDomNode.classList.add('connecting', `${lib}-flow__handle-connecting`);
handleDomNode.classList.toggle('valid', isValid);
handleDomNode.classList.toggle(`${lib}-flow__handle-valid`, isValid);
}
}
function onPointerUp(event: MouseEvent | TouchEvent) {
if ((closestHandle || handleDomNode) && connection && isValid) {
onConnect?.(connection);
}
// it's important to get a fresh reference from the store here
// in order to get the latest state of onConnectEnd
onConnectEnd?.(event);
if (edgeUpdaterType) {
onEdgeUpdateEnd?.(event);
}
resetRecentHandle(prevActiveHandle, lib);
cancelConnection();
cancelAnimationFrame(autoPanId);
autoPanStarted = false;
isValid = false;
connection = null;
handleDomNode = null;
doc.removeEventListener('mousemove', onPointerMove as EventListener);
doc.removeEventListener('mouseup', onPointerUp as EventListener);
doc.removeEventListener('touchmove', onPointerMove as EventListener);
doc.removeEventListener('touchend', onPointerUp as EventListener);
}
doc.addEventListener('mousemove', onPointerMove as EventListener);
doc.addEventListener('mouseup', onPointerUp as EventListener);
doc.addEventListener('touchmove', onPointerMove as EventListener);
doc.addEventListener('touchend', onPointerUp as EventListener);
}
// checks if and returns connection in fom of an object { source: 123, target: 312 }
function isValidHandle(
event: MouseEvent | TouchEvent,
{
handle,
connectionMode,
fromNodeId,
fromHandleId,
fromType,
doc,
lib,
isValidConnection = alwaysValid,
}: IsValidParams
) {
const isTarget = fromType === 'target';
const handleDomNode = doc.querySelector(
`.${lib}-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]`
);
const { x, y } = getEventPosition(event);
const handleBelow = doc.elementFromPoint(x, y);
// we always want to prioritize the handle below the mouse cursor over the closest distance handle,
// because it could be that the center of another handle is closer to the mouse pointer than the handle below the cursor
const handleToCheck = handleBelow?.classList.contains(`${lib}-flow__handle`) ? handleBelow : handleDomNode;
const result: Result = {
handleDomNode: handleToCheck,
isValid: false,
connection: nullConnection,
endHandle: null,
};
if (handleToCheck) {
const handleType = getHandleType(undefined, handleToCheck);
const handleNodeId = handleToCheck.getAttribute('data-nodeid');
const handleId = handleToCheck.getAttribute('data-handleid');
const connectable = handleToCheck.classList.contains('connectable');
const connectableEnd = handleToCheck.classList.contains('connectableend');
const connection: Connection = {
source: isTarget ? handleNodeId : fromNodeId,
sourceHandle: isTarget ? handleId : fromHandleId,
target: isTarget ? fromNodeId : handleNodeId,
targetHandle: isTarget ? fromHandleId : handleId,
};
result.connection = connection;
const isConnectable = connectable && connectableEnd;
// in strict mode we don't allow target to target or source to source connections
const isValid =
isConnectable &&
(connectionMode === ConnectionMode.Strict
? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target')
: handleNodeId !== fromNodeId || handleId !== fromHandleId);
if (isValid) {
result.endHandle = {
nodeId: handleNodeId as string,
handleId,
type: handleType as HandleType,
};
result.isValid = isValidConnection(connection);
}
}
return result;
}
export const XYHandle: XYHandleInstance = {
onPointerDown,
isValid: isValidHandle,
};
+118
View File
@@ -0,0 +1,118 @@
import {
ConnectionStatus,
type HandleType,
type NodeHandleBounds,
type XYPosition,
type BaseNode,
type ConnectionHandle,
} from '../types';
import { internalsSymbol } from '../constants';
// this functions collects all handles and adds an absolute position
// so that we can later find the closest handle to the mouse position
export function getHandles(
node: BaseNode,
handleBounds: NodeHandleBounds,
type: HandleType,
currentHandle: string
): ConnectionHandle[] {
return (handleBounds[type] || []).reduce<ConnectionHandle[]>((res, h) => {
if (`${node.id}-${h.id}-${type}` !== currentHandle) {
res.push({
id: h.id || null,
type,
nodeId: node.id,
x: (node.positionAbsolute?.x ?? 0) + h.x + h.width / 2,
y: (node.positionAbsolute?.y ?? 0) + h.y + h.height / 2,
});
}
return res;
}, []);
}
export function getClosestHandle(
pos: XYPosition,
connectionRadius: number,
handles: ConnectionHandle[]
): ConnectionHandle | null {
let closestHandles: ConnectionHandle[] = [];
let minDistance = Infinity;
handles.forEach((handle) => {
const distance = Math.sqrt(Math.pow(handle.x - pos.x, 2) + Math.pow(handle.y - pos.y, 2));
if (distance <= connectionRadius) {
if (distance < minDistance) {
closestHandles = [handle];
} else if (distance === minDistance) {
// when multiple handles are on the same distance we collect all of them
closestHandles.push(handle);
}
minDistance = distance;
}
});
if (!closestHandles.length) {
return null;
}
return closestHandles.length === 1
? closestHandles[0]
: // if multiple handles are layouted on top of each other we take the one with type = target because it's more likely that the user wants to connect to this one
closestHandles.find((handle) => handle.type === 'target') || closestHandles[0];
}
type GetHandleLookupParams = {
nodes: BaseNode[];
nodeId: string;
handleId: string | null;
handleType: string;
};
export function getHandleLookup({ nodes, nodeId, handleId, handleType }: GetHandleLookupParams) {
return nodes.reduce<ConnectionHandle[]>((res, node) => {
if (node[internalsSymbol]) {
const { handleBounds } = node[internalsSymbol];
let sourceHandles: ConnectionHandle[] = [];
let targetHandles: ConnectionHandle[] = [];
if (handleBounds) {
sourceHandles = getHandles(node, handleBounds, 'source', `${nodeId}-${handleId}-${handleType}`);
targetHandles = getHandles(node, handleBounds, 'target', `${nodeId}-${handleId}-${handleType}`);
}
res.push(...sourceHandles, ...targetHandles);
}
return res;
}, []);
}
export function getHandleType(
edgeUpdaterType: HandleType | undefined,
handleDomNode: Element | null
): HandleType | null {
if (edgeUpdaterType) {
return edgeUpdaterType;
} else if (handleDomNode?.classList.contains('target')) {
return 'target';
} else if (handleDomNode?.classList.contains('source')) {
return 'source';
}
return null;
}
export function resetRecentHandle(handleDomNode: Element, lib: string): void {
handleDomNode?.classList.remove('valid', 'connecting', `${lib}-flow__handle-valid`, `${lib}-flow__handle-connecting`);
}
export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleValid: boolean) {
let connectionStatus = null;
if (isHandleValid) {
connectionStatus = 'valid';
} else if (isInsideConnectionRadius && !isHandleValid) {
connectionStatus = 'invalid';
}
return connectionStatus as ConnectionStatus;
}
+224
View File
@@ -0,0 +1,224 @@
import { type ZoomTransform, zoom, zoomTransform } from 'd3-zoom';
import { select } from 'd3-selection';
import {
type CoordinateExtent,
type Viewport,
PanZoomTransformOptions,
PanZoomUpdateOptions,
PanZoomParams,
PanZoomInstance,
} from '../types';
import { clamp } from '../utils';
import { getD3Transition, viewportToTransform } from './utils';
import {
createPanOnScrollHandler,
createPanZoomEndHandler,
createPanZoomHandler,
createPanZoomStartHandler,
createZoomOnScrollHandler,
} from './eventhandler';
import { createFilter } from './filter';
export type ZoomPanValues = {
isZoomingOrPanning: boolean;
usedRightMouseButton: boolean;
prevViewport: Viewport;
mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined;
};
export function XYPanZoom({
domNode,
minZoom,
maxZoom,
translateExtent,
viewport,
onPanZoom,
onPanZoomStart,
onPanZoomEnd,
onTransformChange,
onDraggingChange,
}: PanZoomParams): PanZoomInstance {
const zoomPanValues: ZoomPanValues = {
isZoomingOrPanning: false,
usedRightMouseButton: false,
prevViewport: { x: 0, y: 0, zoom: 0 },
mouseButton: 0,
timerId: undefined,
};
const bbox = domNode.getBoundingClientRect();
const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent);
const d3Selection = select(domNode).call(d3ZoomInstance);
setViewportConstrained(
{
x: viewport.x,
y: viewport.y,
zoom: clamp(viewport.zoom, minZoom, maxZoom),
},
[
[0, 0],
[bbox.width, bbox.height],
],
translateExtent
);
const d3ZoomHandler = d3Selection.on('wheel.zoom')!;
function setTransform(transform: ZoomTransform, options?: PanZoomTransformOptions) {
if (d3Selection) {
d3ZoomInstance?.transform(getD3Transition(d3Selection, options?.duration), transform);
}
}
// public functions
function update({
noWheelClassName,
noPanClassName,
onPaneContextMenu,
userSelectionActive,
panOnScroll,
panOnDrag,
panOnScrollMode,
panOnScrollSpeed,
preventScrolling,
zoomOnPinch,
zoomOnScroll,
zoomOnDoubleClick,
zoomActivationKeyPressed,
lib,
}: PanZoomUpdateOptions) {
if (userSelectionActive && !zoomPanValues.isZoomingOrPanning) {
destroy();
}
const isPanOnScroll = panOnScroll && !zoomActivationKeyPressed && !userSelectionActive;
const wheelHandler = isPanOnScroll
? createPanOnScrollHandler({
noWheelClassName,
d3Selection,
d3Zoom: d3ZoomInstance,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
})
: createZoomOnScrollHandler({
noWheelClassName,
preventScrolling,
d3ZoomHandler,
});
d3Selection.on('wheel.zoom', wheelHandler, { passive: false });
if (!userSelectionActive) {
// pan zoom start
const startHandler = createPanZoomStartHandler({
zoomPanValues,
onDraggingChange,
onPanZoomStart,
});
d3ZoomInstance.on('start', startHandler);
// pan zoom
const panZoomHandler = createPanZoomHandler({
zoomPanValues,
panOnDrag,
onPaneContextMenu: !!onPaneContextMenu,
onPanZoom,
onTransformChange,
});
d3ZoomInstance.on('zoom', panZoomHandler);
// pan zoom end
const panZoomEndHandler = createPanZoomEndHandler({
zoomPanValues,
panOnDrag,
panOnScroll,
onPaneContextMenu,
onPanZoomEnd,
onDraggingChange,
});
d3ZoomInstance.on('end', panZoomEndHandler);
}
const filter = createFilter({
zoomActivationKeyPressed,
panOnDrag,
zoomOnScroll,
panOnScroll,
zoomOnDoubleClick,
zoomOnPinch,
userSelectionActive,
noPanClassName,
noWheelClassName,
lib,
});
d3ZoomInstance.filter(filter);
}
function destroy() {
d3ZoomInstance.on('zoom', null);
}
function setViewportConstrained(
viewport: Viewport,
extent: CoordinateExtent,
translateExtent: CoordinateExtent
): ZoomTransform | undefined {
const nextTransform = viewportToTransform(viewport);
const contrainedTransform = d3ZoomInstance?.constrain()(nextTransform, extent, translateExtent);
if (contrainedTransform) {
setTransform(contrainedTransform);
}
return contrainedTransform;
}
function setViewport(viewport: Viewport, options?: PanZoomTransformOptions) {
const nextTransform = viewportToTransform(viewport);
setTransform(nextTransform, options);
return nextTransform;
}
function getViewport(): Viewport {
const transform = d3Selection ? zoomTransform(d3Selection.node() as Element) : { x: 0, y: 0, k: 1 };
return { x: transform.x, y: transform.y, zoom: transform.k };
}
function scaleTo(zoom: number, options?: PanZoomTransformOptions) {
if (d3Selection) {
d3ZoomInstance?.scaleTo(getD3Transition(d3Selection, options?.duration), zoom);
}
}
function scaleBy(factor: number, options?: PanZoomTransformOptions) {
if (d3Selection) {
d3ZoomInstance?.scaleBy(getD3Transition(d3Selection, options?.duration), factor);
}
}
function setScaleExtent(scaleExtent: [number, number]) {
d3ZoomInstance?.scaleExtent(scaleExtent);
}
function setTranslateExtent(translateExtent: CoordinateExtent) {
d3ZoomInstance?.translateExtent(translateExtent);
}
return {
update,
destroy,
setViewport,
setViewportConstrained,
getViewport,
scaleTo,
scaleBy,
setScaleExtent,
setTranslateExtent,
};
}
@@ -0,0 +1,192 @@
import type { D3ZoomEvent } from 'd3-zoom';
import { pointer } from 'd3-selection';
import {
PanOnScrollMode,
type D3SelectionInstance,
type D3ZoomHandler,
type D3ZoomInstance,
type OnPanZoom,
type Viewport,
type OnDraggingChange,
type OnTransformChange,
} from '../types';
import { isRightClickPan, isWrappedWithClass, transformToViewport, viewChanged } from './utils';
export type ZoomPanValues = {
isZoomingOrPanning: boolean;
usedRightMouseButton: boolean;
prevViewport: Viewport;
mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined;
};
export type PanOnScrollParams = {
noWheelClassName: string;
d3Selection: D3SelectionInstance;
d3Zoom: D3ZoomInstance;
panOnScrollMode: PanOnScrollMode;
panOnScrollSpeed: number;
zoomOnPinch: boolean;
};
export type ZoomOnScrollParams = {
noWheelClassName: string;
preventScrolling: boolean;
d3ZoomHandler: D3ZoomHandler;
};
export type PanZoomStartParams = {
zoomPanValues: ZoomPanValues;
onDraggingChange: OnDraggingChange;
onPanZoomStart?: OnPanZoom;
};
export type PanZoomParams = {
zoomPanValues: ZoomPanValues;
panOnDrag: boolean | number[];
onPaneContextMenu: boolean;
onTransformChange: OnTransformChange;
onPanZoom?: OnPanZoom;
};
export type PanZoomEndParams = {
zoomPanValues: ZoomPanValues;
panOnDrag: boolean | number[];
panOnScroll: boolean;
onDraggingChange: (isDragging: boolean) => void;
onPanZoomEnd?: OnPanZoom;
onPaneContextMenu?: (event: any) => void;
};
export function createPanOnScrollHandler({
noWheelClassName,
d3Selection,
d3Zoom,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
}: PanOnScrollParams) {
return (event: any) => {
if (isWrappedWithClass(event, noWheelClassName)) {
return false;
}
event.preventDefault();
event.stopImmediatePropagation();
const currentZoom = d3Selection.property('__zoom').k || 1;
if (event.ctrlKey && zoomOnPinch) {
const point = pointer(event);
// taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js
const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10;
const zoom = currentZoom * Math.pow(2, pinchDelta);
d3Zoom.scaleTo(d3Selection, zoom, point);
return;
}
// increase scroll speed in firefox
// firefox: deltaMode === 1; chrome: deltaMode === 0
const deltaNormalize = event.deltaMode === 1 ? 20 : 1;
const deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize;
const deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize;
d3Zoom.translateBy(
d3Selection,
-(deltaX / currentZoom) * panOnScrollSpeed,
-(deltaY / currentZoom) * panOnScrollSpeed
);
};
}
export function createZoomOnScrollHandler({ noWheelClassName, preventScrolling, d3ZoomHandler }: ZoomOnScrollParams) {
return function (this: Element, event: any, d: unknown) {
if (!preventScrolling || isWrappedWithClass(event, noWheelClassName)) {
return null;
}
event.preventDefault();
d3ZoomHandler.call(this, event, d);
};
}
export function createPanZoomStartHandler({ zoomPanValues, onDraggingChange, onPanZoomStart }: PanZoomStartParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
// we need to remember it here, because it's always 0 in the "zoom" event
zoomPanValues.mouseButton = event.sourceEvent?.button || 0;
zoomPanValues.isZoomingOrPanning = true;
if (event.sourceEvent?.type === 'mousedown') {
onDraggingChange(true);
}
if (onPanZoomStart) {
const viewport = transformToViewport(event.transform);
zoomPanValues.prevViewport = viewport;
onPanZoomStart?.(event.sourceEvent as MouseEvent | TouchEvent, viewport);
}
};
}
export function createPanZoomHandler({
zoomPanValues,
panOnDrag,
onPaneContextMenu,
onTransformChange,
onPanZoom,
}: PanZoomParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
zoomPanValues.usedRightMouseButton = !!(
onPaneContextMenu && isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0)
);
onTransformChange([event.transform.x, event.transform.y, event.transform.k]);
if (onPanZoom) {
onPanZoom?.(event.sourceEvent as MouseEvent | TouchEvent, transformToViewport(event.transform));
}
};
}
export function createPanZoomEndHandler({
zoomPanValues,
panOnDrag,
panOnScroll,
onDraggingChange,
onPanZoomEnd,
onPaneContextMenu,
}: PanZoomEndParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
zoomPanValues.isZoomingOrPanning = false;
if (
onPaneContextMenu &&
isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0) &&
!zoomPanValues.usedRightMouseButton &&
event.sourceEvent
) {
onPaneContextMenu(event.sourceEvent);
}
zoomPanValues.usedRightMouseButton = false;
onDraggingChange(false);
if (onPanZoomEnd && viewChanged(zoomPanValues.prevViewport, event.transform)) {
const viewport = transformToViewport(event.transform);
zoomPanValues.prevViewport = viewport;
clearTimeout(zoomPanValues.timerId);
zoomPanValues.timerId = setTimeout(
() => {
onPanZoomEnd?.(event.sourceEvent as MouseEvent | TouchEvent, viewport);
},
// we need a setTimeout for panOnScroll to supress multiple end events fired during scroll
panOnScroll ? 150 : 0
);
}
};
}
+95
View File
@@ -0,0 +1,95 @@
import { isWrappedWithClass } from './utils';
export type FilterParams = {
zoomActivationKeyPressed: boolean;
zoomOnScroll: boolean;
zoomOnPinch: boolean;
panOnDrag: boolean | number[];
panOnScroll: boolean;
zoomOnDoubleClick: boolean;
userSelectionActive: boolean;
noWheelClassName: string;
noPanClassName: string;
lib: string;
};
export function createFilter({
zoomActivationKeyPressed,
zoomOnScroll,
zoomOnPinch,
panOnDrag,
panOnScroll,
zoomOnDoubleClick,
userSelectionActive,
noWheelClassName,
noPanClassName,
lib,
}: FilterParams) {
return (event: any): boolean => {
const zoomScroll = zoomActivationKeyPressed || zoomOnScroll;
const pinchZoom = zoomOnPinch && event.ctrlKey;
if (
event.button === 1 &&
event.type === 'mousedown' &&
(isWrappedWithClass(event, `${lib}-flow__node`) || isWrappedWithClass(event, `${lib}-flow__edge`))
) {
return true;
}
// if all interactions are disabled, we prevent all zoom events
if (!panOnDrag && !zoomScroll && !panOnScroll && !zoomOnDoubleClick && !zoomOnPinch) {
return false;
}
// during a selection we prevent all other interactions
if (userSelectionActive) {
return false;
}
// if zoom on double click is disabled, we prevent the double click event
if (!zoomOnDoubleClick && event.type === 'dblclick') {
return false;
}
// if the target element is inside an element with the nowheel class, we prevent zooming
if (isWrappedWithClass(event, noWheelClassName) && event.type === 'wheel') {
return false;
}
// if the target element is inside an element with the nopan class, we prevent panning
if (isWrappedWithClass(event, noPanClassName) && event.type !== 'wheel') {
return false;
}
if (!zoomOnPinch && event.ctrlKey && event.type === 'wheel') {
return false;
}
// when there is no scroll handling enabled, we prevent all wheel events
if (!zoomScroll && !panOnScroll && !pinchZoom && event.type === 'wheel') {
return false;
}
// if the pane is not movable, we prevent dragging it with mousestart or touchstart
if (!panOnDrag && (event.type === 'mousedown' || event.type === 'touchstart')) {
return false;
}
// if the pane is only movable using allowed clicks
if (
Array.isArray(panOnDrag) &&
!panOnDrag.includes(event.button) &&
(event.type === 'mousedown' || event.type === 'touchstart')
) {
return false;
}
// We only allow right clicks if pan on drag is set to right click
const buttonAllowed =
(Array.isArray(panOnDrag) && panOnDrag.includes(event.button)) || !event.button || event.button <= 1;
// default filter for d3-zoom
return (!event.ctrlKey || event.type === 'wheel') && buttonAllowed;
};
}
+1
View File
@@ -0,0 +1 @@
export * from './XYPanZoom';
+23
View File
@@ -0,0 +1,23 @@
import { type ZoomTransform, zoomIdentity } from 'd3-zoom';
import { type D3SelectionInstance, type Viewport } from '../types';
export const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean =>
prevViewport.x !== eventViewport.x || prevViewport.y !== eventViewport.y || prevViewport.zoom !== eventViewport.k;
export const transformToViewport = (transform: ZoomTransform): Viewport => ({
x: transform.x,
y: transform.y,
zoom: transform.k,
});
export const viewportToTransform = ({ x, y, zoom }: Viewport): ZoomTransform =>
zoomIdentity.translate(x, y).scale(zoom);
export const isWrappedWithClass = (event: any, className: string | undefined) => event.target.closest(`.${className}`);
export const isRightClickPan = (panOnDrag: boolean | number[], usedButton: number) =>
usedButton === 2 && Array.isArray(panOnDrag) && panOnDrag.includes(2);
export const getD3Transition = (selection: D3SelectionInstance, duration = 0) =>
typeof duration === 'number' && duration > 0 ? selection.transition().duration(duration) : selection;