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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
188
components/canvas/__tests__/default-edge.test.tsx
Normal file
188
components/canvas/__tests__/default-edge.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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[]>([
|
||||
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[]>([]);
|
||||
],
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
316
components/canvas/__tests__/use-canvas-edge-insertions.test.tsx
Normal file
316
components/canvas/__tests__/use-canvas-edge-insertions.test.tsx
Normal 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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
122
components/canvas/__tests__/use-canvas-edge-types.test.tsx
Normal file
122
components/canvas/__tests__/use-canvas-edge-types.test.tsx
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,13 @@ export type ConnectionDropMenuState = {
|
||||
fromHandleType: "source" | "target";
|
||||
};
|
||||
|
||||
export type CanvasMenuAnchor = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
};
|
||||
|
||||
type CanvasConnectionDropMenuProps = {
|
||||
state: ConnectionDropMenuState | null;
|
||||
anchor: CanvasMenuAnchor | null;
|
||||
onClose: () => void;
|
||||
onPick: (template: CanvasNodeTemplate) => void;
|
||||
};
|
||||
@@ -33,14 +38,14 @@ const PANEL_MAX_W = 360;
|
||||
const PANEL_MAX_H = 420;
|
||||
|
||||
export function CanvasConnectionDropMenu({
|
||||
state,
|
||||
anchor,
|
||||
onClose,
|
||||
onPick,
|
||||
}: CanvasConnectionDropMenuProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
if (!anchor) return;
|
||||
|
||||
const onEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -59,9 +64,9 @@ export function CanvasConnectionDropMenu({
|
||||
document.removeEventListener("keydown", onEscape);
|
||||
document.removeEventListener("pointerdown", onPointerDownCapture, true);
|
||||
};
|
||||
}, [state, onClose]);
|
||||
}, [anchor, onClose]);
|
||||
|
||||
if (!state) return null;
|
||||
if (!anchor) return null;
|
||||
|
||||
const vw =
|
||||
typeof window !== "undefined" ? window.innerWidth : PANEL_MAX_W + 16;
|
||||
@@ -69,11 +74,11 @@ export function CanvasConnectionDropMenu({
|
||||
typeof window !== "undefined" ? window.innerHeight : PANEL_MAX_H + 16;
|
||||
const left = Math.max(
|
||||
8,
|
||||
Math.min(state.screenX, vw - PANEL_MAX_W - 8),
|
||||
Math.min(anchor.screenX, vw - PANEL_MAX_W - 8),
|
||||
);
|
||||
const top = Math.max(
|
||||
8,
|
||||
Math.min(state.screenY, vh - PANEL_MAX_H - 8),
|
||||
Math.min(anchor.screenY, vh - PANEL_MAX_H - 8),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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: {
|
||||
previousNodes: RFNode[];
|
||||
incomingNodes: RFNode[];
|
||||
@@ -286,11 +363,13 @@ export function reconcileCanvasFlowNodes(args: {
|
||||
pendingConnectionCreateIds: ReadonlySet<string>;
|
||||
preferLocalPositionNodeIds: ReadonlySet<string>;
|
||||
pendingLocalPositionPins: ReadonlyMap<string, { x: number; y: number }>;
|
||||
pendingLocalNodeDataPins?: ReadonlyMap<string, unknown>;
|
||||
pendingMovePins: ReadonlyMap<string, { x: number; y: number }>;
|
||||
}): {
|
||||
nodes: RFNode[];
|
||||
inferredRealIdByClientRequest: Map<string, Id<"nodes">>;
|
||||
nextPendingLocalPositionPins: Map<string, { x: number; y: number }>;
|
||||
nextPendingLocalNodeDataPins: Map<string, unknown>;
|
||||
clearedPreferLocalPositionNodeIds: string[];
|
||||
} {
|
||||
const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({
|
||||
@@ -309,8 +388,12 @@ export function reconcileCanvasFlowNodes(args: {
|
||||
inferredRealIdByClientRequest,
|
||||
args.preferLocalPositionNodeIds,
|
||||
);
|
||||
const pinnedNodes = applyLocalPositionPins({
|
||||
const dataPinnedNodes = applyLocalNodeDataPins({
|
||||
nodes: mergedNodes,
|
||||
pendingLocalNodeDataPins: args.pendingLocalNodeDataPins ?? new Map(),
|
||||
});
|
||||
const pinnedNodes = applyLocalPositionPins({
|
||||
nodes: dataPinnedNodes.nodes,
|
||||
pendingLocalPositionPins: args.pendingLocalPositionPins,
|
||||
});
|
||||
const nodes = applyPinnedNodePositionsReadOnly(
|
||||
@@ -335,6 +418,7 @@ export function reconcileCanvasFlowNodes(args: {
|
||||
nodes,
|
||||
inferredRealIdByClientRequest,
|
||||
nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins,
|
||||
nextPendingLocalNodeDataPins: dataPinnedNodes.nextPendingLocalNodeDataPins,
|
||||
clearedPreferLocalPositionNodeIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,121 @@ import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||
|
||||
type XYPosition = { x: number; y: number };
|
||||
|
||||
export type ComputeEdgeInsertLayoutArgs = {
|
||||
sourceNode: RFNode;
|
||||
targetNode: RFNode;
|
||||
newNodeWidth: number;
|
||||
newNodeHeight: number;
|
||||
gapPx: number;
|
||||
};
|
||||
|
||||
export type EdgeInsertLayout = {
|
||||
insertPosition: XYPosition;
|
||||
sourcePosition?: XYPosition;
|
||||
targetPosition?: XYPosition;
|
||||
};
|
||||
|
||||
function readNodeDimension(node: RFNode, key: "width" | "height"): number | null {
|
||||
const nodeRecord = node as { width?: unknown; height?: unknown };
|
||||
const direct = nodeRecord[key];
|
||||
if (typeof direct === "number" && Number.isFinite(direct) && direct > 0) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const styleValue = node.style?.[key];
|
||||
if (typeof styleValue === "number" && Number.isFinite(styleValue) && styleValue > 0) {
|
||||
return styleValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readNodeBox(node: RFNode): {
|
||||
width: number;
|
||||
height: number;
|
||||
hasDimensions: boolean;
|
||||
} {
|
||||
const width = readNodeDimension(node, "width");
|
||||
const height = readNodeDimension(node, "height");
|
||||
return {
|
||||
width: width ?? 0,
|
||||
height: height ?? 0,
|
||||
hasDimensions: width !== null && height !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeEdgeInsertLayout(args: ComputeEdgeInsertLayoutArgs): EdgeInsertLayout {
|
||||
const sourceBox = readNodeBox(args.sourceNode);
|
||||
const targetBox = readNodeBox(args.targetNode);
|
||||
const safeGap = Number.isFinite(args.gapPx) ? Math.max(0, args.gapPx) : 0;
|
||||
const newWidth = Number.isFinite(args.newNodeWidth) ? Math.max(0, args.newNodeWidth) : 0;
|
||||
const newHeight = Number.isFinite(args.newNodeHeight) ? Math.max(0, args.newNodeHeight) : 0;
|
||||
|
||||
const sourceCenter = {
|
||||
x: args.sourceNode.position.x + sourceBox.width / 2,
|
||||
y: args.sourceNode.position.y + sourceBox.height / 2,
|
||||
};
|
||||
const targetCenter = {
|
||||
x: args.targetNode.position.x + targetBox.width / 2,
|
||||
y: args.targetNode.position.y + targetBox.height / 2,
|
||||
};
|
||||
|
||||
const midpoint = {
|
||||
x: (sourceCenter.x + targetCenter.x) / 2,
|
||||
y: (sourceCenter.y + targetCenter.y) / 2,
|
||||
};
|
||||
|
||||
const layout: EdgeInsertLayout = {
|
||||
insertPosition: {
|
||||
x: midpoint.x - newWidth / 2,
|
||||
y: midpoint.y - newHeight / 2,
|
||||
},
|
||||
};
|
||||
|
||||
if (!sourceBox.hasDimensions || !targetBox.hasDimensions) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
const axisDx = targetCenter.x - sourceCenter.x;
|
||||
const axisDy = targetCenter.y - sourceCenter.y;
|
||||
const axisLength = Math.hypot(axisDx, axisDy);
|
||||
if (axisLength <= Number.EPSILON) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
const ux = axisDx / axisLength;
|
||||
const uy = axisDy / axisLength;
|
||||
|
||||
const extentAlongAxis = (width: number, height: number): number =>
|
||||
Math.abs(ux) * (width / 2) + Math.abs(uy) * (height / 2);
|
||||
|
||||
const sourceExtent = extentAlongAxis(sourceBox.width, sourceBox.height);
|
||||
const targetExtent = extentAlongAxis(targetBox.width, targetBox.height);
|
||||
const newExtent = extentAlongAxis(newWidth, newHeight);
|
||||
const halfAxisLength = axisLength / 2;
|
||||
|
||||
const sourceShift = Math.max(0, sourceExtent + newExtent + safeGap - halfAxisLength);
|
||||
const targetShift = Math.max(0, targetExtent + newExtent + safeGap - halfAxisLength);
|
||||
|
||||
if (sourceShift > 0) {
|
||||
layout.sourcePosition = {
|
||||
x: args.sourceNode.position.x - ux * sourceShift,
|
||||
y: args.sourceNode.position.y - uy * sourceShift,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetShift > 0) {
|
||||
layout.targetPosition = {
|
||||
x: args.targetNode.position.x + ux * targetShift,
|
||||
y: args.targetNode.position.y + uy * targetShift,
|
||||
};
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
export function createCanvasOpId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
@@ -78,19 +193,50 @@ export type DroppedConnectionTarget = {
|
||||
targetHandle?: string;
|
||||
};
|
||||
|
||||
function getNodeElementAtClientPoint(point: { x: number; y: number }): HTMLElement | null {
|
||||
function describeConnectionDebugElement(element: Element): Record<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") {
|
||||
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;
|
||||
return (
|
||||
element.classList.contains("react-flow__node") &&
|
||||
typeof element.dataset.id === "string" &&
|
||||
element.dataset.id.length > 0
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return hit instanceof HTMLElement ? hit : null;
|
||||
}
|
||||
@@ -133,25 +279,52 @@ export function resolveDroppedConnectionTarget(args: {
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
}): DroppedConnectionTarget | null {
|
||||
const nodeElement = getNodeElementAtClientPoint(args.point);
|
||||
const elementsAtPoint =
|
||||
typeof document === "undefined"
|
||||
? []
|
||||
: document.elementsFromPoint(args.point.x, args.point.y);
|
||||
const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint);
|
||||
if (!nodeElement) {
|
||||
logCanvasConnectionDebug("drop-target:node-missed", {
|
||||
point: args.point,
|
||||
fromNodeId: args.fromNodeId,
|
||||
fromHandleId: args.fromHandleId ?? null,
|
||||
fromHandleType: args.fromHandleType,
|
||||
elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetNodeId = nodeElement.dataset.id;
|
||||
if (!targetNodeId) {
|
||||
logCanvasConnectionDebug("drop-target:node-missing-data-id", {
|
||||
point: args.point,
|
||||
fromNodeId: args.fromNodeId,
|
||||
fromHandleId: args.fromHandleId ?? null,
|
||||
fromHandleType: args.fromHandleType,
|
||||
nodeElement: describeConnectionDebugElement(nodeElement),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetNode = args.nodes.find((node) => node.id === targetNodeId);
|
||||
if (!targetNode) {
|
||||
logCanvasConnectionDebug("drop-target:node-not-in-state", {
|
||||
point: args.point,
|
||||
fromNodeId: args.fromNodeId,
|
||||
fromHandleId: args.fromHandleId ?? null,
|
||||
fromHandleType: args.fromHandleType,
|
||||
targetNodeId,
|
||||
nodeCount: args.nodes.length,
|
||||
nodeElement: describeConnectionDebugElement(nodeElement),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const handles = NODE_HANDLE_MAP[targetNode.type ?? ""];
|
||||
|
||||
if (args.fromHandleType === "source") {
|
||||
return {
|
||||
const droppedConnection = {
|
||||
sourceNodeId: args.fromNodeId,
|
||||
targetNodeId,
|
||||
sourceHandle: args.fromHandleId,
|
||||
@@ -165,14 +338,40 @@ export function resolveDroppedConnectionTarget(args: {
|
||||
})
|
||||
: handles?.target,
|
||||
};
|
||||
|
||||
logCanvasConnectionDebug("drop-target:node-detected", {
|
||||
point: args.point,
|
||||
fromNodeId: args.fromNodeId,
|
||||
fromHandleId: args.fromHandleId ?? null,
|
||||
fromHandleType: args.fromHandleType,
|
||||
targetNodeId,
|
||||
targetNodeType: targetNode.type ?? null,
|
||||
nodeElement: describeConnectionDebugElement(nodeElement),
|
||||
resolvedConnection: droppedConnection,
|
||||
});
|
||||
|
||||
return droppedConnection;
|
||||
}
|
||||
|
||||
return {
|
||||
const droppedConnection = {
|
||||
sourceNodeId: targetNodeId,
|
||||
targetNodeId: args.fromNodeId,
|
||||
sourceHandle: handles?.source,
|
||||
targetHandle: args.fromHandleId,
|
||||
};
|
||||
|
||||
logCanvasConnectionDebug("drop-target:node-detected", {
|
||||
point: args.point,
|
||||
fromNodeId: args.fromNodeId,
|
||||
fromHandleId: args.fromHandleId ?? null,
|
||||
fromHandleType: args.fromHandleType,
|
||||
targetNodeId,
|
||||
targetNodeType: targetNode.type ?? null,
|
||||
nodeElement: describeConnectionDebugElement(nodeElement),
|
||||
resolvedConnection: droppedConnection,
|
||||
});
|
||||
|
||||
return droppedConnection;
|
||||
}
|
||||
|
||||
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
|
||||
|
||||
@@ -68,8 +68,11 @@ import { useCanvasNodeInteractions } from "./use-canvas-node-interactions";
|
||||
import { useCanvasConnections } from "./use-canvas-connections";
|
||||
import { useCanvasDrop } from "./use-canvas-drop";
|
||||
import { useCanvasScissors } from "./canvas-scissors";
|
||||
import { type DefaultEdgeInsertAnchor } from "./edges/default-edge";
|
||||
import { CanvasSyncProvider } from "./canvas-sync-context";
|
||||
import { useCanvasData } from "./use-canvas-data";
|
||||
import { useCanvasEdgeInsertions } from "./use-canvas-edge-insertions";
|
||||
import { useCanvasEdgeTypes } from "./use-canvas-edge-types";
|
||||
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
|
||||
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
|
||||
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
||||
@@ -111,6 +114,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
pendingEdgeSplitByClientRequestRef,
|
||||
pendingConnectionCreatesRef,
|
||||
pendingLocalPositionUntilConvexMatchesRef,
|
||||
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||
preferLocalPositionNodeIdsRef,
|
||||
},
|
||||
actions: {
|
||||
@@ -328,12 +332,55 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
screenToFlowPosition,
|
||||
syncPendingMoveForClientRequest,
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
runRemoveEdgeMutation,
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||
showConnectionRejectedToast,
|
||||
});
|
||||
|
||||
const {
|
||||
edgeInsertMenu,
|
||||
closeEdgeInsertMenu,
|
||||
openEdgeInsertMenu,
|
||||
handleEdgeInsertPick,
|
||||
} = useCanvasEdgeInsertions({
|
||||
canvasId,
|
||||
nodes,
|
||||
edges,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
runBatchMoveNodesMutation,
|
||||
showConnectionRejectedToast,
|
||||
});
|
||||
|
||||
const handleEdgeInsertClick = useCallback(
|
||||
(anchor: DefaultEdgeInsertAnchor) => {
|
||||
closeConnectionDropMenu();
|
||||
openEdgeInsertMenu(anchor);
|
||||
},
|
||||
[closeConnectionDropMenu, openEdgeInsertMenu],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionDropMenu) {
|
||||
closeEdgeInsertMenu();
|
||||
}
|
||||
}, [closeEdgeInsertMenu, connectionDropMenu]);
|
||||
|
||||
const defaultEdgeOptions = useMemo(
|
||||
() => ({
|
||||
...DEFAULT_EDGE_OPTIONS,
|
||||
type: "canvas-default" as const,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const edgeTypes = useCanvasEdgeTypes({
|
||||
edgeInsertMenuEdgeId: edgeInsertMenu?.edgeId ?? null,
|
||||
scissorsMode,
|
||||
onInsertClick: handleEdgeInsertClick,
|
||||
});
|
||||
|
||||
useCanvasFlowReconciliation({
|
||||
convexNodes,
|
||||
convexEdges,
|
||||
@@ -351,6 +398,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
resolvedRealIdByClientRequestRef,
|
||||
pendingConnectionCreatesRef,
|
||||
pendingLocalPositionUntilConvexMatchesRef,
|
||||
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||
preferLocalPositionNodeIdsRef,
|
||||
isDragging,
|
||||
isResizing,
|
||||
@@ -411,9 +459,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
canvasId,
|
||||
isSyncOnline,
|
||||
t,
|
||||
edges,
|
||||
screenToFlowPosition,
|
||||
generateUploadUrl,
|
||||
runCreateNodeOnlineOnly,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
notifyOfflineUnsupported,
|
||||
syncPendingMoveForClientRequest,
|
||||
});
|
||||
@@ -473,10 +523,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
<CanvasAppMenu canvasId={canvasId} />
|
||||
<CanvasCommandPalette />
|
||||
<CanvasConnectionDropMenu
|
||||
state={connectionDropMenu}
|
||||
anchor={
|
||||
connectionDropMenu
|
||||
? {
|
||||
screenX: connectionDropMenu.screenX,
|
||||
screenY: connectionDropMenu.screenY,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onClose={closeConnectionDropMenu}
|
||||
onPick={handleConnectionDropPick}
|
||||
/>
|
||||
<CanvasConnectionDropMenu
|
||||
anchor={
|
||||
edgeInsertMenu
|
||||
? {
|
||||
screenX: edgeInsertMenu.screenX,
|
||||
screenY: edgeInsertMenu.screenY,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onClose={closeEdgeInsertMenu}
|
||||
onPick={handleEdgeInsertPick}
|
||||
/>
|
||||
{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">
|
||||
Scherenmodus — Kante anklicken oder ziehen zum Durchtrennen ·{" "}
|
||||
@@ -512,9 +581,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onlyRenderVisibleElements
|
||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
connectionLineComponent={CustomConnectionLine}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
|
||||
@@ -1,3 +1,116 @@
|
||||
export default function DefaultEdge() {
|
||||
return null;
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type MouseEvent } from "react";
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getBezierPath,
|
||||
type EdgeProps,
|
||||
} from "@xyflow/react";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export type DefaultEdgeInsertAnchor = {
|
||||
edgeId: string;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
};
|
||||
|
||||
export type DefaultEdgeProps = EdgeProps & {
|
||||
edgeId?: string;
|
||||
isMenuOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
onInsertClick?: (anchor: DefaultEdgeInsertAnchor) => void;
|
||||
};
|
||||
|
||||
export default function DefaultEdge({
|
||||
id,
|
||||
edgeId,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
markerStart,
|
||||
markerEnd,
|
||||
style,
|
||||
interactionWidth,
|
||||
isMenuOpen = false,
|
||||
disabled = false,
|
||||
onInsertClick,
|
||||
}: DefaultEdgeProps) {
|
||||
const [isEdgeHovered, setIsEdgeHovered] = useState(false);
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false);
|
||||
|
||||
const [edgePath, labelX, labelY] = useMemo(
|
||||
() =>
|
||||
getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}),
|
||||
[sourcePosition, sourceX, sourceY, targetPosition, targetX, targetY],
|
||||
);
|
||||
|
||||
const resolvedEdgeId = edgeId ?? id;
|
||||
const canInsert = Boolean(onInsertClick) && !disabled;
|
||||
const isInsertVisible = canInsert && (isMenuOpen || isEdgeHovered || isButtonHovered);
|
||||
|
||||
const handleInsertClick = (event: MouseEvent<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,9 +41,10 @@ export function useNodeLocalData<T>({
|
||||
useCanvasGraphPreviewOverrides();
|
||||
const [localData, setLocalDataState] = useState<T>(() => normalize(data));
|
||||
const localDataRef = useRef(localData);
|
||||
const persistedDataRef = useRef(localData);
|
||||
const acceptedPersistedDataRef = useRef(localData);
|
||||
const hasPendingLocalChangesRef = useRef(false);
|
||||
const localChangeVersionRef = useRef(0);
|
||||
const acknowledgedSaveVersionRef = useRef(0);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -60,7 +61,7 @@ export function useNodeLocalData<T>({
|
||||
return;
|
||||
}
|
||||
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
acknowledgedSaveVersionRef.current = savedVersion;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
|
||||
@@ -68,34 +69,49 @@ export function useNodeLocalData<T>({
|
||||
}
|
||||
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
localDataRef.current = persistedDataRef.current;
|
||||
setLocalDataState(persistedDataRef.current);
|
||||
acknowledgedSaveVersionRef.current = 0;
|
||||
localDataRef.current = acceptedPersistedDataRef.current;
|
||||
setLocalDataState(acceptedPersistedDataRef.current);
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
});
|
||||
}, saveDelayMs);
|
||||
|
||||
useEffect(() => {
|
||||
const incomingData = normalize(data);
|
||||
persistedDataRef.current = incomingData;
|
||||
const incomingHash = hashNodeData(incomingData);
|
||||
const localHash = hashNodeData(localDataRef.current);
|
||||
const acceptedPersistedHash = hashNodeData(acceptedPersistedDataRef.current);
|
||||
|
||||
if (incomingHash === localHash) {
|
||||
acceptedPersistedDataRef.current = incomingData;
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
acknowledgedSaveVersionRef.current = 0;
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPendingLocalChangesRef.current) {
|
||||
const saveAcknowledgedForCurrentVersion =
|
||||
acknowledgedSaveVersionRef.current === localChangeVersionRef.current;
|
||||
const shouldKeepBlockingIncomingData =
|
||||
!saveAcknowledgedForCurrentVersion || incomingHash === acceptedPersistedHash;
|
||||
|
||||
if (shouldKeepBlockingIncomingData) {
|
||||
logNodeDataDebug("skip-stale-external-data", {
|
||||
nodeId,
|
||||
nodeType: debugLabel,
|
||||
incomingHash,
|
||||
localHash,
|
||||
saveAcknowledgedForCurrentVersion,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
acceptedPersistedDataRef.current = incomingData;
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
acknowledgedSaveVersionRef.current = 0;
|
||||
localDataRef.current = incomingData;
|
||||
setLocalDataState(incomingData);
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
@@ -123,7 +139,7 @@ export function useNodeLocalData<T>({
|
||||
setPreviewNodeDataOverride(nodeId, next);
|
||||
queueSave();
|
||||
},
|
||||
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
[nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
);
|
||||
|
||||
const updateLocalData = useCallback(
|
||||
@@ -137,7 +153,7 @@ export function useNodeLocalData<T>({
|
||||
setPreviewNodeDataOverride(nodeId, next);
|
||||
queueSave();
|
||||
},
|
||||
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
[nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,11 +10,19 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
|
||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||
|
||||
import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers";
|
||||
import { resolveDroppedConnectionTarget } from "./canvas-helpers";
|
||||
import {
|
||||
getConnectEndClientPoint,
|
||||
hasHandleKey,
|
||||
isOptimisticEdgeId,
|
||||
isOptimisticNodeId,
|
||||
logCanvasConnectionDebug,
|
||||
normalizeHandle,
|
||||
resolveDroppedConnectionTarget,
|
||||
} from "./canvas-helpers";
|
||||
import {
|
||||
validateCanvasConnection,
|
||||
validateCanvasConnectionByType,
|
||||
validateCanvasEdgeSplit,
|
||||
} from "./canvas-connection-validation";
|
||||
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
|
||||
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
|
||||
@@ -43,6 +51,15 @@ type UseCanvasConnectionsParams = {
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
}) => Promise<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>;
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -92,6 +109,7 @@ export function useCanvasConnections({
|
||||
screenToFlowPosition,
|
||||
syncPendingMoveForClientRequest,
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
runRemoveEdgeMutation,
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||
@@ -107,8 +125,13 @@ export function useCanvasConnections({
|
||||
connectionDropMenuRef.current = connectionDropMenu;
|
||||
}, [connectionDropMenu]);
|
||||
|
||||
const onConnectStart = useCallback<OnConnectStart>(() => {
|
||||
const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
|
||||
isConnectDragActiveRef.current = true;
|
||||
logCanvasConnectionDebug("connect:start", {
|
||||
nodeId: params.nodeId,
|
||||
handleId: params.handleId,
|
||||
handleType: params.handleType,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onConnect = useCallback(
|
||||
@@ -116,11 +139,33 @@ export function useCanvasConnections({
|
||||
isConnectDragActiveRef.current = false;
|
||||
const validationError = validateCanvasConnection(connection, nodes, edges);
|
||||
if (validationError) {
|
||||
logCanvasConnectionDebug("connect:invalid-direct", {
|
||||
sourceNodeId: connection.source ?? null,
|
||||
targetNodeId: connection.target ?? null,
|
||||
sourceHandle: connection.sourceHandle ?? null,
|
||||
targetHandle: connection.targetHandle ?? null,
|
||||
validationError,
|
||||
});
|
||||
showConnectionRejectedToast(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connection.source || !connection.target) return;
|
||||
if (!connection.source || !connection.target) {
|
||||
logCanvasConnectionDebug("connect:missing-endpoint", {
|
||||
sourceNodeId: connection.source ?? null,
|
||||
targetNodeId: connection.target ?? null,
|
||||
sourceHandle: connection.sourceHandle ?? null,
|
||||
targetHandle: connection.targetHandle ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logCanvasConnectionDebug("connect:direct", {
|
||||
sourceNodeId: connection.source,
|
||||
targetNodeId: connection.target,
|
||||
sourceHandle: connection.sourceHandle ?? null,
|
||||
targetHandle: connection.targetHandle ?? null,
|
||||
});
|
||||
|
||||
void runCreateEdgeMutation({
|
||||
canvasId,
|
||||
@@ -136,18 +181,71 @@ export function useCanvasConnections({
|
||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||
(event, connectionState) => {
|
||||
if (!isConnectDragActiveRef.current) {
|
||||
logCanvasConnectionDebug("connect:end-ignored", {
|
||||
reason: "drag-not-active",
|
||||
isValid: connectionState.isValid ?? null,
|
||||
fromNodeId: connectionState.fromNode?.id ?? null,
|
||||
fromHandleId: connectionState.fromHandle?.id ?? null,
|
||||
toNodeId: connectionState.toNode?.id ?? null,
|
||||
toHandleId: connectionState.toHandle?.id ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectDragActiveRef.current = false;
|
||||
if (isReconnectDragActiveRef.current) return;
|
||||
if (connectionState.isValid === true) return;
|
||||
if (isReconnectDragActiveRef.current) {
|
||||
logCanvasConnectionDebug("connect:end-ignored", {
|
||||
reason: "reconnect-active",
|
||||
isValid: connectionState.isValid ?? null,
|
||||
fromNodeId: connectionState.fromNode?.id ?? null,
|
||||
fromHandleId: connectionState.fromHandle?.id ?? null,
|
||||
toNodeId: connectionState.toNode?.id ?? null,
|
||||
toHandleId: connectionState.toHandle?.id ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (connectionState.isValid === true) {
|
||||
logCanvasConnectionDebug("connect:end-ignored", {
|
||||
reason: "react-flow-valid-connection",
|
||||
fromNodeId: connectionState.fromNode?.id ?? null,
|
||||
fromHandleId: connectionState.fromHandle?.id ?? null,
|
||||
toNodeId: connectionState.toNode?.id ?? null,
|
||||
toHandleId: connectionState.toHandle?.id ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const fromNode = connectionState.fromNode;
|
||||
const fromHandle = connectionState.fromHandle;
|
||||
if (!fromNode || !fromHandle) return;
|
||||
if (!fromNode || !fromHandle) {
|
||||
logCanvasConnectionDebug("connect:end-aborted", {
|
||||
reason: "missing-from-node-or-handle",
|
||||
fromNodeId: fromNode?.id ?? null,
|
||||
fromHandleId: fromHandle?.id ?? null,
|
||||
toNodeId: connectionState.toNode?.id ?? null,
|
||||
toHandleId: connectionState.toHandle?.id ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pt = getConnectEndClientPoint(event);
|
||||
if (!pt) return;
|
||||
if (!pt) {
|
||||
logCanvasConnectionDebug("connect:end-aborted", {
|
||||
reason: "missing-client-point",
|
||||
fromNodeId: fromNode.id,
|
||||
fromHandleId: fromHandle.id ?? null,
|
||||
fromHandleType: fromHandle.type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logCanvasConnectionDebug("connect:end", {
|
||||
point: pt,
|
||||
fromNodeId: fromNode.id,
|
||||
fromHandleId: fromHandle.id ?? null,
|
||||
fromHandleType: fromHandle.type,
|
||||
toNodeId: connectionState.toNode?.id ?? null,
|
||||
toHandleId: connectionState.toHandle?.id ?? null,
|
||||
});
|
||||
|
||||
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
||||
const droppedConnection = resolveDroppedConnectionTarget({
|
||||
@@ -159,6 +257,15 @@ export function useCanvasConnections({
|
||||
edges: edgesRef.current,
|
||||
});
|
||||
|
||||
logCanvasConnectionDebug("connect:end-drop-result", {
|
||||
point: pt,
|
||||
flow,
|
||||
fromNodeId: fromNode.id,
|
||||
fromHandleId: fromHandle.id ?? null,
|
||||
fromHandleType: fromHandle.type,
|
||||
droppedConnection,
|
||||
});
|
||||
|
||||
if (droppedConnection) {
|
||||
const validationError = validateCanvasConnection(
|
||||
{
|
||||
@@ -171,10 +278,75 @@ export function useCanvasConnections({
|
||||
edgesRef.current,
|
||||
);
|
||||
if (validationError) {
|
||||
const fullFromNode = nodesRef.current.find((node) => node.id === fromNode.id);
|
||||
const splitHandles = NODE_HANDLE_MAP[fullFromNode?.type ?? ""];
|
||||
const incomingEdges = edgesRef.current.filter(
|
||||
(edge) =>
|
||||
edge.target === droppedConnection.targetNodeId &&
|
||||
edge.className !== "temp" &&
|
||||
!isOptimisticEdgeId(edge.id),
|
||||
);
|
||||
const incomingEdge = incomingEdges.length === 1 ? incomingEdges[0] : undefined;
|
||||
const splitValidationError =
|
||||
validationError === "adjustment-incoming-limit" &&
|
||||
droppedConnection.sourceNodeId === fromNode.id &&
|
||||
fromHandle.type === "source" &&
|
||||
fullFromNode !== undefined &&
|
||||
splitHandles !== undefined &&
|
||||
hasHandleKey(splitHandles, "source") &&
|
||||
hasHandleKey(splitHandles, "target") &&
|
||||
incomingEdge !== undefined &&
|
||||
incomingEdge.source !== fullFromNode.id &&
|
||||
incomingEdge.target !== fullFromNode.id
|
||||
? validateCanvasEdgeSplit({
|
||||
nodes: nodesRef.current,
|
||||
edges: edgesRef.current,
|
||||
splitEdge: incomingEdge,
|
||||
middleNode: fullFromNode,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!splitValidationError && incomingEdge && fullFromNode && splitHandles) {
|
||||
logCanvasConnectionDebug("connect:end-auto-split", {
|
||||
point: pt,
|
||||
flow,
|
||||
droppedConnection,
|
||||
splitEdgeId: incomingEdge.id,
|
||||
middleNodeId: fullFromNode.id,
|
||||
});
|
||||
void runSplitEdgeAtExistingNodeMutation({
|
||||
canvasId,
|
||||
splitEdgeId: incomingEdge.id as Id<"edges">,
|
||||
middleNodeId: fullFromNode.id as Id<"nodes">,
|
||||
splitSourceHandle: normalizeHandle(incomingEdge.sourceHandle),
|
||||
splitTargetHandle: normalizeHandle(incomingEdge.targetHandle),
|
||||
newNodeSourceHandle: normalizeHandle(splitHandles.source),
|
||||
newNodeTargetHandle: normalizeHandle(splitHandles.target),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logCanvasConnectionDebug("connect:end-drop-rejected", {
|
||||
point: pt,
|
||||
flow,
|
||||
droppedConnection,
|
||||
validationError,
|
||||
attemptedAutoSplit:
|
||||
validationError === "adjustment-incoming-limit" &&
|
||||
droppedConnection.sourceNodeId === fromNode.id &&
|
||||
fromHandle.type === "source",
|
||||
splitValidationError,
|
||||
});
|
||||
showConnectionRejectedToast(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
logCanvasConnectionDebug("connect:end-create-edge", {
|
||||
point: pt,
|
||||
flow,
|
||||
droppedConnection,
|
||||
});
|
||||
|
||||
void runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">,
|
||||
@@ -185,6 +357,14 @@ export function useCanvasConnections({
|
||||
return;
|
||||
}
|
||||
|
||||
logCanvasConnectionDebug("connect:end-open-menu", {
|
||||
point: pt,
|
||||
flow,
|
||||
fromNodeId: fromNode.id,
|
||||
fromHandleId: fromHandle.id ?? null,
|
||||
fromHandleType: fromHandle.type,
|
||||
});
|
||||
|
||||
setConnectionDropMenu({
|
||||
screenX: pt.x,
|
||||
screenY: pt.y,
|
||||
@@ -201,6 +381,7 @@ export function useCanvasConnections({
|
||||
isReconnectDragActiveRef,
|
||||
nodesRef,
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
screenToFlowPosition,
|
||||
showConnectionRejectedToast,
|
||||
],
|
||||
|
||||
@@ -4,19 +4,34 @@ import type { Id } from "@/convex/_generated/dataModel";
|
||||
import {
|
||||
CANVAS_NODE_DND_MIME,
|
||||
} from "@/lib/canvas-connection-policy";
|
||||
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
|
||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
import {
|
||||
isCanvasNodeType,
|
||||
type CanvasNodeType,
|
||||
} from "@/lib/canvas-node-types";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
import {
|
||||
getIntersectedEdgeId,
|
||||
hasHandleKey,
|
||||
isOptimisticEdgeId,
|
||||
logCanvasConnectionDebug,
|
||||
normalizeHandle,
|
||||
} from "./canvas-helpers";
|
||||
import { getImageDimensions } from "./canvas-media-utils";
|
||||
|
||||
type UseCanvasDropParams = {
|
||||
canvasId: Id<"canvases">;
|
||||
isSyncOnline: boolean;
|
||||
t: (key: string) => string;
|
||||
edges: Array<{
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
className?: string;
|
||||
sourceHandle?: string | null;
|
||||
targetHandle?: string | null;
|
||||
}>;
|
||||
screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
|
||||
generateUploadUrl: () => Promise<string>;
|
||||
runCreateNodeOnlineOnly: (args: {
|
||||
@@ -29,6 +44,21 @@ type UseCanvasDropParams = {
|
||||
data: Record<string, unknown>;
|
||||
clientRequestId?: string;
|
||||
}) => 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;
|
||||
syncPendingMoveForClientRequest: (
|
||||
clientRequestId: string,
|
||||
@@ -66,9 +96,11 @@ export function useCanvasDrop({
|
||||
canvasId,
|
||||
isSyncOnline,
|
||||
t,
|
||||
edges,
|
||||
screenToFlowPosition,
|
||||
generateUploadUrl,
|
||||
runCreateNodeOnlineOnly,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
notifyOfflineUnsupported,
|
||||
syncPendingMoveForClientRequest,
|
||||
}: UseCanvasDropParams) {
|
||||
@@ -169,14 +201,80 @@ export function useCanvasDrop({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
const intersectedEdgeId =
|
||||
typeof document !== "undefined" &&
|
||||
typeof document.elementsFromPoint === "function"
|
||||
? getIntersectedEdgeId({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
: null;
|
||||
const defaults = NODE_DEFAULTS[parsedPayload.nodeType] ?? {
|
||||
width: 200,
|
||||
height: 100,
|
||||
data: {},
|
||||
};
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
const hitEdge = intersectedEdgeId
|
||||
? edges.find(
|
||||
(edge) =>
|
||||
edge.id === intersectedEdgeId &&
|
||||
edge.className !== "temp" &&
|
||||
!isOptimisticEdgeId(edge.id),
|
||||
)
|
||||
: undefined;
|
||||
const handles = NODE_HANDLE_MAP[parsedPayload.nodeType];
|
||||
const canSplitEdge =
|
||||
hitEdge !== undefined &&
|
||||
handles !== undefined &&
|
||||
hasHandleKey(handles, "source") &&
|
||||
hasHandleKey(handles, "target");
|
||||
|
||||
void runCreateNodeOnlineOnly({
|
||||
logCanvasConnectionDebug("node-drop", {
|
||||
nodeType: parsedPayload.nodeType,
|
||||
clientPoint: { x: event.clientX, y: event.clientY },
|
||||
flowPoint: position,
|
||||
intersectedEdgeId,
|
||||
hitEdgeId: hitEdge?.id ?? null,
|
||||
usesEdgeSplitPath: canSplitEdge,
|
||||
});
|
||||
|
||||
const createNodePromise = canSplitEdge
|
||||
? (() => {
|
||||
logCanvasConnectionDebug("node-drop:split-edge", {
|
||||
nodeType: parsedPayload.nodeType,
|
||||
clientPoint: { x: event.clientX, y: event.clientY },
|
||||
flowPoint: position,
|
||||
intersectedEdgeId,
|
||||
splitEdgeId: hitEdge.id,
|
||||
});
|
||||
return runCreateNodeWithEdgeSplitOnlineOnly({
|
||||
canvasId,
|
||||
type: parsedPayload.nodeType,
|
||||
positionX: position.x,
|
||||
positionY: position.y,
|
||||
width: defaults.width,
|
||||
height: defaults.height,
|
||||
data: { ...defaults.data, ...parsedPayload.payloadData, canvasId },
|
||||
splitEdgeId: hitEdge.id as Id<"edges">,
|
||||
newNodeTargetHandle: normalizeHandle(handles.target),
|
||||
newNodeSourceHandle: normalizeHandle(handles.source),
|
||||
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
|
||||
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
|
||||
clientRequestId,
|
||||
});
|
||||
})()
|
||||
: (() => {
|
||||
if (intersectedEdgeId) {
|
||||
logCanvasConnectionDebug("node-drop:edge-detected-no-split", {
|
||||
nodeType: parsedPayload.nodeType,
|
||||
clientPoint: { x: event.clientX, y: event.clientY },
|
||||
flowPoint: position,
|
||||
intersectedEdgeId,
|
||||
});
|
||||
}
|
||||
|
||||
return runCreateNodeOnlineOnly({
|
||||
canvasId,
|
||||
type: parsedPayload.nodeType,
|
||||
positionX: position.x,
|
||||
@@ -185,7 +283,10 @@ export function useCanvasDrop({
|
||||
height: defaults.height,
|
||||
data: { ...defaults.data, ...parsedPayload.payloadData, canvasId },
|
||||
clientRequestId,
|
||||
}).then((realId) => {
|
||||
});
|
||||
})();
|
||||
|
||||
void createNodePromise.then((realId) => {
|
||||
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
|
||||
(error: unknown) => {
|
||||
console.error("[Canvas] createNode syncPendingMove failed", error);
|
||||
@@ -195,9 +296,11 @@ export function useCanvasDrop({
|
||||
},
|
||||
[
|
||||
canvasId,
|
||||
edges,
|
||||
generateUploadUrl,
|
||||
isSyncOnline,
|
||||
notifyOfflineUnsupported,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
runCreateNodeOnlineOnly,
|
||||
screenToFlowPosition,
|
||||
syncPendingMoveForClientRequest,
|
||||
|
||||
218
components/canvas/use-canvas-edge-insertions.ts
Normal file
218
components/canvas/use-canvas-edge-insertions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
51
components/canvas/use-canvas-edge-types.tsx
Normal file
51
components/canvas/use-canvas-edge-types.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type CanvasFlowReconciliationRefs = {
|
||||
pendingLocalPositionUntilConvexMatchesRef: MutableRefObject<
|
||||
Map<string, PositionPin>
|
||||
>;
|
||||
pendingLocalNodeDataUntilConvexMatchesRef: MutableRefObject<Map<string, unknown>>;
|
||||
preferLocalPositionNodeIdsRef: MutableRefObject<Set<string>>;
|
||||
isDragging: MutableRefObject<boolean>;
|
||||
isResizing: MutableRefObject<boolean>;
|
||||
@@ -54,6 +55,7 @@ export function useCanvasFlowReconciliation(args: {
|
||||
resolvedRealIdByClientRequestRef,
|
||||
pendingConnectionCreatesRef,
|
||||
pendingLocalPositionUntilConvexMatchesRef,
|
||||
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||
preferLocalPositionNodeIdsRef,
|
||||
isDragging,
|
||||
isResizing,
|
||||
@@ -131,6 +133,8 @@ export function useCanvasFlowReconciliation(args: {
|
||||
pendingConnectionCreateIds: pendingConnectionCreatesRef.current,
|
||||
preferLocalPositionNodeIds: preferLocalPositionNodeIdsRef.current,
|
||||
pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current,
|
||||
pendingLocalNodeDataPins:
|
||||
pendingLocalNodeDataUntilConvexMatchesRef.current,
|
||||
pendingMovePins,
|
||||
});
|
||||
|
||||
@@ -138,6 +142,8 @@ export function useCanvasFlowReconciliation(args: {
|
||||
reconciliation.inferredRealIdByClientRequest;
|
||||
pendingLocalPositionUntilConvexMatchesRef.current =
|
||||
reconciliation.nextPendingLocalPositionPins;
|
||||
pendingLocalNodeDataUntilConvexMatchesRef.current =
|
||||
reconciliation.nextPendingLocalNodeDataPins;
|
||||
for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) {
|
||||
preferLocalPositionNodeIdsRef.current.delete(nodeId);
|
||||
}
|
||||
@@ -155,6 +161,7 @@ export function useCanvasFlowReconciliation(args: {
|
||||
isResizing,
|
||||
pendingConnectionCreatesRef,
|
||||
pendingLocalPositionUntilConvexMatchesRef,
|
||||
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||
preferLocalPositionNodeIdsRef,
|
||||
resolvedRealIdByClientRequestRef,
|
||||
]);
|
||||
|
||||
@@ -205,6 +205,9 @@ export function createCanvasSyncEngineController({
|
||||
const pendingLocalPositionUntilConvexMatchesRef = {
|
||||
current: new Map<string, { x: number; y: number }>(),
|
||||
};
|
||||
const pendingLocalNodeDataUntilConvexMatchesRef = {
|
||||
current: new Map<string, unknown>(),
|
||||
};
|
||||
const preferLocalPositionNodeIdsRef = { current: new Set<string>() };
|
||||
|
||||
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 (
|
||||
clientRequestId: string,
|
||||
realId: Id<"nodes">,
|
||||
@@ -228,6 +246,7 @@ export function createCanvasSyncEngineController({
|
||||
if (!pendingDataAfterCreateRef.current.has(clientRequestId)) return;
|
||||
const pendingData = pendingDataAfterCreateRef.current.get(clientRequestId);
|
||||
pendingDataAfterCreateRef.current.delete(clientRequestId);
|
||||
pinNodeDataLocally(realId as string, pendingData);
|
||||
await getEnqueueSyncMutation()("updateData", {
|
||||
nodeId: realId,
|
||||
data: pendingData,
|
||||
@@ -272,6 +291,7 @@ export function createCanvasSyncEngineController({
|
||||
data: unknown;
|
||||
}): Promise<void> => {
|
||||
const rawNodeId = args.nodeId as string;
|
||||
pinNodeDataLocally(rawNodeId, args.data);
|
||||
if (!isOptimisticNodeId(rawNodeId) || !getIsSyncOnline()) {
|
||||
await getEnqueueSyncMutation()("updateData", args);
|
||||
return;
|
||||
@@ -311,6 +331,7 @@ export function createCanvasSyncEngineController({
|
||||
pendingMoveAfterCreateRef.current.delete(clientRequestId);
|
||||
pendingResizeAfterCreateRef.current.delete(clientRequestId);
|
||||
pendingDataAfterCreateRef.current.delete(clientRequestId);
|
||||
pendingLocalNodeDataUntilConvexMatchesRef.current.delete(realId as string);
|
||||
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
|
||||
pendingConnectionCreatesRef.current.delete(clientRequestId);
|
||||
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
|
||||
@@ -459,6 +480,7 @@ export function createCanvasSyncEngineController({
|
||||
pendingDeleteAfterCreateClientRequestIdsRef,
|
||||
pendingConnectionCreatesRef,
|
||||
pendingLocalPositionUntilConvexMatchesRef,
|
||||
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||
preferLocalPositionNodeIdsRef,
|
||||
flushPendingResizeForClientRequest,
|
||||
flushPendingDataForClientRequest,
|
||||
@@ -998,6 +1020,9 @@ export function useCanvasSyncEngine({
|
||||
controller.pendingMoveAfterCreateRef.current.delete(args.clientRequestId);
|
||||
controller.pendingResizeAfterCreateRef.current.delete(args.clientRequestId);
|
||||
controller.pendingDataAfterCreateRef.current.delete(args.clientRequestId);
|
||||
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete(
|
||||
optimisticNodeId,
|
||||
);
|
||||
pendingCreatePromiseByClientRequestRef.current.delete(args.clientRequestId);
|
||||
controller.pendingEdgeSplitByClientRequestRef.current.delete(
|
||||
args.clientRequestId,
|
||||
@@ -1083,6 +1108,20 @@ export function useCanvasSyncEngine({
|
||||
);
|
||||
}
|
||||
|
||||
const pinnedData =
|
||||
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.get(
|
||||
optimisticNodeId,
|
||||
);
|
||||
if (pinnedData !== undefined) {
|
||||
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete(
|
||||
optimisticNodeId,
|
||||
);
|
||||
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.set(
|
||||
realNodeId,
|
||||
pinnedData,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
controller.preferLocalPositionNodeIdsRef.current.has(optimisticNodeId)
|
||||
) {
|
||||
@@ -1655,6 +1694,10 @@ export function useCanvasSyncEngine({
|
||||
for (const nodeId of op.payload.nodeIds) {
|
||||
deletingNodeIds.current.delete(nodeId as string);
|
||||
}
|
||||
} else if (op.type === "updateData") {
|
||||
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete(
|
||||
op.payload.nodeId as string,
|
||||
);
|
||||
}
|
||||
await ackCanvasSyncOp(op.id);
|
||||
resolveCanvasOp(canvasId as string, op.id);
|
||||
@@ -1767,6 +1810,8 @@ export function useCanvasSyncEngine({
|
||||
pendingConnectionCreatesRef: controller.pendingConnectionCreatesRef,
|
||||
pendingLocalPositionUntilConvexMatchesRef:
|
||||
controller.pendingLocalPositionUntilConvexMatchesRef,
|
||||
pendingLocalNodeDataUntilConvexMatchesRef:
|
||||
controller.pendingLocalNodeDataUntilConvexMatchesRef,
|
||||
preferLocalPositionNodeIdsRef: controller.preferLocalPositionNodeIdsRef,
|
||||
pendingCreatePromiseByClientRequestRef,
|
||||
},
|
||||
|
||||
@@ -12,12 +12,16 @@ export default defineConfig({
|
||||
include: [
|
||||
"tests/**/*.test.ts",
|
||||
"components/canvas/__tests__/canvas-helpers.test.ts",
|
||||
"components/canvas/__tests__/default-edge.test.tsx",
|
||||
"components/canvas/__tests__/canvas-connection-drop-menu.test.tsx",
|
||||
"components/canvas/__tests__/canvas-connection-drop-target.test.tsx",
|
||||
"components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts",
|
||||
"components/canvas/__tests__/compare-node.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts",
|
||||
"components/canvas/__tests__/use-canvas-drop.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-connections.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-edge-insertions.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-edge-types.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
|
||||
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
||||
|
||||
Reference in New Issue
Block a user