feat: enhance canvas connection handling with custom animation and edge management
- Added a custom connection line component with animation for improved visual feedback during node interactions. - Implemented CSS animations for temporary connection lines, enhancing the user experience in the canvas. - Refactored edge creation and removal logic to support optimistic updates, improving performance during node manipulations. - Introduced a utility function to compute edge reconnections after node deletions, streamlining edge management.
This commit is contained in:
@@ -138,6 +138,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Temporäre XYFlow-Verbindungslinie (custom connectionLineComponent) */
|
||||||
|
@keyframes ls-connection-dash-offset {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -18;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-connection-line-marching {
|
||||||
|
animation: ls-connection-dash-offset 0.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ls-connection-line-marching {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.animate-shimmer {
|
.animate-shimmer {
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import type { ReactMutation } from "convex/react";
|
import type { ReactMutation } from "convex/react";
|
||||||
import type { FunctionReference } from "convex/server";
|
import type { FunctionReference } from "convex/server";
|
||||||
import { useReactFlow, useStore, type Edge as RFEdge } from "@xyflow/react";
|
import { useStore, type Edge as RFEdge } from "@xyflow/react";
|
||||||
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||||
@@ -89,6 +89,10 @@ type CreateNodeWithIntersectionInput = {
|
|||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Optionaler Bildschirmpunkt für Hit-Test auf eine Kante. Nur wenn gesetzt,
|
||||||
|
* kann eine bestehende Kante gesplittet werden — ohne dieses Feld niemals.
|
||||||
|
*/
|
||||||
clientPosition?: FlowPoint;
|
clientPosition?: FlowPoint;
|
||||||
zIndex?: number;
|
zIndex?: number;
|
||||||
/** Correlate optimistic node id with server id after create (see canvas move flush). */
|
/** Correlate optimistic node id with server id after create (see canvas move flush). */
|
||||||
@@ -183,7 +187,6 @@ export function CanvasPlacementProvider({
|
|||||||
onCreateNodeSettled,
|
onCreateNodeSettled,
|
||||||
children,
|
children,
|
||||||
}: CanvasPlacementProviderProps) {
|
}: CanvasPlacementProviderProps) {
|
||||||
const { flowToScreenPosition } = useReactFlow();
|
|
||||||
const edges = useStore((store) => store.edges);
|
const edges = useStore((store) => store.edges);
|
||||||
|
|
||||||
const createNodeWithIntersection = useCallback(
|
const createNodeWithIntersection = useCallback(
|
||||||
@@ -205,17 +208,10 @@ export function CanvasPlacementProvider({
|
|||||||
|
|
||||||
const effectiveWidth = width ?? defaults.width;
|
const effectiveWidth = width ?? defaults.width;
|
||||||
const effectiveHeight = height ?? defaults.height;
|
const effectiveHeight = height ?? defaults.height;
|
||||||
const centerClientPosition = flowToScreenPosition({
|
|
||||||
x: position.x + effectiveWidth / 2,
|
|
||||||
y: position.y + effectiveHeight / 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hitEdgeFromClientPosition = clientPosition
|
const hitEdge = clientPosition
|
||||||
? getIntersectedPersistedEdge(clientPosition, edges)
|
? getIntersectedPersistedEdge(clientPosition, edges)
|
||||||
: undefined;
|
: undefined;
|
||||||
const hitEdge =
|
|
||||||
hitEdgeFromClientPosition ??
|
|
||||||
getIntersectedPersistedEdge(centerClientPosition, edges);
|
|
||||||
|
|
||||||
const baseNodePayload = {
|
const baseNodePayload = {
|
||||||
canvasId,
|
canvasId,
|
||||||
@@ -279,7 +275,6 @@ export function CanvasPlacementProvider({
|
|||||||
createNode,
|
createNode,
|
||||||
createNodeWithEdgeSplit,
|
createNodeWithEdgeSplit,
|
||||||
edges,
|
edges,
|
||||||
flowToScreenPosition,
|
|
||||||
onCreateNodeSettled,
|
onCreateNodeSettled,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
|
|
||||||
import { nodeTypes } from "./node-types";
|
import { nodeTypes } from "./node-types";
|
||||||
import {
|
import {
|
||||||
|
computeBridgeCreatesForDeletedNodes,
|
||||||
convexNodeDocWithMergedStorageUrl,
|
convexNodeDocWithMergedStorageUrl,
|
||||||
convexNodeToRF,
|
convexNodeToRF,
|
||||||
convexEdgeToRF,
|
convexEdgeToRF,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
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 { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
||||||
|
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
||||||
|
|
||||||
interface CanvasInnerProps {
|
interface CanvasInnerProps {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
@@ -66,6 +68,17 @@ function clientRequestIdFromOptimisticNodeId(id: string): string | null {
|
|||||||
return suffix.length > 0 ? suffix : null;
|
return suffix.length > 0 ? suffix : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
|
||||||
|
type PendingEdgeSplit = {
|
||||||
|
intersectedEdgeId: Id<"edges">;
|
||||||
|
sourceNodeId: Id<"nodes">;
|
||||||
|
targetNodeId: Id<"nodes">;
|
||||||
|
intersectedSourceHandle?: string;
|
||||||
|
intersectedTargetHandle?: string;
|
||||||
|
middleSourceHandle?: string;
|
||||||
|
middleTargetHandle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] {
|
function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] {
|
||||||
const persistedEdges = edges.filter((edge) => edge.className !== "temp");
|
const persistedEdges = edges.filter((edge) => edge.className !== "temp");
|
||||||
let hasNodeUpdates = false;
|
let hasNodeUpdates = false;
|
||||||
@@ -380,40 +393,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
new Map<string, { positionX: number; positionY: number }>(),
|
new Map<string, { positionX: number; positionY: number }>(),
|
||||||
);
|
);
|
||||||
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
|
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
|
||||||
|
const pendingEdgeSplitByClientRequestRef = useRef(
|
||||||
/** Pairing: create kann vor oder nach Drag-Ende fertig sein — was zuerst kommt, speichert; das andere triggert moveNode. */
|
new Map<string, PendingEdgeSplit>(),
|
||||||
const syncPendingMoveForClientRequest = useCallback(
|
|
||||||
(clientRequestId: string | undefined, realId?: Id<"nodes">) => {
|
|
||||||
if (!clientRequestId) return;
|
|
||||||
|
|
||||||
if (realId !== undefined) {
|
|
||||||
const pending = pendingMoveAfterCreateRef.current.get(clientRequestId);
|
|
||||||
if (pending) {
|
|
||||||
pendingMoveAfterCreateRef.current.delete(clientRequestId);
|
|
||||||
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
|
|
||||||
void moveNode({
|
|
||||||
nodeId: realId,
|
|
||||||
positionX: pending.positionX,
|
|
||||||
positionY: pending.positionY,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = resolvedRealIdByClientRequestRef.current.get(clientRequestId);
|
|
||||||
const p = pendingMoveAfterCreateRef.current.get(clientRequestId);
|
|
||||||
if (!r || !p) return;
|
|
||||||
pendingMoveAfterCreateRef.current.delete(clientRequestId);
|
|
||||||
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
|
|
||||||
void moveNode({
|
|
||||||
nodeId: r,
|
|
||||||
positionX: p.positionX,
|
|
||||||
positionY: p.positionY,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[moveNode],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const createNode = useMutation(api.nodes.create).withOptimisticUpdate(
|
const createNode = useMutation(api.nodes.create).withOptimisticUpdate(
|
||||||
@@ -513,9 +494,165 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
|
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
|
||||||
const batchRemoveNodes = useMutation(api.nodes.batchRemove);
|
|
||||||
const createEdge = useMutation(api.edges.create);
|
const batchRemoveNodes = useMutation(api.nodes.batchRemove).withOptimisticUpdate(
|
||||||
const removeEdge = useMutation(api.edges.remove);
|
(localStore, args) => {
|
||||||
|
const nodeList = localStore.getQuery(api.nodes.list, { canvasId });
|
||||||
|
const edgeList = localStore.getQuery(api.edges.list, { canvasId });
|
||||||
|
if (nodeList === undefined || edgeList === undefined) return;
|
||||||
|
|
||||||
|
const removeSet = new Set<string>(args.nodeIds.map((id) => id as string));
|
||||||
|
localStore.setQuery(
|
||||||
|
api.nodes.list,
|
||||||
|
{ canvasId },
|
||||||
|
nodeList.filter((n) => !removeSet.has(n._id)),
|
||||||
|
);
|
||||||
|
localStore.setQuery(
|
||||||
|
api.edges.list,
|
||||||
|
{ canvasId },
|
||||||
|
edgeList.filter(
|
||||||
|
(e) =>
|
||||||
|
!removeSet.has(e.sourceNodeId) && !removeSet.has(e.targetNodeId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
|
||||||
|
(localStore, args) => {
|
||||||
|
const edgeList = localStore.getQuery(api.edges.list, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
});
|
||||||
|
if (edgeList === undefined) return;
|
||||||
|
|
||||||
|
const tempId = `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` as Id<"edges">;
|
||||||
|
const synthetic: Doc<"edges"> = {
|
||||||
|
_id: tempId,
|
||||||
|
_creationTime: Date.now(),
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
sourceNodeId: args.sourceNodeId,
|
||||||
|
targetNodeId: args.targetNodeId,
|
||||||
|
sourceHandle: args.sourceHandle,
|
||||||
|
targetHandle: args.targetHandle,
|
||||||
|
};
|
||||||
|
localStore.setQuery(
|
||||||
|
api.edges.list,
|
||||||
|
{ canvasId: args.canvasId },
|
||||||
|
[...edgeList, synthetic],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeEdge = useMutation(api.edges.remove).withOptimisticUpdate(
|
||||||
|
(localStore, args) => {
|
||||||
|
const edgeList = localStore.getQuery(api.edges.list, { canvasId });
|
||||||
|
if (edgeList === undefined) return;
|
||||||
|
localStore.setQuery(
|
||||||
|
api.edges.list,
|
||||||
|
{ canvasId },
|
||||||
|
edgeList.filter((e) => e._id !== args.edgeId),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitEdgeIntersectionSplit = useCallback(
|
||||||
|
async (
|
||||||
|
middleNodeId: Id<"nodes">,
|
||||||
|
intersectedEdge: RFEdge,
|
||||||
|
handles: NonNullable<(typeof NODE_HANDLE_MAP)[string]>,
|
||||||
|
) => {
|
||||||
|
await Promise.all([
|
||||||
|
createEdge({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: intersectedEdge.source as Id<"nodes">,
|
||||||
|
targetNodeId: middleNodeId,
|
||||||
|
sourceHandle: normalizeHandle(intersectedEdge.sourceHandle),
|
||||||
|
targetHandle: normalizeHandle(handles.target),
|
||||||
|
}),
|
||||||
|
createEdge({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: middleNodeId,
|
||||||
|
targetNodeId: intersectedEdge.target as Id<"nodes">,
|
||||||
|
sourceHandle: normalizeHandle(handles.source),
|
||||||
|
targetHandle: normalizeHandle(intersectedEdge.targetHandle),
|
||||||
|
}),
|
||||||
|
removeEdge({ edgeId: intersectedEdge.id as Id<"edges"> }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[canvasId, createEdge, removeEdge],
|
||||||
|
);
|
||||||
|
|
||||||
|
const flushPendingEdgeSplit = useCallback(
|
||||||
|
(clientRequestId: string, realMiddleNodeId: Id<"nodes">) => {
|
||||||
|
const pending = pendingEdgeSplitByClientRequestRef.current.get(
|
||||||
|
clientRequestId,
|
||||||
|
);
|
||||||
|
if (!pending) return;
|
||||||
|
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
|
||||||
|
void Promise.all([
|
||||||
|
createEdge({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: pending.sourceNodeId,
|
||||||
|
targetNodeId: realMiddleNodeId,
|
||||||
|
sourceHandle: pending.intersectedSourceHandle,
|
||||||
|
targetHandle: pending.middleTargetHandle,
|
||||||
|
}),
|
||||||
|
createEdge({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: realMiddleNodeId,
|
||||||
|
targetNodeId: pending.targetNodeId,
|
||||||
|
sourceHandle: pending.middleSourceHandle,
|
||||||
|
targetHandle: pending.intersectedTargetHandle,
|
||||||
|
}),
|
||||||
|
removeEdge({ edgeId: pending.intersectedEdgeId }),
|
||||||
|
]).catch((error: unknown) => {
|
||||||
|
console.error("[Canvas pending edge split failed]", {
|
||||||
|
clientRequestId,
|
||||||
|
realMiddleNodeId,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[canvasId, createEdge, removeEdge],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Pairing: create kann vor oder nach Drag-Ende fertig sein — was zuerst kommt, speichert; das andere triggert moveNode. Zusätzlich: Kanten-Split erst mit echter Node-ID (nach create). */
|
||||||
|
const syncPendingMoveForClientRequest = useCallback(
|
||||||
|
(clientRequestId: string | undefined, realId?: Id<"nodes">) => {
|
||||||
|
if (!clientRequestId) return;
|
||||||
|
|
||||||
|
if (realId !== undefined) {
|
||||||
|
const pending = pendingMoveAfterCreateRef.current.get(clientRequestId);
|
||||||
|
if (pending) {
|
||||||
|
pendingMoveAfterCreateRef.current.delete(clientRequestId);
|
||||||
|
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
|
||||||
|
void moveNode({
|
||||||
|
nodeId: realId,
|
||||||
|
positionX: pending.positionX,
|
||||||
|
positionY: pending.positionY,
|
||||||
|
});
|
||||||
|
flushPendingEdgeSplit(clientRequestId, realId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId);
|
||||||
|
flushPendingEdgeSplit(clientRequestId, realId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = resolvedRealIdByClientRequestRef.current.get(clientRequestId);
|
||||||
|
const p = pendingMoveAfterCreateRef.current.get(clientRequestId);
|
||||||
|
if (!r || !p) return;
|
||||||
|
pendingMoveAfterCreateRef.current.delete(clientRequestId);
|
||||||
|
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
|
||||||
|
void moveNode({
|
||||||
|
nodeId: r,
|
||||||
|
positionX: p.positionX,
|
||||||
|
positionY: p.positionY,
|
||||||
|
});
|
||||||
|
flushPendingEdgeSplit(clientRequestId, r);
|
||||||
|
},
|
||||||
|
[moveNode, flushPendingEdgeSplit],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
||||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||||
@@ -1075,23 +1212,36 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await createEdge({
|
const optimisticCid = clientRequestIdFromOptimisticNodeId(node.id);
|
||||||
canvasId,
|
let middleNodeId = node.id as Id<"nodes">;
|
||||||
sourceNodeId: intersectedEdge.source as Id<"nodes">,
|
if (optimisticCid) {
|
||||||
targetNodeId: node.id as Id<"nodes">,
|
const resolvedMiddle =
|
||||||
sourceHandle: normalizeHandle(intersectedEdge.sourceHandle),
|
resolvedRealIdByClientRequestRef.current.get(optimisticCid);
|
||||||
targetHandle: normalizeHandle(handles.target),
|
if (resolvedMiddle) {
|
||||||
});
|
middleNodeId = resolvedMiddle;
|
||||||
|
} else {
|
||||||
|
pendingEdgeSplitByClientRequestRef.current.set(optimisticCid, {
|
||||||
|
intersectedEdgeId: intersectedEdge.id as Id<"edges">,
|
||||||
|
sourceNodeId: intersectedEdge.source as Id<"nodes">,
|
||||||
|
targetNodeId: intersectedEdge.target as Id<"nodes">,
|
||||||
|
intersectedSourceHandle: normalizeHandle(
|
||||||
|
intersectedEdge.sourceHandle,
|
||||||
|
),
|
||||||
|
intersectedTargetHandle: normalizeHandle(
|
||||||
|
intersectedEdge.targetHandle,
|
||||||
|
),
|
||||||
|
middleSourceHandle: normalizeHandle(handles.source),
|
||||||
|
middleTargetHandle: normalizeHandle(handles.target),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await createEdge({
|
await commitEdgeIntersectionSplit(
|
||||||
canvasId,
|
middleNodeId,
|
||||||
sourceNodeId: node.id as Id<"nodes">,
|
intersectedEdge,
|
||||||
targetNodeId: intersectedEdge.target as Id<"nodes">,
|
handles,
|
||||||
sourceHandle: normalizeHandle(handles.source),
|
);
|
||||||
targetHandle: normalizeHandle(intersectedEdge.targetHandle),
|
|
||||||
});
|
|
||||||
|
|
||||||
await removeEdge({ edgeId: intersectedEdge.id as Id<"edges"> });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Canvas edge intersection split failed]", {
|
console.error("[Canvas edge intersection split failed]", {
|
||||||
canvasId,
|
canvasId,
|
||||||
@@ -1110,10 +1260,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
[
|
[
|
||||||
batchMoveNodes,
|
batchMoveNodes,
|
||||||
canvasId,
|
canvasId,
|
||||||
createEdge,
|
commitEdgeIntersectionSplit,
|
||||||
edges,
|
edges,
|
||||||
moveNode,
|
moveNode,
|
||||||
removeEdge,
|
|
||||||
setHighlightedIntersectionEdge,
|
setHighlightedIntersectionEdge,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
],
|
],
|
||||||
@@ -1147,28 +1296,20 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
deletingNodeIds.current.add(id);
|
deletingNodeIds.current.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-Reconnect: Für jeden gelöschten Node eingehende und ausgehende Edges verbinden
|
const bridgeCreates = computeBridgeCreatesForDeletedNodes(
|
||||||
const edgePromises: Promise<unknown>[] = [];
|
deletedNodes,
|
||||||
for (const node of deletedNodes) {
|
nodes,
|
||||||
const incomingEdges = edges.filter((e) => e.target === node.id);
|
edges,
|
||||||
const outgoingEdges = edges.filter((e) => e.source === node.id);
|
);
|
||||||
|
const edgePromises = bridgeCreates.map((b) =>
|
||||||
if (incomingEdges.length > 0 && outgoingEdges.length > 0) {
|
createEdge({
|
||||||
for (const incoming of incomingEdges) {
|
canvasId,
|
||||||
for (const outgoing of outgoingEdges) {
|
sourceNodeId: b.sourceNodeId,
|
||||||
edgePromises.push(
|
targetNodeId: b.targetNodeId,
|
||||||
createEdge({
|
sourceHandle: b.sourceHandle,
|
||||||
canvasId,
|
targetHandle: b.targetHandle,
|
||||||
sourceNodeId: incoming.source as Id<"nodes">,
|
}),
|
||||||
targetNodeId: outgoing.target as Id<"nodes">,
|
);
|
||||||
sourceHandle: incoming.sourceHandle ?? undefined,
|
|
||||||
targetHandle: outgoing.targetHandle ?? undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch-Delete + Auto-Reconnect parallel, dann deletingNodeIds aufräumen
|
// Batch-Delete + Auto-Reconnect parallel, dann deletingNodeIds aufräumen
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
@@ -1195,7 +1336,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
toast.info(title);
|
toast.info(title);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[edges, batchRemoveNodes, createEdge, canvasId],
|
[nodes, edges, batchRemoveNodes, createEdge, canvasId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Edge löschen → Convex ────────────────────────────────────
|
// ─── Edge löschen → Convex ────────────────────────────────────
|
||||||
@@ -1294,6 +1435,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
edges={edges}
|
edges={edges}
|
||||||
onlyRenderVisibleElements
|
onlyRenderVisibleElements
|
||||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||||
|
connectionLineComponent={CustomConnectionLine}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
|
|||||||
72
components/canvas/custom-connection-line.tsx
Normal file
72
components/canvas/custom-connection-line.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConnectionLineType,
|
||||||
|
getBezierPath,
|
||||||
|
getSimpleBezierPath,
|
||||||
|
getSmoothStepPath,
|
||||||
|
getStraightPath,
|
||||||
|
type ConnectionLineComponentProps,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import { connectionLineAccentRgb } from "@/lib/canvas-utils";
|
||||||
|
|
||||||
|
export default function CustomConnectionLine({
|
||||||
|
connectionLineType,
|
||||||
|
fromNode,
|
||||||
|
fromHandle,
|
||||||
|
fromX,
|
||||||
|
fromY,
|
||||||
|
toX,
|
||||||
|
toY,
|
||||||
|
fromPosition,
|
||||||
|
toPosition,
|
||||||
|
connectionStatus,
|
||||||
|
}: ConnectionLineComponentProps) {
|
||||||
|
const pathParams = {
|
||||||
|
sourceX: fromX,
|
||||||
|
sourceY: fromY,
|
||||||
|
sourcePosition: fromPosition,
|
||||||
|
targetX: toX,
|
||||||
|
targetY: toY,
|
||||||
|
targetPosition: toPosition,
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = "";
|
||||||
|
switch (connectionLineType) {
|
||||||
|
case ConnectionLineType.Bezier:
|
||||||
|
[path] = getBezierPath(pathParams);
|
||||||
|
break;
|
||||||
|
case ConnectionLineType.SimpleBezier:
|
||||||
|
[path] = getSimpleBezierPath(pathParams);
|
||||||
|
break;
|
||||||
|
case ConnectionLineType.Step:
|
||||||
|
[path] = getSmoothStepPath({
|
||||||
|
...pathParams,
|
||||||
|
borderRadius: 0,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ConnectionLineType.SmoothStep:
|
||||||
|
[path] = getSmoothStepPath(pathParams);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
[path] = getStraightPath(pathParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandle.id);
|
||||||
|
const opacity = connectionStatus === "invalid" ? 0.45 : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
className="ls-connection-line-marching"
|
||||||
|
style={{
|
||||||
|
stroke: `rgb(${r}, ${g}, ${b})`,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
strokeLinecap: "round",
|
||||||
|
strokeDasharray: "10 8",
|
||||||
|
opacity,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { Node as RFNode, Edge as RFEdge } from "@xyflow/react";
|
import {
|
||||||
import type { Doc } from "@/convex/_generated/dataModel";
|
getConnectedEdges,
|
||||||
|
getIncomers,
|
||||||
|
getOutgoers,
|
||||||
|
type Node as RFNode,
|
||||||
|
type Edge as RFEdge,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convex Node → React Flow Node
|
* Convex Node → React Flow Node
|
||||||
@@ -100,6 +106,35 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
|
|||||||
compare: [100, 116, 139],
|
compare: [100, 116, 139],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
|
||||||
|
const COMPARE_HANDLE_CONNECTION_RGB: Record<
|
||||||
|
string,
|
||||||
|
readonly [number, number, number]
|
||||||
|
> = {
|
||||||
|
left: [59, 130, 246],
|
||||||
|
right: [16, 185, 129],
|
||||||
|
"compare-out": [100, 116, 139],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
|
||||||
|
13, 148, 136,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
|
||||||
|
*/
|
||||||
|
export function connectionLineAccentRgb(
|
||||||
|
nodeType: string | undefined,
|
||||||
|
handleId: string | null | undefined,
|
||||||
|
): readonly [number, number, number] {
|
||||||
|
if (nodeType === "compare" && handleId) {
|
||||||
|
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId];
|
||||||
|
if (byHandle) return byHandle;
|
||||||
|
}
|
||||||
|
if (!nodeType) return CONNECTION_LINE_FALLBACK_RGB;
|
||||||
|
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
|
||||||
|
}
|
||||||
|
|
||||||
export type EdgeGlowColorMode = "light" | "dark";
|
export type EdgeGlowColorMode = "light" | "dark";
|
||||||
|
|
||||||
function sourceGlowFilterForNodeType(
|
function sourceGlowFilterForNodeType(
|
||||||
@@ -253,3 +288,76 @@ export function computeMediaNodeSize(
|
|||||||
aspectRatio,
|
aspectRatio,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reconnectEdgeKey(edge: RFEdge): string {
|
||||||
|
return `${edge.source}\0${edge.target}\0${edge.sourceHandle ?? ""}\0${edge.targetHandle ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BridgeCreatePayload = {
|
||||||
|
sourceNodeId: Id<"nodes">;
|
||||||
|
targetNodeId: Id<"nodes">;
|
||||||
|
sourceHandle?: string;
|
||||||
|
targetHandle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach Löschen mittlerer Knoten: Kanten wie im React-Flow-Beispiel
|
||||||
|
* „Delete Middle Node“ fortschreiben; nur Kanten zurückgeben, die neu
|
||||||
|
* angelegt werden müssen (nicht bereits vor dem Löschen vorhanden).
|
||||||
|
*/
|
||||||
|
export function computeBridgeCreatesForDeletedNodes(
|
||||||
|
deletedNodes: RFNode[],
|
||||||
|
allNodes: RFNode[],
|
||||||
|
allEdges: RFEdge[],
|
||||||
|
): BridgeCreatePayload[] {
|
||||||
|
if (deletedNodes.length === 0) return [];
|
||||||
|
|
||||||
|
const initialPersisted = allEdges.filter((e) => e.className !== "temp");
|
||||||
|
const initialKeys = new Set(initialPersisted.map(reconnectEdgeKey));
|
||||||
|
|
||||||
|
let remainingNodes = [...allNodes];
|
||||||
|
let acc = [...initialPersisted];
|
||||||
|
|
||||||
|
for (const node of deletedNodes) {
|
||||||
|
const incomers = getIncomers(node, remainingNodes, acc);
|
||||||
|
const outgoers = getOutgoers(node, remainingNodes, acc);
|
||||||
|
const connectedEdges = getConnectedEdges([node], acc);
|
||||||
|
const remainingEdges = acc.filter((e) => !connectedEdges.includes(e));
|
||||||
|
|
||||||
|
const createdEdges: RFEdge[] = [];
|
||||||
|
for (const inc of incomers) {
|
||||||
|
for (const out of outgoers) {
|
||||||
|
const inEdge = connectedEdges.find(
|
||||||
|
(e) => e.source === inc.id && e.target === node.id,
|
||||||
|
);
|
||||||
|
const outEdge = connectedEdges.find(
|
||||||
|
(e) => e.source === node.id && e.target === out.id,
|
||||||
|
);
|
||||||
|
if (!inEdge || !outEdge || inc.id === out.id) continue;
|
||||||
|
createdEdges.push({
|
||||||
|
id: `reconnect-${inc.id}-${out.id}-${node.id}-${createdEdges.length}`,
|
||||||
|
source: inc.id,
|
||||||
|
target: out.id,
|
||||||
|
sourceHandle: inEdge.sourceHandle,
|
||||||
|
targetHandle: outEdge.targetHandle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acc = [...remainingEdges, ...createdEdges];
|
||||||
|
remainingNodes = remainingNodes.filter((rn) => rn.id !== node.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: BridgeCreatePayload[] = [];
|
||||||
|
for (const e of acc) {
|
||||||
|
if (!initialKeys.has(reconnectEdgeKey(e))) {
|
||||||
|
result.push({
|
||||||
|
sourceNodeId: e.source as Id<"nodes">,
|
||||||
|
targetNodeId: e.target as Id<"nodes">,
|
||||||
|
sourceHandle: e.sourceHandle ?? undefined,
|
||||||
|
targetHandle: e.targetHandle ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user