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));
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path {
|
.react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-flow.canvas-edge-insert-reflowing .react-flow__node {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
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 {
|
import {
|
||||||
buildGraphSnapshot,
|
buildGraphSnapshot,
|
||||||
pruneCanvasGraphNodeDataOverrides,
|
pruneCanvasGraphNodeDataOverrides,
|
||||||
@@ -414,3 +418,52 @@ describe("computeEdgeInsertLayout", () => {
|
|||||||
expect(layout.targetPosition).toBeUndefined();
|
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 { Id } from "@/convex/_generated/dataModel";
|
||||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
import { useCanvasEdgeInsertions } from "@/components/canvas/use-canvas-edge-insertions";
|
import { useCanvasEdgeInsertions } from "@/components/canvas/use-canvas-edge-insertions";
|
||||||
|
import { computeEdgeInsertLayout } from "@/components/canvas/canvas-helpers";
|
||||||
|
|
||||||
const latestHandlersRef: {
|
const latestHandlersRef: {
|
||||||
current: ReturnType<typeof useCanvasEdgeInsertions> | null;
|
current: ReturnType<typeof useCanvasEdgeInsertions> | null;
|
||||||
@@ -40,7 +41,10 @@ type HookHarnessProps = {
|
|||||||
edges: RFEdge[];
|
edges: RFEdge[];
|
||||||
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
|
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
|
||||||
runBatchMoveNodesMutation?: ReturnType<typeof vi.fn>;
|
runBatchMoveNodesMutation?: ReturnType<typeof vi.fn>;
|
||||||
|
applyLocalNodeMoves?: ReturnType<typeof vi.fn>;
|
||||||
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
||||||
|
onReflowStateChange?: (isReflowing: boolean) => void;
|
||||||
|
reflowSettleMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function HookHarness({
|
function HookHarness({
|
||||||
@@ -48,16 +52,24 @@ function HookHarness({
|
|||||||
edges,
|
edges,
|
||||||
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new"),
|
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new"),
|
||||||
runBatchMoveNodesMutation = vi.fn(async () => undefined),
|
runBatchMoveNodesMutation = vi.fn(async () => undefined),
|
||||||
|
applyLocalNodeMoves,
|
||||||
showConnectionRejectedToast = vi.fn(),
|
showConnectionRejectedToast = vi.fn(),
|
||||||
|
onReflowStateChange,
|
||||||
|
reflowSettleMs = 0,
|
||||||
}: HookHarnessProps) {
|
}: HookHarnessProps) {
|
||||||
const handlers = useCanvasEdgeInsertions({
|
const hookArgs = {
|
||||||
canvasId: asCanvasId("canvas-1"),
|
canvasId: asCanvasId("canvas-1"),
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||||
runBatchMoveNodesMutation,
|
runBatchMoveNodesMutation,
|
||||||
|
applyLocalNodeMoves,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
});
|
onReflowStateChange,
|
||||||
|
reflowSettleMs,
|
||||||
|
} as Parameters<typeof useCanvasEdgeInsertions>[0];
|
||||||
|
|
||||||
|
const handlers = useCanvasEdgeInsertions(hookArgs);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestHandlersRef.current = handlers;
|
latestHandlersRef.current = handlers;
|
||||||
@@ -156,6 +168,72 @@ describe("useCanvasEdgeInsertions", () => {
|
|||||||
expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull();
|
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 () => {
|
it("shows toast and skips create when split validation fails", async () => {
|
||||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
||||||
@@ -269,9 +347,18 @@ describe("useCanvasEdgeInsertions", () => {
|
|||||||
expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull();
|
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 runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
|
||||||
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
|
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");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -281,10 +368,16 @@ describe("useCanvasEdgeInsertions", () => {
|
|||||||
root?.render(
|
root?.render(
|
||||||
<HookHarness
|
<HookHarness
|
||||||
nodes={[
|
nodes={[
|
||||||
|
createNode({ id: "upstream", type: "image", position: { x: -120, y: 0 } }),
|
||||||
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
|
||||||
createNode({ id: "target", type: "text", position: { x: 120, 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}
|
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||||
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
|
||||||
/>,
|
/>,
|
||||||
@@ -308,9 +401,343 @@ describe("useCanvasEdgeInsertions", () => {
|
|||||||
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1);
|
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1);
|
||||||
expect(runBatchMoveNodesMutation).toHaveBeenCalledWith({
|
expect(runBatchMoveNodesMutation).toHaveBeenCalledWith({
|
||||||
moves: [
|
moves: [
|
||||||
|
{ nodeId: "upstream", positionX: -230, positionY: 0 },
|
||||||
{ nodeId: "source", positionX: -110, positionY: 0 },
|
{ nodeId: "source", positionX: -110, positionY: 0 },
|
||||||
{ nodeId: "target", positionX: 230, 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;
|
latestHookValueRef.current = hookValue;
|
||||||
}, [hookValue]);
|
}, [hookValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestEdgesRef.current = edges;
|
||||||
|
}, [edges]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const latestEdgesRef: { current: RFEdge[] } = { current: [] };
|
||||||
|
|
||||||
|
function setNavigatorOnline(online: boolean) {
|
||||||
|
Object.defineProperty(window.navigator, "onLine", {
|
||||||
|
configurable: true,
|
||||||
|
value: online,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("useCanvasSyncEngine hook wiring", () => {
|
describe("useCanvasSyncEngine hook wiring", () => {
|
||||||
let container: HTMLDivElement | null = null;
|
let container: HTMLDivElement | null = null;
|
||||||
let root: Root | null = null;
|
let root: Root | null = null;
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
latestHookValueRef.current = null;
|
latestHookValueRef.current = null;
|
||||||
|
latestEdgesRef.current = [];
|
||||||
|
setNavigatorOnline(true);
|
||||||
mocks.mutationMocks.clear();
|
mocks.mutationMocks.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
if (root) {
|
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;
|
anchor: CanvasMenuAnchor | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (template: CanvasNodeTemplate) => void;
|
onPick: (template: CanvasNodeTemplate) => void;
|
||||||
|
templates?: readonly CanvasNodeTemplate[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const PANEL_MAX_W = 360;
|
const PANEL_MAX_W = 360;
|
||||||
@@ -41,6 +42,7 @@ export function CanvasConnectionDropMenu({
|
|||||||
anchor,
|
anchor,
|
||||||
onClose,
|
onClose,
|
||||||
onPick,
|
onPick,
|
||||||
|
templates,
|
||||||
}: CanvasConnectionDropMenuProps) {
|
}: CanvasConnectionDropMenuProps) {
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ export function CanvasConnectionDropMenu({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
groupHeading="Knoten"
|
groupHeading="Knoten"
|
||||||
|
templates={templates}
|
||||||
/>
|
/>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import {
|
|||||||
type CanvasConnectionValidationReason,
|
type CanvasConnectionValidationReason,
|
||||||
} from "@/lib/canvas-connection-policy";
|
} from "@/lib/canvas-connection-policy";
|
||||||
|
|
||||||
|
import { isOptimisticEdgeId } from "./canvas-helpers";
|
||||||
|
|
||||||
export function validateCanvasConnection(
|
export function validateCanvasConnection(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
nodes: RFNode[],
|
nodes: RFNode[],
|
||||||
edges: RFEdge[],
|
edges: RFEdge[],
|
||||||
edgeToReplaceId?: string,
|
edgeToReplaceId?: string,
|
||||||
|
options?: {
|
||||||
|
includeOptimisticEdges?: boolean;
|
||||||
|
},
|
||||||
): CanvasConnectionValidationReason | null {
|
): CanvasConnectionValidationReason | null {
|
||||||
if (!connection.source || !connection.target) return "incomplete";
|
if (!connection.source || !connection.target) return "incomplete";
|
||||||
if (connection.source === connection.target) return "self-loop";
|
if (connection.source === connection.target) return "self-loop";
|
||||||
@@ -24,6 +29,7 @@ export function validateCanvasConnection(
|
|||||||
targetNodeId: connection.target,
|
targetNodeId: connection.target,
|
||||||
edges,
|
edges,
|
||||||
edgeToReplaceId,
|
edgeToReplaceId,
|
||||||
|
includeOptimisticEdges: options?.includeOptimisticEdges,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,9 +39,14 @@ export function validateCanvasConnectionByType(args: {
|
|||||||
targetNodeId: string;
|
targetNodeId: string;
|
||||||
edges: RFEdge[];
|
edges: RFEdge[];
|
||||||
edgeToReplaceId?: string;
|
edgeToReplaceId?: string;
|
||||||
|
includeOptimisticEdges?: boolean;
|
||||||
}): CanvasConnectionValidationReason | null {
|
}): CanvasConnectionValidationReason | null {
|
||||||
const targetIncomingCount = args.edges.filter(
|
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;
|
).length;
|
||||||
|
|
||||||
return validateCanvasConnectionPolicy({
|
return validateCanvasConnectionPolicy({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { toast } from "@/lib/toast";
|
|||||||
import { type CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
import { type CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
||||||
|
|
||||||
import { getNodeDeleteBlockReason } from "./canvas-helpers";
|
import { getNodeDeleteBlockReason } from "./canvas-helpers";
|
||||||
|
import { validateCanvasConnection } from "./canvas-connection-validation";
|
||||||
|
|
||||||
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
|
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ type UseCanvasDeleteHandlersParams = {
|
|||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
nodes: RFNode[];
|
nodes: RFNode[];
|
||||||
edges: RFEdge[];
|
edges: RFEdge[];
|
||||||
|
nodesRef: MutableRefObject<RFNode[]>;
|
||||||
|
edgesRef: MutableRefObject<RFEdge[]>;
|
||||||
deletingNodeIds: MutableRefObject<Set<string>>;
|
deletingNodeIds: MutableRefObject<Set<string>>;
|
||||||
setAssetBrowserTargetNodeId: Dispatch<SetStateAction<string | null>>;
|
setAssetBrowserTargetNodeId: Dispatch<SetStateAction<string | null>>;
|
||||||
runBatchRemoveNodesMutation: (args: { nodeIds: Id<"nodes">[] }) => Promise<unknown>;
|
runBatchRemoveNodesMutation: (args: { nodeIds: Id<"nodes">[] }) => Promise<unknown>;
|
||||||
@@ -40,6 +43,8 @@ export function useCanvasDeleteHandlers({
|
|||||||
canvasId,
|
canvasId,
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
|
nodesRef,
|
||||||
|
edgesRef,
|
||||||
deletingNodeIds,
|
deletingNodeIds,
|
||||||
setAssetBrowserTargetNodeId,
|
setAssetBrowserTargetNodeId,
|
||||||
runBatchRemoveNodesMutation,
|
runBatchRemoveNodesMutation,
|
||||||
@@ -50,6 +55,12 @@ export function useCanvasDeleteHandlers({
|
|||||||
onNodesDelete: (deletedNodes: RFNode[]) => void;
|
onNodesDelete: (deletedNodes: RFNode[]) => void;
|
||||||
onEdgesDelete: (deletedEdges: RFEdge[]) => 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(
|
const onBeforeDelete = useCallback(
|
||||||
async ({
|
async ({
|
||||||
nodes: matchingNodes,
|
nodes: matchingNodes,
|
||||||
@@ -117,11 +128,33 @@ export function useCanvasDeleteHandlers({
|
|||||||
current !== null && removedTargetSet.has(current) ? null : current,
|
current !== null && removedTargetSet.has(current) ? null : current,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const liveNodes = nodesRef.current;
|
||||||
|
const liveEdges = edgesRef.current;
|
||||||
|
|
||||||
const bridgeCreates = computeBridgeCreatesForDeletedNodes(
|
const bridgeCreates = computeBridgeCreatesForDeletedNodes(
|
||||||
deletedNodes,
|
deletedNodes,
|
||||||
nodes,
|
liveNodes,
|
||||||
edges,
|
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 () => {
|
void (async () => {
|
||||||
await runBatchRemoveNodesMutation({
|
await runBatchRemoveNodesMutation({
|
||||||
@@ -129,6 +162,51 @@ export function useCanvasDeleteHandlers({
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const bridgeCreate of bridgeCreates) {
|
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({
|
await runCreateEdgeMutation({
|
||||||
canvasId,
|
canvasId,
|
||||||
sourceNodeId: bridgeCreate.sourceNodeId,
|
sourceNodeId: bridgeCreate.sourceNodeId,
|
||||||
@@ -136,6 +214,25 @@ export function useCanvasDeleteHandlers({
|
|||||||
sourceHandle: bridgeCreate.sourceHandle,
|
sourceHandle: bridgeCreate.sourceHandle,
|
||||||
targetHandle: bridgeCreate.targetHandle,
|
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(() => {
|
.then(() => {
|
||||||
@@ -156,8 +253,11 @@ export function useCanvasDeleteHandlers({
|
|||||||
t,
|
t,
|
||||||
canvasId,
|
canvasId,
|
||||||
deletingNodeIds,
|
deletingNodeIds,
|
||||||
|
edgeKey,
|
||||||
edges,
|
edges,
|
||||||
|
edgesRef,
|
||||||
nodes,
|
nodes,
|
||||||
|
nodesRef,
|
||||||
runBatchRemoveNodesMutation,
|
runBatchRemoveNodesMutation,
|
||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
setAssetBrowserTargetNodeId,
|
setAssetBrowserTargetNodeId,
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ export type EdgeInsertLayout = {
|
|||||||
targetPosition?: XYPosition;
|
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 {
|
function readNodeDimension(node: RFNode, key: "width" | "height"): number | null {
|
||||||
const nodeRecord = node as { width?: unknown; height?: unknown };
|
const nodeRecord = node as { width?: unknown; height?: unknown };
|
||||||
const direct = nodeRecord[key];
|
const direct = nodeRecord[key];
|
||||||
@@ -127,6 +139,131 @@ export function computeEdgeInsertLayout(args: ComputeEdgeInsertLayoutArgs): Edge
|
|||||||
return layout;
|
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 {
|
export function createCanvasOpId(): string {
|
||||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const NODE_SEARCH_KEYWORDS: Partial<
|
|||||||
export type CanvasNodeTemplatePickerProps = {
|
export type CanvasNodeTemplatePickerProps = {
|
||||||
onPick: (template: CanvasNodeTemplate) => void;
|
onPick: (template: CanvasNodeTemplate) => void;
|
||||||
groupHeading?: string;
|
groupHeading?: string;
|
||||||
|
templates?: readonly CanvasNodeTemplate[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,10 +71,11 @@ export type CanvasNodeTemplatePickerProps = {
|
|||||||
export function CanvasNodeTemplatePicker({
|
export function CanvasNodeTemplatePicker({
|
||||||
onPick,
|
onPick,
|
||||||
groupHeading = "Knoten",
|
groupHeading = "Knoten",
|
||||||
|
templates = CANVAS_NODE_TEMPLATES,
|
||||||
}: CanvasNodeTemplatePickerProps) {
|
}: CanvasNodeTemplatePickerProps) {
|
||||||
return (
|
return (
|
||||||
<CommandGroup heading={groupHeading}>
|
<CommandGroup heading={groupHeading}>
|
||||||
{CANVAS_NODE_TEMPLATES.map((template) => {
|
{templates.map((template) => {
|
||||||
const Icon = NODE_ICONS[template.type];
|
const Icon = NODE_ICONS[template.type];
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type CSSProperties,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -81,6 +82,8 @@ interface CanvasInnerProps {
|
|||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EDGE_INSERT_REFLOW_SETTLE_MS = 1297;
|
||||||
|
|
||||||
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||||
const t = useTranslations('toasts');
|
const t = useTranslations('toasts');
|
||||||
const showConnectionRejectedToast = useCallback(
|
const showConnectionRejectedToast = useCallback(
|
||||||
@@ -154,6 +157,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const nodesRef = useRef<RFNode[]>(nodes);
|
const nodesRef = useRef<RFNode[]>(nodes);
|
||||||
|
|
||||||
const [scissorsMode, setScissorsMode] = useState(false);
|
const [scissorsMode, setScissorsMode] = useState(false);
|
||||||
|
const [isEdgeInsertReflowing, setIsEdgeInsertReflowing] = useState(false);
|
||||||
const [scissorStrokePreview, setScissorStrokePreview] = useState<
|
const [scissorStrokePreview, setScissorStrokePreview] = useState<
|
||||||
{ x: number; y: number }[] | null
|
{ x: number; y: number }[] | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -300,6 +304,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
canvasId,
|
canvasId,
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
|
nodesRef,
|
||||||
|
edgesRef,
|
||||||
deletingNodeIds,
|
deletingNodeIds,
|
||||||
setAssetBrowserTargetNodeId,
|
setAssetBrowserTargetNodeId,
|
||||||
runBatchRemoveNodesMutation,
|
runBatchRemoveNodesMutation,
|
||||||
@@ -339,8 +345,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
showConnectionRejectedToast,
|
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 {
|
const {
|
||||||
edgeInsertMenu,
|
edgeInsertMenu,
|
||||||
|
edgeInsertTemplates,
|
||||||
closeEdgeInsertMenu,
|
closeEdgeInsertMenu,
|
||||||
openEdgeInsertMenu,
|
openEdgeInsertMenu,
|
||||||
handleEdgeInsertPick,
|
handleEdgeInsertPick,
|
||||||
@@ -350,7 +394,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
edges,
|
edges,
|
||||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||||
runBatchMoveNodesMutation,
|
runBatchMoveNodesMutation,
|
||||||
|
applyLocalNodeMoves: applyLocalEdgeInsertMoves,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
|
onReflowStateChange: setIsEdgeInsertReflowing,
|
||||||
|
reflowSettleMs: EDGE_INSERT_REFLOW_SETTLE_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleEdgeInsertClick = useCallback(
|
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({
|
const edgeTypes = useCanvasEdgeTypes({
|
||||||
edgeInsertMenuEdgeId: edgeInsertMenu?.edgeId ?? null,
|
edgeInsertMenuEdgeId: edgeInsertMenu?.edgeId ?? null,
|
||||||
scissorsMode,
|
scissorsMode,
|
||||||
@@ -545,6 +599,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
onClose={closeEdgeInsertMenu}
|
onClose={closeEdgeInsertMenu}
|
||||||
onPick={handleEdgeInsertPick}
|
onPick={handleEdgeInsertPick}
|
||||||
|
templates={edgeInsertTemplates}
|
||||||
/>
|
/>
|
||||||
{scissorsMode ? (
|
{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">
|
<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}>
|
<CanvasGraphProvider nodes={canvasGraphNodes} edges={canvasGraphEdges}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
style={edgeInsertReflowStyle}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onlyRenderVisibleElements
|
onlyRenderVisibleElements
|
||||||
@@ -614,7 +670,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
panActivationKeyCode="Space"
|
panActivationKeyCode="Space"
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
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} />
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||||
<Controls className="bg-card! border! shadow-sm! rounded-lg!" />
|
<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 { Id } from "@/convex/_generated/dataModel";
|
||||||
import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-policy";
|
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 type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
computeEdgeInsertReflowPlan,
|
||||||
computeEdgeInsertLayout,
|
computeEdgeInsertLayout,
|
||||||
hasHandleKey,
|
hasHandleKey,
|
||||||
isOptimisticEdgeId,
|
isOptimisticEdgeId,
|
||||||
normalizeHandle,
|
normalizeHandle,
|
||||||
|
rfEdgeConnectionSignature,
|
||||||
} from "./canvas-helpers";
|
} from "./canvas-helpers";
|
||||||
import { validateCanvasEdgeSplit } from "./canvas-connection-validation";
|
import { validateCanvasEdgeSplit } from "./canvas-connection-validation";
|
||||||
|
|
||||||
@@ -22,6 +27,13 @@ export type EdgeInsertMenuState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EDGE_INSERT_GAP_PX = 10;
|
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 = {
|
type UseCanvasEdgeInsertionsArgs = {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
@@ -49,7 +61,16 @@ type UseCanvasEdgeInsertionsArgs = {
|
|||||||
positionY: number;
|
positionY: number;
|
||||||
}[];
|
}[];
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
applyLocalNodeMoves?: (
|
||||||
|
moves: {
|
||||||
|
nodeId: Id<"nodes">;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
}[],
|
||||||
|
) => void;
|
||||||
showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void;
|
showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void;
|
||||||
|
onReflowStateChange?: (isReflowing: boolean) => void;
|
||||||
|
reflowSettleMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCanvasEdgeInsertions({
|
export function useCanvasEdgeInsertions({
|
||||||
@@ -58,11 +79,18 @@ export function useCanvasEdgeInsertions({
|
|||||||
edges,
|
edges,
|
||||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||||
runBatchMoveNodesMutation,
|
runBatchMoveNodesMutation,
|
||||||
|
applyLocalNodeMoves,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
|
onReflowStateChange,
|
||||||
|
reflowSettleMs = DEFAULT_REFLOW_SETTLE_MS,
|
||||||
}: UseCanvasEdgeInsertionsArgs) {
|
}: UseCanvasEdgeInsertionsArgs) {
|
||||||
const [edgeInsertMenu, setEdgeInsertMenu] = useState<EdgeInsertMenuState | null>(null);
|
const [edgeInsertMenu, setEdgeInsertMenu] = useState<EdgeInsertMenuState | null>(null);
|
||||||
const edgeInsertMenuRef = useRef<EdgeInsertMenuState | null>(null);
|
const edgeInsertMenuRef = useRef<EdgeInsertMenuState | null>(null);
|
||||||
|
|
||||||
|
const policyEdges = edges.filter(
|
||||||
|
(edge) => edge.className !== "temp" && !isOptimisticEdgeId(edge.id),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
edgeInsertMenuRef.current = edgeInsertMenu;
|
edgeInsertMenuRef.current = edgeInsertMenu;
|
||||||
}, [edgeInsertMenu]);
|
}, [edgeInsertMenu]);
|
||||||
@@ -73,20 +101,67 @@ export function useCanvasEdgeInsertions({
|
|||||||
|
|
||||||
const openEdgeInsertMenu = useCallback(
|
const openEdgeInsertMenu = useCallback(
|
||||||
({ edgeId, screenX, screenY }: EdgeInsertMenuState) => {
|
({ edgeId, screenX, screenY }: EdgeInsertMenuState) => {
|
||||||
const edge = edges.find(
|
const clickedEdge = edges.find(
|
||||||
(candidate) =>
|
(candidate) => candidate.id === edgeId && candidate.className !== "temp",
|
||||||
candidate.id === edgeId &&
|
|
||||||
candidate.className !== "temp" &&
|
|
||||||
!isOptimisticEdgeId(candidate.id),
|
|
||||||
);
|
);
|
||||||
if (!edge) {
|
if (!clickedEdge) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEdgeInsertMenu({ edgeId, screenX, screenY });
|
let resolvedEdgeId: string | null = null;
|
||||||
},
|
if (!isOptimisticEdgeId(edgeId)) {
|
||||||
[edges],
|
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(
|
const handleEdgeInsertPick = useCallback(
|
||||||
async (template: CanvasNodeTemplate) => {
|
async (template: CanvasNodeTemplate) => {
|
||||||
@@ -95,10 +170,7 @@ export function useCanvasEdgeInsertions({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitEdge = edges.find(
|
const splitEdge = policyEdges.find((edge) => edge.id === menu.edgeId);
|
||||||
(edge) =>
|
|
||||||
edge.id === menu.edgeId && edge.className !== "temp" && !isOptimisticEdgeId(edge.id),
|
|
||||||
);
|
|
||||||
if (!splitEdge) {
|
if (!splitEdge) {
|
||||||
showConnectionRejectedToast("unknown-node");
|
showConnectionRejectedToast("unknown-node");
|
||||||
return;
|
return;
|
||||||
@@ -133,7 +205,7 @@ export function useCanvasEdgeInsertions({
|
|||||||
|
|
||||||
const splitValidationError = validateCanvasEdgeSplit({
|
const splitValidationError = validateCanvasEdgeSplit({
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges: policyEdges,
|
||||||
splitEdge,
|
splitEdge,
|
||||||
middleNode,
|
middleNode,
|
||||||
});
|
});
|
||||||
@@ -143,7 +215,10 @@ export function useCanvasEdgeInsertions({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout = computeEdgeInsertLayout({
|
const reflowPlan = computeEdgeInsertReflowPlan({
|
||||||
|
nodes,
|
||||||
|
edges: policyEdges,
|
||||||
|
splitEdge,
|
||||||
sourceNode,
|
sourceNode,
|
||||||
targetNode,
|
targetNode,
|
||||||
newNodeWidth: width,
|
newNodeWidth: width,
|
||||||
@@ -151,6 +226,43 @@ export function useCanvasEdgeInsertions({
|
|||||||
gapPx: EDGE_INSERT_GAP_PX,
|
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({
|
await runCreateNodeWithEdgeSplitOnlineOnly({
|
||||||
canvasId,
|
canvasId,
|
||||||
type: template.type,
|
type: template.type,
|
||||||
@@ -170,47 +282,25 @@ export function useCanvasEdgeInsertions({
|
|||||||
splitTargetHandle: normalizeHandle(splitEdge.targetHandle),
|
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();
|
closeEdgeInsertMenu();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
canvasId,
|
canvasId,
|
||||||
closeEdgeInsertMenu,
|
closeEdgeInsertMenu,
|
||||||
edges,
|
|
||||||
nodes,
|
nodes,
|
||||||
|
policyEdges,
|
||||||
runBatchMoveNodesMutation,
|
runBatchMoveNodesMutation,
|
||||||
|
applyLocalNodeMoves,
|
||||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
|
onReflowStateChange,
|
||||||
|
reflowSettleMs,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
edgeInsertMenu,
|
edgeInsertMenu,
|
||||||
|
edgeInsertTemplates,
|
||||||
openEdgeInsertMenu,
|
openEdgeInsertMenu,
|
||||||
closeEdgeInsertMenu,
|
closeEdgeInsertMenu,
|
||||||
handleEdgeInsertPick,
|
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(
|
const splitEdgeAtExistingNodeMut = useMutation(
|
||||||
api.nodes.splitEdgeAtExistingNode,
|
api.nodes.splitEdgeAtExistingNode,
|
||||||
).withOptimisticUpdate((localStore, args) => {
|
).withOptimisticUpdate((localStore, args) => {
|
||||||
@@ -1488,11 +1507,6 @@ export function useCanvasSyncEngine({
|
|||||||
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||||
const payload = { ...args, clientRequestId };
|
const payload = { ...args, clientRequestId };
|
||||||
|
|
||||||
if (isSyncOnline) {
|
|
||||||
await createEdge(payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addOptimisticEdgeLocally({
|
addOptimisticEdgeLocally({
|
||||||
clientRequestId,
|
clientRequestId,
|
||||||
sourceNodeId: payload.sourceNodeId,
|
sourceNodeId: payload.sourceNodeId,
|
||||||
@@ -1500,9 +1514,31 @@ export function useCanvasSyncEngine({
|
|||||||
sourceHandle: payload.sourceHandle,
|
sourceHandle: payload.sourceHandle,
|
||||||
targetHandle: payload.targetHandle,
|
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);
|
await enqueueSyncMutation("createEdge", payload);
|
||||||
},
|
},
|
||||||
[addOptimisticEdgeLocally, createEdge, enqueueSyncMutation, isSyncOnline],
|
[
|
||||||
|
addOptimisticEdgeLocally,
|
||||||
|
createEdge,
|
||||||
|
enqueueSyncMutation,
|
||||||
|
isSyncOnline,
|
||||||
|
remapOptimisticEdgeLocally,
|
||||||
|
removeOptimisticCreateLocally,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runRemoveEdgeMutation = useCallback(
|
const runRemoveEdgeMutation = useCallback(
|
||||||
@@ -1611,7 +1647,8 @@ export function useCanvasSyncEngine({
|
|||||||
);
|
);
|
||||||
setEdgeSyncNonce((value) => value + 1);
|
setEdgeSyncNonce((value) => value + 1);
|
||||||
} else if (op.type === "createEdge") {
|
} 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") {
|
} else if (op.type === "removeEdge") {
|
||||||
await removeEdgeRaw(op.payload);
|
await removeEdgeRaw(op.payload);
|
||||||
} else if (op.type === "batchRemoveNodes") {
|
} else if (op.type === "batchRemoveNodes") {
|
||||||
@@ -1729,6 +1766,7 @@ export function useCanvasSyncEngine({
|
|||||||
moveNode,
|
moveNode,
|
||||||
refreshPendingSyncCount,
|
refreshPendingSyncCount,
|
||||||
remapOptimisticNodeLocally,
|
remapOptimisticNodeLocally,
|
||||||
|
remapOptimisticEdgeLocally,
|
||||||
removeEdgeRaw,
|
removeEdgeRaw,
|
||||||
removeOptimisticCreateLocally,
|
removeOptimisticCreateLocally,
|
||||||
resizeNode,
|
resizeNode,
|
||||||
|
|||||||
@@ -65,16 +65,27 @@ async function assertConnectionPolicy(
|
|||||||
throw new Error("Source or target node not found");
|
throw new Error("Source or target node not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetIncomingCount = await countIncomingEdges(ctx, {
|
||||||
|
targetNodeId: args.targetNodeId,
|
||||||
|
edgeIdToIgnore: args.edgeIdToIgnore,
|
||||||
|
});
|
||||||
|
|
||||||
const reason = validateCanvasConnectionPolicy({
|
const reason = validateCanvasConnectionPolicy({
|
||||||
sourceType: sourceNode.type,
|
sourceType: sourceNode.type,
|
||||||
targetType: targetNode.type,
|
targetType: targetNode.type,
|
||||||
targetIncomingCount: await countIncomingEdges(ctx, {
|
targetIncomingCount,
|
||||||
targetNodeId: args.targetNodeId,
|
|
||||||
edgeIdToIgnore: args.edgeIdToIgnore,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (reason) {
|
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));
|
throw new Error(getCanvasConnectionValidationMessage(reason));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,6 +189,13 @@ export const create = mutation({
|
|||||||
const source = await ctx.db.get(args.sourceNodeId);
|
const source = await ctx.db.get(args.sourceNodeId);
|
||||||
const target = await ctx.db.get(args.targetNodeId);
|
const target = await ctx.db.get(args.targetNodeId);
|
||||||
if (!source || !target) {
|
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");
|
throw new Error("Source or target node not found");
|
||||||
}
|
}
|
||||||
if (source.canvasId !== args.canvasId || target.canvasId !== args.canvasId) {
|
if (source.canvasId !== args.canvasId || target.canvasId !== args.canvasId) {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const latestHandlersRef: {
|
|||||||
type HarnessProps = {
|
type HarnessProps = {
|
||||||
nodes: RFNode[];
|
nodes: RFNode[];
|
||||||
edges: RFEdge[];
|
edges: RFEdge[];
|
||||||
|
liveNodes?: RFNode[];
|
||||||
|
liveEdges?: RFEdge[];
|
||||||
runBatchRemoveNodesMutation: ReturnType<typeof vi.fn>;
|
runBatchRemoveNodesMutation: ReturnType<typeof vi.fn>;
|
||||||
runCreateEdgeMutation: ReturnType<typeof vi.fn>;
|
runCreateEdgeMutation: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
@@ -33,6 +35,11 @@ type HarnessProps = {
|
|||||||
function HookHarness(props: HarnessProps) {
|
function HookHarness(props: HarnessProps) {
|
||||||
const deletingNodeIds = useRef(new Set<string>());
|
const deletingNodeIds = useRef(new Set<string>());
|
||||||
const [, setAssetBrowserTargetNodeId] = useState<string | null>(null);
|
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({
|
const handlers = useCanvasDeleteHandlers({
|
||||||
t: ((key: string, values?: Record<string, unknown>) =>
|
t: ((key: string, values?: Record<string, unknown>) =>
|
||||||
@@ -40,6 +47,8 @@ function HookHarness(props: HarnessProps) {
|
|||||||
canvasId: asCanvasId("canvas-1"),
|
canvasId: asCanvasId("canvas-1"),
|
||||||
nodes: props.nodes,
|
nodes: props.nodes,
|
||||||
edges: props.edges,
|
edges: props.edges,
|
||||||
|
nodesRef,
|
||||||
|
edgesRef,
|
||||||
deletingNodeIds,
|
deletingNodeIds,
|
||||||
setAssetBrowserTargetNodeId,
|
setAssetBrowserTargetNodeId,
|
||||||
runBatchRemoveNodesMutation: props.runBatchRemoveNodesMutation,
|
runBatchRemoveNodesMutation: props.runBatchRemoveNodesMutation,
|
||||||
@@ -57,10 +66,14 @@ function HookHarness(props: HarnessProps) {
|
|||||||
describe("useCanvasDeleteHandlers", () => {
|
describe("useCanvasDeleteHandlers", () => {
|
||||||
let container: HTMLDivElement | null = null;
|
let container: HTMLDivElement | null = null;
|
||||||
let root: Root | null = null;
|
let root: Root | null = null;
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let consoleInfoSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
latestHandlersRef.current = null;
|
latestHandlersRef.current = null;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
consoleErrorSpy?.mockRestore();
|
||||||
|
consoleInfoSpy?.mockRestore();
|
||||||
if (root) {
|
if (root) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
@@ -132,4 +145,298 @@ describe("useCanvasDeleteHandlers", () => {
|
|||||||
targetHandle: undefined,
|
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