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:
152
components/canvas/canvas-command-palette.tsx
Normal file
152
components/canvas/canvas-command-palette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user