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 { readCanvasOps } from "@/lib/canvas-local-persistence";
import type { Doc, Id } from "@/convex/_generated/dataModel"; import type { Doc, Id } from "@/convex/_generated/dataModel";
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast"; import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
import { getSourceImage } from "@/lib/image-pipeline/contracts";
export const OPTIMISTIC_NODE_PREFIX = "optimistic_"; export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_"; export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
@@ -81,6 +82,50 @@ export type PendingEdgeSplit = {
export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] { export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] {
const persistedEdges = edges.filter((edge) => edge.className !== "temp"); 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; let hasNodeUpdates = false;
const nextNodes = nodes.map((node) => { const nextNodes = nodes.map((node) => {
@@ -97,12 +142,13 @@ export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNod
if (!source) continue; if (!source) continue;
const srcData = source.data as { url?: string; label?: string }; const srcData = source.data as { url?: string; label?: string };
const resolvedUrl = resolvePipelineImageUrl(source);
if (edge.targetHandle === "left") { if (edge.targetHandle === "left") {
leftUrl = srcData.url; leftUrl = resolvedUrl;
leftLabel = srcData.label ?? source.type ?? "Before"; leftLabel = srcData.label ?? source.type ?? "Before";
} else if (edge.targetHandle === "right") { } else if (edge.targetHandle === "right") {
rightUrl = srcData.url; rightUrl = resolvedUrl;
rightLabel = srcData.label ?? source.type ?? "After"; rightLabel = srcData.label ?? source.type ?? "After";
} }
} }

View File

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

View File

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

View File

@@ -2,25 +2,20 @@ import { query, mutation, type MutationCtx } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { requireAuth } from "./helpers"; import { requireAuth } from "./helpers";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import { isAdjustmentNodeType } from "../lib/canvas-node-types"; import {
getCanvasConnectionValidationMessage,
validateCanvasConnectionPolicy,
} from "../lib/canvas-connection-policy";
const PERFORMANCE_LOG_THRESHOLD_MS = 250; const PERFORMANCE_LOG_THRESHOLD_MS = 250;
async function assertTargetAllowsIncomingEdge( async function countIncomingEdges(
ctx: MutationCtx, ctx: MutationCtx,
args: { args: {
targetNodeId: Id<"nodes">; targetNodeId: Id<"nodes">;
edgeIdToIgnore?: Id<"edges">; edgeIdToIgnore?: Id<"edges">;
}, },
): Promise<void> { ): Promise<number> {
const targetNode = await ctx.db.get(args.targetNodeId);
if (!targetNode) {
throw new Error("Target node not found");
}
if (!isAdjustmentNodeType(targetNode.type)) {
return;
}
const incomingEdgesQuery = ctx.db const incomingEdgesQuery = ctx.db
.query("edges") .query("edges")
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)); .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
@@ -33,9 +28,11 @@ async function assertTargetAllowsIncomingEdge(
); );
const checkDurationMs = Date.now() - checkStartedAt; const checkDurationMs = Date.now() - checkStartedAt;
const hasAnyIncoming = Array.isArray(incomingEdges) const incomingCount = Array.isArray(incomingEdges)
? incomingEdges.some((edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore) ? incomingEdges.filter((edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore).length
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore; : incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore
? 1
: 0;
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
const inspected = Array.isArray(incomingEdges) const inspected = Array.isArray(incomingEdges)
? incomingEdges.length ? incomingEdges.length
@@ -51,8 +48,34 @@ async function assertTargetAllowsIncomingEdge(
}); });
} }
if (hasAnyIncoming) { return incomingCount;
throw new Error("Adjustment nodes allow only one incoming edge."); }
async function assertConnectionPolicy(
ctx: MutationCtx,
args: {
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
edgeIdToIgnore?: Id<"edges">;
},
): Promise<void> {
const sourceNode = await ctx.db.get(args.sourceNodeId);
const targetNode = await ctx.db.get(args.targetNodeId);
if (!sourceNode || !targetNode) {
throw new Error("Source or target node not found");
}
const reason = validateCanvasConnectionPolicy({
sourceType: sourceNode.type,
targetType: targetNode.type,
targetIncomingCount: await countIncomingEdges(ctx, {
targetNodeId: args.targetNodeId,
edgeIdToIgnore: args.edgeIdToIgnore,
}),
});
if (reason) {
throw new Error(getCanvasConnectionValidationMessage(reason));
} }
} }
@@ -142,7 +165,8 @@ export const create = mutation({
throw new Error("Cannot connect a node to itself"); throw new Error("Cannot connect a node to itself");
} }
await assertTargetAllowsIncomingEdge(ctx, { await assertConnectionPolicy(ctx, {
sourceNodeId: args.sourceNodeId,
targetNodeId: args.targetNodeId, targetNodeId: args.targetNodeId,
}); });

