feat(canvas): implement edge insertion reflow and enhance connection validation

- Introduced a new CSS transition for edge insertion reflowing to improve visual feedback during node adjustments.
- Enhanced the connection validation logic to include options for optimistic edges, ensuring better handling of edge creation scenarios.
- Updated the canvas connection drop menu to support additional templates and improved edge insertion handling.
- Refactored edge insertion logic to accommodate local node position adjustments during reflow operations.
- Added tests for new edge insertion features and connection validation improvements.
This commit is contained in:
2026-04-05 23:25:26 +02:00
parent 7c34da45b4
commit fa6a41f775
14 changed files with 1477 additions and 67 deletions

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { computeEdgeInsertLayout, withResolvedCompareData } from "../canvas-helpers";
import {
computeEdgeInsertLayout,
computeEdgeInsertReflowPlan,
withResolvedCompareData,
} from "../canvas-helpers";
import {
buildGraphSnapshot,
pruneCanvasGraphNodeDataOverrides,
@@ -414,3 +418,52 @@ describe("computeEdgeInsertLayout", () => {
expect(layout.targetPosition).toBeUndefined();
});
});
describe("computeEdgeInsertReflowPlan", () => {
it("propagates source and target shifts across full upstream/downstream chains", () => {
const upstream = createNode({
id: "upstream",
position: { x: -120, y: 0 },
style: { width: 100, height: 60 },
});
const source = createNode({
id: "source",
position: { x: 0, y: 0 },
style: { width: 100, height: 60 },
});
const target = createNode({
id: "target",
position: { x: 120, y: 0 },
style: { width: 100, height: 60 },
});
const downstream = createNode({
id: "downstream",
position: { x: 240, y: 0 },
style: { width: 100, height: 60 },
});
const edges = [
createEdge({ id: "edge-upstream", source: "upstream", target: "source" }),
createEdge({ id: "edge-split", source: "source", target: "target" }),
createEdge({ id: "edge-downstream", source: "target", target: "downstream" }),
];
const plan = computeEdgeInsertReflowPlan({
nodes: [upstream, source, target, downstream],
edges,
splitEdge: edges[1],
sourceNode: source,
targetNode: target,
newNodeWidth: 220,
newNodeHeight: 120,
gapPx: 10,
});
expect(plan.moves).toEqual([
{ nodeId: "upstream", positionX: -230, positionY: 0 },
{ nodeId: "source", positionX: -110, positionY: 0 },
{ nodeId: "target", positionX: 230, positionY: 0 },
{ nodeId: "downstream", positionX: 350, positionY: 0 },
]);
});
});

View File

