diff --git a/components/canvas/__tests__/canvas-connection-drop-menu.test.tsx b/components/canvas/__tests__/canvas-connection-drop-menu.test.tsx new file mode 100644 index 0000000..f88073f --- /dev/null +++ b/components/canvas/__tests__/canvas-connection-drop-menu.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom + +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CanvasConnectionDropMenu } from "@/components/canvas/canvas-connection-drop-menu"; +import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; + +vi.mock("@/components/ui/command", () => ({ + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandEmpty: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandInput: () => , + CommandList: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/canvas/canvas-node-template-picker", () => ({ + CanvasNodeTemplatePicker: ({ + onPick, + }: { + onPick: (template: CanvasNodeTemplate) => void; + }) => ( + + ), +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("CanvasConnectionDropMenu", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + vi.restoreAllMocks(); + }); + + it("renders using a generic anchor and forwards template picks", () => { + const onPick = vi.fn<(template: CanvasNodeTemplate) => void>(); + const onClose = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + , + ); + }); + + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + + const pickButton = document.querySelector('[data-testid="pick-template"]'); + if (!(pickButton instanceof HTMLButtonElement)) { + throw new Error("Template pick button was not rendered"); + } + + act(() => { + pickButton.click(); + }); + + expect(onPick).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts b/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts index dbddd99..16aad93 100644 --- a/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts +++ b/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts @@ -256,6 +256,96 @@ describe("canvas flow reconciliation helpers", () => { expect(result.nextPendingLocalPositionPins.size).toBe(0); }); + it("keeps pinned local node data until convex catches up", () => { + const pinnedData = { blackPoint: 209, whitePoint: 255 }; + const staleIncomingData = { blackPoint: 124, whitePoint: 255 }; + + const result = reconcileCanvasFlowNodes({ + previousNodes: [ + { + id: "node-1", + type: "curves", + position: { x: 120, y: 80 }, + data: pinnedData, + }, + ], + incomingNodes: [ + { + id: "node-1", + type: "curves", + position: { x: 120, y: 80 }, + data: staleIncomingData, + }, + ], + convexNodes: [{ _id: asNodeId("node-1"), type: "curves" }], + deletingNodeIds: new Set(), + resolvedRealIdByClientRequest: new Map(), + pendingConnectionCreateIds: new Set(), + preferLocalPositionNodeIds: new Set(), + pendingLocalPositionPins: new Map(), + pendingLocalNodeDataPins: new Map([["node-1", pinnedData]]), + pendingMovePins: new Map(), + }); + + expect(result.nodes).toEqual([ + { + id: "node-1", + type: "curves", + position: { x: 120, y: 80 }, + data: pinnedData, + }, + ]); + expect(result.nextPendingLocalNodeDataPins).toEqual( + new Map([["node-1", pinnedData]]), + ); + }); + + it("clears pinned local node data once incoming data includes the persisted values", () => { + const pinnedData = { blackPoint: 209, whitePoint: 255 }; + const incomingData = { + blackPoint: 209, + whitePoint: 255, + _status: "idle", + }; + + const result = reconcileCanvasFlowNodes({ + previousNodes: [ + { + id: "node-1", + type: "curves", + position: { x: 120, y: 80 }, + data: pinnedData, + }, + ], + incomingNodes: [ + { + id: "node-1", + type: "curves", + position: { x: 120, y: 80 }, + data: incomingData, + }, + ], + convexNodes: [{ _id: asNodeId("node-1"), type: "curves" }], + deletingNodeIds: new Set(), + resolvedRealIdByClientRequest: new Map(), + pendingConnectionCreateIds: new Set(), + preferLocalPositionNodeIds: new Set(), + pendingLocalPositionPins: new Map(), + pendingLocalNodeDataPins: new Map([["node-1", pinnedData]]), + pendingMovePins: new Map(), + }); + + expect(result.nodes).toEqual([ + { + id: "node-1", + type: "curves", + position: { x: 120, y: 80 }, + data: incomingData, + }, + ]); + expect(result.nextPendingLocalNodeDataPins.size).toBe(0); + }); + it("filters deleting nodes from incoming reconciliation results", () => { const result = reconcileCanvasFlowNodes({ previousNodes: [ diff --git a/components/canvas/__tests__/canvas-helpers.test.ts b/components/canvas/__tests__/canvas-helpers.test.ts index d1844b0..b9275f3 100644 --- a/components/canvas/__tests__/canvas-helpers.test.ts +++ b/components/canvas/__tests__/canvas-helpers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; -import { withResolvedCompareData } from "../canvas-helpers"; +import { computeEdgeInsertLayout, withResolvedCompareData } from "../canvas-helpers"; import { buildGraphSnapshot, pruneCanvasGraphNodeDataOverrides, @@ -310,3 +310,107 @@ describe("canvas preview graph helpers", () => { ]); }); }); + +describe("computeEdgeInsertLayout", () => { + it("shifts source and target along a horizontal axis when spacing is too tight", () => { + 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 layout = computeEdgeInsertLayout({ + sourceNode: source, + targetNode: target, + newNodeWidth: 80, + newNodeHeight: 40, + gapPx: 10, + }); + + expect(layout.insertPosition).toEqual({ x: 70, y: 10 }); + expect(layout.sourcePosition).toEqual({ x: -40, y: 0 }); + expect(layout.targetPosition).toEqual({ x: 160, y: 0 }); + }); + + it("keeps diagonal-axis spacing adjustments aligned to the edge direction", () => { + const source = createNode({ + id: "source", + position: { x: 0, y: 0 }, + style: { width: 100, height: 100 }, + }); + const target = createNode({ + id: "target", + position: { x: 100, y: 100 }, + style: { width: 100, height: 100 }, + }); + + const layout = computeEdgeInsertLayout({ + sourceNode: source, + targetNode: target, + newNodeWidth: 80, + newNodeHeight: 80, + gapPx: 10, + }); + + expect(layout.insertPosition).toEqual({ x: 60, y: 60 }); + expect(layout.sourcePosition).toBeDefined(); + expect(layout.targetPosition).toBeDefined(); + expect(layout.sourcePosition?.x).toBeCloseTo(layout.sourcePosition?.y ?? 0, 6); + expect(layout.targetPosition?.x).toBeCloseTo(layout.targetPosition?.y ?? 0, 6); + expect(layout.sourcePosition?.x).toBeLessThan(source.position.x); + expect(layout.targetPosition?.x).toBeGreaterThan(target.position.x); + }); + + it("does not shift source or target when there is enough spacing", () => { + const source = createNode({ + id: "source", + position: { x: 0, y: 0 }, + style: { width: 100, height: 60 }, + }); + const target = createNode({ + id: "target", + position: { x: 320, y: 0 }, + style: { width: 100, height: 60 }, + }); + + const layout = computeEdgeInsertLayout({ + sourceNode: source, + targetNode: target, + newNodeWidth: 80, + newNodeHeight: 40, + gapPx: 10, + }); + + expect(layout.insertPosition).toEqual({ x: 170, y: 10 }); + expect(layout.sourcePosition).toBeUndefined(); + expect(layout.targetPosition).toBeUndefined(); + }); + + it("falls back to midpoint placement without aggressive shifts in degenerate cases", () => { + const source = createNode({ + id: "source", + position: { x: 40, y: 80 }, + }); + const target = createNode({ + id: "target", + position: { x: 40, y: 80 }, + }); + + const layout = computeEdgeInsertLayout({ + sourceNode: source, + targetNode: target, + newNodeWidth: 30, + newNodeHeight: 10, + gapPx: 10, + }); + + expect(layout.insertPosition).toEqual({ x: 25, y: 75 }); + expect(layout.sourcePosition).toBeUndefined(); + expect(layout.targetPosition).toBeUndefined(); + }); +}); diff --git a/components/canvas/__tests__/default-edge.test.tsx b/components/canvas/__tests__/default-edge.test.tsx new file mode 100644 index 0000000..82d4976 --- /dev/null +++ b/components/canvas/__tests__/default-edge.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom + +import React, { type ReactNode } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Position } from "@xyflow/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import DefaultEdge from "@/components/canvas/edges/default-edge"; + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual( + "@xyflow/react", + ); + + return { + ...actual, + EdgeLabelRenderer: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +}); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type EdgeInsertAnchor = { + edgeId: string; + screenX: number; + screenY: number; +}; + +type DefaultEdgeRenderProps = { + id: string; + edgeId?: string; + source: string; + target: string; + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition: Position; + targetPosition: Position; + isMenuOpen?: boolean; + disabled?: boolean; + onInsertClick?: (anchor: EdgeInsertAnchor) => void; +}; + +const DefaultEdgeComponent = DefaultEdge as unknown as ( + props: DefaultEdgeRenderProps, +) => React.JSX.Element; + +const baseProps: DefaultEdgeRenderProps = { + id: "edge-1", + edgeId: "edge-1", + source: "node-a", + target: "node-b", + sourceX: 40, + sourceY: 80, + targetX: 260, + targetY: 80, + sourcePosition: Position.Right, + targetPosition: Position.Left, +}; + +function renderEdge(overrides: Partial = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + return { container, root }; +} + +function getInsertButton(container: HTMLDivElement): HTMLButtonElement { + const button = container.querySelector( + '[data-testid="default-edge-insert-button"]', + ); + + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Insert button was not rendered"); + } + + return button; +} + +describe("DefaultEdge", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + vi.restoreAllMocks(); + }); + + it("keeps plus hidden initially and shows it on hover and when menu is open", () => { + const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>(); + ({ container, root } = renderEdge({ onInsertClick })); + + const insertButton = getInsertButton(container); + expect(insertButton.getAttribute("data-visible")).toBe("false"); + + const edgeContainer = container.querySelector('[data-testid="default-edge"]'); + if (!(edgeContainer instanceof Element)) { + throw new Error("Edge container was not rendered"); + } + + act(() => { + edgeContainer.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + }); + + expect(insertButton.getAttribute("data-visible")).toBe("true"); + + act(() => { + root?.render( + + + , + ); + }); + + expect(insertButton.getAttribute("data-visible")).toBe("true"); + }); + + it("calls onInsertClick with edge id and anchor screen coordinates", () => { + const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>(); + ({ container, root } = renderEdge({ onInsertClick, isMenuOpen: true })); + + const insertButton = getInsertButton(container); + vi.spyOn(insertButton, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 200, + left: 100, + right: 160, + bottom: 260, + width: 60, + height: 60, + toJSON: () => ({}), + } as DOMRect); + + act(() => { + insertButton.click(); + }); + + expect(onInsertClick).toHaveBeenCalledWith({ + edgeId: "edge-1", + screenX: 130, + screenY: 230, + }); + }); + + it("suppresses insert interaction in disabled mode", () => { + const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>(); + ({ container, root } = renderEdge({ onInsertClick, isMenuOpen: true, disabled: true })); + + const insertButton = getInsertButton(container); + expect(insertButton.disabled).toBe(true); + expect(insertButton.getAttribute("data-visible")).toBe("false"); + + act(() => { + insertButton.click(); + }); + + expect(onInsertClick).not.toHaveBeenCalled(); + }); + + it("renders the edge path", () => { + ({ container, root } = renderEdge()); + + const edgePath = container.querySelector("path.react-flow__edge-path"); + expect(edgePath).not.toBeNull(); + expect(edgePath?.getAttribute("d")).toBeTruthy(); + }); +}); diff --git a/components/canvas/__tests__/use-canvas-connections.test.tsx b/components/canvas/__tests__/use-canvas-connections.test.tsx index 0f992ea..c2f18f9 100644 --- a/components/canvas/__tests__/use-canvas-connections.test.tsx +++ b/components/canvas/__tests__/use-canvas-connections.test.tsx @@ -44,19 +44,27 @@ const latestHandlersRef: { type HookHarnessProps = { helperResult: DroppedConnectionTarget | null; runCreateEdgeMutation?: ReturnType; + runSplitEdgeAtExistingNodeMutation?: ReturnType; showConnectionRejectedToast?: ReturnType; + nodes?: RFNode[]; + edges?: RFEdge[]; }; function HookHarness({ helperResult, runCreateEdgeMutation = vi.fn(async () => undefined), + runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined), showConnectionRejectedToast = vi.fn(), + nodes: providedNodes, + edges: providedEdges, }: HookHarnessProps) { - const [nodes] = useState([ - { id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} }, - { id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} }, - ]); - const [edges] = useState([]); + const [nodes] = useState( + providedNodes ?? [ + { id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} }, + { id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} }, + ], + ); + const [edges] = useState(providedEdges ?? []); const nodesRef = useRef(nodes); const edgesRef = useRef(edges); const edgeReconnectSuccessful = useRef(true); @@ -90,9 +98,10 @@ function HookHarness({ resolvedRealIdByClientRequestRef, setEdges, setEdgeSyncNonce, - screenToFlowPosition: (position) => position, + screenToFlowPosition: (position: { x: number; y: number }) => position, syncPendingMoveForClientRequest: vi.fn(async () => undefined), runCreateEdgeMutation, + runSplitEdgeAtExistingNodeMutation, runRemoveEdgeMutation: vi.fn(async () => undefined), runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"), runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"), @@ -346,6 +355,83 @@ describe("useCanvasConnections", () => { }); }); + it("splits the existing incoming edge when dropping onto an already-connected adjustment node", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const runSplitEdgeAtExistingNodeMutation = 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?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-curves", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 400, clientY: 260 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-curves", type: "curves" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 400, y: 260 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runSplitEdgeAtExistingNodeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + splitEdgeId: "edge-image-light", + middleNodeId: "node-curves", + splitSourceHandle: undefined, + splitTargetHandle: undefined, + newNodeSourceHandle: undefined, + newNodeTargetHandle: undefined, + }); + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).not.toHaveBeenCalled(); + expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); + }); + it("ignores onConnectEnd when no connect drag is active", async () => { const runCreateEdgeMutation = vi.fn(async () => undefined); const showConnectionRejectedToast = vi.fn(); diff --git a/components/canvas/__tests__/use-canvas-drop.test.tsx b/components/canvas/__tests__/use-canvas-drop.test.tsx index 1ebf676..b675003 100644 --- a/components/canvas/__tests__/use-canvas-drop.test.tsx +++ b/components/canvas/__tests__/use-canvas-drop.test.tsx @@ -2,6 +2,7 @@ import React, { act, useEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; +import type { Edge as RFEdge } from "@xyflow/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Id } from "@/convex/_generated/dataModel"; @@ -33,26 +34,32 @@ type HookHarnessProps = { isSyncOnline?: boolean; generateUploadUrl?: ReturnType; runCreateNodeOnlineOnly?: ReturnType; + runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType; notifyOfflineUnsupported?: ReturnType; syncPendingMoveForClientRequest?: ReturnType; screenToFlowPosition?: (position: { x: number; y: number }) => { x: number; y: number }; + edges?: RFEdge[]; }; function HookHarness({ isSyncOnline = true, generateUploadUrl = vi.fn(async () => "https://upload.test"), runCreateNodeOnlineOnly = vi.fn(async () => "node-1"), + runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-1"), notifyOfflineUnsupported = vi.fn(), syncPendingMoveForClientRequest = vi.fn(async () => undefined), screenToFlowPosition = (position) => position, + edges = [], }: HookHarnessProps) { const handlers = useCanvasDrop({ canvasId: asCanvasId("canvas-1"), isSyncOnline, t: ((key: string) => key) as (key: string) => string, + edges, screenToFlowPosition, generateUploadUrl, runCreateNodeOnlineOnly, + runCreateNodeWithEdgeSplitOnlineOnly, notifyOfflineUnsupported, syncPendingMoveForClientRequest, }); @@ -260,6 +267,72 @@ describe("useCanvasDrop", () => { expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-video"); }); + it("splits an intersected persisted edge for sidebar node drops", async () => { + const runCreateNodeOnlineOnly = vi.fn(async () => "node-note"); + const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-note"); + const syncPendingMoveForClientRequest = vi.fn(async () => undefined); + const edgeContainer = document.createElement("g"); + edgeContainer.classList.add("react-flow__edge"); + edgeContainer.setAttribute("data-id", "edge-a"); + const interaction = document.createElement("path"); + interaction.classList.add("react-flow__edge-interaction"); + edgeContainer.appendChild(interaction); + Object.defineProperty(document, "elementsFromPoint", { + value: vi.fn(() => [interaction]), + configurable: true, + }); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + await latestHandlersRef.current?.onDrop({ + preventDefault: vi.fn(), + clientX: 120, + clientY: 340, + dataTransfer: { + getData: vi.fn((type: string) => + type === CANVAS_NODE_DND_MIME ? "note" : "", + ), + files: [], + }, + } as unknown as React.DragEvent); + }); + + expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith({ + canvasId: "canvas-1", + type: "note", + positionX: 120, + positionY: 340, + width: NODE_DEFAULTS.note.width, + height: NODE_DEFAULTS.note.height, + data: { + ...NODE_DEFAULTS.note.data, + canvasId: "canvas-1", + }, + splitEdgeId: "edge-a", + newNodeTargetHandle: undefined, + newNodeSourceHandle: undefined, + splitSourceHandle: undefined, + splitTargetHandle: undefined, + clientRequestId: "req-1", + }); + expect(runCreateNodeOnlineOnly).not.toHaveBeenCalled(); + expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-note"); + }); + it("shows an upload failure toast when the dropped file upload fails", async () => { const generateUploadUrl = vi.fn(async () => "https://upload.test"); const runCreateNodeOnlineOnly = vi.fn(async () => "node-image"); diff --git a/components/canvas/__tests__/use-canvas-edge-insertions.test.tsx b/components/canvas/__tests__/use-canvas-edge-insertions.test.tsx new file mode 100644 index 0000000..7f93b6a --- /dev/null +++ b/components/canvas/__tests__/use-canvas-edge-insertions.test.tsx @@ -0,0 +1,316 @@ +// @vitest-environment jsdom + +import React, { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; +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"; + +const latestHandlersRef: { + current: ReturnType | null; +} = { current: null }; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">; + +function createNode(overrides: Partial & Pick): RFNode { + return { + type: "note", + position: { x: 0, y: 0 }, + style: { width: 100, height: 60 }, + data: {}, + ...overrides, + } as RFNode; +} + +function createEdge( + overrides: Partial & Pick, +): RFEdge { + return { + ...overrides, + } as RFEdge; +} + +type HookHarnessProps = { + nodes: RFNode[]; + edges: RFEdge[]; + runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType; + runBatchMoveNodesMutation?: ReturnType; + showConnectionRejectedToast?: ReturnType; +}; + +function HookHarness({ + nodes, + edges, + runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new"), + runBatchMoveNodesMutation = vi.fn(async () => undefined), + showConnectionRejectedToast = vi.fn(), +}: HookHarnessProps) { + const handlers = useCanvasEdgeInsertions({ + canvasId: asCanvasId("canvas-1"), + nodes, + edges, + runCreateNodeWithEdgeSplitOnlineOnly, + runBatchMoveNodesMutation, + showConnectionRejectedToast, + }); + + useEffect(() => { + latestHandlersRef.current = handlers; + }, [handlers]); + + return null; +} + +describe("useCanvasEdgeInsertions", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(async () => { + latestHandlersRef.current = null; + vi.clearAllMocks(); + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + }); + + it("opens edge insert menu for persisted edges", 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: 120, + screenY: 240, + }); + }); + + expect(latestHandlersRef.current?.edgeInsertMenu).toEqual({ + edgeId: "edge-1", + screenX: 120, + screenY: 240, + }); + }); + + it("ignores temp, optimistic, and missing edges when opening menu", 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-temp", + screenX: 1, + screenY: 2, + }); + latestHandlersRef.current?.openEdgeInsertMenu({ + edgeId: "optimistic_edge_1", + screenX: 3, + screenY: 4, + }); + latestHandlersRef.current?.openEdgeInsertMenu({ + edgeId: "edge-missing", + screenX: 5, + screenY: 6, + }); + }); + + expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull(); + }); + + it("shows toast and skips create when split validation fails", 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: 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(showConnectionRejectedToast).toHaveBeenCalledWith("adjustment-source-invalid"); + expect(runCreateNodeWithEdgeSplitOnlineOnly).not.toHaveBeenCalled(); + expect(runBatchMoveNodesMutation).not.toHaveBeenCalled(); + }); + + it("creates split node with computed payload when split is valid", 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: 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(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith({ + canvasId: "canvas-1", + type: "prompt", + positionX: 140, + positionY: -80, + width: 320, + height: 220, + data: { + prompt: "", + model: "", + aspectRatio: "1:1", + canvasId: "canvas-1", + }, + splitEdgeId: "edge-1", + newNodeTargetHandle: "image-in", + newNodeSourceHandle: "prompt-out", + splitSourceHandle: "source-handle", + splitTargetHandle: "target-handle", + }); + expect(runBatchMoveNodesMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).not.toHaveBeenCalled(); + expect(latestHandlersRef.current?.edgeInsertMenu).toBeNull(); + }); + + it("moves source and target nodes when spacing is too tight", async () => { + const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-new"); + const runBatchMoveNodesMutation = vi.fn(async () => undefined); + + 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(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledTimes(1); + expect(runBatchMoveNodesMutation).toHaveBeenCalledWith({ + moves: [ + { nodeId: "source", positionX: -110, positionY: 0 }, + { nodeId: "target", positionX: 230, positionY: 0 }, + ], + }); + }); +}); diff --git a/components/canvas/__tests__/use-canvas-edge-types.test.tsx b/components/canvas/__tests__/use-canvas-edge-types.test.tsx new file mode 100644 index 0000000..b87ef07 --- /dev/null +++ b/components/canvas/__tests__/use-canvas-edge-types.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom + +import React, { useEffect } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useCanvasEdgeTypes } from "@/components/canvas/use-canvas-edge-types"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type HookHarnessProps = { + edgeInsertMenuEdgeId: string | null; + scissorsMode: boolean; + onInsertClick: ReturnType; +}; + +const latestRef: { + current: ReturnType | null; +} = { current: null }; + +function HookHarness({ + edgeInsertMenuEdgeId, + scissorsMode, + onInsertClick, +}: HookHarnessProps) { + const edgeTypes = useCanvasEdgeTypes({ + edgeInsertMenuEdgeId, + scissorsMode, + onInsertClick, + }); + + useEffect(() => { + latestRef.current = edgeTypes; + }, [edgeTypes]); + + return null; +} + +describe("useCanvasEdgeTypes", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + latestRef.current = null; + if (root) { + act(() => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + }); + + it("keeps edgeTypes reference stable while using latest UI state", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + const onInsertClickA = vi.fn(); + const onInsertClickB = vi.fn(); + + act(() => { + root?.render( + , + ); + }); + + const firstEdgeTypes = latestRef.current; + if (!firstEdgeTypes) { + throw new Error("edgeTypes not initialized"); + } + + const renderer = firstEdgeTypes["canvas-default"] as + | ((props: { id: string }) => React.JSX.Element) + | undefined; + if (!renderer) { + throw new Error("canvas-default edge renderer missing"); + } + + act(() => { + const renderedEdge = renderer({ id: "edge-1" }); + expect(renderedEdge.props).toEqual( + expect.objectContaining({ + edgeId: "edge-1", + isMenuOpen: false, + disabled: false, + onInsertClick: onInsertClickA, + }), + ); + }); + + act(() => { + root?.render( + , + ); + }); + + expect(latestRef.current).toBe(firstEdgeTypes); + + act(() => { + const renderedEdge = renderer({ id: "edge-1" }); + expect(renderedEdge.props).toEqual( + expect.objectContaining({ + edgeId: "edge-1", + isMenuOpen: true, + disabled: true, + onInsertClick: onInsertClickB, + }), + ); + }); + }); +}); diff --git a/components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts b/components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts index 91ddaf7..1ddb7c8 100644 --- a/components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts +++ b/components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts @@ -38,6 +38,7 @@ type HarnessProps = { pendingConnectionCreateIds: Set; previousConvexNodeIdsSnapshot: Set; pendingLocalPositionPins?: Map; + pendingLocalNodeDataPins?: Map; preferLocalPositionNodeIds?: Set; isResizingRefOverride?: { current: boolean }; }; @@ -78,6 +79,9 @@ function HookHarness(props: HarnessProps) { const pendingLocalPositionUntilConvexMatchesRef = useRef( props.pendingLocalPositionPins ?? new Map(), ); + const pendingLocalNodeDataUntilConvexMatchesRef = useRef( + props.pendingLocalNodeDataPins ?? new Map(), + ); const preferLocalPositionNodeIdsRef = useRef( props.preferLocalPositionNodeIds ?? new Set(), ); @@ -115,6 +119,7 @@ function HookHarness(props: HarnessProps) { resolvedRealIdByClientRequestRef, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, + pendingLocalNodeDataUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, isDragging: isDraggingRef, isResizing: isResizingRef, diff --git a/components/canvas/__tests__/use-canvas-sync-engine.test.ts b/components/canvas/__tests__/use-canvas-sync-engine.test.ts index 1778e8a..016ac21 100644 --- a/components/canvas/__tests__/use-canvas-sync-engine.test.ts +++ b/components/canvas/__tests__/use-canvas-sync-engine.test.ts @@ -74,4 +74,44 @@ describe("useCanvasSyncEngine", () => { expect(controller.pendingResizeAfterCreateRef.current.has("req-2")).toBe(false); expect(controller.pendingDataAfterCreateRef.current.has("req-2")).toBe(false); }); + + + it("pins local node data immediately when queueing an update", async () => { + const enqueueSyncMutation = vi.fn(async () => undefined); + let nodes = [ + { + id: "node-1", + type: "curves", + position: { x: 0, y: 0 }, + data: { blackPoint: 124 }, + }, + ]; + const setNodes = (updater: (current: typeof nodes) => typeof nodes) => { + nodes = updater(nodes); + return nodes; + }; + + const controller = createCanvasSyncEngineController({ + canvasId: asCanvasId("canvas-1"), + isSyncOnline: true, + getEnqueueSyncMutation: () => enqueueSyncMutation, + getRunBatchRemoveNodes: () => vi.fn(async () => undefined), + getRunSplitEdgeAtExistingNode: () => vi.fn(async () => undefined), + getSetNodes: () => setNodes, + }); + + await controller.queueNodeDataUpdate({ + nodeId: asNodeId("node-1"), + data: { blackPoint: 209 }, + }); + + expect(nodes[0]?.data).toEqual({ blackPoint: 209 }); + expect(controller.pendingLocalNodeDataUntilConvexMatchesRef.current).toEqual( + new Map([["node-1", { blackPoint: 209 }]]), + ); + expect(enqueueSyncMutation).toHaveBeenCalledWith("updateData", { + nodeId: asNodeId("node-1"), + data: { blackPoint: 209 }, + }); + }); }); diff --git a/components/canvas/__tests__/use-node-local-data.test.tsx b/components/canvas/__tests__/use-node-local-data.test.tsx index bcf5277..821c552 100644 --- a/components/canvas/__tests__/use-node-local-data.test.tsx +++ b/components/canvas/__tests__/use-node-local-data.test.tsx @@ -445,4 +445,140 @@ describe("useNodeLocalData preview overrides", () => { vi.useRealTimers(); }); + + it("keeps local data when save resolves before Convex catches up", async () => { + vi.useFakeTimers(); + const onSave = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHookRef.current?.applyLocalData({ + exposure: 0.8, + label: "local", + }); + }); + + expect(latestHookRef.current?.localData).toEqual({ + exposure: 0.8, + label: "local", + }); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + }); + + expect(latestHookRef.current?.localData).toEqual({ + exposure: 0.8, + label: "local", + }); + + vi.useRealTimers(); + }); + + it("accepts a later normalized server value after blocking a stale rerender", async () => { + vi.useFakeTimers(); + const onSave = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHookRef.current?.applyLocalData({ + exposure: 0.8, + label: "local", + }); + }); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + }); + + expect(latestHookRef.current?.localData).toEqual({ + exposure: 0.8, + label: "local", + }); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + }); + + expect(latestHookRef.current?.localData).toEqual({ + exposure: 0.75, + label: "server-normalized", + }); + expect(latestOverridesRef.current).toEqual(new Map()); + + vi.useRealTimers(); + }); }); diff --git a/components/canvas/canvas-connection-drop-menu.tsx b/components/canvas/canvas-connection-drop-menu.tsx index b7cfff2..a5bc007 100644 --- a/components/canvas/canvas-connection-drop-menu.tsx +++ b/components/canvas/canvas-connection-drop-menu.tsx @@ -23,8 +23,13 @@ export type ConnectionDropMenuState = { fromHandleType: "source" | "target"; }; +export type CanvasMenuAnchor = { + screenX: number; + screenY: number; +}; + type CanvasConnectionDropMenuProps = { - state: ConnectionDropMenuState | null; + anchor: CanvasMenuAnchor | null; onClose: () => void; onPick: (template: CanvasNodeTemplate) => void; }; @@ -33,14 +38,14 @@ const PANEL_MAX_W = 360; const PANEL_MAX_H = 420; export function CanvasConnectionDropMenu({ - state, + anchor, onClose, onPick, }: CanvasConnectionDropMenuProps) { const panelRef = useRef(null); useEffect(() => { - if (!state) return; + if (!anchor) return; const onEscape = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); @@ -59,9 +64,9 @@ export function CanvasConnectionDropMenu({ document.removeEventListener("keydown", onEscape); document.removeEventListener("pointerdown", onPointerDownCapture, true); }; - }, [state, onClose]); + }, [anchor, onClose]); - if (!state) return null; + if (!anchor) return null; const vw = typeof window !== "undefined" ? window.innerWidth : PANEL_MAX_W + 16; @@ -69,11 +74,11 @@ export function CanvasConnectionDropMenu({ typeof window !== "undefined" ? window.innerHeight : PANEL_MAX_H + 16; const left = Math.max( 8, - Math.min(state.screenX, vw - PANEL_MAX_W - 8), + Math.min(anchor.screenX, vw - PANEL_MAX_W - 8), ); const top = Math.max( 8, - Math.min(state.screenY, vh - PANEL_MAX_H - 8), + Math.min(anchor.screenY, vh - PANEL_MAX_H - 8), ); return ( diff --git a/components/canvas/canvas-flow-reconciliation-helpers.ts b/components/canvas/canvas-flow-reconciliation-helpers.ts index 737e4ea..6d2ea0d 100644 --- a/components/canvas/canvas-flow-reconciliation-helpers.ts +++ b/components/canvas/canvas-flow-reconciliation-helpers.ts @@ -277,6 +277,83 @@ function applyLocalPositionPins(args: { }; } +function isNodeDataRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nodeDataIncludesPin(incoming: unknown, pin: unknown): boolean { + if (Array.isArray(pin)) { + return ( + Array.isArray(incoming) && + incoming.length === pin.length && + pin.every((pinEntry, index) => nodeDataIncludesPin(incoming[index], pinEntry)) + ); + } + + if (isNodeDataRecord(pin)) { + if (!isNodeDataRecord(incoming)) { + return false; + } + + return Object.keys(pin).every((key) => + nodeDataIncludesPin(incoming[key], pin[key]), + ); + } + + return Object.is(incoming, pin); +} + +function mergeNodeDataWithPin(incoming: unknown, pin: unknown): unknown { + if (Array.isArray(pin)) { + return pin; + } + + if (isNodeDataRecord(pin)) { + const base = isNodeDataRecord(incoming) ? incoming : {}; + const next: Record = { ...base }; + + for (const [key, value] of Object.entries(pin)) { + next[key] = mergeNodeDataWithPin(base[key], value); + } + + return next; + } + + return pin; +} + +function applyLocalNodeDataPins(args: { + nodes: RFNode[]; + pendingLocalNodeDataPins: ReadonlyMap; +}): { + nodes: RFNode[]; + nextPendingLocalNodeDataPins: Map; +} { + const nodeIds = new Set(args.nodes.map((node) => node.id)); + const nextPendingLocalNodeDataPins = new Map( + [...args.pendingLocalNodeDataPins].filter(([nodeId]) => nodeIds.has(nodeId)), + ); + const nodes = args.nodes.map((node) => { + const pin = nextPendingLocalNodeDataPins.get(node.id); + if (pin === undefined) return node; + + if (nodeDataIncludesPin(node.data, pin)) { + nextPendingLocalNodeDataPins.delete(node.id); + return node; + } + + return { + ...node, + data: mergeNodeDataWithPin(node.data, pin) as Record, + }; + }); + + return { + nodes, + nextPendingLocalNodeDataPins, + }; +} + export function reconcileCanvasFlowNodes(args: { previousNodes: RFNode[]; incomingNodes: RFNode[]; @@ -286,11 +363,13 @@ export function reconcileCanvasFlowNodes(args: { pendingConnectionCreateIds: ReadonlySet; preferLocalPositionNodeIds: ReadonlySet; pendingLocalPositionPins: ReadonlyMap; + pendingLocalNodeDataPins?: ReadonlyMap; pendingMovePins: ReadonlyMap; }): { nodes: RFNode[]; inferredRealIdByClientRequest: Map>; nextPendingLocalPositionPins: Map; + nextPendingLocalNodeDataPins: Map; clearedPreferLocalPositionNodeIds: string[]; } { const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({ @@ -309,8 +388,12 @@ export function reconcileCanvasFlowNodes(args: { inferredRealIdByClientRequest, args.preferLocalPositionNodeIds, ); - const pinnedNodes = applyLocalPositionPins({ + const dataPinnedNodes = applyLocalNodeDataPins({ nodes: mergedNodes, + pendingLocalNodeDataPins: args.pendingLocalNodeDataPins ?? new Map(), + }); + const pinnedNodes = applyLocalPositionPins({ + nodes: dataPinnedNodes.nodes, pendingLocalPositionPins: args.pendingLocalPositionPins, }); const nodes = applyPinnedNodePositionsReadOnly( @@ -335,6 +418,7 @@ export function reconcileCanvasFlowNodes(args: { nodes, inferredRealIdByClientRequest, nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins, + nextPendingLocalNodeDataPins: dataPinnedNodes.nextPendingLocalNodeDataPins, clearedPreferLocalPositionNodeIds, }; } diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index 2b5f7e5..6d8210d 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -12,6 +12,121 @@ import { NODE_HANDLE_MAP } from "@/lib/canvas-utils"; export const OPTIMISTIC_NODE_PREFIX = "optimistic_"; export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_"; +type XYPosition = { x: number; y: number }; + +export type ComputeEdgeInsertLayoutArgs = { + sourceNode: RFNode; + targetNode: RFNode; + newNodeWidth: number; + newNodeHeight: number; + gapPx: number; +}; + +export type EdgeInsertLayout = { + insertPosition: XYPosition; + 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]; + if (typeof direct === "number" && Number.isFinite(direct) && direct > 0) { + return direct; + } + + const styleValue = node.style?.[key]; + if (typeof styleValue === "number" && Number.isFinite(styleValue) && styleValue > 0) { + return styleValue; + } + + return null; +} + +function readNodeBox(node: RFNode): { + width: number; + height: number; + hasDimensions: boolean; +} { + const width = readNodeDimension(node, "width"); + const height = readNodeDimension(node, "height"); + return { + width: width ?? 0, + height: height ?? 0, + hasDimensions: width !== null && height !== null, + }; +} + +export function computeEdgeInsertLayout(args: ComputeEdgeInsertLayoutArgs): EdgeInsertLayout { + const sourceBox = readNodeBox(args.sourceNode); + const targetBox = readNodeBox(args.targetNode); + const safeGap = Number.isFinite(args.gapPx) ? Math.max(0, args.gapPx) : 0; + const newWidth = Number.isFinite(args.newNodeWidth) ? Math.max(0, args.newNodeWidth) : 0; + const newHeight = Number.isFinite(args.newNodeHeight) ? Math.max(0, args.newNodeHeight) : 0; + + const sourceCenter = { + x: args.sourceNode.position.x + sourceBox.width / 2, + y: args.sourceNode.position.y + sourceBox.height / 2, + }; + const targetCenter = { + x: args.targetNode.position.x + targetBox.width / 2, + y: args.targetNode.position.y + targetBox.height / 2, + }; + + const midpoint = { + x: (sourceCenter.x + targetCenter.x) / 2, + y: (sourceCenter.y + targetCenter.y) / 2, + }; + + const layout: EdgeInsertLayout = { + insertPosition: { + x: midpoint.x - newWidth / 2, + y: midpoint.y - newHeight / 2, + }, + }; + + if (!sourceBox.hasDimensions || !targetBox.hasDimensions) { + return layout; + } + + const axisDx = targetCenter.x - sourceCenter.x; + const axisDy = targetCenter.y - sourceCenter.y; + const axisLength = Math.hypot(axisDx, axisDy); + if (axisLength <= Number.EPSILON) { + return layout; + } + + const ux = axisDx / axisLength; + const uy = axisDy / axisLength; + + const extentAlongAxis = (width: number, height: number): number => + Math.abs(ux) * (width / 2) + Math.abs(uy) * (height / 2); + + const sourceExtent = extentAlongAxis(sourceBox.width, sourceBox.height); + const targetExtent = extentAlongAxis(targetBox.width, targetBox.height); + const newExtent = extentAlongAxis(newWidth, newHeight); + const halfAxisLength = axisLength / 2; + + const sourceShift = Math.max(0, sourceExtent + newExtent + safeGap - halfAxisLength); + const targetShift = Math.max(0, targetExtent + newExtent + safeGap - halfAxisLength); + + if (sourceShift > 0) { + layout.sourcePosition = { + x: args.sourceNode.position.x - ux * sourceShift, + y: args.sourceNode.position.y - uy * sourceShift, + }; + } + + if (targetShift > 0) { + layout.targetPosition = { + x: args.targetNode.position.x + ux * targetShift, + y: args.targetNode.position.y + uy * targetShift, + }; + } + + return layout; +} + export function createCanvasOpId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); @@ -78,19 +193,50 @@ export type DroppedConnectionTarget = { targetHandle?: string; }; -function getNodeElementAtClientPoint(point: { x: number; y: number }): HTMLElement | null { +function describeConnectionDebugElement(element: Element): Record { + if (!(element instanceof HTMLElement)) { + return { + tagName: element.tagName.toLowerCase(), + }; + } + + return { + tagName: element.tagName.toLowerCase(), + id: element.id || undefined, + dataId: element.dataset.id || undefined, + className: element.className || undefined, + }; +} + +export function logCanvasConnectionDebug( + event: string, + payload: Record, +): void { + if (process.env.NODE_ENV !== "development") { + return; + } + + console.info("[Canvas connection debug]", event, payload); +} + +function getNodeElementAtClientPoint( + point: { x: number; y: number }, + elementsAtPoint?: Element[], +): HTMLElement | null { if (typeof document === "undefined") { return null; } - const hit = document.elementsFromPoint(point.x, point.y).find((element) => { - if (!(element instanceof HTMLElement)) return false; - return ( - element.classList.contains("react-flow__node") && - typeof element.dataset.id === "string" && - element.dataset.id.length > 0 - ); - }); + const hit = (elementsAtPoint ?? document.elementsFromPoint(point.x, point.y)).find( + (element) => { + if (!(element instanceof HTMLElement)) return false; + return ( + element.classList.contains("react-flow__node") && + typeof element.dataset.id === "string" && + element.dataset.id.length > 0 + ); + }, + ); return hit instanceof HTMLElement ? hit : null; } @@ -133,25 +279,52 @@ export function resolveDroppedConnectionTarget(args: { nodes: RFNode[]; edges: RFEdge[]; }): DroppedConnectionTarget | null { - const nodeElement = getNodeElementAtClientPoint(args.point); + const elementsAtPoint = + typeof document === "undefined" + ? [] + : document.elementsFromPoint(args.point.x, args.point.y); + const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint); if (!nodeElement) { + logCanvasConnectionDebug("drop-target:node-missed", { + point: args.point, + fromNodeId: args.fromNodeId, + fromHandleId: args.fromHandleId ?? null, + fromHandleType: args.fromHandleType, + elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement), + }); return null; } const targetNodeId = nodeElement.dataset.id; if (!targetNodeId) { + logCanvasConnectionDebug("drop-target:node-missing-data-id", { + point: args.point, + fromNodeId: args.fromNodeId, + fromHandleId: args.fromHandleId ?? null, + fromHandleType: args.fromHandleType, + nodeElement: describeConnectionDebugElement(nodeElement), + }); return null; } const targetNode = args.nodes.find((node) => node.id === targetNodeId); if (!targetNode) { + logCanvasConnectionDebug("drop-target:node-not-in-state", { + point: args.point, + fromNodeId: args.fromNodeId, + fromHandleId: args.fromHandleId ?? null, + fromHandleType: args.fromHandleType, + targetNodeId, + nodeCount: args.nodes.length, + nodeElement: describeConnectionDebugElement(nodeElement), + }); return null; } const handles = NODE_HANDLE_MAP[targetNode.type ?? ""]; if (args.fromHandleType === "source") { - return { + const droppedConnection = { sourceNodeId: args.fromNodeId, targetNodeId, sourceHandle: args.fromHandleId, @@ -165,14 +338,40 @@ export function resolveDroppedConnectionTarget(args: { }) : handles?.target, }; + + logCanvasConnectionDebug("drop-target:node-detected", { + point: args.point, + fromNodeId: args.fromNodeId, + fromHandleId: args.fromHandleId ?? null, + fromHandleType: args.fromHandleType, + targetNodeId, + targetNodeType: targetNode.type ?? null, + nodeElement: describeConnectionDebugElement(nodeElement), + resolvedConnection: droppedConnection, + }); + + return droppedConnection; } - return { + const droppedConnection = { sourceNodeId: targetNodeId, targetNodeId: args.fromNodeId, sourceHandle: handles?.source, targetHandle: args.fromHandleId, }; + + logCanvasConnectionDebug("drop-target:node-detected", { + point: args.point, + fromNodeId: args.fromNodeId, + fromHandleId: args.fromHandleId ?? null, + fromHandleType: args.fromHandleType, + targetNodeId, + targetNodeType: targetNode.type ?? null, + nodeElement: describeConnectionDebugElement(nodeElement), + resolvedConnection: droppedConnection, + }); + + return droppedConnection; } /** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */ diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index f508a3e..1147ab5 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -68,8 +68,11 @@ import { useCanvasNodeInteractions } from "./use-canvas-node-interactions"; import { useCanvasConnections } from "./use-canvas-connections"; import { useCanvasDrop } from "./use-canvas-drop"; import { useCanvasScissors } from "./canvas-scissors"; +import { type DefaultEdgeInsertAnchor } from "./edges/default-edge"; import { CanvasSyncProvider } from "./canvas-sync-context"; import { useCanvasData } from "./use-canvas-data"; +import { useCanvasEdgeInsertions } from "./use-canvas-edge-insertions"; +import { useCanvasEdgeTypes } from "./use-canvas-edge-types"; import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; @@ -111,6 +114,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { pendingEdgeSplitByClientRequestRef, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, + pendingLocalNodeDataUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, }, actions: { @@ -328,12 +332,55 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { screenToFlowPosition, syncPendingMoveForClientRequest, runCreateEdgeMutation, + runSplitEdgeAtExistingNodeMutation, runRemoveEdgeMutation, runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly, showConnectionRejectedToast, }); + const { + edgeInsertMenu, + closeEdgeInsertMenu, + openEdgeInsertMenu, + handleEdgeInsertPick, + } = useCanvasEdgeInsertions({ + canvasId, + nodes, + edges, + runCreateNodeWithEdgeSplitOnlineOnly, + runBatchMoveNodesMutation, + showConnectionRejectedToast, + }); + + const handleEdgeInsertClick = useCallback( + (anchor: DefaultEdgeInsertAnchor) => { + closeConnectionDropMenu(); + openEdgeInsertMenu(anchor); + }, + [closeConnectionDropMenu, openEdgeInsertMenu], + ); + + useEffect(() => { + if (connectionDropMenu) { + closeEdgeInsertMenu(); + } + }, [closeEdgeInsertMenu, connectionDropMenu]); + + const defaultEdgeOptions = useMemo( + () => ({ + ...DEFAULT_EDGE_OPTIONS, + type: "canvas-default" as const, + }), + [], + ); + + const edgeTypes = useCanvasEdgeTypes({ + edgeInsertMenuEdgeId: edgeInsertMenu?.edgeId ?? null, + scissorsMode, + onInsertClick: handleEdgeInsertClick, + }); + useCanvasFlowReconciliation({ convexNodes, convexEdges, @@ -351,6 +398,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { resolvedRealIdByClientRequestRef, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, + pendingLocalNodeDataUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, isDragging, isResizing, @@ -411,9 +459,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { canvasId, isSyncOnline, t, + edges, screenToFlowPosition, generateUploadUrl, runCreateNodeOnlineOnly, + runCreateNodeWithEdgeSplitOnlineOnly, notifyOfflineUnsupported, syncPendingMoveForClientRequest, }); @@ -473,10 +523,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { + {scissorsMode ? (
Scherenmodus — Kante anklicken oder ziehen zum Durchtrennen ·{" "} @@ -512,9 +581,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { nodes={nodes} edges={edges} onlyRenderVisibleElements - defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} + defaultEdgeOptions={defaultEdgeOptions} connectionLineComponent={CustomConnectionLine} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onNodeDragStart={onNodeDragStart} diff --git a/components/canvas/edges/default-edge.tsx b/components/canvas/edges/default-edge.tsx index 4a93263..f399418 100644 --- a/components/canvas/edges/default-edge.tsx +++ b/components/canvas/edges/default-edge.tsx @@ -1,3 +1,116 @@ -export default function DefaultEdge() { - return null; +"use client"; + +import { useMemo, useState, type MouseEvent } from "react"; +import { + BaseEdge, + EdgeLabelRenderer, + getBezierPath, + type EdgeProps, +} from "@xyflow/react"; +import { Plus } from "lucide-react"; + +export type DefaultEdgeInsertAnchor = { + edgeId: string; + screenX: number; + screenY: number; +}; + +export type DefaultEdgeProps = EdgeProps & { + edgeId?: string; + isMenuOpen?: boolean; + disabled?: boolean; + onInsertClick?: (anchor: DefaultEdgeInsertAnchor) => void; +}; + +export default function DefaultEdge({ + id, + edgeId, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerStart, + markerEnd, + style, + interactionWidth, + isMenuOpen = false, + disabled = false, + onInsertClick, +}: DefaultEdgeProps) { + const [isEdgeHovered, setIsEdgeHovered] = useState(false); + const [isButtonHovered, setIsButtonHovered] = useState(false); + + const [edgePath, labelX, labelY] = useMemo( + () => + getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }), + [sourcePosition, sourceX, sourceY, targetPosition, targetX, targetY], + ); + + const resolvedEdgeId = edgeId ?? id; + const canInsert = Boolean(onInsertClick) && !disabled; + const isInsertVisible = canInsert && (isMenuOpen || isEdgeHovered || isButtonHovered); + + const handleInsertClick = (event: MouseEvent) => { + if (!onInsertClick || disabled) { + return; + } + + const rect = event.currentTarget.getBoundingClientRect(); + onInsertClick({ + edgeId: resolvedEdgeId, + screenX: rect.left + rect.width / 2, + screenY: rect.top + rect.height / 2, + }); + }; + + return ( + <> + setIsEdgeHovered(true)} + onMouseLeave={() => setIsEdgeHovered(false)} + > + + + + + + + + ); } diff --git a/components/canvas/nodes/use-node-local-data.ts b/components/canvas/nodes/use-node-local-data.ts index f95e253..b714fc3 100644 --- a/components/canvas/nodes/use-node-local-data.ts +++ b/components/canvas/nodes/use-node-local-data.ts @@ -41,9 +41,10 @@ export function useNodeLocalData({ useCanvasGraphPreviewOverrides(); const [localData, setLocalDataState] = useState(() => normalize(data)); const localDataRef = useRef(localData); - const persistedDataRef = useRef(localData); + const acceptedPersistedDataRef = useRef(localData); const hasPendingLocalChangesRef = useRef(false); const localChangeVersionRef = useRef(0); + const acknowledgedSaveVersionRef = useRef(0); const isMountedRef = useRef(true); useEffect(() => { @@ -60,7 +61,7 @@ export function useNodeLocalData({ return; } - hasPendingLocalChangesRef.current = false; + acknowledgedSaveVersionRef.current = savedVersion; }) .catch(() => { if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) { @@ -68,34 +69,49 @@ export function useNodeLocalData({ } hasPendingLocalChangesRef.current = false; - localDataRef.current = persistedDataRef.current; - setLocalDataState(persistedDataRef.current); + acknowledgedSaveVersionRef.current = 0; + localDataRef.current = acceptedPersistedDataRef.current; + setLocalDataState(acceptedPersistedDataRef.current); clearPreviewNodeDataOverride(nodeId); }); }, saveDelayMs); useEffect(() => { const incomingData = normalize(data); - persistedDataRef.current = incomingData; const incomingHash = hashNodeData(incomingData); const localHash = hashNodeData(localDataRef.current); + const acceptedPersistedHash = hashNodeData(acceptedPersistedDataRef.current); if (incomingHash === localHash) { + acceptedPersistedDataRef.current = incomingData; hasPendingLocalChangesRef.current = false; + acknowledgedSaveVersionRef.current = 0; clearPreviewNodeDataOverride(nodeId); return; } if (hasPendingLocalChangesRef.current) { - logNodeDataDebug("skip-stale-external-data", { - nodeType: debugLabel, - incomingHash, - localHash, - }); - return; + const saveAcknowledgedForCurrentVersion = + acknowledgedSaveVersionRef.current === localChangeVersionRef.current; + const shouldKeepBlockingIncomingData = + !saveAcknowledgedForCurrentVersion || incomingHash === acceptedPersistedHash; + + if (shouldKeepBlockingIncomingData) { + logNodeDataDebug("skip-stale-external-data", { + nodeId, + nodeType: debugLabel, + incomingHash, + localHash, + saveAcknowledgedForCurrentVersion, + }); + return; + } } const timer = window.setTimeout(() => { + acceptedPersistedDataRef.current = incomingData; + hasPendingLocalChangesRef.current = false; + acknowledgedSaveVersionRef.current = 0; localDataRef.current = incomingData; setLocalDataState(incomingData); clearPreviewNodeDataOverride(nodeId); @@ -123,7 +139,7 @@ export function useNodeLocalData({ setPreviewNodeDataOverride(nodeId, next); queueSave(); }, - [debugLabel, nodeId, queueSave, setPreviewNodeDataOverride], + [nodeId, queueSave, setPreviewNodeDataOverride], ); const updateLocalData = useCallback( @@ -137,7 +153,7 @@ export function useNodeLocalData({ setPreviewNodeDataOverride(nodeId, next); queueSave(); }, - [debugLabel, nodeId, queueSave, setPreviewNodeDataOverride], + [nodeId, queueSave, setPreviewNodeDataOverride], ); return { diff --git a/components/canvas/use-canvas-connections.ts b/components/canvas/use-canvas-connections.ts index 43c44c0..2f0b336 100644 --- a/components/canvas/use-canvas-connections.ts +++ b/components/canvas/use-canvas-connections.ts @@ -10,11 +10,19 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import type { CanvasNodeType } from "@/lib/canvas-node-types"; -import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers"; -import { resolveDroppedConnectionTarget } from "./canvas-helpers"; +import { + getConnectEndClientPoint, + hasHandleKey, + isOptimisticEdgeId, + isOptimisticNodeId, + logCanvasConnectionDebug, + normalizeHandle, + resolveDroppedConnectionTarget, +} from "./canvas-helpers"; import { validateCanvasConnection, validateCanvasConnectionByType, + validateCanvasEdgeSplit, } from "./canvas-connection-validation"; import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu"; @@ -43,6 +51,15 @@ type UseCanvasConnectionsParams = { sourceHandle?: string; targetHandle?: string; }) => Promise; + runSplitEdgeAtExistingNodeMutation: (args: { + canvasId: Id<"canvases">; + splitEdgeId: Id<"edges">; + middleNodeId: Id<"nodes">; + splitSourceHandle?: string; + splitTargetHandle?: string; + newNodeSourceHandle?: string; + newNodeTargetHandle?: string; + }) => Promise; runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise; runCreateNodeWithEdgeFromSourceOnlineOnly: (args: { canvasId: Id<"canvases">; @@ -92,6 +109,7 @@ export function useCanvasConnections({ screenToFlowPosition, syncPendingMoveForClientRequest, runCreateEdgeMutation, + runSplitEdgeAtExistingNodeMutation, runRemoveEdgeMutation, runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly, @@ -107,8 +125,13 @@ export function useCanvasConnections({ connectionDropMenuRef.current = connectionDropMenu; }, [connectionDropMenu]); - const onConnectStart = useCallback(() => { + const onConnectStart = useCallback((_event, params) => { isConnectDragActiveRef.current = true; + logCanvasConnectionDebug("connect:start", { + nodeId: params.nodeId, + handleId: params.handleId, + handleType: params.handleType, + }); }, []); const onConnect = useCallback( @@ -116,11 +139,33 @@ export function useCanvasConnections({ isConnectDragActiveRef.current = false; const validationError = validateCanvasConnection(connection, nodes, edges); if (validationError) { + logCanvasConnectionDebug("connect:invalid-direct", { + sourceNodeId: connection.source ?? null, + targetNodeId: connection.target ?? null, + sourceHandle: connection.sourceHandle ?? null, + targetHandle: connection.targetHandle ?? null, + validationError, + }); showConnectionRejectedToast(validationError); return; } - if (!connection.source || !connection.target) return; + if (!connection.source || !connection.target) { + logCanvasConnectionDebug("connect:missing-endpoint", { + sourceNodeId: connection.source ?? null, + targetNodeId: connection.target ?? null, + sourceHandle: connection.sourceHandle ?? null, + targetHandle: connection.targetHandle ?? null, + }); + return; + } + + logCanvasConnectionDebug("connect:direct", { + sourceNodeId: connection.source, + targetNodeId: connection.target, + sourceHandle: connection.sourceHandle ?? null, + targetHandle: connection.targetHandle ?? null, + }); void runCreateEdgeMutation({ canvasId, @@ -136,18 +181,71 @@ export function useCanvasConnections({ const onConnectEnd = useCallback( (event, connectionState) => { if (!isConnectDragActiveRef.current) { + logCanvasConnectionDebug("connect:end-ignored", { + reason: "drag-not-active", + isValid: connectionState.isValid ?? null, + fromNodeId: connectionState.fromNode?.id ?? null, + fromHandleId: connectionState.fromHandle?.id ?? null, + toNodeId: connectionState.toNode?.id ?? null, + toHandleId: connectionState.toHandle?.id ?? null, + }); return; } isConnectDragActiveRef.current = false; - if (isReconnectDragActiveRef.current) return; - if (connectionState.isValid === true) return; + if (isReconnectDragActiveRef.current) { + logCanvasConnectionDebug("connect:end-ignored", { + reason: "reconnect-active", + isValid: connectionState.isValid ?? null, + fromNodeId: connectionState.fromNode?.id ?? null, + fromHandleId: connectionState.fromHandle?.id ?? null, + toNodeId: connectionState.toNode?.id ?? null, + toHandleId: connectionState.toHandle?.id ?? null, + }); + return; + } + if (connectionState.isValid === true) { + logCanvasConnectionDebug("connect:end-ignored", { + reason: "react-flow-valid-connection", + fromNodeId: connectionState.fromNode?.id ?? null, + fromHandleId: connectionState.fromHandle?.id ?? null, + toNodeId: connectionState.toNode?.id ?? null, + toHandleId: connectionState.toHandle?.id ?? null, + }); + return; + } const fromNode = connectionState.fromNode; const fromHandle = connectionState.fromHandle; - if (!fromNode || !fromHandle) return; + if (!fromNode || !fromHandle) { + logCanvasConnectionDebug("connect:end-aborted", { + reason: "missing-from-node-or-handle", + fromNodeId: fromNode?.id ?? null, + fromHandleId: fromHandle?.id ?? null, + toNodeId: connectionState.toNode?.id ?? null, + toHandleId: connectionState.toHandle?.id ?? null, + }); + return; + } const pt = getConnectEndClientPoint(event); - if (!pt) return; + if (!pt) { + logCanvasConnectionDebug("connect:end-aborted", { + reason: "missing-client-point", + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? null, + fromHandleType: fromHandle.type, + }); + return; + } + + logCanvasConnectionDebug("connect:end", { + point: pt, + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? null, + fromHandleType: fromHandle.type, + toNodeId: connectionState.toNode?.id ?? null, + toHandleId: connectionState.toHandle?.id ?? null, + }); const flow = screenToFlowPosition({ x: pt.x, y: pt.y }); const droppedConnection = resolveDroppedConnectionTarget({ @@ -159,6 +257,15 @@ export function useCanvasConnections({ edges: edgesRef.current, }); + logCanvasConnectionDebug("connect:end-drop-result", { + point: pt, + flow, + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? null, + fromHandleType: fromHandle.type, + droppedConnection, + }); + if (droppedConnection) { const validationError = validateCanvasConnection( { @@ -171,10 +278,75 @@ export function useCanvasConnections({ edgesRef.current, ); if (validationError) { + const fullFromNode = nodesRef.current.find((node) => node.id === fromNode.id); + const splitHandles = NODE_HANDLE_MAP[fullFromNode?.type ?? ""]; + const incomingEdges = edgesRef.current.filter( + (edge) => + edge.target === droppedConnection.targetNodeId && + edge.className !== "temp" && + !isOptimisticEdgeId(edge.id), + ); + const incomingEdge = incomingEdges.length === 1 ? incomingEdges[0] : undefined; + const splitValidationError = + validationError === "adjustment-incoming-limit" && + droppedConnection.sourceNodeId === fromNode.id && + fromHandle.type === "source" && + fullFromNode !== undefined && + splitHandles !== undefined && + hasHandleKey(splitHandles, "source") && + hasHandleKey(splitHandles, "target") && + incomingEdge !== undefined && + incomingEdge.source !== fullFromNode.id && + incomingEdge.target !== fullFromNode.id + ? validateCanvasEdgeSplit({ + nodes: nodesRef.current, + edges: edgesRef.current, + splitEdge: incomingEdge, + middleNode: fullFromNode, + }) + : null; + + if (!splitValidationError && incomingEdge && fullFromNode && splitHandles) { + logCanvasConnectionDebug("connect:end-auto-split", { + point: pt, + flow, + droppedConnection, + splitEdgeId: incomingEdge.id, + middleNodeId: fullFromNode.id, + }); + void runSplitEdgeAtExistingNodeMutation({ + canvasId, + splitEdgeId: incomingEdge.id as Id<"edges">, + middleNodeId: fullFromNode.id as Id<"nodes">, + splitSourceHandle: normalizeHandle(incomingEdge.sourceHandle), + splitTargetHandle: normalizeHandle(incomingEdge.targetHandle), + newNodeSourceHandle: normalizeHandle(splitHandles.source), + newNodeTargetHandle: normalizeHandle(splitHandles.target), + }); + return; + } + + logCanvasConnectionDebug("connect:end-drop-rejected", { + point: pt, + flow, + droppedConnection, + validationError, + attemptedAutoSplit: + validationError === "adjustment-incoming-limit" && + droppedConnection.sourceNodeId === fromNode.id && + fromHandle.type === "source", + splitValidationError, + }); showConnectionRejectedToast(validationError); return; } + logCanvasConnectionDebug("connect:end-create-edge", { + point: pt, + flow, + droppedConnection, + }); + void runCreateEdgeMutation({ canvasId, sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">, @@ -185,6 +357,14 @@ export function useCanvasConnections({ return; } + logCanvasConnectionDebug("connect:end-open-menu", { + point: pt, + flow, + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? null, + fromHandleType: fromHandle.type, + }); + setConnectionDropMenu({ screenX: pt.x, screenY: pt.y, @@ -201,6 +381,7 @@ export function useCanvasConnections({ isReconnectDragActiveRef, nodesRef, runCreateEdgeMutation, + runSplitEdgeAtExistingNodeMutation, screenToFlowPosition, showConnectionRejectedToast, ], diff --git a/components/canvas/use-canvas-drop.ts b/components/canvas/use-canvas-drop.ts index 248a3b7..f0826a9 100644 --- a/components/canvas/use-canvas-drop.ts +++ b/components/canvas/use-canvas-drop.ts @@ -4,19 +4,34 @@ import type { Id } from "@/convex/_generated/dataModel"; import { CANVAS_NODE_DND_MIME, } from "@/lib/canvas-connection-policy"; -import { NODE_DEFAULTS } from "@/lib/canvas-utils"; +import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; import { isCanvasNodeType, type CanvasNodeType, } from "@/lib/canvas-node-types"; import { toast } from "@/lib/toast"; +import { + getIntersectedEdgeId, + hasHandleKey, + isOptimisticEdgeId, + logCanvasConnectionDebug, + normalizeHandle, +} from "./canvas-helpers"; import { getImageDimensions } from "./canvas-media-utils"; type UseCanvasDropParams = { canvasId: Id<"canvases">; isSyncOnline: boolean; t: (key: string) => string; + edges: Array<{ + id: string; + source: string; + target: string; + className?: string; + sourceHandle?: string | null; + targetHandle?: string | null; + }>; screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number }; generateUploadUrl: () => Promise; runCreateNodeOnlineOnly: (args: { @@ -29,6 +44,21 @@ type UseCanvasDropParams = { data: Record; clientRequestId?: string; }) => Promise>; + runCreateNodeWithEdgeSplitOnlineOnly: (args: { + canvasId: Id<"canvases">; + type: CanvasNodeType; + positionX: number; + positionY: number; + width: number; + height: number; + data: Record; + splitEdgeId: Id<"edges">; + newNodeTargetHandle?: string; + newNodeSourceHandle?: string; + splitSourceHandle?: string; + splitTargetHandle?: string; + clientRequestId?: string; + }) => Promise>; notifyOfflineUnsupported: (featureLabel: string) => void; syncPendingMoveForClientRequest: ( clientRequestId: string, @@ -66,9 +96,11 @@ export function useCanvasDrop({ canvasId, isSyncOnline, t, + edges, screenToFlowPosition, generateUploadUrl, runCreateNodeOnlineOnly, + runCreateNodeWithEdgeSplitOnlineOnly, notifyOfflineUnsupported, syncPendingMoveForClientRequest, }: UseCanvasDropParams) { @@ -169,23 +201,92 @@ export function useCanvasDrop({ x: event.clientX, y: event.clientY, }); + const intersectedEdgeId = + typeof document !== "undefined" && + typeof document.elementsFromPoint === "function" + ? getIntersectedEdgeId({ + x: event.clientX, + y: event.clientY, + }) + : null; const defaults = NODE_DEFAULTS[parsedPayload.nodeType] ?? { width: 200, height: 100, data: {}, }; const clientRequestId = crypto.randomUUID(); + const hitEdge = intersectedEdgeId + ? edges.find( + (edge) => + edge.id === intersectedEdgeId && + edge.className !== "temp" && + !isOptimisticEdgeId(edge.id), + ) + : undefined; + const handles = NODE_HANDLE_MAP[parsedPayload.nodeType]; + const canSplitEdge = + hitEdge !== undefined && + handles !== undefined && + hasHandleKey(handles, "source") && + hasHandleKey(handles, "target"); - void runCreateNodeOnlineOnly({ - canvasId, - type: parsedPayload.nodeType, - positionX: position.x, - positionY: position.y, - width: defaults.width, - height: defaults.height, - data: { ...defaults.data, ...parsedPayload.payloadData, canvasId }, - clientRequestId, - }).then((realId) => { + logCanvasConnectionDebug("node-drop", { + nodeType: parsedPayload.nodeType, + clientPoint: { x: event.clientX, y: event.clientY }, + flowPoint: position, + intersectedEdgeId, + hitEdgeId: hitEdge?.id ?? null, + usesEdgeSplitPath: canSplitEdge, + }); + + const createNodePromise = canSplitEdge + ? (() => { + logCanvasConnectionDebug("node-drop:split-edge", { + nodeType: parsedPayload.nodeType, + clientPoint: { x: event.clientX, y: event.clientY }, + flowPoint: position, + intersectedEdgeId, + splitEdgeId: hitEdge.id, + }); + return runCreateNodeWithEdgeSplitOnlineOnly({ + canvasId, + type: parsedPayload.nodeType, + positionX: position.x, + positionY: position.y, + width: defaults.width, + height: defaults.height, + data: { ...defaults.data, ...parsedPayload.payloadData, canvasId }, + splitEdgeId: hitEdge.id as Id<"edges">, + newNodeTargetHandle: normalizeHandle(handles.target), + newNodeSourceHandle: normalizeHandle(handles.source), + splitSourceHandle: normalizeHandle(hitEdge.sourceHandle), + splitTargetHandle: normalizeHandle(hitEdge.targetHandle), + clientRequestId, + }); + })() + : (() => { + if (intersectedEdgeId) { + logCanvasConnectionDebug("node-drop:edge-detected-no-split", { + nodeType: parsedPayload.nodeType, + clientPoint: { x: event.clientX, y: event.clientY }, + flowPoint: position, + intersectedEdgeId, + }); + } + + return runCreateNodeOnlineOnly({ + canvasId, + type: parsedPayload.nodeType, + positionX: position.x, + positionY: position.y, + width: defaults.width, + height: defaults.height, + data: { ...defaults.data, ...parsedPayload.payloadData, canvasId }, + clientRequestId, + }); + })(); + + void createNodePromise.then((realId) => { void syncPendingMoveForClientRequest(clientRequestId, realId).catch( (error: unknown) => { console.error("[Canvas] createNode syncPendingMove failed", error); @@ -195,9 +296,11 @@ export function useCanvasDrop({ }, [ canvasId, + edges, generateUploadUrl, isSyncOnline, notifyOfflineUnsupported, + runCreateNodeWithEdgeSplitOnlineOnly, runCreateNodeOnlineOnly, screenToFlowPosition, syncPendingMoveForClientRequest, diff --git a/components/canvas/use-canvas-edge-insertions.ts b/components/canvas/use-canvas-edge-insertions.ts new file mode 100644 index 0000000..d1638d0 --- /dev/null +++ b/components/canvas/use-canvas-edge-insertions.ts @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +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 type { CanvasNodeType } from "@/lib/canvas-node-types"; +import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; + +import { + computeEdgeInsertLayout, + hasHandleKey, + isOptimisticEdgeId, + normalizeHandle, +} from "./canvas-helpers"; +import { validateCanvasEdgeSplit } from "./canvas-connection-validation"; + +export type EdgeInsertMenuState = { + edgeId: string; + screenX: number; + screenY: number; +}; + +const EDGE_INSERT_GAP_PX = 10; + +type UseCanvasEdgeInsertionsArgs = { + canvasId: Id<"canvases">; + nodes: RFNode[]; + edges: RFEdge[]; + runCreateNodeWithEdgeSplitOnlineOnly: (args: { + canvasId: Id<"canvases">; + type: CanvasNodeType; + positionX: number; + positionY: number; + width: number; + height: number; + data: Record; + splitEdgeId: Id<"edges">; + newNodeTargetHandle?: string; + newNodeSourceHandle?: string; + splitSourceHandle?: string; + splitTargetHandle?: string; + clientRequestId?: string; + }) => Promise | string>; + runBatchMoveNodesMutation: (args: { + moves: { + nodeId: Id<"nodes">; + positionX: number; + positionY: number; + }[]; + }) => Promise; + showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void; +}; + +export function useCanvasEdgeInsertions({ + canvasId, + nodes, + edges, + runCreateNodeWithEdgeSplitOnlineOnly, + runBatchMoveNodesMutation, + showConnectionRejectedToast, +}: UseCanvasEdgeInsertionsArgs) { + const [edgeInsertMenu, setEdgeInsertMenu] = useState(null); + const edgeInsertMenuRef = useRef(null); + + useEffect(() => { + edgeInsertMenuRef.current = edgeInsertMenu; + }, [edgeInsertMenu]); + + const closeEdgeInsertMenu = useCallback(() => { + setEdgeInsertMenu(null); + }, []); + + const openEdgeInsertMenu = useCallback( + ({ edgeId, screenX, screenY }: EdgeInsertMenuState) => { + const edge = edges.find( + (candidate) => + candidate.id === edgeId && + candidate.className !== "temp" && + !isOptimisticEdgeId(candidate.id), + ); + if (!edge) { + return; + } + + setEdgeInsertMenu({ edgeId, screenX, screenY }); + }, + [edges], + ); + + const handleEdgeInsertPick = useCallback( + async (template: CanvasNodeTemplate) => { + const menu = edgeInsertMenuRef.current; + if (!menu) { + return; + } + + const splitEdge = edges.find( + (edge) => + edge.id === menu.edgeId && edge.className !== "temp" && !isOptimisticEdgeId(edge.id), + ); + if (!splitEdge) { + showConnectionRejectedToast("unknown-node"); + return; + } + + const sourceNode = nodes.find((node) => node.id === splitEdge.source); + const targetNode = nodes.find((node) => node.id === splitEdge.target); + if (!sourceNode || !targetNode) { + showConnectionRejectedToast("unknown-node"); + return; + } + + const defaults = NODE_DEFAULTS[template.type] ?? { + width: 200, + height: 100, + data: {}, + }; + const width = template.width ?? defaults.width; + const height = template.height ?? defaults.height; + const handles = NODE_HANDLE_MAP[template.type]; + if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) { + showConnectionRejectedToast("unknown-node"); + return; + } + + const middleNode: RFNode = { + id: "__pending_edge_insert__", + type: template.type, + position: { x: 0, y: 0 }, + data: {}, + }; + + const splitValidationError = validateCanvasEdgeSplit({ + nodes, + edges, + splitEdge, + middleNode, + }); + + if (splitValidationError) { + showConnectionRejectedToast(splitValidationError); + return; + } + + const layout = computeEdgeInsertLayout({ + sourceNode, + targetNode, + newNodeWidth: width, + newNodeHeight: height, + gapPx: EDGE_INSERT_GAP_PX, + }); + + await runCreateNodeWithEdgeSplitOnlineOnly({ + canvasId, + type: template.type, + positionX: layout.insertPosition.x, + positionY: layout.insertPosition.y, + width, + height, + data: { + ...defaults.data, + ...(template.defaultData as Record), + canvasId, + }, + splitEdgeId: splitEdge.id as Id<"edges">, + newNodeTargetHandle: normalizeHandle(handles.target), + newNodeSourceHandle: normalizeHandle(handles.source), + splitSourceHandle: normalizeHandle(splitEdge.sourceHandle), + 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, + runBatchMoveNodesMutation, + runCreateNodeWithEdgeSplitOnlineOnly, + showConnectionRejectedToast, + ], + ); + + return { + edgeInsertMenu, + openEdgeInsertMenu, + closeEdgeInsertMenu, + handleEdgeInsertPick, + }; +} diff --git a/components/canvas/use-canvas-edge-types.tsx b/components/canvas/use-canvas-edge-types.tsx new file mode 100644 index 0000000..75e4bdd --- /dev/null +++ b/components/canvas/use-canvas-edge-types.tsx @@ -0,0 +1,51 @@ +import { useEffect, useMemo, useRef } from "react"; +import type { EdgeTypes } from "@xyflow/react"; + +import { isOptimisticEdgeId } from "@/components/canvas/canvas-helpers"; +import type { DefaultEdgeInsertAnchor } from "@/components/canvas/edges/default-edge"; +import DefaultEdge from "@/components/canvas/edges/default-edge"; + +type UseCanvasEdgeTypesArgs = { + edgeInsertMenuEdgeId: string | null; + scissorsMode: boolean; + onInsertClick: (anchor: DefaultEdgeInsertAnchor) => void; +}; + +export function useCanvasEdgeTypes({ + edgeInsertMenuEdgeId, + scissorsMode, + onInsertClick, +}: UseCanvasEdgeTypesArgs): EdgeTypes { + const edgeInsertMenuEdgeIdRef = useRef(edgeInsertMenuEdgeId); + const scissorsModeRef = useRef(scissorsMode); + const onInsertClickRef = useRef(onInsertClick); + + useEffect(() => { + edgeInsertMenuEdgeIdRef.current = edgeInsertMenuEdgeId; + scissorsModeRef.current = scissorsMode; + onInsertClickRef.current = onInsertClick; + }, [edgeInsertMenuEdgeId, onInsertClick, scissorsMode]); + + return useMemo( + () => ({ + "canvas-default": (edgeProps: Parameters[0]) => { + const edgeClassName = (edgeProps as { className?: string }).className; + const isInsertableEdge = + edgeClassName !== "temp" && !isOptimisticEdgeId(edgeProps.id); + + return ( + + ); + }, + }), + [], + ); +} diff --git a/components/canvas/use-canvas-flow-reconciliation.ts b/components/canvas/use-canvas-flow-reconciliation.ts index f7f50f1..8eb68fb 100644 --- a/components/canvas/use-canvas-flow-reconciliation.ts +++ b/components/canvas/use-canvas-flow-reconciliation.ts @@ -20,6 +20,7 @@ type CanvasFlowReconciliationRefs = { pendingLocalPositionUntilConvexMatchesRef: MutableRefObject< Map >; + pendingLocalNodeDataUntilConvexMatchesRef: MutableRefObject>; preferLocalPositionNodeIdsRef: MutableRefObject>; isDragging: MutableRefObject; isResizing: MutableRefObject; @@ -54,6 +55,7 @@ export function useCanvasFlowReconciliation(args: { resolvedRealIdByClientRequestRef, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, + pendingLocalNodeDataUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, isDragging, isResizing, @@ -131,6 +133,8 @@ export function useCanvasFlowReconciliation(args: { pendingConnectionCreateIds: pendingConnectionCreatesRef.current, preferLocalPositionNodeIds: preferLocalPositionNodeIdsRef.current, pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current, + pendingLocalNodeDataPins: + pendingLocalNodeDataUntilConvexMatchesRef.current, pendingMovePins, }); @@ -138,6 +142,8 @@ export function useCanvasFlowReconciliation(args: { reconciliation.inferredRealIdByClientRequest; pendingLocalPositionUntilConvexMatchesRef.current = reconciliation.nextPendingLocalPositionPins; + pendingLocalNodeDataUntilConvexMatchesRef.current = + reconciliation.nextPendingLocalNodeDataPins; for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) { preferLocalPositionNodeIdsRef.current.delete(nodeId); } @@ -155,6 +161,7 @@ export function useCanvasFlowReconciliation(args: { isResizing, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, + pendingLocalNodeDataUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, resolvedRealIdByClientRequestRef, ]); diff --git a/components/canvas/use-canvas-sync-engine.ts b/components/canvas/use-canvas-sync-engine.ts index 019ea72..716f2e8 100644 --- a/components/canvas/use-canvas-sync-engine.ts +++ b/components/canvas/use-canvas-sync-engine.ts @@ -205,6 +205,9 @@ export function createCanvasSyncEngineController({ const pendingLocalPositionUntilConvexMatchesRef = { current: new Map(), }; + const pendingLocalNodeDataUntilConvexMatchesRef = { + current: new Map(), + }; const preferLocalPositionNodeIdsRef = { current: new Set() }; const flushPendingResizeForClientRequest = async ( @@ -221,6 +224,21 @@ export function createCanvasSyncEngineController({ }); }; + const pinNodeDataLocally = (nodeId: string, data: unknown): void => { + pendingLocalNodeDataUntilConvexMatchesRef.current.set(nodeId, data); + const setNodes = getSetNodes?.(); + setNodes?.((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + data: data as Record, + } + : node, + ), + ); + }; + const flushPendingDataForClientRequest = async ( clientRequestId: string, realId: Id<"nodes">, @@ -228,6 +246,7 @@ export function createCanvasSyncEngineController({ if (!pendingDataAfterCreateRef.current.has(clientRequestId)) return; const pendingData = pendingDataAfterCreateRef.current.get(clientRequestId); pendingDataAfterCreateRef.current.delete(clientRequestId); + pinNodeDataLocally(realId as string, pendingData); await getEnqueueSyncMutation()("updateData", { nodeId: realId, data: pendingData, @@ -272,6 +291,7 @@ export function createCanvasSyncEngineController({ data: unknown; }): Promise => { const rawNodeId = args.nodeId as string; + pinNodeDataLocally(rawNodeId, args.data); if (!isOptimisticNodeId(rawNodeId) || !getIsSyncOnline()) { await getEnqueueSyncMutation()("updateData", args); return; @@ -311,6 +331,7 @@ export function createCanvasSyncEngineController({ pendingMoveAfterCreateRef.current.delete(clientRequestId); pendingResizeAfterCreateRef.current.delete(clientRequestId); pendingDataAfterCreateRef.current.delete(clientRequestId); + pendingLocalNodeDataUntilConvexMatchesRef.current.delete(realId as string); pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); pendingConnectionCreatesRef.current.delete(clientRequestId); resolvedRealIdByClientRequestRef.current.delete(clientRequestId); @@ -459,6 +480,7 @@ export function createCanvasSyncEngineController({ pendingDeleteAfterCreateClientRequestIdsRef, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, + pendingLocalNodeDataUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, flushPendingResizeForClientRequest, flushPendingDataForClientRequest, @@ -998,6 +1020,9 @@ export function useCanvasSyncEngine({ controller.pendingMoveAfterCreateRef.current.delete(args.clientRequestId); controller.pendingResizeAfterCreateRef.current.delete(args.clientRequestId); controller.pendingDataAfterCreateRef.current.delete(args.clientRequestId); + controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete( + optimisticNodeId, + ); pendingCreatePromiseByClientRequestRef.current.delete(args.clientRequestId); controller.pendingEdgeSplitByClientRequestRef.current.delete( args.clientRequestId, @@ -1083,6 +1108,20 @@ export function useCanvasSyncEngine({ ); } + const pinnedData = + controller.pendingLocalNodeDataUntilConvexMatchesRef.current.get( + optimisticNodeId, + ); + if (pinnedData !== undefined) { + controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete( + optimisticNodeId, + ); + controller.pendingLocalNodeDataUntilConvexMatchesRef.current.set( + realNodeId, + pinnedData, + ); + } + if ( controller.preferLocalPositionNodeIdsRef.current.has(optimisticNodeId) ) { @@ -1655,6 +1694,10 @@ export function useCanvasSyncEngine({ for (const nodeId of op.payload.nodeIds) { deletingNodeIds.current.delete(nodeId as string); } + } else if (op.type === "updateData") { + controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete( + op.payload.nodeId as string, + ); } await ackCanvasSyncOp(op.id); resolveCanvasOp(canvasId as string, op.id); @@ -1767,6 +1810,8 @@ export function useCanvasSyncEngine({ pendingConnectionCreatesRef: controller.pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef: controller.pendingLocalPositionUntilConvexMatchesRef, + pendingLocalNodeDataUntilConvexMatchesRef: + controller.pendingLocalNodeDataUntilConvexMatchesRef, preferLocalPositionNodeIdsRef: controller.preferLocalPositionNodeIdsRef, pendingCreatePromiseByClientRequestRef, }, diff --git a/vitest.config.ts b/vitest.config.ts index 507848e..444c7d5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,12 +12,16 @@ export default defineConfig({ include: [ "tests/**/*.test.ts", "components/canvas/__tests__/canvas-helpers.test.ts", + "components/canvas/__tests__/default-edge.test.tsx", + "components/canvas/__tests__/canvas-connection-drop-menu.test.tsx", "components/canvas/__tests__/canvas-connection-drop-target.test.tsx", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", "components/canvas/__tests__/compare-node.test.tsx", "components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts", "components/canvas/__tests__/use-canvas-drop.test.tsx", "components/canvas/__tests__/use-canvas-connections.test.tsx", + "components/canvas/__tests__/use-canvas-edge-insertions.test.tsx", + "components/canvas/__tests__/use-canvas-edge-types.test.tsx", "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/use-node-local-data.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine.test.ts",