View File

@@ -3,6 +3,10 @@ import { v } from "convex/values";
import { requireAuth } from "./helpers"; import { requireAuth } from "./helpers";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import { isAdjustmentNodeType } from "../lib/canvas-node-types"; import { isAdjustmentNodeType } from "../lib/canvas-node-types";
import {
getCanvasConnectionValidationMessage,
validateCanvasConnectionPolicy,
} from "../lib/canvas-connection-policy";
import { nodeTypeValidator } from "./node_type_validator"; import { nodeTypeValidator } from "./node_type_validator";
// ============================================================================ // ============================================================================
@@ -373,22 +377,13 @@ function normalizeNodeDataForWrite(
return data; return data;
} }
async function assertTargetAllowsIncomingEdge( async function countIncomingEdges(
ctx: MutationCtx, ctx: MutationCtx,
args: { args: {
targetNodeId: Id<"nodes">; targetNodeId: Id<"nodes">;
edgeIdToIgnore?: Id<"edges">; edgeIdToIgnore?: Id<"edges">;
}, },
): Promise<void> { ): Promise<number> {
const targetNode = await ctx.db.get(args.targetNodeId);
if (!targetNode) {
throw new Error("Target node not found");
}
if (!isAdjustmentNodeType(targetNode.type)) {
return;
}
const incomingEdgesQuery = ctx.db const incomingEdgesQuery = ctx.db
.query("edges") .query("edges")
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)); .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
@@ -399,9 +394,11 @@ async function assertTargetAllowsIncomingEdge(
); );
const checkDurationMs = Date.now() - checkStartedAt; const checkDurationMs = Date.now() - checkStartedAt;
const hasAnyIncoming = Array.isArray(incomingEdges) const incomingCount = Array.isArray(incomingEdges)
? incomingEdges.some((edge) => edge._id !== args.edgeIdToIgnore) ? incomingEdges.filter((edge) => edge._id !== args.edgeIdToIgnore).length
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore; : incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore
? 1
: 0;
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
const inspected = Array.isArray(incomingEdges) const inspected = Array.isArray(incomingEdges)
? incomingEdges.length ? incomingEdges.length
@@ -409,7 +406,7 @@ async function assertTargetAllowsIncomingEdge(
? 0 ? 0
: 1; : 1;
console.warn("[nodes.assertTargetAllowsIncomingEdge] slow incoming edge check", { console.warn("[nodes.countIncomingEdges] slow incoming edge check", {
targetNodeId: args.targetNodeId, targetNodeId: args.targetNodeId,
edgeIdToIgnore: args.edgeIdToIgnore, edgeIdToIgnore: args.edgeIdToIgnore,
inspected, inspected,
@@ -417,8 +414,29 @@ async function assertTargetAllowsIncomingEdge(
}); });
} }
if (hasAnyIncoming) { return incomingCount;
throw new Error("Adjustment nodes allow only one incoming edge."); }
async function assertConnectionPolicyForTypes(
ctx: MutationCtx,
args: {
sourceType: Doc<"nodes">["type"];
targetType: Doc<"nodes">["type"];
targetNodeId: Id<"nodes">;
edgeIdToIgnore?: Id<"edges">;
},
): Promise<void> {
const reason = validateCanvasConnectionPolicy({
sourceType: args.sourceType,
targetType: args.targetType,
targetIncomingCount: await countIncomingEdges(ctx, {
targetNodeId: args.targetNodeId,
edgeIdToIgnore: args.edgeIdToIgnore,
}),
});
if (reason) {
throw new Error(getCanvasConnectionValidationMessage(reason));
} }
} }
@@ -699,6 +717,28 @@ export const createWithEdgeSplit = mutation({
throw new Error("Edge not found"); throw new Error("Edge not found");
} }
const sourceNode = await ctx.db.get(edge.sourceNodeId);
const targetNode = await ctx.db.get(edge.targetNodeId);
if (!sourceNode || !targetNode) {
throw new Error("Source or target node not found");
}
const firstEdgeReason = validateCanvasConnectionPolicy({
sourceType: sourceNode.type,
targetType: args.type,
targetIncomingCount: 0,
});
if (firstEdgeReason) {
throw new Error(getCanvasConnectionValidationMessage(firstEdgeReason));
}
await assertConnectionPolicyForTypes(ctx, {
sourceType: args.type,
targetType: targetNode.type,
targetNodeId: edge.targetNodeId,
edgeIdToIgnore: args.splitEdgeId,
});
const normalizedData = normalizeNodeDataForWrite(args.type, args.data); const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
const nodeId = await ctx.db.insert("nodes", { const nodeId = await ctx.db.insert("nodes", {
@@ -805,6 +845,12 @@ export const splitEdgeAtExistingNode = mutation({
throw new Error("Middle node not found"); throw new Error("Middle node not found");
} }
const sourceNode = await ctx.db.get(edge.sourceNodeId);
const targetNode = await ctx.db.get(edge.targetNodeId);
if (!sourceNode || !targetNode) {
throw new Error("Source or target node not found");
}
if ( if (
args.positionX !== undefined && args.positionX !== undefined &&
args.positionY !== undefined args.positionY !== undefined
@@ -815,6 +861,12 @@ export const splitEdgeAtExistingNode = mutation({
}); });
} }
await assertConnectionPolicyForTypes(ctx, {
sourceType: sourceNode.type,
targetType: middle.type,
targetNodeId: args.middleNodeId,
});
await ctx.db.insert("edges", { await ctx.db.insert("edges", {
canvasId: args.canvasId, canvasId: args.canvasId,
sourceNodeId: edge.sourceNodeId, sourceNodeId: edge.sourceNodeId,
@@ -823,7 +875,9 @@ export const splitEdgeAtExistingNode = mutation({
targetHandle: args.newNodeTargetHandle, targetHandle: args.newNodeTargetHandle,
}); });
await assertTargetAllowsIncomingEdge(ctx, { await assertConnectionPolicyForTypes(ctx, {
sourceType: middle.type,
targetType: targetNode.type,
targetNodeId: edge.targetNodeId, targetNodeId: edge.targetNodeId,
edgeIdToIgnore: args.splitEdgeId, edgeIdToIgnore: args.splitEdgeId,
}); });
@@ -892,6 +946,15 @@ export const createWithEdgeFromSource = mutation({
throw new Error("Source node not found"); throw new Error("Source node not found");
} }
const fromSourceReason = validateCanvasConnectionPolicy({
sourceType: source.type,
targetType: args.type,
targetIncomingCount: 0,
});
if (fromSourceReason) {
throw new Error(getCanvasConnectionValidationMessage(fromSourceReason));
}
const normalizedData = normalizeNodeDataForWrite(args.type, args.data); const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
const nodeId = await ctx.db.insert("nodes", { const nodeId = await ctx.db.insert("nodes", {
@@ -968,6 +1031,12 @@ export const createWithEdgeToTarget = mutation({
throw new Error("Target node not found"); throw new Error("Target node not found");
} }
await assertConnectionPolicyForTypes(ctx, {
sourceType: args.type,
targetType: target.type,
targetNodeId: args.targetNodeId,
});
const normalizedData = normalizeNodeDataForWrite(args.type, args.data); const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
const nodeId = await ctx.db.insert("nodes", { const nodeId = await ctx.db.insert("nodes", {
@@ -984,10 +1053,6 @@ export const createWithEdgeToTarget = mutation({
zIndex: args.zIndex, zIndex: args.zIndex,
}); });
await assertTargetAllowsIncomingEdge(ctx, {
targetNodeId: args.targetNodeId,
});
await ctx.db.insert("edges", { await ctx.db.insert("edges", {
canvasId: args.canvasId, canvasId: args.canvasId,
sourceNodeId: nodeId, sourceNodeId: nodeId,

View File

@@ -0,0 +1,87 @@
import { isAdjustmentNodeType } from "@/lib/canvas-node-types";
export const CANVAS_NODE_DND_MIME = "application/lemonspace-node-type";
const ADJUSTMENT_ALLOWED_SOURCE_TYPES = new Set<string>([
"image",
"asset",
"ai-image",
"curves",
"color-adjust",
"light-adjust",
"detail-adjust",
]);
const RENDER_ALLOWED_SOURCE_TYPES = new Set<string>([
"image",
"asset",
"ai-image",
"curves",
"color-adjust",
"light-adjust",
"detail-adjust",
]);
const ADJUSTMENT_DISALLOWED_TARGET_TYPES = new Set<string>(["prompt", "ai-image"]);
export type CanvasConnectionValidationReason =
| "incomplete"
| "self-loop"
| "unknown-node"
| "adjustment-source-invalid"
| "adjustment-incoming-limit"
| "adjustment-target-forbidden"
| "render-source-invalid";
export function validateCanvasConnectionPolicy(args: {
sourceType: string;
targetType: string;
targetIncomingCount: number;
}): CanvasConnectionValidationReason | null {
const { sourceType, targetType, targetIncomingCount } = args;
if (isAdjustmentNodeType(targetType)) {
if (!ADJUSTMENT_ALLOWED_SOURCE_TYPES.has(sourceType)) {
return "adjustment-source-invalid";
}
if (targetIncomingCount >= 1) {
return "adjustment-incoming-limit";
}
}
if (targetType === "render" && !RENDER_ALLOWED_SOURCE_TYPES.has(sourceType)) {
return "render-source-invalid";
}
if (
isAdjustmentNodeType(sourceType) &&
ADJUSTMENT_DISALLOWED_TARGET_TYPES.has(targetType)
) {
return "adjustment-target-forbidden";
}
return null;
}
export function getCanvasConnectionValidationMessage(
reason: CanvasConnectionValidationReason,
): string {
switch (reason) {
case "incomplete":
return "Unvollstaendige Verbindung.";
case "self-loop":
return "Node kann nicht mit sich selbst verbunden werden.";
case "unknown-node":
return "Verbindung enthaelt unbekannte Nodes.";
case "adjustment-source-invalid":
return "Adjustment-Nodes akzeptieren nur Bild-, Asset-, KI-Bild- oder Adjustment-Input.";
case "adjustment-incoming-limit":
return "Adjustment-Nodes erlauben genau eine eingehende Verbindung.";
case "adjustment-target-forbidden":
return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden.";
case "render-source-invalid":
return "Render akzeptiert nur Bild-, Asset-, KI-Bild- oder Adjustment-Input.";
default:
return "Verbindung ist fuer diese Node-Typen nicht erlaubt.";
}
}

View File

@@ -3,6 +3,10 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { toast, type ToastDurationOverrides } from './toast'; import { toast, type ToastDurationOverrides } from './toast';
import type { CanvasNodeDeleteBlockReason } from './toast'; import type { CanvasNodeDeleteBlockReason } from './toast';
import {
getCanvasConnectionValidationMessage,
type CanvasConnectionValidationReason,
} from '@/lib/canvas-connection-policy';
const DURATION = { const DURATION = {
success: 4000, success: 4000,
@@ -77,6 +81,13 @@ export const msg = {
desc: `${why.desc} ${suffix}`, desc: `${why.desc} ${suffix}`,
}; };
}, },
connectionRejected: (
_t: ToastTranslations,
reason: CanvasConnectionValidationReason,
) => ({
title: 'Verbindung abgelehnt',
desc: getCanvasConnectionValidationMessage(reason),
}),
}, },
ai: { ai: {
generating: (t: ToastTranslations) => ({ title: t('ai.generating') }), generating: (t: ToastTranslations) => ({ title: t('ai.generating') }),
@@ -205,3 +216,12 @@ export const msg = {
deleteFailed: (t: ToastTranslations) => ({ title: t('dashboard.deleteFailed') }), deleteFailed: (t: ToastTranslations) => ({ title: t('dashboard.deleteFailed') }),
}, },
} as const; } as const;
export function showCanvasConnectionRejectedToast(
t: ToastTranslations,
reason: CanvasConnectionValidationReason,
duration?: ToastDurationOverrides,
): void {
const payload = msg.canvas.connectionRejected(t, reason);
toast.warning(payload.title, payload.desc, duration ?? { duration: DURATION.warning });
}