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:
2026-04-05 23:25:26 +02:00
parent 7c34da45b4
commit fa6a41f775
14 changed files with 1477 additions and 67 deletions

View File

@@ -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 },
]);
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});