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

@@ -3,6 +3,7 @@ import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow
import { readCanvasOps } from "@/lib/canvas-local-persistence";
import type { Doc, Id } from "@/convex/_generated/dataModel";
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
import { getSourceImage } from "@/lib/image-pipeline/contracts";
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
@@ -81,6 +82,50 @@ export type PendingEdgeSplit = {
export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] {
const persistedEdges = edges.filter((edge) => edge.className !== "temp");
const pipelineNodes = nodes.map((node) => ({
id: node.id,
type: node.type ?? "",
data: node.data,
}));
const pipelineEdges = persistedEdges.map((edge) => ({
source: edge.source,
target: edge.target,
}));
const resolveImageFromNode = (node: RFNode): string | undefined => {
const nodeData = node.data as { url?: string; previewUrl?: string };
if (typeof nodeData.url === "string" && nodeData.url.length > 0) {
return nodeData.url;
}
if (typeof nodeData.previewUrl === "string" && nodeData.previewUrl.length > 0) {
return nodeData.previewUrl;
}
return undefined;
};
const resolvePipelineImageUrl = (sourceNode: RFNode): string | undefined => {
const direct = resolveImageFromNode(sourceNode);
if (direct) {
return direct;
}
return getSourceImage({
nodeId: sourceNode.id,
nodes: pipelineNodes,
edges: pipelineEdges,
isSourceNode: (node) =>
node.type === "image" ||
node.type === "ai-image" ||
node.type === "asset" ||
node.type === "render",
getSourceImageFromNode: (node) => {
const candidate = nodes.find((entry) => entry.id === node.id);
if (!candidate) return null;
return resolveImageFromNode(candidate) ?? null;
},
}) ?? undefined;
};
let hasNodeUpdates = false;
const nextNodes = nodes.map((node) => {
@@ -97,12 +142,13 @@ export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNod
if (!source) continue;
const srcData = source.data as { url?: string; label?: string };
const resolvedUrl = resolvePipelineImageUrl(source);
if (edge.targetHandle === "left") {
leftUrl = srcData.url;
leftUrl = resolvedUrl;
leftLabel = srcData.label ?? source.type ?? "Before";
} else if (edge.targetHandle === "right") {
rightUrl = srcData.url;
rightUrl = resolvedUrl;
rightLabel = srcData.label ?? source.type ?? "After";
}
}

View File

@@ -40,6 +40,7 @@ import {
isNodePaletteEnabled,
type NodeCatalogEntry,
} from "@/lib/canvas-node-catalog";
import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy";
import { cn } from "@/lib/utils";
const CATALOG_ICONS: Partial<Record<string, LucideIcon>> = {
@@ -89,7 +90,7 @@ function SidebarRow({
const onDragStart = (event: React.DragEvent) => {
if (!enabled) return;
event.dataTransfer.setData("application/lemonspace-node-type", entry.type);
event.dataTransfer.setData(CANVAS_NODE_DND_MIME, entry.type);
event.dataTransfer.effectAllowed = "move";
};

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;