feat: implement createNodeConnectedFromSource functionality for enhanced node creation

- Added a new mutation to create nodes connected to existing nodes, improving the canvas interaction model.
- Updated the CanvasPlacementContext to include createNodeConnectedFromSource, allowing for seamless node connections.
- Refactored the PromptNode component to utilize the new connection method, enhancing the workflow for AI image generation.
- Introduced optimistic updates for immediate UI feedback during node creation and connection processes.
This commit is contained in:
Matthias
2026-03-28 00:06:45 +01:00
parent 5dd5dcf55b
commit b243443431
5 changed files with 376 additions and 86 deletions

View File

@@ -58,6 +58,29 @@ type CreateNodeWithEdgeSplitMutation = ReactMutation<
> >
>; >;
type CreateNodeWithEdgeFromSourceMutation = ReactMutation<
FunctionReference<
"mutation",
"public",
{
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: unknown;
parentId?: Id<"nodes">;
zIndex?: number;
clientRequestId?: string;
sourceNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
},
Id<"nodes">
>
>;
type FlowPoint = { x: number; y: number }; type FlowPoint = { x: number; y: number };
type CreateNodeWithIntersectionInput = { type CreateNodeWithIntersectionInput = {
@@ -72,10 +95,19 @@ type CreateNodeWithIntersectionInput = {
clientRequestId?: string; clientRequestId?: string;
}; };
export type CreateNodeConnectedFromSourceInput = CreateNodeWithIntersectionInput & {
sourceNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
type CanvasPlacementContextValue = { type CanvasPlacementContextValue = {
createNodeWithIntersection: ( createNodeWithIntersection: (
input: CreateNodeWithIntersectionInput, input: CreateNodeWithIntersectionInput,
) => Promise<Id<"nodes">>; ) => Promise<Id<"nodes">>;
createNodeConnectedFromSource: (
input: CreateNodeConnectedFromSourceInput,
) => Promise<Id<"nodes">>;
}; };
const CanvasPlacementContext = createContext<CanvasPlacementContextValue | null>( const CanvasPlacementContext = createContext<CanvasPlacementContextValue | null>(
@@ -135,6 +167,7 @@ interface CanvasPlacementProviderProps {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
createNode: CreateNodeMutation; createNode: CreateNodeMutation;
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation; createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
createNodeWithEdgeFromSource: CreateNodeWithEdgeFromSourceMutation;
onCreateNodeSettled?: (payload: { onCreateNodeSettled?: (payload: {
clientRequestId?: string; clientRequestId?: string;
realId: Id<"nodes">; realId: Id<"nodes">;
@@ -146,6 +179,7 @@ export function CanvasPlacementProvider({
canvasId, canvasId,
createNode, createNode,
createNodeWithEdgeSplit, createNodeWithEdgeSplit,
createNodeWithEdgeFromSource,
onCreateNodeSettled, onCreateNodeSettled,
children, children,
}: CanvasPlacementProviderProps) { }: CanvasPlacementProviderProps) {
@@ -250,9 +284,57 @@ export function CanvasPlacementProvider({
], ],
); );
const createNodeConnectedFromSource = useCallback(
async ({
type,
position,
width,
height,
data,
zIndex,
clientRequestId,
sourceNodeId,
sourceHandle,
targetHandle,
}: CreateNodeConnectedFromSourceInput) => {
const defaults = NODE_DEFAULTS[type] ?? {
width: 200,
height: 100,
data: {},
};
const effectiveWidth = width ?? defaults.width;
const effectiveHeight = height ?? defaults.height;
const payload = {
canvasId,
type,
positionX: position.x,
positionY: position.y,
width: effectiveWidth,
height: effectiveHeight,
data: {
...defaults.data,
...(data ?? {}),
canvasId,
},
...(zIndex !== undefined ? { zIndex } : {}),
...(clientRequestId !== undefined ? { clientRequestId } : {}),
sourceNodeId,
sourceHandle,
targetHandle,
};
const realId = await createNodeWithEdgeFromSource(payload);
onCreateNodeSettled?.({ clientRequestId, realId });
return realId;
},
[canvasId, createNodeWithEdgeFromSource, onCreateNodeSettled],
);
const value = useMemo( const value = useMemo(
() => ({ createNodeWithIntersection }), () => ({ createNodeWithIntersection, createNodeConnectedFromSource }),
[createNodeWithIntersection], [createNodeConnectedFromSource, createNodeWithIntersection],
); );
return ( return (

View File

@@ -38,6 +38,12 @@ import {
NODE_HANDLE_MAP, NODE_HANDLE_MAP,
resolveMediaAspectRatio, resolveMediaAspectRatio,
} from "@/lib/canvas-utils"; } from "@/lib/canvas-utils";
import {
AI_IMAGE_NODE_FOOTER_PX,
AI_IMAGE_NODE_HEADER_PX,
DEFAULT_ASPECT_RATIO,
parseAspectRatioString,
} from "@/lib/image-formats";
import CanvasToolbar from "@/components/canvas/canvas-toolbar"; import CanvasToolbar from "@/components/canvas/canvas-toolbar";
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette"; import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
@@ -47,6 +53,7 @@ interface CanvasInnerProps {
} }
const OPTIMISTIC_NODE_PREFIX = "optimistic_"; const OPTIMISTIC_NODE_PREFIX = "optimistic_";
const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
function isOptimisticNodeId(id: string): boolean { function isOptimisticNodeId(id: string): boolean {
return id.startsWith(OPTIMISTIC_NODE_PREFIX); return id.startsWith(OPTIMISTIC_NODE_PREFIX);
@@ -250,7 +257,10 @@ function mergeNodesPreservingLocalState(
typeof (previousNode as { resizing?: boolean }).resizing === "boolean" typeof (previousNode as { resizing?: boolean }).resizing === "boolean"
? (previousNode as { resizing?: boolean }).resizing ? (previousNode as { resizing?: boolean }).resizing
: false; : false;
const isMediaNode = incomingNode.type === "asset" || incomingNode.type === "image"; const isMediaNode =
incomingNode.type === "asset" ||
incomingNode.type === "image" ||
incomingNode.type === "ai-image";
const shouldPreserveInteractivePosition = const shouldPreserveInteractivePosition =
isMediaNode && (Boolean(previousNode.selected) || Boolean(previousNode.dragging) || previousResizing); isMediaNode && (Boolean(previousNode.selected) || Boolean(previousNode.dragging) || previousResizing);
const shouldPreserveInteractiveSize = const shouldPreserveInteractiveSize =
@@ -441,6 +451,66 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
); );
}, },
); );
const createNodeWithEdgeFromSource = useMutation(
api.nodes.createWithEdgeFromSource,
).withOptimisticUpdate((localStore, args) => {
const nodeList = localStore.getQuery(api.nodes.list, {
canvasId: args.canvasId,
});
const edgeList = localStore.getQuery(api.edges.list, {
canvasId: args.canvasId,
});
if (nodeList === undefined || edgeList === undefined) return;
const tempNodeId = (
args.clientRequestId
? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`
: `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
) as Id<"nodes">;
const tempEdgeId = (
args.clientRequestId
? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`
: `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
) as Id<"edges">;
const syntheticNode: Doc<"nodes"> = {
_id: tempNodeId,
_creationTime: Date.now(),
canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"],
positionX: args.positionX,
positionY: args.positionY,
width: args.width,
height: args.height,
status: "idle",
retryCount: 0,
data: args.data,
parentId: args.parentId,
zIndex: args.zIndex,
};
const syntheticEdge: Doc<"edges"> = {
_id: tempEdgeId,
_creationTime: Date.now(),
canvasId: args.canvasId,
sourceNodeId: args.sourceNodeId,
targetNodeId: tempNodeId,
sourceHandle: args.sourceHandle,
targetHandle: args.targetHandle,
};
localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [
...nodeList,
syntheticNode,
]);
localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [
...edgeList,
syntheticEdge,
]);
});
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit); const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
const batchRemoveNodes = useMutation(api.nodes.batchRemove); const batchRemoveNodes = useMutation(api.nodes.batchRemove);
const createEdge = useMutation(api.edges.create); const createEdge = useMutation(api.edges.create);
@@ -580,86 +650,169 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
} }
const node = nds.find((candidate) => candidate.id === change.id); const node = nds.find((candidate) => candidate.id === change.id);
if (!node || node.type !== "asset") { if (!node) {
return change; return change;
} }
const isActiveResize = const isActiveResize =
change.resizing === true || change.resizing === false; change.resizing === true || change.resizing === false;
if (!isActiveResize) {
return change; if (node.type === "asset") {
if (!isActiveResize) {
return change;
}
const nodeData = node.data as {
intrinsicWidth?: number;
intrinsicHeight?: number;
orientation?: string;
};
const hasIntrinsicRatioInput =
typeof nodeData.intrinsicWidth === "number" &&
nodeData.intrinsicWidth > 0 &&
typeof nodeData.intrinsicHeight === "number" &&
nodeData.intrinsicHeight > 0;
if (!hasIntrinsicRatioInput) {
return change;
}
const targetRatio = resolveMediaAspectRatio(
nodeData.intrinsicWidth,
nodeData.intrinsicHeight,
nodeData.orientation,
);
if (!Number.isFinite(targetRatio) || targetRatio <= 0) {
return change;
}
const previousWidth =
typeof node.style?.width === "number"
? node.style.width
: change.dimensions.width;
const previousHeight =
typeof node.style?.height === "number"
? node.style.height
: change.dimensions.height;
const widthDelta = Math.abs(change.dimensions.width - previousWidth);
const heightDelta = Math.abs(change.dimensions.height - previousHeight);
let constrainedWidth = change.dimensions.width;
let constrainedHeight = change.dimensions.height;
// Axis with larger delta drives resize; the other axis is ratio-locked.
if (heightDelta > widthDelta) {
constrainedWidth = constrainedHeight * targetRatio;
} else {
constrainedHeight = constrainedWidth / targetRatio;
}
const assetChromeHeight = 88;
const assetMinPreviewHeight = 120;
const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight;
const assetMinNodeWidth = 140;
const minWidthFromHeight = assetMinNodeHeight * targetRatio;
const minimumAllowedWidth = Math.max(assetMinNodeWidth, minWidthFromHeight);
const minimumAllowedHeight = minimumAllowedWidth / targetRatio;
const enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
const enforcedHeight = Math.max(
constrainedHeight,
minimumAllowedHeight,
assetMinNodeHeight,
);
return {
...change,
dimensions: {
...change.dimensions,
width: enforcedWidth,
height: enforcedHeight,
},
};
} }
const nodeData = node.data as { if (node.type === "ai-image") {
intrinsicWidth?: number; if (!isActiveResize) {
intrinsicHeight?: number; return change;
orientation?: string; }
};
const hasIntrinsicRatioInput = const nodeData = node.data as { aspectRatio?: string };
typeof nodeData.intrinsicWidth === "number" && const arLabel =
nodeData.intrinsicWidth > 0 && typeof nodeData.aspectRatio === "string" && nodeData.aspectRatio.trim()
typeof nodeData.intrinsicHeight === "number" && ? nodeData.aspectRatio.trim()
nodeData.intrinsicHeight > 0; : DEFAULT_ASPECT_RATIO;
if (!hasIntrinsicRatioInput) {
return change; let arW: number;
let arH: number;
try {
const parsed = parseAspectRatioString(arLabel);
arW = parsed.w;
arH = parsed.h;
} catch {
return change;
}
const chrome = AI_IMAGE_NODE_HEADER_PX + AI_IMAGE_NODE_FOOTER_PX;
const hPerW = arH / arW;
const previousWidth =
typeof node.style?.width === "number"
? node.style.width
: change.dimensions.width;
const previousHeight =
typeof node.style?.height === "number"
? node.style.height
: change.dimensions.height;
const widthDelta = Math.abs(change.dimensions.width - previousWidth);
const heightDelta = Math.abs(change.dimensions.height - previousHeight);
let constrainedWidth = change.dimensions.width;
let constrainedHeight = change.dimensions.height;
if (heightDelta > widthDelta) {
const viewportH = Math.max(1, constrainedHeight - chrome);
constrainedWidth = viewportH * (arW / arH);
constrainedHeight = chrome + viewportH;
} else {
constrainedHeight = chrome + constrainedWidth * hPerW;
}
const aiMinViewport = 120;
const aiMinOuterHeight = chrome + aiMinViewport;
const aiMinOuterWidthBase = 200;
const minimumAllowedWidth = Math.max(
aiMinOuterWidthBase,
aiMinViewport * (arW / arH),
);
const minimumAllowedHeight = Math.max(
aiMinOuterHeight,
chrome + minimumAllowedWidth * hPerW,
);
let enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
let enforcedHeight = chrome + enforcedWidth * hPerW;
if (enforcedHeight < minimumAllowedHeight) {
enforcedHeight = minimumAllowedHeight;
enforcedWidth = (enforcedHeight - chrome) * (arW / arH);
}
enforcedWidth = Math.max(enforcedWidth, minimumAllowedWidth);
enforcedHeight = chrome + enforcedWidth * hPerW;
return {
...change,
dimensions: {
...change.dimensions,
width: enforcedWidth,
height: enforcedHeight,
},
};
} }
const targetRatio = resolveMediaAspectRatio( return change;
nodeData.intrinsicWidth,
nodeData.intrinsicHeight,
nodeData.orientation,
);
if (!Number.isFinite(targetRatio) || targetRatio <= 0) {
return change;
}
const previousWidth =
typeof node.style?.width === "number"
? node.style.width
: change.dimensions.width;
const previousHeight =
typeof node.style?.height === "number"
? node.style.height
: change.dimensions.height;
const widthDelta = Math.abs(change.dimensions.width - previousWidth);
const heightDelta = Math.abs(change.dimensions.height - previousHeight);
let constrainedWidth = change.dimensions.width;
let constrainedHeight = change.dimensions.height;
// Axis with larger delta drives resize; the other axis is ratio-locked.
if (heightDelta > widthDelta) {
constrainedWidth = constrainedHeight * targetRatio;
} else {
constrainedHeight = constrainedWidth / targetRatio;
}
const assetChromeHeight = 88;
const assetMinPreviewHeight = 120;
const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight;
const assetMinNodeWidth = 140;
const minWidthFromHeight = assetMinNodeHeight * targetRatio;
const minimumAllowedWidth = Math.max(assetMinNodeWidth, minWidthFromHeight);
const minimumAllowedHeight = minimumAllowedWidth / targetRatio;
const enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
const enforcedHeight = Math.max(
constrainedHeight,
minimumAllowedHeight,
assetMinNodeHeight,
);
return {
...change,
dimensions: {
...change.dimensions,
width: enforcedWidth,
height: enforcedHeight,
},
};
}) })
.filter((change): change is NodeChange => change !== null); .filter((change): change is NodeChange => change !== null);
@@ -1114,6 +1267,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
canvasId={canvasId} canvasId={canvasId}
createNode={createNode} createNode={createNode}
createNodeWithEdgeSplit={createNodeWithEdgeSplit} createNodeWithEdgeSplit={createNodeWithEdgeSplit}
createNodeWithEdgeFromSource={createNodeWithEdgeFromSource}
onCreateNodeSettled={({ clientRequestId, realId }) => onCreateNodeSettled={({ clientRequestId, realId }) =>
syncPendingMoveForClientRequest(clientRequestId, realId) syncPendingMoveForClientRequest(clientRequestId, realId)
} }

