From b243443431aa466d77e7be194dfeab31255d6b9a Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 28 Mar 2026 00:06:45 +0100 Subject: [PATCH] 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. --- .../canvas/canvas-placement-context.tsx | 86 ++++- components/canvas/canvas.tsx | 298 +++++++++++++----- components/canvas/nodes/base-node-wrapper.tsx | 3 +- components/canvas/nodes/prompt-node.tsx | 17 +- convex/nodes.ts | 58 ++++ 5 files changed, 376 insertions(+), 86 deletions(-) diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx index 239f576..64aa8cc 100644 --- a/components/canvas/canvas-placement-context.tsx +++ b/components/canvas/canvas-placement-context.tsx @@ -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>; + createNodeConnectedFromSource: ( + input: CreateNodeConnectedFromSourceInput, + ) => Promise>; }; const CanvasPlacementContext = createContext( @@ -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 ( diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 771bc5f..36833e8 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -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,86 +650,169 @@ 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 (!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 { - 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; + 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, + }, + }; } - 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, - }, - }; + 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) } diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx index 769b617..38346a7 100644 --- a/components/canvas/nodes/base-node-wrapper.tsx +++ b/components/canvas/nodes/base-node-wrapper.tsx @@ -17,7 +17,8 @@ const RESIZE_CONFIGS: Record = { 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 }, diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx index e852768..7c1be54 100644 --- a/components/canvas/nodes/prompt-node.tsx +++ b/components/canvas/nodes/prompt-node.tsx @@ -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; @@ -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, diff --git a/convex/nodes.ts b/convex/nodes.ts index 397658a..21b4b8f 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -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. */