fix(core): use center position of handle as snapping point for connection lines (#1625)

* fix(core): calculate handle positions correctly

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* fix(core): remove handleId check

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* chore(core): cleanup

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* chore(core): cleanup click connect handlers

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* fix(core): add flow id to handle ids

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* tests: correct updateEdge test

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* fix(core): use center position of handle

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* chore(examples): cleanup easy connect example

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* refactor(tests): run actions on chrome

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* chore(workflows): correctly hash lock file

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

---------

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>
This commit is contained in:
Braks
2025-01-04 14:00:41 +01:00
parent 041fd871a1
commit b33da47a3a
16 changed files with 385 additions and 312 deletions

View File

@@ -26,7 +26,7 @@ runs:
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-

View File

@@ -54,5 +54,3 @@ jobs:
with:
install-command: pnpm cypress install
command: pnpm run test --cache-dir=.turbo
browser: chrome
component: true

View File

@@ -1,12 +1,10 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import type { Elements } from '@vue-flow/core'
import type { Node } from '@vue-flow/core'
import { MarkerType, VueFlow, useVueFlow } from '@vue-flow/core'
import CustomNode from './CustomNode.vue'
import CustomConnectionLine from './CustomConnectionLine.vue'
import FloatingConnectionLine from './FloatingConnectionLine.vue'
import FloatingEdge from './FloatingEdge.vue'
const { onConnect, addEdges } = useVueFlow()
@@ -20,7 +18,7 @@ const defaultEdgeOptions = {
},
}
const elements = ref<Elements>([
const nodes = ref<Node[]>([
{
id: '1',
type: 'custom',
@@ -43,37 +41,31 @@ const elements = ref<Elements>([
},
])
const edges = ref([])
onConnect(addEdges)
</script>
<template>
<div style="height: 100vh">
<VueFlow
v-model="elements"
:elevate-nodes-on-select="false"
class="vue-flow-basic-example"
:default-zoom="1.5"
:default-edge-options="defaultEdgeOptions"
:min-zoom="0.2"
:max-zoom="4"
>
<Background pattern-color="#aaa" :gap="8" />
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:elevate-nodes-on-select="false"
:default-edge-options="defaultEdgeOptions"
fit-view-on-init
>
<Background pattern-color="#aaa" :gap="8" />
<MiniMap />
<template #node-custom="props">
<CustomNode :id="props.id" />
</template>
<Controls />
<template #edge-floating="fProps">
<FloatingEdge v-bind="fProps" />
</template>
<template #node-custom="props">
<CustomNode :id="props.id" />
</template>
<template #edge-floating="fProps">
<FloatingEdge v-bind="fProps" />
</template>
<template #connection-line="cProps">
<CustomConnectionLine v-bind="cProps" />
</template>
</VueFlow>
</div>
<template #connection-line="cProps">
<FloatingConnectionLine v-bind="cProps" />
</template>
</VueFlow>
</template>

View File

@@ -1,9 +1,9 @@
<script lang="ts" setup>
import type { GraphNode } from '@vue-flow/core'
import { getStraightPath } from '@vue-flow/core'
import type { ConnectionLineProps } from '@vue-flow/core'
import { BaseEdge, getStraightPath } from '@vue-flow/core'
import { computed } from 'vue'
const props = defineProps<{ sourceNode: GraphNode; sourceX: number; sourceY: number; targetX: number; targetY: number }>()
const props = defineProps<ConnectionLineProps>()
const edgePath = computed(() =>
getStraightPath({
@@ -15,13 +15,12 @@ const edgePath = computed(() =>
<template>
<g>
<path
<BaseEdge
:style="{
strokeWidth: 3,
stroke: 'black',
}"
fill="none"
:d="edgePath[0]"
:path="edgePath[0]"
/>
<circle :cx="targetX" :cy="targetY" fill="black" :r="3" stroke="black" :stroke-width="1.5" />
</g>

View File

@@ -1,11 +1,11 @@
<script lang="ts" setup>
import type { GraphNode } from '@vue-flow/core'
import { getStraightPath } from '@vue-flow/core'
import type { EdgeProps } from '@vue-flow/core'
import { BaseEdge, getStraightPath } from '@vue-flow/core'
import { computed } from 'vue'
import { getEdgeParams } from './utils'
const props = defineProps<{ id: string; sourceNode: GraphNode; targetNode: GraphNode; markerEnd: string; style: any }>()
const props = defineProps<EdgeProps>()
const edgeParams = computed(() => getEdgeParams(props.sourceNode, props.targetNode))
@@ -20,5 +20,5 @@ const edgePath = computed(() =>
</script>
<template>
<path :id="id" class="vue-flow__edge-path" :d="edgePath[0]" :marker-end="markerEnd" :style="style" />
<BaseEdge :id="id" :path="edgePath[0]" :marker-end="markerEnd" :style="style" />
</template>

View File

@@ -1,18 +1,11 @@
import { computed, defineComponent, h, inject } from 'vue'
import type { HandleElement } from '../../types'
import { ConnectionLineType, ConnectionMode, Position } from '../../types'
import { getHandlePosition, getMarkerId } from '../../utils'
import { getHandlePosition, getMarkerId, oppositePosition } from '../../utils'
import { useVueFlow } from '../../composables'
import { Slots } from '../../context'
import { getBezierPath, getSimpleBezierPath, getSmoothStepPath } from '../Edges/utils'
const oppositePosition = {
[Position.Left]: Position.Right,
[Position.Right]: Position.Left,
[Position.Top]: Position.Bottom,
[Position.Bottom]: Position.Top,
}
const ConnectionLine = defineComponent({
name: 'ConnectionLine',
compatConfig: { MODE: 3 },
@@ -57,7 +50,7 @@ const ConnectionLine = defineComponent({
return null
}
const startHandleId = connectionStartHandle.value.handleId
const startHandleId = connectionStartHandle.value.id
const handleType = connectionStartHandle.value.type
@@ -78,18 +71,18 @@ const ConnectionLine = defineComponent({
const { x: fromX, y: fromY } = getHandlePosition(fromNode.value, fromHandle, fromPosition)
let toHandle: HandleElement | null = null
if (toNode.value && connectionEndHandle.value?.handleId) {
if (toNode.value) {
// if connection mode is strict, we only look for handles of the opposite type
if (connectionMode.value === ConnectionMode.Strict) {
toHandle =
toNode.value.handleBounds[handleType === 'source' ? 'target' : 'source']?.find(
(d) => d.id === connectionEndHandle.value?.handleId,
(d) => d.id === connectionEndHandle.value?.id,
) || null
} else {
// if connection mode is loose, look for the handle in both source and target bounds
toHandle =
[...(toNode.value.handleBounds.source || []), ...(toNode.value.handleBounds.target || [])]?.find(
(d) => d.id === connectionEndHandle.value?.handleId,
(d) => d.id === connectionEndHandle.value?.id,
) || null
}
}

View File

@@ -8,7 +8,7 @@ import {
ErrorCode,
VueFlowError,
elementSelectionKeys,
getHandle,
getEdgeHandle,
getHandlePosition,
getMarkerId,
} from '../../utils'
@@ -150,7 +150,7 @@ const EdgeWrapper = defineComponent({
sourceNodeHandles = [...(sourceNode.handleBounds.source || []), ...(sourceNode.handleBounds.target || [])]
}
const sourceHandle = getHandle(sourceNodeHandles, edge.value.sourceHandle)
const sourceHandle = getEdgeHandle(sourceNodeHandles, edge.value.sourceHandle)
let targetNodeHandles
if (connectionMode.value === ConnectionMode.Strict) {
@@ -159,7 +159,7 @@ const EdgeWrapper = defineComponent({
targetNodeHandles = [...(targetNode.handleBounds.target || []), ...(targetNode.handleBounds.source || [])]
}
const targetHandle = getHandle(targetNodeHandles, edge.value.targetHandle)
const targetHandle = getEdgeHandle(targetNodeHandles, edge.value.targetHandle)
const sourcePosition = sourceHandle?.position || Position.Bottom

View File

@@ -19,6 +19,7 @@ const type = toRef(() => props.type ?? 'source')
const isValidConnection = toRef(() => props.isValidConnection ?? null)
const {
id: flowId,
connectionStartHandle,
connectionClickStartHandle,
connectionEndHandle,
@@ -39,17 +40,17 @@ const isConnectableEnd = toRef(() => (typeof connectableEnd !== 'undefined' ? co
const isConnecting = toRef(
() =>
(connectionStartHandle.value?.nodeId === nodeId &&
connectionStartHandle.value?.handleId === handleId &&
connectionStartHandle.value?.id === handleId &&
connectionStartHandle.value?.type === type.value) ||
(connectionEndHandle.value?.nodeId === nodeId &&
connectionEndHandle.value?.handleId === handleId &&
connectionEndHandle.value?.id === handleId &&
connectionEndHandle.value?.type === type.value),
)
const isClickConnecting = toRef(
() =>
connectionClickStartHandle.value?.nodeId === nodeId &&
connectionClickStartHandle.value?.handleId === handleId &&
connectionClickStartHandle.value?.id === handleId &&
connectionClickStartHandle.value?.type === type.value,
)
@@ -127,6 +128,8 @@ onMounted(() => {
position,
x: (handleBounds.left - nodeBounds.left) / zoom,
y: (handleBounds.top - nodeBounds.top) / zoom,
type: type.value,
nodeId,
...getDimensions(handle.value),
}
@@ -177,7 +180,7 @@ export default {
<template>
<div
ref="handle"
:data-id="`${nodeId}-${handleId}-${type}`"
:data-id="`${flowId}-${nodeId}-${handleId}-${type}`"
:data-handleid="handleId"
:data-nodeid="nodeId"
:data-handlepos="position"

View File

@@ -1,16 +1,19 @@
import type { MaybeRefOrGetter } from 'vue'
import { toValue } from 'vue'
import type { Connection, ConnectionHandle, HandleType, MouseTouchEvent, ValidConnectionFunc, ValidHandleResult } from '../types'
import type { Connection, ConnectionInProgress, HandleElement, HandleType, MouseTouchEvent, ValidConnectionFunc } from '../types'
import {
calcAutoPan,
getClosestHandle,
getConnectionStatus,
getEventPosition,
getHandleLookup,
getHandle,
getHandlePosition,
getHandleType,
getHostForElement,
isConnectionValid,
isMouseEvent,
isValidHandle,
oppositePosition,
pointToRendererPoint,
rendererPointToPoint,
resetRecentHandle,
@@ -49,6 +52,7 @@ export function useHandle({
onEdgeUpdateEnd,
}: UseHandleProps) {
const {
id: flowId,
vueFlowRef,
connectionMode,
connectionRadius,
@@ -67,12 +71,12 @@ export function useHandle({
edges,
nodes,
isValidConnection: isValidConnectionProp,
nodeLookup,
} = useVueFlow()
let connection: Connection | null = null
let isValid = false
let isValid: boolean | null = false
let handleDomNode: Element | null = null
let previousConnection: ValidHandleResult | null = null
function handlePointerDown(event: MouseTouchEvent) {
const isTarget = toValue(type) === 'target'
@@ -91,7 +95,7 @@ export function useHandle({
isValidConnectionHandler = (!isTarget ? node.isValidTargetPos : node.isValidSourcePos) || alwaysValid
}
let closestHandle: ConnectionHandle | null
let closestHandle: HandleElement | null
let autoPanId = 0
@@ -104,17 +108,16 @@ export function useHandle({
return
}
const fromHandleInternal = getHandle(toValue(nodeId), handleType, toValue(handleId), nodeLookup.value, connectionMode.value)
if (!fromHandleInternal) {
return
}
let prevActiveHandle: Element
let connectionPosition = getEventPosition(event, containerBounds)
let autoPanStarted = false
const handleLookup = getHandleLookup({
nodes: nodes.value,
nodeId: toValue(nodeId),
handleId: toValue(handleId),
handleType,
})
// when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas
const autoPan = () => {
if (!autoPanOnConnect.value) {
@@ -127,10 +130,37 @@ export function useHandle({
autoPanId = requestAnimationFrame(autoPan)
}
// Stays the same for all consecutive pointermove events
const fromHandle: HandleElement = {
...fromHandleInternal,
nodeId: toValue(nodeId),
type: handleType,
position: fromHandleInternal.position,
}
const fromNodeInternal = nodeLookup.value.get(toValue(nodeId))!
const from = getHandlePosition(fromNodeInternal, fromHandle, Position.Left, true)
const newConnection: ConnectionInProgress = {
inProgress: true,
isValid: null,
from,
fromHandle,
fromPosition: fromHandle.position,
fromNode: fromNodeInternal,
to: connectionPosition,
toHandle: null,
toPosition: oppositePosition[fromHandle.position],
toNode: null,
}
startConnection(
{
nodeId: toValue(nodeId),
handleId: toValue(handleId),
id: toValue(handleId),
type: handleType,
position: (clickedHandle?.getAttribute('data-handlepos') as Position) || Position.Top,
},
@@ -142,52 +172,71 @@ export function useHandle({
emits.connectStart({ event, nodeId: toValue(nodeId), handleId: toValue(handleId), handleType })
let previousConnection: ConnectionInProgress = newConnection
function onPointerMove(event: MouseTouchEvent) {
connectionPosition = getEventPosition(event, containerBounds)
const { handle, validHandleResult } = getClosestHandle(
event,
doc,
closestHandle = getClosestHandle(
pointToRendererPoint(connectionPosition, viewport.value, false, [1, 1]),
connectionRadius.value,
handleLookup,
(handle) =>
isValidHandle(
event,
handle,
connectionMode.value,
toValue(nodeId),
toValue(handleId),
isTarget ? 'target' : 'source',
isValidConnectionHandler,
doc,
edges.value,
nodes.value,
findNode,
),
nodeLookup.value,
fromHandle,
)
closestHandle = handle
if (!autoPanStarted) {
autoPan()
autoPanStarted = true
}
connection = validHandleResult.connection
isValid = validHandleResult.isValid
handleDomNode = validHandleResult.handleDomNode
const result = isValidHandle(
event,
{
handle: closestHandle,
connectionMode: connectionMode.value,
fromNodeId: toValue(nodeId),
fromHandleId: toValue(handleId),
fromType: isTarget ? 'target' : 'source',
isValidConnection: isValidConnectionHandler,
doc,
lib: 'vue',
flowId,
nodeLookup: nodeLookup.value,
},
edges.value,
nodes.value,
findNode,
)
handleDomNode = result.handleDomNode
connection = result.connection
isValid = isConnectionValid(!!closestHandle, result.isValid)
const newConnection: ConnectionInProgress = {
// from stays the same
...previousConnection,
isValid,
to:
closestHandle && isValid
? rendererPointToPoint({ x: closestHandle.x, y: closestHandle.y }, viewport.value)
: connectionPosition,
toHandle: result.toHandle,
toPosition: isValid && result.toHandle ? result.toHandle.position : oppositePosition[fromHandle.position],
toNode: result.toHandle ? nodeLookup.value.get(result.toHandle.nodeId)! : null,
}
// we don't want to trigger an update when the connection
// is snapped to the same handle as before
if (
isValid &&
closestHandle &&
previousConnection?.endHandle &&
validHandleResult.endHandle &&
previousConnection.endHandle.type === validHandleResult.endHandle.type &&
previousConnection.endHandle.nodeId === validHandleResult.endHandle.nodeId &&
previousConnection.endHandle.handleId === validHandleResult.endHandle.handleId
previousConnection?.toHandle &&
newConnection.toHandle &&
previousConnection.toHandle.type === newConnection.toHandle.type &&
previousConnection.toHandle.nodeId === newConnection.toHandle.nodeId &&
previousConnection.toHandle.id === newConnection.toHandle.id &&
previousConnection.to.x === newConnection.to.x &&
previousConnection.to.y === newConnection.to.y
) {
return
}
@@ -202,11 +251,11 @@ export function useHandle({
viewport.value,
)
: connectionPosition,
validHandleResult.endHandle,
result.toHandle,
getConnectionStatus(!!closestHandle, isValid),
)
previousConnection = validHandleResult
previousConnection = newConnection
if (!closestHandle && !isValid && !handleDomNode) {
return resetRecentHandle(prevActiveHandle)
@@ -219,9 +268,9 @@ export function useHandle({
// todo: remove `vue-flow__handle-connecting` in next major version
handleDomNode.classList.add('connecting', 'vue-flow__handle-connecting')
handleDomNode.classList.toggle('valid', isValid)
handleDomNode.classList.toggle('valid', !!isValid)
// todo: remove this in next major version
handleDomNode.classList.toggle('vue-flow__handle-valid', isValid)
handleDomNode.classList.toggle('vue-flow__handle-valid', !!isValid)
}
}
@@ -275,50 +324,62 @@ export function useHandle({
if (!connectionClickStartHandle.value) {
emits.clickConnectStart({ event, nodeId: toValue(nodeId), handleId: toValue(handleId) })
startConnection({ nodeId: toValue(nodeId), type: toValue(type), handleId: toValue(handleId) }, undefined, true)
} else {
let isValidConnectionHandler = toValue(isValidConnection) || isValidConnectionProp.value || alwaysValid
startConnection(
{ nodeId: toValue(nodeId), type: toValue(type), id: toValue(handleId), position: Position.Top },
undefined,
true,
)
const node = findNode(toValue(nodeId))
return
}
if (!isValidConnectionHandler && node) {
isValidConnectionHandler = (!isTarget ? node.isValidTargetPos : node.isValidSourcePos) || alwaysValid
}
let isValidConnectionHandler = toValue(isValidConnection) || isValidConnectionProp.value || alwaysValid
if (node && (typeof node.connectable === 'undefined' ? nodesConnectable.value : node.connectable) === false) {
return
}
const node = findNode(toValue(nodeId))
const doc = getHostForElement(event.target as HTMLElement)
if (!isValidConnectionHandler && node) {
isValidConnectionHandler = (!isTarget ? node.isValidTargetPos : node.isValidSourcePos) || alwaysValid
}
const { connection, isValid } = isValidHandle(
event,
{
if (node && (typeof node.connectable === 'undefined' ? nodesConnectable.value : node.connectable) === false) {
return
}
const doc = getHostForElement(event.target as HTMLElement)
const result = isValidHandle(
event,
{
handle: {
nodeId: toValue(nodeId),
id: toValue(handleId),
type: toValue(type),
position: Position.Top,
},
connectionMode.value,
connectionClickStartHandle.value.nodeId,
connectionClickStartHandle.value.handleId || null,
connectionClickStartHandle.value.type,
isValidConnectionHandler,
connectionMode: connectionMode.value,
fromNodeId: connectionClickStartHandle.value.nodeId,
fromHandleId: connectionClickStartHandle.value.id || null,
fromType: connectionClickStartHandle.value.type,
isValidConnection: isValidConnectionHandler,
doc,
edges.value,
nodes.value,
findNode,
)
lib: 'vue',
flowId,
nodeLookup: nodeLookup.value,
},
edges.value,
nodes.value,
findNode,
)
const isOwnHandle = connection.source === connection.target
const isOwnHandle = result.connection?.source === result.connection?.target
if (isValid && !isOwnHandle) {
emits.connect(connection)
}
emits.clickConnectEnd(event)
endConnection(event, true)
if (result.isValid && result.connection && !isOwnHandle) {
emits.connect(result.connection)
}
emits.clickConnectEnd(event)
endConnection(event, true)
}
return {

View File

@@ -157,8 +157,8 @@ export function useActions(state: State, nodeLookup: ComputedRef<NodeLookup>, ed
if (doUpdate) {
const nodeBounds = update.nodeElement.getBoundingClientRect()
node.dimensions = dimensions
node.handleBounds.source = getHandleBounds('.source', update.nodeElement, nodeBounds, zoom)
node.handleBounds.target = getHandleBounds('.target', update.nodeElement, nodeBounds, zoom)
node.handleBounds.source = getHandleBounds('source', update.nodeElement, nodeBounds, zoom)
node.handleBounds.target = getHandleBounds('target', update.nodeElement, nodeBounds, zoom)
changes.push({
id: node.id,

View File

@@ -1,13 +1,16 @@
import type { Dimensions, Position, XYPosition } from './flow'
import type { Connection } from './connection'
import type { Connection, ConnectionMode } from './connection'
import type { GraphEdge } from './edge'
import type { GraphNode } from './node'
import type { NodeLookup } from './store'
export type HandleType = 'source' | 'target'
export interface HandleElement extends XYPosition, Dimensions {
id?: string | null
position: Position
type: HandleType
nodeId: string
}
export interface ConnectionHandle extends XYPosition {
@@ -16,18 +19,11 @@ export interface ConnectionHandle extends XYPosition {
nodeId: string
}
export interface ValidHandleResult {
endHandle: ConnectingHandle | null
handleDomNode: Element | null
isValid: boolean
connection: Connection
}
export interface ConnectingHandle {
nodeId: string
type: HandleType
handleId?: string | null
position?: Position | null
id?: string | null
position: Position
}
/** A valid connection function can determine if an attempted connection is valid or not, i.e. abort creating a new edge */
@@ -63,3 +59,36 @@ export interface HandleProps {
/** Can this handle be used to *end* a connection */
connectableEnd?: boolean
}
export interface IsValidParams {
handle: ConnectingHandle | null
connectionMode: ConnectionMode
fromNodeId: string
fromHandleId: string | null
fromType: HandleType
isValidConnection?: ValidConnectionFunc
doc: Document | ShadowRoot
lib: string
flowId: string | null
nodeLookup: NodeLookup
}
export interface Result {
handleDomNode: Element | null
isValid: boolean
connection: Connection | null
toHandle: ConnectingHandle | null
}
export interface ConnectionInProgress<NodeType extends GraphNode = GraphNode> {
inProgress: true
isValid: boolean | null
from: XYPosition
fromHandle: HandleElement
fromPosition: Position
fromNode: NodeType
to: XYPosition
toHandle: ConnectingHandle | null
toPosition: Position
toNode: NodeType | null
}

View File

@@ -6,41 +6,36 @@ export function getHandlePosition(
node: GraphNode,
handle: HandleElement | null,
fallbackPosition: Position = Position.Left,
center = false,
): XYPosition {
const x = (handle?.x ?? 0) + node.computedPosition.x
const y = (handle?.y ?? 0) + node.computedPosition.y
const { width, height } = handle ?? getNodeDimensions(node)
if (center) {
return { x: x + width / 2, y: y + height / 2 }
}
const position = handle?.position ?? fallbackPosition
switch (position) {
case Position.Top:
return {
x: x + width / 2,
y,
}
return { x: x + width / 2, y }
case Position.Right:
return {
x: x + width,
y: y + height / 2,
}
return { x: x + width, y: y + height / 2 }
case Position.Bottom:
return {
x: x + width / 2,
y: y + height,
}
return { x: x + width / 2, y: y + height }
case Position.Left:
return {
x,
y: y + height / 2,
}
return { x, y: y + height / 2 }
}
}
export function getHandle(bounds: HandleElement[] = [], handleId?: string | null): HandleElement | null {
if (!bounds.length) {
export function getEdgeHandle(bounds: HandleElement[] | undefined, handleId?: string | null): HandleElement | null {
if (!bounds) {
return null
}
// if no handleId is given, we use the first handle, otherwise we check for the id
return (!handleId ? bounds[0] : bounds.find((d) => d.id === handleId)) || null
}

View File

@@ -1,4 +1,4 @@
import { ConnectionMode } from '../types'
import { ConnectionMode, Position } from '../types'
import type {
Actions,
Connection,
@@ -6,23 +6,17 @@ import type {
ConnectionStatus,
GraphEdge,
GraphNode,
HandleElement,
HandleType,
IsValidParams,
NodeHandleBounds,
Position,
ValidConnectionFunc,
ValidHandleResult,
NodeLookup,
Result,
XYPosition,
} from '../types'
import { getEventPosition, getHandlePosition } from '.'
import { getEventPosition, getHandlePosition, getOverlappingArea, nodeToRect } from '.'
function defaultValidHandleResult(): ValidHandleResult {
return {
handleDomNode: null,
isValid: false,
connection: { source: '', target: '', sourceHandle: null, targetHandle: null },
endHandle: null,
}
}
const alwaysValid = () => true
export function resetRecentHandle(handleDomNode: Element): void {
handleDomNode?.classList.remove('valid', 'connecting', 'vue-flow__handle-valid', 'vue-flow__handle-connecting')
@@ -55,126 +49,126 @@ export function getHandles(
return connectionHandles
}
export function getClosestHandle(
event: MouseEvent | TouchEvent,
doc: Document | ShadowRoot,
pos: XYPosition,
connectionRadius: number,
handles: ConnectionHandle[],
validator: (handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'>) => ValidHandleResult,
) {
// 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 { x, y } = getEventPosition(event)
const domNodes = doc.elementsFromPoint(x, y)
function getNodesWithinDistance(position: XYPosition, nodeLookup: NodeLookup, distance: number): GraphNode[] {
const nodes: GraphNode[] = []
const rect = {
x: position.x - distance,
y: position.y - distance,
width: distance * 2,
height: distance * 2,
}
const handleBelow = domNodes.find((el) => el.classList.contains('vue-flow__handle'))
if (handleBelow) {
const handleNodeId = handleBelow.getAttribute('data-nodeid')
if (handleNodeId) {
const handleType = getHandleType(undefined, handleBelow)
const handleId = handleBelow.getAttribute('data-handleid')
const validHandleResult = validator({ nodeId: handleNodeId, id: handleId, type: handleType })
if (validHandleResult) {
const handle = handles.find((h) => h.nodeId === handleNodeId && h.type === handleType && h.id === handleId)
return {
handle: {
id: handleId,
type: handleType,
nodeId: handleNodeId,
x: handle?.x || pos.x,
y: handle?.y || pos.y,
},
validHandleResult,
}
}
for (const node of nodeLookup.values()) {
if (getOverlappingArea(rect, nodeToRect(node)) > 0) {
nodes.push(node)
}
}
// if we couldn't find a handle below the mouse cursor we look for the closest distance based on the connectionRadius
let closestHandles: { handle: ConnectionHandle; validHandleResult: ValidHandleResult }[] = []
return nodes
}
// this distance is used for the area around the user pointer
// while doing a connection for finding the closest nodes
const ADDITIONAL_DISTANCE = 250
export function getClosestHandle(
position: XYPosition,
connectionRadius: number,
nodeLookup: NodeLookup,
fromHandle: { nodeId: string; type: HandleType; id?: string | null },
): HandleElement | null {
let closestHandles: HandleElement[] = []
let minDistance = Number.POSITIVE_INFINITY
for (const handle of handles) {
const distance = Math.sqrt((handle.x - pos.x) ** 2 + (handle.y - pos.y) ** 2)
const closeNodes = getNodesWithinDistance(position, nodeLookup, connectionRadius + ADDITIONAL_DISTANCE)
if (distance <= connectionRadius) {
const validHandleResult = validator(handle)
for (const node of closeNodes) {
const allHandles = [...(node.handleBounds?.source ?? []), ...(node.handleBounds?.target ?? [])]
if (distance <= minDistance) {
if (distance < minDistance) {
closestHandles = [{ handle, validHandleResult }]
} else if (distance === minDistance) {
// when multiple handles are on the same distance we collect all of them
closestHandles.push({
handle,
validHandleResult,
})
}
for (const handle of allHandles) {
// if the handle is the same as the fromHandle we skip it
if (fromHandle.nodeId === handle.nodeId && fromHandle.type === handle.type && fromHandle.id === handle.id) {
continue
}
// determine absolute position of the handle
const { x, y } = getHandlePosition(node, handle, handle.position, true)
const distance = Math.sqrt((x - position.x) ** 2 + (y - position.y) ** 2)
if (distance > connectionRadius) {
continue
}
if (distance < minDistance) {
closestHandles = [{ ...handle, x, y }]
minDistance = distance
} else if (distance === minDistance) {
// when multiple handles are on the same distance we collect all of them
closestHandles.push({ ...handle, x, y })
}
}
}
if (!closestHandles.length) {
return { handle: null, validHandleResult: defaultValidHandleResult() }
return null
}
if (closestHandles.length === 1) {
return closestHandles[0]
// when multiple handles overlay each other we prefer the opposite handle
if (closestHandles.length > 1) {
const oppositeHandleType = fromHandle.type === 'source' ? 'target' : 'source'
return closestHandles.find((handle) => handle.type === oppositeHandleType) ?? closestHandles[0]
}
const hasValidHandle = closestHandles.some(({ validHandleResult }) => validHandleResult.isValid)
const hasTargetHandle = closestHandles.some(({ handle }) => handle.type === 'target')
// if multiple handles are layout on top of each other we prefer the one with type = target and the one that is valid
return (
closestHandles.find(({ handle, validHandleResult }) =>
hasTargetHandle ? handle.type === 'target' : hasValidHandle ? validHandleResult.isValid : true,
) || closestHandles[0]
)
return closestHandles[0]
}
// checks if and returns connection in fom of an object { source: 123, target: 312 }
// checks if and returns connection in form of an object { source: 123, target: 312 }
export function isValidHandle(
event: MouseEvent | TouchEvent,
handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'> | null,
connectionMode: ConnectionMode,
fromNodeId: string,
fromHandleId: string | null,
fromType: HandleType,
isValidConnection: ValidConnectionFunc,
doc: Document | ShadowRoot,
{
handle,
connectionMode,
fromNodeId,
fromHandleId,
fromType,
doc,
lib,
flowId,
isValidConnection = alwaysValid,
}: IsValidParams,
edges: GraphEdge[],
nodes: GraphNode[],
findNode: Actions['findNode'],
) {
const isTarget = fromType === 'target'
const handleDomNode = handle
? doc.querySelector(`.${lib}-flow__handle[data-id="${flowId}-${handle?.nodeId}-${handle?.id}-${handle?.type}"]`)
: null
const handleDomNode = doc.querySelector(`.vue-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]`)
const { x, y } = getEventPosition(event)
const elementBelow = doc.elementFromPoint(x, y)
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 = elementBelow?.classList.contains('vue-flow__handle') ? elementBelow : handleDomNode
const handleToCheck = handleBelow?.classList.contains(`${lib}-flow__handle`) ? handleBelow : handleDomNode
const result = defaultValidHandleResult()
const result: Result = {
handleDomNode: handleToCheck,
isValid: false,
connection: null,
toHandle: null,
}
if (handleToCheck) {
result.handleDomNode = handleToCheck
const handleType = getHandleType(undefined, handleToCheck)
const handleNodeId = handleToCheck.getAttribute('data-nodeid')!
const handleNodeId = handleToCheck.getAttribute('data-nodeid')
const handleId = handleToCheck.getAttribute('data-handleid')
const connectable = handleToCheck.classList.contains('connectable')
const connectableEnd = handleToCheck.classList.contains('connectableend')
if (!handleNodeId || !handleType) {
return result
}
const connection: Connection = {
source: isTarget ? handleNodeId : fromNodeId,
sourceHandle: isTarget ? handleId : fromHandleId,
@@ -185,7 +179,6 @@ export function isValidHandle(
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 &&
@@ -193,54 +186,21 @@ export function isValidHandle(
? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target')
: handleNodeId !== fromNodeId || handleId !== fromHandleId)
if (isValid) {
result.isValid = isValidConnection(connection, {
edges,
result.isValid =
isValid &&
isValidConnection(connection, {
nodes,
sourceNode: findNode(connection.source)!,
targetNode: findNode(connection.target)!,
edges,
sourceNode: findNode(fromNodeId)!,
targetNode: findNode(handleNodeId)!,
})
result.endHandle = {
nodeId: handleNodeId,
handleId,
type: handleType as HandleType,
position: result.isValid ? (handleToCheck.getAttribute('data-handlepos') as Position) : null,
}
}
result.toHandle = handle
}
return result
}
interface GetHandleLookupParams {
nodes: GraphNode[]
nodeId: string
handleId: string | null
handleType: string
}
export function getHandleLookup({ nodes, nodeId, handleId, handleType }: GetHandleLookupParams) {
const handleLookup: ConnectionHandle[] = []
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
const { handleBounds } = node
let sourceHandles: ConnectionHandle[] = []
let targetHandles: ConnectionHandle[] = []
if (handleBounds) {
sourceHandles = getHandles(node, handleBounds, 'source', `${nodeId}-${handleId}-${handleType}`)
targetHandles = getHandles(node, handleBounds, 'target', `${nodeId}-${handleId}-${handleType}`)
}
handleLookup.push(...sourceHandles, ...targetHandles)
}
return handleLookup
}
export function getHandleType(edgeUpdaterType: HandleType | undefined, handleDomNode: Element | null): HandleType | null {
if (edgeUpdaterType) {
return edgeUpdaterType
@@ -253,7 +213,7 @@ export function getHandleType(edgeUpdaterType: HandleType | undefined, handleDom
return null
}
export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleValid: boolean) {
export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleValid: boolean | null) {
let connectionStatus: ConnectionStatus | null = null
if (isHandleValid) {
@@ -264,3 +224,44 @@ export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleV
return connectionStatus
}
export function isConnectionValid(isInsideConnectionRadius: boolean, isHandleValid: boolean) {
let isValid: boolean | null = null
if (isHandleValid) {
isValid = true
} else if (isInsideConnectionRadius && !isHandleValid) {
isValid = false
}
return isValid
}
export function getHandle(
nodeId: string,
handleType: HandleType,
handleId: string | null,
nodeLookup: NodeLookup,
connectionMode: ConnectionMode,
withAbsolutePosition = false,
): HandleElement | null {
const node = nodeLookup.get(nodeId)
if (!node) {
return null
}
const handles =
connectionMode === ConnectionMode.Strict
? node.handleBounds?.[handleType]
: [...(node.handleBounds?.source ?? []), ...(node.handleBounds?.target ?? [])]
const handle = (handleId ? handles?.find((h) => h.id === handleId) : handles?.[0]) ?? null
return handle && withAbsolutePosition ? { ...handle, ...getHandlePosition(node, handle, handle.position, true) } : handle
}
export const oppositePosition = {
[Position.Left]: Position.Right,
[Position.Right]: Position.Left,
[Position.Top]: Position.Bottom,
[Position.Bottom]: Position.Top,
}

View File

@@ -1,15 +1,15 @@
import type { Ref } from 'vue'
import { nextTick } from 'vue'
import type { Actions, GraphNode, HandleElement, Position } from '../types'
import type { Actions, GraphNode, HandleElement, HandleType, Position } from '../types'
import { getDimensions } from '.'
export function getHandleBounds(
selector: string,
type: HandleType,
nodeElement: HTMLDivElement,
nodeBounds: DOMRect,
zoom: number,
): HandleElement[] {
const handles = nodeElement.querySelectorAll(`.vue-flow__handle${selector}`)
const handles = nodeElement.querySelectorAll(`.vue-flow__handle.${type}`)
const handlesArray = Array.from(handles) as HTMLDivElement[]
@@ -19,6 +19,8 @@ export function getHandleBounds(
return {
id: handle.getAttribute('data-handleid'),
position: handle.getAttribute('data-handlepos') as unknown as Position,
nodeId: handle.getAttribute('data-nodeid') as string,
type,
x: (handleBounds.left - nodeBounds.left) / zoom,
y: (handleBounds.top - nodeBounds.top) / zoom,
...getDimensions(handle),

View File

@@ -50,13 +50,13 @@ describe('Check if edges are updatable', () => {
view: win,
})
.trigger('mousemove', {
clientX: x + 5,
clientY: y + 5,
clientX: x,
clientY: y,
force: true,
})
.trigger('mouseup', {
clientX: x + 5,
clientY: y + 5,
clientX: x,
clientY: y,
force: true,
view: win,
})

View File

@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"scripts": {
"test": "cypress run --component",
"test": "cypress run --component --browser chrome",
"open": "cypress open",
"lint": "eslint --ext .js,.ts ./"
},