View File

@@ -17,7 +17,8 @@ const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
group: { minWidth: 150, minHeight: 100 }, group: { minWidth: 150, minHeight: 100 },
image: { minWidth: 140, minHeight: 120, keepAspectRatio: true }, image: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false }, asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false },
"ai-image": { minWidth: 200, minHeight: 200 }, // Chrome 88 + min. Viewport 120 → äußere Mindesthöhe 208 (siehe canvas onNodesChange)
"ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false },
compare: { minWidth: 300, minHeight: 200 }, compare: { minWidth: 300, minHeight: 200 },
prompt: { minWidth: 260, minHeight: 220 }, prompt: { minWidth: 260, minHeight: 220 },
text: { minWidth: 220, minHeight: 90 }, text: { minWidth: 220, minHeight: 90 },

View File

@@ -118,9 +118,8 @@ export default function PromptNode({
availableCredits !== null && availableCredits >= creditCost; availableCredits !== null && availableCredits >= creditCost;
const updateData = useMutation(api.nodes.updateData); const updateData = useMutation(api.nodes.updateData);
const createEdge = useMutation(api.edges.create);
const generateImage = useAction(api.ai.generateImage); const generateImage = useAction(api.ai.generateImage);
const { createNodeWithIntersection } = useCanvasPlacement(); const { createNodeConnectedFromSource } = useCanvasPlacement();
const debouncedSave = useDebouncedCallback(() => { const debouncedSave = useDebouncedCallback(() => {
const raw = dataRef.current as Record<string, unknown>; const raw = dataRef.current as Record<string, unknown>;
@@ -215,7 +214,9 @@ export default function PromptNode({
const viewport = getImageViewportSize(aspectRatio); const viewport = getImageViewportSize(aspectRatio);
const outer = getAiImageNodeOuterSize(viewport); const outer = getAiImageNodeOuterSize(viewport);
const aiNodeId = await createNodeWithIntersection({ const clientRequestId = crypto.randomUUID();
const aiNodeId = await createNodeConnectedFromSource({
type: "ai-image", type: "ai-image",
position: { x: posX, y: posY }, position: { x: posX, y: posY },
width: outer.width, width: outer.width,
@@ -229,13 +230,8 @@ export default function PromptNode({
outputWidth: viewport.width, outputWidth: viewport.width,
outputHeight: viewport.height, outputHeight: viewport.height,
}, },
clientRequestId: crypto.randomUUID(), clientRequestId,
});
await createEdge({
canvasId,
sourceNodeId: id as Id<"nodes">, sourceNodeId: id as Id<"nodes">,
targetNodeId: aiNodeId,
sourceHandle: "prompt-out", sourceHandle: "prompt-out",
targetHandle: "prompt-in", targetHandle: "prompt-in",
}); });
@@ -274,8 +270,7 @@ export default function PromptNode({
id, id,
getEdges, getEdges,
getNode, getNode,
createNodeWithIntersection, createNodeConnectedFromSource,
createEdge,
generateImage, generateImage,
creditCost, creditCost,
availableCredits, availableCredits,

View File

@@ -224,6 +224,64 @@ export const createWithEdgeSplit = mutation({
}, },
}); });
/**
* Neuen Node erstellen und sofort mit einem bestehenden Node verbinden
* (ein Roundtrip — z. B. Prompt → neue AI-Image-Node).
*/
export const createWithEdgeFromSource = mutation({
args: {
canvasId: v.id("canvases"),
type: v.string(),
positionX: v.number(),
positionY: v.number(),
width: v.number(),
height: v.number(),
data: v.any(),
parentId: v.optional(v.id("nodes")),
zIndex: v.optional(v.number()),
clientRequestId: v.optional(v.string()),
sourceNodeId: v.id("nodes"),
sourceHandle: v.optional(v.string()),
targetHandle: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
void args.clientRequestId;
const source = await ctx.db.get(args.sourceNodeId);
if (!source || source.canvasId !== args.canvasId) {
throw new Error("Source node not found");
}
const nodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"],
positionX: args.positionX,
positionY: args.positionY,
width: args.width,
height: args.height,
status: "idle",
retryCount: 0,
data: args.data,
parentId: args.parentId,
zIndex: args.zIndex,
});
await ctx.db.insert("edges", {
canvasId: args.canvasId,
sourceNodeId: args.sourceNodeId,
targetNodeId: nodeId,
sourceHandle: args.sourceHandle,
targetHandle: args.targetHandle,
});
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
return nodeId;
},
});
/** /**
* Node-Position auf dem Canvas verschieben. * Node-Position auf dem Canvas verschieben.
*/ */