From fa6a41f775dcbc7c69ea873da19854de2a8e6836 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sun, 5 Apr 2026 23:25:26 +0200 Subject: [PATCH] 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. --- app/globals.css | 53 +++ .../canvas/__tests__/canvas-helpers.test.ts | 55 ++- .../use-canvas-edge-insertions.test.tsx | 435 +++++++++++++++++- .../use-canvas-sync-engine-hook.test.tsx | 111 +++++ .../canvas/canvas-connection-drop-menu.tsx | 3 + .../canvas/canvas-connection-validation.ts | 13 +- components/canvas/canvas-delete-handlers.ts | 112 ++++- components/canvas/canvas-helpers.ts | 137 ++++++ .../canvas/canvas-node-template-picker.tsx | 4 +- components/canvas/canvas.tsx | 62 ++- .../canvas/use-canvas-edge-insertions.ts | 174 +++++-- components/canvas/use-canvas-sync-engine.ts | 52 ++- convex/edges.ts | 26 +- tests/canvas-delete-handlers.test.ts | 307 ++++++++++++ 14 files changed, 1477 insertions(+), 67 deletions(-) diff --git a/app/globals.css b/app/globals.css index 7702f2f..1d9d88b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -248,9 +248,62 @@ drop-shadow(0 0 9px rgba(39, 39, 42, 0.4)); } + .react-flow.canvas-edge-insert-reflowing .react-flow__node { + transition-property: transform; + transition-duration: var(--ls-edge-insert-reflow-duration, 1297ms); + transition-timing-function: linear( + 0 0%, + 0.2718 2.5%, + 0.6464 5%, + 1 7.5%, + 1.25 10%, + 1.3641 12.5%, + 1.3536 15%, + 1.2575 17.5%, + 1.125 20%, + 1 22.5%, + 0.9116 25%, + 0.8713 27.5%, + 0.875 30%, + 0.909 32.5%, + 0.9558 35%, + 1 37.5%, + 1.0313 40%, + 1.0455 42.5%, + 1.0442 45%, + 1.0322 47.5%, + 1.0156 50%, + 1 52.5%, + 0.989 55%, + 0.9839 57.5%, + 0.9844 60%, + 0.9886 62.5%, + 0.9945 65%, + 1 67.5%, + 1.0039 70%, + 1.0057 72.5%, + 1.0055 75%, + 1.004 77.5%, + 1.002 80%, + 1 82.5%, + 0.9986 85%, + 0.998 87.5%, + 0.998 90%, + 0.9986 92.5%, + 0.9993 95%, + 1 97.5%, + 1 100% + ); + will-change: transform; + } + @media (prefers-reduced-motion: reduce) { .react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path { transition: none; } + + .react-flow.canvas-edge-insert-reflowing .react-flow__node { + transition: none; + } } } diff --git a/components/canvas/__tests__/canvas-helpers.test.ts b/components/canvas/__tests__/canvas-helpers.test.ts index b9275f3..a896b2b 100644 --- a/components/canvas/__tests__/canvas-helpers.test.ts +++ b/components/canvas/__tests__/canvas-helpers.test.ts @@ -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 }, + ]); + }); +}); diff --git a/components/canvas/__tests__/use-canvas-edge-insertions.test.tsx b/components/canvas/__tests__/use-canvas-edge-insertions.test.tsx index 7f93b6a..3f87fc4 100644 --- a/components/canvas/__tests__/use-canvas-edge-insertions.test.tsx +++ b/components/canvas/__tests__/use-canvas-edge-insertions.test.tsx @@ -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 | null; @@ -40,7 +41,10 @@ type HookHarnessProps = { edges: RFEdge[]; runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType; runBatchMoveNodesMutation?: ReturnType; + applyLocalNodeMoves?: ReturnType; showConnectionRejectedToast?: ReturnType; + 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[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( + , + ); + }); + + 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( , @@ -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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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"); }); }); diff --git a/components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx b/components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx index 680681d..549003e 100644 --- a/components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx +++ b/components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx @@ -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(); + }); + + 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(); + }); + + 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); + }); }); diff --git a/components/canvas/canvas-connection-drop-menu.tsx b/components/canvas/canvas-connection-drop-menu.tsx index a5bc007..4ff07fc 100644 --- a/components/canvas/canvas-connection-drop-menu.tsx +++ b/components/canvas/canvas-connection-drop-menu.tsx @@ -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(null); @@ -109,6 +111,7 @@ export function CanvasConnectionDropMenu({ onClose(); }} groupHeading="Knoten" + templates={templates} /> diff --git a/components/canvas/canvas-connection-validation.ts b/components/canvas/canvas-connection-validation.ts index 5d49f4c..3406ef6 100644 --- a/components/canvas/canvas-connection-validation.ts +++ b/components/canvas/canvas-connection-validation.ts @@ -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({ diff --git a/components/canvas/canvas-delete-handlers.ts b/components/canvas/canvas-delete-handlers.ts index a70c236..2d03ffc 100644 --- a/components/canvas/canvas-delete-handlers.ts +++ b/components/canvas/canvas-delete-handlers.ts @@ -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>; @@ -22,6 +23,8 @@ type UseCanvasDeleteHandlersParams = { canvasId: Id<"canvases">; nodes: RFNode[]; edges: RFEdge[]; + nodesRef: MutableRefObject; + edgesRef: MutableRefObject; deletingNodeIds: MutableRefObject>; setAssetBrowserTargetNodeId: Dispatch>; runBatchRemoveNodesMutation: (args: { nodeIds: Id<"nodes">[] }) => Promise; @@ -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) => + `${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, diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index 6d8210d..72f020d 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -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; +}): Set { + const visited = new Set(); + 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(); + const outgoingBySource = new Map(); + + 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(); + + 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(); diff --git a/components/canvas/canvas-node-template-picker.tsx b/components/canvas/canvas-node-template-picker.tsx index a222d96..0132bb3 100644 --- a/components/canvas/canvas-node-template-picker.tsx +++ b/components/canvas/canvas-node-template-picker.tsx @@ -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 ( - {CANVAS_NODE_TEMPLATES.map((template) => { + {templates.map((template) => { const Icon = NODE_ICONS[template.type]; return ( ; } +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(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( + () => ({ + "--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 ? (
@@ -578,6 +633,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { > diff --git a/components/canvas/use-canvas-edge-insertions.ts b/components/canvas/use-canvas-edge-insertions.ts index d1638d0..2b935ba 100644 --- a/components/canvas/use-canvas-edge-insertions.ts +++ b/components/canvas/use-canvas-edge-insertions.ts @@ -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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} type UseCanvasEdgeInsertionsArgs = { canvasId: Id<"canvases">; @@ -49,7 +61,16 @@ type UseCanvasEdgeInsertionsArgs = { positionY: number; }[]; }) => Promise; + 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(null); const edgeInsertMenuRef = useRef(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, diff --git a/components/canvas/use-canvas-sync-engine.ts b/components/canvas/use-canvas-sync-engine.ts index 716f2e8..b2f5460 100644 --- a/components/canvas/use-canvas-sync-engine.ts +++ b/components/canvas/use-canvas-sync-engine.ts @@ -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, diff --git a/convex/edges.ts b/convex/edges.ts index 25fe158..93b95ac 100644 --- a/convex/edges.ts +++ b/convex/edges.ts @@ -65,16 +65,27 @@ async function assertConnectionPolicy( throw new Error("Source or target node not found"); } + const targetIncomingCount = await countIncomingEdges(ctx, { + targetNodeId: args.targetNodeId, + edgeIdToIgnore: args.edgeIdToIgnore, + }); + const reason = validateCanvasConnectionPolicy({ sourceType: sourceNode.type, targetType: targetNode.type, - targetIncomingCount: await countIncomingEdges(ctx, { - targetNodeId: args.targetNodeId, - edgeIdToIgnore: args.edgeIdToIgnore, - }), + targetIncomingCount, }); if (reason) { + console.warn("[edges.create] connection policy rejected", { + sourceNodeId: args.sourceNodeId, + targetNodeId: args.targetNodeId, + edgeIdToIgnore: args.edgeIdToIgnore, + sourceType: sourceNode.type, + targetType: targetNode.type, + targetIncomingCount, + reason, + }); throw new Error(getCanvasConnectionValidationMessage(reason)); } } @@ -178,6 +189,13 @@ export const create = mutation({ const source = await ctx.db.get(args.sourceNodeId); const target = await ctx.db.get(args.targetNodeId); if (!source || !target) { + console.warn("[edges.create] missing source or target node", { + canvasId: args.canvasId, + sourceNodeId: args.sourceNodeId, + targetNodeId: args.targetNodeId, + hasSource: Boolean(source), + hasTarget: Boolean(target), + }); throw new Error("Source or target node not found"); } if (source.canvasId !== args.canvasId || target.canvasId !== args.canvasId) { diff --git a/tests/canvas-delete-handlers.test.ts b/tests/canvas-delete-handlers.test.ts index 31b9068..d697e28 100644 --- a/tests/canvas-delete-handlers.test.ts +++ b/tests/canvas-delete-handlers.test.ts @@ -26,6 +26,8 @@ const latestHandlersRef: { type HarnessProps = { nodes: RFNode[]; edges: RFEdge[]; + liveNodes?: RFNode[]; + liveEdges?: RFEdge[]; runBatchRemoveNodesMutation: ReturnType; runCreateEdgeMutation: ReturnType; }; @@ -33,6 +35,11 @@ type HarnessProps = { function HookHarness(props: HarnessProps) { const deletingNodeIds = useRef(new Set()); const [, setAssetBrowserTargetNodeId] = useState(null); + const nodesRef = useRef(props.liveNodes ?? props.nodes); + const edgesRef = useRef(props.liveEdges ?? props.edges); + + nodesRef.current = props.liveNodes ?? props.nodes; + edgesRef.current = props.liveEdges ?? props.edges; const handlers = useCanvasDeleteHandlers({ t: ((key: string, values?: Record) => @@ -40,6 +47,8 @@ function HookHarness(props: HarnessProps) { canvasId: asCanvasId("canvas-1"), nodes: props.nodes, edges: props.edges, + nodesRef, + edgesRef, deletingNodeIds, setAssetBrowserTargetNodeId, runBatchRemoveNodesMutation: props.runBatchRemoveNodesMutation, @@ -57,10 +66,14 @@ function HookHarness(props: HarnessProps) { describe("useCanvasDeleteHandlers", () => { let container: HTMLDivElement | null = null; let root: Root | null = null; + let consoleErrorSpy: ReturnType; + let consoleInfoSpy: ReturnType; afterEach(async () => { latestHandlersRef.current = null; vi.clearAllMocks(); + consoleErrorSpy?.mockRestore(); + consoleInfoSpy?.mockRestore(); if (root) { await act(async () => { root?.unmount(); @@ -132,4 +145,298 @@ describe("useCanvasDeleteHandlers", () => { targetHandle: undefined, }); }); + + it("logs bridge payload details when bridge edge creation fails", async () => { + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined); + + let resolveBatchRemove: (() => void) | null = null; + const runBatchRemoveNodesMutation = vi.fn( + () => + new Promise((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((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((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((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(); + }); });