feat: implement createNodeWithEdgeToTarget mutation for enhanced node connections
- Added a new mutation to create nodes connected to existing nodes, allowing for more dynamic interactions on the canvas. - Updated the CanvasPlacementContext to include the new mutation, improving the workflow for node creation and edge management. - Enhanced optimistic updates for immediate UI feedback during node and edge creation processes. - Refactored related components to support the new connection method, streamlining user interactions.
This commit is contained in:
@@ -2,18 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import { Moon, Sun } from "lucide-react";
|
||||||
Frame,
|
|
||||||
GitCompare,
|
|
||||||
Image,
|
|
||||||
Moon,
|
|
||||||
Sparkles,
|
|
||||||
StickyNote,
|
|
||||||
Sun,
|
|
||||||
Type,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
|
import { CanvasNodeTemplatePicker } from "@/components/canvas/canvas-node-template-picker";
|
||||||
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||||
import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
|
import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
|
||||||
import {
|
import {
|
||||||
@@ -26,30 +17,7 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import {
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
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() {
|
export function CanvasCommandPalette() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -69,21 +37,16 @@ export function CanvasCommandPalette() {
|
|||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddNode = (
|
const handleAddNode = (template: CanvasNodeTemplate) => {
|
||||||
type: CanvasNodeTemplate["type"],
|
|
||||||
data: CanvasNodeTemplate["defaultData"],
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
) => {
|
|
||||||
const stagger = (nodeCountRef.current % 8) * 24;
|
const stagger = (nodeCountRef.current % 8) * 24;
|
||||||
nodeCountRef.current += 1;
|
nodeCountRef.current += 1;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
void createNodeWithIntersection({
|
void createNodeWithIntersection({
|
||||||
type,
|
type: template.type,
|
||||||
position: getCenteredPosition(width, height, stagger),
|
position: getCenteredPosition(template.width, template.height, stagger),
|
||||||
width,
|
width: template.width,
|
||||||
height,
|
height: template.height,
|
||||||
data,
|
data: template.defaultData,
|
||||||
clientRequestId: crypto.randomUUID(),
|
clientRequestId: crypto.randomUUID(),
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("[CanvasCommandPalette] createNode failed", error);
|
console.error("[CanvasCommandPalette] createNode failed", error);
|
||||||
@@ -101,28 +64,7 @@ export function CanvasCommandPalette() {
|
|||||||
<CommandInput placeholder="Suchen …" />
|
<CommandInput placeholder="Suchen …" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>Keine Treffer.</CommandEmpty>
|
<CommandEmpty>Keine Treffer.</CommandEmpty>
|
||||||
<CommandGroup heading="Knoten">
|
<CanvasNodeTemplatePicker onPick={handleAddNode} />
|
||||||
{CANVAS_NODE_TEMPLATES.map((template) => {
|
|
||||||
const Icon = NODE_ICONS[template.type];
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={template.type}
|
|
||||||
keywords={NODE_SEARCH_KEYWORDS[template.type] ?? []}
|
|
||||||
onSelect={() =>
|
|
||||||
handleAddNode(
|
|
||||||
template.type,
|
|
||||||
template.defaultData,
|
|
||||||
template.width,
|
|
||||||
template.height,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
{template.label}
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
<CommandGroup heading="Erscheinungsbild">
|
<CommandGroup heading="Erscheinungsbild">
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
|||||||
112
components/canvas/canvas-connection-drop-menu.tsx
Normal file
112
components/canvas/canvas-connection-drop-menu.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, type CSSProperties } from "react";
|
||||||
|
|
||||||
|
import { CanvasNodeTemplatePicker } from "@/components/canvas/canvas-node-template-picker";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
|
||||||
|
export type ConnectionDropMenuState = {
|
||||||
|
screenX: number;
|
||||||
|
screenY: number;
|
||||||
|
flowX: number;
|
||||||
|
flowY: number;
|
||||||
|
fromNodeId: Id<"nodes">;
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Knoten wählen zur Verbindung"
|
||||||
|
className={cn(
|
||||||
|
"fixed z-100 flex max-h-(--panel-max-h) w-[min(100vw-1rem,360px)] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground ring-1 ring-foreground/10 shadow-lg",
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
"--panel-max-h": `${PANEL_MAX_H}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Command className="rounded-xl! bg-popover">
|
||||||
|
<CommandInput placeholder="Knoten suchen …" autoFocus />
|
||||||
|
<CommandList className="max-h-72">
|
||||||
|
<CommandEmpty>Keine Treffer.</CommandEmpty>
|
||||||
|
<CanvasNodeTemplatePicker
|
||||||
|
onPick={(template) => {
|
||||||
|
onPick(template);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
groupHeading="Knoten"
|
||||||
|
/>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
components/canvas/canvas-node-template-picker.tsx
Normal file
68
components/canvas/canvas-node-template-picker.tsx
Normal file
@@ -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<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 type CanvasNodeTemplatePickerProps = {
|
||||||
|
onPick: (template: CanvasNodeTemplate) => void;
|
||||||
|
groupHeading?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knoten-Template-Liste für cmdk. Eltern: `<Command><CommandInput/><CommandList><CommandEmpty/> <CanvasNodeTemplatePicker /> …`.
|
||||||
|
*/
|
||||||
|
export function CanvasNodeTemplatePicker({
|
||||||
|
onPick,
|
||||||
|
groupHeading = "Knoten",
|
||||||
|
}: CanvasNodeTemplatePickerProps) {
|
||||||
|
return (
|
||||||
|
<CommandGroup heading={groupHeading}>
|
||||||
|
{CANVAS_NODE_TEMPLATES.map((template) => {
|
||||||
|
const Icon = NODE_ICONS[template.type];
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={template.type}
|
||||||
|
keywords={NODE_SEARCH_KEYWORDS[template.type] ?? []}
|
||||||
|
onSelect={() => onPick(template)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{template.label}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 FlowPoint = { x: number; y: number };
|
||||||
|
|
||||||
type CreateNodeWithIntersectionInput = {
|
type CreateNodeWithIntersectionInput = {
|
||||||
@@ -105,6 +128,12 @@ export type CreateNodeConnectedFromSourceInput = CreateNodeWithIntersectionInput
|
|||||||
targetHandle?: string;
|
targetHandle?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreateNodeConnectedToTargetInput = CreateNodeWithIntersectionInput & {
|
||||||
|
targetNodeId: Id<"nodes">;
|
||||||
|
sourceHandle?: string;
|
||||||
|
targetHandle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type CanvasPlacementContextValue = {
|
type CanvasPlacementContextValue = {
|
||||||
createNodeWithIntersection: (
|
createNodeWithIntersection: (
|
||||||
input: CreateNodeWithIntersectionInput,
|
input: CreateNodeWithIntersectionInput,
|
||||||
@@ -112,6 +141,9 @@ type CanvasPlacementContextValue = {
|
|||||||
createNodeConnectedFromSource: (
|
createNodeConnectedFromSource: (
|
||||||
input: CreateNodeConnectedFromSourceInput,
|
input: CreateNodeConnectedFromSourceInput,
|
||||||
) => Promise<Id<"nodes">>;
|
) => Promise<Id<"nodes">>;
|
||||||
|
createNodeConnectedToTarget: (
|
||||||
|
input: CreateNodeConnectedToTargetInput,
|
||||||
|
) => Promise<Id<"nodes">>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CanvasPlacementContext = createContext<CanvasPlacementContextValue | null>(
|
const CanvasPlacementContext = createContext<CanvasPlacementContextValue | null>(
|
||||||
@@ -172,6 +204,7 @@ interface CanvasPlacementProviderProps {
|
|||||||
createNode: CreateNodeMutation;
|
createNode: CreateNodeMutation;
|
||||||
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
|
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
|
||||||
createNodeWithEdgeFromSource: CreateNodeWithEdgeFromSourceMutation;
|
createNodeWithEdgeFromSource: CreateNodeWithEdgeFromSourceMutation;
|
||||||
|
createNodeWithEdgeToTarget: CreateNodeWithEdgeToTargetMutation;
|
||||||
onCreateNodeSettled?: (payload: {
|
onCreateNodeSettled?: (payload: {
|
||||||
clientRequestId?: string;
|
clientRequestId?: string;
|
||||||
realId: Id<"nodes">;
|
realId: Id<"nodes">;
|
||||||
@@ -184,6 +217,7 @@ export function CanvasPlacementProvider({
|
|||||||
createNode,
|
createNode,
|
||||||
createNodeWithEdgeSplit,
|
createNodeWithEdgeSplit,
|
||||||
createNodeWithEdgeFromSource,
|
createNodeWithEdgeFromSource,
|
||||||
|
createNodeWithEdgeToTarget,
|
||||||
onCreateNodeSettled,
|
onCreateNodeSettled,
|
||||||
children,
|
children,
|
||||||
}: CanvasPlacementProviderProps) {
|
}: CanvasPlacementProviderProps) {
|
||||||
@@ -327,9 +361,65 @@ export function CanvasPlacementProvider({
|
|||||||
[canvasId, createNodeWithEdgeFromSource, onCreateNodeSettled],
|
[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(
|
const value = useMemo(
|
||||||
() => ({ createNodeWithIntersection, createNodeConnectedFromSource }),
|
() => ({
|
||||||
[createNodeConnectedFromSource, createNodeWithIntersection],
|
createNodeWithIntersection,
|
||||||
|
createNodeConnectedFromSource,
|
||||||
|
createNodeConnectedToTarget,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
createNodeConnectedFromSource,
|
||||||
|
createNodeConnectedToTarget,
|
||||||
|
createNodeWithIntersection,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type EdgeChange,
|
type EdgeChange,
|
||||||
type Connection,
|
type Connection,
|
||||||
type DefaultEdgeOptions,
|
type DefaultEdgeOptions,
|
||||||
|
type OnConnectEnd,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
@@ -48,8 +49,13 @@ import {
|
|||||||
} from "@/lib/image-formats";
|
} from "@/lib/image-formats";
|
||||||
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
||||||
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
|
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 { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
||||||
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
||||||
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
|
|
||||||
interface CanvasInnerProps {
|
interface CanvasInnerProps {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
@@ -62,12 +68,27 @@ function isOptimisticNodeId(id: string): boolean {
|
|||||||
return id.startsWith(OPTIMISTIC_NODE_PREFIX);
|
return id.startsWith(OPTIMISTIC_NODE_PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOptimisticEdgeId(id: string): boolean {
|
||||||
|
return id.startsWith(OPTIMISTIC_EDGE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
function clientRequestIdFromOptimisticNodeId(id: string): string | null {
|
function clientRequestIdFromOptimisticNodeId(id: string): string | null {
|
||||||
if (!isOptimisticNodeId(id)) return null;
|
if (!isOptimisticNodeId(id)) return null;
|
||||||
const suffix = id.slice(OPTIMISTIC_NODE_PREFIX.length);
|
const suffix = id.slice(OPTIMISTIC_NODE_PREFIX.length);
|
||||||
return suffix.length > 0 ? suffix : null;
|
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. */
|
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
|
||||||
type PendingEdgeSplit = {
|
type PendingEdgeSplit = {
|
||||||
intersectedEdgeId: Id<"edges">;
|
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 createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
|
||||||
|
|
||||||
const batchRemoveNodes = useMutation(api.nodes.batchRemove).withOptimisticUpdate(
|
const batchRemoveNodes = useMutation(api.nodes.batchRemove).withOptimisticUpdate(
|
||||||
@@ -709,6 +789,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
||||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||||
|
const [connectionDropMenu, setConnectionDropMenu] =
|
||||||
|
useState<ConnectionDropMenuState | null>(null);
|
||||||
|
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
||||||
|
connectionDropMenuRef.current = connectionDropMenu;
|
||||||
|
|
||||||
// Drag-Lock: während des Drags kein Convex-Override
|
// Drag-Lock: während des Drags kein Convex-Override
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
@@ -718,6 +802,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
// Delete Edge on Drop
|
// Delete Edge on Drop
|
||||||
const edgeReconnectSuccessful = useRef(true);
|
const edgeReconnectSuccessful = useRef(true);
|
||||||
|
const isReconnectDragActiveRef = useRef(false);
|
||||||
const overlappedEdgeRef = useRef<string | null>(null);
|
const overlappedEdgeRef = useRef<string | null>(null);
|
||||||
const highlightedEdgeRef = useRef<string | null>(null);
|
const highlightedEdgeRef = useRef<string | null>(null);
|
||||||
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
|
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
|
||||||
@@ -1060,6 +1145,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
// ─── Delete Edge on Drop ──────────────────────────────────────
|
// ─── Delete Edge on Drop ──────────────────────────────────────
|
||||||
const onReconnectStart = useCallback(() => {
|
const onReconnectStart = useCallback(() => {
|
||||||
edgeReconnectSuccessful.current = false;
|
edgeReconnectSuccessful.current = false;
|
||||||
|
isReconnectDragActiveRef.current = true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onReconnect = useCallback(
|
const onReconnect = useCallback(
|
||||||
@@ -1072,24 +1158,32 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
const onReconnectEnd = useCallback(
|
const onReconnectEnd = useCallback(
|
||||||
(_: MouseEvent | TouchEvent, edge: RFEdge) => {
|
(_: MouseEvent | TouchEvent, edge: RFEdge) => {
|
||||||
if (!edgeReconnectSuccessful.current) {
|
try {
|
||||||
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
|
if (!edgeReconnectSuccessful.current) {
|
||||||
if (edge.className === "temp") {
|
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
|
||||||
edgeReconnectSuccessful.current = true;
|
if (edge.className === "temp") {
|
||||||
return;
|
edgeReconnectSuccessful.current = true;
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
if (isOptimisticEdgeId(edge.id)) {
|
||||||
console.error("[Canvas edge remove failed] reconnect end", {
|
return;
|
||||||
edgeId: edge.id,
|
}
|
||||||
edgeClassName: edge.className ?? null,
|
|
||||||
source: edge.source,
|
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
||||||
target: edge.target,
|
console.error("[Canvas edge remove failed] reconnect end", {
|
||||||
error: String(error),
|
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],
|
[removeEdge],
|
||||||
);
|
);
|
||||||
@@ -1396,6 +1490,98 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
[createEdge, canvasId],
|
[createEdge, canvasId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||||
|
(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<string, unknown>),
|
||||||
|
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 ────────────────────────────────────
|
// ─── Node löschen → Convex ────────────────────────────────────
|
||||||
const onNodesDelete = useCallback(
|
const onNodesDelete = useCallback(
|
||||||
(deletedNodes: RFNode[]) => {
|
(deletedNodes: RFNode[]) => {
|
||||||
@@ -1459,6 +1645,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOptimisticEdgeId(edge.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
||||||
console.error("[Canvas edge remove failed] edge delete", {
|
console.error("[Canvas edge remove failed] edge delete", {
|
||||||
edgeId: edge.id,
|
edgeId: edge.id,
|
||||||
@@ -1535,6 +1725,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
createNode={createNode}
|
createNode={createNode}
|
||||||
createNodeWithEdgeSplit={createNodeWithEdgeSplit}
|
createNodeWithEdgeSplit={createNodeWithEdgeSplit}
|
||||||
createNodeWithEdgeFromSource={createNodeWithEdgeFromSource}
|
createNodeWithEdgeFromSource={createNodeWithEdgeFromSource}
|
||||||
|
createNodeWithEdgeToTarget={createNodeWithEdgeToTarget}
|
||||||
onCreateNodeSettled={({ clientRequestId, realId }) =>
|
onCreateNodeSettled={({ clientRequestId, realId }) =>
|
||||||
syncPendingMoveForClientRequest(clientRequestId, realId)
|
syncPendingMoveForClientRequest(clientRequestId, realId)
|
||||||
}
|
}
|
||||||
@@ -1542,6 +1733,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
<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 />
|
<CanvasCommandPalette />
|
||||||
|
<CanvasConnectionDropMenu
|
||||||
|
state={connectionDropMenu}
|
||||||
|
onClose={() => setConnectionDropMenu(null)}
|
||||||
|
onPick={handleConnectionDropPick}
|
||||||
|
/>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -1555,6 +1751,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
onNodeDrag={onNodeDrag}
|
onNodeDrag={onNodeDrag}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={onNodeDragStop}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onConnectEnd={onConnectEnd}
|
||||||
onReconnect={onReconnect}
|
onReconnect={onReconnect}
|
||||||
onReconnectStart={onReconnectStart}
|
onReconnectStart={onReconnectStart}
|
||||||
onReconnectEnd={onReconnectEnd}
|
onReconnectEnd={onReconnectEnd}
|
||||||
|
|||||||
@@ -350,6 +350,64 @@ export const createWithEdgeFromSource = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuen Node erstellen und als Quelle mit einem bestehenden Node verbinden
|
||||||
|
* (Kante: neu → bestehend), z. B. Kante von Input-Handle gezogen und abgesetzt.
|
||||||
|
*/
|
||||||
|
export const createWithEdgeToTarget = 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()),
|
||||||
|
clientRequestId: v.optional(v.string()),
|
||||||
|
targetNodeId: v.id("nodes"),
|
||||||
|
sourceHandle: v.optional(v.string()),
|
||||||
|
targetHandle: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
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.
|
* Node-Position auf dem Canvas verschieben.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user