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:
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
function validateCanvasConnectionByType(args: {
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
targetNodeId: string;
|
||||
edges: RFEdge[];
|
||||
}): CanvasConnectionValidationReason | null {
|
||||
const targetIncomingCount = args.edges.filter(
|
||||
(edge) => edge.target === args.targetNodeId,
|
||||
).length;
|
||||
if (incomingCount >= 1) {
|
||||
return "Adjustment-Nodes erlauben genau eine eingehende Verbindung.";
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdjustmentNodeType(sourceType) && ADJUSTMENT_DISALLOWED_TARGET_TYPES.has(targetType)) {
|
||||
return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden.";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<void> {
|
||||
const targetNode = await ctx.db.get(args.targetNodeId);
|
||||
if (!targetNode) {
|
||||
throw new Error("Target node not found");
|
||||
}
|
||||
if (!isAdjustmentNodeType(targetNode.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
): Promise<number> {
|
||||
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<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");
|
||||
}
|
||||
|
||||
await assertTargetAllowsIncomingEdge(ctx, {
|
||||
await assertConnectionPolicy(ctx, {
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
targetNodeId: args.targetNodeId,
|
||||
});
|
||||
|
||||
|
||||
109
convex/nodes.ts
109
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<void> {
|
||||
const targetNode = await ctx.db.get(args.targetNodeId);
|
||||
if (!targetNode) {
|
||||
throw new Error("Target node not found");
|
||||
}
|
||||
|
||||
if (!isAdjustmentNodeType(targetNode.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
): Promise<number> {
|
||||
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<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");
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
87
lib/canvas-connection-policy.ts
Normal file
87
lib/canvas-connection-policy.ts
Normal 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.";
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user