405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
import { useCallback } from "react";
|
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
import {
|
|
getConnectedEdges,
|
|
type Edge as RFEdge,
|
|
type Node as RFNode,
|
|
type OnBeforeDelete,
|
|
} from "@xyflow/react";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
import type { Id } from "@/convex/_generated/dataModel";
|
|
import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils";
|
|
import { toast } from "@/lib/toast";
|
|
import { type CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
|
|
|
import { getNodeDeleteBlockReason } from "./canvas-helpers";
|
|
import { validateCanvasConnection } from "./canvas-connection-validation";
|
|
|
|
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
|
|
|
|
const BRIDGE_CREATE_MAX_ATTEMPTS = 4;
|
|
const BRIDGE_CREATE_INITIAL_BACKOFF_MS = 40;
|
|
|
|
function waitFor(ms: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
function isRetryableBridgeCreateError(error: unknown): boolean {
|
|
const message = getErrorMessage(error).toLowerCase();
|
|
if (
|
|
message.includes("unauthorized") ||
|
|
message.includes("forbidden") ||
|
|
message.includes("not authenticated")
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
message.includes("limit") ||
|
|
message.includes("duplicate") ||
|
|
message.includes("already exists") ||
|
|
message.includes("conflict") ||
|
|
message.includes("concurrent") ||
|
|
message.includes("tempor") ||
|
|
message.includes("timeout") ||
|
|
message.includes("try again") ||
|
|
message.includes("retry") ||
|
|
message.includes("stale")
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
type UseCanvasDeleteHandlersParams = {
|
|
t: ToastTranslations;
|
|
canvasId: Id<"canvases">;
|
|
nodes: RFNode[];
|
|
edges: RFEdge[];
|
|
nodesRef: MutableRefObject<RFNode[]>;
|
|
edgesRef: MutableRefObject<RFEdge[]>;
|
|
deletingNodeIds: MutableRefObject<Set<string>>;
|
|
setAssetBrowserTargetNodeId: Dispatch<SetStateAction<string | null>>;
|
|
runBatchRemoveNodesMutation: (args: { nodeIds: Id<"nodes">[] }) => Promise<unknown>;
|
|
runCreateEdgeMutation: (args: {
|
|
canvasId: Id<"canvases">;
|
|
sourceNodeId: Id<"nodes">;
|
|
targetNodeId: Id<"nodes">;
|
|
sourceHandle?: string;
|
|
targetHandle?: string;
|
|
}) => Promise<unknown>;
|
|
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
|
};
|
|
|
|
export function useCanvasDeleteHandlers({
|
|
t,
|
|
canvasId,
|
|
nodes,
|
|
edges,
|
|
nodesRef,
|
|
edgesRef,
|
|
deletingNodeIds,
|
|
setAssetBrowserTargetNodeId,
|
|
runBatchRemoveNodesMutation,
|
|
runCreateEdgeMutation,
|
|
runRemoveEdgeMutation,
|
|
}: UseCanvasDeleteHandlersParams): {
|
|
onBeforeDelete: OnBeforeDelete<RFNode, RFEdge>;
|
|
onNodesDelete: (deletedNodes: RFNode[]) => void;
|
|
onEdgesDelete: (deletedEdges: RFEdge[]) => void;
|
|
} {
|
|
const edgeKey = useCallback(
|
|
(edge: Pick<RFEdge, "source" | "target" | "sourceHandle" | "targetHandle">) =>
|
|
`${edge.source}\0${edge.target}\0${edge.sourceHandle ?? ""}\0${edge.targetHandle ?? ""}`,
|
|
[],
|
|
);
|
|
|
|
const onBeforeDelete = useCallback(
|
|
async ({
|
|
nodes: matchingNodes,
|
|
edges: matchingEdges,
|
|
}: {
|
|
nodes: RFNode[];
|
|
edges: RFEdge[];
|
|
}) => {
|
|
if (matchingNodes.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const allowed: RFNode[] = [];
|
|
const blocked: RFNode[] = [];
|
|
const blockedReasons = new Set<CanvasNodeDeleteBlockReason>();
|
|
for (const node of matchingNodes) {
|
|
const reason = getNodeDeleteBlockReason(node);
|
|
if (reason !== null) {
|
|
blocked.push(node);
|
|
blockedReasons.add(reason);
|
|
} else {
|
|
allowed.push(node);
|
|
}
|
|
}
|
|
|
|
if (allowed.length === 0) {
|
|
const title = t('canvas.nodeDeleteBlockedTitle');
|
|
const desc = t('canvas.nodeDeleteBlockedDesc');
|
|
toast.warning(title, desc);
|
|
return false;
|
|
}
|
|
|
|
if (blocked.length > 0) {
|
|
const title = t('canvas.nodeDeleteBlockedPartialTitle');
|
|
const whyDesc = t('canvas.nodeDeleteBlockedDesc');
|
|
const suffix =
|
|
blocked.length === 1
|
|
? t('canvas.nodeDeleteBlockedPartialSuffixOne')
|
|
: t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blocked.length });
|
|
const desc = `${whyDesc} ${suffix}`;
|
|
toast.warning(title, desc);
|
|
return {
|
|
nodes: allowed,
|
|
edges: getConnectedEdges(allowed, matchingEdges),
|
|
};
|
|
}
|
|
|
|
return true;
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const onNodesDelete = useCallback(
|
|
(deletedNodes: RFNode[]) => {
|
|
const count = deletedNodes.length;
|
|
if (count === 0) return;
|
|
|
|
const idsToDelete = deletedNodes.map((node) => node.id);
|
|
for (const id of idsToDelete) {
|
|
deletingNodeIds.current.add(id);
|
|
}
|
|
|
|
const removedTargetSet = new Set(idsToDelete);
|
|
setAssetBrowserTargetNodeId((current) =>
|
|
current !== null && removedTargetSet.has(current) ? null : current,
|
|
);
|
|
|
|
const liveNodes = nodesRef.current;
|
|
const liveEdges = edgesRef.current;
|
|
|
|
const bridgeCreates = computeBridgeCreatesForDeletedNodes(
|
|
deletedNodes,
|
|
liveNodes,
|
|
liveEdges,
|
|
);
|
|
const bridgeEdgesCreatedInThisRun: RFEdge[] = [];
|
|
|
|
const getRemainingNodes = () =>
|
|
nodesRef.current.filter((node) => !removedTargetSet.has(node.id));
|
|
|
|
const getRemainingEdges = () => {
|
|
const fromRefs = edgesRef.current.filter((edge) => {
|
|
if (edge.className === "temp") {
|
|
return false;
|
|
}
|
|
|
|
if (removedTargetSet.has(edge.source) || removedTargetSet.has(edge.target)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
const deduped = [...fromRefs];
|
|
const dedupedKeys = new Set(fromRefs.map((edge) => edgeKey(edge)));
|
|
for (const createdEdge of bridgeEdgesCreatedInThisRun) {
|
|
const key = edgeKey(createdEdge);
|
|
if (dedupedKeys.has(key)) {
|
|
continue;
|
|
}
|
|
deduped.push(createdEdge);
|
|
dedupedKeys.add(key);
|
|
}
|
|
|
|
return deduped;
|
|
};
|
|
|
|
if (bridgeCreates.length > 0) {
|
|
console.info("[Canvas] computed bridge edges for delete", {
|
|
canvasId,
|
|
deletedNodeIds: idsToDelete,
|
|
deletedNodes: deletedNodes.map((node) => ({
|
|
id: node.id,
|
|
type: node.type ?? null,
|
|
})),
|
|
bridgeCreates,
|
|
});
|
|
}
|
|
|
|
void (async () => {
|
|
await runBatchRemoveNodesMutation({
|
|
nodeIds: idsToDelete as Id<"nodes">[],
|
|
});
|
|
|
|
for (const bridgeCreate of bridgeCreates) {
|
|
const bridgeKey = edgeKey({
|
|
source: bridgeCreate.sourceNodeId,
|
|
target: bridgeCreate.targetNodeId,
|
|
sourceHandle: bridgeCreate.sourceHandle,
|
|
targetHandle: bridgeCreate.targetHandle,
|
|
});
|
|
|
|
let created = false;
|
|
|
|
for (
|
|
let attempt = 1;
|
|
attempt <= BRIDGE_CREATE_MAX_ATTEMPTS;
|
|
attempt += 1
|
|
) {
|
|
const remainingNodes = getRemainingNodes();
|
|
const remainingEdges = getRemainingEdges();
|
|
|
|
if (remainingEdges.some((edge) => edgeKey(edge) === bridgeKey)) {
|
|
console.info("[Canvas] skipped duplicate bridge edge after delete", {
|
|
canvasId,
|
|
deletedNodeIds: idsToDelete,
|
|
bridgeCreate,
|
|
});
|
|
break;
|
|
}
|
|
|
|
const validationError = validateCanvasConnection(
|
|
{
|
|
source: bridgeCreate.sourceNodeId,
|
|
target: bridgeCreate.targetNodeId,
|
|
sourceHandle: bridgeCreate.sourceHandle ?? null,
|
|
targetHandle: bridgeCreate.targetHandle ?? null,
|
|
},
|
|
remainingNodes,
|
|
remainingEdges,
|
|
undefined,
|
|
{ includeOptimisticEdges: true },
|
|
);
|
|
|
|
if (validationError) {
|
|
console.info("[Canvas] skipped invalid bridge edge after delete", {
|
|
canvasId,
|
|
deletedNodeIds: idsToDelete,
|
|
bridgeCreate,
|
|
validationError,
|
|
});
|
|
break;
|
|
}
|
|
|
|
try {
|
|
console.info("[Canvas] creating bridge edge after delete", {
|
|
canvasId,
|
|
deletedNodeIds: idsToDelete,
|
|
bridgeCreate,
|
|
attempt,
|
|
});
|
|
|
|
await runCreateEdgeMutation({
|
|
canvasId,
|
|
sourceNodeId: bridgeCreate.sourceNodeId,
|
|
targetNodeId: bridgeCreate.targetNodeId,
|
|
sourceHandle: bridgeCreate.sourceHandle,
|
|
targetHandle: bridgeCreate.targetHandle,
|
|
});
|
|
|
|
bridgeEdgesCreatedInThisRun.push({
|
|
id: `bridge-${bridgeCreate.sourceNodeId}-${bridgeCreate.targetNodeId}-${bridgeEdgesCreatedInThisRun.length}`,
|
|
source: bridgeCreate.sourceNodeId,
|
|
target: bridgeCreate.targetNodeId,
|
|
sourceHandle: bridgeCreate.sourceHandle,
|
|
targetHandle: bridgeCreate.targetHandle,
|
|
});
|
|
created = true;
|
|
break;
|
|
} catch (error: unknown) {
|
|
const errorMessage = getErrorMessage(error);
|
|
const retryable = isRetryableBridgeCreateError(error);
|
|
const isLastAttempt = attempt >= BRIDGE_CREATE_MAX_ATTEMPTS;
|
|
|
|
if (!retryable || isLastAttempt) {
|
|
console.error("[Canvas] bridge edge create failed", {
|
|
canvasId,
|
|
deletedNodeIds: idsToDelete,
|
|
bridgeCreate,
|
|
attempt,
|
|
maxAttempts: BRIDGE_CREATE_MAX_ATTEMPTS,
|
|
retryable,
|
|
error: errorMessage,
|
|
});
|
|
break;
|
|
}
|
|
|
|
const backoffMs =
|
|
BRIDGE_CREATE_INITIAL_BACKOFF_MS * 2 ** (attempt - 1);
|
|
|
|
console.warn("[Canvas] bridge edge create retry scheduled", {
|
|
canvasId,
|
|
deletedNodeIds: idsToDelete,
|
|
bridgeCreate,
|
|
attempt,
|
|
nextAttempt: attempt + 1,
|
|
backoffMs,
|
|
error: errorMessage,
|
|
});
|
|
|
|
await waitFor(backoffMs);
|
|
}
|
|
}
|
|
|
|
if (!created) {
|
|
continue;
|
|
}
|
|
}
|
|
})()
|
|
.then(() => {
|
|
// Erfolg bedeutet hier nur: Mutation/Queue wurde angenommen.
|
|
// Den Delete-Lock erst lösen, wenn Convex-Snapshot die Node wirklich nicht mehr enthält.
|
|
})
|
|
.catch((error: unknown) => {
|
|
console.error("[Canvas] batch remove failed", {
|
|
canvasId,
|
|
deletedNodeIds: idsToDelete,
|
|
error: getErrorMessage(error),
|
|
});
|
|
for (const id of idsToDelete) {
|
|
deletingNodeIds.current.delete(id);
|
|
}
|
|
});
|
|
|
|
const title = t('canvas.nodesRemoved', { count });
|
|
toast.info(title);
|
|
},
|
|
[
|
|
t,
|
|
canvasId,
|
|
deletingNodeIds,
|
|
edgeKey,
|
|
edges,
|
|
edgesRef,
|
|
nodes,
|
|
nodesRef,
|
|
runBatchRemoveNodesMutation,
|
|
runCreateEdgeMutation,
|
|
setAssetBrowserTargetNodeId,
|
|
],
|
|
);
|
|
|
|
const onEdgesDelete = useCallback(
|
|
(deletedEdges: RFEdge[]) => {
|
|
for (const edge of deletedEdges) {
|
|
if (edge.className === "temp") {
|
|
continue;
|
|
}
|
|
|
|
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
|
|
(error) => {
|
|
console.error("[Canvas edge remove failed] edge delete", {
|
|
edgeId: edge.id,
|
|
edgeClassName: edge.className ?? null,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
error: String(error),
|
|
});
|
|
},
|
|
);
|
|
}
|
|
},
|
|
[runRemoveEdgeMutation],
|
|
);
|
|
|
|
return { onBeforeDelete, onNodesDelete, onEdgesDelete };
|
|
}
|