feat: implement createNodeWithEdgeToTarget mutation for enhanced node connections

- Added a new mutation to create nodes connected to existing nodes, allowing for more dynamic interactions on the canvas.
- Updated the CanvasPlacementContext to include the new mutation, improving the workflow for node creation and edge management.
- Enhanced optimistic updates for immediate UI feedback during node and edge creation processes.
- Refactored related components to support the new connection method, streamlining user interactions.
This commit is contained in:
Matthias
2026-03-28 17:50:45 +01:00
parent b3a1ed54db
commit 9694c50195
6 changed files with 552 additions and 85 deletions

View File

@@ -2,18 +2,9 @@
import { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import {
Frame,
GitCompare,
Image,
Moon,
Sparkles,
StickyNote,
Sun,
Type,
type LucideIcon,
} from "lucide-react";
import { Moon, Sun } from "lucide-react";
import { CanvasNodeTemplatePicker } from "@/components/canvas/canvas-node-template-picker";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
import {
@@ -26,30 +17,7 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
CANVAS_NODE_TEMPLATES,
type CanvasNodeTemplate,
} from "@/lib/canvas-node-templates";
const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
image: Image,
text: Type,
prompt: Sparkles,
note: StickyNote,
frame: Frame,
compare: GitCompare,
};
const NODE_SEARCH_KEYWORDS: Partial<
Record<CanvasNodeTemplate["type"], string[]>
> = {
image: ["image", "photo", "foto"],
text: ["text", "typo"],
prompt: ["prompt", "ai", "generate"],
note: ["note", "sticky", "notiz"],
frame: ["frame", "artboard"],
compare: ["compare", "before", "after"],
};
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
export function CanvasCommandPalette() {
const [open, setOpen] = useState(false);
@@ -69,21 +37,16 @@ export function CanvasCommandPalette() {
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
const handleAddNode = (
type: CanvasNodeTemplate["type"],
data: CanvasNodeTemplate["defaultData"],
width: number,
height: number,
) => {
const handleAddNode = (template: CanvasNodeTemplate) => {
const stagger = (nodeCountRef.current % 8) * 24;
nodeCountRef.current += 1;
setOpen(false);
void createNodeWithIntersection({
type,
position: getCenteredPosition(width, height, stagger),
width,
height,
data,
type: template.type,
position: getCenteredPosition(template.width, template.height, stagger),
width: template.width,
height: template.height,
data: template.defaultData,
clientRequestId: crypto.randomUUID(),
}).catch((error) => {
console.error("[CanvasCommandPalette] createNode failed", error);
@@ -101,28 +64,7 @@ export function CanvasCommandPalette() {
<CommandInput placeholder="Suchen …" />
<CommandList>
<CommandEmpty>Keine Treffer.</CommandEmpty>
<CommandGroup heading="Knoten">
{CANVAS_NODE_TEMPLATES.map((template) => {
const Icon = NODE_ICONS[template.type];
return (
<CommandItem
key={template.type}
keywords={NODE_SEARCH_KEYWORDS[template.type] ?? []}
onSelect={() =>
handleAddNode(
template.type,
template.defaultData,
template.width,
template.height,
)
}
>
<Icon className="size-4" />
{template.label}
</CommandItem>
);
})}
</CommandGroup>
<CanvasNodeTemplatePicker onPick={handleAddNode} />
<CommandSeparator />
<CommandGroup heading="Erscheinungsbild">
<CommandItem

View File

@@ -0,0 +1,112 @@
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
import { CanvasNodeTemplatePicker } from "@/components/canvas/canvas-node-template-picker";
import {
Command,
CommandEmpty,
CommandInput,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import type { Id } from "@/convex/_generated/dataModel";
export type ConnectionDropMenuState = {
screenX: number;
screenY: number;
flowX: number;
flowY: number;
fromNodeId: Id<"nodes">;
fromHandleId: string | undefined;
fromHandleType: "source" | "target";
};
type CanvasConnectionDropMenuProps = {
state: ConnectionDropMenuState | null;
onClose: () => void;
onPick: (template: CanvasNodeTemplate) => void;
};
const PANEL_MAX_W = 360;
const PANEL_MAX_H = 420;
export function CanvasConnectionDropMenu({
state,
onClose,
onPick,
}: CanvasConnectionDropMenuProps) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!state) return;
const onEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onEscape);
const onPointerDownCapture = (e: PointerEvent) => {
const panel = panelRef.current;
if (panel && !panel.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("pointerdown", onPointerDownCapture, true);
return () => {
document.removeEventListener("keydown", onEscape);
document.removeEventListener("pointerdown", onPointerDownCapture, true);
};
}, [state, onClose]);
if (!state) return null;
const vw =
typeof window !== "undefined" ? window.innerWidth : PANEL_MAX_W + 16;
const vh =
typeof window !== "undefined" ? window.innerHeight : PANEL_MAX_H + 16;
const left = Math.max(
8,
Math.min(state.screenX, vw - PANEL_MAX_W - 8),
);
const top = Math.max(
8,
Math.min(state.screenY, vh - PANEL_MAX_H - 8),
);
return (
<div
ref={panelRef}
role="dialog"
aria-label="Knoten wählen zur Verbindung"
className={cn(
"fixed z-100 flex max-h-(--panel-max-h) w-[min(100vw-1rem,360px)] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground ring-1 ring-foreground/10 shadow-lg",
)}
style={
{
left,
top,
"--panel-max-h": `${PANEL_MAX_H}px`,
} as CSSProperties
}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Command className="rounded-xl! bg-popover">
<CommandInput placeholder="Knoten suchen …" autoFocus />
<CommandList className="max-h-72">
<CommandEmpty>Keine Treffer.</CommandEmpty>
<CanvasNodeTemplatePicker
onPick={(template) => {
onPick(template);
onClose();
}}
groupHeading="Knoten"
/>
</CommandList>
</Command>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import {
Frame,
GitCompare,
Image,
Sparkles,
StickyNote,
Type,
type LucideIcon,
} from "lucide-react";
import { CommandGroup, CommandItem } from "@/components/ui/command";
import {
CANVAS_NODE_TEMPLATES,
type CanvasNodeTemplate,
} from "@/lib/canvas-node-templates";
const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
image: Image,
text: Type,
prompt: Sparkles,
note: StickyNote,
frame: Frame,
compare: GitCompare,
};
const NODE_SEARCH_KEYWORDS: Partial<
Record<CanvasNodeTemplate["type"], string[]>
> = {
image: ["image", "photo", "foto"],
text: ["text", "typo"],
prompt: ["prompt", "ai", "generate"],
note: ["note", "sticky", "notiz"],
frame: ["frame", "artboard"],
compare: ["compare", "before", "after"],
};
export type CanvasNodeTemplatePickerProps = {
onPick: (template: CanvasNodeTemplate) => void;
groupHeading?: string;
};
/**
* Knoten-Template-Liste für cmdk. Eltern: `<Command><CommandInput/><CommandList><CommandEmpty/> <CanvasNodeTemplatePicker /> …`.
*/
export function CanvasNodeTemplatePicker({
onPick,
groupHeading = "Knoten",
}: CanvasNodeTemplatePickerProps) {
return (
<CommandGroup heading={groupHeading}>
{CANVAS_NODE_TEMPLATES.map((template) => {
const Icon = NODE_ICONS[template.type];
return (
<CommandItem
key={template.type}
keywords={NODE_SEARCH_KEYWORDS[template.type] ?? []}
onSelect={() => onPick(template)}
>
<Icon className="size-4" />
{template.label}
</CommandItem>
);
})}
</CommandGroup>
);
}

View File

@@ -81,6 +81,29 @@ type CreateNodeWithEdgeFromSourceMutation = ReactMutation<
>
>;
type CreateNodeWithEdgeToTargetMutation = 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;
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
},
Id<"nodes">
>
>;
type FlowPoint = { x: number; y: number };
type CreateNodeWithIntersectionInput = {
@@ -105,6 +128,12 @@ export type CreateNodeConnectedFromSourceInput = CreateNodeWithIntersectionInput
targetHandle?: string;
};
export type CreateNodeConnectedToTargetInput = CreateNodeWithIntersectionInput & {
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
type CanvasPlacementContextValue = {
createNodeWithIntersection: (
input: CreateNodeWithIntersectionInput,
@@ -112,6 +141,9 @@ type CanvasPlacementContextValue = {
createNodeConnectedFromSource: (
input: CreateNodeConnectedFromSourceInput,
) => Promise<Id<"nodes">>;
createNodeConnectedToTarget: (
input: CreateNodeConnectedToTargetInput,
) => Promise<Id<"nodes">>;
};
const CanvasPlacementContext = createContext<CanvasPlacementContextValue | null>(
@@ -172,6 +204,7 @@ interface CanvasPlacementProviderProps {
createNode: CreateNodeMutation;
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
createNodeWithEdgeFromSource: CreateNodeWithEdgeFromSourceMutation;
createNodeWithEdgeToTarget: CreateNodeWithEdgeToTargetMutation;
onCreateNodeSettled?: (payload: {
clientRequestId?: string;
realId: Id<"nodes">;
@@ -184,6 +217,7 @@ export function CanvasPlacementProvider({
createNode,
createNodeWithEdgeSplit,
createNodeWithEdgeFromSource,
createNodeWithEdgeToTarget,
onCreateNodeSettled,
children,
}: CanvasPlacementProviderProps) {
@@ -327,9 +361,65 @@ export function CanvasPlacementProvider({
[canvasId, createNodeWithEdgeFromSource, onCreateNodeSettled],
);
const createNodeConnectedToTarget = useCallback(
async ({
type,
position,
width,
height,
data,
zIndex,
clientRequestId,
targetNodeId,
sourceHandle,
targetHandle,
}: CreateNodeConnectedToTargetInput) => {
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 } : {}),
targetNodeId,
sourceHandle,
targetHandle,
};
const realId = await createNodeWithEdgeToTarget(payload);
onCreateNodeSettled?.({ clientRequestId, realId });
return realId;
},
[canvasId, createNodeWithEdgeToTarget, onCreateNodeSettled],
);
const value = useMemo(
() => ({ createNodeWithIntersection, createNodeConnectedFromSource }),
[createNodeConnectedFromSource, createNodeWithIntersection],
() => ({
createNodeWithIntersection,
createNodeConnectedFromSource,
createNodeConnectedToTarget,
}),
[
createNodeConnectedFromSource,
createNodeConnectedToTarget,
createNodeWithIntersection,
],
);
return (

View File

@@ -18,6 +18,7 @@ import {
type EdgeChange,
type Connection,
type DefaultEdgeOptions,
type OnConnectEnd,
BackgroundVariant,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
@@ -48,8 +49,13 @@ import {
} from "@/lib/image-formats";
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
import {
CanvasConnectionDropMenu,
type ConnectionDropMenuState,
} from "@/components/canvas/canvas-connection-drop-menu";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
interface CanvasInnerProps {
canvasId: Id<"canvases">;
@@ -62,12 +68,27 @@ function isOptimisticNodeId(id: string): boolean {
return id.startsWith(OPTIMISTIC_NODE_PREFIX);
}
function isOptimisticEdgeId(id: string): boolean {
return id.startsWith(OPTIMISTIC_EDGE_PREFIX);
}
function clientRequestIdFromOptimisticNodeId(id: string): string | null {
if (!isOptimisticNodeId(id)) return null;
const suffix = id.slice(OPTIMISTIC_NODE_PREFIX.length);
return suffix.length > 0 ? suffix : null;
}
function getConnectEndClientPoint(
event: MouseEvent | TouchEvent,
): { x: number; y: number } | null {
if ("clientX" in event && typeof event.clientX === "number") {
return { x: event.clientX, y: event.clientY };
}
const t = (event as TouchEvent).changedTouches?.[0];
if (t) return { x: t.clientX, y: t.clientY };
return null;
}
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
type PendingEdgeSplit = {
intersectedEdgeId: Id<"edges">;
@@ -495,6 +516,65 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
]);
});
const createNodeWithEdgeToTarget = useMutation(
api.nodes.createWithEdgeToTarget,
).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: tempNodeId,
targetNodeId: args.targetNodeId,
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).withOptimisticUpdate(
@@ -709,6 +789,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
const [nodes, setNodes] = useState<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]);
const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
connectionDropMenuRef.current = connectionDropMenu;
// Drag-Lock: während des Drags kein Convex-Override
const isDragging = useRef(false);
@@ -718,6 +802,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// Delete Edge on Drop
const edgeReconnectSuccessful = useRef(true);
const isReconnectDragActiveRef = useRef(false);
const overlappedEdgeRef = useRef<string | null>(null);
const highlightedEdgeRef = useRef<string | null>(null);
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
@@ -1060,6 +1145,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Delete Edge on Drop ──────────────────────────────────────
const onReconnectStart = useCallback(() => {
edgeReconnectSuccessful.current = false;
isReconnectDragActiveRef.current = true;
}, []);
const onReconnect = useCallback(
@@ -1072,24 +1158,32 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const onReconnectEnd = useCallback(
(_: MouseEvent | TouchEvent, edge: RFEdge) => {
if (!edgeReconnectSuccessful.current) {
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
if (edge.className === "temp") {
edgeReconnectSuccessful.current = true;
return;
}
try {
if (!edgeReconnectSuccessful.current) {
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
if (edge.className === "temp") {
edgeReconnectSuccessful.current = true;
return;
}
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
console.error("[Canvas edge remove failed] reconnect end", {
edgeId: edge.id,
edgeClassName: edge.className ?? null,
source: edge.source,
target: edge.target,
error: String(error),
if (isOptimisticEdgeId(edge.id)) {
return;
}
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
console.error("[Canvas edge remove failed] reconnect end", {
edgeId: edge.id,
edgeClassName: edge.className ?? null,
source: edge.source,
target: edge.target,
error: String(error),
});
});
});
}
edgeReconnectSuccessful.current = true;
} finally {
isReconnectDragActiveRef.current = false;
}
edgeReconnectSuccessful.current = true;
},
[removeEdge],
);
@@ -1396,6 +1490,98 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
[createEdge, canvasId],
);
const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => {
if (isReconnectDragActiveRef.current) return;
if (connectionState.isValid === true) return;
const fromNode = connectionState.fromNode;
const fromHandle = connectionState.fromHandle;
if (!fromNode || !fromHandle) return;
const pt = getConnectEndClientPoint(event);
if (!pt) return;
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
setConnectionDropMenu({
screenX: pt.x,
screenY: pt.y,
flowX: flow.x,
flowY: flow.y,
fromNodeId: fromNode.id as Id<"nodes">,
fromHandleId: fromHandle.id ?? undefined,
fromHandleType: fromHandle.type,
});
},
[screenToFlowPosition],
);
const handleConnectionDropPick = useCallback(
(template: CanvasNodeTemplate) => {
const ctx = connectionDropMenuRef.current;
if (!ctx) return;
const defaults = NODE_DEFAULTS[template.type] ?? {
width: 200,
height: 100,
data: {},
};
const clientRequestId = crypto.randomUUID();
const handles = NODE_HANDLE_MAP[template.type];
const width = template.width ?? defaults.width;
const height = template.height ?? defaults.height;
const data = {
...defaults.data,
...(template.defaultData as Record<string, unknown>),
canvasId,
};
const base = {
canvasId,
type: template.type,
positionX: ctx.flowX,
positionY: ctx.flowY,
width,
height,
data,
clientRequestId,
};
const settle = (realId: Id<"nodes">) => {
syncPendingMoveForClientRequest(clientRequestId, realId);
};
if (ctx.fromHandleType === "source") {
void createNodeWithEdgeFromSource({
...base,
sourceNodeId: ctx.fromNodeId,
sourceHandle: ctx.fromHandleId,
targetHandle: handles?.target ?? undefined,
})
.then(settle)
.catch((error) => {
console.error("[Canvas] createNodeWithEdgeFromSource failed", error);
});
} else {
void createNodeWithEdgeToTarget({
...base,
targetNodeId: ctx.fromNodeId,
sourceHandle: handles?.source ?? undefined,
targetHandle: ctx.fromHandleId,
})
.then(settle)
.catch((error) => {
console.error("[Canvas] createNodeWithEdgeToTarget failed", error);
});
}
},
[
canvasId,
createNodeWithEdgeFromSource,
createNodeWithEdgeToTarget,
syncPendingMoveForClientRequest,
],
);
// ─── Node löschen → Convex ────────────────────────────────────
const onNodesDelete = useCallback(
(deletedNodes: RFNode[]) => {
@@ -1459,6 +1645,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
continue;
}
if (isOptimisticEdgeId(edge.id)) {
continue;
}
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
console.error("[Canvas edge remove failed] edge delete", {
edgeId: edge.id,
@@ -1535,6 +1725,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
createNode={createNode}
createNodeWithEdgeSplit={createNodeWithEdgeSplit}
createNodeWithEdgeFromSource={createNodeWithEdgeFromSource}
createNodeWithEdgeToTarget={createNodeWithEdgeToTarget}
onCreateNodeSettled={({ clientRequestId, realId }) =>
syncPendingMoveForClientRequest(clientRequestId, realId)
}
@@ -1542,6 +1733,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
<div className="relative h-full w-full">
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
<CanvasCommandPalette />
<CanvasConnectionDropMenu
state={connectionDropMenu}
onClose={() => setConnectionDropMenu(null)}
onPick={handleConnectionDropPick}
/>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -1555,6 +1751,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onConnect={onConnect}
onConnectEnd={onConnectEnd}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}