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);
});
it("keeps pinned local node data until convex catches up", () => {
const pinnedData = { blackPoint: 209, whitePoint: 255 };
const staleIncomingData = { blackPoint: 124, whitePoint: 255 };
const result = reconcileCanvasFlowNodes({
previousNodes: [
{
id: "node-1",
type: "curves",
position: { x: 120, y: 80 },
data: pinnedData,
},
],
incomingNodes: [
{
id: "node-1",
type: "curves",
position: { x: 120, y: 80 },
data: staleIncomingData,
},
],
convexNodes: [{ _id: asNodeId("node-1"), type: "curves" }],
deletingNodeIds: new Set(),
resolvedRealIdByClientRequest: new Map(),
pendingConnectionCreateIds: new Set(),
preferLocalPositionNodeIds: new Set(),
pendingLocalPositionPins: new Map(),
pendingLocalNodeDataPins: new Map([["node-1", pinnedData]]),
pendingMovePins: new Map(),
});
expect(result.nodes).toEqual([
{
id: "node-1",
type: "curves",
position: { x: 120, y: 80 },
data: pinnedData,
},
]);
expect(result.nextPendingLocalNodeDataPins).toEqual(
new Map([["node-1", pinnedData]]),
);
});
it("clears pinned local node data once incoming data includes the persisted values", () => {
const pinnedData = { blackPoint: 209, whitePoint: 255 };
const incomingData = {
blackPoint: 209,
whitePoint: 255,
_status: "idle",
};
const result = reconcileCanvasFlowNodes({
previousNodes: [
{
id: "node-1",
type: "curves",
position: { x: 120, y: 80 },
data: pinnedData,
},
],
incomingNodes: [
{
id: "node-1",
type: "curves",
position: { x: 120, y: 80 },
data: incomingData,
},
],
convexNodes: [{ _id: asNodeId("node-1"), type: "curves" }],
deletingNodeIds: new Set(),
resolvedRealIdByClientRequest: new Map(),
pendingConnectionCreateIds: new Set(),
preferLocalPositionNodeIds: new Set(),
pendingLocalPositionPins: new Map(),
pendingLocalNodeDataPins: new Map([["node-1", pinnedData]]),
pendingMovePins: new Map(),
});
expect(result.nodes).toEqual([
{
id: "node-1",
type: "curves",
position: { x: 120, y: 80 },
data: incomingData,
},
]);
expect(result.nextPendingLocalNodeDataPins.size).toBe(0);
});
it("filters deleting nodes from incoming reconciliation results", () => {
const result = reconcileCanvasFlowNodes({
previousNodes: [

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { withResolvedCompareData } from "../canvas-helpers";
import { computeEdgeInsertLayout, withResolvedCompareData } from "../canvas-helpers";
import {
buildGraphSnapshot,
pruneCanvasGraphNodeDataOverrides,
@@ -310,3 +310,107 @@ describe("canvas preview graph helpers", () => {
]);
});
});
describe("computeEdgeInsertLayout", () => {
it("shifts source and target along a horizontal axis when spacing is too tight", () => {
const source = createNode({
id: "source",
position: { x: 0, y: 0 },
style: { width: 100, height: 60 },
});
const target = createNode({
id: "target",
position: { x: 120, y: 0 },
style: { width: 100, height: 60 },
});
const layout = computeEdgeInsertLayout({
sourceNode: source,
targetNode: target,
newNodeWidth: 80,
newNodeHeight: 40,
gapPx: 10,
});
expect(layout.insertPosition).toEqual({ x: 70, y: 10 });
expect(layout.sourcePosition).toEqual({ x: -40, y: 0 });
expect(layout.targetPosition).toEqual({ x: 160, y: 0 });
});
it("keeps diagonal-axis spacing adjustments aligned to the edge direction", () => {
const source = createNode({
id: "source",
position: { x: 0, y: 0 },
style: { width: 100, height: 100 },
});
const target = createNode({
id: "target",
position: { x: 100, y: 100 },
style: { width: 100, height: 100 },
});
const layout = computeEdgeInsertLayout({
sourceNode: source,
targetNode: target,
newNodeWidth: 80,
newNodeHeight: 80,
gapPx: 10,
});
expect(layout.insertPosition).toEqual({ x: 60, y: 60 });
expect(layout.sourcePosition).toBeDefined();
expect(layout.targetPosition).toBeDefined();
expect(layout.sourcePosition?.x).toBeCloseTo(layout.sourcePosition?.y ?? 0, 6);
expect(layout.targetPosition?.x).toBeCloseTo(layout.targetPosition?.y ?? 0, 6);
expect(layout.sourcePosition?.x).toBeLessThan(source.position.x);
expect(layout.targetPosition?.x).toBeGreaterThan(target.position.x);
});
it("does not shift source or target when there is enough spacing", () => {
const source = createNode({
id: "source",
position: { x: 0, y: 0 },
style: { width: 100, height: 60 },
});
const target = createNode({
id: "target",
position: { x: 320, y: 0 },
style: { width: 100, height: 60 },
});
const layout = computeEdgeInsertLayout({
sourceNode: source,
targetNode: target,
newNodeWidth: 80,
newNodeHeight: 40,
gapPx: 10,
});
expect(layout.insertPosition).toEqual({ x: 170, y: 10 });
expect(layout.sourcePosition).toBeUndefined();
expect(layout.targetPosition).toBeUndefined();
});
it("falls back to midpoint placement without aggressive shifts in degenerate cases", () => {
const source = createNode({
id: "source",
position: { x: 40, y: 80 },
});
const target = createNode({
id: "target",
position: { x: 40, y: 80 },
});
const layout = computeEdgeInsertLayout({
sourceNode: source,
targetNode: target,
newNodeWidth: 30,
newNodeHeight: 10,
gapPx: 10,
});
expect(layout.insertPosition).toEqual({ x: 25, y: 75 });
expect(layout.sourcePosition).toBeUndefined();
expect(layout.targetPosition).toBeUndefined();
});
});

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 = {
helperResult: DroppedConnectionTarget | null;
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
runSplitEdgeAtExistingNodeMutation?: ReturnType<typeof vi.fn>;
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
nodes?: RFNode[];
edges?: RFEdge[];
};
function HookHarness({
helperResult,
runCreateEdgeMutation = vi.fn(async () => undefined),
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
showConnectionRejectedToast = vi.fn(),
nodes: providedNodes,
edges: providedEdges,
}: HookHarnessProps) {
const [nodes] = useState<RFNode[]>([
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} },
]);
const [edges] = useState<RFEdge[]>([]);
const [nodes] = useState<RFNode[]>(
providedNodes ?? [
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} },
],
);
const [edges] = useState<RFEdge[]>(providedEdges ?? []);
const nodesRef = useRef(nodes);
const edgesRef = useRef(edges);
const edgeReconnectSuccessful = useRef(true);
@@ -90,9 +98,10 @@ function HookHarness({
resolvedRealIdByClientRequestRef,
setEdges,
setEdgeSyncNonce,
screenToFlowPosition: (position) => position,
screenToFlowPosition: (position: { x: number; y: number }) => position,
syncPendingMoveForClientRequest: vi.fn(async () => undefined),
runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation,
runRemoveEdgeMutation: vi.fn(async () => undefined),
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
@@ -346,6 +355,83 @@ describe("useCanvasConnections", () => {
});
});
it("splits the existing incoming edge when dropping onto an already-connected adjustment node", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<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 () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn();

View File

@@ -2,6 +2,7 @@
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { Edge as RFEdge } from "@xyflow/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel";
@@ -33,26 +34,32 @@ type HookHarnessProps = {
isSyncOnline?: boolean;
generateUploadUrl?: ReturnType<typeof vi.fn>;
runCreateNodeOnlineOnly?: ReturnType<typeof vi.fn>;
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
notifyOfflineUnsupported?: ReturnType<typeof vi.fn>;
syncPendingMoveForClientRequest?: ReturnType<typeof vi.fn>;
screenToFlowPosition?: (position: { x: number; y: number }) => { x: number; y: number };
edges?: RFEdge[];
};
function HookHarness({
isSyncOnline = true,
generateUploadUrl = vi.fn(async () => "https://upload.test"),
runCreateNodeOnlineOnly = vi.fn(async () => "node-1"),
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-1"),
notifyOfflineUnsupported = vi.fn(),
syncPendingMoveForClientRequest = vi.fn(async () => undefined),
screenToFlowPosition = (position) => position,
edges = [],
}: HookHarnessProps) {
const handlers = useCanvasDrop({
canvasId: asCanvasId("canvas-1"),
isSyncOnline,
t: ((key: string) => key) as (key: string) => string,
edges,
screenToFlowPosition,
generateUploadUrl,
runCreateNodeOnlineOnly,
runCreateNodeWithEdgeSplitOnlineOnly,
notifyOfflineUnsupported,
syncPendingMoveForClientRequest,
});
@@ -260,6 +267,72 @@ describe("useCanvasDrop", () => {
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-video");
});
it("splits an intersected persisted edge for sidebar node drops", async () => {
const runCreateNodeOnlineOnly = vi.fn(async () => "node-note");
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-note");
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
const edgeContainer = document.createElement("g");
edgeContainer.classList.add("react-flow__edge");
edgeContainer.setAttribute("data-id", "edge-a");
const interaction = document.createElement("path");
interaction.classList.add("react-flow__edge-interaction");
edgeContainer.appendChild(interaction);
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => [interaction]),
configurable: true,
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<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 () => {
const generateUploadUrl = vi.fn(async () => "https://upload.test");
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>;
previousConvexNodeIdsSnapshot: Set<string>;
pendingLocalPositionPins?: Map<string, { x: number; y: number }>;
pendingLocalNodeDataPins?: Map<string, unknown>;
preferLocalPositionNodeIds?: Set<string>;
isResizingRefOverride?: { current: boolean };
};
@@ -78,6 +79,9 @@ function HookHarness(props: HarnessProps) {
const pendingLocalPositionUntilConvexMatchesRef = useRef(
props.pendingLocalPositionPins ?? new Map<string, { x: number; y: number }>(),
);
const pendingLocalNodeDataUntilConvexMatchesRef = useRef(
props.pendingLocalNodeDataPins ?? new Map<string, unknown>(),
);
const preferLocalPositionNodeIdsRef = useRef(
props.preferLocalPositionNodeIds ?? new Set<string>(),
);
@@ -115,6 +119,7 @@ function HookHarness(props: HarnessProps) {
resolvedRealIdByClientRequestRef,
pendingConnectionCreatesRef,
pendingLocalPositionUntilConvexMatchesRef,
pendingLocalNodeDataUntilConvexMatchesRef,
preferLocalPositionNodeIdsRef,
isDragging: isDraggingRef,
isResizing: isResizingRef,

View File

@@ -74,4 +74,44 @@ describe("useCanvasSyncEngine", () => {
expect(controller.pendingResizeAfterCreateRef.current.has("req-2")).toBe(false);
expect(controller.pendingDataAfterCreateRef.current.has("req-2")).toBe(false);
});
it("pins local node data immediately when queueing an update", async () => {
const enqueueSyncMutation = vi.fn(async () => undefined);
let nodes = [
{
id: "node-1",
type: "curves",
position: { x: 0, y: 0 },
data: { blackPoint: 124 },
},
];
const setNodes = (updater: (current: typeof nodes) => typeof nodes) => {
nodes = updater(nodes);
return nodes;
};
const controller = createCanvasSyncEngineController({
canvasId: asCanvasId("canvas-1"),
isSyncOnline: true,
getEnqueueSyncMutation: () => enqueueSyncMutation,
getRunBatchRemoveNodes: () => vi.fn(async () => undefined),
getRunSplitEdgeAtExistingNode: () => vi.fn(async () => undefined),
getSetNodes: () => setNodes,
});
await controller.queueNodeDataUpdate({
nodeId: asNodeId("node-1"),
data: { blackPoint: 209 },
});
expect(nodes[0]?.data).toEqual({ blackPoint: 209 });
expect(controller.pendingLocalNodeDataUntilConvexMatchesRef.current).toEqual(
new Map([["node-1", { blackPoint: 209 }]]),
);
expect(enqueueSyncMutation).toHaveBeenCalledWith("updateData", {
nodeId: asNodeId("node-1"),
data: { blackPoint: 209 },
});
});
});

View File

@@ -445,4 +445,140 @@ describe("useNodeLocalData preview overrides", () => {
vi.useRealTimers();
});
it("keeps local data when save resolves before Convex catches up", async () => {
vi.useFakeTimers();
const onSave = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<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();
});
});