feat: enhance canvas connection handling with custom animation and edge management

- Added a custom connection line component with animation for improved visual feedback during node interactions.
- Implemented CSS animations for temporary connection lines, enhancing the user experience in the canvas.
- Refactored edge creation and removal logic to support optimistic updates, improving performance during node manipulations.
- Introduced a utility function to compute edge reconnections after node deletions, streamlining edge management.
This commit is contained in:
Matthias
2026-03-28 13:26:47 +01:00
parent e5f27d7d29
commit fb24205da0
5 changed files with 425 additions and 91 deletions

View File

@@ -1,5 +1,11 @@
import type { Node as RFNode, Edge as RFEdge } from "@xyflow/react";
import type { Doc } from "@/convex/_generated/dataModel";
import {
getConnectedEdges,
getIncomers,
getOutgoers,
type Node as RFNode,
type Edge as RFEdge,
} from "@xyflow/react";
import type { Doc, Id } from "@/convex/_generated/dataModel";
/**
* Convex Node → React Flow Node
@@ -100,6 +106,35 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
compare: [100, 116, 139],
};
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
const COMPARE_HANDLE_CONNECTION_RGB: Record<
string,
readonly [number, number, number]
> = {
left: [59, 130, 246],
right: [16, 185, 129],
"compare-out": [100, 116, 139],
};
const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
13, 148, 136,
];
/**
* RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
*/
export function connectionLineAccentRgb(
nodeType: string | undefined,
handleId: string | null | undefined,
): readonly [number, number, number] {
if (nodeType === "compare" && handleId) {
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId];
if (byHandle) return byHandle;
}
if (!nodeType) return CONNECTION_LINE_FALLBACK_RGB;
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
}
export type EdgeGlowColorMode = "light" | "dark";
function sourceGlowFilterForNodeType(
@@ -253,3 +288,76 @@ export function computeMediaNodeSize(
aspectRatio,
};
}
function reconnectEdgeKey(edge: RFEdge): string {
return `${edge.source}\0${edge.target}\0${edge.sourceHandle ?? ""}\0${edge.targetHandle ?? ""}`;
}
export type BridgeCreatePayload = {
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
/**
* Nach Löschen mittlerer Knoten: Kanten wie im React-Flow-Beispiel
* „Delete Middle Node“ fortschreiben; nur Kanten zurückgeben, die neu
* angelegt werden müssen (nicht bereits vor dem Löschen vorhanden).
*/
export function computeBridgeCreatesForDeletedNodes(
deletedNodes: RFNode[],
allNodes: RFNode[],
allEdges: RFEdge[],
): BridgeCreatePayload[] {
if (deletedNodes.length === 0) return [];
const initialPersisted = allEdges.filter((e) => e.className !== "temp");
const initialKeys = new Set(initialPersisted.map(reconnectEdgeKey));
let remainingNodes = [...allNodes];
let acc = [...initialPersisted];
for (const node of deletedNodes) {
const incomers = getIncomers(node, remainingNodes, acc);
const outgoers = getOutgoers(node, remainingNodes, acc);
const connectedEdges = getConnectedEdges([node], acc);
const remainingEdges = acc.filter((e) => !connectedEdges.includes(e));
const createdEdges: RFEdge[] = [];
for (const inc of incomers) {
for (const out of outgoers) {
const inEdge = connectedEdges.find(
(e) => e.source === inc.id && e.target === node.id,
);
const outEdge = connectedEdges.find(
(e) => e.source === node.id && e.target === out.id,
);
if (!inEdge || !outEdge || inc.id === out.id) continue;
createdEdges.push({
id: `reconnect-${inc.id}-${out.id}-${node.id}-${createdEdges.length}`,
source: inc.id,
target: out.id,
sourceHandle: inEdge.sourceHandle,
targetHandle: outEdge.targetHandle,
});
}
}
acc = [...remainingEdges, ...createdEdges];
remainingNodes = remainingNodes.filter((rn) => rn.id !== node.id);
}
const result: BridgeCreatePayload[] = [];
for (const e of acc) {
if (!initialKeys.has(reconnectEdgeKey(e))) {
result.push({
sourceNodeId: e.source as Id<"nodes">,
targetNodeId: e.target as Id<"nodes">,
sourceHandle: e.sourceHandle ?? undefined,
targetHandle: e.targetHandle ?? undefined,
});
}
}
return result;
}