refactor(canvas): extract flow reconciliation helpers
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import {
|
||||||
|
reconcileCanvasFlowEdges,
|
||||||
|
reconcileCanvasFlowNodes,
|
||||||
|
} from "@/components/canvas/canvas-flow-reconciliation-helpers";
|
||||||
|
|
||||||
|
const asNodeId = (id: string): Id<"nodes"> => id as Id<"nodes">;
|
||||||
|
|
||||||
|
describe("canvas flow reconciliation helpers", () => {
|
||||||
|
it("carries an optimistic edge while a pending connection create awaits convex edge sync", () => {
|
||||||
|
const previousEdges: RFEdge[] = [
|
||||||
|
{
|
||||||
|
id: "optimistic_edge_req-1",
|
||||||
|
source: "node-source",
|
||||||
|
target: "optimistic_req-1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = reconcileCanvasFlowEdges({
|
||||||
|
previousEdges,
|
||||||
|
convexEdges: [],
|
||||||
|
convexNodes: [
|
||||||
|
{ _id: asNodeId("node-source"), type: "image" },
|
||||||
|
{ _id: asNodeId("node-real"), type: "prompt" },
|
||||||
|
],
|
||||||
|
previousConvexNodeIdsSnapshot: new Set(["node-source"]),
|
||||||
|
pendingRemovedEdgeIds: new Set(),
|
||||||
|
pendingConnectionCreateIds: new Set(["req-1"]),
|
||||||
|
resolvedRealIdByClientRequest: new Map(),
|
||||||
|
localNodeIds: new Set(["node-source"]),
|
||||||
|
isAnyNodeDragging: false,
|
||||||
|
colorMode: "light",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.edges).toEqual([
|
||||||
|
{
|
||||||
|
id: "optimistic_edge_req-1",
|
||||||
|
source: "node-source",
|
||||||
|
target: "node-real",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.inferredRealIdByClientRequest).toEqual(
|
||||||
|
new Map([["req-1", "node-real"]]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remaps optimistic endpoints to resolved real node ids", () => {
|
||||||
|
const previousEdges: RFEdge[] = [
|
||||||
|
{
|
||||||
|
id: "optimistic_edge_req-2",
|
||||||
|
source: "optimistic_req-2",
|
||||||
|
target: "node-target",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = reconcileCanvasFlowEdges({
|
||||||
|
previousEdges,
|
||||||
|
convexEdges: [],
|
||||||
|
convexNodes: [
|
||||||
|
{ _id: asNodeId("node-real"), type: "image" },
|
||||||
|
{ _id: asNodeId("node-target"), type: "prompt" },
|
||||||
|
],
|
||||||
|
previousConvexNodeIdsSnapshot: new Set(["node-real", "node-target"]),
|
||||||
|
pendingRemovedEdgeIds: new Set(),
|
||||||
|
pendingConnectionCreateIds: new Set(),
|
||||||
|
resolvedRealIdByClientRequest: new Map([["req-2", asNodeId("node-real")]]),
|
||||||
|
localNodeIds: new Set(["node-target"]),
|
||||||
|
isAnyNodeDragging: false,
|
||||||
|
colorMode: "light",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.edges).toEqual([
|
||||||
|
{
|
||||||
|
id: "optimistic_edge_req-2",
|
||||||
|
source: "node-real",
|
||||||
|
target: "node-target",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up matched local position pins once convex catches up", () => {
|
||||||
|
const previousNodes: RFNode[] = [
|
||||||
|
{
|
||||||
|
id: "node-1",
|
||||||
|
type: "image",
|
||||||
|
position: { x: 120, y: 80 },
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const incomingNodes: RFNode[] = [
|
||||||
|
{
|
||||||
|
id: "node-1",
|
||||||
|
type: "image",
|
||||||
|
position: { x: 120, y: 80 },
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = reconcileCanvasFlowNodes({
|
||||||
|
previousNodes,
|
||||||
|
incomingNodes,
|
||||||
|
convexNodes: [{ _id: asNodeId("node-1"), type: "image" }],
|
||||||
|
deletingNodeIds: new Set(),
|
||||||
|
resolvedRealIdByClientRequest: new Map(),
|
||||||
|
pendingConnectionCreateIds: new Set(),
|
||||||
|
preferLocalPositionNodeIds: new Set(),
|
||||||
|
pendingLocalPositionPins: new Map([["node-1", { x: 120, y: 80 }]]),
|
||||||
|
pendingMovePins: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.nodes).toEqual(incomingNodes);
|
||||||
|
expect(result.nextPendingLocalPositionPins.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
318
components/canvas/canvas-flow-reconciliation-helpers.ts
Normal file
318
components/canvas/canvas-flow-reconciliation-helpers.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||||
|
|
||||||
|
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
||||||
|
import { convexEdgeToRF, convexEdgeToRFWithSourceGlow } from "@/lib/canvas-utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyPinnedNodePositionsReadOnly,
|
||||||
|
clientRequestIdFromOptimisticEdgeId,
|
||||||
|
clientRequestIdFromOptimisticNodeId,
|
||||||
|
isOptimisticEdgeId,
|
||||||
|
isOptimisticNodeId,
|
||||||
|
mergeNodesPreservingLocalState,
|
||||||
|
OPTIMISTIC_NODE_PREFIX,
|
||||||
|
positionsMatchPin,
|
||||||
|
rfEdgeConnectionSignature,
|
||||||
|
} from "./canvas-helpers";
|
||||||
|
|
||||||
|
type FlowConvexNodeRecord = Pick<Doc<"nodes">, "_id" | "type">;
|
||||||
|
type FlowConvexEdgeRecord = Pick<
|
||||||
|
Doc<"edges">,
|
||||||
|
"_id" | "sourceNodeId" | "targetNodeId" | "sourceHandle" | "targetHandle"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function inferPendingConnectionNodeHandoff(args: {
|
||||||
|
previousNodes: RFNode[];
|
||||||
|
incomingConvexNodes: FlowConvexNodeRecord[];
|
||||||
|
pendingConnectionCreateIds: ReadonlySet<string>;
|
||||||
|
resolvedRealIdByClientRequest: ReadonlyMap<string, Id<"nodes">>;
|
||||||
|
}): Map<string, Id<"nodes">> {
|
||||||
|
const nextResolvedRealIdByClientRequest = new Map(args.resolvedRealIdByClientRequest);
|
||||||
|
const unresolvedClientRequestIds: string[] = [];
|
||||||
|
|
||||||
|
for (const clientRequestId of args.pendingConnectionCreateIds) {
|
||||||
|
if (nextResolvedRealIdByClientRequest.has(clientRequestId)) continue;
|
||||||
|
|
||||||
|
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
|
||||||
|
const optimisticNodePresent = args.previousNodes.some(
|
||||||
|
(node) => node.id === optimisticNodeId,
|
||||||
|
);
|
||||||
|
if (optimisticNodePresent) {
|
||||||
|
unresolvedClientRequestIds.push(clientRequestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolvedClientRequestIds.length !== 1) {
|
||||||
|
return nextResolvedRealIdByClientRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousIds = new Set(args.previousNodes.map((node) => node.id));
|
||||||
|
const newlyAppearedIncomingRealNodeIds = args.incomingConvexNodes
|
||||||
|
.map((node) => node._id as string)
|
||||||
|
.filter((id) => !isOptimisticNodeId(id))
|
||||||
|
.filter((id) => !previousIds.has(id));
|
||||||
|
|
||||||
|
if (newlyAppearedIncomingRealNodeIds.length !== 1) {
|
||||||
|
return nextResolvedRealIdByClientRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextResolvedRealIdByClientRequest.set(
|
||||||
|
unresolvedClientRequestIds[0]!,
|
||||||
|
newlyAppearedIncomingRealNodeIds[0] as Id<"nodes">,
|
||||||
|
);
|
||||||
|
return nextResolvedRealIdByClientRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileCanvasFlowEdges(args: {
|
||||||
|
previousEdges: RFEdge[];
|
||||||
|
convexEdges: FlowConvexEdgeRecord[];
|
||||||
|
convexNodes?: FlowConvexNodeRecord[];
|
||||||
|
previousConvexNodeIdsSnapshot: ReadonlySet<string>;
|
||||||
|
pendingRemovedEdgeIds: ReadonlySet<string>;
|
||||||
|
pendingConnectionCreateIds: ReadonlySet<string>;
|
||||||
|
resolvedRealIdByClientRequest: ReadonlyMap<string, Id<"nodes">>;
|
||||||
|
localNodeIds: ReadonlySet<string>;
|
||||||
|
isAnyNodeDragging: boolean;
|
||||||
|
colorMode: "light" | "dark";
|
||||||
|
}): {
|
||||||
|
edges: RFEdge[];
|
||||||
|
nextConvexNodeIdsSnapshot: Set<string>;
|
||||||
|
inferredRealIdByClientRequest: Map<string, Id<"nodes">>;
|
||||||
|
settledPendingConnectionCreateIds: string[];
|
||||||
|
} {
|
||||||
|
const currentConvexIdList = args.convexNodes?.map((node) => node._id as string) ?? [];
|
||||||
|
const currentConvexIdSet = new Set(currentConvexIdList);
|
||||||
|
const newlyAppearedIds = currentConvexIdList.filter(
|
||||||
|
(id) => !args.previousConvexNodeIdsSnapshot.has(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tempEdges = args.previousEdges.filter((edge) => edge.className === "temp");
|
||||||
|
const sourceTypeByNodeId = args.convexNodes
|
||||||
|
? new Map(args.convexNodes.map((node) => [node._id as string, node.type as string]))
|
||||||
|
: undefined;
|
||||||
|
const mapped = args.convexEdges
|
||||||
|
.filter((edge) => !args.pendingRemovedEdgeIds.has(edge._id as string))
|
||||||
|
.map((edge) =>
|
||||||
|
sourceTypeByNodeId
|
||||||
|
? convexEdgeToRFWithSourceGlow(
|
||||||
|
edge as Doc<"edges">,
|
||||||
|
sourceTypeByNodeId.get(edge.sourceNodeId),
|
||||||
|
args.colorMode,
|
||||||
|
)
|
||||||
|
: convexEdgeToRF(edge as Doc<"edges">),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature));
|
||||||
|
const convexNodeIds = args.convexNodes
|
||||||
|
? new Set(args.convexNodes.map((node) => node._id as string))
|
||||||
|
: null;
|
||||||
|
const inferredRealIdByClientRequest = new Map(args.resolvedRealIdByClientRequest);
|
||||||
|
|
||||||
|
const resolveEndpoint = (nodeId: string): string => {
|
||||||
|
if (!isOptimisticNodeId(nodeId)) return nodeId;
|
||||||
|
|
||||||
|
const clientRequestId = clientRequestIdFromOptimisticNodeId(nodeId);
|
||||||
|
if (!clientRequestId) return nodeId;
|
||||||
|
|
||||||
|
if (args.isAnyNodeDragging && args.localNodeIds.has(nodeId)) {
|
||||||
|
return nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realId = inferredRealIdByClientRequest.get(clientRequestId);
|
||||||
|
return realId !== undefined ? (realId as string) : nodeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveEndpointWithInference = (nodeId: string, edge: RFEdge): string => {
|
||||||
|
const baseNodeId = resolveEndpoint(nodeId);
|
||||||
|
if (!isOptimisticNodeId(baseNodeId)) return baseNodeId;
|
||||||
|
if (args.isAnyNodeDragging) return baseNodeId;
|
||||||
|
|
||||||
|
const nodeClientRequestId = clientRequestIdFromOptimisticNodeId(baseNodeId);
|
||||||
|
if (nodeClientRequestId === null) return baseNodeId;
|
||||||
|
|
||||||
|
const edgeClientRequestId = clientRequestIdFromOptimisticEdgeId(edge.id);
|
||||||
|
if (edgeClientRequestId === null || edgeClientRequestId !== nodeClientRequestId) {
|
||||||
|
return baseNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.pendingConnectionCreateIds.has(nodeClientRequestId)) {
|
||||||
|
return baseNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newlyAppearedIds.length !== 1) {
|
||||||
|
return baseNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferredRealId = newlyAppearedIds[0] as Id<"nodes">;
|
||||||
|
inferredRealIdByClientRequest.set(nodeClientRequestId, inferredRealId);
|
||||||
|
return inferredRealId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const endpointUsable = (nodeId: string): boolean => {
|
||||||
|
if (args.isAnyNodeDragging && args.localNodeIds.has(nodeId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedNodeId = resolveEndpoint(nodeId);
|
||||||
|
return Boolean(convexNodeIds?.has(resolvedNodeId) || convexNodeIds?.has(nodeId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const optimisticEndpointHasPendingCreate = (nodeId: string): boolean => {
|
||||||
|
if (!isOptimisticNodeId(nodeId)) return false;
|
||||||
|
const clientRequestId = clientRequestIdFromOptimisticNodeId(nodeId);
|
||||||
|
return clientRequestId !== null && args.pendingConnectionCreateIds.has(clientRequestId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldCarryOptimisticEdge = (original: RFEdge, remapped: RFEdge): boolean => {
|
||||||
|
if (mappedSignatures.has(rfEdgeConnectionSignature(remapped))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceOk = endpointUsable(remapped.source);
|
||||||
|
const targetOk = endpointUsable(remapped.target);
|
||||||
|
if (sourceOk && targetOk) return true;
|
||||||
|
|
||||||
|
if (!args.pendingConnectionCreateIds.size) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceOk && optimisticEndpointHasPendingCreate(original.target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetOk && optimisticEndpointHasPendingCreate(original.source)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const carriedOptimistic: RFEdge[] = [];
|
||||||
|
for (const edge of args.previousEdges) {
|
||||||
|
if (edge.className === "temp") continue;
|
||||||
|
if (!isOptimisticEdgeId(edge.id)) continue;
|
||||||
|
|
||||||
|
const remappedEdge: RFEdge = {
|
||||||
|
...edge,
|
||||||
|
source: resolveEndpointWithInference(edge.source, edge),
|
||||||
|
target: resolveEndpointWithInference(edge.target, edge),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shouldCarryOptimisticEdge(edge, remappedEdge)) continue;
|
||||||
|
carriedOptimistic.push(remappedEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settledPendingConnectionCreateIds: string[] = [];
|
||||||
|
for (const clientRequestId of args.pendingConnectionCreateIds) {
|
||||||
|
const realId = inferredRealIdByClientRequest.get(clientRequestId);
|
||||||
|
if (realId === undefined) continue;
|
||||||
|
|
||||||
|
const nodePresent = args.convexNodes?.some((node) => node._id === realId) ?? false;
|
||||||
|
const edgeTouchesNewNode = args.convexEdges.some(
|
||||||
|
(edge) => edge.sourceNodeId === realId || edge.targetNodeId === realId,
|
||||||
|
);
|
||||||
|
if (nodePresent && edgeTouchesNewNode) {
|
||||||
|
settledPendingConnectionCreateIds.push(clientRequestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
edges: [...mapped, ...carriedOptimistic, ...tempEdges],
|
||||||
|
nextConvexNodeIdsSnapshot: args.convexNodes
|
||||||
|
? currentConvexIdSet
|
||||||
|
: new Set(args.previousConvexNodeIdsSnapshot),
|
||||||
|
inferredRealIdByClientRequest,
|
||||||
|
settledPendingConnectionCreateIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLocalPositionPins(args: {
|
||||||
|
nodes: RFNode[];
|
||||||
|
pendingLocalPositionPins: ReadonlyMap<string, { x: number; y: number }>;
|
||||||
|
}): {
|
||||||
|
nodes: RFNode[];
|
||||||
|
nextPendingLocalPositionPins: Map<string, { x: number; y: number }>;
|
||||||
|
} {
|
||||||
|
const nextPendingLocalPositionPins = new Map(args.pendingLocalPositionPins);
|
||||||
|
const nodes = args.nodes.map((node) => {
|
||||||
|
const pin = nextPendingLocalPositionPins.get(node.id);
|
||||||
|
if (!pin) return node;
|
||||||
|
|
||||||
|
if (positionsMatchPin(node.position, pin)) {
|
||||||
|
nextPendingLocalPositionPins.delete(node.id);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: { x: pin.x, y: pin.y },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
nextPendingLocalPositionPins,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileCanvasFlowNodes(args: {
|
||||||
|
previousNodes: RFNode[];
|
||||||
|
incomingNodes: RFNode[];
|
||||||
|
convexNodes: FlowConvexNodeRecord[];
|
||||||
|
deletingNodeIds: ReadonlySet<string>;
|
||||||
|
resolvedRealIdByClientRequest: ReadonlyMap<string, Id<"nodes">>;
|
||||||
|
pendingConnectionCreateIds: ReadonlySet<string>;
|
||||||
|
preferLocalPositionNodeIds: ReadonlySet<string>;
|
||||||
|
pendingLocalPositionPins: ReadonlyMap<string, { x: number; y: number }>;
|
||||||
|
pendingMovePins: ReadonlyMap<string, { x: number; y: number }>;
|
||||||
|
}): {
|
||||||
|
nodes: RFNode[];
|
||||||
|
inferredRealIdByClientRequest: Map<string, Id<"nodes">>;
|
||||||
|
nextPendingLocalPositionPins: Map<string, { x: number; y: number }>;
|
||||||
|
clearedPreferLocalPositionNodeIds: string[];
|
||||||
|
} {
|
||||||
|
const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({
|
||||||
|
previousNodes: args.previousNodes,
|
||||||
|
incomingConvexNodes: args.convexNodes,
|
||||||
|
pendingConnectionCreateIds: args.pendingConnectionCreateIds,
|
||||||
|
resolvedRealIdByClientRequest: args.resolvedRealIdByClientRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredIncomingNodes = args.deletingNodeIds.size
|
||||||
|
? args.incomingNodes.filter((node) => !args.deletingNodeIds.has(node.id))
|
||||||
|
: args.incomingNodes;
|
||||||
|
const mergedNodes = mergeNodesPreservingLocalState(
|
||||||
|
args.previousNodes,
|
||||||
|
filteredIncomingNodes,
|
||||||
|
inferredRealIdByClientRequest,
|
||||||
|
args.preferLocalPositionNodeIds,
|
||||||
|
);
|
||||||
|
const pinnedNodes = applyLocalPositionPins({
|
||||||
|
nodes: mergedNodes,
|
||||||
|
pendingLocalPositionPins: args.pendingLocalPositionPins,
|
||||||
|
});
|
||||||
|
const nodes = applyPinnedNodePositionsReadOnly(
|
||||||
|
pinnedNodes.nodes,
|
||||||
|
args.pendingMovePins,
|
||||||
|
);
|
||||||
|
|
||||||
|
const incomingById = new Map(filteredIncomingNodes.map((node) => [node.id, node]));
|
||||||
|
const clearedPreferLocalPositionNodeIds: string[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!args.preferLocalPositionNodeIds.has(node.id)) continue;
|
||||||
|
|
||||||
|
const incomingNode = incomingById.get(node.id);
|
||||||
|
if (!incomingNode) continue;
|
||||||
|
|
||||||
|
if (positionsMatchPin(node.position, incomingNode.position)) {
|
||||||
|
clearedPreferLocalPositionNodeIds.push(node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
inferredRealIdByClientRequest,
|
||||||
|
nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins,
|
||||||
|
clearedPreferLocalPositionNodeIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||||
|
|
||||||
import { readCanvasOps } from "@/lib/canvas-local-persistence";
|
import { readCanvasOps } from "@/lib/canvas-local-persistence";
|
||||||
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
||||||
import { getSourceImage } from "@/lib/image-pipeline/contracts";
|
import { getSourceImage } from "@/lib/image-pipeline/contracts";
|
||||||
|
|
||||||
@@ -385,38 +385,6 @@ export function applyPinnedNodePositionsReadOnly(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferPendingConnectionNodeHandoff(
|
|
||||||
previousNodes: RFNode[],
|
|
||||||
incomingConvexNodes: Doc<"nodes">[],
|
|
||||||
pendingConnectionCreates: ReadonlySet<string>,
|
|
||||||
resolvedRealIdByClientRequest: Map<string, Id<"nodes">>,
|
|
||||||
): void {
|
|
||||||
const unresolvedClientRequestIds: string[] = [];
|
|
||||||
for (const clientRequestId of pendingConnectionCreates) {
|
|
||||||
if (resolvedRealIdByClientRequest.has(clientRequestId)) continue;
|
|
||||||
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
|
|
||||||
const optimisticNodePresent = previousNodes.some(
|
|
||||||
(node) => node.id === optimisticNodeId,
|
|
||||||
);
|
|
||||||
if (optimisticNodePresent) {
|
|
||||||
unresolvedClientRequestIds.push(clientRequestId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (unresolvedClientRequestIds.length !== 1) return;
|
|
||||||
|
|
||||||
const previousIds = new Set(previousNodes.map((node) => node.id));
|
|
||||||
const newlyAppearedIncomingRealNodeIds = incomingConvexNodes
|
|
||||||
.map((node) => node._id as string)
|
|
||||||
.filter((id) => !isOptimisticNodeId(id))
|
|
||||||
.filter((id) => !previousIds.has(id));
|
|
||||||
|
|
||||||
if (newlyAppearedIncomingRealNodeIds.length !== 1) return;
|
|
||||||
|
|
||||||
const inferredClientRequestId = unresolvedClientRequestIds[0]!;
|
|
||||||
const inferredRealId = newlyAppearedIncomingRealNodeIds[0] as Id<"nodes">;
|
|
||||||
resolvedRealIdByClientRequest.set(inferredClientRequestId, inferredRealId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMoveNodeOpPayload(
|
function isMoveNodeOpPayload(
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): payload is { nodeId: Id<"nodes">; positionX: number; positionY: number } {
|
): payload is { nodeId: Id<"nodes">; positionX: number; positionY: number } {
|
||||||
|
|||||||
@@ -76,8 +76,6 @@ import { nodeTypes } from "./node-types";
|
|||||||
import {
|
import {
|
||||||
convexNodeDocWithMergedStorageUrl,
|
convexNodeDocWithMergedStorageUrl,
|
||||||
convexNodeToRF,
|
convexNodeToRF,
|
||||||
convexEdgeToRF,
|
|
||||||
convexEdgeToRFWithSourceGlow,
|
|
||||||
NODE_DEFAULTS,
|
NODE_DEFAULTS,
|
||||||
NODE_HANDLE_MAP,
|
NODE_HANDLE_MAP,
|
||||||
} from "@/lib/canvas-utils";
|
} from "@/lib/canvas-utils";
|
||||||
@@ -99,8 +97,6 @@ import {
|
|||||||
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
||||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
import {
|
import {
|
||||||
applyPinnedNodePositions,
|
|
||||||
applyPinnedNodePositionsReadOnly,
|
|
||||||
CANVAS_MIN_ZOOM,
|
CANVAS_MIN_ZOOM,
|
||||||
clientRequestIdFromOptimisticEdgeId,
|
clientRequestIdFromOptimisticEdgeId,
|
||||||
clientRequestIdFromOptimisticNodeId,
|
clientRequestIdFromOptimisticNodeId,
|
||||||
@@ -115,19 +111,19 @@ import {
|
|||||||
getPendingRemovedEdgeIdsFromLocalOps,
|
getPendingRemovedEdgeIdsFromLocalOps,
|
||||||
getPendingMovePinsFromLocalOps,
|
getPendingMovePinsFromLocalOps,
|
||||||
hasHandleKey,
|
hasHandleKey,
|
||||||
inferPendingConnectionNodeHandoff,
|
|
||||||
isEditableKeyboardTarget,
|
isEditableKeyboardTarget,
|
||||||
isOptimisticEdgeId,
|
isOptimisticEdgeId,
|
||||||
isOptimisticNodeId,
|
isOptimisticNodeId,
|
||||||
mergeNodesPreservingLocalState,
|
|
||||||
normalizeHandle,
|
normalizeHandle,
|
||||||
OPTIMISTIC_EDGE_PREFIX,
|
OPTIMISTIC_EDGE_PREFIX,
|
||||||
OPTIMISTIC_NODE_PREFIX,
|
OPTIMISTIC_NODE_PREFIX,
|
||||||
positionsMatchPin,
|
|
||||||
type PendingEdgeSplit,
|
type PendingEdgeSplit,
|
||||||
rfEdgeConnectionSignature,
|
|
||||||
withResolvedCompareData,
|
withResolvedCompareData,
|
||||||
} from "./canvas-helpers";
|
} from "./canvas-helpers";
|
||||||
|
import {
|
||||||
|
reconcileCanvasFlowEdges,
|
||||||
|
reconcileCanvasFlowNodes,
|
||||||
|
} from "./canvas-flow-reconciliation-helpers";
|
||||||
import { adjustNodeDimensionChanges } from "./canvas-node-change-helpers";
|
import { adjustNodeDimensionChanges } from "./canvas-node-change-helpers";
|
||||||
import { useGenerationFailureWarnings } from "./canvas-generation-failures";
|
import { useGenerationFailureWarnings } from "./canvas-generation-failures";
|
||||||
import { useCanvasDeleteHandlers } from "./canvas-delete-handlers";
|
import { useCanvasDeleteHandlers } from "./canvas-delete-handlers";
|
||||||
@@ -1942,189 +1938,38 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!convexEdges) return;
|
if (!convexEdges) return;
|
||||||
setEdges((prev) => {
|
setEdges((prev) => {
|
||||||
const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current;
|
const reconciliation = reconcileCanvasFlowEdges({
|
||||||
const currentConvexIdList: string[] =
|
previousEdges: prev,
|
||||||
convexNodes !== undefined
|
convexEdges,
|
||||||
? convexNodes.map((n: Doc<"nodes">) => n._id as string)
|
convexNodes,
|
||||||
: [];
|
previousConvexNodeIdsSnapshot: convexNodeIdsSnapshotForEdgeCarryRef.current,
|
||||||
const currentConvexIdSet = new Set(currentConvexIdList);
|
pendingRemovedEdgeIds: getPendingRemovedEdgeIdsFromLocalOps(canvasId as string),
|
||||||
const newlyAppearedIds: string[] = [];
|
pendingConnectionCreateIds: pendingConnectionCreatesRef.current,
|
||||||
for (const id of currentConvexIdList) {
|
resolvedRealIdByClientRequest: resolvedRealIdByClientRequestRef.current,
|
||||||
if (!prevConvexSnap.has(id)) newlyAppearedIds.push(id);
|
localNodeIds: new Set(nodesRef.current.map((node) => node.id)),
|
||||||
}
|
isAnyNodeDragging:
|
||||||
|
|
||||||
const tempEdges = prev.filter((e) => e.className === "temp");
|
|
||||||
const pendingRemovedEdgeIds = getPendingRemovedEdgeIdsFromLocalOps(
|
|
||||||
canvasId as string,
|
|
||||||
);
|
|
||||||
const sourceTypeByNodeId =
|
|
||||||
convexNodes !== undefined
|
|
||||||
? new Map<string, string>(
|
|
||||||
convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type as string]),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const glowMode = resolvedTheme === "dark" ? "dark" : "light";
|
|
||||||
const mapped = convexEdges
|
|
||||||
.filter((edge: Doc<"edges">) => !pendingRemovedEdgeIds.has(edge._id as string))
|
|
||||||
.map((edge: Doc<"edges">) =>
|
|
||||||
sourceTypeByNodeId
|
|
||||||
? convexEdgeToRFWithSourceGlow(
|
|
||||||
edge,
|
|
||||||
sourceTypeByNodeId.get(edge.sourceNodeId),
|
|
||||||
glowMode,
|
|
||||||
)
|
|
||||||
: convexEdgeToRF(edge),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature));
|
|
||||||
const convexNodeIds =
|
|
||||||
convexNodes !== undefined
|
|
||||||
? new Set(convexNodes.map((n: Doc<"nodes">) => n._id as string))
|
|
||||||
: null;
|
|
||||||
const realIdByClientRequest = resolvedRealIdByClientRequestRef.current;
|
|
||||||
const isAnyNodeDragging =
|
|
||||||
isDragging.current ||
|
isDragging.current ||
|
||||||
nodesRef.current.some((n) =>
|
nodesRef.current.some((node) =>
|
||||||
Boolean((n as { dragging?: boolean }).dragging),
|
Boolean((node as { dragging?: boolean }).dragging),
|
||||||
);
|
),
|
||||||
|
colorMode: resolvedTheme === "dark" ? "dark" : "light",
|
||||||
|
});
|
||||||
|
|
||||||
const localHasOptimisticNode = (nodeId: string): boolean => {
|
resolvedRealIdByClientRequestRef.current =
|
||||||
if (!isOptimisticNodeId(nodeId)) return false;
|
reconciliation.inferredRealIdByClientRequest;
|
||||||
return nodesRef.current.some((n) => n.id === nodeId);
|
convexNodeIdsSnapshotForEdgeCarryRef.current =
|
||||||
};
|
reconciliation.nextConvexNodeIdsSnapshot;
|
||||||
|
for (const clientRequestId of reconciliation.settledPendingConnectionCreateIds) {
|
||||||
const resolveEndpoint = (nodeId: string): string => {
|
pendingConnectionCreatesRef.current.delete(clientRequestId);
|
||||||
if (!isOptimisticNodeId(nodeId)) return nodeId;
|
|
||||||
const cr = clientRequestIdFromOptimisticNodeId(nodeId);
|
|
||||||
if (!cr) return nodeId;
|
|
||||||
if (isAnyNodeDragging && localHasOptimisticNode(nodeId)) {
|
|
||||||
return nodeId;
|
|
||||||
}
|
|
||||||
const real = realIdByClientRequest.get(cr);
|
|
||||||
return real !== undefined ? (real as string) : nodeId;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Wenn Mutation-.then noch nicht lief: echte ID aus Delta (eine neue Node) + gleiche clientRequestId wie Kante. */
|
|
||||||
const resolveEndpointWithInference = (
|
|
||||||
nodeId: string,
|
|
||||||
edge: RFEdge,
|
|
||||||
): string => {
|
|
||||||
const base = resolveEndpoint(nodeId);
|
|
||||||
if (!isOptimisticNodeId(base)) return base;
|
|
||||||
if (isAnyNodeDragging) return base;
|
|
||||||
const nodeCr = clientRequestIdFromOptimisticNodeId(base);
|
|
||||||
if (nodeCr === null) return base;
|
|
||||||
const edgeCr = clientRequestIdFromOptimisticEdgeId(edge.id);
|
|
||||||
if (edgeCr === null || edgeCr !== nodeCr) return base;
|
|
||||||
if (!pendingConnectionCreatesRef.current.has(nodeCr)) return base;
|
|
||||||
if (newlyAppearedIds.length !== 1) return base;
|
|
||||||
const inferred = newlyAppearedIds[0];
|
|
||||||
resolvedRealIdByClientRequestRef.current.set(
|
|
||||||
nodeCr,
|
|
||||||
inferred as Id<"nodes">,
|
|
||||||
);
|
|
||||||
return inferred;
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpointUsable = (nodeId: string): boolean => {
|
|
||||||
if (isAnyNodeDragging && localHasOptimisticNode(nodeId)) return true;
|
|
||||||
const resolved = resolveEndpoint(nodeId);
|
|
||||||
if (convexNodeIds?.has(resolved)) return true;
|
|
||||||
if (convexNodeIds?.has(nodeId)) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const optimisticEndpointHasPendingCreate = (nodeId: string): boolean => {
|
|
||||||
if (!isOptimisticNodeId(nodeId)) return false;
|
|
||||||
const cr = clientRequestIdFromOptimisticNodeId(nodeId);
|
|
||||||
return (
|
|
||||||
cr !== null && pendingConnectionCreatesRef.current.has(cr)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldCarryOptimisticEdge = (
|
|
||||||
original: RFEdge,
|
|
||||||
remapped: RFEdge,
|
|
||||||
): boolean => {
|
|
||||||
if (mappedSignatures.has(rfEdgeConnectionSignature(remapped))) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceOk = endpointUsable(remapped.source);
|
return reconciliation.edges;
|
||||||
const targetOk = endpointUsable(remapped.target);
|
|
||||||
if (sourceOk && targetOk) return true;
|
|
||||||
|
|
||||||
if (!pendingConnectionCreatesRef.current.size) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
sourceOk &&
|
|
||||||
optimisticEndpointHasPendingCreate(original.target)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
targetOk &&
|
|
||||||
optimisticEndpointHasPendingCreate(original.source)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const carriedOptimistic: RFEdge[] = [];
|
|
||||||
for (const e of prev) {
|
|
||||||
if (e.className === "temp") continue;
|
|
||||||
if (!isOptimisticEdgeId(e.id)) continue;
|
|
||||||
|
|
||||||
const remapped: RFEdge = {
|
|
||||||
...e,
|
|
||||||
source: resolveEndpointWithInference(e.source, e),
|
|
||||||
target: resolveEndpointWithInference(e.target, e),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!shouldCarryOptimisticEdge(e, remapped)) continue;
|
|
||||||
|
|
||||||
carriedOptimistic.push(remapped);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (convexNodes !== undefined) {
|
|
||||||
convexNodeIdsSnapshotForEdgeCarryRef.current = currentConvexIdSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Erst löschen, wenn Convex die neue Kante geliefert hat — sonst kurzes Fenster: pending=0, Kanten-Query noch alt, Carry schlägt fehl. */
|
|
||||||
for (const cr of [...pendingConnectionCreatesRef.current]) {
|
|
||||||
const realId = resolvedRealIdByClientRequestRef.current.get(cr);
|
|
||||||
if (realId === undefined) continue;
|
|
||||||
const nodePresent =
|
|
||||||
convexNodes !== undefined &&
|
|
||||||
convexNodes.some((n: Doc<"nodes">) => n._id === realId);
|
|
||||||
const edgeTouchesNewNode = convexEdges.some(
|
|
||||||
(e: Doc<"edges">) =>
|
|
||||||
e.sourceNodeId === realId || e.targetNodeId === realId,
|
|
||||||
);
|
|
||||||
if (nodePresent && edgeTouchesNewNode) {
|
|
||||||
pendingConnectionCreatesRef.current.delete(cr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...mapped, ...carriedOptimistic, ...tempEdges];
|
|
||||||
});
|
});
|
||||||
}, [canvasId, convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]);
|
}, [canvasId, convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!convexNodes || isResizing.current) return;
|
if (!convexNodes || isResizing.current) return;
|
||||||
setNodes((previousNodes) => {
|
setNodes((previousNodes) => {
|
||||||
inferPendingConnectionNodeHandoff(
|
|
||||||
previousNodes,
|
|
||||||
convexNodes,
|
|
||||||
pendingConnectionCreatesRef.current,
|
|
||||||
resolvedRealIdByClientRequestRef.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
/** RF setzt `node.dragging` + Position oft bevor `onNodeDragStart` `isDraggingRef` setzt — ohne diese Zeile zieht useLayoutEffect Convex-Stand darüber („Kleben“). */
|
/** RF setzt `node.dragging` + Position oft bevor `onNodeDragStart` `isDraggingRef` setzt — ohne diese Zeile zieht useLayoutEffect Convex-Stand darüber („Kleben“). */
|
||||||
const anyRfNodeDragging = previousNodes.some((n) =>
|
const anyRfNodeDragging = previousNodes.some((n) =>
|
||||||
Boolean((n as { dragging?: boolean }).dragging),
|
Boolean((n as { dragging?: boolean }).dragging),
|
||||||
@@ -2150,41 +1995,27 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
enriched.map(convexNodeToRF),
|
enriched.map(convexNodeToRF),
|
||||||
edges,
|
edges,
|
||||||
);
|
);
|
||||||
// Nodes, die gerade optimistisch gelöscht werden, nicht wiederherstellen
|
const reconciliation = reconcileCanvasFlowNodes({
|
||||||
const filteredIncoming = deletingNodeIds.current.size > 0
|
|
||||||
? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id))
|
|
||||||
: incomingNodes;
|
|
||||||
const merged = applyPinnedNodePositions(
|
|
||||||
mergeNodesPreservingLocalState(
|
|
||||||
previousNodes,
|
previousNodes,
|
||||||
filteredIncoming,
|
incomingNodes,
|
||||||
resolvedRealIdByClientRequestRef.current,
|
convexNodes,
|
||||||
preferLocalPositionNodeIdsRef.current,
|
deletingNodeIds: deletingNodeIds.current,
|
||||||
),
|
resolvedRealIdByClientRequest: resolvedRealIdByClientRequestRef.current,
|
||||||
pendingLocalPositionUntilConvexMatchesRef.current,
|
pendingConnectionCreateIds: pendingConnectionCreatesRef.current,
|
||||||
);
|
preferLocalPositionNodeIds: preferLocalPositionNodeIdsRef.current,
|
||||||
const mergedWithOpPins = applyPinnedNodePositionsReadOnly(
|
pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current,
|
||||||
merged,
|
pendingMovePins: getPendingMovePinsFromLocalOps(canvasId as string),
|
||||||
getPendingMovePinsFromLocalOps(canvasId as string),
|
});
|
||||||
);
|
|
||||||
/** Nicht am Drag-Ende leeren (moveNode läuft oft async): solange Convex alt ist, Eintrag behalten und erst bei übereinstimmendem Snapshot entfernen. */
|
resolvedRealIdByClientRequestRef.current =
|
||||||
const incomingById = new Map(
|
reconciliation.inferredRealIdByClientRequest;
|
||||||
filteredIncoming.map((n) => [n.id, n]),
|
pendingLocalPositionUntilConvexMatchesRef.current =
|
||||||
);
|
reconciliation.nextPendingLocalPositionPins;
|
||||||
for (const n of mergedWithOpPins) {
|
for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) {
|
||||||
if (!preferLocalPositionNodeIdsRef.current.has(n.id)) continue;
|
preferLocalPositionNodeIdsRef.current.delete(nodeId);
|
||||||
const inc = incomingById.get(n.id);
|
|
||||||
if (!inc) continue;
|
|
||||||
if (
|
|
||||||
positionsMatchPin(n.position, {
|
|
||||||
x: inc.position.x,
|
|
||||||
y: inc.position.y,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
preferLocalPositionNodeIdsRef.current.delete(n.id);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return mergedWithOpPins;
|
return reconciliation.nodes;
|
||||||
});
|
});
|
||||||
}, [canvasId, convexNodes, edges, storageUrlsById]);
|
}, [canvasId, convexNodes, edges, storageUrlsById]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user