diff --git a/components/canvas/canvas-command-palette.tsx b/components/canvas/canvas-command-palette.tsx index 1f3dfcd..2ed94b2 100644 --- a/components/canvas/canvas-command-palette.tsx +++ b/components/canvas/canvas-command-palette.tsx @@ -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 = { - 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"], -}; +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() { Keine Treffer. - - {CANVAS_NODE_TEMPLATES.map((template) => { - const Icon = NODE_ICONS[template.type]; - return ( - - handleAddNode( - template.type, - template.defaultData, - template.width, - template.height, - ) - } - > - - {template.label} - - ); - })} - + ; + 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(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 ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + + Keine Treffer. + { + onPick(template); + onClose(); + }} + groupHeading="Knoten" + /> + + +
+ ); +} diff --git a/components/canvas/canvas-node-template-picker.tsx b/components/canvas/canvas-node-template-picker.tsx new file mode 100644 index 0000000..3eda901 --- /dev/null +++ b/components/canvas/canvas-node-template-picker.tsx @@ -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 = { + 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 type CanvasNodeTemplatePickerProps = { + onPick: (template: CanvasNodeTemplate) => void; + groupHeading?: string; +}; + +/** + * Knoten-Template-Liste für cmdk. Eltern: ` …`. + */ +export function CanvasNodeTemplatePicker({ + onPick, + groupHeading = "Knoten", +}: CanvasNodeTemplatePickerProps) { + return ( + + {CANVAS_NODE_TEMPLATES.map((template) => { + const Icon = NODE_ICONS[template.type]; + return ( + onPick(template)} + > + + {template.label} + + ); + })} + + ); +} diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx index b22001d..b156260 100644 --- a/components/canvas/canvas-placement-context.tsx +++ b/components/canvas/canvas-placement-context.tsx @@ -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>; + createNodeConnectedToTarget: ( + input: CreateNodeConnectedToTargetInput, + ) => Promise>; }; const CanvasPlacementContext = createContext( @@ -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 ( diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 4abbdc6..5f27062 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -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([]); const [edges, setEdges] = useState([]); + const [connectionDropMenu, setConnectionDropMenu] = + useState(null); + const connectionDropMenuRef = useRef(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(null); const highlightedEdgeRef = useRef(null); const highlightedEdgeOriginalStyleRef = useRef( @@ -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( + (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), + 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) {
+ setConnectionDropMenu(null)} + onPick={handleConnectionDropPick} + /> { + const user = await requireAuth(ctx); + await getCanvasOrThrow(ctx, args.canvasId, user.userId); + void args.clientRequestId; + + const target = await ctx.db.get(args.targetNodeId); + if (!target || target.canvasId !== args.canvasId) { + throw new Error("Target node 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: nodeId, + targetNodeId: args.targetNodeId, + sourceHandle: args.sourceHandle, + targetHandle: args.targetHandle, + }); + + await ctx.db.patch(args.canvasId, { updatedAt: Date.now() }); + + return nodeId; + }, +}); + /** * Node-Position auf dem Canvas verschieben. */