Scherenmodus — Kante anklicken oder ziehen zum Durchtrennen ·{" "}
@@ -512,9 +581,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
nodes={nodes}
edges={edges}
onlyRenderVisibleElements
- defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
+ defaultEdgeOptions={defaultEdgeOptions}
connectionLineComponent={CustomConnectionLine}
nodeTypes={nodeTypes}
+ edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStart={onNodeDragStart}
diff --git a/components/canvas/edges/default-edge.tsx b/components/canvas/edges/default-edge.tsx
index 4a93263..f399418 100644
--- a/components/canvas/edges/default-edge.tsx
+++ b/components/canvas/edges/default-edge.tsx
@@ -1,3 +1,116 @@
-export default function DefaultEdge() {
- return null;
+"use client";
+
+import { useMemo, useState, type MouseEvent } from "react";
+import {
+ BaseEdge,
+ EdgeLabelRenderer,
+ getBezierPath,
+ type EdgeProps,
+} from "@xyflow/react";
+import { Plus } from "lucide-react";
+
+export type DefaultEdgeInsertAnchor = {
+ edgeId: string;
+ screenX: number;
+ screenY: number;
+};
+
+export type DefaultEdgeProps = EdgeProps & {
+ edgeId?: string;
+ isMenuOpen?: boolean;
+ disabled?: boolean;
+ onInsertClick?: (anchor: DefaultEdgeInsertAnchor) => void;
+};
+
+export default function DefaultEdge({
+ id,
+ edgeId,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ markerStart,
+ markerEnd,
+ style,
+ interactionWidth,
+ isMenuOpen = false,
+ disabled = false,
+ onInsertClick,
+}: DefaultEdgeProps) {
+ const [isEdgeHovered, setIsEdgeHovered] = useState(false);
+ const [isButtonHovered, setIsButtonHovered] = useState(false);
+
+ const [edgePath, labelX, labelY] = useMemo(
+ () =>
+ getBezierPath({
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ }),
+ [sourcePosition, sourceX, sourceY, targetPosition, targetX, targetY],
+ );
+
+ const resolvedEdgeId = edgeId ?? id;
+ const canInsert = Boolean(onInsertClick) && !disabled;
+ const isInsertVisible = canInsert && (isMenuOpen || isEdgeHovered || isButtonHovered);
+
+ const handleInsertClick = (event: MouseEvent
) => {
+ if (!onInsertClick || disabled) {
+ return;
+ }
+
+ const rect = event.currentTarget.getBoundingClientRect();
+ onInsertClick({
+ edgeId: resolvedEdgeId,
+ screenX: rect.left + rect.width / 2,
+ screenY: rect.top + rect.height / 2,
+ });
+ };
+
+ return (
+ <>
+ setIsEdgeHovered(true)}
+ onMouseLeave={() => setIsEdgeHovered(false)}
+ >
+
+
+
+
+
+
+ >
+ );
}
diff --git a/components/canvas/nodes/use-node-local-data.ts b/components/canvas/nodes/use-node-local-data.ts
index f95e253..b714fc3 100644
--- a/components/canvas/nodes/use-node-local-data.ts
+++ b/components/canvas/nodes/use-node-local-data.ts
@@ -41,9 +41,10 @@ export function useNodeLocalData({
useCanvasGraphPreviewOverrides();
const [localData, setLocalDataState] = useState(() => normalize(data));
const localDataRef = useRef(localData);
- const persistedDataRef = useRef(localData);
+ const acceptedPersistedDataRef = useRef(localData);
const hasPendingLocalChangesRef = useRef(false);
const localChangeVersionRef = useRef(0);
+ const acknowledgedSaveVersionRef = useRef(0);
const isMountedRef = useRef(true);
useEffect(() => {
@@ -60,7 +61,7 @@ export function useNodeLocalData({
return;
}
- hasPendingLocalChangesRef.current = false;
+ acknowledgedSaveVersionRef.current = savedVersion;
})
.catch(() => {
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
@@ -68,34 +69,49 @@ export function useNodeLocalData({
}
hasPendingLocalChangesRef.current = false;
- localDataRef.current = persistedDataRef.current;
- setLocalDataState(persistedDataRef.current);
+ acknowledgedSaveVersionRef.current = 0;
+ localDataRef.current = acceptedPersistedDataRef.current;
+ setLocalDataState(acceptedPersistedDataRef.current);
clearPreviewNodeDataOverride(nodeId);
});
}, saveDelayMs);
useEffect(() => {
const incomingData = normalize(data);
- persistedDataRef.current = incomingData;
const incomingHash = hashNodeData(incomingData);
const localHash = hashNodeData(localDataRef.current);
+ const acceptedPersistedHash = hashNodeData(acceptedPersistedDataRef.current);
if (incomingHash === localHash) {
+ acceptedPersistedDataRef.current = incomingData;
hasPendingLocalChangesRef.current = false;
+ acknowledgedSaveVersionRef.current = 0;
clearPreviewNodeDataOverride(nodeId);
return;
}
if (hasPendingLocalChangesRef.current) {
- logNodeDataDebug("skip-stale-external-data", {
- nodeType: debugLabel,
- incomingHash,
- localHash,
- });
- return;
+ const saveAcknowledgedForCurrentVersion =
+ acknowledgedSaveVersionRef.current === localChangeVersionRef.current;
+ const shouldKeepBlockingIncomingData =
+ !saveAcknowledgedForCurrentVersion || incomingHash === acceptedPersistedHash;
+
+ if (shouldKeepBlockingIncomingData) {
+ logNodeDataDebug("skip-stale-external-data", {
+ nodeId,
+ nodeType: debugLabel,
+ incomingHash,
+ localHash,
+ saveAcknowledgedForCurrentVersion,
+ });
+ return;
+ }
}
const timer = window.setTimeout(() => {
+ acceptedPersistedDataRef.current = incomingData;
+ hasPendingLocalChangesRef.current = false;
+ acknowledgedSaveVersionRef.current = 0;
localDataRef.current = incomingData;
setLocalDataState(incomingData);
clearPreviewNodeDataOverride(nodeId);
@@ -123,7 +139,7 @@ export function useNodeLocalData({
setPreviewNodeDataOverride(nodeId, next);
queueSave();
},
- [debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
+ [nodeId, queueSave, setPreviewNodeDataOverride],
);
const updateLocalData = useCallback(
@@ -137,7 +153,7 @@ export function useNodeLocalData({
setPreviewNodeDataOverride(nodeId, next);
queueSave();
},
- [debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
+ [nodeId, queueSave, setPreviewNodeDataOverride],
);
return {
diff --git a/components/canvas/use-canvas-connections.ts b/components/canvas/use-canvas-connections.ts
index 43c44c0..2f0b336 100644
--- a/components/canvas/use-canvas-connections.ts
+++ b/components/canvas/use-canvas-connections.ts
@@ -10,11 +10,19 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import type { CanvasNodeType } from "@/lib/canvas-node-types";
-import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers";
-import { resolveDroppedConnectionTarget } from "./canvas-helpers";
+import {
+ getConnectEndClientPoint,
+ hasHandleKey,
+ isOptimisticEdgeId,
+ isOptimisticNodeId,
+ logCanvasConnectionDebug,
+ normalizeHandle,
+ resolveDroppedConnectionTarget,
+} from "./canvas-helpers";
import {
validateCanvasConnection,
validateCanvasConnectionByType,
+ validateCanvasEdgeSplit,
} from "./canvas-connection-validation";
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
@@ -43,6 +51,15 @@ type UseCanvasConnectionsParams = {
sourceHandle?: string;
targetHandle?: string;
}) => Promise;
+ runSplitEdgeAtExistingNodeMutation: (args: {
+ canvasId: Id<"canvases">;
+ splitEdgeId: Id<"edges">;
+ middleNodeId: Id<"nodes">;
+ splitSourceHandle?: string;
+ splitTargetHandle?: string;
+ newNodeSourceHandle?: string;
+ newNodeTargetHandle?: string;
+ }) => Promise;
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise;
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
canvasId: Id<"canvases">;
@@ -92,6 +109,7 @@ export function useCanvasConnections({
screenToFlowPosition,
syncPendingMoveForClientRequest,
runCreateEdgeMutation,
+ runSplitEdgeAtExistingNodeMutation,
runRemoveEdgeMutation,
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
@@ -107,8 +125,13 @@ export function useCanvasConnections({
connectionDropMenuRef.current = connectionDropMenu;
}, [connectionDropMenu]);
- const onConnectStart = useCallback(() => {
+ const onConnectStart = useCallback((_event, params) => {
isConnectDragActiveRef.current = true;
+ logCanvasConnectionDebug("connect:start", {
+ nodeId: params.nodeId,
+ handleId: params.handleId,
+ handleType: params.handleType,
+ });
}, []);
const onConnect = useCallback(
@@ -116,11 +139,33 @@ export function useCanvasConnections({
isConnectDragActiveRef.current = false;
const validationError = validateCanvasConnection(connection, nodes, edges);
if (validationError) {
+ logCanvasConnectionDebug("connect:invalid-direct", {
+ sourceNodeId: connection.source ?? null,
+ targetNodeId: connection.target ?? null,
+ sourceHandle: connection.sourceHandle ?? null,
+ targetHandle: connection.targetHandle ?? null,
+ validationError,
+ });
showConnectionRejectedToast(validationError);
return;
}
- if (!connection.source || !connection.target) return;
+ if (!connection.source || !connection.target) {
+ logCanvasConnectionDebug("connect:missing-endpoint", {
+ sourceNodeId: connection.source ?? null,
+ targetNodeId: connection.target ?? null,
+ sourceHandle: connection.sourceHandle ?? null,
+ targetHandle: connection.targetHandle ?? null,
+ });
+ return;
+ }
+
+ logCanvasConnectionDebug("connect:direct", {
+ sourceNodeId: connection.source,
+ targetNodeId: connection.target,
+ sourceHandle: connection.sourceHandle ?? null,
+ targetHandle: connection.targetHandle ?? null,
+ });
void runCreateEdgeMutation({
canvasId,
@@ -136,18 +181,71 @@ export function useCanvasConnections({
const onConnectEnd = useCallback(
(event, connectionState) => {
if (!isConnectDragActiveRef.current) {
+ logCanvasConnectionDebug("connect:end-ignored", {
+ reason: "drag-not-active",
+ isValid: connectionState.isValid ?? null,
+ fromNodeId: connectionState.fromNode?.id ?? null,
+ fromHandleId: connectionState.fromHandle?.id ?? null,
+ toNodeId: connectionState.toNode?.id ?? null,
+ toHandleId: connectionState.toHandle?.id ?? null,
+ });
return;
}
isConnectDragActiveRef.current = false;
- if (isReconnectDragActiveRef.current) return;
- if (connectionState.isValid === true) return;
+ if (isReconnectDragActiveRef.current) {
+ logCanvasConnectionDebug("connect:end-ignored", {
+ reason: "reconnect-active",
+ isValid: connectionState.isValid ?? null,
+ fromNodeId: connectionState.fromNode?.id ?? null,
+ fromHandleId: connectionState.fromHandle?.id ?? null,
+ toNodeId: connectionState.toNode?.id ?? null,
+ toHandleId: connectionState.toHandle?.id ?? null,
+ });
+ return;
+ }
+ if (connectionState.isValid === true) {
+ logCanvasConnectionDebug("connect:end-ignored", {
+ reason: "react-flow-valid-connection",
+ fromNodeId: connectionState.fromNode?.id ?? null,
+ fromHandleId: connectionState.fromHandle?.id ?? null,
+ toNodeId: connectionState.toNode?.id ?? null,
+ toHandleId: connectionState.toHandle?.id ?? null,
+ });
+ return;
+ }
const fromNode = connectionState.fromNode;
const fromHandle = connectionState.fromHandle;
- if (!fromNode || !fromHandle) return;
+ if (!fromNode || !fromHandle) {
+ logCanvasConnectionDebug("connect:end-aborted", {
+ reason: "missing-from-node-or-handle",
+ fromNodeId: fromNode?.id ?? null,
+ fromHandleId: fromHandle?.id ?? null,
+ toNodeId: connectionState.toNode?.id ?? null,
+ toHandleId: connectionState.toHandle?.id ?? null,
+ });
+ return;
+ }
const pt = getConnectEndClientPoint(event);
- if (!pt) return;
+ if (!pt) {
+ logCanvasConnectionDebug("connect:end-aborted", {
+ reason: "missing-client-point",
+ fromNodeId: fromNode.id,
+ fromHandleId: fromHandle.id ?? null,
+ fromHandleType: fromHandle.type,
+ });
+ return;
+ }
+
+ logCanvasConnectionDebug("connect:end", {
+ point: pt,
+ fromNodeId: fromNode.id,
+ fromHandleId: fromHandle.id ?? null,
+ fromHandleType: fromHandle.type,
+ toNodeId: connectionState.toNode?.id ?? null,
+ toHandleId: connectionState.toHandle?.id ?? null,
+ });
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
const droppedConnection = resolveDroppedConnectionTarget({
@@ -159,6 +257,15 @@ export function useCanvasConnections({
edges: edgesRef.current,
});
+ logCanvasConnectionDebug("connect:end-drop-result", {
+ point: pt,
+ flow,
+ fromNodeId: fromNode.id,
+ fromHandleId: fromHandle.id ?? null,
+ fromHandleType: fromHandle.type,
+ droppedConnection,
+ });
+
if (droppedConnection) {
const validationError = validateCanvasConnection(
{
@@ -171,10 +278,75 @@ export function useCanvasConnections({
edgesRef.current,
);
if (validationError) {
+ const fullFromNode = nodesRef.current.find((node) => node.id === fromNode.id);
+ const splitHandles = NODE_HANDLE_MAP[fullFromNode?.type ?? ""];
+ const incomingEdges = edgesRef.current.filter(
+ (edge) =>
+ edge.target === droppedConnection.targetNodeId &&
+ edge.className !== "temp" &&
+ !isOptimisticEdgeId(edge.id),
+ );
+ const incomingEdge = incomingEdges.length === 1 ? incomingEdges[0] : undefined;
+ const splitValidationError =
+ validationError === "adjustment-incoming-limit" &&
+ droppedConnection.sourceNodeId === fromNode.id &&
+ fromHandle.type === "source" &&
+ fullFromNode !== undefined &&
+ splitHandles !== undefined &&
+ hasHandleKey(splitHandles, "source") &&
+ hasHandleKey(splitHandles, "target") &&
+ incomingEdge !== undefined &&
+ incomingEdge.source !== fullFromNode.id &&
+ incomingEdge.target !== fullFromNode.id
+ ? validateCanvasEdgeSplit({
+ nodes: nodesRef.current,
+ edges: edgesRef.current,
+ splitEdge: incomingEdge,
+ middleNode: fullFromNode,
+ })
+ : null;
+
+ if (!splitValidationError && incomingEdge && fullFromNode && splitHandles) {
+ logCanvasConnectionDebug("connect:end-auto-split", {
+ point: pt,
+ flow,
+ droppedConnection,
+ splitEdgeId: incomingEdge.id,
+ middleNodeId: fullFromNode.id,
+ });
+ void runSplitEdgeAtExistingNodeMutation({
+ canvasId,
+ splitEdgeId: incomingEdge.id as Id<"edges">,
+ middleNodeId: fullFromNode.id as Id<"nodes">,
+ splitSourceHandle: normalizeHandle(incomingEdge.sourceHandle),
+ splitTargetHandle: normalizeHandle(incomingEdge.targetHandle),
+ newNodeSourceHandle: normalizeHandle(splitHandles.source),
+ newNodeTargetHandle: normalizeHandle(splitHandles.target),
+ });
+ return;
+ }
+
+ logCanvasConnectionDebug("connect:end-drop-rejected", {
+ point: pt,
+ flow,
+ droppedConnection,
+ validationError,
+ attemptedAutoSplit:
+ validationError === "adjustment-incoming-limit" &&
+ droppedConnection.sourceNodeId === fromNode.id &&
+ fromHandle.type === "source",
+ splitValidationError,
+ });
showConnectionRejectedToast(validationError);
return;
}
+ logCanvasConnectionDebug("connect:end-create-edge", {
+ point: pt,
+ flow,
+ droppedConnection,
+ });
+
void runCreateEdgeMutation({
canvasId,
sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">,
@@ -185,6 +357,14 @@ export function useCanvasConnections({
return;
}
+ logCanvasConnectionDebug("connect:end-open-menu", {
+ point: pt,
+ flow,
+ fromNodeId: fromNode.id,
+ fromHandleId: fromHandle.id ?? null,
+ fromHandleType: fromHandle.type,
+ });
+
setConnectionDropMenu({
screenX: pt.x,
screenY: pt.y,
@@ -201,6 +381,7 @@ export function useCanvasConnections({
isReconnectDragActiveRef,
nodesRef,
runCreateEdgeMutation,
+ runSplitEdgeAtExistingNodeMutation,
screenToFlowPosition,
showConnectionRejectedToast,
],
diff --git a/components/canvas/use-canvas-drop.ts b/components/canvas/use-canvas-drop.ts
index 248a3b7..f0826a9 100644
--- a/components/canvas/use-canvas-drop.ts
+++ b/components/canvas/use-canvas-drop.ts
@@ -4,19 +4,34 @@ import type { Id } from "@/convex/_generated/dataModel";
import {
CANVAS_NODE_DND_MIME,
} from "@/lib/canvas-connection-policy";
-import { NODE_DEFAULTS } from "@/lib/canvas-utils";
+import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
import {
isCanvasNodeType,
type CanvasNodeType,
} from "@/lib/canvas-node-types";
import { toast } from "@/lib/toast";
+import {
+ getIntersectedEdgeId,
+ hasHandleKey,
+ isOptimisticEdgeId,
+ logCanvasConnectionDebug,
+ normalizeHandle,
+} from "./canvas-helpers";
import { getImageDimensions } from "./canvas-media-utils";
type UseCanvasDropParams = {
canvasId: Id<"canvases">;
isSyncOnline: boolean;
t: (key: string) => string;
+ edges: Array<{
+ id: string;
+ source: string;
+ target: string;
+ className?: string;
+ sourceHandle?: string | null;
+ targetHandle?: string | null;
+ }>;
screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
generateUploadUrl: () => Promise;
runCreateNodeOnlineOnly: (args: {
@@ -29,6 +44,21 @@ type UseCanvasDropParams = {
data: Record;
clientRequestId?: string;
}) => Promise>;
+ runCreateNodeWithEdgeSplitOnlineOnly: (args: {
+ canvasId: Id<"canvases">;
+ type: CanvasNodeType;
+ positionX: number;
+ positionY: number;
+ width: number;
+ height: number;
+ data: Record;
+ splitEdgeId: Id<"edges">;
+ newNodeTargetHandle?: string;
+ newNodeSourceHandle?: string;
+ splitSourceHandle?: string;
+ splitTargetHandle?: string;
+ clientRequestId?: string;
+ }) => Promise>;
notifyOfflineUnsupported: (featureLabel: string) => void;
syncPendingMoveForClientRequest: (
clientRequestId: string,
@@ -66,9 +96,11 @@ export function useCanvasDrop({
canvasId,
isSyncOnline,
t,
+ edges,
screenToFlowPosition,
generateUploadUrl,
runCreateNodeOnlineOnly,
+ runCreateNodeWithEdgeSplitOnlineOnly,
notifyOfflineUnsupported,
syncPendingMoveForClientRequest,
}: UseCanvasDropParams) {
@@ -169,23 +201,92 @@ export function useCanvasDrop({
x: event.clientX,
y: event.clientY,
});
+ const intersectedEdgeId =
+ typeof document !== "undefined" &&
+ typeof document.elementsFromPoint === "function"
+ ? getIntersectedEdgeId({
+ x: event.clientX,
+ y: event.clientY,
+ })
+ : null;
const defaults = NODE_DEFAULTS[parsedPayload.nodeType] ?? {
width: 200,
height: 100,
data: {},
};
const clientRequestId = crypto.randomUUID();
+ const hitEdge = intersectedEdgeId
+ ? edges.find(
+ (edge) =>
+ edge.id === intersectedEdgeId &&
+ edge.className !== "temp" &&
+ !isOptimisticEdgeId(edge.id),
+ )
+ : undefined;
+ const handles = NODE_HANDLE_MAP[parsedPayload.nodeType];
+ const canSplitEdge =
+ hitEdge !== undefined &&
+ handles !== undefined &&
+ hasHandleKey(handles, "source") &&
+ hasHandleKey(handles, "target");
- void runCreateNodeOnlineOnly({
- canvasId,
- type: parsedPayload.nodeType,
- positionX: position.x,
- positionY: position.y,
- width: defaults.width,
- height: defaults.height,
- data: { ...defaults.data, ...parsedPayload.payloadData, canvasId },
- clientRequestId,
- }).then((realId) => {
+ logCanvasConnectionDebug("node-drop", {
+ nodeType: parsedPayload.nodeType,
+ clientPoint: { x: event.clientX, y: event.clientY },
+ flowPoint: position,
+ intersectedEdgeId,
+ hitEdgeId: hitEdge?.id ?? null,
+ usesEdgeSplitPath: canSplitEdge,
+ });
+
+ const createNodePromise = canSplitEdge
+ ? (() => {
+ logCanvasConnectionDebug("node-drop:split-edge", {
+ nodeType: parsedPayload.nodeType,
+ clientPoint: { x: event.clientX, y: event.clientY },
+ flowPoint: position,
+ intersectedEdgeId,
+ splitEdgeId: hitEdge.id,
+ });
+ return runCreateNodeWithEdgeSplitOnlineOnly({
+ canvasId,
+ type: parsedPayload.nodeType,
+ positionX: position.x,
+ positionY: position.y,
+ width: defaults.width,
+ height: defaults.height,
+ data: { ...defaults.data, ...parsedPayload.payloadData, canvasId },
+ splitEdgeId: hitEdge.id as Id<"edges">,
+ newNodeTargetHandle: normalizeHandle(handles.target),
+ newNodeSourceHandle: normalizeHandle(handles.source),
+ splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
+ splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
+ clientRequestId,
+ });
+ })()
+ : (() => {
+ if (intersectedEdgeId) {
+ logCanvasConnectionDebug("node-drop:edge-detected-no-split", {
+ nodeType: parsedPayload.nodeType,
+ clientPoint: { x: event.clientX, y: event.clientY },
+ flowPoint: position,
+ intersectedEdgeId,
+ });
+ }
+
+ return runCreateNodeOnlineOnly({
+ canvasId,
+ type: parsedPayload.nodeType,
+ positionX: position.x,
+ positionY: position.y,
+ width: defaults.width,
+ height: defaults.height,
+ data: { ...defaults.data, ...parsedPayload.payloadData, canvasId },
+ clientRequestId,
+ });
+ })();
+
+ void createNodePromise.then((realId) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => {
console.error("[Canvas] createNode syncPendingMove failed", error);
@@ -195,9 +296,11 @@ export function useCanvasDrop({
},
[
canvasId,
+ edges,
generateUploadUrl,
isSyncOnline,
notifyOfflineUnsupported,
+ runCreateNodeWithEdgeSplitOnlineOnly,
runCreateNodeOnlineOnly,
screenToFlowPosition,
syncPendingMoveForClientRequest,
diff --git a/components/canvas/use-canvas-edge-insertions.ts b/components/canvas/use-canvas-edge-insertions.ts
new file mode 100644
index 0000000..d1638d0
--- /dev/null
+++ b/components/canvas/use-canvas-edge-insertions.ts
@@ -0,0 +1,218 @@
+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;
+ splitEdgeId: Id<"edges">;
+ newNodeTargetHandle?: string;
+ newNodeSourceHandle?: string;
+ splitSourceHandle?: string;
+ splitTargetHandle?: string;
+ clientRequestId?: string;
+ }) => Promise | string>;
+ runBatchMoveNodesMutation: (args: {
+ moves: {
+ nodeId: Id<"nodes">;
+ positionX: number;
+ positionY: number;
+ }[];
+ }) => Promise;
+ showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void;
+};
+
+export function useCanvasEdgeInsertions({
+ canvasId,
+ nodes,
+ edges,
+ runCreateNodeWithEdgeSplitOnlineOnly,
+ runBatchMoveNodesMutation,
+ showConnectionRejectedToast,
+}: UseCanvasEdgeInsertionsArgs) {
+ const [edgeInsertMenu, setEdgeInsertMenu] = useState(null);
+ const edgeInsertMenuRef = useRef(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),
+ 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,
+ };
+}
diff --git a/components/canvas/use-canvas-edge-types.tsx b/components/canvas/use-canvas-edge-types.tsx
new file mode 100644
index 0000000..75e4bdd
--- /dev/null
+++ b/components/canvas/use-canvas-edge-types.tsx
@@ -0,0 +1,51 @@
+import { useEffect, useMemo, useRef } from "react";
+import type { EdgeTypes } from "@xyflow/react";
+
+import { isOptimisticEdgeId } from "@/components/canvas/canvas-helpers";
+import type { DefaultEdgeInsertAnchor } from "@/components/canvas/edges/default-edge";
+import DefaultEdge from "@/components/canvas/edges/default-edge";
+
+type UseCanvasEdgeTypesArgs = {
+ edgeInsertMenuEdgeId: string | null;
+ scissorsMode: boolean;
+ onInsertClick: (anchor: DefaultEdgeInsertAnchor) => void;
+};
+
+export function useCanvasEdgeTypes({
+ edgeInsertMenuEdgeId,
+ scissorsMode,
+ onInsertClick,
+}: UseCanvasEdgeTypesArgs): EdgeTypes {
+ const edgeInsertMenuEdgeIdRef = useRef(edgeInsertMenuEdgeId);
+ const scissorsModeRef = useRef(scissorsMode);
+ const onInsertClickRef = useRef(onInsertClick);
+
+ useEffect(() => {
+ edgeInsertMenuEdgeIdRef.current = edgeInsertMenuEdgeId;
+ scissorsModeRef.current = scissorsMode;
+ onInsertClickRef.current = onInsertClick;
+ }, [edgeInsertMenuEdgeId, onInsertClick, scissorsMode]);
+
+ return useMemo(
+ () => ({
+ "canvas-default": (edgeProps: Parameters[0]) => {
+ const edgeClassName = (edgeProps as { className?: string }).className;
+ const isInsertableEdge =
+ edgeClassName !== "temp" && !isOptimisticEdgeId(edgeProps.id);
+
+ return (
+
+ );
+ },
+ }),
+ [],
+ );
+}
diff --git a/components/canvas/use-canvas-flow-reconciliation.ts b/components/canvas/use-canvas-flow-reconciliation.ts
index f7f50f1..8eb68fb 100644
--- a/components/canvas/use-canvas-flow-reconciliation.ts
+++ b/components/canvas/use-canvas-flow-reconciliation.ts
@@ -20,6 +20,7 @@ type CanvasFlowReconciliationRefs = {
pendingLocalPositionUntilConvexMatchesRef: MutableRefObject<
Map
>;
+ pendingLocalNodeDataUntilConvexMatchesRef: MutableRefObject