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 uses: actions/cache@v4
with: with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 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: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GraphNode } from '@vue-flow/core' import type { EdgeProps } from '@vue-flow/core'
import { getStraightPath } from '@vue-flow/core' import { BaseEdge, getStraightPath } from '@vue-flow/core'
import { computed } from 'vue' import { computed } from 'vue'
import { getEdgeParams } from './utils' 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)) const edgeParams = computed(() => getEdgeParams(props.sourceNode, props.targetNode))
@@ -20,5 +20,5 @@ const edgePath = computed(() =>
</script> </script>
<template> <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> </template>

View File

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

View File

@@ -8,7 +8,7 @@ import {
ErrorCode, ErrorCode,
VueFlowError, VueFlowError,
elementSelectionKeys, elementSelectionKeys,
getHandle, getEdgeHandle,
getHandlePosition, getHandlePosition,
getMarkerId, getMarkerId,
} from '../../utils' } from '../../utils'
@@ -150,7 +150,7 @@ const EdgeWrapper = defineComponent({
sourceNodeHandles = [...(sourceNode.handleBounds.source || []), ...(sourceNode.handleBounds.target || [])] sourceNodeHandles = [...(sourceNode.handleBounds.source || []), ...(sourceNode.handleBounds.target || [])]
} }
const sourceHandle = getHandle(sourceNodeHandles, edge.value.sourceHandle) const sourceHandle = getEdgeHandle(sourceNodeHandles, edge.value.sourceHandle)
let targetNodeHandles let targetNodeHandles
if (connectionMode.value === ConnectionMode.Strict) { if (connectionMode.value === ConnectionMode.Strict) {
@@ -159,7 +159,7 @@ const EdgeWrapper = defineComponent({
targetNodeHandles = [...(targetNode.handleBounds.target || []), ...(targetNode.handleBounds.source || [])] 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 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 isValidConnection = toRef(() => props.isValidConnection ?? null)
const { const {
id: flowId,
connectionStartHandle, connectionStartHandle,
connectionClickStartHandle, connectionClickStartHandle,
connectionEndHandle, connectionEndHandle,
@@ -39,17 +40,17 @@ const isConnectableEnd = toRef(() => (typeof connectableEnd !== 'undefined' ? co
const isConnecting = toRef( const isConnecting = toRef(
() => () =>
(connectionStartHandle.value?.nodeId === nodeId && (connectionStartHandle.value?.nodeId === nodeId &&
connectionStartHandle.value?.handleId === handleId && connectionStartHandle.value?.id === handleId &&
connectionStartHandle.value?.type === type.value) || connectionStartHandle.value?.type === type.value) ||
(connectionEndHandle.value?.nodeId === nodeId && (connectionEndHandle.value?.nodeId === nodeId &&
connectionEndHandle.value?.handleId === handleId && connectionEndHandle.value?.id === handleId &&
connectionEndHandle.value?.type === type.value), connectionEndHandle.value?.type === type.value),
) )
const isClickConnecting = toRef( const isClickConnecting = toRef(
() => () =>
connectionClickStartHandle.value?.nodeId === nodeId && connectionClickStartHandle.value?.nodeId === nodeId &&
connectionClickStartHandle.value?.handleId === handleId && connectionClickStartHandle.value?.id === handleId &&
connectionClickStartHandle.value?.type === type.value, connectionClickStartHandle.value?.type === type.value,
) )
@@ -127,6 +128,8 @@ onMounted(() => {
position, position,
x: (handleBounds.left - nodeBounds.left) / zoom, x: (handleBounds.left - nodeBounds.left) / zoom,
y: (handleBounds.top - nodeBounds.top) / zoom, y: (handleBounds.top - nodeBounds.top) / zoom,
type: type.value,
nodeId,
...getDimensions(handle.value), ...getDimensions(handle.value),
} }
@@ -177,7 +180,7 @@ export default {
<template> <template>
<div <div
ref="handle" ref="handle"
:data-id="`${nodeId}-${handleId}-${type}`" :data-id="`${flowId}-${nodeId}-${handleId}-${type}`"
:data-handleid="handleId" :data-handleid="handleId"
:data-nodeid="nodeId" :data-nodeid="nodeId"
:data-handlepos="position" :data-handlepos="position"

View File

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

View File

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

View File

@@ -1,13 +1,16 @@
import type { Dimensions, Position, XYPosition } from './flow' 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 { GraphEdge } from './edge'
import type { GraphNode } from './node' import type { GraphNode } from './node'
import type { NodeLookup } from './store'
export type HandleType = 'source' | 'target' export type HandleType = 'source' | 'target'
export interface HandleElement extends XYPosition, Dimensions { export interface HandleElement extends XYPosition, Dimensions {
id?: string | null id?: string | null
position: Position position: Position
type: HandleType
nodeId: string
} }
export interface ConnectionHandle extends XYPosition { export interface ConnectionHandle extends XYPosition {
@@ -16,18 +19,11 @@ export interface ConnectionHandle extends XYPosition {
nodeId: string nodeId: string
} }
export interface ValidHandleResult {
endHandle: ConnectingHandle | null
handleDomNode: Element | null
isValid: boolean
connection: Connection
}
export interface ConnectingHandle { export interface ConnectingHandle {
nodeId: string nodeId: string
type: HandleType type: HandleType
handleId?: string | null id?: string | null
position?: Position | null position: Position
} }
/** A valid connection function can determine if an attempted connection is valid or not, i.e. abort creating a new edge */ /** 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 */ /** Can this handle be used to *end* a connection */
connectableEnd?: boolean 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, node: GraphNode,
handle: HandleElement | null, handle: HandleElement | null,
fallbackPosition: Position = Position.Left, fallbackPosition: Position = Position.Left,
center = false,
): XYPosition { ): XYPosition {
const x = (handle?.x ?? 0) + node.computedPosition.x const x = (handle?.x ?? 0) + node.computedPosition.x
const y = (handle?.y ?? 0) + node.computedPosition.y const y = (handle?.y ?? 0) + node.computedPosition.y
const { width, height } = handle ?? getNodeDimensions(node) const { width, height } = handle ?? getNodeDimensions(node)
if (center) {
return { x: x + width / 2, y: y + height / 2 }
}
const position = handle?.position ?? fallbackPosition const position = handle?.position ?? fallbackPosition
switch (position) { switch (position) {
case Position.Top: case Position.Top:
return { return { x: x + width / 2, y }
x: x + width / 2,
y,
}
case Position.Right: case Position.Right:
return { return { x: x + width, y: y + height / 2 }
x: x + width,
y: y + height / 2,
}
case Position.Bottom: case Position.Bottom:
return { return { x: x + width / 2, y: y + height }
x: x + width / 2,
y: y + height,
}
case Position.Left: case Position.Left:
return { return { x, y: y + height / 2 }
x,
y: y + height / 2,
}
} }
} }
export function getHandle(bounds: HandleElement[] = [], handleId?: string | null): HandleElement | null { export function getEdgeHandle(bounds: HandleElement[] | undefined, handleId?: string | null): HandleElement | null {
if (!bounds.length) { if (!bounds) {
return null 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 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 { import type {
Actions, Actions,
Connection, Connection,
@@ -6,23 +6,17 @@ import type {
ConnectionStatus, ConnectionStatus,
GraphEdge, GraphEdge,
GraphNode, GraphNode,
HandleElement,
HandleType, HandleType,
IsValidParams,
NodeHandleBounds, NodeHandleBounds,
Position, NodeLookup,
ValidConnectionFunc, Result,
ValidHandleResult,
XYPosition, XYPosition,
} from '../types' } from '../types'
import { getEventPosition, getHandlePosition } from '.' import { getEventPosition, getHandlePosition, getOverlappingArea, nodeToRect } from '.'
function defaultValidHandleResult(): ValidHandleResult { const alwaysValid = () => true
return {
handleDomNode: null,
isValid: false,
connection: { source: '', target: '', sourceHandle: null, targetHandle: null },
endHandle: null,
}
}
export function resetRecentHandle(handleDomNode: Element): void { export function resetRecentHandle(handleDomNode: Element): void {
handleDomNode?.classList.remove('valid', 'connecting', 'vue-flow__handle-valid', 'vue-flow__handle-connecting') handleDomNode?.classList.remove('valid', 'connecting', 'vue-flow__handle-valid', 'vue-flow__handle-connecting')
@@ -55,126 +49,126 @@ export function getHandles(
return connectionHandles return connectionHandles
} }
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,
}
for (const node of nodeLookup.values()) {
if (getOverlappingArea(rect, nodeToRect(node)) > 0) {
nodes.push(node)
}
}
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( export function getClosestHandle(
event: MouseEvent | TouchEvent, position: XYPosition,
doc: Document | ShadowRoot,
pos: XYPosition,
connectionRadius: number, connectionRadius: number,
handles: ConnectionHandle[], nodeLookup: NodeLookup,
validator: (handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'>) => ValidHandleResult, fromHandle: { nodeId: string; type: HandleType; id?: string | null },
) { ): HandleElement | null {
// we always want to prioritize the handle below the mouse cursor over the closest distance handle, let closestHandles: HandleElement[] = []
// 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)
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,
}
}
}
}
// 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 }[] = []
let minDistance = Number.POSITIVE_INFINITY let minDistance = Number.POSITIVE_INFINITY
for (const handle of handles) { const closeNodes = getNodesWithinDistance(position, nodeLookup, connectionRadius + ADDITIONAL_DISTANCE)
const distance = Math.sqrt((handle.x - pos.x) ** 2 + (handle.y - pos.y) ** 2)
if (distance <= connectionRadius) { for (const node of closeNodes) {
const validHandleResult = validator(handle) const allHandles = [...(node.handleBounds?.source ?? []), ...(node.handleBounds?.target ?? [])]
if (distance <= minDistance) { for (const handle of allHandles) {
if (distance < minDistance) { // if the handle is the same as the fromHandle we skip it
closestHandles = [{ handle, validHandleResult }] if (fromHandle.nodeId === handle.nodeId && fromHandle.type === handle.type && fromHandle.id === handle.id) {
} else if (distance === minDistance) { continue
// when multiple handles are on the same distance we collect all of them
closestHandles.push({
handle,
validHandleResult,
})
} }
// 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 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) { if (!closestHandles.length) {
return { handle: null, validHandleResult: defaultValidHandleResult() } return null
}
// 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]
} }
if (closestHandles.length === 1) {
return closestHandles[0] return closestHandles[0]
} }
const hasValidHandle = closestHandles.some(({ validHandleResult }) => validHandleResult.isValid) // checks if and returns connection in form of an object { source: 123, target: 312 }
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]
)
}
// checks if and returns connection in fom of an object { source: 123, target: 312 }
export function isValidHandle( export function isValidHandle(
event: MouseEvent | TouchEvent, event: MouseEvent | TouchEvent,
handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'> | null, {
connectionMode: ConnectionMode, handle,
fromNodeId: string, connectionMode,
fromHandleId: string | null, fromNodeId,
fromType: HandleType, fromHandleId,
isValidConnection: ValidConnectionFunc, fromType,
doc: Document | ShadowRoot, doc,
lib,
flowId,
isValidConnection = alwaysValid,
}: IsValidParams,
edges: GraphEdge[], edges: GraphEdge[],
nodes: GraphNode[], nodes: GraphNode[],
findNode: Actions['findNode'], findNode: Actions['findNode'],
) { ) {
const isTarget = fromType === 'target' 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 { 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, // 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 // 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) { if (handleToCheck) {
result.handleDomNode = handleToCheck
const handleType = getHandleType(undefined, 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 handleId = handleToCheck.getAttribute('data-handleid')
const connectable = handleToCheck.classList.contains('connectable') const connectable = handleToCheck.classList.contains('connectable')
const connectableEnd = handleToCheck.classList.contains('connectableend') const connectableEnd = handleToCheck.classList.contains('connectableend')
if (!handleNodeId || !handleType) {
return result
}
const connection: Connection = { const connection: Connection = {
source: isTarget ? handleNodeId : fromNodeId, source: isTarget ? handleNodeId : fromNodeId,
sourceHandle: isTarget ? handleId : fromHandleId, sourceHandle: isTarget ? handleId : fromHandleId,
@@ -185,7 +179,6 @@ export function isValidHandle(
result.connection = connection result.connection = connection
const isConnectable = connectable && connectableEnd const isConnectable = connectable && connectableEnd
// in strict mode we don't allow target to target or source to source connections // in strict mode we don't allow target to target or source to source connections
const isValid = const isValid =
isConnectable && isConnectable &&
@@ -193,54 +186,21 @@ export function isValidHandle(
? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target') ? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target')
: handleNodeId !== fromNodeId || handleId !== fromHandleId) : handleNodeId !== fromNodeId || handleId !== fromHandleId)
if (isValid) { result.isValid =
result.isValid = isValidConnection(connection, { isValid &&
edges, isValidConnection(connection, {
nodes, nodes,
sourceNode: findNode(connection.source)!, edges,
targetNode: findNode(connection.target)!, sourceNode: findNode(fromNodeId)!,
targetNode: findNode(handleNodeId)!,
}) })
result.endHandle = { result.toHandle = handle
nodeId: handleNodeId,
handleId,
type: handleType as HandleType,
position: result.isValid ? (handleToCheck.getAttribute('data-handlepos') as Position) : null,
}
}
} }
return result 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 { export function getHandleType(edgeUpdaterType: HandleType | undefined, handleDomNode: Element | null): HandleType | null {
if (edgeUpdaterType) { if (edgeUpdaterType) {
return edgeUpdaterType return edgeUpdaterType
@@ -253,7 +213,7 @@ export function getHandleType(edgeUpdaterType: HandleType | undefined, handleDom
return null return null
} }
export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleValid: boolean) { export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleValid: boolean | null) {
let connectionStatus: ConnectionStatus | null = null let connectionStatus: ConnectionStatus | null = null
if (isHandleValid) { if (isHandleValid) {
@@ -264,3 +224,44 @@ export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleV
return connectionStatus 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 type { Ref } from 'vue'
import { nextTick } 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 '.' import { getDimensions } from '.'
export function getHandleBounds( export function getHandleBounds(
selector: string, type: HandleType,
nodeElement: HTMLDivElement, nodeElement: HTMLDivElement,
nodeBounds: DOMRect, nodeBounds: DOMRect,
zoom: number, zoom: number,
): HandleElement[] { ): HandleElement[] {
const handles = nodeElement.querySelectorAll(`.vue-flow__handle${selector}`) const handles = nodeElement.querySelectorAll(`.vue-flow__handle.${type}`)
const handlesArray = Array.from(handles) as HTMLDivElement[] const handlesArray = Array.from(handles) as HTMLDivElement[]
@@ -19,6 +19,8 @@ export function getHandleBounds(
return { return {
id: handle.getAttribute('data-handleid'), id: handle.getAttribute('data-handleid'),
position: handle.getAttribute('data-handlepos') as unknown as Position, position: handle.getAttribute('data-handlepos') as unknown as Position,
nodeId: handle.getAttribute('data-nodeid') as string,
type,
x: (handleBounds.left - nodeBounds.left) / zoom, x: (handleBounds.left - nodeBounds.left) / zoom,
y: (handleBounds.top - nodeBounds.top) / zoom, y: (handleBounds.top - nodeBounds.top) / zoom,
...getDimensions(handle), ...getDimensions(handle),

View File

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

View File

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