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

View File

@@ -5,51 +5,10 @@ import { useRef } from "react";
import { CreditDisplay } from "@/components/canvas/credit-display"; import { CreditDisplay } from "@/components/canvas/credit-display";
import { ExportButton } from "@/components/canvas/export-button"; import { ExportButton } from "@/components/canvas/export-button";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import {
const nodeTemplates = [ CANVAS_NODE_TEMPLATES,
{ type CanvasNodeTemplate,
type: "image", } from "@/lib/canvas-node-templates";
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;
interface CanvasToolbarProps { interface CanvasToolbarProps {
canvasName?: string; canvasName?: string;
@@ -62,8 +21,8 @@ export default function CanvasToolbar({
const nodeCountRef = useRef(0); const nodeCountRef = useRef(0);
const handleAddNode = async ( const handleAddNode = async (
type: (typeof nodeTemplates)[number]["type"], type: CanvasNodeTemplate["type"],
data: (typeof nodeTemplates)[number]["defaultData"], data: CanvasNodeTemplate["defaultData"],
width: number, width: number,
height: number, height: number,
) => { ) => {
@@ -80,7 +39,7 @@ export default function CanvasToolbar({
return ( 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"> <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 <button
key={template.type} key={template.type}
onClick={() => onClick={() =>

View File

@@ -26,11 +26,12 @@ import { msg } from "@/lib/toast-messages";
import { useConvexAuth, useMutation, useQuery } from "convex/react"; import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api"; 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 { authClient } from "@/lib/auth-client";
import { nodeTypes } from "./node-types"; import { nodeTypes } from "./node-types";
import { import {
convexNodeDocWithMergedStorageUrl,
convexNodeToRF, convexNodeToRF,
convexEdgeToRF, convexEdgeToRF,
NODE_DEFAULTS, NODE_DEFAULTS,
@@ -38,6 +39,7 @@ import {
resolveMediaAspectRatio, resolveMediaAspectRatio,
} from "@/lib/canvas-utils"; } from "@/lib/canvas-utils";
import CanvasToolbar from "@/components/canvas/canvas-toolbar"; import CanvasToolbar from "@/components/canvas/canvas-toolbar";
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
interface CanvasInnerProps { interface CanvasInnerProps {
@@ -338,6 +340,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
api.edges.list, api.edges.list,
shouldSkipCanvasQueries ? "skip" : { canvasId }, shouldSkipCanvasQueries ? "skip" : { canvasId },
); );
const storageUrlsById = useQuery(
api.storage.batchGetUrlsForCanvas,
shouldSkipCanvasQueries ? "skip" : { canvasId },
);
const canvas = useQuery( const canvas = useQuery(
api.canvases.get, api.canvases.get,
shouldSkipCanvasQueries ? "skip" : { canvasId }, shouldSkipCanvasQueries ? "skip" : { canvasId },
@@ -347,7 +353,40 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const moveNode = useMutation(api.nodes.move); const moveNode = useMutation(api.nodes.move);
const resizeNode = useMutation(api.nodes.resize); const resizeNode = useMutation(api.nodes.resize);
const batchMoveNodes = useMutation(api.nodes.batchMove); 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 batchRemoveNodes = useMutation(api.nodes.batchRemove);
const createEdge = useMutation(api.edges.create); const createEdge = useMutation(api.edges.create);
const removeEdge = useMutation(api.edges.remove); const removeEdge = useMutation(api.edges.remove);
@@ -432,14 +471,27 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
useEffect(() => { useEffect(() => {
if (!convexNodes || isDragging.current) return; if (!convexNodes || isDragging.current) return;
setNodes((previousNodes) => { 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 // Nodes, die gerade optimistisch gelöscht werden, nicht wiederherstellen
const filteredIncoming = deletingNodeIds.current.size > 0 const filteredIncoming = deletingNodeIds.current.size > 0
? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id)) ? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id))
: incomingNodes; : incomingNodes;
return mergeNodesPreservingLocalState(previousNodes, filteredIncoming); return mergeNodesPreservingLocalState(previousNodes, filteredIncoming);
}); });
}, [convexNodes, edges]); }, [convexNodes, edges, storageUrlsById]);
useEffect(() => { useEffect(() => {
if (!convexEdges) return; if (!convexEdges) return;
@@ -976,9 +1028,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
} }
return ( return (
<CanvasPlacementProvider canvasId={canvasId}> <CanvasPlacementProvider
canvasId={canvasId}
createNode={createNode}
createNodeWithEdgeSplit={createNodeWithEdgeSplit}
>
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} /> <CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
<CanvasCommandPalette />
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}

View File

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

195
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

168
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -47,29 +47,10 @@ export const list = query({
const user = await requireAuth(ctx); const user = await requireAuth(ctx);
await getCanvasOrThrow(ctx, canvasId, user.userId); await getCanvasOrThrow(ctx, canvasId, user.userId);
const nodes = await ctx.db return await ctx.db
.query("nodes") .query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect(); .collect();
return Promise.all(
nodes.map(async (node) => {
const data = node.data as Record<string, unknown> | undefined;
if (!data?.storageId) {
return node;
}
const url = await ctx.storage.getUrl(data.storageId as Id<"_storage">);
return {
...node,
data: {
...data,
url: url ?? undefined,
},
};
})
);
}, },
}); });
@@ -173,6 +154,72 @@ export const create = mutation({
}, },
}); });
/**
* Neuen Node erzeugen und eine bestehende Kante in zwei Kanten aufteilen (ein Roundtrip).
*/
export const createWithEdgeSplit = 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()),
splitEdgeId: v.id("edges"),
newNodeTargetHandle: v.optional(v.string()),
newNodeSourceHandle: v.optional(v.string()),
splitSourceHandle: v.optional(v.string()),
splitTargetHandle: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
const edge = await ctx.db.get(args.splitEdgeId);
if (!edge || edge.canvasId !== args.canvasId) {
throw new Error("Edge 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: edge.sourceNodeId,
targetNodeId: nodeId,
sourceHandle: args.splitSourceHandle,
targetHandle: args.newNodeTargetHandle,
});
await ctx.db.insert("edges", {
canvasId: args.canvasId,
sourceNodeId: nodeId,
targetNodeId: edge.targetNodeId,
sourceHandle: args.newNodeSourceHandle,
targetHandle: args.splitTargetHandle,
});
await ctx.db.delete(args.splitEdgeId);
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
return nodeId;
},
});
/** /**
* Node-Position auf dem Canvas verschieben. * Node-Position auf dem Canvas verschieben.
*/ */
@@ -409,8 +456,6 @@ export const batchRemove = mutation({
if (!firstNode) throw new Error("Node not found"); if (!firstNode) throw new Error("Node not found");
await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId); await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId);
const nodeIdSet = new Set(nodeIds.map((id) => id.toString()));
for (const nodeId of nodeIds) { for (const nodeId of nodeIds) {
const node = await ctx.db.get(nodeId); const node = await ctx.db.get(nodeId);
if (!node) continue; if (!node) continue;

View File

@@ -1,5 +1,7 @@
import { mutation } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { requireAuth } from "./helpers"; import { requireAuth } from "./helpers";
import type { Id } from "./_generated/dataModel";
export const generateUploadUrl = mutation({ export const generateUploadUrl = mutation({
args: {}, args: {},
@@ -8,3 +10,41 @@ export const generateUploadUrl = mutation({
return await ctx.storage.generateUploadUrl(); return await ctx.storage.generateUploadUrl();
}, },
}); });
/**
* Signierte URLs für alle Storage-Assets eines Canvas (gebündelt).
* `nodes.list` liefert keine URLs mehr, damit Node-Liste schnell bleibt.
*/
export const batchGetUrlsForCanvas = query({
args: { canvasId: v.id("canvases") },
handler: async (ctx, { canvasId }) => {
const user = await requireAuth(ctx);
const canvas = await ctx.db.get(canvasId);
if (!canvas || canvas.ownerId !== user.userId) {
throw new Error("Canvas not found");
}
const nodes = await ctx.db
.query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect();
const ids = new Set<Id<"_storage">>();
for (const node of nodes) {
const data = node.data as Record<string, unknown> | undefined;
const sid = data?.storageId;
if (typeof sid === "string" && sid.length > 0) {
ids.add(sid as Id<"_storage">);
}
}
const entries = await Promise.all(
[...ids].map(
async (id) =>
[id, (await ctx.storage.getUrl(id)) ?? undefined] as const,
),
);
return Object.fromEntries(entries) as Record<string, string | undefined>;
},
});

View File

@@ -0,0 +1,46 @@
export const CANVAS_NODE_TEMPLATES = [
{
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;
export type CanvasNodeTemplate = (typeof CANVAS_NODE_TEMPLATES)[number];

View File

@@ -7,6 +7,46 @@ import type { Doc } from "@/convex/_generated/dataModel";
* Convex speichert positionX/positionY als separate Felder, * Convex speichert positionX/positionY als separate Felder,
* React Flow erwartet position: { x, y }. * React Flow erwartet position: { x, y }.
*/ */
/**
* Reichert Node-Dokumente mit `data.url` an (aus gebündelter Storage-URL-Map).
* Behält eine zuvor gemappte URL bei, solange die Batch-Query noch lädt.
*/
export function convexNodeDocWithMergedStorageUrl(
node: Doc<"nodes">,
urlByStorage: Record<string, string | undefined> | undefined,
previousDataByNodeId: Map<string, Record<string, unknown>>,
): Doc<"nodes"> {
const data = node.data as Record<string, unknown> | undefined;
const sid = data?.storageId;
if (typeof sid !== "string") {
return node;
}
if (urlByStorage) {
const fromBatch = urlByStorage[sid];
if (fromBatch !== undefined) {
return {
...node,
data: { ...data, url: fromBatch },
};
}
}
const prev = previousDataByNodeId.get(node._id);
if (
prev?.url !== undefined &&
typeof prev.storageId === "string" &&
prev.storageId === sid
) {
return {
...node,
data: { ...data, url: prev.url },
};
}
return node;
}
export function convexNodeToRF(node: Doc<"nodes">): RFNode { export function convexNodeToRF(node: Doc<"nodes">): RFNode {
return { return {
id: node._id, id: node._id,

View File

@@ -22,6 +22,7 @@
"better-auth": "^1.5.6", "better-auth": "^1.5.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"convex": "^1.34.0", "convex": "^1.34.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"goey-toast": "^0.3.0", "goey-toast": "^0.3.0",

3
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
convex: convex:
specifier: ^1.34.0 specifier: ^1.34.0
version: 1.34.0(react@19.2.4) version: 1.34.0(react@19.2.4)