Enhance canvas connection validation and image resolution handling

- Introduced new functions for validating canvas connections, ensuring proper source and target node types.
- Updated edge and node mutation logic to enforce connection policies and improve error handling.
- Enhanced image resolution handling by integrating a new image source resolution function for better URL retrieval.
- Refactored existing validation logic to streamline connection checks and improve maintainability.
This commit is contained in:
Matthias
2026-04-02 22:27:05 +02:00
parent 3fa686d60d
commit 519caefae2
7 changed files with 356 additions and 82 deletions

View File

@@ -31,6 +31,12 @@ import {
import { cn } from "@/lib/utils";
import "@xyflow/react/dist/style.css";
import { toast } from "@/lib/toast";
import {
CANVAS_NODE_DND_MIME,
type CanvasConnectionValidationReason,
validateCanvasConnectionPolicy,
} from "@/lib/canvas-connection-policy";
import { showCanvasConnectionRejectedToast } from "@/lib/toast-messages";
import {
dropCanvasOpsByClientRequestIds,
dropCanvasOpsByEdgeIds,
@@ -66,7 +72,6 @@ import { api } from "@/convex/_generated/api";
import type { Doc, Id } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client";
import {
isAdjustmentNodeType,
isCanvasNodeType,
type CanvasNodeType,
} from "@/lib/canvas-node-types";
@@ -162,56 +167,53 @@ function hasStorageId(node: Doc<"nodes">): boolean {
return typeof data?.storageId === "string" && data.storageId.length > 0;
}
const ADJUSTMENT_ALLOWED_SOURCE_TYPES = new Set([
"image",
"asset",
"ai-image",
"curves",
"color-adjust",
"light-adjust",
"detail-adjust",
]);
const ADJUSTMENT_DISALLOWED_TARGET_TYPES = new Set(["prompt", "ai-image"]);
function validateCanvasConnection(
connection: Connection,
nodes: RFNode[],
edges: RFEdge[],
edgeToReplaceId?: string,
): string | null {
if (!connection.source || !connection.target) return "Unvollstaendige Verbindung.";
if (connection.source === connection.target) return "Node kann nicht mit sich selbst verbunden werden.";
): CanvasConnectionValidationReason | null {
if (!connection.source || !connection.target) return "incomplete";
if (connection.source === connection.target) return "self-loop";
const sourceNode = nodes.find((node) => node.id === connection.source);
const targetNode = nodes.find((node) => node.id === connection.target);
if (!sourceNode || !targetNode) return "Verbindung enthaelt unbekannte Nodes.";
if (!sourceNode || !targetNode) return "unknown-node";
const sourceType = sourceNode.type ?? "";
const targetType = targetNode.type ?? "";
if (isAdjustmentNodeType(targetType)) {
if (!ADJUSTMENT_ALLOWED_SOURCE_TYPES.has(sourceType)) {
return "Adjustment-Nodes akzeptieren nur Bild-, Asset-, KI-Bild- oder Adjustment-Input.";
}
const incomingCount = edges.filter(
return validateCanvasConnectionPolicy({
sourceType: sourceNode.type ?? "",
targetType: targetNode.type ?? "",
targetIncomingCount: edges.filter(
(edge) => edge.target === connection.target && edge.id !== edgeToReplaceId,
).length;
if (incomingCount >= 1) {
return "Adjustment-Nodes erlauben genau eine eingehende Verbindung.";
}
}
).length,
});
}
if (isAdjustmentNodeType(sourceType) && ADJUSTMENT_DISALLOWED_TARGET_TYPES.has(targetType)) {
return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden.";
}
function validateCanvasConnectionByType(args: {
sourceType: string;
targetType: string;
targetNodeId: string;
edges: RFEdge[];
}): CanvasConnectionValidationReason | null {
const targetIncomingCount = args.edges.filter(
(edge) => edge.target === args.targetNodeId,
).length;
return null;
return validateCanvasConnectionPolicy({
sourceType: args.sourceType,
targetType: args.targetType,
targetIncomingCount,
});
}
function CanvasInner({ canvasId }: CanvasInnerProps) {
const t = useTranslations('toasts');
const showConnectionRejectedToast = useCallback(
(reason: CanvasConnectionValidationReason) => {
showCanvasConnectionRejectedToast(t, reason);
},
[t],
);
const { screenToFlowPosition } = useReactFlow();
const { resolvedTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession();
@@ -1688,8 +1690,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
runRemoveEdgeMutation,
validateConnection: (oldEdge, nextConnection) =>
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id),
onInvalidConnection: (message) => {
toast.warning("Verbindung abgelehnt", message);
onInvalidConnection: (reason) => {
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
},
});
@@ -2330,7 +2332,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
(connection: Connection) => {
const validationError = validateCanvasConnection(connection, nodes, edges);
if (validationError) {
toast.warning("Verbindung abgelehnt", validationError);
showConnectionRejectedToast(validationError);
return;
}
@@ -2344,7 +2346,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
targetHandle: connection.targetHandle ?? undefined,
});
},
[canvasId, edges, nodes, runCreateEdgeMutation],
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast],
);
const onConnectEnd = useCallback<OnConnectEnd>(
@@ -2377,6 +2379,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const ctx = connectionDropMenuRef.current;
if (!ctx) return;
const fromNode = nodesRef.current.find((node) => node.id === ctx.fromNodeId);
if (!fromNode) {
showConnectionRejectedToast("unknown-node");
return;
}
const defaults = NODE_DEFAULTS[template.type] ?? {
width: 200,
height: 100,
@@ -2413,6 +2421,17 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
};
if (ctx.fromHandleType === "source") {
const validationError = validateCanvasConnectionByType({
sourceType: fromNode.type ?? "",
targetType: template.type,
targetNodeId: `__pending_${template.type}_${Date.now()}`,
edges: edgesRef.current,
});
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
void runCreateNodeWithEdgeFromSourceOnlineOnly({
...base,
sourceNodeId: ctx.fromNodeId,
@@ -2435,6 +2454,17 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
console.error("[Canvas] createNodeWithEdgeFromSource failed", error);
});
} else {
const validationError = validateCanvasConnectionByType({
sourceType: template.type,
targetType: fromNode.type ?? "",
targetNodeId: fromNode.id,
edges: edgesRef.current,
});
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
void runCreateNodeWithEdgeToTargetOnlineOnly({
...base,
targetNodeId: ctx.fromNodeId,
@@ -2462,6 +2492,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
canvasId,
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
showConnectionRejectedToast,
syncPendingMoveForClientRequest,
],
);
@@ -2477,7 +2508,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
event.preventDefault();
const rawData = event.dataTransfer.getData(
"application/lemonspace-node-type",
CANVAS_NODE_DND_MIME,
);
if (!rawData) {
const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0;