feat(canvas): enhance edge insertion and local node data handling

- Added support for new edge insertion features, including default edge types and improved layout calculations.
- Introduced local node data persistence during flow reconciliation to ensure data integrity.
- Updated connection drop menu to handle edge insertions and node interactions more effectively.
- Enhanced testing for edge insert layout and local node data management.
This commit is contained in:
2026-04-05 21:26:20 +02:00
parent de37b63b2b
commit 7c34da45b4
24 changed files with 2404 additions and 63 deletions

View File

@@ -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 }) => <div>{children}</div>,
CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandInput: () => <input aria-label="command-input" />,
CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/canvas/canvas-node-template-picker", () => ({
CanvasNodeTemplatePicker: ({
onPick,
}: {
onPick: (template: CanvasNodeTemplate) => void;
}) => (
<button
type="button"
data-testid="pick-template"
onClick={() => onPick({ type: "note", label: "Notiz" } as CanvasNodeTemplate)}
>
pick
</button>
),
}));
(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(
<CanvasConnectionDropMenu
anchor={{ screenX: 140, screenY: 200 }}
onClose={onClose}
onPick={onPick}
/>,
);
});
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);
});
});

View File

@@ -256,6 +256,96 @@ describe("canvas flow reconciliation helpers", () => {
expect(result.nextPendingLocalPositionPins.size).toBe(0); 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", () => { it("filters deleting nodes from incoming reconciliation results", () => {
const result = reconcileCanvasFlowNodes({ const result = reconcileCanvasFlowNodes({
previousNodes: [ previousNodes: [

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { withResolvedCompareData } from "../canvas-helpers"; import { computeEdgeInsertLayout, withResolvedCompareData } from "../canvas-helpers";
import { import {
buildGraphSnapshot, buildGraphSnapshot,
pruneCanvasGraphNodeDataOverrides, 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();
});
});

View File

@@ -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<typeof import("@xyflow/react")>(
"@xyflow/react",
);
return {
...actual,
EdgeLabelRenderer: ({ children }: { children: ReactNode }) => (
<foreignObject>{children}</foreignObject>
),
};
});
(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<DefaultEdgeRenderProps> = {}) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<svg>
<DefaultEdgeComponent {...baseProps} {...overrides} />
</svg>,
);
});
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(
<svg>
<DefaultEdgeComponent {...baseProps} onInsertClick={onInsertClick} isMenuOpen />
</svg>,
);
});
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();
});
});

View File

@@ -44,19 +44,27 @@ const latestHandlersRef: {
type HookHarnessProps = { type HookHarnessProps = {
helperResult: DroppedConnectionTarget | null; helperResult: DroppedConnectionTarget | null;
runCreateEdgeMutation?: ReturnType<typeof vi.fn>; runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
runSplitEdgeAtExistingNodeMutation?: ReturnType<typeof vi.fn>;
showConnectionRejectedToast?: ReturnType<typeof vi.fn>; showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
nodes?: RFNode[];
edges?: RFEdge[];
}; };
function HookHarness({ function HookHarness({
helperResult, helperResult,
runCreateEdgeMutation = vi.fn(async () => undefined), runCreateEdgeMutation = vi.fn(async () => undefined),
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
showConnectionRejectedToast = vi.fn(), showConnectionRejectedToast = vi.fn(),
nodes: providedNodes,
edges: providedEdges,
}: HookHarnessProps) { }: HookHarnessProps) {
const [nodes] = useState<RFNode[]>([ const [nodes] = useState<RFNode[]>(
providedNodes ?? [
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} }, { id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} }, { id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} },
]); ],
const [edges] = useState<RFEdge[]>([]); );
const [edges] = useState<RFEdge[]>(providedEdges ?? []);
const nodesRef = useRef(nodes); const nodesRef = useRef(nodes);
const edgesRef = useRef(edges); const edgesRef = useRef(edges);
const edgeReconnectSuccessful = useRef(true); const edgeReconnectSuccessful = useRef(true);
@@ -90,9 +98,10 @@ function HookHarness({
resolvedRealIdByClientRequestRef, resolvedRealIdByClientRequestRef,
setEdges, setEdges,
setEdgeSyncNonce, setEdgeSyncNonce,
screenToFlowPosition: (position) => position, screenToFlowPosition: (position: { x: number; y: number }) => position,
syncPendingMoveForClientRequest: vi.fn(async () => undefined), syncPendingMoveForClientRequest: vi.fn(async () => undefined),
runCreateEdgeMutation, runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation,
runRemoveEdgeMutation: vi.fn(async () => undefined), runRemoveEdgeMutation: vi.fn(async () => undefined),
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"), runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
runCreateNodeWithEdgeToTargetOnlineOnly: 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(
<HookHarness
helperResult={{
sourceNodeId: "node-curves",
targetNodeId: "node-light",
sourceHandle: undefined,
targetHandle: undefined,
}}
runCreateEdgeMutation={runCreateEdgeMutation}
runSplitEdgeAtExistingNodeMutation={runSplitEdgeAtExistingNodeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
nodes={[
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-curves", type: "curves", position: { x: 180, y: 120 }, data: {} },
{ id: "node-light", type: "light-adjust", position: { x: 360, y: 120 }, data: {} },
]}
edges={[
{
id: "edge-image-light",
source: "node-image",
target: "node-light",
},
]}
/>,
);
});
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 () => { it("ignores onConnectEnd when no connect drag is active", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined); const runCreateEdgeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn(); const showConnectionRejectedToast = vi.fn();

View File

@@ -2,6 +2,7 @@
import React, { act, useEffect } from "react"; import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client"; 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
@@ -33,26 +34,32 @@ type HookHarnessProps = {
isSyncOnline?: boolean; isSyncOnline?: boolean;
generateUploadUrl?: ReturnType<typeof vi.fn>; generateUploadUrl?: ReturnType<typeof vi.fn>;
runCreateNodeOnlineOnly?: ReturnType<typeof vi.fn>; runCreateNodeOnlineOnly?: ReturnType<typeof vi.fn>;
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
notifyOfflineUnsupported?: ReturnType<typeof vi.fn>; notifyOfflineUnsupported?: ReturnType<typeof vi.fn>;
syncPendingMoveForClientRequest?: ReturnType<typeof vi.fn>; syncPendingMoveForClientRequest?: ReturnType<typeof vi.fn>;
screenToFlowPosition?: (position: { x: number; y: number }) => { x: number; y: number }; screenToFlowPosition?: (position: { x: number; y: number }) => { x: number; y: number };
edges?: RFEdge[];
}; };
function HookHarness({ function HookHarness({
isSyncOnline = true, isSyncOnline = true,
generateUploadUrl = vi.fn(async () => "https://upload.test"), generateUploadUrl = vi.fn(async () => "https://upload.test"),
runCreateNodeOnlineOnly = vi.fn(async () => "node-1"), runCreateNodeOnlineOnly = vi.fn(async () => "node-1"),
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-1"),
notifyOfflineUnsupported = vi.fn(), notifyOfflineUnsupported = vi.fn(),
syncPendingMoveForClientRequest = vi.fn(async () => undefined), syncPendingMoveForClientRequest = vi.fn(async () => undefined),
screenToFlowPosition = (position) => position, screenToFlowPosition = (position) => position,
edges = [],
}: HookHarnessProps) { }: HookHarnessProps) {
const handlers = useCanvasDrop({ const handlers = useCanvasDrop({
canvasId: asCanvasId("canvas-1"), canvasId: asCanvasId("canvas-1"),
isSyncOnline, isSyncOnline,
t: ((key: string) => key) as (key: string) => string, t: ((key: string) => key) as (key: string) => string,
edges,
screenToFlowPosition, screenToFlowPosition,
generateUploadUrl, generateUploadUrl,
runCreateNodeOnlineOnly, runCreateNodeOnlineOnly,
runCreateNodeWithEdgeSplitOnlineOnly,
notifyOfflineUnsupported, notifyOfflineUnsupported,
syncPendingMoveForClientRequest, syncPendingMoveForClientRequest,
}); });
@@ -260,6 +267,72 @@ describe("useCanvasDrop", () => {
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-video"); 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(
<HookHarness
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
edges={[{ id: "edge-a", source: "node-1", target: "node-2" } as RFEdge]}
/>,
);
});
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 () => { it("shows an upload failure toast when the dropped file upload fails", async () => {
const generateUploadUrl = vi.fn(async () => "https://upload.test"); const generateUploadUrl = vi.fn(async () => "https://upload.test");
const runCreateNodeOnlineOnly = vi.fn(async () => "node-image"); const runCreateNodeOnlineOnly = vi.fn(async () => "node-image");

View File

@@ -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<typeof useCanvasEdgeInsertions> | 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<RFNode> & Pick<RFNode, "id">): RFNode {
return {
type: "note",
position: { x: 0, y: 0 },
style: { width: 100, height: 60 },
data: {},
...overrides,
} as RFNode;
}
function createEdge(
overrides: Partial<RFEdge> & Pick<RFEdge, "id" | "source" | "target">,
): RFEdge {
return {
...overrides,
} as RFEdge;
}
type HookHarnessProps = {
nodes: RFNode[];
edges: RFEdge[];
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
runBatchMoveNodesMutation?: ReturnType<typeof vi.fn>;
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
};
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(
<HookHarness
nodes={[
createNode({ id: "source", type: "image" }),
createNode({ id: "target", type: "text" }),
]}
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
/>,
);
});
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(
<HookHarness
nodes={[
createNode({ id: "source", type: "image" }),
createNode({ id: "target", type: "text" }),
]}
edges={[
createEdge({ id: "edge-temp", source: "source", target: "target", className: "temp" }),
createEdge({ id: "optimistic_edge_1", source: "source", target: "target" }),
]}
/>,
);
});
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(
<HookHarness
nodes={[
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "curves", position: { x: 300, y: 0 } }),
]}
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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(
<HookHarness
nodes={[
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "text", position: { x: 500, y: 0 } }),
]}
edges={[
createEdge({
id: "edge-1",
source: "source",
target: "target",
sourceHandle: "source-handle",
targetHandle: "target-handle",
}),
]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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(
<HookHarness
nodes={[
createNode({ id: "source", type: "image", position: { x: 0, y: 0 } }),
createNode({ id: "target", type: "text", position: { x: 120, y: 0 } }),
]}
edges={[createEdge({ id: "edge-1", source: "source", target: "target" })]}
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
runBatchMoveNodesMutation={runBatchMoveNodesMutation}
/>,
);
});
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 },
],
});
});
});

