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 { 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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
109
convex/nodes.ts
109
convex/nodes.ts
@@ -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,
|
||||||
|
|||||||
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 { 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 });
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user