fix(edges): Remove orphaned edges

# What's changed?

* Remove source/targetNode properties from GraphEdge type, the source/target will be fetched with getters if necessary
* Remove edges when they have no existing source or target nodes that can be found in the state
* Add nodes to custom edge test
This commit is contained in:
Braks
2022-04-21 10:47:53 +02:00
parent 9a5b5465b6
commit ee38f4af9b
10 changed files with 111 additions and 73 deletions
@@ -46,6 +46,16 @@ describe('test store state', () => {
it('gets custom edge types', () => { it('gets custom edge types', () => {
store.setState({ store.setState({
nodes: [
{
id: '1',
position: { x: 0, y: 0 },
},
{
id: '2',
position: { x: 50, y: 50 },
},
],
edges: [ edges: [
{ {
id: '1', id: '1',
+19 -17
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { CSSProperties } from 'vue' import { CSSProperties } from 'vue'
import { useHandle, useVueFlow } from '../../composables' import { useHandle, useVueFlow } from '../../composables'
import { ConnectionMode, EdgeComponent, GraphEdge, Position } from '../../types' import { ConnectionMode, EdgeComponent, GraphEdge, GraphNode, Position } from '../../types'
import { getEdgePositions, getHandle, getMarkerId } from '../../utils' import { getEdgePositions, getHandle, getMarkerId } from '../../utils'
import { Slots } from '../../context' import { Slots } from '../../context'
import EdgeAnchor from './EdgeAnchor.vue' import EdgeAnchor from './EdgeAnchor.vue'
@@ -9,6 +9,8 @@ import EdgeAnchor from './EdgeAnchor.vue'
interface EdgeWrapper { interface EdgeWrapper {
id: string id: string
edge: GraphEdge edge: GraphEdge
sourceNode: GraphNode
targetNode: GraphNode
selectable?: boolean selectable?: boolean
updatable?: boolean updatable?: boolean
} }
@@ -59,21 +61,21 @@ const handleEdgeUpdater = (event: MouseEvent, isSourceHandle: boolean) => {
// when connection type is loose we can define all handles as sources // when connection type is loose we can define all handles as sources
const targetNodeHandles = computed(() => { const targetNodeHandles = computed(() => {
if (store.connectionMode === ConnectionMode.Strict) { if (store.connectionMode === ConnectionMode.Strict) {
return edge.value.targetNode.handleBounds.target return props.targetNode.handleBounds.target
} }
const targetBounds = edge.value.targetNode.handleBounds.target const targetBounds = props.targetNode.handleBounds.target
const sourceBounds = edge.value.targetNode.handleBounds.source const sourceBounds = props.targetNode.handleBounds.source
return targetBounds ?? sourceBounds return targetBounds ?? sourceBounds
}) })
const sourceNodeHandles = computed(() => { const sourceNodeHandles = computed(() => {
if (store.connectionMode === ConnectionMode.Strict) { if (store.connectionMode === ConnectionMode.Strict) {
return edge.value.sourceNode.handleBounds.source return props.sourceNode.handleBounds.source
} }
const targetBounds = edge.value.sourceNode.handleBounds.target const targetBounds = props.sourceNode.handleBounds.target
const sourceBounds = edge.value.sourceNode.handleBounds.source const sourceBounds = props.sourceNode.handleBounds.source
return sourceBounds ?? targetBounds return sourceBounds ?? targetBounds
}) })
@@ -90,19 +92,19 @@ onMounted(() => {
[ [
sourcePosition, sourcePosition,
targetPosition, targetPosition,
() => edge.value.sourceNode.position, () => props.sourceNode.position,
() => edge.value.targetNode.position, () => props.targetNode.position,
() => edge.value.sourceNode.computedPosition, () => props.sourceNode.computedPosition,
() => edge.value.targetNode.computedPosition, () => props.targetNode.computedPosition,
() => edge.value.sourceNode.dimensions, () => props.sourceNode.dimensions,
() => edge.value.targetNode.dimensions, () => props.targetNode.dimensions,
], ],
() => { () => {
const { sourceX, sourceY, targetY, targetX } = getEdgePositions( const { sourceX, sourceY, targetY, targetX } = getEdgePositions(
edge.value.sourceNode, props.sourceNode,
sourceHandle.value, sourceHandle.value,
sourcePosition.value, sourcePosition.value,
edge.value.targetNode, props.targetNode,
targetHandle.value, targetHandle.value,
targetPosition.value, targetPosition.value,
) )
@@ -185,8 +187,8 @@ export default {
<component <component
:is="type" :is="type"
:id="edge.id" :id="edge.id"
:source-node="edge.sourceNode" :source-node="props.sourceNode"
:target-node="edge.targetNode" :target-node="props.targetNode"
:source="edge.source" :source="edge.source"
:target="edge.target" :target="edge.target"
:updatable="props.updatable" :updatable="props.updatable"
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useDraggableCore } from '@braks/revue-draggable' import { useDraggableCore } from '@braks/revue-draggable'
import { CSSProperties, isVNode } from 'vue' import { CSSProperties } from 'vue'
import { useVueFlow } from '../../composables' import { useVueFlow } from '../../composables'
import { GraphNode, NodeComponent, SnapGrid } from '../../types' import { GraphNode, NodeComponent, SnapGrid } from '../../types'
import { NodeId, Slots } from '../../context' import { NodeId, Slots } from '../../context'
@@ -23,7 +23,11 @@ const connectionLineVisible = controlledComputed(
store.connectionHandleType store.connectionHandleType
), ),
) )
const groups = computed(() => groupEdgesByZLevel(store.getEdges, store.getNodes))
const getNode = computed(() => (node: string) => store.getNode(node)!)
const memoizedGroups = useMemoize(groupEdgesByZLevel)
const groups = computed(() => memoizedGroups(store.getEdges, getNode.value))
</script> </script>
<script lang="ts"> <script lang="ts">
export default { export default {
@@ -39,6 +43,8 @@ export default {
:id="edge.id" :id="edge.id"
:key="edge.id" :key="edge.id"
:edge="edge" :edge="edge"
:source-node="getNode(edge.source)"
:target-node="getNode(edge.target)"
:selectable="typeof edge.selectable === 'undefined' ? store.elementsSelectable : edge.selectable" :selectable="typeof edge.selectable === 'undefined' ? store.elementsSelectable : edge.selectable"
:updatable="typeof edge.updatable === 'undefined' ? store.edgesUpdatable : edge.updatable" :updatable="typeof edge.updatable === 'undefined' ? store.edgesUpdatable : edge.updatable"
/> />
@@ -14,7 +14,7 @@ export default {
<NodeWrapper <NodeWrapper
v-for="node of store.getNodes" v-for="node of store.getNodes"
:id="node.id" :id="node.id"
:key="`vue-flow__node-${node.id}`" :key="node.id"
:node="node" :node="node"
:draggable="typeof node.draggable === 'undefined' ? store.nodesDraggable : !!node.draggable" :draggable="typeof node.draggable === 'undefined' ? store.nodesDraggable : !!node.draggable"
:selectable="typeof node.selectable === 'undefined' ? store.elementsSelectable : !!node.selectable" :selectable="typeof node.selectable === 'undefined' ? store.elementsSelectable : !!node.selectable"
+5 -8
View File
@@ -16,8 +16,7 @@ import {
ComputedGetters, ComputedGetters,
} from '~/types' } from '~/types'
import { import {
applyNodeChanges as applyNodes, applyChanges,
applyEdgeChanges as applyEdges,
connectionExists, connectionExists,
createPositionChange, createPositionChange,
createSelectionChange, createSelectionChange,
@@ -241,6 +240,7 @@ export default (state: State, getters: ComputedGetters): Actions => {
const missingTarget = !targetNode || typeof targetNode === 'undefined' const missingTarget = !targetNode || typeof targetNode === 'undefined'
if (missingSource) console.warn(`[vueflow]: Couldn't create edge for source id: ${edge.source}; edge id: ${edge.id}`) if (missingSource) console.warn(`[vueflow]: Couldn't create edge for source id: ${edge.source}; edge id: ${edge.id}`)
if (missingTarget) console.warn(`[vueflow]: Couldn't create edge for target id: ${edge.target}; edge id: ${edge.id}`) if (missingTarget) console.warn(`[vueflow]: Couldn't create edge for target id: ${edge.target}; edge id: ${edge.id}`)
if (missingSource || missingTarget) return res
const storedEdge = getters.getEdge.value(edge.id) const storedEdge = getters.getEdge.value(edge.id)
@@ -249,8 +249,6 @@ export default (state: State, getters: ComputedGetters): Actions => {
...state.defaultEdgeOptions, ...state.defaultEdgeOptions,
...storedEdge, ...storedEdge,
}), }),
sourceNode,
targetNode,
}) })
return res return res
@@ -284,12 +282,11 @@ export default (state: State, getters: ComputedGetters): Actions => {
const missingTarget = !targetNode || typeof targetNode === 'undefined' const missingTarget = !targetNode || typeof targetNode === 'undefined'
if (missingSource) console.warn(`[vueflow]: Couldn't create edge for source id: ${edge.source}; edge id: ${edge.id}`) if (missingSource) console.warn(`[vueflow]: Couldn't create edge for source id: ${edge.source}; edge id: ${edge.id}`)
if (missingTarget) console.warn(`[vueflow]: Couldn't create edge for target id: ${edge.target}; edge id: ${edge.id}`) if (missingTarget) console.warn(`[vueflow]: Couldn't create edge for target id: ${edge.target}; edge id: ${edge.id}`)
if (missingTarget || missingSource) return
state.edges.push({ state.edges.push({
...state.defaultEdgeOptions, ...state.defaultEdgeOptions,
...edge, ...edge,
sourceNode,
targetNode,
}) })
} }
}) })
@@ -298,9 +295,9 @@ export default (state: State, getters: ComputedGetters): Actions => {
const updateEdge: Actions['updateEdge'] = (oldEdge, newConnection) => const updateEdge: Actions['updateEdge'] = (oldEdge, newConnection) =>
updateEdgeAction(oldEdge, newConnection, state.edges, addEdges) updateEdgeAction(oldEdge, newConnection, state.edges, addEdges)
const applyNodeChanges: Actions['applyNodeChanges'] = (changes) => applyNodes(changes, state.nodes) const applyNodeChanges: Actions['applyNodeChanges'] = (changes) => applyChanges(changes, state.nodes, addNodes)
const applyEdgeChanges: Actions['applyEdgeChanges'] = (changes) => applyEdges(changes, state.edges) const applyEdgeChanges: Actions['applyEdgeChanges'] = (changes) => applyChanges(changes, state.edges, addEdges)
const setState: Actions['setState'] = (options) => { const setState: Actions['setState'] = (options) => {
const skip = ['modelValue', 'nodes', 'edges', 'maxZoom', 'minZoom', 'translateExtent'] const skip = ['modelValue', 'nodes', 'edges', 'maxZoom', 'minZoom', 'translateExtent']
+47 -28
View File
@@ -3,6 +3,11 @@ import { State, GraphEdge, GraphNode, ComputedGetters } from '~/types'
import { getNodesInside, isEdgeVisible } from '~/utils' import { getNodesInside, isEdgeVisible } from '~/utils'
export default (state: State): ComputedGetters => { export default (state: State): ComputedGetters => {
const nodeIds = computed(() => state.nodes.map((n) => n.id))
const edgeIds = computed(() => state.edges.map((e) => e.id))
const getNode: ComputedGetters['getNode'] = computed(() => (id: string) => state.nodes[nodeIds.value.indexOf(id)])
const getEdge: ComputedGetters['getEdge'] = computed(() => (id: string) => state.edges[edgeIds.value.indexOf(id)])
const getEdgeTypes = computed(() => { const getEdgeTypes = computed(() => {
const edgeTypes: Record<string, any> = { const edgeTypes: Record<string, any> = {
...defaultEdgeTypes, ...defaultEdgeTypes,
@@ -41,31 +46,50 @@ export default (state: State): ComputedGetters => {
: nodes ?? [] : nodes ?? []
}) })
const edgeHidden = (e: GraphEdge) => {
const source = getNode.value(e.source)
const target = getNode.value(e.target)
if (!source || !target) {
console.warn(`[vue-flow]: Orphaned edge ${e.id} will be removed.`)
state.edges.splice(state.edges.indexOf(e), 1)
return true
}
return (
!e.hidden &&
target &&
!target.hidden &&
source &&
!source.hidden &&
source.dimensions.width &&
source.dimensions.height &&
target.dimensions.width &&
target.dimensions.height
)
}
const getEdges = computed<GraphEdge[]>(() => { const getEdges = computed<GraphEdge[]>(() => {
if (!state.onlyRenderVisibleElements) if (!state.onlyRenderVisibleElements) return state.edges.filter(edgeHidden)
return state.edges.filter((e) => !e.hidden && e.targetNode && !e.targetNode.hidden && e.sourceNode && !e.sourceNode.hidden)
else return state.edges.filter((e) => {
return state.edges.filter( const source = getNode.value(e.source)!
(e) => const target = getNode.value(e.target)!
!e.hidden &&
e.sourceNode.dimensions.width && return (
e.sourceNode.dimensions.height && edgeHidden(e) &&
e.targetNode.dimensions.width && isEdgeVisible({
e.targetNode.dimensions.height && sourcePos: source.computedPosition || { x: 0, y: 0 },
!e.targetNode.hidden && targetPos: target.computedPosition || { x: 0, y: 0 },
!e.sourceNode.hidden && sourceWidth: source.dimensions.width,
isEdgeVisible({ sourceHeight: source.dimensions.height,
sourcePos: e.sourceNode.computedPosition || { x: 0, y: 0 }, targetWidth: target.dimensions.width,
targetPos: e.targetNode.computedPosition || { x: 0, y: 0 }, targetHeight: target.dimensions.height,
sourceWidth: e.sourceNode.dimensions.width, width: state.dimensions.width,
sourceHeight: e.sourceNode.dimensions.height, height: state.dimensions.height,
targetWidth: e.targetNode.dimensions.width, viewport: state.viewport,
targetHeight: e.targetNode.dimensions.height, })
width: state.dimensions.width,
height: state.dimensions.height,
viewport: state.viewport,
}),
) )
})
}) })
const getSelectedNodes: ComputedGetters['getSelectedNodes'] = computed(() => state.nodes.filter((n) => n.selected)) const getSelectedNodes: ComputedGetters['getSelectedNodes'] = computed(() => state.nodes.filter((n) => n.selected))
@@ -75,11 +99,6 @@ export default (state: State): ComputedGetters => {
...(getSelectedEdges.value ?? []), ...(getSelectedEdges.value ?? []),
]) ])
const nodeIds = computed(() => state.nodes.map((n) => n.id))
const edgeIds = computed(() => state.edges.map((e) => e.id))
const getNode: ComputedGetters['getNode'] = computed(() => (id: string) => state.nodes[nodeIds.value.indexOf(id)])
const getEdge: ComputedGetters['getEdge'] = computed(() => (id: string) => state.edges[edgeIds.value.indexOf(id)])
return { return {
getNode, getNode,
getEdge, getEdge,
+1 -3
View File
@@ -94,9 +94,7 @@ export interface EdgePositions {
} }
/** Internal edge type */ /** Internal edge type */
export type GraphEdge<Data = ElementData, SourceNodeData = any, TargetNodeData = SourceNodeData> = Edge<Data> & { export type GraphEdge<Data = ElementData> = Edge<Data> & {
sourceNode: GraphNode<SourceNodeData>
targetNode: GraphNode<TargetNodeData>
selected?: boolean selected?: boolean
z?: number z?: number
} & EdgePositions } & EdgePositions
+12 -5
View File
@@ -1,9 +1,8 @@
import { clampPosition, isGraphNode } from './graph' import { clampPosition, isGraphEdge, isGraphNode } from './graph'
import { import {
EdgeChange, EdgeChange,
EdgeSelectionChange, EdgeSelectionChange,
ElementChange, ElementChange,
FlowElement,
FlowElements, FlowElements,
GraphNode, GraphNode,
NodeChange, NodeChange,
@@ -13,6 +12,9 @@ import {
CoordinateExtent, CoordinateExtent,
XYPosition, XYPosition,
GraphEdge, GraphEdge,
Node,
FlowElement,
Edge,
} from '~/types' } from '~/types'
type CreatePositionChangeParams = { type CreatePositionChangeParams = {
@@ -90,20 +92,25 @@ function handleParentExpand(updateItem: GraphNode, curr: GraphNode[]) {
} }
export const applyChanges = < export const applyChanges = <
T extends FlowElement = GraphNode, T extends Node | Edge | FlowElement = Node,
C extends ElementChange = T extends GraphNode ? NodeChange : EdgeChange, C extends ElementChange = T extends GraphNode ? NodeChange : EdgeChange,
>( >(
changes: C[], changes: C[],
elements: T[], elements: T[],
addElement?: (els: T[]) => void,
): T[] => { ): T[] => {
let elementIds = elements.map((el) => el.id) let elementIds = elements.map((el) => el.id)
changes.forEach((change) => { changes.forEach((change) => {
if (change.type === 'add') return elements.push(change.item as any) if (change.type === 'add') {
if (addElement) addElement([change.item as any])
else elements.push(change.item as any)
}
const i = elementIds.indexOf((<any>change).id) const i = elementIds.indexOf((<any>change).id)
const el = elements[i] const el = elements[i]
switch (change.type) { switch (change.type) {
case 'select': case 'select':
el.selected = change.selected if (isGraphNode(el) || isGraphEdge(el)) el.selected = change.selected
break break
case 'position': case 'position':
if (isGraphNode(el)) { if (isGraphNode(el)) {
+8 -9
View File
@@ -1,5 +1,5 @@
import { rectToBox } from './graph' import { rectToBox } from './graph'
import { EdgePositions, GraphEdge, GraphNode, HandleElement, Position, Rect, Viewport, XYPosition } from '~/types' import { EdgePositions, Getters, GraphEdge, GraphNode, HandleElement, Position, Rect, Viewport, XYPosition } from '~/types'
export const getHandlePosition = (position: Position, rect: Rect, handle?: HandleElement): XYPosition => { export const getHandlePosition = (position: Position, rect: Rect, handle?: HandleElement): XYPosition => {
const x = (handle?.x ?? 0) + rect.x const x = (handle?.x ?? 0) + rect.x
@@ -126,17 +126,16 @@ export function isEdgeVisible({
return overlappingArea > 0 return overlappingArea > 0
} }
export const groupEdgesByZLevel = (edges: GraphEdge[], nodes: GraphNode[]) => { export const groupEdgesByZLevel = (edges: GraphEdge[], getNode: Getters['getNode']) => {
let maxLevel = -1 let maxLevel = -1
const nodeIds = nodes.map((n) => n.id)
const levelLookup = edges.reduce<Record<string, GraphEdge[]>>((tree, edge) => { const levelLookup = edges.reduce<Record<string, GraphEdge[]>>((tree, edge) => {
const z = edge.z const source = getNode(edge.source)
? edge.z const target = getNode(edge.target)
: Math.max(
nodes[nodeIds.indexOf(edge.source)]?.computedPosition.z || 0, if (!source || !target) return tree
nodes[nodeIds.indexOf(edge.target)]?.computedPosition.z || 0,
) const z = edge.z ? edge.z : Math.max(source.computedPosition.z || 0, target.computedPosition.z || 0)
if (tree[z]) { if (tree[z]) {
tree[z].push(edge) tree[z].push(edge)
} else { } else {