docs: snappable connection line example
This commit is contained in:
@@ -17,6 +17,7 @@ import { HorizontalApp, HorizontalElements } from './horizontal'
|
|||||||
import { TeleportApp, TeleportCSS, TeleportSidebar, TeleportableNode, TeleportableUseTransition } from './teleport'
|
import { TeleportApp, TeleportCSS, TeleportSidebar, TeleportableNode, TeleportableUseTransition } from './teleport'
|
||||||
import { TransitionApp, TransitionCSS, TransitionEdge } from './transition'
|
import { TransitionApp, TransitionCSS, TransitionEdge } from './transition'
|
||||||
import { IntersectionApp, IntersectionCSS, IntersectionElements } from './intersection'
|
import { IntersectionApp, IntersectionCSS, IntersectionElements } from './intersection'
|
||||||
|
import { SnapToHandleApp, SnappableConnectionLine } from './snap-to-handle'
|
||||||
|
|
||||||
export const exampleImports = {
|
export const exampleImports = {
|
||||||
basic: {
|
basic: {
|
||||||
@@ -114,4 +115,8 @@ export const exampleImports = {
|
|||||||
'initial-elements.js': IntersectionElements,
|
'initial-elements.js': IntersectionElements,
|
||||||
'style.css': IntersectionCSS,
|
'style.css': IntersectionCSS,
|
||||||
},
|
},
|
||||||
|
snappable: {
|
||||||
|
'App.vue': SnapToHandleApp,
|
||||||
|
'SnappableConnectionLine.vue': SnappableConnectionLine,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
import { VueFlow } from '@vue-flow/core'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ConnectionLine from './SnappableConnectionLine.vue'
|
||||||
|
|
||||||
|
const elements = ref([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Node 1',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
label: 'Node 2',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
label: 'Node 3',
|
||||||
|
position: { x: 200, y: 0 },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VueFlow v-model="elements" auto-connect fit-view-on-init>
|
||||||
|
<template #connection-line="{ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition }">
|
||||||
|
<ConnectionLine
|
||||||
|
:source-x="sourceX"
|
||||||
|
:source-y="sourceY"
|
||||||
|
:target-x="targetX"
|
||||||
|
:target-y="targetY"
|
||||||
|
:source-position="sourcePosition"
|
||||||
|
:target-position="targetPosition"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VueFlow>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup>
|
||||||
|
import { connectionExists, getBezierPath, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
sourceX: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
sourceY: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
targetX: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
targetY: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
targetPosition: {
|
||||||
|
type: String,
|
||||||
|
reuire: true,
|
||||||
|
},
|
||||||
|
sourcePosition: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getNodes, connectionStartHandle, onConnectEnd, addEdges, edges } = useVueFlow()
|
||||||
|
|
||||||
|
const closest = reactive({
|
||||||
|
node: null,
|
||||||
|
handle: null,
|
||||||
|
startHandle: connectionStartHandle.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSnap = ref(false)
|
||||||
|
|
||||||
|
const HIGHLIGHT_COLOR = '#f59e0b'
|
||||||
|
|
||||||
|
const SNAP_HIGHLIGHT_COLOR = '#10b981'
|
||||||
|
|
||||||
|
const MIN_DISTANCE = 75
|
||||||
|
|
||||||
|
const SNAP_DISTANCE = 50
|
||||||
|
|
||||||
|
watch([() => props.targetY, () => props.targetX], (_, __, onCleanup) => {
|
||||||
|
const closestNode = getNodes.value.reduce(
|
||||||
|
(res, n) => {
|
||||||
|
if (n.id !== connectionStartHandle.value?.nodeId) {
|
||||||
|
const dx = props.targetX - (n.computedPosition.x + n.dimensions.width / 2)
|
||||||
|
const dy = props.targetY - (n.computedPosition.y + n.dimensions.height / 2)
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
if (d < res.distance && d < MIN_DISTANCE) {
|
||||||
|
res.distance = d
|
||||||
|
res.node = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distance: Number.MAX_VALUE,
|
||||||
|
node: null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!closestNode.node) return
|
||||||
|
|
||||||
|
canSnap.value = closestNode.distance < SNAP_DISTANCE
|
||||||
|
|
||||||
|
const type = connectionStartHandle.value.type === 'source' ? 'target' : 'source'
|
||||||
|
|
||||||
|
const closestHandle = closestNode.node.handleBounds[type]?.reduce((prev, curr) => {
|
||||||
|
const prevDistance = Math.sqrt((prev.x - props.targetX) ** 2 + (prev.y - props.targetY) ** 2)
|
||||||
|
const currDistance = Math.sqrt((curr.x - props.targetX) ** 2 + (curr.y - props.targetY) ** 2)
|
||||||
|
|
||||||
|
return prevDistance < currDistance ? prev : curr
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
connectionExists(
|
||||||
|
{
|
||||||
|
source: connectionStartHandle.value.nodeId,
|
||||||
|
sourceHandle: connectionStartHandle.value.handleId,
|
||||||
|
target: closestNode.node.id,
|
||||||
|
targetHandle: closestHandle.id,
|
||||||
|
},
|
||||||
|
edges.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (closestHandle) {
|
||||||
|
const el = document.querySelector(`[data-handleid='${closestHandle.id}']`)
|
||||||
|
|
||||||
|
const prevStyle = el.style.backgroundColor
|
||||||
|
el.style.backgroundColor = canSnap.value ? SNAP_HIGHLIGHT_COLOR : HIGHLIGHT_COLOR
|
||||||
|
closest.node = closestNode.node
|
||||||
|
closest.handle = closestHandle
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
el.style.backgroundColor = prevStyle
|
||||||
|
closest.node = null
|
||||||
|
closest.handle = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const path = computed(() => getBezierPath(props))
|
||||||
|
|
||||||
|
onConnectEnd(() => {
|
||||||
|
if (closest.startHandle && closest.handle && closest.node) {
|
||||||
|
if (canSnap.value) {
|
||||||
|
addEdges([
|
||||||
|
{
|
||||||
|
sourceHandle: closest.startHandle.handleId,
|
||||||
|
source: closest.startHandle.nodeId,
|
||||||
|
target: closest.node.id,
|
||||||
|
targetHandle: closest.handle.id,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const strokeColor = computed(() => {
|
||||||
|
if (canSnap.value) {
|
||||||
|
return SNAP_HIGHLIGHT_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closest.node) {
|
||||||
|
return HIGHLIGHT_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#222'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<g>
|
||||||
|
<path :d="path[0]" class="vue-flow__connection-path" />
|
||||||
|
<circle :cx="targetX" :cy="targetY" fill="#fff" :stroke="strokeColor" :r="3" :stroke-width="1.5" />
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as SnapToHandleApp } from './App.vue?raw'
|
||||||
|
export { default as SnappableConnectionLine } from './SnappableConnectionLine.vue?raw'
|
||||||
@@ -218,6 +218,7 @@ export default defineConfigWithTheme<DefaultTheme.Config>({
|
|||||||
{ text: 'Updatable Edge', link: '/examples/edges/updatable-edge' },
|
{ text: 'Updatable Edge', link: '/examples/edges/updatable-edge' },
|
||||||
{ text: 'Custom Connection Line', link: '/examples/edges/connection-line' },
|
{ text: 'Custom Connection Line', link: '/examples/edges/connection-line' },
|
||||||
{ text: 'Connection Validation', link: '/examples/edges/validation' },
|
{ text: 'Connection Validation', link: '/examples/edges/validation' },
|
||||||
|
{ text: 'Snap To Handle', link: '/examples/edges/snap-to-handle' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Vendored
+1
@@ -35,6 +35,7 @@ declare module '@vue/runtime-core' {
|
|||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Sidebar: typeof import('./../components/examples/dnd/Sidebar.vue')['default']
|
Sidebar: typeof import('./../components/examples/dnd/Sidebar.vue')['default']
|
||||||
|
SnappableConnectionLine: typeof import('./../components/examples/snap-to-handle/SnappableConnectionLine.vue')['default']
|
||||||
Team: typeof import('./../components/home/Team.vue')['default']
|
Team: typeof import('./../components/home/Team.vue')['default']
|
||||||
TeleportableNode: typeof import('./../components/examples/teleport/TeleportableNode.vue')['default']
|
TeleportableNode: typeof import('./../components/examples/teleport/TeleportableNode.vue')['default']
|
||||||
TransitionEdge: typeof import('./../components/examples/transition/TransitionEdge.vue')['default']
|
TransitionEdge: typeof import('./../components/examples/transition/TransitionEdge.vue')['default']
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Custom Connection Line
|
||||||
|
|
||||||
|
In this example we will create a custom connection line
|
||||||
|
that snaps to the nearest handle when the connection is dropped within distance of a handle.
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<Repl example="snappable"></Repl>
|
||||||
|
</div>
|
||||||
@@ -6,10 +6,6 @@ export const routes: RouterOptions['routes'] = [
|
|||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/overview',
|
redirect: '/overview',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/rgb',
|
|
||||||
component: () => import('./src/RGBFlow/RGBFlow.vue'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/basic',
|
path: '/basic',
|
||||||
component: () => import('./src/Basic/Basic.vue'),
|
component: () => import('./src/Basic/Basic.vue'),
|
||||||
@@ -18,6 +14,10 @@ export const routes: RouterOptions['routes'] = [
|
|||||||
path: '/basic-options-api',
|
path: '/basic-options-api',
|
||||||
component: () => import('./src/Basic/BasicOptionsAPI.vue'),
|
component: () => import('./src/Basic/BasicOptionsAPI.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/snap-handle',
|
||||||
|
component: () => import('./src/SnapHandle/SnapHandleExample.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/custom-connectionline',
|
path: '/custom-connectionline',
|
||||||
component: () => import('./src/CustomConnectionLine/CustomConnectionLine.vue'),
|
component: () => import('./src/CustomConnectionLine/CustomConnectionLine.vue'),
|
||||||
@@ -106,6 +106,10 @@ export const routes: RouterOptions['routes'] = [
|
|||||||
path: '/nesting',
|
path: '/nesting',
|
||||||
component: () => import('./src/Nesting/Nesting.vue'),
|
component: () => import('./src/Nesting/Nesting.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/rgb',
|
||||||
|
component: () => import('./src/RGBFlow/RGBFlow.vue'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { VueFlow } from '@vue-flow/core'
|
||||||
|
import ConnectionLine from './SnappableConnectionLine.vue'
|
||||||
|
|
||||||
|
const elements = ref([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Node 1',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
label: 'Node 2',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
label: 'Node 3',
|
||||||
|
position: { x: 200, y: 0 },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VueFlow v-model="elements" auto-connect fit-view-on-init>
|
||||||
|
<template #connection-line="{ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition }">
|
||||||
|
<ConnectionLine
|
||||||
|
:source-x="sourceX"
|
||||||
|
:source-y="sourceY"
|
||||||
|
:target-x="targetX"
|
||||||
|
:target-y="targetY"
|
||||||
|
:source-position="sourcePosition"
|
||||||
|
:target-position="targetPosition"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VueFlow>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ConnectionLineProps, GraphNode, HandleElement, Position, StartHandle } from '@vue-flow/core'
|
||||||
|
import { getBezierPath, useVueFlow } from '@vue-flow/core'
|
||||||
|
|
||||||
|
interface CustomConnectionLineProps extends ConnectionLineProps {
|
||||||
|
sourceX: number
|
||||||
|
sourceY: number
|
||||||
|
targetX: number
|
||||||
|
targetY: number
|
||||||
|
sourcePosition: Position
|
||||||
|
targetPosition: Position
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClosestElements {
|
||||||
|
node: GraphNode | null
|
||||||
|
handle: HandleElement | null
|
||||||
|
startHandle: StartHandle | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<CustomConnectionLineProps>()
|
||||||
|
|
||||||
|
const { getNodes, connectionStartHandle, onConnectEnd, addEdges } = useVueFlow()
|
||||||
|
|
||||||
|
const closest = reactive<ClosestElements>({
|
||||||
|
node: null,
|
||||||
|
handle: null,
|
||||||
|
startHandle: connectionStartHandle.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSnap = ref(false)
|
||||||
|
|
||||||
|
const HIGHLIGHT_COLOR = '#f59e0b'
|
||||||
|
|
||||||
|
const SNAP_HIGHLIGHT_COLOR = '#10b981'
|
||||||
|
|
||||||
|
const MIN_DISTANCE = 75
|
||||||
|
|
||||||
|
const SNAP_DISTANCE = 50
|
||||||
|
|
||||||
|
watch([() => props.targetY, () => props.targetX], (_, __, onCleanup) => {
|
||||||
|
const closestNode = getNodes.value.reduce(
|
||||||
|
(res, n) => {
|
||||||
|
if (n.id !== connectionStartHandle.value?.nodeId) {
|
||||||
|
const dx = props.targetX - (n.computedPosition.x + n.dimensions.width / 2)
|
||||||
|
const dy = props.targetY - (n.computedPosition.y + n.dimensions.height / 2)
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
if (d < res.distance && d < MIN_DISTANCE) {
|
||||||
|
res.distance = d
|
||||||
|
res.node = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distance: Number.MAX_VALUE,
|
||||||
|
node: null as GraphNode | null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!closestNode.node) return
|
||||||
|
|
||||||
|
canSnap.value = closestNode.distance < SNAP_DISTANCE
|
||||||
|
|
||||||
|
const type = connectionStartHandle.value!.type === 'source' ? 'target' : 'source'
|
||||||
|
|
||||||
|
const closestHandle = closestNode.node.handleBounds[type]?.reduce((prev, curr) => {
|
||||||
|
const prevDistance = Math.sqrt((prev.x - props.targetX) ** 2 + (prev.y - props.targetY) ** 2)
|
||||||
|
const currDistance = Math.sqrt((curr.x - props.targetX) ** 2 + (curr.y - props.targetY) ** 2)
|
||||||
|
|
||||||
|
return prevDistance < currDistance ? prev : curr
|
||||||
|
})
|
||||||
|
|
||||||
|
if (closestHandle) {
|
||||||
|
const el = document.querySelector(`[data-handleid='${closestHandle.id}']`) as HTMLElement
|
||||||
|
|
||||||
|
const prevStyle = el.style.backgroundColor
|
||||||
|
el.style.backgroundColor = canSnap.value ? SNAP_HIGHLIGHT_COLOR : HIGHLIGHT_COLOR
|
||||||
|
closest.node = closestNode.node
|
||||||
|
closest.handle = closestHandle
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
el.style.backgroundColor = prevStyle
|
||||||
|
closest.node = null
|
||||||
|
closest.handle = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const path = computed(() => getBezierPath(props))
|
||||||
|
|
||||||
|
onConnectEnd(() => {
|
||||||
|
if (closest.startHandle && closest.handle && closest.node) {
|
||||||
|
if (canSnap.value) {
|
||||||
|
addEdges([
|
||||||
|
{
|
||||||
|
sourceHandle: closest.startHandle.handleId,
|
||||||
|
source: closest.startHandle.nodeId,
|
||||||
|
target: closest.node.id,
|
||||||
|
targetHandle: closest.handle.id!,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const strokeColor = computed(() => {
|
||||||
|
if (canSnap.value) {
|
||||||
|
return SNAP_HIGHLIGHT_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closest.node) {
|
||||||
|
return HIGHLIGHT_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#222'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<g>
|
||||||
|
<path :d="path[0]" class="vue-flow__connection-path" />
|
||||||
|
<circle :cx="targetX" :cy="targetY" fill="#fff" :stroke="strokeColor" :r="3" :stroke-width="1.5" />
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user