View File

@@ -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<typeof vi.fn>;
};
const latestRef: {
current: ReturnType<typeof useCanvasEdgeTypes> | 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(
<HookHarness
edgeInsertMenuEdgeId={null}
scissorsMode={false}
onInsertClick={onInsertClickA}
/>,
);
});
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(
<HookHarness
edgeInsertMenuEdgeId="edge-1"
scissorsMode
onInsertClick={onInsertClickB}
/>,
);
});
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,
}),
);
});
});
});

View File

@@ -38,6 +38,7 @@ type HarnessProps = {
pendingConnectionCreateIds: Set<string>; pendingConnectionCreateIds: Set<string>;
previousConvexNodeIdsSnapshot: Set<string>; previousConvexNodeIdsSnapshot: Set<string>;
pendingLocalPositionPins?: Map<string, { x: number; y: number }>; pendingLocalPositionPins?: Map<string, { x: number; y: number }>;
pendingLocalNodeDataPins?: Map<string, unknown>;
preferLocalPositionNodeIds?: Set<string>; preferLocalPositionNodeIds?: Set<string>;
isResizingRefOverride?: { current: boolean }; isResizingRefOverride?: { current: boolean };
}; };
@@ -78,6 +79,9 @@ function HookHarness(props: HarnessProps) {
const pendingLocalPositionUntilConvexMatchesRef = useRef( const pendingLocalPositionUntilConvexMatchesRef = useRef(
props.pendingLocalPositionPins ?? new Map<string, { x: number; y: number }>(), props.pendingLocalPositionPins ?? new Map<string, { x: number; y: number }>(),
); );
const pendingLocalNodeDataUntilConvexMatchesRef = useRef(
props.pendingLocalNodeDataPins ?? new Map<string, unknown>(),
);
const preferLocalPositionNodeIdsRef = useRef( const preferLocalPositionNodeIdsRef = useRef(
props.preferLocalPositionNodeIds ?? new Set<string>(), props.preferLocalPositionNodeIds ?? new Set<string>(),
); );
@@ -115,6 +119,7 @@ function HookHarness(props: HarnessProps) {
resolvedRealIdByClientRequestRef, resolvedRealIdByClientRequestRef,
pendingConnectionCreatesRef, pendingConnectionCreatesRef,
pendingLocalPositionUntilConvexMatchesRef, pendingLocalPositionUntilConvexMatchesRef,
pendingLocalNodeDataUntilConvexMatchesRef,
preferLocalPositionNodeIdsRef, preferLocalPositionNodeIdsRef,
isDragging: isDraggingRef, isDragging: isDraggingRef,
isResizing: isResizingRef, isResizing: isResizingRef,

View File

@@ -74,4 +74,44 @@ describe("useCanvasSyncEngine", () => {
expect(controller.pendingResizeAfterCreateRef.current.has("req-2")).toBe(false); expect(controller.pendingResizeAfterCreateRef.current.has("req-2")).toBe(false);
expect(controller.pendingDataAfterCreateRef.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 },
});
});
}); });

