feat(canvas): implement edge insertion reflow and enhance connection validation

- Introduced a new CSS transition for edge insertion reflowing to improve visual feedback during node adjustments.
- Enhanced the connection validation logic to include options for optimistic edges, ensuring better handling of edge creation scenarios.
- Updated the canvas connection drop menu to support additional templates and improved edge insertion handling.
- Refactored edge insertion logic to accommodate local node position adjustments during reflow operations.
- Added tests for new edge insertion features and connection validation improvements.
This commit is contained in:
2026-04-05 23:25:26 +02:00
parent 7c34da45b4
commit fa6a41f775
14 changed files with 1477 additions and 67 deletions

View File

@@ -14,6 +14,7 @@ import { toast } from "@/lib/toast";
import { type CanvasNodeDeleteBlockReason } from "@/lib/toast";
import { getNodeDeleteBlockReason } from "./canvas-helpers";
import { validateCanvasConnection } from "./canvas-connection-validation";
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
@@ -22,6 +23,8 @@ type UseCanvasDeleteHandlersParams = {
canvasId: Id<"canvases">;
nodes: RFNode[];
edges: RFEdge[];
nodesRef: MutableRefObject<RFNode[]>;
edgesRef: MutableRefObject<RFEdge[]>;
deletingNodeIds: MutableRefObject<Set<string>>;
setAssetBrowserTargetNodeId: Dispatch<SetStateAction<string | null>>;
runBatchRemoveNodesMutation: (args: { nodeIds: Id<"nodes">[] }) => Promise<unknown>;
@@ -40,6 +43,8 @@ export function useCanvasDeleteHandlers({
canvasId,
nodes,
edges,
nodesRef,
edgesRef,
deletingNodeIds,
setAssetBrowserTargetNodeId,
runBatchRemoveNodesMutation,
@@ -50,6 +55,12 @@ export function useCanvasDeleteHandlers({
onNodesDelete: (deletedNodes: RFNode[]) => void;
onEdgesDelete: (deletedEdges: RFEdge[]) => void;
} {
const edgeKey = useCallback(
(edge: Pick<RFEdge, "source" | "target" | "sourceHandle" | "targetHandle">) =>
`${edge.source}\0${edge.target}\0${edge.sourceHandle ?? ""}\0${edge.targetHandle ?? ""}`,
[],
);
const onBeforeDelete = useCallback(
async ({
nodes: matchingNodes,
@@ -117,11 +128,33 @@ export function useCanvasDeleteHandlers({
current !== null && removedTargetSet.has(current) ? null : current,
);
const liveNodes = nodesRef.current;
const liveEdges = edgesRef.current;
const bridgeCreates = computeBridgeCreatesForDeletedNodes(
deletedNodes,
nodes,
edges,
liveNodes,
liveEdges,
);
const connectedDeletedEdges = getConnectedEdges(deletedNodes, liveEdges);
const remainingNodes = liveNodes.filter(
(node) => !removedTargetSet.has(node.id),
);
let remainingEdges = liveEdges.filter(
(edge) => !connectedDeletedEdges.includes(edge) && edge.className !== "temp",
);
if (bridgeCreates.length > 0) {
console.info("[Canvas] computed bridge edges for delete", {
canvasId,
deletedNodeIds: idsToDelete,
deletedNodes: deletedNodes.map((node) => ({
id: node.id,
type: node.type ?? null,
})),
bridgeCreates,
});
}
void (async () => {
await runBatchRemoveNodesMutation({
@@ -129,13 +162,77 @@ export function useCanvasDeleteHandlers({
});
for (const bridgeCreate of bridgeCreates) {
await runCreateEdgeMutation({
canvasId,
sourceNodeId: bridgeCreate.sourceNodeId,
targetNodeId: bridgeCreate.targetNodeId,
const bridgeKey = edgeKey({
source: bridgeCreate.sourceNodeId,
target: bridgeCreate.targetNodeId,
sourceHandle: bridgeCreate.sourceHandle,
targetHandle: bridgeCreate.targetHandle,
});
if (remainingEdges.some((edge) => edgeKey(edge) === bridgeKey)) {
console.info("[Canvas] skipped duplicate bridge edge after delete", {
canvasId,
deletedNodeIds: idsToDelete,
bridgeCreate,
});
continue;
}
const validationError = validateCanvasConnection(
{
source: bridgeCreate.sourceNodeId,
target: bridgeCreate.targetNodeId,
sourceHandle: bridgeCreate.sourceHandle ?? null,
targetHandle: bridgeCreate.targetHandle ?? null,
},
remainingNodes,
remainingEdges,
undefined,
{ includeOptimisticEdges: true },
);
if (validationError) {
console.info("[Canvas] skipped invalid bridge edge after delete", {
canvasId,
deletedNodeIds: idsToDelete,
bridgeCreate,
validationError,
});
continue;
}
try {
console.info("[Canvas] creating bridge edge after delete", {
canvasId,
deletedNodeIds: idsToDelete,
bridgeCreate,
});
await runCreateEdgeMutation({
canvasId,
sourceNodeId: bridgeCreate.sourceNodeId,
targetNodeId: bridgeCreate.targetNodeId,
sourceHandle: bridgeCreate.sourceHandle,
targetHandle: bridgeCreate.targetHandle,
});
remainingEdges = [
...remainingEdges,
{
id: `bridge-${bridgeCreate.sourceNodeId}-${bridgeCreate.targetNodeId}-${remainingEdges.length}`,
source: bridgeCreate.sourceNodeId,
target: bridgeCreate.targetNodeId,
sourceHandle: bridgeCreate.sourceHandle,
targetHandle: bridgeCreate.targetHandle,
},
];
} catch (error: unknown) {
console.error("[Canvas] bridge edge create failed", {
canvasId,
deletedNodeIds: idsToDelete,
bridgeCreate,
error,
});
throw error;
}
}
})()
.then(() => {
@@ -156,8 +253,11 @@ export function useCanvasDeleteHandlers({
t,
canvasId,
deletingNodeIds,
edgeKey,
edges,
edgesRef,
nodes,
nodesRef,
runBatchRemoveNodesMutation,
runCreateEdgeMutation,
setAssetBrowserTargetNodeId,