feat(canvas): implement edge insertion reflow and enhance connection validation
- Introduced a new CSS transition for edge insertion reflowing to improve visual feedback during node adjustments. - Enhanced the connection validation logic to include options for optimistic edges, ensuring better handling of edge creation scenarios. - Updated the canvas connection drop menu to support additional templates and improved edge insertion handling. - Refactored edge insertion logic to accommodate local node position adjustments during reflow operations. - Added tests for new edge insertion features and connection validation improvements.
This commit is contained in:
@@ -248,9 +248,62 @@
|
||||
drop-shadow(0 0 9px rgba(39, 39, 42, 0.4));
|
||||
}
|
||||
|
||||
.react-flow.canvas-edge-insert-reflowing .react-flow__node {
|
||||
transition-property: transform;
|
||||
transition-duration: var(--ls-edge-insert-reflow-duration, 1297ms);
|
||||
transition-timing-function: linear(
|
||||
0 0%,
|
||||
0.2718 2.5%,
|
||||
0.6464 5%,
|
||||
1 7.5%,
|
||||
1.25 10%,
|
||||
1.3641 12.5%,
|
||||
1.3536 15%,
|
||||
1.2575 17.5%,
|
||||
1.125 20%,
|
||||
1 22.5%,
|
||||
0.9116 25%,
|
||||
0.8713 27.5%,
|
||||
0.875 30%,
|
||||
0.909 32.5%,
|
||||
0.9558 35%,
|
||||
1 37.5%,
|
||||
1.0313 40%,
|
||||
1.0455 42.5%,
|
||||
1.0442 45%,
|
||||
1.0322 47.5%,
|
||||
1.0156 50%,
|
||||
1 52.5%,
|
||||
0.989 55%,
|
||||
0.9839 57.5%,
|
||||
0.9844 60%,
|
||||
0.9886 62.5%,
|
||||
0.9945 65%,
|
||||
1 67.5%,
|
||||
1.0039 70%,
|
||||
1.0057 72.5%,
|
||||
1.0055 75%,
|
||||
1.004 77.5%,
|
||||
1.002 80%,
|
||||
1 82.5%,
|
||||
0.9986 85%,
|
||||
0.998 87.5%,
|
||||
0.998 90%,
|
||||
0.9986 92.5%,
|
||||
0.9993 95%,
|
||||
1 97.5%,
|
||||
1 100%
|
||||
);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.react-flow.canvas-edge-insert-reflowing .react-flow__node {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
|
||||
import { computeEdgeInsertLayout, withResolvedCompareData } from "../canvas-helpers";
|
||||
import {
|
||||
computeEdgeInsertLayout,
|
||||
computeEdgeInsertReflowPlan,
|
||||
withResolvedCompareData,
|
||||
} from "../canvas-helpers";
|
||||
import {
|
||||
buildGraphSnapshot,
|
||||
pruneCanvasGraphNodeDataOverrides,
|
||||
@@ -414,3 +418,52 @@ describe("computeEdgeInsertLayout", () => {
|
||||
expect(layout.targetPosition).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEdgeInsertReflowPlan", () => {
|
||||
it("propagates source and target shifts across full upstream/downstream chains", () => {
|
||||
const upstream = createNode({
|
||||
id: "upstream",
|
||||
position: { x: -120, y: 0 },
|
||||
style: { width: 100, height: 60 },
|
||||
});
|
||||
const source = createNode({
|
||||
id: "source",
|
||||
position: { x: 0, y: 0 },
|
||||
style: { width: 100, height: 60 },
|
||||
});
|
||||
const target = createNode({
|
||||
id: "target",
|
||||
position: { x: 120, y: 0 },
|
||||
style: { width: 100, height: 60 },
|
||||
});
|
||||
const downstream = createNode({
|
||||
id: "downstream",
|
||||
position: { x: 240, y: 0 },
|
||||
style: { width: 100, height: 60 },
|
||||
});
|
||||
|
||||
const edges = [
|
||||
createEdge({ id: "edge-upstream", source: "upstream", target: "source" }),
|
||||
createEdge({ id: "edge-split", source: "source", target: "target" }),
|
||||
createEdge({ id: "edge-downstream", source: "target", target: "downstream" }),
|
||||
];
|
||||
|
||||
const plan = computeEdgeInsertReflowPlan({
|
||||
nodes: [upstream, source, target, downstream],
|
||||
edges,
|
||||
splitEdge: edges[1],
|
||||
sourceNode: source,
|
||||
targetNode: target,
|
||||
newNodeWidth: 220,
|
||||
newNodeHeight: 120,
|
||||
gapPx: 10,
|
||||
});
|
||||
|
||||
expect(plan.moves).toEqual([
|
||||
{ nodeId: "upstream", positionX: -230, positionY: 0 },
|
||||
{ nodeId: "source", positionX: -110, positionY: 0 },
|
||||
{ nodeId: "target", positionX: 230, positionY: 0 },
|
||||
{ nodeId: "downstream", positionX: 350, positionY: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||
import { useCanvasEdgeInsertions } from "@/components/canvas/use-canvas-edge-insertions";
|
||||
import { computeEdgeInsertLayout } from "@/components/canvas/canvas-helpers";
|
||||
|
||||
const latestHandlersRef: {
|
||||
current: ReturnType<typeof useCanvasEdgeInsertions> | null;
|
||||
@@ -40,7 +41,10 @@ type HookHarnessProps = {
|
||||
edges: RFEdge[];
|
||||
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
|
||||
runBatchMoveNodesMutation?: ReturnType<typeof vi.fn>;
|
||||
applyLocalNodeMoves?: ReturnType<typeof vi.fn>;
|
||||
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
||||
onReflowStateChange?: (isReflowing: boolean) => void;
|
||||
reflowSettleMs?: number;
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
@@ -48,16 +52,24 @@ function HookHarness({
|
||||
edges,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new"),
|
||||
runBatchMoveNodesMutation = vi.fn(async () => undefined),
|
||||
applyLocalNodeMoves,
|
||||
showConnectionRejectedToast = vi.fn(),
|
||||
onReflowStateChange,
|
||||
reflowSettleMs = 0,
|
||||
}: HookHarnessProps) {
|
||||
const handlers = useCanvasEdgeInsertions({
|
||||
const hookArgs = {
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes,
|
||||
edges,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
runBatchMoveNodesMutation,
|
||||
applyLocalNodeMoves,
|
||||
showConnectionRejectedToast,
|
||||
});
|
||||
onReflowStateChange,
|
||||
reflowSettleMs,
|
||||
} as Parameters<typeof useCanvasEdgeInsertions>[0];
|
||||
|
||||
const handlers = useCanvasEdgeInsertions(hookArgs);
|
||||
|
||||
useEffect(() => {
|
||||
latestHandlersRef.current = handlers;
|
||||
@@ -156,6 +168,72 @@ describe("useCanvasEdgeInsertions", () => {
|
||||
expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves optimistic edge menu opens to a persisted twin edge id", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "text", position: { x: 500, y: 0 } }),
|
||||
]}
|
||||
edges={[
|
||||
createEdge({
|
||||
id: "optimistic_edge_req-1",
|
||||
source: "source",
|
||||
target: "target",
|
||||
sourceHandle: "source-handle",
|
||||
targetHandle: "target-handle",
|
||||
}),
|
||||
createEdge({
|
||||
id: "edge-real-1",
|
||||
source: "source",
|
||||
target: "target",
|
||||
sourceHandle: "source-handle",
|
||||
targetHandle: "target-handle",
|
||||
}),
|
||||
]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({
|
||||
edgeId: "optimistic_edge_req-1",
|
||||
screenX: 20,
|
||||
screenY: 30,
|
||||
});
|
||||
});
|
||||
|
||||
expect(latestHandlersRef.current?.edgeInsertMenu).toEqual({
|
||||
edgeId: "edge-real-1",
|
||||
screenX: 20,
|
||||
screenY: 30,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.handleEdgeInsertPick({
|
||||
type: "prompt",
|
||||
label: "Prompt",
|
||||
width: 320,
|
||||
height: 220,
|
||||
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
|
||||
} as CanvasNodeTemplate);
|
||||
});
|
||||
|
||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
splitEdgeId: "edge-real-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows toast and skips create when split validation fails", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
||||
@@ -269,9 +347,18 @@ describe("useCanvasEdgeInsertions", () => {
|
||||
expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("moves source and target nodes when spacing is too tight", async () => {
|
||||
it("moves chain nodes before create when spacing is too tight", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
||||
const events: string[] = [];
|
||||
|
||||
runBatchMoveNodesMutation.mockImplementation(async () => {
|
||||
events.push("move");
|
||||
});
|
||||
runCreateNodeWithEdgeSplitOnlineOnly.mockImplementation(async () => {
|
||||
events.push("create");
|
||||
return "node-new";
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -281,10 +368,16 @@ describe("useCanvasEdgeInsertions", () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "upstream", type: "image", position: { x: -120, y: 0 } }),
|
||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "text", position: { x: 120, y: 0 } }),
|
||||
createNode({ id: "downstream", type: "text", position: { x: 240, y: 0 } }),
|
||||
]}
|
||||
edges={[
|
||||
createEdge({ id: "edge-upstream", source: "upstream", target: "source" }),
|
||||
createEdge({ id: "edge-1", source: "source", target: "target" }),
|
||||
createEdge({ id: "edge-downstream", source: "target", target: "downstream" }),
|
||||
]}
|
||||
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||
/>,
|
||||
@@ -308,9 +401,343 @@ describe("useCanvasEdgeInsertions", () => {
|
||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1);
|
||||
expect(runBatchMoveNodesMutation).toHaveBeenCalledWith({
|
||||
moves: [
|
||||
{ nodeId: "upstream", positionX: -230, positionY: 0 },
|
||||
{ nodeId: "source", positionX: -110, positionY: 0 },
|
||||
{ nodeId: "target", positionX: 230, positionY: 0 },
|
||||
{ nodeId: "downstream", positionX: 350, positionY: 0 },
|
||||
],
|
||||
});
|
||||
expect(events).toEqual(["move", "create"]);
|
||||
});
|
||||
|
||||
it("computes insert position from post-reflow source/target positions", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
||||
|
||||
const source = createNode({
|
||||
id: "source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 20 },
|
||||
style: { width: 160, height: 60 },
|
||||
});
|
||||
const target = createNode({
|
||||
id: "target",
|
||||
type: "text",
|
||||
position: { x: 150, y: 20 },
|
||||
style: { width: 80, height: 120 },
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[source, target]}
|
||||
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
|
||||
});
|
||||
|
||||
const initialLayout = computeEdgeInsertLayout({
|
||||
sourceNode: source,
|
||||
targetNode: target,
|
||||
newNodeWidth: 220,
|
||||
newNodeHeight: 120,
|
||||
gapPx: 10,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.handleEdgeInsertPick({
|
||||
type: "note",
|
||||
label: "Notiz",
|
||||
width: 220,
|
||||
height: 120,
|
||||
defaultData: { content: "" },
|
||||
} as CanvasNodeTemplate);
|
||||
});
|
||||
|
||||
const typedBatchMoveMock = runBatchMoveNodesMutation as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
};
|
||||
const firstMoveCallUnknown = typedBatchMoveMock.mock.calls[0]?.[0];
|
||||
const firstMoveCall = firstMoveCallUnknown as
|
||||
| {
|
||||
moves: {
|
||||
nodeId: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
}[];
|
||||
}
|
||||
| undefined;
|
||||
expect(firstMoveCall).toBeDefined();
|
||||
const moveByNodeId = new Map(
|
||||
(firstMoveCall?.moves ?? []).map((move) => [move.nodeId, move]),
|
||||
);
|
||||
|
||||
const sourceMove = moveByNodeId.get("source");
|
||||
const targetMove = moveByNodeId.get("target");
|
||||
expect(sourceMove).toBeDefined();
|
||||
expect(targetMove).toBeDefined();
|
||||
|
||||
const sourceAfter = {
|
||||
...source,
|
||||
position: {
|
||||
x: sourceMove?.positionX ?? source.position.x,
|
||||
y: sourceMove?.positionY ?? source.position.y,
|
||||
},
|
||||
};
|
||||
const targetAfter = {
|
||||
...target,
|
||||
position: {
|
||||
x: targetMove?.positionX ?? target.position.x,
|
||||
y: targetMove?.positionY ?? target.position.y,
|
||||
},
|
||||
};
|
||||
|
||||
const postMoveLayout = computeEdgeInsertLayout({
|
||||
sourceNode: sourceAfter,
|
||||
targetNode: targetAfter,
|
||||
newNodeWidth: 220,
|
||||
newNodeHeight: 120,
|
||||
gapPx: 10,
|
||||
});
|
||||
|
||||
expect(initialLayout.insertPosition).not.toEqual(postMoveLayout.insertPosition);
|
||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
positionX: postMoveLayout.insertPosition.x,
|
||||
positionY: postMoveLayout.insertPosition.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes reflow state while chain nodes are moving", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
||||
const onReflowStateChange = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "text", position: { x: 120, y: 0 } }),
|
||||
]}
|
||||
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||
onReflowStateChange={onReflowStateChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.handleEdgeInsertPick({
|
||||
type: "note",
|
||||
label: "Notiz",
|
||||
width: 220,
|
||||
height: 120,
|
||||
defaultData: { content: "" },
|
||||
} as CanvasNodeTemplate);
|
||||
});
|
||||
|
||||
expect(onReflowStateChange.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it("applies local reflow moves before creating split node", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
||||
const applyLocalNodeMoves = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "upstream", type: "image", position: { x: -120, y: 0 } }),
|
||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "text", position: { x: 120, y: 0 } }),
|
||||
createNode({ id: "downstream", type: "text", position: { x: 240, y: 0 } }),
|
||||
]}
|
||||
edges={[
|
||||
createEdge({ id: "edge-upstream", source: "upstream", target: "source" }),
|
||||
createEdge({ id: "edge-1", source: "source", target: "target" }),
|
||||
createEdge({ id: "edge-downstream", source: "target", target: "downstream" }),
|
||||
]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||
applyLocalNodeMoves={applyLocalNodeMoves}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.handleEdgeInsertPick({
|
||||
type: "note",
|
||||
label: "Notiz",
|
||||
width: 220,
|
||||
height: 120,
|
||||
defaultData: { content: "" },
|
||||
} as CanvasNodeTemplate);
|
||||
});
|
||||
|
||||
expect(applyLocalNodeMoves).toHaveBeenCalledTimes(1);
|
||||
expect(applyLocalNodeMoves).toHaveBeenCalledWith([
|
||||
{ nodeId: "upstream", positionX: -230, positionY: 0 },
|
||||
{ nodeId: "source", positionX: -110, positionY: 0 },
|
||||
{ nodeId: "target", positionX: 230, positionY: 0 },
|
||||
{ nodeId: "downstream", positionX: 350, positionY: 0 },
|
||||
]);
|
||||
expect(applyLocalNodeMoves.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
runCreateNodeWithEdgeSplitOnlineOnly.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply local reflow moves when no move is needed", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
||||
const applyLocalNodeMoves = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "text", position: { x: 500, y: 0 } }),
|
||||
]}
|
||||
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||
applyLocalNodeMoves={applyLocalNodeMoves}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.handleEdgeInsertPick({
|
||||
type: "prompt",
|
||||
label: "Prompt",
|
||||
width: 320,
|
||||
height: 220,
|
||||
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
|
||||
} as CanvasNodeTemplate);
|
||||
});
|
||||
|
||||
expect(runBatchMoveNodesMutation).not.toHaveBeenCalled();
|
||||
expect(applyLocalNodeMoves).not.toHaveBeenCalled();
|
||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores temp edges when validating a split into adjustment targets", async () => {
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "curves", position: { x: 280, y: 0 } }),
|
||||
createNode({ id: "other", type: "image", position: { x: 0, y: 160 } }),
|
||||
]}
|
||||
edges={[
|
||||
createEdge({ id: "edge-1", source: "source", target: "target" }),
|
||||
createEdge({ id: "edge-temp", source: "other", target: "target", className: "temp" }),
|
||||
]}
|
||||
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 20, screenY: 20 });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.handleEdgeInsertPick({
|
||||
type: "color-adjust",
|
||||
label: "Farbe",
|
||||
width: 320,
|
||||
height: 800,
|
||||
defaultData: {},
|
||||
} as CanvasNodeTemplate);
|
||||
});
|
||||
|
||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1);
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes only edge-compatible templates for selected edge", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
nodes={[
|
||||
createNode({ id: "source", type: "color-adjust", position: { x: 0, y: 0 } }),
|
||||
createNode({ id: "target", type: "render", position: { x: 360, y: 0 } }),
|
||||
]}
|
||||
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 20, screenY: 20 });
|
||||
});
|
||||
|
||||
const templateTypes = (latestHandlersRef.current?.edgeInsertTemplates ?? []).map(
|
||||
(template) => template.type,
|
||||
);
|
||||
|
||||
expect(templateTypes).toContain("curves");
|
||||
expect(templateTypes).toContain("color-adjust");
|
||||
expect(templateTypes).toContain("image");
|
||||
expect(templateTypes).toContain("asset");
|
||||
|
||||
expect(templateTypes).not.toContain("render");
|
||||
expect(templateTypes).not.toContain("prompt");
|
||||
expect(templateTypes).not.toContain("text");
|
||||
expect(templateTypes).not.toContain("ai-image");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,15 +116,30 @@ function HookHarness({ canvasId }: { canvasId: Id<"canvases"> }) {
|
||||
latestHookValueRef.current = hookValue;
|
||||
}, [hookValue]);
|
||||
|
||||
useEffect(() => {
|
||||
latestEdgesRef.current = edges;
|
||||
}, [edges]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestEdgesRef: { current: RFEdge[] } = { current: [] };
|
||||
|
||||
function setNavigatorOnline(online: boolean) {
|
||||
Object.defineProperty(window.navigator, "onLine", {
|
||||
configurable: true,
|
||||
value: online,
|
||||
});
|
||||
}
|
||||
|
||||
describe("useCanvasSyncEngine hook wiring", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
latestHookValueRef.current = null;
|
||||
latestEdgesRef.current = [];
|
||||
setNavigatorOnline(true);
|
||||
mocks.mutationMocks.clear();
|
||||
vi.clearAllMocks();
|
||||
if (root) {
|
||||
@@ -170,4 +185,100 @@ describe("useCanvasSyncEngine hook wiring", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("remaps optimistic edge ids to persisted ids after online createEdge returns", async () => {
|
||||
const createEdgeMutation = vi.fn(async () => "edge-real-1");
|
||||
Object.assign(createEdgeMutation, {
|
||||
withOptimisticUpdate: () => createEdgeMutation,
|
||||
});
|
||||
mocks.mutationMocks.set("edges.create", createEdgeMutation);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<HookHarness canvasId={asCanvasId("canvas-1")} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHookValueRef.current?.actions.createEdge({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
sourceNodeId: asNodeId("node-a"),
|
||||
targetNodeId: asNodeId("node-b"),
|
||||
clientRequestId: "req-1",
|
||||
});
|
||||
});
|
||||
|
||||
expect(createEdgeMutation).toHaveBeenCalledTimes(1);
|
||||
expect(latestEdgesRef.current.some((edge) => edge.id === "edge-real-1")).toBe(true);
|
||||
expect(
|
||||
latestEdgesRef.current.some((edge) => edge.id === "optimistic_edge_req-1"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("remaps optimistic edge ids to persisted ids while flushing queued createEdge ops", async () => {
|
||||
setNavigatorOnline(false);
|
||||
const queuedCreateEdgeOp = {
|
||||
id: "op-1",
|
||||
canvasId: "canvas-1",
|
||||
type: "createEdge",
|
||||
payload: {
|
||||
canvasId: "canvas-1",
|
||||
sourceNodeId: "node-a",
|
||||
targetNodeId: "node-b",
|
||||
clientRequestId: "req-2",
|
||||
},
|
||||
enqueuedAt: Date.now(),
|
||||
attemptCount: 0,
|
||||
nextRetryAt: 0,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const typedListCanvasSyncOps = mocks.listCanvasSyncOps as unknown as {
|
||||
mockResolvedValueOnce: (value: unknown) => { mockResolvedValueOnce: (v: unknown) => void };
|
||||
};
|
||||
typedListCanvasSyncOps
|
||||
.mockResolvedValueOnce([queuedCreateEdgeOp])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const createEdgeMutation = vi.fn(async () => "edge-real-2");
|
||||
Object.assign(createEdgeMutation, {
|
||||
withOptimisticUpdate: () => createEdgeMutation,
|
||||
});
|
||||
mocks.mutationMocks.set("edges.create", createEdgeMutation);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<HookHarness canvasId={asCanvasId("canvas-1")} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHookValueRef.current?.actions.createEdge({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
sourceNodeId: asNodeId("node-a"),
|
||||
targetNodeId: asNodeId("node-b"),
|
||||
clientRequestId: "req-2",
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
latestEdgesRef.current.some((edge) => edge.id === "optimistic_edge_req-2"),
|
||||
).toBe(true);
|
||||
|
||||
setNavigatorOnline(true);
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event("online"));
|
||||
await latestHookValueRef.current?.actions.flushCanvasSyncQueue();
|
||||
});
|
||||
|
||||
expect(createEdgeMutation).toHaveBeenCalledTimes(1);
|
||||
expect(latestEdgesRef.current.some((edge) => edge.id === "edge-real-2")).toBe(true);
|
||||
expect(
|
||||
latestEdgesRef.current.some((edge) => edge.id === "optimistic_edge_req-2"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ type CanvasConnectionDropMenuProps = {
|
||||
anchor: CanvasMenuAnchor | null;
|
||||
onClose: () => void;
|
||||
onPick: (template: CanvasNodeTemplate) => void;
|
||||
templates?: readonly CanvasNodeTemplate[];
|
||||
};
|
||||
|
||||
const PANEL_MAX_W = 360;
|
||||
@@ -41,6 +42,7 @@ export function CanvasConnectionDropMenu({
|
||||
anchor,
|
||||
onClose,
|
||||
onPick,
|
||||
templates,
|
||||
}: CanvasConnectionDropMenuProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -109,6 +111,7 @@ export function CanvasConnectionDropMenu({
|
||||
onClose();
|
||||
}}
|
||||
groupHeading="Knoten"
|
||||
templates={templates}
|
||||
/>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -5,11 +5,16 @@ import {
|
||||
type CanvasConnectionValidationReason,
|
||||
} from "@/lib/canvas-connection-policy";
|
||||
|
||||
import { isOptimisticEdgeId } from "./canvas-helpers";
|
||||
|
||||
export function validateCanvasConnection(
|
||||
connection: Connection,
|
||||
nodes: RFNode[],
|
||||
edges: RFEdge[],
|
||||
edgeToReplaceId?: string,
|
||||
options?: {
|
||||
includeOptimisticEdges?: boolean;
|
||||
},
|
||||
): CanvasConnectionValidationReason | null {
|
||||
if (!connection.source || !connection.target) return "incomplete";
|
||||
if (connection.source === connection.target) return "self-loop";
|
||||
@@ -24,6 +29,7 @@ export function validateCanvasConnection(
|
||||
targetNodeId: connection.target,
|
||||
edges,
|
||||
edgeToReplaceId,
|
||||
includeOptimisticEdges: options?.includeOptimisticEdges,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,9 +39,14 @@ export function validateCanvasConnectionByType(args: {
|
||||
targetNodeId: string;
|
||||
edges: RFEdge[];
|
||||
edgeToReplaceId?: string;
|
||||
includeOptimisticEdges?: boolean;
|
||||
}): CanvasConnectionValidationReason | null {
|
||||
const targetIncomingCount = args.edges.filter(
|
||||
(edge) => edge.target === args.targetNodeId && edge.id !== args.edgeToReplaceId,
|
||||
(edge) =>
|
||||
edge.className !== "temp" &&
|
||||
(args.includeOptimisticEdges || !isOptimisticEdgeId(edge.id)) &&
|
||||
edge.target === args.targetNodeId &&
|
||||
edge.id !== args.edgeToReplaceId,
|
||||
).length;
|
||||
|
||||
return validateCanvasConnectionPolicy({
|
||||
|
||||
@@ -14,6 +14,7 @@ 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'>>;
|
||||
|
||||
@@ -22,6 +23,8 @@ type UseCanvasDeleteHandlersParams = {
|
||||
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>;
|
||||
@@ -40,6 +43,8 @@ export function useCanvasDeleteHandlers({
|
||||
canvasId,
|
||||
nodes,
|
||||
edges,
|
||||
nodesRef,
|
||||
edgesRef,
|
||||
deletingNodeIds,
|
||||
setAssetBrowserTargetNodeId,
|
||||
runBatchRemoveNodesMutation,
|
||||
@@ -50,6 +55,12 @@ export function useCanvasDeleteHandlers({
|
||||
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,
|
||||
@@ -117,11 +128,33 @@ export function useCanvasDeleteHandlers({
|
||||
current !== null && removedTargetSet.has(current) ? null : current,
|
||||
);
|
||||
|
||||
const liveNodes = nodesRef.current;
|
||||
const liveEdges = edgesRef.current;
|
||||
|
||||
const bridgeCreates = computeBridgeCreatesForDeletedNodes(
|
||||
deletedNodes,
|
||||
nodes,
|
||||
edges,
|
||||
liveNodes,
|
||||
liveEdges,
|
||||
);
|
||||
const connectedDeletedEdges = getConnectedEdges(deletedNodes, liveEdges);
|
||||
const remainingNodes = liveNodes.filter(
|
||||
(node) => !removedTargetSet.has(node.id),
|
||||
);
|
||||
let remainingEdges = liveEdges.filter(
|
||||
(edge) => !connectedDeletedEdges.includes(edge) && edge.className !== "temp",
|
||||
);
|
||||
|
||||
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({
|
||||
@@ -129,6 +162,51 @@ export function useCanvasDeleteHandlers({
|
||||
});
|
||||
|
||||
for (const bridgeCreate of bridgeCreates) {
|
||||
const bridgeKey = edgeKey({
|
||||
source: bridgeCreate.sourceNodeId,
|
||||
target: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
});
|
||||
if (remainingEdges.some((edge) => edgeKey(edge) === bridgeKey)) {
|
||||
console.info("[Canvas] skipped duplicate bridge edge after delete", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.info("[Canvas] creating bridge edge after delete", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
});
|
||||
|
||||
await runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: bridgeCreate.sourceNodeId,
|
||||
@@ -136,6 +214,25 @@ export function useCanvasDeleteHandlers({
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
});
|
||||
remainingEdges = [
|
||||
...remainingEdges,
|
||||
{
|
||||
id: `bridge-${bridgeCreate.sourceNodeId}-${bridgeCreate.targetNodeId}-${remainingEdges.length}`,
|
||||
source: bridgeCreate.sourceNodeId,
|
||||
target: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
},
|
||||
];
|
||||
} catch (error: unknown) {
|
||||
console.error("[Canvas] bridge edge create failed", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})()
|
||||
.then(() => {
|
||||
@@ -156,8 +253,11 @@ export function useCanvasDeleteHandlers({
|
||||
t,
|
||||
canvasId,
|
||||
deletingNodeIds,
|
||||
edgeKey,
|
||||
edges,
|
||||
edgesRef,
|
||||
nodes,
|
||||
nodesRef,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
setAssetBrowserTargetNodeId,
|
||||
|
||||
@@ -28,6 +28,18 @@ export type EdgeInsertLayout = {
|
||||
targetPosition?: XYPosition;
|
||||
};
|
||||
|
||||
type EdgeInsertReflowMove = {
|
||||
nodeId: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
};
|
||||
|
||||
export type EdgeInsertReflowPlan = {
|
||||
moves: EdgeInsertReflowMove[];
|
||||
sourcePosition?: XYPosition;
|
||||
targetPosition?: XYPosition;
|
||||
};
|
||||
|
||||
function readNodeDimension(node: RFNode, key: "width" | "height"): number | null {
|
||||
const nodeRecord = node as { width?: unknown; height?: unknown };
|
||||
const direct = nodeRecord[key];
|
||||
@@ -127,6 +139,131 @@ export function computeEdgeInsertLayout(args: ComputeEdgeInsertLayoutArgs): Edge
|
||||
return layout;
|
||||
}
|
||||
|
||||
function collectReachableNodeIds(args: {
|
||||
startNodeId: string;
|
||||
adjacency: Map<string, string[]>;
|
||||
}): Set<string> {
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [args.startNodeId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const nodeId = queue.shift();
|
||||
if (!nodeId || visited.has(nodeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(nodeId);
|
||||
const next = args.adjacency.get(nodeId) ?? [];
|
||||
for (const candidate of next) {
|
||||
if (!visited.has(candidate)) {
|
||||
queue.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
export function computeEdgeInsertReflowPlan(args: {
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
splitEdge: RFEdge;
|
||||
sourceNode: RFNode;
|
||||
targetNode: RFNode;
|
||||
newNodeWidth: number;
|
||||
newNodeHeight: number;
|
||||
gapPx: number;
|
||||
}): EdgeInsertReflowPlan {
|
||||
const layout = computeEdgeInsertLayout({
|
||||
sourceNode: args.sourceNode,
|
||||
targetNode: args.targetNode,
|
||||
newNodeWidth: args.newNodeWidth,
|
||||
newNodeHeight: args.newNodeHeight,
|
||||
gapPx: args.gapPx,
|
||||
});
|
||||
|
||||
const sourcePosition = layout.sourcePosition;
|
||||
const targetPosition = layout.targetPosition;
|
||||
|
||||
if (!sourcePosition && !targetPosition) {
|
||||
return {
|
||||
moves: [],
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceDx = sourcePosition ? sourcePosition.x - args.sourceNode.position.x : 0;
|
||||
const sourceDy = sourcePosition ? sourcePosition.y - args.sourceNode.position.y : 0;
|
||||
const targetDx = targetPosition ? targetPosition.x - args.targetNode.position.x : 0;
|
||||
const targetDy = targetPosition ? targetPosition.y - args.targetNode.position.y : 0;
|
||||
|
||||
const incomingByTarget = new Map<string, string[]>();
|
||||
const outgoingBySource = new Map<string, string[]>();
|
||||
|
||||
for (const edge of args.edges) {
|
||||
const incoming = incomingByTarget.get(edge.target) ?? [];
|
||||
incoming.push(edge.source);
|
||||
incomingByTarget.set(edge.target, incoming);
|
||||
|
||||
const outgoing = outgoingBySource.get(edge.source) ?? [];
|
||||
outgoing.push(edge.target);
|
||||
outgoingBySource.set(edge.source, outgoing);
|
||||
}
|
||||
|
||||
const upstreamIds = collectReachableNodeIds({
|
||||
startNodeId: args.splitEdge.source,
|
||||
adjacency: incomingByTarget,
|
||||
});
|
||||
const downstreamIds = collectReachableNodeIds({
|
||||
startNodeId: args.splitEdge.target,
|
||||
adjacency: outgoingBySource,
|
||||
});
|
||||
|
||||
const deltaByNodeId = new Map<string, { dx: number; dy: number }>();
|
||||
|
||||
for (const nodeId of upstreamIds) {
|
||||
const previous = deltaByNodeId.get(nodeId);
|
||||
deltaByNodeId.set(nodeId, {
|
||||
dx: (previous?.dx ?? 0) + sourceDx,
|
||||
dy: (previous?.dy ?? 0) + sourceDy,
|
||||
});
|
||||
}
|
||||
|
||||
for (const nodeId of downstreamIds) {
|
||||
const previous = deltaByNodeId.get(nodeId);
|
||||
deltaByNodeId.set(nodeId, {
|
||||
dx: (previous?.dx ?? 0) + targetDx,
|
||||
dy: (previous?.dy ?? 0) + targetDy,
|
||||
});
|
||||
}
|
||||
|
||||
const moves: EdgeInsertReflowMove[] = [];
|
||||
|
||||
for (const node of args.nodes) {
|
||||
const delta = deltaByNodeId.get(node.id);
|
||||
if (!delta) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.abs(delta.dx) <= Number.EPSILON && Math.abs(delta.dy) <= Number.EPSILON) {
|
||||
continue;
|
||||
}
|
||||
|
||||
moves.push({
|
||||
nodeId: node.id,
|
||||
positionX: node.position.x + delta.dx,
|
||||
positionY: node.position.y + delta.dy,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
moves,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCanvasOpId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
|
||||
@@ -62,6 +62,7 @@ const NODE_SEARCH_KEYWORDS: Partial<
|
||||
export type CanvasNodeTemplatePickerProps = {
|
||||
onPick: (template: CanvasNodeTemplate) => void;
|
||||
groupHeading?: string;
|
||||
templates?: readonly CanvasNodeTemplate[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,10 +71,11 @@ export type CanvasNodeTemplatePickerProps = {
|
||||
export function CanvasNodeTemplatePicker({
|
||||
onPick,
|
||||
groupHeading = "Knoten",
|
||||
templates = CANVAS_NODE_TEMPLATES,
|
||||
}: CanvasNodeTemplatePickerProps) {
|
||||
return (
|
||||
<CommandGroup heading={groupHeading}>
|
||||
{CANVAS_NODE_TEMPLATES.map((template) => {
|
||||
{templates.map((template) => {
|
||||
const Icon = NODE_ICONS[template.type];
|
||||
return (
|
||||
<CommandItem
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -81,6 +82,8 @@ interface CanvasInnerProps {
|
||||
canvasId: Id<"canvases">;
|
||||
}
|
||||
|
||||
const EDGE_INSERT_REFLOW_SETTLE_MS = 1297;
|
||||
|
||||
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const showConnectionRejectedToast = useCallback(
|
||||
@@ -154,6 +157,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const nodesRef = useRef<RFNode[]>(nodes);
|
||||
|
||||
const [scissorsMode, setScissorsMode] = useState(false);
|
||||
const [isEdgeInsertReflowing, setIsEdgeInsertReflowing] = useState(false);
|
||||
const [scissorStrokePreview, setScissorStrokePreview] = useState<
|
||||
{ x: number; y: number }[] | null
|
||||
>(null);
|
||||
@@ -300,6 +304,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
canvasId,
|
||||
nodes,
|
||||
edges,
|
||||
nodesRef,
|
||||
edgesRef,
|
||||
deletingNodeIds,
|
||||
setAssetBrowserTargetNodeId,
|
||||
runBatchRemoveNodesMutation,
|
||||
@@ -339,8 +345,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
showConnectionRejectedToast,
|
||||
});
|
||||
|
||||
const applyLocalEdgeInsertMoves = useCallback(
|
||||
(
|
||||
moves: {
|
||||
nodeId: Id<"nodes">;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
}[],
|
||||
) => {
|
||||
if (moves.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const positionByNodeId = new Map(
|
||||
moves.map((move) => [move.nodeId, { x: move.positionX, y: move.positionY }]),
|
||||
);
|
||||
|
||||
setNodes((currentNodes) =>
|
||||
currentNodes.map((node) => {
|
||||
const nextPosition = positionByNodeId.get(node.id as Id<"nodes">);
|
||||
if (!nextPosition) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.position.x === nextPosition.x && node.position.y === nextPosition.y) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: nextPosition,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
edgeInsertMenu,
|
||||
edgeInsertTemplates,
|
||||
closeEdgeInsertMenu,
|
||||
openEdgeInsertMenu,
|
||||
handleEdgeInsertPick,
|
||||
@@ -350,7 +394,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
edges,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
runBatchMoveNodesMutation,
|
||||
applyLocalNodeMoves: applyLocalEdgeInsertMoves,
|
||||
showConnectionRejectedToast,
|
||||
onReflowStateChange: setIsEdgeInsertReflowing,
|
||||
reflowSettleMs: EDGE_INSERT_REFLOW_SETTLE_MS,
|
||||
});
|
||||
|
||||
const handleEdgeInsertClick = useCallback(
|
||||
@@ -375,6 +422,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const edgeInsertReflowStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
"--ls-edge-insert-reflow-duration": `${EDGE_INSERT_REFLOW_SETTLE_MS}ms`,
|
||||
}) as CSSProperties,
|
||||
[],
|
||||
);
|
||||
|
||||
const edgeTypes = useCanvasEdgeTypes({
|
||||
edgeInsertMenuEdgeId: edgeInsertMenu?.edgeId ?? null,
|
||||
scissorsMode,
|
||||
@@ -545,6 +599,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
}
|
||||
onClose={closeEdgeInsertMenu}
|
||||
onPick={handleEdgeInsertPick}
|
||||
templates={edgeInsertTemplates}
|
||||
/>
|
||||
{scissorsMode ? (
|
||||
<div className="pointer-events-none absolute top-14 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10">
|
||||
@@ -578,6 +633,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
>
|
||||
<CanvasGraphProvider nodes={canvasGraphNodes} edges={canvasGraphEdges}>
|
||||
<ReactFlow
|
||||
style={edgeInsertReflowStyle}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onlyRenderVisibleElements
|
||||
@@ -614,7 +670,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
panActivationKeyCode="Space"
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
className={cn("bg-background", scissorsMode && "canvas-scissors-mode")}
|
||||
className={cn(
|
||||
"bg-background",
|
||||
scissorsMode && "canvas-scissors-mode",
|
||||
isEdgeInsertReflowing && "canvas-edge-insert-reflowing",
|
||||
)}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls className="bg-card! border! shadow-sm! rounded-lg!" />
|
||||
|
||||
@@ -3,15 +3,20 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-policy";
|
||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||
import {
|
||||
CANVAS_NODE_TEMPLATES,
|
||||
type CanvasNodeTemplate,
|
||||
} from "@/lib/canvas-node-templates";
|
||||
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
|
||||
import {
|
||||
computeEdgeInsertReflowPlan,
|
||||
computeEdgeInsertLayout,
|
||||
hasHandleKey,
|
||||
isOptimisticEdgeId,
|
||||
normalizeHandle,
|
||||
rfEdgeConnectionSignature,
|
||||
} from "./canvas-helpers";
|
||||
import { validateCanvasEdgeSplit } from "./canvas-connection-validation";
|
||||
|
||||
@@ -22,6 +27,13 @@ export type EdgeInsertMenuState = {
|
||||
};
|
||||
|
||||
const EDGE_INSERT_GAP_PX = 10;
|
||||
const DEFAULT_REFLOW_SETTLE_MS = 1297;
|
||||
|
||||
function waitForReflowSettle(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
type UseCanvasEdgeInsertionsArgs = {
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -49,7 +61,16 @@ type UseCanvasEdgeInsertionsArgs = {
|
||||
positionY: number;
|
||||
}[];
|
||||
}) => Promise<void>;
|
||||
applyLocalNodeMoves?: (
|
||||
moves: {
|
||||
nodeId: Id<"nodes">;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
}[],
|
||||
) => void;
|
||||
showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void;
|
||||
onReflowStateChange?: (isReflowing: boolean) => void;
|
||||
reflowSettleMs?: number;
|
||||
};
|
||||
|
||||
export function useCanvasEdgeInsertions({
|
||||
@@ -58,11 +79,18 @@ export function useCanvasEdgeInsertions({
|
||||
edges,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
runBatchMoveNodesMutation,
|
||||
applyLocalNodeMoves,
|
||||
showConnectionRejectedToast,
|
||||
onReflowStateChange,
|
||||
reflowSettleMs = DEFAULT_REFLOW_SETTLE_MS,
|
||||
}: UseCanvasEdgeInsertionsArgs) {
|
||||
const [edgeInsertMenu, setEdgeInsertMenu] = useState<EdgeInsertMenuState | null>(null);
|
||||
const edgeInsertMenuRef = useRef<EdgeInsertMenuState | null>(null);
|
||||
|
||||
const policyEdges = edges.filter(
|
||||
(edge) => edge.className !== "temp" && !isOptimisticEdgeId(edge.id),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
edgeInsertMenuRef.current = edgeInsertMenu;
|
||||
}, [edgeInsertMenu]);
|
||||
@@ -73,20 +101,67 @@ export function useCanvasEdgeInsertions({
|
||||
|
||||
const openEdgeInsertMenu = useCallback(
|
||||
({ edgeId, screenX, screenY }: EdgeInsertMenuState) => {
|
||||
const edge = edges.find(
|
||||
(candidate) =>
|
||||
candidate.id === edgeId &&
|
||||
candidate.className !== "temp" &&
|
||||
!isOptimisticEdgeId(candidate.id),
|
||||
const clickedEdge = edges.find(
|
||||
(candidate) => candidate.id === edgeId && candidate.className !== "temp",
|
||||
);
|
||||
if (!edge) {
|
||||
if (!clickedEdge) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEdgeInsertMenu({ edgeId, screenX, screenY });
|
||||
},
|
||||
[edges],
|
||||
let resolvedEdgeId: string | null = null;
|
||||
if (!isOptimisticEdgeId(edgeId)) {
|
||||
const persisted = policyEdges.find((candidate) => candidate.id === edgeId);
|
||||
resolvedEdgeId = persisted?.id ?? null;
|
||||
} else {
|
||||
const signature = rfEdgeConnectionSignature(clickedEdge);
|
||||
const persistedTwin = policyEdges.find(
|
||||
(candidate) => rfEdgeConnectionSignature(candidate) === signature,
|
||||
);
|
||||
resolvedEdgeId = persistedTwin?.id ?? null;
|
||||
}
|
||||
|
||||
if (!resolvedEdgeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEdgeInsertMenu({ edgeId: resolvedEdgeId, screenX, screenY });
|
||||
},
|
||||
[edges, policyEdges],
|
||||
);
|
||||
|
||||
const edgeInsertTemplates = (() => {
|
||||
if (!edgeInsertMenu) {
|
||||
return [] as CanvasNodeTemplate[];
|
||||
}
|
||||
|
||||
const splitEdge = policyEdges.find((edge) => edge.id === edgeInsertMenu.edgeId);
|
||||
if (!splitEdge) {
|
||||
return [] as CanvasNodeTemplate[];
|
||||
}
|
||||
|
||||
return CANVAS_NODE_TEMPLATES.filter((template) => {
|
||||
const handles = NODE_HANDLE_MAP[template.type];
|
||||
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const middleNode: RFNode = {
|
||||
id: "__pending_edge_insert__",
|
||||
type: template.type,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
|
||||
const splitValidationError = validateCanvasEdgeSplit({
|
||||
nodes,
|
||||
edges: policyEdges,
|
||||
splitEdge,
|
||||
middleNode,
|
||||
});
|
||||
|
||||
return splitValidationError === null;
|
||||
});
|
||||
})();
|
||||
|
||||
const handleEdgeInsertPick = useCallback(
|
||||
async (template: CanvasNodeTemplate) => {
|
||||
@@ -95,10 +170,7 @@ export function useCanvasEdgeInsertions({
|
||||
return;
|
||||
}
|
||||
|
||||
const splitEdge = edges.find(
|
||||
(edge) =>
|
||||
edge.id === menu.edgeId && edge.className !== "temp" && !isOptimisticEdgeId(edge.id),
|
||||
);
|
||||
const splitEdge = policyEdges.find((edge) => edge.id === menu.edgeId);
|
||||
if (!splitEdge) {
|
||||
showConnectionRejectedToast("unknown-node");
|
||||
return;
|
||||
@@ -133,7 +205,7 @@ export function useCanvasEdgeInsertions({
|
||||
|
||||
const splitValidationError = validateCanvasEdgeSplit({
|
||||
nodes,
|
||||
edges,
|
||||
edges: policyEdges,
|
||||
splitEdge,
|
||||
middleNode,
|
||||
});
|
||||
@@ -143,7 +215,10 @@ export function useCanvasEdgeInsertions({
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = computeEdgeInsertLayout({
|
||||
const reflowPlan = computeEdgeInsertReflowPlan({
|
||||
nodes,
|
||||
edges: policyEdges,
|
||||
splitEdge,
|
||||
sourceNode,
|
||||
targetNode,
|
||||
newNodeWidth: width,
|
||||
@@ -151,6 +226,43 @@ export function useCanvasEdgeInsertions({
|
||||
gapPx: EDGE_INSERT_GAP_PX,
|
||||
});
|
||||
|
||||
const reflowMoves = reflowPlan.moves.map((move) => ({
|
||||
nodeId: move.nodeId as Id<"nodes">,
|
||||
positionX: move.positionX,
|
||||
positionY: move.positionY,
|
||||
}));
|
||||
|
||||
if (reflowMoves.length > 0) {
|
||||
onReflowStateChange?.(true);
|
||||
try {
|
||||
applyLocalNodeMoves?.(reflowMoves);
|
||||
await runBatchMoveNodesMutation({
|
||||
moves: reflowMoves,
|
||||
});
|
||||
|
||||
if (reflowSettleMs > 0) {
|
||||
await waitForReflowSettle(reflowSettleMs);
|
||||
}
|
||||
} finally {
|
||||
onReflowStateChange?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceAfterMove = reflowPlan.sourcePosition
|
||||
? { ...sourceNode, position: reflowPlan.sourcePosition }
|
||||
: sourceNode;
|
||||
const targetAfterMove = reflowPlan.targetPosition
|
||||
? { ...targetNode, position: reflowPlan.targetPosition }
|
||||
: targetNode;
|
||||
|
||||
const layout = computeEdgeInsertLayout({
|
||||
sourceNode: sourceAfterMove,
|
||||
targetNode: targetAfterMove,
|
||||
newNodeWidth: width,
|
||||
newNodeHeight: height,
|
||||
gapPx: EDGE_INSERT_GAP_PX,
|
||||
});
|
||||
|
||||
await runCreateNodeWithEdgeSplitOnlineOnly({
|
||||
canvasId,
|
||||
type: template.type,
|
||||
@@ -170,47 +282,25 @@ export function useCanvasEdgeInsertions({
|
||||
splitTargetHandle: normalizeHandle(splitEdge.targetHandle),
|
||||
});
|
||||
|
||||
const moves: {
|
||||
nodeId: Id<"nodes">;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
}[] = [];
|
||||
|
||||
if (layout.sourcePosition) {
|
||||
moves.push({
|
||||
nodeId: sourceNode.id as Id<"nodes">,
|
||||
positionX: layout.sourcePosition.x,
|
||||
positionY: layout.sourcePosition.y,
|
||||
});
|
||||
}
|
||||
|
||||
if (layout.targetPosition) {
|
||||
moves.push({
|
||||
nodeId: targetNode.id as Id<"nodes">,
|
||||
positionX: layout.targetPosition.x,
|
||||
positionY: layout.targetPosition.y,
|
||||
});
|
||||
}
|
||||
|
||||
if (moves.length > 0) {
|
||||
await runBatchMoveNodesMutation({ moves });
|
||||
}
|
||||
|
||||
closeEdgeInsertMenu();
|
||||
},
|
||||
[
|
||||
canvasId,
|
||||
closeEdgeInsertMenu,
|
||||
edges,
|
||||
nodes,
|
||||
policyEdges,
|
||||
runBatchMoveNodesMutation,
|
||||
applyLocalNodeMoves,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
showConnectionRejectedToast,
|
||||
onReflowStateChange,
|
||||
reflowSettleMs,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
edgeInsertMenu,
|
||||
edgeInsertTemplates,
|
||||
openEdgeInsertMenu,
|
||||
closeEdgeInsertMenu,
|
||||
handleEdgeInsertPick,
|
||||
|
||||
@@ -1145,6 +1145,25 @@ export function useCanvasSyncEngine({
|
||||
],
|
||||
);
|
||||
|
||||
const remapOptimisticEdgeLocally = useCallback(
|
||||
(clientRequestId: string, realId: Id<"edges">): void => {
|
||||
const optimisticEdgeId = `${OPTIMISTIC_EDGE_PREFIX}${clientRequestId}`;
|
||||
const realEdgeId = realId as string;
|
||||
|
||||
setEdges((current) =>
|
||||
current.map((edge) =>
|
||||
edge.id === optimisticEdgeId
|
||||
? {
|
||||
...edge,
|
||||
id: realEdgeId,
|
||||
}
|
||||
: edge,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setEdges],
|
||||
);
|
||||
|
||||
const splitEdgeAtExistingNodeMut = useMutation(
|
||||
api.nodes.splitEdgeAtExistingNode,
|
||||
).withOptimisticUpdate((localStore, args) => {
|
||||
@@ -1488,11 +1507,6 @@ export function useCanvasSyncEngine({
|
||||
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||
const payload = { ...args, clientRequestId };
|
||||
|
||||
if (isSyncOnline) {
|
||||
await createEdge(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
addOptimisticEdgeLocally({
|
||||
clientRequestId,
|
||||
sourceNodeId: payload.sourceNodeId,
|
||||
@@ -1500,9 +1514,31 @@ export function useCanvasSyncEngine({
|
||||
sourceHandle: payload.sourceHandle,
|
||||
targetHandle: payload.targetHandle,
|
||||
});
|
||||
|
||||
if (isSyncOnline) {
|
||||
try {
|
||||
const realId = await createEdge(payload);
|
||||
remapOptimisticEdgeLocally(clientRequestId, realId);
|
||||
} catch (error) {
|
||||
removeOptimisticCreateLocally({
|
||||
clientRequestId,
|
||||
removeEdge: true,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await enqueueSyncMutation("createEdge", payload);
|
||||
},
|
||||
[addOptimisticEdgeLocally, createEdge, enqueueSyncMutation, isSyncOnline],
|
||||
[
|
||||
addOptimisticEdgeLocally,
|
||||
createEdge,
|
||||
enqueueSyncMutation,
|
||||
isSyncOnline,
|
||||
remapOptimisticEdgeLocally,
|
||||
removeOptimisticCreateLocally,
|
||||
],
|
||||
);
|
||||
|
||||
const runRemoveEdgeMutation = useCallback(
|
||||
@@ -1611,7 +1647,8 @@ export function useCanvasSyncEngine({
|
||||
);
|
||||
setEdgeSyncNonce((value) => value + 1);
|
||||
} else if (op.type === "createEdge") {
|
||||
await createEdgeRaw(op.payload);
|
||||
const realEdgeId = await createEdgeRaw(op.payload);
|
||||
remapOptimisticEdgeLocally(op.payload.clientRequestId, realEdgeId);
|
||||
} else if (op.type === "removeEdge") {
|
||||
await removeEdgeRaw(op.payload);
|
||||
} else if (op.type === "batchRemoveNodes") {
|
||||
@@ -1729,6 +1766,7 @@ export function useCanvasSyncEngine({
|
||||
moveNode,
|
||||
refreshPendingSyncCount,
|
||||
remapOptimisticNodeLocally,
|
||||
remapOptimisticEdgeLocally,
|
||||
removeEdgeRaw,
|
||||
removeOptimisticCreateLocally,
|
||||
resizeNode,
|
||||
|
||||
@@ -65,16 +65,27 @@ async function assertConnectionPolicy(
|
||||
throw new Error("Source or target node not found");
|
||||
}
|
||||
|
||||
const targetIncomingCount = await countIncomingEdges(ctx, {
|
||||
targetNodeId: args.targetNodeId,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
});
|
||||
|
||||
const reason = validateCanvasConnectionPolicy({
|
||||
sourceType: sourceNode.type,
|
||||
targetType: targetNode.type,
|
||||
targetIncomingCount: await countIncomingEdges(ctx, {
|
||||
targetNodeId: args.targetNodeId,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
}),
|
||||
targetIncomingCount,
|
||||
});
|
||||
|
||||
if (reason) {
|
||||
console.warn("[edges.create] connection policy rejected", {
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
targetNodeId: args.targetNodeId,
|
||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||
sourceType: sourceNode.type,
|
||||
targetType: targetNode.type,
|
||||
targetIncomingCount,
|
||||
reason,
|
||||
});
|
||||
throw new Error(getCanvasConnectionValidationMessage(reason));
|
||||
}
|
||||
}
|
||||
@@ -178,6 +189,13 @@ export const create = mutation({
|
||||
const source = await ctx.db.get(args.sourceNodeId);
|
||||
const target = await ctx.db.get(args.targetNodeId);
|
||||
if (!source || !target) {
|
||||
console.warn("[edges.create] missing source or target node", {
|
||||
canvasId: args.canvasId,
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
targetNodeId: args.targetNodeId,
|
||||
hasSource: Boolean(source),
|
||||
hasTarget: Boolean(target),
|
||||
});
|
||||
throw new Error("Source or target node not found");
|
||||
}
|
||||
if (source.canvasId !== args.canvasId || target.canvasId !== args.canvasId) {
|
||||
|
||||
@@ -26,6 +26,8 @@ const latestHandlersRef: {
|
||||
type HarnessProps = {
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
liveNodes?: RFNode[];
|
||||
liveEdges?: RFEdge[];
|
||||
runBatchRemoveNodesMutation: ReturnType<typeof vi.fn>;
|
||||
runCreateEdgeMutation: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
@@ -33,6 +35,11 @@ type HarnessProps = {
|
||||
function HookHarness(props: HarnessProps) {
|
||||
const deletingNodeIds = useRef(new Set<string>());
|
||||
const [, setAssetBrowserTargetNodeId] = useState<string | null>(null);
|
||||
const nodesRef = useRef<RFNode[]>(props.liveNodes ?? props.nodes);
|
||||
const edgesRef = useRef<RFEdge[]>(props.liveEdges ?? props.edges);
|
||||
|
||||
nodesRef.current = props.liveNodes ?? props.nodes;
|
||||
edgesRef.current = props.liveEdges ?? props.edges;
|
||||
|
||||
const handlers = useCanvasDeleteHandlers({
|
||||
t: ((key: string, values?: Record<string, unknown>) =>
|
||||
@@ -40,6 +47,8 @@ function HookHarness(props: HarnessProps) {
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes: props.nodes,
|
||||
edges: props.edges,
|
||||
nodesRef,
|
||||
edgesRef,
|
||||
deletingNodeIds,
|
||||
setAssetBrowserTargetNodeId,
|
||||
runBatchRemoveNodesMutation: props.runBatchRemoveNodesMutation,
|
||||
@@ -57,10 +66,14 @@ function HookHarness(props: HarnessProps) {
|
||||
describe("useCanvasDeleteHandlers", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
afterEach(async () => {
|
||||
latestHandlersRef.current = null;
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy?.mockRestore();
|
||||
consoleInfoSpy?.mockRestore();
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
@@ -132,4 +145,298 @@ describe("useCanvasDeleteHandlers", () => {
|
||||
targetHandle: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("logs bridge payload details when bridge edge creation fails", async () => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
|
||||
let resolveBatchRemove: (() => void) | null = null;
|
||||
const runBatchRemoveNodesMutation = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBatchRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const bridgeError = new Error("Render accepts only image input");
|
||||
const runCreateEdgeMutation = vi.fn(async () => {
|
||||
throw bridgeError;
|
||||
});
|
||||
|
||||
const imageNode: RFNode = { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} };
|
||||
const deletedNode: RFNode = {
|
||||
id: "node-color",
|
||||
type: "color-adjust",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const renderNode: RFNode = { id: "node-render", type: "render", position: { x: 400, y: 0 }, data: {} };
|
||||
|
||||
const edges: RFEdge[] = [
|
||||
{ id: "edge-in", source: "node-image", target: "node-color" },
|
||||
{ id: "edge-out", source: "node-color", target: "node-render" },
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
nodes: [imageNode, deletedNode, renderNode],
|
||||
edges,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([deletedNode]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"[Canvas] bridge edge create failed",
|
||||
expect.objectContaining({
|
||||
canvasId: "canvas-1",
|
||||
deletedNodeIds: ["node-color"],
|
||||
bridgeCreate: {
|
||||
sourceNodeId: "node-image",
|
||||
targetNodeId: "node-render",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
},
|
||||
error: bridgeError,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips invalid bridge edges that violate the connection policy", async () => {
|
||||
consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
|
||||
let resolveBatchRemove: (() => void) | null = null;
|
||||
const runBatchRemoveNodesMutation = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBatchRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
const imageNode: RFNode = { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} };
|
||||
const sourceCurvesNode: RFNode = {
|
||||
id: "node-curves-source",
|
||||
type: "curves",
|
||||
position: { x: 120, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const deletedNode: RFNode = {
|
||||
id: "node-curves-deleted",
|
||||
type: "curves",
|
||||
position: { x: 240, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const targetCurvesNode: RFNode = {
|
||||
id: "node-curves-target",
|
||||
type: "curves",
|
||||
position: { x: 360, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
|
||||
const edges: RFEdge[] = [
|
||||
{ id: "edge-image-target", source: "node-image", target: "node-curves-target" },
|
||||
{ id: "edge-source-deleted", source: "node-curves-source", target: "node-curves-deleted" },
|
||||
{ id: "edge-deleted-target", source: "node-curves-deleted", target: "node-curves-target" },
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
nodes: [imageNode, sourceCurvesNode, deletedNode, targetCurvesNode],
|
||||
edges,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([deletedNode]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
"[Canvas] skipped invalid bridge edge after delete",
|
||||
expect.objectContaining({
|
||||
canvasId: "canvas-1",
|
||||
deletedNodeIds: ["node-curves-deleted"],
|
||||
bridgeCreate: {
|
||||
sourceNodeId: "node-curves-source",
|
||||
targetNodeId: "node-curves-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
},
|
||||
validationError: "adjustment-incoming-limit",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses live graph refs to avoid creating duplicate bridge edges", async () => {
|
||||
let resolveBatchRemove: (() => void) | null = null;
|
||||
const runBatchRemoveNodesMutation = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBatchRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
const sourceNode: RFNode = {
|
||||
id: "node-light-adjust",
|
||||
type: "light-adjust",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const deletedNode: RFNode = {
|
||||
id: "node-middle",
|
||||
type: "color-adjust",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const renderNode: RFNode = {
|
||||
id: "node-render",
|
||||
type: "render",
|
||||
position: { x: 400, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
|
||||
const staleEdges: RFEdge[] = [
|
||||
{ id: "edge-source-middle", source: "node-light-adjust", target: "node-middle" },
|
||||
{ id: "edge-middle-render", source: "node-middle", target: "node-render" },
|
||||
];
|
||||
const liveEdges: RFEdge[] = [
|
||||
...staleEdges,
|
||||
{ id: "edge-source-render", source: "node-light-adjust", target: "node-render" },
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
nodes: [sourceNode, deletedNode, renderNode],
|
||||
edges: staleEdges,
|
||||
liveNodes: [sourceNode, deletedNode, renderNode],
|
||||
liveEdges,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([deletedNode]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips bridge edges when only an optimistic incoming edge already occupies the target", async () => {
|
||||
let resolveBatchRemove: (() => void) | null = null;
|
||||
const runBatchRemoveNodesMutation = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBatchRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
const sourceNode: RFNode = {
|
||||
id: "node-image-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const otherSourceNode: RFNode = {
|
||||
id: "node-image-other",
|
||||
type: "image",
|
||||
position: { x: 0, y: 120 },
|
||||
data: {},
|
||||
};
|
||||
const deletedNode: RFNode = {
|
||||
id: "node-middle-adjust",
|
||||
type: "curves",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const targetNode: RFNode = {
|
||||
id: "node-target-adjust",
|
||||
type: "color-adjust",
|
||||
position: { x: 400, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
|
||||
const liveEdges: RFEdge[] = [
|
||||
{ id: "edge-source-middle", source: "node-image-source", target: "node-middle-adjust" },
|
||||
{ id: "edge-middle-target", source: "node-middle-adjust", target: "node-target-adjust" },
|
||||
{
|
||||
id: "optimistic_edge_existing",
|
||||
source: "node-image-other",
|
||||
target: "node-target-adjust",
|
||||
},
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
nodes: [sourceNode, otherSourceNode, deletedNode, targetNode],
|
||||
edges: liveEdges,
|
||||
liveNodes: [sourceNode, otherSourceNode, deletedNode, targetNode],
|
||||
liveEdges,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([deletedNode]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user