View File

@@ -445,4 +445,140 @@ describe("useNodeLocalData preview overrides", () => {
vi.useRealTimers(); 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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(latestHookRef.current?.localData).toEqual({
exposure: 0.8,
label: "local",
});
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.75, label: "server-normalized" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(latestHookRef.current?.localData).toEqual({
exposure: 0.75,
label: "server-normalized",
});
expect(latestOverridesRef.current).toEqual(new Map());
vi.useRealTimers();
});
}); });

View File

@@ -23,8 +23,13 @@ export type ConnectionDropMenuState = {
fromHandleType: "source" | "target"; fromHandleType: "source" | "target";
}; };
export type CanvasMenuAnchor = {
screenX: number;
screenY: number;
};
type CanvasConnectionDropMenuProps = { type CanvasConnectionDropMenuProps = {
state: ConnectionDropMenuState | null; anchor: CanvasMenuAnchor | null;
onClose: () => void; onClose: () => void;
onPick: (template: CanvasNodeTemplate) => void; onPick: (template: CanvasNodeTemplate) => void;
}; };
@@ -33,14 +38,14 @@ const PANEL_MAX_W = 360;
const PANEL_MAX_H = 420; const PANEL_MAX_H = 420;
export function CanvasConnectionDropMenu({ export function CanvasConnectionDropMenu({
state, anchor,
onClose, onClose,
onPick, onPick,
}: CanvasConnectionDropMenuProps) { }: CanvasConnectionDropMenuProps) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!state) return; if (!anchor) return;
const onEscape = (e: KeyboardEvent) => { const onEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -59,9 +64,9 @@ export function CanvasConnectionDropMenu({
document.removeEventListener("keydown", onEscape); document.removeEventListener("keydown", onEscape);
document.removeEventListener("pointerdown", onPointerDownCapture, true); document.removeEventListener("pointerdown", onPointerDownCapture, true);
}; };
}, [state, onClose]); }, [anchor, onClose]);
if (!state) return null; if (!anchor) return null;
const vw = const vw =
typeof window !== "undefined" ? window.innerWidth : PANEL_MAX_W + 16; 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; typeof window !== "undefined" ? window.innerHeight : PANEL_MAX_H + 16;
const left = Math.max( const left = Math.max(
8, 8,
Math.min(state.screenX, vw - PANEL_MAX_W - 8), Math.min(anchor.screenX, vw - PANEL_MAX_W - 8),
); );
const top = Math.max( const top = Math.max(
8, 8,
Math.min(state.screenY, vh - PANEL_MAX_H - 8), Math.min(anchor.screenY, vh - PANEL_MAX_H - 8),
); );
return ( return (

View File

@@ -277,6 +277,83 @@ function applyLocalPositionPins(args: {
}; };
} }
function isNodeDataRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> = { ...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<string, unknown>;
}): {
nodes: RFNode[];
nextPendingLocalNodeDataPins: Map<string, unknown>;
} {
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<string, unknown>,
};
});
return {
nodes,
nextPendingLocalNodeDataPins,
};
}
export function reconcileCanvasFlowNodes(args: { export function reconcileCanvasFlowNodes(args: {
previousNodes: RFNode[]; previousNodes: RFNode[];
incomingNodes: RFNode[]; incomingNodes: RFNode[];
@@ -286,11 +363,13 @@ export function reconcileCanvasFlowNodes(args: {
pendingConnectionCreateIds: ReadonlySet<string>; pendingConnectionCreateIds: ReadonlySet<string>;
preferLocalPositionNodeIds: ReadonlySet<string>; preferLocalPositionNodeIds: ReadonlySet<string>;
pendingLocalPositionPins: ReadonlyMap<string, { x: number; y: number }>; pendingLocalPositionPins: ReadonlyMap<string, { x: number; y: number }>;
pendingLocalNodeDataPins?: ReadonlyMap<string, unknown>;
pendingMovePins: ReadonlyMap<string, { x: number; y: number }>; pendingMovePins: ReadonlyMap<string, { x: number; y: number }>;
}): { }): {
nodes: RFNode[]; nodes: RFNode[];
inferredRealIdByClientRequest: Map<string, Id<"nodes">>; inferredRealIdByClientRequest: Map<string, Id<"nodes">>;
nextPendingLocalPositionPins: Map<string, { x: number; y: number }>; nextPendingLocalPositionPins: Map<string, { x: number; y: number }>;
nextPendingLocalNodeDataPins: Map<string, unknown>;
clearedPreferLocalPositionNodeIds: string[]; clearedPreferLocalPositionNodeIds: string[];
} { } {
const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({ const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({
@@ -309,8 +388,12 @@ export function reconcileCanvasFlowNodes(args: {
inferredRealIdByClientRequest, inferredRealIdByClientRequest,
args.preferLocalPositionNodeIds, args.preferLocalPositionNodeIds,
); );
const pinnedNodes = applyLocalPositionPins({ const dataPinnedNodes = applyLocalNodeDataPins({
nodes: mergedNodes, nodes: mergedNodes,
pendingLocalNodeDataPins: args.pendingLocalNodeDataPins ?? new Map(),
});
const pinnedNodes = applyLocalPositionPins({
nodes: dataPinnedNodes.nodes,
pendingLocalPositionPins: args.pendingLocalPositionPins, pendingLocalPositionPins: args.pendingLocalPositionPins,
}); });
const nodes = applyPinnedNodePositionsReadOnly( const nodes = applyPinnedNodePositionsReadOnly(
@@ -335,6 +418,7 @@ export function reconcileCanvasFlowNodes(args: {
nodes, nodes,
inferredRealIdByClientRequest, inferredRealIdByClientRequest,
nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins, nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins,
nextPendingLocalNodeDataPins: dataPinnedNodes.nextPendingLocalNodeDataPins,
clearedPreferLocalPositionNodeIds, clearedPreferLocalPositionNodeIds,
}; };
} }

View File

@@ -12,6 +12,121 @@ import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
export const OPTIMISTIC_NODE_PREFIX = "optimistic_"; export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_"; 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 { export function createCanvasOpId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID(); return crypto.randomUUID();
@@ -78,19 +193,50 @@ export type DroppedConnectionTarget = {
targetHandle?: string; targetHandle?: string;
}; };
function getNodeElementAtClientPoint(point: { x: number; y: number }): HTMLElement | null { function describeConnectionDebugElement(element: Element): Record<string, unknown> {
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<string, unknown>,
): 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") { if (typeof document === "undefined") {
return null; return null;
} }
const hit = document.elementsFromPoint(point.x, point.y).find((element) => { const hit = (elementsAtPoint ?? document.elementsFromPoint(point.x, point.y)).find(
(element) => {
if (!(element instanceof HTMLElement)) return false; if (!(element instanceof HTMLElement)) return false;
return ( return (
element.classList.contains("react-flow__node") && element.classList.contains("react-flow__node") &&
typeof element.dataset.id === "string" && typeof element.dataset.id === "string" &&
element.dataset.id.length > 0 element.dataset.id.length > 0
); );
}); },
);
return hit instanceof HTMLElement ? hit : null; return hit instanceof HTMLElement ? hit : null;
} }
@@ -133,25 +279,52 @@ export function resolveDroppedConnectionTarget(args: {
nodes: RFNode[]; nodes: RFNode[];
edges: RFEdge[]; edges: RFEdge[];
}): DroppedConnectionTarget | null { }): 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) { 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; return null;
} }
const targetNodeId = nodeElement.dataset.id; const targetNodeId = nodeElement.dataset.id;
if (!targetNodeId) { 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; return null;
} }
const targetNode = args.nodes.find((node) => node.id === targetNodeId); const targetNode = args.nodes.find((node) => node.id === targetNodeId);
if (!targetNode) { 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; return null;
} }
const handles = NODE_HANDLE_MAP[targetNode.type ?? ""]; const handles = NODE_HANDLE_MAP[targetNode.type ?? ""];
if (args.fromHandleType === "source") { if (args.fromHandleType === "source") {
return { const droppedConnection = {
sourceNodeId: args.fromNodeId, sourceNodeId: args.fromNodeId,
targetNodeId, targetNodeId,
sourceHandle: args.fromHandleId, sourceHandle: args.fromHandleId,
@@ -165,14 +338,40 @@ export function resolveDroppedConnectionTarget(args: {
}) })
: handles?.target, : 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, sourceNodeId: targetNodeId,
targetNodeId: args.fromNodeId, targetNodeId: args.fromNodeId,
sourceHandle: handles?.source, sourceHandle: handles?.source,
targetHandle: args.fromHandleId, 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. */ /** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */

View File

@@ -68,8 +68,11 @@ import { useCanvasNodeInteractions } from "./use-canvas-node-interactions";
import { useCanvasConnections } from "./use-canvas-connections"; import { useCanvasConnections } from "./use-canvas-connections";
import { useCanvasDrop } from "./use-canvas-drop"; import { useCanvasDrop } from "./use-canvas-drop";
import { useCanvasScissors } from "./canvas-scissors"; import { useCanvasScissors } from "./canvas-scissors";
import { type DefaultEdgeInsertAnchor } from "./edges/default-edge";
import { CanvasSyncProvider } from "./canvas-sync-context"; import { CanvasSyncProvider } from "./canvas-sync-context";
import { useCanvasData } from "./use-canvas-data"; 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 { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
@@ -111,6 +114,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
pendingEdgeSplitByClientRequestRef, pendingEdgeSplitByClientRequestRef,
pendingConnectionCreatesRef, pendingConnectionCreatesRef,
pendingLocalPositionUntilConvexMatchesRef, pendingLocalPositionUntilConvexMatchesRef,
pendingLocalNodeDataUntilConvexMatchesRef,
preferLocalPositionNodeIdsRef, preferLocalPositionNodeIdsRef,
}, },
actions: { actions: {
@@ -328,12 +332,55 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
screenToFlowPosition, screenToFlowPosition,
syncPendingMoveForClientRequest, syncPendingMoveForClientRequest,
runCreateEdgeMutation, runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation,
runRemoveEdgeMutation, runRemoveEdgeMutation,
runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly,
showConnectionRejectedToast, 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({ useCanvasFlowReconciliation({
convexNodes, convexNodes,
convexEdges, convexEdges,
@@ -351,6 +398,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
resolvedRealIdByClientRequestRef, resolvedRealIdByClientRequestRef,
pendingConnectionCreatesRef, pendingConnectionCreatesRef,
pendingLocalPositionUntilConvexMatchesRef, pendingLocalPositionUntilConvexMatchesRef,
pendingLocalNodeDataUntilConvexMatchesRef,
preferLocalPositionNodeIdsRef, preferLocalPositionNodeIdsRef,
isDragging, isDragging,
isResizing, isResizing,
@@ -411,9 +459,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
canvasId, canvasId,
isSyncOnline, isSyncOnline,
t, t,
edges,
screenToFlowPosition, screenToFlowPosition,
generateUploadUrl, generateUploadUrl,
runCreateNodeOnlineOnly, runCreateNodeOnlineOnly,
runCreateNodeWithEdgeSplitOnlineOnly,
notifyOfflineUnsupported, notifyOfflineUnsupported,
syncPendingMoveForClientRequest, syncPendingMoveForClientRequest,
}); });
@@ -473,10 +523,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
<CanvasAppMenu canvasId={canvasId} /> <CanvasAppMenu canvasId={canvasId} />
<CanvasCommandPalette /> <CanvasCommandPalette />
<CanvasConnectionDropMenu <CanvasConnectionDropMenu
state={connectionDropMenu} anchor={
connectionDropMenu
? {
screenX: connectionDropMenu.screenX,
screenY: connectionDropMenu.screenY,
}
: null
}
onClose={closeConnectionDropMenu} onClose={closeConnectionDropMenu}
onPick={handleConnectionDropPick} onPick={handleConnectionDropPick}
/> />
<CanvasConnectionDropMenu
anchor={
edgeInsertMenu
? {
screenX: edgeInsertMenu.screenX,
screenY: edgeInsertMenu.screenY,
}
: null
}
onClose={closeEdgeInsertMenu}
onPick={handleEdgeInsertPick}
/>
{scissorsMode ? ( {scissorsMode ? (
<div className="pointer-events-none absolute top-14 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10"> <div className="pointer-events-none absolute top-14 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10">
Scherenmodus Kante anklicken oder ziehen zum Durchtrennen ·{" "} Scherenmodus Kante anklicken oder ziehen zum Durchtrennen ·{" "}
@@ -512,9 +581,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onlyRenderVisibleElements onlyRenderVisibleElements
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} defaultEdgeOptions={defaultEdgeOptions}
connectionLineComponent={CustomConnectionLine} connectionLineComponent={CustomConnectionLine}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onNodeDragStart={onNodeDragStart} onNodeDragStart={onNodeDragStart}

View File

@@ -1,3 +1,116 @@
export default function DefaultEdge() { "use client";
return null;
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<HTMLButtonElement>) => {
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 (
<>
<g
data-testid="default-edge"
onMouseEnter={() => setIsEdgeHovered(true)}
onMouseLeave={() => setIsEdgeHovered(false)}
>
<BaseEdge
id={id}
path={edgePath}
style={style}
markerStart={markerStart}
markerEnd={markerEnd}
interactionWidth={interactionWidth}
/>
</g>
<EdgeLabelRenderer>
<button
type="button"
data-testid="default-edge-insert-button"
data-visible={isInsertVisible ? "true" : "false"}
aria-label="Insert node"
aria-hidden={!isInsertVisible}
disabled={!canInsert}
className="nodrag nopan absolute h-7 w-7 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-opacity"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
opacity: isInsertVisible ? 1 : 0,
pointerEvents: isInsertVisible ? "all" : "none",
display: "flex",
}}
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
onClick={handleInsertClick}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</button>
</EdgeLabelRenderer>
</>
);
} }

View File

@@ -41,9 +41,10 @@ export function useNodeLocalData<T>({
useCanvasGraphPreviewOverrides(); useCanvasGraphPreviewOverrides();
const [localData, setLocalDataState] = useState<T>(() => normalize(data)); const [localData, setLocalDataState] = useState<T>(() => normalize(data));
const localDataRef = useRef(localData); const localDataRef = useRef(localData);
const persistedDataRef = useRef(localData); const acceptedPersistedDataRef = useRef(localData);
const hasPendingLocalChangesRef = useRef(false); const hasPendingLocalChangesRef = useRef(false);
const localChangeVersionRef = useRef(0); const localChangeVersionRef = useRef(0);
const acknowledgedSaveVersionRef = useRef(0);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
useEffect(() => { useEffect(() => {
@@ -60,7 +61,7 @@ export function useNodeLocalData<T>({
return; return;
} }
hasPendingLocalChangesRef.current = false; acknowledgedSaveVersionRef.current = savedVersion;
}) })
.catch(() => { .catch(() => {
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) { if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
@@ -68,34 +69,49 @@ export function useNodeLocalData<T>({
} }
hasPendingLocalChangesRef.current = false; hasPendingLocalChangesRef.current = false;
localDataRef.current = persistedDataRef.current; acknowledgedSaveVersionRef.current = 0;
setLocalDataState(persistedDataRef.current); localDataRef.current = acceptedPersistedDataRef.current;
setLocalDataState(acceptedPersistedDataRef.current);
clearPreviewNodeDataOverride(nodeId); clearPreviewNodeDataOverride(nodeId);
}); });
}, saveDelayMs); }, saveDelayMs);
useEffect(() => { useEffect(() => {
const incomingData = normalize(data); const incomingData = normalize(data);
persistedDataRef.current = incomingData;
const incomingHash = hashNodeData(incomingData); const incomingHash = hashNodeData(incomingData);
const localHash = hashNodeData(localDataRef.current); const localHash = hashNodeData(localDataRef.current);
const acceptedPersistedHash = hashNodeData(acceptedPersistedDataRef.current);
if (incomingHash === localHash) { if (incomingHash === localHash) {
acceptedPersistedDataRef.current = incomingData;
hasPendingLocalChangesRef.current = false; hasPendingLocalChangesRef.current = false;
acknowledgedSaveVersionRef.current = 0;
clearPreviewNodeDataOverride(nodeId); clearPreviewNodeDataOverride(nodeId);
return; return;
} }
if (hasPendingLocalChangesRef.current) { if (hasPendingLocalChangesRef.current) {
const saveAcknowledgedForCurrentVersion =
acknowledgedSaveVersionRef.current === localChangeVersionRef.current;
const shouldKeepBlockingIncomingData =
!saveAcknowledgedForCurrentVersion || incomingHash === acceptedPersistedHash;
if (shouldKeepBlockingIncomingData) {
logNodeDataDebug("skip-stale-external-data", { logNodeDataDebug("skip-stale-external-data", {
nodeId,
nodeType: debugLabel, nodeType: debugLabel,
incomingHash, incomingHash,
localHash, localHash,
saveAcknowledgedForCurrentVersion,
}); });
return; return;
} }
}
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
acceptedPersistedDataRef.current = incomingData;
hasPendingLocalChangesRef.current = false;
acknowledgedSaveVersionRef.current = 0;
localDataRef.current = incomingData; localDataRef.current = incomingData;
setLocalDataState(incomingData); setLocalDataState(incomingData);
clearPreviewNodeDataOverride(nodeId); clearPreviewNodeDataOverride(nodeId);
@@ -123,7 +139,7 @@ export function useNodeLocalData<T>({
setPreviewNodeDataOverride(nodeId, next); setPreviewNodeDataOverride(nodeId, next);
queueSave(); queueSave();
}, },
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride], [nodeId, queueSave, setPreviewNodeDataOverride],
); );
const updateLocalData = useCallback( const updateLocalData = useCallback(
@@ -137,7 +153,7 @@ export function useNodeLocalData<T>({
setPreviewNodeDataOverride(nodeId, next); setPreviewNodeDataOverride(nodeId, next);
queueSave(); queueSave();
}, },
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride], [nodeId, queueSave, setPreviewNodeDataOverride],
); );
return { return {

View File

@@ -10,11 +10,19 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import type { CanvasNodeType } from "@/lib/canvas-node-types"; import type { CanvasNodeType } from "@/lib/canvas-node-types";
import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers"; import {
import { resolveDroppedConnectionTarget } from "./canvas-helpers"; getConnectEndClientPoint,
hasHandleKey,
isOptimisticEdgeId,
isOptimisticNodeId,
logCanvasConnectionDebug,
normalizeHandle,
resolveDroppedConnectionTarget,
} from "./canvas-helpers";
import { import {
validateCanvasConnection, validateCanvasConnection,
validateCanvasConnectionByType, validateCanvasConnectionByType,
validateCanvasEdgeSplit,
} from "./canvas-connection-validation"; } from "./canvas-connection-validation";
import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import { useCanvasReconnectHandlers } from "./canvas-reconnect";
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu"; import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
@@ -43,6 +51,15 @@ type UseCanvasConnectionsParams = {
sourceHandle?: string; sourceHandle?: string;
targetHandle?: string; targetHandle?: string;
}) => Promise<unknown>; }) => Promise<unknown>;
runSplitEdgeAtExistingNodeMutation: (args: {
canvasId: Id<"canvases">;
splitEdgeId: Id<"edges">;
middleNodeId: Id<"nodes">;
splitSourceHandle?: string;
splitTargetHandle?: string;
newNodeSourceHandle?: string;
newNodeTargetHandle?: string;
}) => Promise<unknown>;
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>; runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: { runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
@@ -92,6 +109,7 @@ export function useCanvasConnections({
screenToFlowPosition, screenToFlowPosition,
syncPendingMoveForClientRequest, syncPendingMoveForClientRequest,
runCreateEdgeMutation, runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation,
runRemoveEdgeMutation, runRemoveEdgeMutation,
runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly,
@@ -107,8 +125,13 @@ export function useCanvasConnections({
connectionDropMenuRef.current = connectionDropMenu; connectionDropMenuRef.current = connectionDropMenu;
}, [connectionDropMenu]); }, [connectionDropMenu]);
const onConnectStart = useCallback<OnConnectStart>(() => { const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
isConnectDragActiveRef.current = true; isConnectDragActiveRef.current = true;
logCanvasConnectionDebug("connect:start", {
nodeId: params.nodeId,
handleId: params.handleId,
handleType: params.handleType,
});
}, []); }, []);
const onConnect = useCallback( const onConnect = useCallback(
@@ -116,11 +139,33 @@ export function useCanvasConnections({
isConnectDragActiveRef.current = false; isConnectDragActiveRef.current = false;
const validationError = validateCanvasConnection(connection, nodes, edges); const validationError = validateCanvasConnection(connection, nodes, edges);
if (validationError) { 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); showConnectionRejectedToast(validationError);
return; 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({ void runCreateEdgeMutation({
canvasId, canvasId,
@@ -136,18 +181,71 @@ export function useCanvasConnections({
const onConnectEnd = useCallback<OnConnectEnd>( const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => { (event, connectionState) => {
if (!isConnectDragActiveRef.current) { 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; return;
} }
isConnectDragActiveRef.current = false; isConnectDragActiveRef.current = false;
if (isReconnectDragActiveRef.current) return; if (isReconnectDragActiveRef.current) {
if (connectionState.isValid === true) return; 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 fromNode = connectionState.fromNode;
const fromHandle = connectionState.fromHandle; 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); 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 flow = screenToFlowPosition({ x: pt.x, y: pt.y });
const droppedConnection = resolveDroppedConnectionTarget({ const droppedConnection = resolveDroppedConnectionTarget({
@@ -159,6 +257,15 @@ export function useCanvasConnections({
edges: edgesRef.current, edges: edgesRef.current,
}); });
logCanvasConnectionDebug("connect:end-drop-result", {
point: pt,
flow,
fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? null,
fromHandleType: fromHandle.type,
droppedConnection,
});
if (droppedConnection) { if (droppedConnection) {
const validationError = validateCanvasConnection( const validationError = validateCanvasConnection(
{ {
@@ -171,10 +278,75 @@ export function useCanvasConnections({
edgesRef.current, edgesRef.current,
); );
if (validationError) { 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); showConnectionRejectedToast(validationError);
return; return;
} }
logCanvasConnectionDebug("connect:end-create-edge", {
point: pt,
flow,
droppedConnection,
});
void runCreateEdgeMutation({ void runCreateEdgeMutation({
canvasId, canvasId,
sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">, sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">,
@@ -185,6 +357,14 @@ export function useCanvasConnections({
return; return;
} }
logCanvasConnectionDebug("connect:end-open-menu", {
point: pt,
flow,
fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? null,
fromHandleType: fromHandle.type,
});
setConnectionDropMenu({ setConnectionDropMenu({
screenX: pt.x, screenX: pt.x,
screenY: pt.y, screenY: pt.y,
@@ -201,6 +381,7 @@ export function useCanvasConnections({
isReconnectDragActiveRef, isReconnectDragActiveRef,
nodesRef, nodesRef,
runCreateEdgeMutation, runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation,
screenToFlowPosition, screenToFlowPosition,
showConnectionRejectedToast, showConnectionRejectedToast,
], ],

View File

@@ -4,19 +4,34 @@ import type { Id } from "@/convex/_generated/dataModel";
import { import {
CANVAS_NODE_DND_MIME, CANVAS_NODE_DND_MIME,
} from "@/lib/canvas-connection-policy"; } from "@/lib/canvas-connection-policy";
import { NODE_DEFAULTS } from "@/lib/canvas-utils"; import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
import { import {
isCanvasNodeType, isCanvasNodeType,
type CanvasNodeType, type CanvasNodeType,
} from "@/lib/canvas-node-types"; } from "@/lib/canvas-node-types";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import {
getIntersectedEdgeId,
hasHandleKey,
isOptimisticEdgeId,
logCanvasConnectionDebug,
normalizeHandle,
} from "./canvas-helpers";
import { getImageDimensions } from "./canvas-media-utils"; import { getImageDimensions } from "./canvas-media-utils";
type UseCanvasDropParams = { type UseCanvasDropParams = {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
isSyncOnline: boolean; isSyncOnline: boolean;
t: (key: string) => string; 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 }; screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
generateUploadUrl: () => Promise<string>; generateUploadUrl: () => Promise<string>;
runCreateNodeOnlineOnly: (args: { runCreateNodeOnlineOnly: (args: {
@@ -29,6 +44,21 @@ type UseCanvasDropParams = {
data: Record<string, unknown>; data: Record<string, unknown>;
clientRequestId?: string; clientRequestId?: string;
}) => Promise<Id<"nodes">>; }) => Promise<Id<"nodes">>;
runCreateNodeWithEdgeSplitOnlineOnly: (args: {
canvasId: Id<"canvases">;
type: CanvasNodeType;
positionX: number;
positionY: number;
width: number;
height: number;
data: Record<string, unknown>;
splitEdgeId: Id<"edges">;
newNodeTargetHandle?: string;
newNodeSourceHandle?: string;
splitSourceHandle?: string;
splitTargetHandle?: string;
clientRequestId?: string;
}) => Promise<Id<"nodes">>;
notifyOfflineUnsupported: (featureLabel: string) => void; notifyOfflineUnsupported: (featureLabel: string) => void;
syncPendingMoveForClientRequest: ( syncPendingMoveForClientRequest: (
clientRequestId: string, clientRequestId: string,
@@ -66,9 +96,11 @@ export function useCanvasDrop({
canvasId, canvasId,
isSyncOnline, isSyncOnline,
t, t,
edges,
screenToFlowPosition, screenToFlowPosition,
generateUploadUrl, generateUploadUrl,
runCreateNodeOnlineOnly, runCreateNodeOnlineOnly,
runCreateNodeWithEdgeSplitOnlineOnly,
notifyOfflineUnsupported, notifyOfflineUnsupported,
syncPendingMoveForClientRequest, syncPendingMoveForClientRequest,
}: UseCanvasDropParams) { }: UseCanvasDropParams) {
@@ -169,14 +201,80 @@ export function useCanvasDrop({
x: event.clientX, x: event.clientX,
y: event.clientY, 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] ?? { const defaults = NODE_DEFAULTS[parsedPayload.nodeType] ?? {
width: 200, width: 200,
height: 100, height: 100,
data: {}, data: {},
}; };
const clientRequestId = crypto.randomUUID(); 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({ 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, canvasId,
type: parsedPayload.nodeType, type: parsedPayload.nodeType,
positionX: position.x, positionX: position.x,
@@ -185,7 +283,10 @@ export function useCanvasDrop({
height: defaults.height, height: defaults.height,
data: { ...defaults.data, ...parsedPayload.payloadData, canvasId }, data: { ...defaults.data, ...parsedPayload.payloadData, canvasId },
clientRequestId, clientRequestId,
}).then((realId) => { });
})();
void createNodePromise.then((realId) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch( void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => { (error: unknown) => {
console.error("[Canvas] createNode syncPendingMove failed", error); console.error("[Canvas] createNode syncPendingMove failed", error);
@@ -195,9 +296,11 @@ export function useCanvasDrop({
}, },
[ [
canvasId, canvasId,
edges,
generateUploadUrl, generateUploadUrl,
isSyncOnline, isSyncOnline,
notifyOfflineUnsupported, notifyOfflineUnsupported,
runCreateNodeWithEdgeSplitOnlineOnly,
runCreateNodeOnlineOnly, runCreateNodeOnlineOnly,
screenToFlowPosition, screenToFlowPosition,
syncPendingMoveForClientRequest, syncPendingMoveForClientRequest,

View File

@@ -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<string, unknown>;
splitEdgeId: Id<"edges">;
newNodeTargetHandle?: string;
newNodeSourceHandle?: string;
splitSourceHandle?: string;
splitTargetHandle?: string;
clientRequestId?: string;
}) => Promise<Id<"nodes"> | string>;
runBatchMoveNodesMutation: (args: {
moves: {
nodeId: Id<"nodes">;
positionX: number;
positionY: number;
}[];
}) => Promise<void>;
showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void;
};
export function useCanvasEdgeInsertions({
canvasId,
nodes,
edges,
runCreateNodeWithEdgeSplitOnlineOnly,
runBatchMoveNodesMutation,
showConnectionRejectedToast,
}: UseCanvasEdgeInsertionsArgs) {
const [edgeInsertMenu, setEdgeInsertMenu] = useState<EdgeInsertMenuState | null>(null);
const edgeInsertMenuRef = useRef<EdgeInsertMenuState | null>(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<string, unknown>),
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,
};
}

View File

@@ -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<string | null>(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<typeof DefaultEdge>[0]) => {
const edgeClassName = (edgeProps as { className?: string }).className;
const isInsertableEdge =
edgeClassName !== "temp" && !isOptimisticEdgeId(edgeProps.id);
return (
<DefaultEdge
{...edgeProps}
edgeId={edgeProps.id}
isMenuOpen={edgeInsertMenuEdgeIdRef.current === edgeProps.id}
disabled={scissorsModeRef.current || !isInsertableEdge}
onInsertClick={
isInsertableEdge ? onInsertClickRef.current : undefined
}
/>
);
},
}),
[],
);
}

View File

@@ -20,6 +20,7 @@ type CanvasFlowReconciliationRefs = {
pendingLocalPositionUntilConvexMatchesRef: MutableRefObject< pendingLocalPositionUntilConvexMatchesRef: MutableRefObject<
Map<string, PositionPin> Map<string, PositionPin>
>; >;
pendingLocalNodeDataUntilConvexMatchesRef: MutableRefObject<Map<string, unknown>>;
preferLocalPositionNodeIdsRef: MutableRefObject<Set<string>>; preferLocalPositionNodeIdsRef: MutableRefObject<Set<string>>;
isDragging: MutableRefObject<boolean>; isDragging: MutableRefObject<boolean>;
isResizing: MutableRefObject<boolean>; isResizing: MutableRefObject<boolean>;
@@ -54,6 +55,7 @@ export function useCanvasFlowReconciliation(args: {
resolvedRealIdByClientRequestRef, resolvedRealIdByClientRequestRef,
pendingConnectionCreatesRef, pendingConnectionCreatesRef,
pendingLocalPositionUntilConvexMatchesRef, pendingLocalPositionUntilConvexMatchesRef,
pendingLocalNodeDataUntilConvexMatchesRef,
preferLocalPositionNodeIdsRef, preferLocalPositionNodeIdsRef,
isDragging, isDragging,
isResizing, isResizing,
@@ -131,6 +133,8 @@ export function useCanvasFlowReconciliation(args: {
pendingConnectionCreateIds: pendingConnectionCreatesRef.current, pendingConnectionCreateIds: pendingConnectionCreatesRef.current,
preferLocalPositionNodeIds: preferLocalPositionNodeIdsRef.current, preferLocalPositionNodeIds: preferLocalPositionNodeIdsRef.current,
pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current, pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current,
pendingLocalNodeDataPins:
pendingLocalNodeDataUntilConvexMatchesRef.current,
pendingMovePins, pendingMovePins,
}); });
@@ -138,6 +142,8 @@ export function useCanvasFlowReconciliation(args: {
reconciliation.inferredRealIdByClientRequest; reconciliation.inferredRealIdByClientRequest;
pendingLocalPositionUntilConvexMatchesRef.current = pendingLocalPositionUntilConvexMatchesRef.current =
reconciliation.nextPendingLocalPositionPins; reconciliation.nextPendingLocalPositionPins;
pendingLocalNodeDataUntilConvexMatchesRef.current =
reconciliation.nextPendingLocalNodeDataPins;
for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) { for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) {
preferLocalPositionNodeIdsRef.current.delete(nodeId); preferLocalPositionNodeIdsRef.current.delete(nodeId);
} }
@@ -155,6 +161,7 @@ export function useCanvasFlowReconciliation(args: {
isResizing, isResizing,
pendingConnectionCreatesRef, pendingConnectionCreatesRef,
pendingLocalPositionUntilConvexMatchesRef, pendingLocalPositionUntilConvexMatchesRef,
pendingLocalNodeDataUntilConvexMatchesRef,
preferLocalPositionNodeIdsRef, preferLocalPositionNodeIdsRef,
resolvedRealIdByClientRequestRef, resolvedRealIdByClientRequestRef,
]); ]);

View File

@@ -205,6 +205,9 @@ export function createCanvasSyncEngineController({
const pendingLocalPositionUntilConvexMatchesRef = { const pendingLocalPositionUntilConvexMatchesRef = {
current: new Map<string, { x: number; y: number }>(), current: new Map<string, { x: number; y: number }>(),
}; };
const pendingLocalNodeDataUntilConvexMatchesRef = {
current: new Map<string, unknown>(),
};
const preferLocalPositionNodeIdsRef = { current: new Set<string>() }; const preferLocalPositionNodeIdsRef = { current: new Set<string>() };
const flushPendingResizeForClientRequest = async ( 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<string, unknown>,
}
: node,
),
);
};
const flushPendingDataForClientRequest = async ( const flushPendingDataForClientRequest = async (
clientRequestId: string, clientRequestId: string,
realId: Id<"nodes">, realId: Id<"nodes">,
@@ -228,6 +246,7 @@ export function createCanvasSyncEngineController({
if (!pendingDataAfterCreateRef.current.has(clientRequestId)) return; if (!pendingDataAfterCreateRef.current.has(clientRequestId)) return;
const pendingData = pendingDataAfterCreateRef.current.get(clientRequestId); const pendingData = pendingDataAfterCreateRef.current.get(clientRequestId);
pendingDataAfterCreateRef.current.delete(clientRequestId); pendingDataAfterCreateRef.current.delete(clientRequestId);
pinNodeDataLocally(realId as string, pendingData);
await getEnqueueSyncMutation()("updateData", { await getEnqueueSyncMutation()("updateData", {
nodeId: realId, nodeId: realId,
data: pendingData, data: pendingData,
@@ -272,6 +291,7 @@ export function createCanvasSyncEngineController({
data: unknown; data: unknown;
}): Promise<void> => { }): Promise<void> => {
const rawNodeId = args.nodeId as string; const rawNodeId = args.nodeId as string;
pinNodeDataLocally(rawNodeId, args.data);
if (!isOptimisticNodeId(rawNodeId) || !getIsSyncOnline()) { if (!isOptimisticNodeId(rawNodeId) || !getIsSyncOnline()) {
await getEnqueueSyncMutation()("updateData", args); await getEnqueueSyncMutation()("updateData", args);
return; return;
@@ -311,6 +331,7 @@ export function createCanvasSyncEngineController({
pendingMoveAfterCreateRef.current.delete(clientRequestId); pendingMoveAfterCreateRef.current.delete(clientRequestId);
pendingResizeAfterCreateRef.current.delete(clientRequestId); pendingResizeAfterCreateRef.current.delete(clientRequestId);
pendingDataAfterCreateRef.current.delete(clientRequestId); pendingDataAfterCreateRef.current.delete(clientRequestId);
pendingLocalNodeDataUntilConvexMatchesRef.current.delete(realId as string);
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
pendingConnectionCreatesRef.current.delete(clientRequestId); pendingConnectionCreatesRef.current.delete(clientRequestId);
resolvedRealIdByClientRequestRef.current.delete(clientRequestId); resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
@@ -459,6 +480,7 @@ export function createCanvasSyncEngineController({
pendingDeleteAfterCreateClientRequestIdsRef, pendingDeleteAfterCreateClientRequestIdsRef,
pendingConnectionCreatesRef, pendingConnectionCreatesRef,
pendingLocalPositionUntilConvexMatchesRef, pendingLocalPositionUntilConvexMatchesRef,
pendingLocalNodeDataUntilConvexMatchesRef,
preferLocalPositionNodeIdsRef, preferLocalPositionNodeIdsRef,
flushPendingResizeForClientRequest, flushPendingResizeForClientRequest,
flushPendingDataForClientRequest, flushPendingDataForClientRequest,
@@ -998,6 +1020,9 @@ export function useCanvasSyncEngine({
controller.pendingMoveAfterCreateRef.current.delete(args.clientRequestId); controller.pendingMoveAfterCreateRef.current.delete(args.clientRequestId);
controller.pendingResizeAfterCreateRef.current.delete(args.clientRequestId); controller.pendingResizeAfterCreateRef.current.delete(args.clientRequestId);
controller.pendingDataAfterCreateRef.current.delete(args.clientRequestId); controller.pendingDataAfterCreateRef.current.delete(args.clientRequestId);
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete(
optimisticNodeId,
);
pendingCreatePromiseByClientRequestRef.current.delete(args.clientRequestId); pendingCreatePromiseByClientRequestRef.current.delete(args.clientRequestId);
controller.pendingEdgeSplitByClientRequestRef.current.delete( controller.pendingEdgeSplitByClientRequestRef.current.delete(
args.clientRequestId, 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 ( if (
controller.preferLocalPositionNodeIdsRef.current.has(optimisticNodeId) controller.preferLocalPositionNodeIdsRef.current.has(optimisticNodeId)
) { ) {
@@ -1655,6 +1694,10 @@ export function useCanvasSyncEngine({
for (const nodeId of op.payload.nodeIds) { for (const nodeId of op.payload.nodeIds) {
deletingNodeIds.current.delete(nodeId as string); deletingNodeIds.current.delete(nodeId as string);
} }
} else if (op.type === "updateData") {
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete(
op.payload.nodeId as string,
);
} }
await ackCanvasSyncOp(op.id); await ackCanvasSyncOp(op.id);
resolveCanvasOp(canvasId as string, op.id); resolveCanvasOp(canvasId as string, op.id);
@@ -1767,6 +1810,8 @@ export function useCanvasSyncEngine({
pendingConnectionCreatesRef: controller.pendingConnectionCreatesRef, pendingConnectionCreatesRef: controller.pendingConnectionCreatesRef,
pendingLocalPositionUntilConvexMatchesRef: pendingLocalPositionUntilConvexMatchesRef:
controller.pendingLocalPositionUntilConvexMatchesRef, controller.pendingLocalPositionUntilConvexMatchesRef,
pendingLocalNodeDataUntilConvexMatchesRef:
controller.pendingLocalNodeDataUntilConvexMatchesRef,
preferLocalPositionNodeIdsRef: controller.preferLocalPositionNodeIdsRef, preferLocalPositionNodeIdsRef: controller.preferLocalPositionNodeIdsRef,
pendingCreatePromiseByClientRequestRef, pendingCreatePromiseByClientRequestRef,
}, },

View File

@@ -12,12 +12,16 @@ export default defineConfig({
include: [ include: [
"tests/**/*.test.ts", "tests/**/*.test.ts",
"components/canvas/__tests__/canvas-helpers.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-connection-drop-target.test.tsx",
"components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts",
"components/canvas/__tests__/compare-node.test.tsx", "components/canvas/__tests__/compare-node.test.tsx",
"components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts", "components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts",
"components/canvas/__tests__/use-canvas-drop.test.tsx", "components/canvas/__tests__/use-canvas-drop.test.tsx",
"components/canvas/__tests__/use-canvas-connections.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-canvas-node-interactions.test.tsx",
"components/canvas/__tests__/use-node-local-data.test.tsx", "components/canvas/__tests__/use-node-local-data.test.tsx",
"components/canvas/__tests__/use-canvas-sync-engine.test.ts", "components/canvas/__tests__/use-canvas-sync-engine.test.ts",