feat: add cmdk dependency and enhance canvas node creation with edge splitting functionality

- Introduced the cmdk package for improved command palette capabilities.
- Enhanced the canvas placement context to support creating nodes with edge splitting, allowing for more dynamic node interactions.
- Updated the canvas inner component to utilize optimistic updates for node creation, improving user experience during interactions.
- Refactored node handling logic to incorporate new mutation types and streamline data management.
This commit is contained in:
Matthias
2026-03-27 23:40:31 +01:00
parent 4e84e7f76f
commit 6e866f2df6
15 changed files with 1037 additions and 112 deletions

View File

@@ -0,0 +1,152 @@
"use client";
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 { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
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"],
};
export function CanvasCommandPalette() {
const [open, setOpen] = useState(false);
const { createNodeWithIntersection } = useCanvasPlacement();
const { setTheme } = useTheme();
const nodeCountRef = useRef(0);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (!e.metaKey && !e.ctrlKey) return;
if (e.key.toLowerCase() !== "k") return;
e.preventDefault();
setOpen((prev) => !prev);
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
const handleAddNode = async (
type: CanvasNodeTemplate["type"],
data: CanvasNodeTemplate["defaultData"],
width: number,
height: number,
) => {
const offset = (nodeCountRef.current % 8) * 24;
nodeCountRef.current += 1;
await createNodeWithIntersection({
type,
position: { x: 100 + offset, y: 100 + offset },
width,
height,
data,
});
setOpen(false);
};
return (
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Befehle"
description="Knoten hinzufuegen oder Erscheinungsbild aendern"
>
<Command>
<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={() =>
void handleAddNode(
template.type,
template.defaultData,
template.width,
template.height,
)
}
>
<Icon className="size-4" />
{template.label}
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Erscheinungsbild">
<CommandItem
keywords={["light", "hell", "day"]}
onSelect={() => {
setTheme("light");
setOpen(false);
}}
>
<Sun className="size-4" />
Hell
</CommandItem>
<CommandItem
keywords={["dark", "dunkel", "night"]}
onSelect={() => {
setTheme("dark");
setOpen(false);
}}
>
<Moon className="size-4" />
Dunkel
</CommandItem>
</CommandGroup>
</CommandList>
<div className="border-t px-3 py-2 text-xs text-muted-foreground">
<span className="font-mono tracking-wide">K · Ctrl+K</span>
<span className="ml-2">Palette umschalten</span>
</div>
</Command>
</CommandDialog>
);
}

View File