@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import { useCanvasEdgeInsertions } from "@/components/canvas/use-canvas-edge-insertions";
import { computeEdgeInsertLayout } from "@/components/canvas/canvas-helpers";
const latestHandlersRef: {
current: ReturnType<typeof useCanvasEdgeInsertions> | null;
@@ -40,7 +41,10 @@ type HookHarnessProps = {
edges: RFEdge[];
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
runBatchMoveNodesMutation?: ReturnType<typeof vi.fn>;
applyLocalNodeMoves?: ReturnType<typeof vi.fn>;
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
onReflowStateChange?: (isReflowing: boolean) => void;
reflowSettleMs?: number;
};
function HookHarness({
@@ -48,16 +52,24 @@ function HookHarness({
edges,
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new"),
runBatchMoveNodesMutation = vi.fn(async () => undefined),
applyLocalNodeMoves,
showConnectionRejectedToast = vi.fn(),
onReflowStateChange,
reflowSettleMs = 0,
}: HookHarnessProps) {
const handlers = useCanvasEdgeInsertions({
const hookArgs = {
canvasId: asCanvasId("canvas-1"),
nodes,
edges,
runCreateNodeWithEdgeSplitOnlineOnly,
runBatchMoveNodesMutation,
applyLocalNodeMoves,
showConnectionRejectedToast,
});
onReflowStateChange,
reflowSettleMs,
} as Parameters<typeof useCanvasEdgeInsertions>[0];
const handlers = useCanvasEdgeInsertions(hookArgs);
useEffect(() => {
latestHandlersRef.current = handlers;
@@ -156,6 +168,72 @@ describe("useCanvasEdgeInsertions", () => {
expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull();
});
it("resolves optimistic edge menu opens to a persisted twin edge id", async () => {
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
nodes={[
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "text", position: { x: 500, y: 0 } }),
]}
edges={[
createEdge({
id: "optimistic_edge_req-1",
source: "source",
target: "target",
sourceHandle: "source-handle",
targetHandle: "target-handle",
}),
createEdge({
id: "edge-real-1",
source: "source",
target: "target",
sourceHandle: "source-handle",
targetHandle: "target-handle",
}),
]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.openEdgeInsertMenu({
edgeId: "optimistic_edge_req-1",
screenX: 20,
screenY: 30,
});
});
expect(latestHandlersRef.current?.edgeInsertMenu).toEqual({
edgeId: "edge-real-1",
screenX: 20,
screenY: 30,
});
await act(async () => {
await latestHandlersRef.current?.handleEdgeInsertPick({
type: "prompt",
label: "Prompt",
width: 320,
height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
} as CanvasNodeTemplate);
});
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith(
expect.objectContaining({
splitEdgeId: "edge-real-1",
}),
);
});
it("shows toast and skips create when split validation fails", async () => {
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
@@ -269,9 +347,18 @@ describe("useCanvasEdgeInsertions", () => {
expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull();
});
it("moves source and target nodes when spacing is too tight", async () => {
it("moves chain nodes before create when spacing is too tight", async () => {
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
const events: string[] = [];
runBatchMoveNodesMutation.mockImplementation(async () => {
events.push("move");
});
runCreateNodeWithEdgeSplitOnlineOnly.mockImplementation(async () => {
events.push("create");
return "node-new";
});
container = document.createElement("div");
document.body.appendChild(container);
@@ -281,10 +368,16 @@ describe("useCanvasEdgeInsertions", () => {
root?.render(
<HookHarness
nodes={[
createNode({ id: "upstream", type: "image", position: { x: -120, y: 0 } }),
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "text", position: { x: 120, y: 0 } }),
createNode({ id: "downstream", type: "text", position: { x: 240, y: 0 } }),
]}
edges={[
createEdge({ id: "edge-upstream", source: "upstream", target: "source" }),
createEdge({ id: "edge-1", source: "source", target: "target" }),
createEdge({ id: "edge-downstream", source: "target", target: "downstream" }),
]}
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
/>,
@@ -308,9 +401,343 @@ describe("useCanvasEdgeInsertions", () => {
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1);
expect(runBatchMoveNodesMutation).toHaveBeenCalledWith({
moves: [
{ nodeId: "upstream", positionX: -230, positionY: 0 },
{ nodeId: "source", positionX: -110, positionY: 0 },
{ nodeId: "target", positionX: 230, positionY: 0 },
{ nodeId: "downstream", positionX: 350, positionY: 0 },
],
});
expect(events).toEqual(["move", "create"]);
});
it("computes insert position from post-reflow source/target positions", async () => {
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
const source = createNode({
id: "source",
type: "image",
position: { x: 0, y: 20 },
style: { width: 160, height: 60 },
});
const target = createNode({
id: "target",
type: "text",
position: { x: 150, y: 20 },
style: { width: 80, height: 120 },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
nodes={[source, target]}
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
});
const initialLayout = computeEdgeInsertLayout({
sourceNode: source,
targetNode: target,
newNodeWidth: 220,
newNodeHeight: 120,
gapPx: 10,
});
await act(async () => {
await latestHandlersRef.current?.handleEdgeInsertPick({
type: "note",
label: "Notiz",
width: 220,
height: 120,
defaultData: { content: "" },
} as CanvasNodeTemplate);
});
const typedBatchMoveMock = runBatchMoveNodesMutation as unknown as {
mock: { calls: unknown[][] };
};
const firstMoveCallUnknown = typedBatchMoveMock.mock.calls[0]?.[0];
const firstMoveCall = firstMoveCallUnknown as
| {
moves: {
nodeId: string;
positionX: number;
positionY: number;
}[];
}
| undefined;
expect(firstMoveCall).toBeDefined();
const moveByNodeId = new Map(
(firstMoveCall?.moves ?? []).map((move) => [move.nodeId, move]),
);
const sourceMove = moveByNodeId.get("source");
const targetMove = moveByNodeId.get("target");
expect(sourceMove).toBeDefined();
expect(targetMove).toBeDefined();
const sourceAfter = {
...source,
position: {
x: sourceMove?.positionX ?? source.position.x,
y: sourceMove?.positionY ?? source.position.y,
},
};
const targetAfter = {
...target,
position: {
x: targetMove?.positionX ?? target.position.x,
y: targetMove?.positionY ?? target.position.y,
},
};
const postMoveLayout = computeEdgeInsertLayout({
sourceNode: sourceAfter,
targetNode: targetAfter,
newNodeWidth: 220,
newNodeHeight: 120,
gapPx: 10,
});
expect(initialLayout.insertPosition).not.toEqual(postMoveLayout.insertPosition);
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith(
expect.objectContaining({
positionX: postMoveLayout.insertPosition.x,
positionY: postMoveLayout.insertPosition.y,
}),
);
});
it("publishes reflow state while chain nodes are moving", async () => {
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
const onReflowStateChange = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
nodes={[
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "text", position: { x: 120, y: 0 } }),
]}
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
onReflowStateChange={onReflowStateChange}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
});
await act(async () => {
await latestHandlersRef.current?.handleEdgeInsertPick({
type: "note",
label: "Notiz",
width: 220,
height: 120,
defaultData: { content: "" },
} as CanvasNodeTemplate);
});
expect(onReflowStateChange.mock.calls).toEqual([[true], [false]]);
});
it("applies local reflow moves before creating split node", async () => {
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
const applyLocalNodeMoves = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
nodes={[
createNode({ id: "upstream", type: "image", position: { x: -120, y: 0 } }),
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "text", position: { x: 120, y: 0 } }),
createNode({ id: "downstream", type: "text", position: { x: 240, y: 0 } }),
]}
edges={[
createEdge({ id: "edge-upstream", source: "upstream", target: "source" }),
createEdge({ id: "edge-1", source: "source", target: "target" }),
createEdge({ id: "edge-downstream", source: "target", target: "downstream" }),
]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
applyLocalNodeMoves={applyLocalNodeMoves}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
});
await act(async () => {
await latestHandlersRef.current?.handleEdgeInsertPick({
type: "note",
label: "Notiz",
width: 220,
height: 120,
defaultData: { content: "" },
} as CanvasNodeTemplate);
});
expect(applyLocalNodeMoves).toHaveBeenCalledTimes(1);
expect(applyLocalNodeMoves).toHaveBeenCalledWith([
{ nodeId: "upstream", positionX: -230, positionY: 0 },
{ nodeId: "source", positionX: -110, positionY: 0 },
{ nodeId: "target", positionX: 230, positionY: 0 },
{ nodeId: "downstream", positionX: 350, positionY: 0 },
]);
expect(applyLocalNodeMoves.mock.invocationCallOrder[0]).toBeLessThan(
runCreateNodeWithEdgeSplitOnlineOnly.mock.invocationCallOrder[0],
);
});
it("does not apply local reflow moves when no move is needed", async () => {
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
const applyLocalNodeMoves = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
nodes={[
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "text", position: { x: 500, y: 0 } }),
]}
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
applyLocalNodeMoves={applyLocalNodeMoves}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 10, screenY: 10 });
});
await act(async () => {
await latestHandlersRef.current?.handleEdgeInsertPick({
type: "prompt",
label: "Prompt",
width: 320,
height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
} as CanvasNodeTemplate);
});
expect(runBatchMoveNodesMutation).not.toHaveBeenCalled();
expect(applyLocalNodeMoves).not.toHaveBeenCalled();
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1);
});
it("ignores temp edges when validating a split into adjustment targets", async () => {
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new");
const runBatchMoveNodesMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
nodes={[
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "curves", position: { x: 280, y: 0 } }),
createNode({ id: "other", type: "image", position: { x: 0, y: 160 } }),
]}
edges={[
createEdge({ id: "edge-1", source: "source", target: "target" }),
createEdge({ id: "edge-temp", source: "other", target: "target", className: "temp" }),
]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 20, screenY: 20 });
});
await act(async () => {
await latestHandlersRef.current?.handleEdgeInsertPick({
type: "color-adjust",
label: "Farbe",
width: 320,
height: 800,
defaultData: {},
} as CanvasNodeTemplate);
});
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1);
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
});
it("exposes only edge-compatible templates for selected edge", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
nodes={[
createNode({ id: "source", type: "color-adjust", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "render", position: { x: 360, y: 0 } }),
]}
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.openEdgeInsertMenu({ edgeId: "edge-1", screenX: 20, screenY: 20 });
});
const templateTypes = (latestHandlersRef.current?.edgeInsertTemplates ?? []).map(
(template) => template.type,
);
expect(templateTypes).toContain("curves");
expect(templateTypes).toContain("color-adjust");
expect(templateTypes).toContain("image");
expect(templateTypes).toContain("asset");
expect(templateTypes).not.toContain("render");
expect(templateTypes).not.toContain("prompt");
expect(templateTypes).not.toContain("text");
expect(templateTypes).not.toContain("ai-image");
});
});

