From 519caefae29a0c0a7e1c841fb8d5a24b50cba71c Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 2 Apr 2026 22:27:05 +0200 Subject: [PATCH] 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. --- components/canvas/canvas-helpers.ts | 50 +++++++++++- components/canvas/canvas-sidebar.tsx | 3 +- components/canvas/canvas.tsx | 111 +++++++++++++++++---------- convex/edges.ts | 58 ++++++++++---- convex/nodes.ts | 109 ++++++++++++++++++++------ lib/canvas-connection-policy.ts | 87 +++++++++++++++++++++ lib/toast-messages.ts | 20 +++++ 7 files changed, 356 insertions(+), 82 deletions(-) create mode 100644 lib/canvas-connection-policy.ts diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index ad84307..1870572 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -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"; } } diff --git a/components/canvas/canvas-sidebar.tsx b/components/canvas/canvas-sidebar.tsx index 3310822..18ef893 100644 --- a/components/canvas/canvas-sidebar.tsx +++ b/components/canvas/canvas-sidebar.tsx @@ -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> = { @@ -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"; }; diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 0d034de..8c72fd3 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -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( @@ -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; diff --git a/convex/edges.ts b/convex/edges.ts index 729f9c1..6c4c0fc 100644 --- a/convex/edges.ts +++ b/convex/edges.ts @@ -2,25 +2,20 @@ import { query, mutation, type MutationCtx } from "./_generated/server"; import { v } from "convex/values"; import { requireAuth } from "./helpers"; 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; -async function assertTargetAllowsIncomingEdge( +async function countIncomingEdges( ctx: MutationCtx, args: { targetNodeId: Id<"nodes">; edgeIdToIgnore?: Id<"edges">; }, -): Promise { - const targetNode = await ctx.db.get(args.targetNodeId); - if (!targetNode) { - throw new Error("Target node not found"); - } - if (!isAdjustmentNodeType(targetNode.type)) { - return; - } - +): Promise { const incomingEdgesQuery = ctx.db .query("edges") .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)); @@ -33,9 +28,11 @@ async function assertTargetAllowsIncomingEdge( ); const checkDurationMs = Date.now() - checkStartedAt; - const hasAnyIncoming = Array.isArray(incomingEdges) - ? incomingEdges.some((edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore) - : incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore; + const incomingCount = Array.isArray(incomingEdges) + ? incomingEdges.filter((edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore).length + : incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore + ? 1 + : 0; if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { const inspected = Array.isArray(incomingEdges) ? incomingEdges.length @@ -51,8 +48,34 @@ async function assertTargetAllowsIncomingEdge( }); } - if (hasAnyIncoming) { - throw new Error("Adjustment nodes allow only one incoming edge."); + return incomingCount; +} + +async function assertConnectionPolicy( + ctx: MutationCtx, + args: { + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + edgeIdToIgnore?: Id<"edges">; + }, +): Promise { + 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"); } - await assertTargetAllowsIncomingEdge(ctx, { + await assertConnectionPolicy(ctx, { + sourceNodeId: args.sourceNodeId, targetNodeId: args.targetNodeId, }); diff --git a/convex/nodes.ts b/convex/nodes.ts index 4bb7e9d..bee3f84 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -3,6 +3,10 @@ import { v } from "convex/values"; import { requireAuth } from "./helpers"; import type { Doc, Id } from "./_generated/dataModel"; import { isAdjustmentNodeType } from "../lib/canvas-node-types"; +import { + getCanvasConnectionValidationMessage, + validateCanvasConnectionPolicy, +} from "../lib/canvas-connection-policy"; import { nodeTypeValidator } from "./node_type_validator"; // ============================================================================ @@ -373,22 +377,13 @@ function normalizeNodeDataForWrite( return data; } -async function assertTargetAllowsIncomingEdge( +async function countIncomingEdges( ctx: MutationCtx, args: { targetNodeId: Id<"nodes">; edgeIdToIgnore?: Id<"edges">; }, -): Promise { - const targetNode = await ctx.db.get(args.targetNodeId); - if (!targetNode) { - throw new Error("Target node not found"); - } - - if (!isAdjustmentNodeType(targetNode.type)) { - return; - } - +): Promise { const incomingEdgesQuery = ctx.db .query("edges") .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)); @@ -399,9 +394,11 @@ async function assertTargetAllowsIncomingEdge( ); const checkDurationMs = Date.now() - checkStartedAt; - const hasAnyIncoming = Array.isArray(incomingEdges) - ? incomingEdges.some((edge) => edge._id !== args.edgeIdToIgnore) - : incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore; + const incomingCount = Array.isArray(incomingEdges) + ? incomingEdges.filter((edge) => edge._id !== args.edgeIdToIgnore).length + : incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore + ? 1 + : 0; if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { const inspected = Array.isArray(incomingEdges) ? incomingEdges.length @@ -409,7 +406,7 @@ async function assertTargetAllowsIncomingEdge( ? 0 : 1; - console.warn("[nodes.assertTargetAllowsIncomingEdge] slow incoming edge check", { + console.warn("[nodes.countIncomingEdges] slow incoming edge check", { targetNodeId: args.targetNodeId, edgeIdToIgnore: args.edgeIdToIgnore, inspected, @@ -417,8 +414,29 @@ async function assertTargetAllowsIncomingEdge( }); } - if (hasAnyIncoming) { - throw new Error("Adjustment nodes allow only one incoming edge."); + return incomingCount; +} + +async function assertConnectionPolicyForTypes( + ctx: MutationCtx, + args: { + sourceType: Doc<"nodes">["type"]; + targetType: Doc<"nodes">["type"]; + targetNodeId: Id<"nodes">; + edgeIdToIgnore?: Id<"edges">; + }, +): Promise { + 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"); } + 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 nodeId = await ctx.db.insert("nodes", { @@ -805,6 +845,12 @@ export const splitEdgeAtExistingNode = mutation({ 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 ( args.positionX !== 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", { canvasId: args.canvasId, sourceNodeId: edge.sourceNodeId, @@ -823,7 +875,9 @@ export const splitEdgeAtExistingNode = mutation({ targetHandle: args.newNodeTargetHandle, }); - await assertTargetAllowsIncomingEdge(ctx, { + await assertConnectionPolicyForTypes(ctx, { + sourceType: middle.type, + targetType: targetNode.type, targetNodeId: edge.targetNodeId, edgeIdToIgnore: args.splitEdgeId, }); @@ -892,6 +946,15 @@ export const createWithEdgeFromSource = mutation({ 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 nodeId = await ctx.db.insert("nodes", { @@ -968,6 +1031,12 @@ export const createWithEdgeToTarget = mutation({ 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 nodeId = await ctx.db.insert("nodes", { @@ -984,10 +1053,6 @@ export const createWithEdgeToTarget = mutation({ zIndex: args.zIndex, }); - await assertTargetAllowsIncomingEdge(ctx, { - targetNodeId: args.targetNodeId, - }); - await ctx.db.insert("edges", { canvasId: args.canvasId, sourceNodeId: nodeId, diff --git a/lib/canvas-connection-policy.ts b/lib/canvas-connection-policy.ts new file mode 100644 index 0000000..ff98d47 --- /dev/null +++ b/lib/canvas-connection-policy.ts @@ -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([ + "image", + "asset", + "ai-image", + "curves", + "color-adjust", + "light-adjust", + "detail-adjust", +]); + +const RENDER_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"]); + +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."; + } +} diff --git a/lib/toast-messages.ts b/lib/toast-messages.ts index 4c5f34e..8f2a625 100644 --- a/lib/toast-messages.ts +++ b/lib/toast-messages.ts @@ -3,6 +3,10 @@ import { useTranslations } from 'next-intl'; import { toast, type ToastDurationOverrides } from './toast'; import type { CanvasNodeDeleteBlockReason } from './toast'; +import { + getCanvasConnectionValidationMessage, + type CanvasConnectionValidationReason, +} from '@/lib/canvas-connection-policy'; const DURATION = { success: 4000, @@ -77,6 +81,13 @@ export const msg = { desc: `${why.desc} ${suffix}`, }; }, + connectionRejected: ( + _t: ToastTranslations, + reason: CanvasConnectionValidationReason, + ) => ({ + title: 'Verbindung abgelehnt', + desc: getCanvasConnectionValidationMessage(reason), + }), }, ai: { generating: (t: ToastTranslations) => ({ title: t('ai.generating') }), @@ -205,3 +216,12 @@ export const msg = { deleteFailed: (t: ToastTranslations) => ({ title: t('dashboard.deleteFailed') }), }, } 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 }); +}