@@ -7,13 +7,56 @@ import {
useMemo,
type ReactNode,
} from "react";
import { useMutation } from "convex/react";
import type { ReactMutation } from "convex/react";
import type { FunctionReference } from "convex/server";
import { useReactFlow, useStore, type Edge as RFEdge } from "@xyflow/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
type CreateNodeMutation = ReactMutation<
FunctionReference<
"mutation",
"public",
{
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: unknown;
parentId?: Id<"nodes">;
zIndex?: number;
},
Id<"nodes">
>
>;
type CreateNodeWithEdgeSplitMutation = ReactMutation<
FunctionReference<
"mutation",
"public",
{
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: unknown;
parentId?: Id<"nodes">;
zIndex?: number;
splitEdgeId: Id<"edges">;
newNodeTargetHandle?: string;
newNodeSourceHandle?: string;
splitSourceHandle?: string;
splitTargetHandle?: string;
},
Id<"nodes">
>
>;
type FlowPoint = { x: number; y: number };
type CreateNodeWithIntersectionInput = {
@@ -87,18 +130,19 @@ function normalizeHandle(handle: string | null | undefined): string | undefined
interface CanvasPlacementProviderProps {
canvasId: Id<"canvases">;
createNode: CreateNodeMutation;
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
children: ReactNode;
}
export function CanvasPlacementProvider({
canvasId,
createNode,
createNodeWithEdgeSplit,
children,
}: CanvasPlacementProviderProps) {
const { flowToScreenPosition } = useReactFlow();
const edges = useStore((store) => store.edges);
const createNode = useMutation(api.nodes.create);
const createEdge = useMutation(api.edges.create);
const removeEdge = useMutation(api.edges.remove);
const createNodeWithIntersection = useCallback(
async ({
@@ -130,7 +174,7 @@ export function CanvasPlacementProvider({
hitEdgeFromClientPosition ??
getIntersectedPersistedEdge(centerClientPosition, edges);
const nodeId = await createNode({
const nodePayload = {
canvasId,
type,
positionX: position.x,
@@ -143,47 +187,36 @@ export function CanvasPlacementProvider({
canvasId,
},
...(zIndex !== undefined ? { zIndex } : {}),
});
};
if (!hitEdge) {
return nodeId;
return await createNode(nodePayload);
}
const handles = NODE_HANDLE_MAP[type];
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
return nodeId;
return await createNode(nodePayload);
}
try {
await createEdge({
canvasId,
sourceNodeId: hitEdge.source as Id<"nodes">,
targetNodeId: nodeId,
sourceHandle: normalizeHandle(hitEdge.sourceHandle),
targetHandle: normalizeHandle(handles.target),
return await createNodeWithEdgeSplit({
...nodePayload,
splitEdgeId: hitEdge.id as Id<"edges">,
newNodeTargetHandle: normalizeHandle(handles.target),
newNodeSourceHandle: normalizeHandle(handles.source),
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
});
await createEdge({
canvasId,
sourceNodeId: nodeId,
targetNodeId: hitEdge.target as Id<"nodes">,
sourceHandle: normalizeHandle(handles.source),
targetHandle: normalizeHandle(hitEdge.targetHandle),
});
await removeEdge({ edgeId: hitEdge.id as Id<"edges"> });
} catch (error) {
console.error("[Canvas placement] edge split failed", {
edgeId: hitEdge.id,
nodeId,
type,
error: String(error),
});
throw error;
}
return nodeId;
},
[canvasId, createEdge, createNode, edges, flowToScreenPosition, removeEdge],
[canvasId, createNode, createNodeWithEdgeSplit, edges, flowToScreenPosition],
);
const value = useMemo(

View File

@@ -5,51 +5,10 @@ import { useRef } from "react";
import { CreditDisplay } from "@/components/canvas/credit-display";
import { ExportButton } from "@/components/canvas/export-button";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
const nodeTemplates = [
{
type: "image",
label: "Bild",
width: 280,
height: 180,
defaultData: {},
},
{
type: "text",
label: "Text",
width: 256,
height: 120,
defaultData: { content: "" },
},
{
type: "prompt",
label: "Prompt",
width: 320,
height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
},
{
type: "note",
label: "Notiz",
width: 220,
height: 120,
defaultData: { content: "" },
},
{
type: "frame",
label: "Frame",
width: 360,
height: 240,
defaultData: { label: "Untitled", exportWidth: 1080, exportHeight: 1080 },
},
{
type: "compare",
label: "Compare",
width: 500,
height: 380,
defaultData: {},
},
] as const;
import {
CANVAS_NODE_TEMPLATES,
type CanvasNodeTemplate,
} from "@/lib/canvas-node-templates";
interface CanvasToolbarProps {
canvasName?: string;
@@ -62,8 +21,8 @@ export default function CanvasToolbar({
const nodeCountRef = useRef(0);
const handleAddNode = async (
type: (typeof nodeTemplates)[number]["type"],
data: (typeof nodeTemplates)[number]["defaultData"],
type: CanvasNodeTemplate["type"],
data: CanvasNodeTemplate["defaultData"],
width: number,
height: number,
) => {
@@ -80,7 +39,7 @@ export default function CanvasToolbar({
return (
<div className="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-xl border bg-card/90 p-1.5 shadow-lg backdrop-blur-sm">
{nodeTemplates.map((template) => (
{CANVAS_NODE_TEMPLATES.map((template) => (
<button
key={template.type}
onClick={() =>

View File

@@ -26,11 +26,12 @@ import { msg } from "@/lib/toast-messages";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import type { Doc, Id } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client";
import { nodeTypes } from "./node-types";
import {
convexNodeDocWithMergedStorageUrl,
convexNodeToRF,
convexEdgeToRF,
NODE_DEFAULTS,
@@ -38,6 +39,7 @@ import {
resolveMediaAspectRatio,
} from "@/lib/canvas-utils";
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
interface CanvasInnerProps {
@@ -338,6 +340,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
api.edges.list,
shouldSkipCanvasQueries ? "skip" : { canvasId },
);
const storageUrlsById = useQuery(
api.storage.batchGetUrlsForCanvas,
shouldSkipCanvasQueries ? "skip" : { canvasId },
);
const canvas = useQuery(
api.canvases.get,
shouldSkipCanvasQueries ? "skip" : { canvasId },
@@ -347,7 +353,40 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const moveNode = useMutation(api.nodes.move);
const resizeNode = useMutation(api.nodes.resize);
const batchMoveNodes = useMutation(api.nodes.batchMove);
const createNode = useMutation(api.nodes.create);
const createNode = useMutation(api.nodes.create).withOptimisticUpdate(
(localStore, args) => {
const current = localStore.getQuery(api.nodes.list, {
canvasId: args.canvasId,
});
if (current === undefined) return;
const tempId =
`optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 11)}` as Id<"nodes">;
const synthetic: Doc<"nodes"> = {
_id: tempId,
_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,
};
localStore.setQuery(
api.nodes.list,
{ canvasId: args.canvasId },
[...current, synthetic],
);
},
);
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
const batchRemoveNodes = useMutation(api.nodes.batchRemove);
const createEdge = useMutation(api.edges.create);
const removeEdge = useMutation(api.edges.remove);
@@ -432,14 +471,27 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
useEffect(() => {
if (!convexNodes || isDragging.current) return;
setNodes((previousNodes) => {
const incomingNodes = withResolvedCompareData(convexNodes.map(convexNodeToRF), edges);
const prevDataById = new Map(
previousNodes.map((node) => [node.id, node.data as Record<string, unknown>]),
);
const enriched = convexNodes.map((node) =>
convexNodeDocWithMergedStorageUrl(
node,
storageUrlsById,
prevDataById,
),
);
const incomingNodes = withResolvedCompareData(
enriched.map(convexNodeToRF),
edges,
);
// Nodes, die gerade optimistisch gelöscht werden, nicht wiederherstellen
const filteredIncoming = deletingNodeIds.current.size > 0
? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id))
: incomingNodes;
return mergeNodesPreservingLocalState(previousNodes, filteredIncoming);
});
}, [convexNodes, edges]);
}, [convexNodes, edges, storageUrlsById]);
useEffect(() => {
if (!convexEdges) return;
@@ -976,9 +1028,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}
return (
<CanvasPlacementProvider canvasId={canvasId}>
<CanvasPlacementProvider
canvasId={canvasId}
createNode={createNode}
createNodeWithEdgeSplit={createNodeWithEdgeSplit}
>
<div className="relative h-full w-full">
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
<CanvasCommandPalette />
<ReactFlow
nodes={nodes}
edges={edges}

View File

@@ -1,8 +1,8 @@
"use client";
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react";
import { Trash2, Copy } from "lucide-react";
import { Trash2, Copy, Loader2 } from "lucide-react";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { NodeErrorBoundary } from "./node-error-boundary";
@@ -46,6 +46,7 @@ function NodeToolbarActions() {
const nodeId = useNodeId();
const { deleteElements, getNode, getNodes, setNodes } = useReactFlow();
const { createNodeWithIntersection } = useCanvasPlacement();
const [isDuplicating, setIsDuplicating] = useState(false);
const handleDelete = () => {
if (!nodeId) return;
@@ -53,10 +54,13 @@ function NodeToolbarActions() {
};
const handleDuplicate = async () => {
if (!nodeId) return;
if (!nodeId || isDuplicating) return;
const node = getNode(nodeId);
if (!node) return;
setIsDuplicating(true);
try {
// Strip internal/runtime fields, keep only user content
const originalData = (node.data ?? {}) as Record<string, unknown>;
const cleanedData: Record<string, unknown> = {};
@@ -112,6 +116,9 @@ function NodeToolbarActions() {
};
selectCreatedNode();
} finally {
setIsDuplicating(false);
}
};
const stopPropagation = (e: React.MouseEvent | React.PointerEvent) => {
@@ -123,12 +130,17 @@ function NodeToolbarActions() {
<div className="flex items-center gap-1 rounded-lg border bg-card p-1 shadow-md">
<button
type="button"
onClick={(e) => { stopPropagation(e); handleDuplicate(); }}
onClick={(e) => { stopPropagation(e); void handleDuplicate(); }}
onPointerDown={stopPropagation}
title="Duplicate"
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title={isDuplicating ? "Duplicating…" : "Duplicate"}
disabled={isDuplicating}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:cursor-wait"
>
<Copy size={14} />
{isDuplicating ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Copy size={14} />
)}
</button>
<button
type="button"