View File

@@ -116,15 +116,30 @@ function HookHarness({ canvasId }: { canvasId: Id<"canvases"> }) {
latestHookValueRef.current = hookValue;
}, [hookValue]);
useEffect(() => {
latestEdgesRef.current = edges;
}, [edges]);
return null;
}
const latestEdgesRef: { current: RFEdge[] } = { current: [] };
function setNavigatorOnline(online: boolean) {
Object.defineProperty(window.navigator, "onLine", {
configurable: true,
value: online,
});
}
describe("useCanvasSyncEngine hook wiring", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(async () => {
latestHookValueRef.current = null;
latestEdgesRef.current = [];
setNavigatorOnline(true);
mocks.mutationMocks.clear();
vi.clearAllMocks();
if (root) {
@@ -170,4 +185,100 @@ describe("useCanvasSyncEngine hook wiring", () => {
}),
);
});
it("remaps optimistic edge ids to persisted ids after online createEdge returns", async () => {
const createEdgeMutation = vi.fn(async () => "edge-real-1");
Object.assign(createEdgeMutation, {
withOptimisticUpdate: () => createEdgeMutation,
});
mocks.mutationMocks.set("edges.create", createEdgeMutation);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<HookHarness canvasId={asCanvasId("canvas-1")} />);
});
await act(async () => {
await latestHookValueRef.current?.actions.createEdge({
canvasId: asCanvasId("canvas-1"),
sourceNodeId: asNodeId("node-a"),
targetNodeId: asNodeId("node-b"),
clientRequestId: "req-1",
});
});
expect(createEdgeMutation).toHaveBeenCalledTimes(1);
expect(latestEdgesRef.current.some((edge) => edge.id === "edge-real-1")).toBe(true);
expect(
latestEdgesRef.current.some((edge) => edge.id === "optimistic_edge_req-1"),
).toBe(false);
});
it("remaps optimistic edge ids to persisted ids while flushing queued createEdge ops", async () => {
setNavigatorOnline(false);
const queuedCreateEdgeOp = {
id: "op-1",
canvasId: "canvas-1",
type: "createEdge",
payload: {
canvasId: "canvas-1",
sourceNodeId: "node-a",
targetNodeId: "node-b",
clientRequestId: "req-2",
},
enqueuedAt: Date.now(),
attemptCount: 0,
nextRetryAt: 0,
expiresAt: Date.now() + 60_000,
};
const typedListCanvasSyncOps = mocks.listCanvasSyncOps as unknown as {
mockResolvedValueOnce: (value: unknown) => { mockResolvedValueOnce: (v: unknown) => void };
};
typedListCanvasSyncOps
.mockResolvedValueOnce([queuedCreateEdgeOp])
.mockResolvedValueOnce([]);
const createEdgeMutation = vi.fn(async () => "edge-real-2");
Object.assign(createEdgeMutation, {
withOptimisticUpdate: () => createEdgeMutation,
});
mocks.mutationMocks.set("edges.create", createEdgeMutation);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<HookHarness canvasId={asCanvasId("canvas-1")} />);
});
await act(async () => {
await latestHookValueRef.current?.actions.createEdge({
canvasId: asCanvasId("canvas-1"),
sourceNodeId: asNodeId("node-a"),
targetNodeId: asNodeId("node-b"),
clientRequestId: "req-2",
});
});
expect(
latestEdgesRef.current.some((edge) => edge.id === "optimistic_edge_req-2"),
).toBe(true);
setNavigatorOnline(true);
await act(async () => {
window.dispatchEvent(new Event("online"));
await latestHookValueRef.current?.actions.flushCanvasSyncQueue();
});
expect(createEdgeMutation).toHaveBeenCalledTimes(1);
expect(latestEdgesRef.current.some((edge) => edge.id === "edge-real-2")).toBe(true);
expect(
latestEdgesRef.current.some((edge) => edge.id === "optimistic_edge_req-2"),
).toBe(false);
});
});

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,13 +162,77 @@ export function useCanvasDeleteHandlers({
});
for (const bridgeCreate of bridgeCreates) {
await runCreateEdgeMutation({
canvasId,
sourceNodeId: bridgeCreate.sourceNodeId,
targetNodeId: bridgeCreate.targetNodeId,
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,
targetNodeId: bridgeCreate.targetNodeId,
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,21 +101,68 @@ 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 });
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],
[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) => {
const menu = edgeInsertMenuRef.current;
@@ -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,