docs(examples): helper lines (#1915)

feat: add optional chaining to prevent types errors and add an example of helper lines (#1904)

* docs: code errors in "Updating Node Data" (#1900)

fix(docs): Example of fixing code errors in "Updating Node Data"

* fix: add optional chaining to prevent errors in link filtering

* feat: add an example of helper lines

* refactor: In the HelperLines example, replace the observer with dimensions

---------

Co-authored-by: Charles Lee <114982593+rookie-orange@users.noreply.github.com>
Co-authored-by: J <gongjie0422@163.com>
This commit is contained in:
Braks
2025-08-07 15:46:48 +02:00
committed by GitHub
parent 4bc9acc1e8
commit 5dc276af52
9 changed files with 359 additions and 1 deletions

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { GraphNode, Node, NodeChange } from '@vue-flow/core'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { ref } from 'vue'
import HelperLines from './HelperLines.vue'
import { getHelperLines } from './utils'
import { initialNodes } from './initialElements'
const { applyNodeChanges } = useVueFlow()
const nodes = ref<Node[]>(initialNodes)
const helperLineHorizontal = ref<number | undefined>(undefined)
const helperLineVertical = ref<number | undefined>(undefined)
function updateHelperLines(changes: NodeChange[], nodes: GraphNode[]) {
helperLineHorizontal.value = undefined
helperLineVertical.value = undefined
if (changes.length === 1 && changes[0].type === 'position' && changes[0].dragging && changes[0].position) {
const helperLines = getHelperLines(changes[0], nodes)
// if we have a helper line, we snap the node to the helper line position
// this is being done by manipulating the node position inside the change object
changes[0].position.x = helperLines.snapPosition.x ?? changes[0].position.x
changes[0].position.y = helperLines.snapPosition.y ?? changes[0].position.y
// if helper lines are returned, we set them so that they can be displayed
helperLineHorizontal.value = helperLines.horizontal
helperLineVertical.value = helperLines.vertical
}
return changes
}
function onNodesChange(changes: NodeChange[]) {
const updatedChanges = updateHelperLines(changes, nodes.value as GraphNode[])
nodes.value = applyNodeChanges(updatedChanges)
}
</script>
<template>
<VueFlow :nodes="nodes" fit-view-on-init @nodes-change="onNodesChange">
<HelperLines :horizontal="helperLineHorizontal" :vertical="helperLineVertical" />
</VueFlow>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { useVueFlow } from '@vue-flow/core'
import { computed, ref, watch } from 'vue'
interface HelperLinesProps {
horizontal?: number
vertical?: number
}
const props = defineProps<HelperLinesProps>()
const horizontal = computed(() => props.horizontal)
const vertical = computed(() => props.vertical)
const { viewport, dimensions } = useVueFlow()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const width = computed(() => dimensions.value.width)
const height = computed(() => dimensions.value.height)
const x = computed(() => viewport.value.x)
const y = computed(() => viewport.value.y)
const zoom = computed(() => viewport.value.zoom)
function updateCanvasHelperLines() {
const canvas = canvasRef.value
const ctx = canvas?.getContext('2d')
if (!ctx || !canvas) {
return
}
const dpi = window.devicePixelRatio
canvas.width = width.value * dpi
canvas.height = height.value * dpi
ctx.scale(dpi, dpi)
ctx.clearRect(0, 0, width.value, height.value)
ctx.strokeStyle = '#00AF79'
if (typeof vertical.value === 'number') {
ctx.moveTo(vertical.value * zoom.value + x.value, 0)
ctx.lineTo(vertical.value * zoom.value + x.value, height.value)
ctx.stroke()
}
if (typeof horizontal.value === 'number') {
ctx.moveTo(0, horizontal.value * zoom.value + y.value)
ctx.lineTo(width.value, horizontal.value * zoom.value + y.value)
ctx.stroke()
}
}
watch([width, height, x, y, zoom, horizontal, vertical], () => updateCanvasHelperLines(), { immediate: true, deep: true })
</script>
<template>
<canvas ref="canvasRef" class="vue-flow__canvas" />
</template>
<style scoped>
.vue-flow__canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,5 @@
export { default as HelperLinesApp } from './App.vue?raw'
export { default as HelperLinesUtils } from './utils.ts?raw'
export { default as HelperLinesInitialElements } from './initialElements.ts?raw'
export { default as HelperLinesStyle } from './style.css?inline'
export { default as HelperLinesComponent } from './HelperLines.vue?raw'

View File

@@ -0,0 +1,36 @@
import type { Edge, Node } from '@vue-flow/core'
export const initialNodes: Node[] = [
{
id: '1',
position: { x: 0, y: 0 },
style: { width: '200px', height: '100px', backgroundColor: '#D6E4F0' },
data: { label: 'Move me around' },
},
{
id: '2',
position: { x: 400, y: 300 },
style: { width: '220px', height: '400px', backgroundColor: '#5CD882' },
data: { label: 'Move me around' },
},
{
id: '3',
position: { x: -55, y: 220 },
style: { width: '125px', height: '220px', backgroundColor: '#F6E5E5' },
data: { label: 'Move me around' },
},
{
id: '4',
position: { x: 250, y: -200 },
style: { width: '180px', height: '180px', backgroundColor: '#FF0071' },
data: { label: 'Move me around' },
},
{
id: '5',
position: { x: -120, y: 500 },
style: { width: '300px', height: '120px', backgroundColor: '#FBC2EB' },
data: { label: 'Move me around' },
},
]
export const initialEdges: Edge[] = []

View File

@@ -0,0 +1,14 @@
.vue-flow__node {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding: 10px;
font-size: 12px;
font-weight: 700;
flex-direction: column;
}
.vue-flow__handle {
visibility: hidden;
}

View File

@@ -0,0 +1,163 @@
import type { GraphNode, NodePositionChange, XYPosition } from '@vue-flow/core'
interface GetHelperLinesResult {
horizontal?: number
vertical?: number
snapPosition: Partial<XYPosition>
}
// this utility function can be called with a position change (inside onNodesChange)
// it checks all other nodes and calculated the helper line positions and the position where the current node should snap to
export function getHelperLines(change: NodePositionChange, nodes: GraphNode[], distance = 5): GetHelperLinesResult {
const defaultResult = {
horizontal: undefined,
vertical: undefined,
snapPosition: { x: undefined, y: undefined },
}
const nodeA = nodes.find((node) => node.id === change.id)
if (!nodeA || !change.position) {
return defaultResult
}
const nodeABounds = {
left: change.position.x,
right: change.position.x + ((nodeA.dimensions.width as number) ?? 0),
top: change.position.y,
bottom: change.position.y + ((nodeA.dimensions.height as number) ?? 0),
width: (nodeA.dimensions.width as number) ?? 0,
height: (nodeA.dimensions.height as number) ?? 0,
}
let horizontalDistance = distance
let verticalDistance = distance
return nodes
.filter((node) => node.id !== nodeA.id)
.reduce<GetHelperLinesResult>((result, nodeB) => {
const nodeBBounds = {
left: nodeB.position.x,
right: nodeB.position.x + ((nodeB.dimensions.width as number) ?? 0),
top: nodeB.position.y,
bottom: nodeB.position.y + ((nodeB.dimensions.height as number) ?? 0),
width: nodeB.width ?? 0,
height: nodeB.height ?? 0,
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceLeftLeft = Math.abs(nodeABounds.left - nodeBBounds.left)
if (distanceLeftLeft < verticalDistance) {
result.snapPosition.x = nodeBBounds.left
result.vertical = nodeBBounds.left
verticalDistance = distanceLeftLeft
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceRightRight = Math.abs(nodeABounds.right - nodeBBounds.right)
if (distanceRightRight < verticalDistance) {
result.snapPosition.x = nodeBBounds.right - nodeABounds.width
result.vertical = nodeBBounds.right
verticalDistance = distanceRightRight
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceLeftRight = Math.abs(nodeABounds.left - nodeBBounds.right)
if (distanceLeftRight < verticalDistance) {
result.snapPosition.x = nodeBBounds.right
result.vertical = nodeBBounds.right
verticalDistance = distanceLeftRight
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceRightLeft = Math.abs(nodeABounds.right - nodeBBounds.left)
if (distanceRightLeft < verticalDistance) {
result.snapPosition.x = nodeBBounds.left - nodeABounds.width
result.vertical = nodeBBounds.left
verticalDistance = distanceRightLeft
}
// |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾|
// | A | | B |
// |___________| |___________|
const distanceTopTop = Math.abs(nodeABounds.top - nodeBBounds.top)
if (distanceTopTop < horizontalDistance) {
result.snapPosition.y = nodeBBounds.top
result.horizontal = nodeBBounds.top
horizontalDistance = distanceTopTop
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|_________________
// | |
// | B |
// |___________|
const distanceBottomTop = Math.abs(nodeABounds.bottom - nodeBBounds.top)
if (distanceBottomTop < horizontalDistance) {
result.snapPosition.y = nodeBBounds.top - nodeABounds.height
result.horizontal = nodeBBounds.top
horizontalDistance = distanceBottomTop
}
// |‾‾‾‾‾‾‾‾‾‾‾| |‾‾‾‾‾‾‾‾‾‾‾|
// | A | | B |
// |___________|_____|___________|
const distanceBottomBottom = Math.abs(nodeABounds.bottom - nodeBBounds.bottom)
if (distanceBottomBottom < horizontalDistance) {
result.snapPosition.y = nodeBBounds.bottom - nodeABounds.height
result.horizontal = nodeBBounds.bottom
horizontalDistance = distanceBottomBottom
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// | |
// |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
// | A |
// |___________|
const distanceTopBottom = Math.abs(nodeABounds.top - nodeBBounds.bottom)
if (distanceTopBottom < horizontalDistance) {
result.snapPosition.y = nodeBBounds.bottom
result.horizontal = nodeBBounds.bottom
horizontalDistance = distanceTopBottom
}
return result
}, defaultResult)
}

View File

@@ -24,6 +24,13 @@ import { LoopbackApp, LoopbackCSS, LoopbackEdge } from './loopback'
import { MathApp, MathCSS, MathElements, MathIcon, MathOperatorNode, MathResultNode, MathValueNode } from './math'
import { ConfirmApp, ConfirmDialog, useDialog } from './confirm-delete'
import { EdgeMarkersApp, EdgeMarkersCSS, EdgeMarkersEdge, EdgeMarkersMarker } from './edge-markers'
import {
HelperLinesApp,
HelperLinesComponent,
HelperLinesInitialElements,
HelperLinesStyle,
HelperLinesUtils,
} from './helper-lines'
export const exampleImports = {
basic: {
@@ -176,4 +183,11 @@ export const exampleImports = {
'CustomMarker.vue': EdgeMarkersMarker,
'style.css': EdgeMarkersCSS,
},
helperLines: {
'App.vue': HelperLinesApp,
'HelperLines.vue': HelperLinesComponent,
'utils.ts': HelperLinesUtils,
'initialElements.ts': HelperLinesInitialElements,
'style.css': HelperLinesStyle,
},
}

View File

@@ -31,7 +31,7 @@ function typedocSidebarEntries() {
if (module === 'variables') {
children = children.filter((child) => {
return child.link.includes('default')
return child.link?.includes('default')
})
}
@@ -218,6 +218,7 @@ export default defineConfigWithTheme<DefaultTheme.Config>({
{ text: 'Viewport Transition', link: '/examples/transition' },
{ text: 'Teleport Nodes', link: '/examples/teleport' },
{ text: 'Stress Test', link: '/examples/stress' },
{ text: 'Helper Lines', link: '/examples/helper-lines' },
],
},
{

View File

@@ -0,0 +1,7 @@
# Helper Lines
Visual guides and snapping for nodes while dragging.
<div class="mt-6">
<Repl example="helperLines"></Repl>
</div>