diff --git a/components/canvas/canvas-command-palette.tsx b/components/canvas/canvas-command-palette.tsx new file mode 100644 index 0000000..b9d7e0c --- /dev/null +++ b/components/canvas/canvas-command-palette.tsx @@ -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 = { + image: Image, + text: Type, + prompt: Sparkles, + note: StickyNote, + frame: Frame, + compare: GitCompare, +}; + +const NODE_SEARCH_KEYWORDS: Partial< + Record +> = { + 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 ( + + + + + Keine Treffer. + + {CANVAS_NODE_TEMPLATES.map((template) => { + const Icon = NODE_ICONS[template.type]; + return ( + + void handleAddNode( + template.type, + template.defaultData, + template.width, + template.height, + ) + } + > + + {template.label} + + ); + })} + + + + { + setTheme("light"); + setOpen(false); + }} + > + + Hell + + { + setTheme("dark"); + setOpen(false); + }} + > + + Dunkel + + + +
+ ⌘K · Ctrl+K + Palette umschalten +
+
+
+ ); +} diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx index e362798..c1a6a12 100644 --- a/components/canvas/canvas-placement-context.tsx +++ b/components/canvas/canvas-placement-context.tsx @@ -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( diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx index f1c7788..3c97a97 100644 --- a/components/canvas/canvas-toolbar.tsx +++ b/components/canvas/canvas-toolbar.tsx @@ -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 (
- {nodeTemplates.map((template) => ( + {CANVAS_NODE_TEMPLATES.map((template) => ( + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/components/ui/input-group.tsx b/components/ui/input-group.tsx new file mode 100644 index 0000000..256ba4b --- /dev/null +++ b/components/ui/input-group.tsx @@ -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 ( +
[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) { + return ( +
{ + 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, "size"> & + VariantProps) { + return ( +