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 CreateNodeWithIntersectionInput = {
@@ -72,10 +95,19 @@ type CreateNodeWithIntersectionInput = {
clientRequestId?: string;
};
export type CreateNodeConnectedFromSourceInput = CreateNodeWithIntersectionInput & {
sourceNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
type CanvasPlacementContextValue = {
createNodeWithIntersection: (
input: CreateNodeWithIntersectionInput,
) => Promise<Id<"nodes">>;
createNodeConnectedFromSource: (
input: CreateNodeConnectedFromSourceInput,
) => Promise<Id<"nodes">>;
};
const CanvasPlacementContext = createContext<CanvasPlacementContextValue | null>(
@@ -135,6 +167,7 @@ interface CanvasPlacementProviderProps {
canvasId: Id<"canvases">;
createNode: CreateNodeMutation;
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
createNodeWithEdgeFromSource: CreateNodeWithEdgeFromSourceMutation;
onCreateNodeSettled?: (payload: {
clientRequestId?: string;
realId: Id<"nodes">;
@@ -146,6 +179,7 @@ export function CanvasPlacementProvider({
canvasId,
createNode,
createNodeWithEdgeSplit,
createNodeWithEdgeFromSource,
onCreateNodeSettled,
children,
}: 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(
() => ({ createNodeWithIntersection }),
[createNodeWithIntersection],
() => ({ createNodeWithIntersection, createNodeConnectedFromSource }),
[createNodeConnectedFromSource, createNodeWithIntersection],
);
return (

View File

@@ -38,6 +38,12 @@ import {
NODE_HANDLE_MAP,
resolveMediaAspectRatio,
} 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 { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
@@ -47,6 +53,7 @@ interface CanvasInnerProps {
}
const OPTIMISTIC_NODE_PREFIX = "optimistic_";
const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
function isOptimisticNodeId(id: string): boolean {
return id.startsWith(OPTIMISTIC_NODE_PREFIX);
@@ -250,7 +257,10 @@ function mergeNodesPreservingLocalState(
typeof (previousNode as { resizing?: boolean }).resizing === "boolean"
? (previousNode as { resizing?: boolean }).resizing
: false;
const isMediaNode = incomingNode.type === "asset" || incomingNode.type === "image";
const isMediaNode =
incomingNode.type === "asset" ||
incomingNode.type === "image" ||
incomingNode.type === "ai-image";
const shouldPreserveInteractivePosition =
isMediaNode && (Boolean(previousNode.selected) || Boolean(previousNode.dragging) || previousResizing);
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 batchRemoveNodes = useMutation(api.nodes.batchRemove);
const createEdge = useMutation(api.edges.create);
@@ -580,12 +650,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}
const node = nds.find((candidate) => candidate.id === change.id);
if (!node || node.type !== "asset") {
if (!node) {
return change;
}
const isActiveResize =
change.resizing === true || change.resizing === false;
if (node.type === "asset") {
if (!isActiveResize) {
return change;
}
@@ -660,6 +732,87 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
height: enforcedHeight,
},
};
}
if (node.type === "ai-image") {
if (!isActiveResize) {
return change;
}
const nodeData = node.data as { aspectRatio?: string };
const arLabel =
typeof nodeData.aspectRatio === "string" && nodeData.aspectRatio.trim()
? nodeData.aspectRatio.trim()
: DEFAULT_ASPECT_RATIO;
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,
},
};
}
return change;
})
.filter((change): change is NodeChange => change !== null);
@@ -1114,6 +1267,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
canvasId={canvasId}
createNode={createNode}
createNodeWithEdgeSplit={createNodeWithEdgeSplit}
createNodeWithEdgeFromSource={createNodeWithEdgeFromSource}
onCreateNodeSettled={({ clientRequestId, realId }) =>
syncPendingMoveForClientRequest(clientRequestId, realId)
}

View File

@@ -17,7 +17,8 @@ const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
group: { minWidth: 150, minHeight: 100 },
image: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
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 },
prompt: { minWidth: 260, minHeight: 220 },
text: { minWidth: 220, minHeight: 90 },

View File

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