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

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

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

View File

@@ -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>

View File

@@ -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({

View File

@@ -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,

View File

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

View File

@@ -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

View File

@@ -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!" />

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {

View File

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