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 { 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<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"],
|
||||
};
|
||||
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() {
|
||||
<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={() =>
|
||||
handleAddNode(
|
||||
template.type,
|
||||
template.defaultData,
|
||||
template.width,
|
||||
template.height,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{template.label}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CanvasNodeTemplatePicker onPick={handleAddNode} />
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Erscheinungsbild">
|
||||
<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 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<Id<"nodes">>;
|
||||
createNodeConnectedToTarget: (
|
||||
input: CreateNodeConnectedToTargetInput,
|
||||
) => Promise<Id<"nodes">>;
|
||||
};
|
||||
|
||||
const CanvasPlacementContext = createContext<CanvasPlacementContextValue | null>(
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<RFNode[]>([]);
|
||||
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
|
||||
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<string | null>(null);
|
||||
const highlightedEdgeRef = useRef<string | null>(null);
|
||||
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
|
||||
@@ -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<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 ────────────────────────────────────
|
||||
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) {
|
||||
<div className="relative h-full w-full">
|
||||
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
|
||||
<CanvasCommandPalette />
|
||||
<CanvasConnectionDropMenu
|
||||
state={connectionDropMenu}
|
||||
onClose={() => setConnectionDropMenu(null)}
|
||||
onPick={handleConnectionDropPick}
|
||||
/>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -1555,6 +1751,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user