Files
lemonspace_app/components/canvas/use-canvas-edge-insertions.ts
Matthias Meister 7c34da45b4 feat(canvas): enhance edge insertion and local node data handling
- Added support for new edge insertion features, including default edge types and improved layout calculations.
- Introduced local node data persistence during flow reconciliation to ensure data integrity.
- Updated connection drop menu to handle edge insertions and node interactions more effectively.
- Enhanced testing for edge insert layout and local node data management.
2026-04-05 21:26:20 +02:00

219 lines
5.9 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel";
import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-policy";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import type { CanvasNodeType } from "@/lib/canvas-node-types";
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
import {
computeEdgeInsertLayout,
hasHandleKey,
isOptimisticEdgeId,
normalizeHandle,
} from "./canvas-helpers";
import { validateCanvasEdgeSplit } from "./canvas-connection-validation";
export type EdgeInsertMenuState = {
edgeId: string;
screenX: number;
screenY: number;
};
const EDGE_INSERT_GAP_PX = 10;
type UseCanvasEdgeInsertionsArgs = {
canvasId: Id<"canvases">;
nodes: RFNode[];
edges: RFEdge[];
runCreateNodeWithEdgeSplitOnlineOnly: (args: {
canvasId: Id<"canvases">;
type: CanvasNodeType;
positionX: number;
positionY: number;
width: number;
height: number;
data: Record<string, unknown>;
splitEdgeId: Id<"edges">;
newNodeTargetHandle?: string;
newNodeSourceHandle?: string;
splitSourceHandle?: string;
splitTargetHandle?: string;
clientRequestId?: string;
}) => Promise<Id<"nodes"> | string>;
runBatchMoveNodesMutation: (args: {
moves: {
nodeId: Id<"nodes">;
positionX: number;
positionY: number;
}[];
}) => Promise<void>;
showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void;
};
export function useCanvasEdgeInsertions({
canvasId,
nodes,
edges,
runCreateNodeWithEdgeSplitOnlineOnly,
runBatchMoveNodesMutation,
showConnectionRejectedToast,
}: UseCanvasEdgeInsertionsArgs) {
const [edgeInsertMenu, setEdgeInsertMenu] = useState<EdgeInsertMenuState | null>(null);
const edgeInsertMenuRef = useRef<EdgeInsertMenuState | null>(null);
useEffect(() => {
edgeInsertMenuRef.current = edgeInsertMenu;
}, [edgeInsertMenu]);
const closeEdgeInsertMenu = useCallback(() => {
setEdgeInsertMenu(null);
}, []);
const openEdgeInsertMenu = useCallback(
({ edgeId, screenX, screenY }: EdgeInsertMenuState) => {
const edge = edges.find(
(candidate) =>
candidate.id === edgeId &&
candidate.className !== "temp" &&
!isOptimisticEdgeId(candidate.id),
);
if (!edge) {
return;
}
setEdgeInsertMenu({ edgeId, screenX, screenY });
},
[edges],
);
const handleEdgeInsertPick = useCallback(
async (template: CanvasNodeTemplate) => {
const menu = edgeInsertMenuRef.current;
if (!menu) {
return;
}
const splitEdge = edges.find(
(edge) =>
edge.id === menu.edgeId && edge.className !== "temp" && !isOptimisticEdgeId(edge.id),
);
if (!splitEdge) {
showConnectionRejectedToast("unknown-node");
return;
}
const sourceNode = nodes.find((node) => node.id === splitEdge.source);
const targetNode = nodes.find((node) => node.id === splitEdge.target);
if (!sourceNode || !targetNode) {
showConnectionRejectedToast("unknown-node");
return;
}
const defaults = NODE_DEFAULTS[template.type] ?? {
width: 200,
height: 100,
data: {},
};
const width = template.width ?? defaults.width;
const height = template.height ?? defaults.height;
const handles = NODE_HANDLE_MAP[template.type];
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
showConnectionRejectedToast("unknown-node");
return;
}
const middleNode: RFNode = {
id: "__pending_edge_insert__",
type: template.type,
position: { x: 0, y: 0 },
data: {},
};
const splitValidationError = validateCanvasEdgeSplit({
nodes,
edges,
splitEdge,
middleNode,
});
if (splitValidationError) {
showConnectionRejectedToast(splitValidationError);
return;
}
const layout = computeEdgeInsertLayout({
sourceNode,
targetNode,
newNodeWidth: width,
newNodeHeight: height,
gapPx: EDGE_INSERT_GAP_PX,
});
await runCreateNodeWithEdgeSplitOnlineOnly({
canvasId,
type: template.type,
positionX: layout.insertPosition.x,
positionY: layout.insertPosition.y,
width,
height,
data: {
...defaults.data,
...(template.defaultData as Record<string, unknown>),
canvasId,
},
splitEdgeId: splitEdge.id as Id<"edges">,
newNodeTargetHandle: normalizeHandle(handles.target),
newNodeSourceHandle: normalizeHandle(handles.source),
splitSourceHandle: normalizeHandle(splitEdge.sourceHandle),
splitTargetHandle: normalizeHandle(splitEdge.targetHandle),
});
const moves: {
nodeId: Id<"nodes">;
positionX: number;
positionY: number;
}[] = [];
if (layout.sourcePosition) {
moves.push({
nodeId: sourceNode.id as Id<"nodes">,
positionX: layout.sourcePosition.x,
positionY: layout.sourcePosition.y,
});
}
if (layout.targetPosition) {
moves.push({
nodeId: targetNode.id as Id<"nodes">,
positionX: layout.targetPosition.x,
positionY: layout.targetPosition.y,
});
}
if (moves.length > 0) {
await runBatchMoveNodesMutation({ moves });
}
closeEdgeInsertMenu();
},
[
canvasId,
closeEdgeInsertMenu,
edges,
nodes,
runBatchMoveNodesMutation,
runCreateNodeWithEdgeSplitOnlineOnly,
showConnectionRejectedToast,
],
);
return {
edgeInsertMenu,
openEdgeInsertMenu,
closeEdgeInsertMenu,
handleEdgeInsertPick,
};
}