added Edge Reconnection

This commit is contained in:
peterkogo
2025-04-11 15:32:07 +02:00
parent d94f3f490a
commit a3e3055186
17 changed files with 314 additions and 165 deletions

View File

@@ -5,38 +5,21 @@
type EdgeProps,
EdgeLabelRenderer,
useSvelteFlow,
MarkerType
MarkerType,
EdgeReconnectAnchor
} from '@xyflow/svelte';
type $$Props = EdgeProps;
interface Props {
id?: $$Props['id'];
style?: $$Props['style'];
markerEnd?: $$Props['markerEnd'];
sourceX: $$Props['sourceX'];
sourceY: $$Props['sourceY'];
sourcePosition: $$Props['sourcePosition'];
targetX: $$Props['targetX'];
targetY: $$Props['targetY'];
targetPosition: $$Props['targetPosition'];
[key: string]: any;
}
let {
id = '',
style = undefined,
markerEnd = undefined,
style,
markerEnd,
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
...rest
}: Props = $props();
rest;
selected
}: EdgeProps = $props();
let [edgePath, labelX, labelY] = $derived(
getBezierPath({
@@ -50,42 +33,61 @@
);
const { updateEdge } = useSvelteFlow();
let reconnecting = $state(false);
</script>
<BaseEdge path={edgePath} {markerEnd} {style} />
<EdgeLabelRenderer>
<div
class="edgeButtonContainer nodrag nopan"
style:transform="translate(-50%, -50%) translate({labelX}px,{labelY}px)"
>
<button
class="edgeButton"
onclick={(event) => {
event.stopPropagation();
updateEdge('e5-6', {
markerEnd: {
type: MarkerType.Arrow,
color: '#FFCC00',
markerUnits: 'userSpaceOnUse',
width: 20,
height: 20,
strokeWidth: 2
},
markerStart: {
type: MarkerType.ArrowClosed,
color: '#FFCC00',
orient: 'auto-start-reverse',
markerUnits: 'userSpaceOnUse',
width: 20,
height: 20
}
});
}}
{#if !reconnecting}
<BaseEdge path={edgePath} {markerEnd} {style} />
<EdgeLabelRenderer>
<div
class="edgeButtonContainer nodrag nopan"
style:transform="translate(-50%, -50%) translate({labelX}px,{labelY}px)"
>
×
</button>
</div>
</EdgeLabelRenderer>
<button
class="edgeButton"
onclick={(event) => {
event.stopPropagation();
updateEdge('e5-6', {
markerEnd: {
type: MarkerType.Arrow,
color: '#FFCC00',
markerUnits: 'userSpaceOnUse',
width: 20,
height: 20,
strokeWidth: 2
},
markerStart: {
type: MarkerType.ArrowClosed,
color: '#FFCC00',
orient: 'auto-start-reverse',
markerUnits: 'userSpaceOnUse',
width: 20,
height: 20
}
});
}}
>
×
</button>
</div>
</EdgeLabelRenderer>
{/if}
{#if selected}
<EdgeReconnectAnchor
bind:reconnecting
type="source"
position={{ x: sourceX, y: sourceY }}
asDomNode
/>
<EdgeReconnectAnchor
bind:reconnecting
type="target"
position={{ x: targetX, y: targetY }}
asDomNode
/>
{/if}
<style>
.edgeButtonContainer {

View File

@@ -1,57 +1,7 @@
<script lang="ts">
import { BezierEdge, type EdgeProps } from '@xyflow/svelte';
type $$Props = EdgeProps;
interface Props {
label?: $$Props['label'];
labelStyle?: $$Props['labelStyle'];
style?: $$Props['style'];
markerStart?: $$Props['markerStart'];
markerEnd?: $$Props['markerEnd'];
interactionWidth?: $$Props['interactionWidth'];
sourceX: $$Props['sourceX'];
sourceY: $$Props['sourceY'];
sourcePosition: $$Props['sourcePosition'];
targetX: $$Props['targetX'];
targetY: $$Props['targetY'];
targetPosition: $$Props['targetPosition'];
[key: string]: any
}
let {
label = undefined,
labelStyle = undefined,
style = undefined,
markerStart = undefined,
markerEnd = undefined,
interactionWidth = undefined,
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
...rest
}: Props = $props();
rest;
let { ...props }: EdgeProps = $props();
</script>
<BezierEdge
{sourceX}
{sourceY}
{sourcePosition}
{targetX}
{targetY}
{targetPosition}
{label}
{labelStyle}
{markerStart}
{markerEnd}
{style}
{interactionWidth}
pathOptions={{ curvature: 1 }}
/>
<BezierEdge {...props} pathOptions={{ curvature: 1 }} />

View File

@@ -1,31 +1 @@
type PortalOptions = {
target?: string;
domNode: Element | null;
};
function tryToMount(node: Element, domNode: Element | null, target: string | undefined) {
if (!domNode) {
return;
}
const targetEl = target ? domNode.querySelector(target) : domNode;
if (targetEl) {
targetEl.appendChild(node);
}
}
export default function (node: Element, { target, domNode }: PortalOptions) {
tryToMount(node, domNode, target);
return {
async update({ target, domNode }: PortalOptions) {
tryToMount(node, domNode, target);
},
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
}
};
}
export { default as portal } from './portal.svelte';

View File

@@ -0,0 +1,42 @@
import { useStore } from '$lib/store';
type Portal = 'viewport' | 'edgelabel' | 'root';
const selector: Record<Exclude<Portal, 'root'>, string> = {
viewport: '.svelte-flow__viewport-portal',
edgelabel: '.svelte-flow__edgelabel-renderer'
};
function tryToMount(node: Element, domNode: Element | null, target: Portal | undefined) {
if (!target || !domNode) {
return;
}
const targetEl = target === 'root' ? domNode : domNode.querySelector(selector[target]);
if (targetEl) {
targetEl.appendChild(node);
}
}
export default function (node: Element, target: Portal | undefined) {
const { domNode } = $derived(useStore());
let previousTarget: Portal | undefined = target;
tryToMount(node, domNode, target);
return {
async update(target: Portal) {
if (target !== previousTarget) {
node.parentNode?.removeChild(node);
}
tryToMount(node, domNode, target);
},
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
}
};
}

View File

@@ -1,13 +1,10 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import portal from '$lib/actions/portal';
import { useStore } from '$lib/store';
const store = useStore();
import { portal } from '$lib/actions/portal';
let { children }: { children?: Snippet } = $props();
</script>
<div use:portal={{ target: '.svelte-flow__edgelabel-renderer', domNode: store.domNode }}>
<div use:portal={'edgelabel'}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import { useStore } from '$lib/store';
import type { Edge } from '$lib/types';
import { XYHandle, type HandleType, type XYPosition } from '@xyflow/system';
import { getContext, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { portal } from '$lib/actions/portal';
let {
type,
reconnecting = $bindable(false),
position,
class: className,
size = 25,
style,
children,
asDomNode = false
}: {
type: HandleType;
reconnecting?: boolean;
style?: string;
class?: ClassValue;
position?: XYPosition;
asDomNode?: boolean;
size?: number;
children?: Snippet;
} = $props();
const store = useStore();
let edgeId: string | undefined = getContext('svelteflow__edge_id');
if (!edgeId) {
throw new Error('EdgeReconnectAnchor must be used within an Edge component');
}
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
const {
autoPanOnConnect,
domNode,
isValidConnection,
connectionMode,
connectionRadius,
onconnectstart,
onconnectend,
onreconnect,
onreconnectstart,
onreconnectend,
onbeforereconnect,
cancelConnection,
nodeLookup,
flowId,
panBy,
updateConnection,
edgeLookup
} = store;
let newEdge: Edge | undefined;
let edge = edgeLookup.get(edgeId)!;
reconnecting = true;
onreconnectstart?.(event, edge, type);
const opposite =
type === 'target'
? { nodeId: edge.source, handleId: edge.sourceHandle ?? null, type: 'source' as HandleType }
: {
nodeId: edge.target,
handleId: edge.targetHandle ?? null,
type: 'target' as HandleType
};
XYHandle.onPointerDown(event, {
autoPanOnConnect,
connectionMode,
connectionRadius,
domNode,
handleId: opposite.handleId,
nodeId: opposite.nodeId,
nodeLookup,
isTarget: opposite.type === 'target',
edgeUpdaterType: opposite.type,
lib: 'svelte',
flowId,
cancelConnection,
panBy,
isValidConnection,
onConnectStart: onconnectstart,
onConnectEnd: onconnectend,
onConnect: (connection) => {
newEdge = { ...edge, ...connection };
newEdge = onbeforereconnect ? (onbeforereconnect(newEdge, edge) ?? undefined) : newEdge;
if (newEdge) {
store.edges = store.edges.map((e) => (e.id === edge.id ? (newEdge as Edge) : e));
}
onreconnect?.(edge, connection);
},
onReconnectEnd: (event, connectionState) => {
reconnecting = false;
onreconnectend?.(event, edge, opposite.type, connectionState);
},
updateConnection,
getTransform: () => [store.viewport.x, store.viewport.y, store.viewport.zoom],
getFromHandle: () => store.connection.fromHandle
});
};
let offset = $derived(asDomNode ? '-50%' : `-${size * 0.5}px`);
</script>
<svelte:element
this={asDomNode ? 'div' : 'g'}
use:portal={asDomNode ? 'edgelabel' : undefined}
xmlns={asDomNode ? undefined : 'http://www.w3.org/2000/svg'}
class={['svelte-flow__edgeupdater nopan', `svelte-flow__edgeupdater-${type}`, className]}
style:position="absolute"
style:width={`${size}px`}
style:height={`${size}px`}
style:transform={position
? `translate(calc(${offset} + ${position.x}px), calc(${offset} + ${position.y}px))`
: `translate(${offset}, ${offset})`}
{style}
onpointerdown={onPointerDown}
>
{#if !reconnecting}
{#if children}
{@render children()}
{:else if !asDomNode}
<rect
width={size}
height={size}
x={0}
fill="transparent"
stroke="transparent"
pointer-events="all"
/>
{/if}
{/if}
</svelte:element>

View File

@@ -0,0 +1 @@
export { default as EdgeReconnectAnchor } from './EdgeReconnectAnchor.svelte';

View File

@@ -196,10 +196,10 @@
The Handle component is the part of a node that can be used to connect nodes.
-->
<div
data-handleid={handleId}
data-handleid={handleId ?? 'null'}
data-nodeid={nodeId}
data-handlepos={position}
data-id="{store.flowId}-{nodeId}-{handleId}-{type}"
data-id="{store.flowId}-{nodeId}-{handleId ?? 'null'}-{type}"
class={[
'svelte-flow__handle',
`svelte-flow__handle-${position}`,

View File

@@ -1,14 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import portal from '$lib/actions/portal';
import { useStore } from '$lib/store';
import { portal } from '$lib/actions/portal';
let { children }: { children?: Snippet } = $props();
const store = useStore();
</script>
<div use:portal={{ target: '.svelte-flow__viewport-portal', domNode: store.domNode }}>
<div use:portal={'viewport'}>
{@render children?.()}
</div>

View File

@@ -66,9 +66,6 @@
nodes = $bindable([]),
edges = $bindable([]),
viewport = $bindable(undefined),
nodesDraggable = $bindable(true),
nodesConnectable = $bindable(true),
elementsSelectable = $bindable(true),
...props
}: SvelteFlowProps & HTMLAttributes<HTMLDivElement> = $props();

View File

@@ -32,6 +32,9 @@
edgeTypes,
colorMode: _colorMode,
isValidConnection,
onmove,
onmovestart,
onmoveend,
onerror,
ondelete,
onbeforedelete,
@@ -39,6 +42,10 @@
onconnect,
onconnectstart,
onconnectend,
onbeforereconnect,
onreconnect,
onreconnectstart,
onreconnectend,
onclickconnectstart,
onclickconnectend,
clickConnect,

View File

@@ -18,7 +18,10 @@ import type {
OnConnect,
OnConnectStart,
OnConnectEnd,
ColorModeClass
ColorModeClass,
OnReconnect,
OnRecoonnectStart,
OnReconnectEnd
} from '@xyflow/system';
import type {
@@ -32,7 +35,8 @@ import type {
OnDelete,
OnBeforeConnect,
OnBeforeDelete,
IsValidConnection
IsValidConnection,
OnBeforeReconnect
} from '$lib/types';
import type { Component } from 'svelte';
@@ -356,16 +360,22 @@ export type SvelteFlowProps = NodeEvents &
ondelete?: OnDelete;
/** This handler gets called before the user deletes nodes or edges and provides a way to abort the deletion by returning false. */
onbeforedelete?: OnBeforeDelete;
/** This handler gets called when a new edge is created. You can use it to modify the newly created edge. */
onbeforeconnect?: OnBeforeConnect;
/** This event gets fired when a connection successfully completes and an edge is created. */
onconnect?: OnConnect;
/** When a user starts to drag a connection line, this event gets fired. */
onconnectstart?: OnConnectStart;
/** When a user stops dragging a connection line, this event gets fired. */
onconnectend?: OnConnectEnd;
/** This event gets fired when after an edge was reconnected*/
onreconnect?: OnReconnect;
/** This event gets fired when a user starts to reconnect an edge */
onreconnectstart?: OnRecoonnectStart;
/** This event gets fired when a user stops reconnecting an edge */
onreconnectend?: OnReconnectEnd;
/** This handler gets called when an edge is reconnected. You can use it to modify the edge before the update is applied. */
onbeforereconnect?: OnBeforeReconnect;
/** A connection is started by clicking on a handle */
onclickconnectstart?: OnConnectStart;
/** A connection is finished by clicking on a handle */

View File

@@ -16,6 +16,7 @@ export {
} from '$lib/components/edges';
export * from '$lib/components/Handle';
export * from '$lib/components/EdgeLabel';
export * from '$lib/components/EdgeReconnectAnchor';
// plugins
export * from '$lib/plugins/Controls';
@@ -40,6 +41,9 @@ export * from '$lib/hooks/useNodesData.svelte';
export * from '$lib/hooks/useInternalNode.svelte';
export * from '$lib/hooks/useInitialized.svelte';
//actions
export * from '$lib/actions/portal';
// types
export type {
Edge,

View File

@@ -2,7 +2,7 @@
import { getContext } from 'svelte';
import { Position, getNodeToolbarTransform } from '@xyflow/system';
import portal from '$lib/actions/portal';
import { portal } from '$lib/actions/portal';
import { useStore } from '$lib/store';
import { useSvelteFlow } from '$lib/hooks/useSvelteFlow.svelte';
@@ -68,7 +68,7 @@
<div
data-id={toolbarNodes.reduce((acc, node) => `${acc}${node.id} `, '').trim()}
class="svelte-flow__node-toolbar"
use:portal={{ domNode: store.domNode }}
use:portal={'root'}
style:position="absolute"
style:transform
style:z-index={zIndex}

View File

@@ -29,7 +29,10 @@ import {
type ColorModeClass,
type Transform,
fitViewport,
type Handle
type Handle,
type OnReconnect,
type OnRecoonnectStart,
type OnReconnectEnd
} from '@xyflow/system';
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
@@ -54,7 +57,8 @@ import type {
IsValidConnection,
Edge,
EdgeLayouted,
InternalNode
InternalNode,
OnBeforeReconnect
} from '$lib/types';
import type { StoreSignals } from './types';
@@ -270,16 +274,22 @@ export const getInitialStore = (signals: StoreSignals) => {
onlyRenderVisibleElements: boolean = $derived(signals.props.onlyRenderVisibleElements ?? false);
onerror: OnError = $derived(signals.props.onerror ?? devWarn);
ondelete?: OnDelete = $derived(signals.props.ondelete ?? undefined);
ondelete?: OnDelete = $derived(signals.props.ondelete);
onbeforedelete?: OnBeforeDelete = $derived(signals.props.onbeforedelete);
onbeforeconnect?: OnBeforeConnect = $derived(signals.props.onbeforeconnect);
onconnect?: OnConnect = $derived(signals.props.onconnect);
onconnectstart?: OnConnectStart = $derived(signals.props.onconnectstart);
onconnectend?: OnConnectEnd = $derived(signals.props.onconnectend);
onbeforereconnect?: OnBeforeReconnect = $derived(signals.props.onbeforereconnect);
onreconnect?: OnReconnect = $derived(signals.props.onreconnect);
onreconnectstart?: OnRecoonnectStart = $derived(signals.props.onreconnectstart);
onreconnectend?: OnReconnectEnd = $derived(signals.props.onreconnectend);
clickConnect?: boolean = $derived(signals.props.clickConnect ?? true);
onclickconnectstart?: OnConnectStart = $derived(signals.props.onclickconnectstart ?? undefined);
onclickconnectend?: OnConnectEnd = $derived(signals.props.onclickconnectend ?? undefined);
onclickconnectstart?: OnConnectStart = $derived(signals.props.onclickconnectstart);
onclickconnectend?: OnConnectEnd = $derived(signals.props.onclickconnectend);
clickConnectStartHandle: Pick<Handle, 'id' | 'nodeId' | 'type'> | null = $state(null);
resolveFitView = async () => {

View File

@@ -36,6 +36,10 @@ export type FitViewOptions<NodeType extends Node = Node> = FitViewOptionsBase<No
export type OnDelete = (params: { nodes: Node[]; edges: Edge[] }) => void;
export type OnBeforeConnect = (connection: Connection) => Edge | Connection | void;
export type OnBeforeReconnect<EdgeType extends Edge = Edge> = (
newEdge: EdgeType,
oldEdge: EdgeType
) => EdgeType | void;
export type OnBeforeDelete<
NodeType extends Node = Node,
EdgeType extends Edge = Edge

View File

@@ -80,6 +80,19 @@ export type OnConnectStart = (event: MouseEvent | TouchEvent, params: OnConnectS
export type OnConnect = (connection: Connection) => void;
export type OnConnectEnd = (event: MouseEvent | TouchEvent, connectionState: FinalConnectionState) => void;
export type OnReconnect<EdgeType extends EdgeBase = EdgeBase> = (oldEdge: EdgeType, newConnection: Connection) => void;
export type OnRecoonnectStart<EdgeType extends EdgeBase = EdgeBase> = (
event: MouseEvent | TouchEvent,
edge: EdgeType,
handleType: HandleType
) => void;
export type OnReconnectEnd<EdgeType extends EdgeBase = EdgeBase> = (
event: MouseEvent | TouchEvent,
edge: EdgeType,
handleType: HandleType,
connectionState: FinalConnectionState
) => void;
export type IsValidConnection = (edge: EdgeBase | Connection) => boolean;
/**