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);
|
expect(result.nextPendingLocalPositionPins.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps pinned local node data until convex catches up", () => {
|
||||||
|
const pinnedData = { blackPoint: 209, whitePoint: 255 };
|
||||||
|
const staleIncomingData = { blackPoint: 124, whitePoint: 255 };
|
||||||
|
|
||||||
|
const result = reconcileCanvasFlowNodes({
|
||||||
|
previousNodes: [
|
||||||
|
{
|
||||||
|
id: "node-1",
|
||||||
|
type: "curves",
|
||||||
|
position: { x: 120, y: 80 },
|
||||||
|
data: pinnedData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
incomingNodes: [
|
||||||
|
{
|
||||||
|
id: "node-1",
|
||||||
|
type: "curves",
|
||||||
|
position: { x: 120, y: 80 },
|
||||||
|
data: staleIncomingData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
convexNodes: [{ _id: asNodeId("node-1"), type: "curves" }],
|
||||||
|
deletingNodeIds: new Set(),
|
||||||
|
resolvedRealIdByClientRequest: new Map(),
|
||||||
|
pendingConnectionCreateIds: new Set(),
|
||||||
|
preferLocalPositionNodeIds: new Set(),
|
||||||
|
pendingLocalPositionPins: new Map(),
|
||||||
|
pendingLocalNodeDataPins: new Map([["node-1", pinnedData]]),
|
||||||
|
pendingMovePins: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.nodes).toEqual([
|
||||||
|
{
|
||||||
|
id: "node-1",
|
||||||
|
type: "curves",
|
||||||
|
position: { x: 120, y: 80 },
|
||||||
|
data: pinnedData,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.nextPendingLocalNodeDataPins).toEqual(
|
||||||
|
new Map([["node-1", pinnedData]]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears pinned local node data once incoming data includes the persisted values", () => {
|
||||||
|
const pinnedData = { blackPoint: 209, whitePoint: 255 };
|
||||||
|
const incomingData = {
|
||||||
|
blackPoint: 209,
|
||||||
|
whitePoint: 255,
|
||||||
|
_status: "idle",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = reconcileCanvasFlowNodes({
|
||||||
|
previousNodes: [
|
||||||
|
{
|
||||||
|
id: "node-1",
|
||||||
|
type: "curves",
|
||||||
|
position: { x: 120, y: 80 },
|
||||||
|
data: pinnedData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
incomingNodes: [
|
||||||
|
{
|
||||||
|
id: "node-1",
|
||||||
|
type: "curves",
|
||||||
|
position: { x: 120, y: 80 },
|
||||||
|
data: incomingData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
convexNodes: [{ _id: asNodeId("node-1"), type: "curves" }],
|
||||||
|
deletingNodeIds: new Set(),
|
||||||
|
resolvedRealIdByClientRequest: new Map(),
|
||||||
|
pendingConnectionCreateIds: new Set(),
|
||||||
|
preferLocalPositionNodeIds: new Set(),
|
||||||
|
pendingLocalPositionPins: new Map(),
|
||||||
|
pendingLocalNodeDataPins: new Map([["node-1", pinnedData]]),
|
||||||
|
pendingMovePins: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.nodes).toEqual([
|
||||||
|
{
|
||||||
|
id: "node-1",
|
||||||
|
type: "curves",
|
||||||
|
position: { x: 120, y: 80 },
|
||||||
|
data: incomingData,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.nextPendingLocalNodeDataPins.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("filters deleting nodes from incoming reconciliation results", () => {
|
it("filters deleting nodes from incoming reconciliation results", () => {
|
||||||
const result = reconcileCanvasFlowNodes({
|
const result = reconcileCanvasFlowNodes({
|
||||||
previousNodes: [
|
previousNodes: [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||||
|
|
||||||
import { withResolvedCompareData } from "../canvas-helpers";
|
import { computeEdgeInsertLayout, withResolvedCompareData } from "../canvas-helpers";
|
||||||
import {
|
import {
|
||||||
buildGraphSnapshot,
|
buildGraphSnapshot,
|
||||||
pruneCanvasGraphNodeDataOverrides,
|
pruneCanvasGraphNodeDataOverrides,
|
||||||
@@ -310,3 +310,107 @@ describe("canvas preview graph helpers", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("computeEdgeInsertLayout", () => {
|
||||||
|
it("shifts source and target along a horizontal axis when spacing is too tight", () => {
|
||||||
|
const source = createNode({
|
||||||
|
id: "source",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
style: { width: 100, height: 60 },
|
||||||
|
});
|
||||||
|
const target = createNode({
|
||||||
|
id: "target",
|
||||||
|
position: { x: 120, y: 0 },
|
||||||
|
style: { width: 100, height: 60 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout = computeEdgeInsertLayout({
|
||||||
|
sourceNode: source,
|
||||||
|
targetNode: target,
|
||||||
|
newNodeWidth: 80,
|
||||||
|
newNodeHeight: 40,
|
||||||
|
gapPx: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.insertPosition).toEqual({ x: 70, y: 10 });
|
||||||
|
expect(layout.sourcePosition).toEqual({ x: -40, y: 0 });
|
||||||
|
expect(layout.targetPosition).toEqual({ x: 160, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps diagonal-axis spacing adjustments aligned to the edge direction", () => {
|
||||||
|
const source = createNode({
|
||||||
|
id: "source",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
style: { width: 100, height: 100 },
|
||||||
|
});
|
||||||
|
const target = createNode({
|
||||||
|
id: "target",
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
style: { width: 100, height: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout = computeEdgeInsertLayout({
|
||||||
|
sourceNode: source,
|
||||||
|
targetNode: target,
|
||||||
|
newNodeWidth: 80,
|
||||||
|
newNodeHeight: 80,
|
||||||
|
gapPx: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.insertPosition).toEqual({ x: 60, y: 60 });
|
||||||
|
expect(layout.sourcePosition).toBeDefined();
|
||||||
|
expect(layout.targetPosition).toBeDefined();
|
||||||
|
expect(layout.sourcePosition?.x).toBeCloseTo(layout.sourcePosition?.y ?? 0, 6);
|
||||||
|
expect(layout.targetPosition?.x).toBeCloseTo(layout.targetPosition?.y ?? 0, 6);
|
||||||
|
expect(layout.sourcePosition?.x).toBeLessThan(source.position.x);
|
||||||
|
expect(layout.targetPosition?.x).toBeGreaterThan(target.position.x);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not shift source or target when there is enough spacing", () => {
|
||||||
|
const source = createNode({
|
||||||
|
id: "source",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
style: { width: 100, height: 60 },
|
||||||
|
});
|
||||||
|
const target = createNode({
|
||||||
|
id: "target",
|
||||||
|
position: { x: 320, y: 0 },
|
||||||
|
style: { width: 100, height: 60 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout = computeEdgeInsertLayout({
|
||||||
|
sourceNode: source,
|
||||||
|
targetNode: target,
|
||||||
|
newNodeWidth: 80,
|
||||||
|
newNodeHeight: 40,
|
||||||
|
gapPx: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.insertPosition).toEqual({ x: 170, y: 10 });
|
||||||
|
expect(layout.sourcePosition).toBeUndefined();
|
||||||
|
expect(layout.targetPosition).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to midpoint placement without aggressive shifts in degenerate cases", () => {
|
||||||
|
const source = createNode({
|
||||||
|
id: "source",
|
||||||
|
position: { x: 40, y: 80 },
|
||||||
|
});
|
||||||
|
const target = createNode({
|
||||||
|
id: "target",
|
||||||
|
position: { x: 40, y: 80 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout = computeEdgeInsertLayout({
|
||||||
|
sourceNode: source,
|
||||||
|
targetNode: target,
|
||||||
|
newNodeWidth: 30,
|
||||||
|
newNodeHeight: 10,
|
||||||
|
gapPx: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.insertPosition).toEqual({ x: 25, y: 75 });
|
||||||
|
expect(layout.sourcePosition).toBeUndefined();
|
||||||
|
expect(layout.targetPosition).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
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 = {
|
type HookHarnessProps = {
|
||||||
helperResult: DroppedConnectionTarget | null;
|
helperResult: DroppedConnectionTarget | null;
|
||||||
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
|
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
|
||||||
|
runSplitEdgeAtExistingNodeMutation?: ReturnType<typeof vi.fn>;
|
||||||
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
||||||
|
nodes?: RFNode[];
|
||||||
|
edges?: RFEdge[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function HookHarness({
|
function HookHarness({
|
||||||
helperResult,
|
helperResult,
|
||||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||||
|
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
||||||
showConnectionRejectedToast = vi.fn(),
|
showConnectionRejectedToast = vi.fn(),
|
||||||
|
nodes: providedNodes,
|
||||||
|
edges: providedEdges,
|
||||||
}: HookHarnessProps) {
|
}: HookHarnessProps) {
|
||||||
const [nodes] = useState<RFNode[]>([
|
const [nodes] = useState<RFNode[]>(
|
||||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
providedNodes ?? [
|
||||||
{ id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} },
|
{ 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 nodesRef = useRef(nodes);
|
||||||
const edgesRef = useRef(edges);
|
const edgesRef = useRef(edges);
|
||||||
const edgeReconnectSuccessful = useRef(true);
|
const edgeReconnectSuccessful = useRef(true);
|
||||||
@@ -90,9 +98,10 @@ function HookHarness({
|
|||||||
resolvedRealIdByClientRequestRef,
|
resolvedRealIdByClientRequestRef,
|
||||||
setEdges,
|
setEdges,
|
||||||
setEdgeSyncNonce,
|
setEdgeSyncNonce,
|
||||||
screenToFlowPosition: (position) => position,
|
screenToFlowPosition: (position: { x: number; y: number }) => position,
|
||||||
syncPendingMoveForClientRequest: vi.fn(async () => undefined),
|
syncPendingMoveForClientRequest: vi.fn(async () => undefined),
|
||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
|
runSplitEdgeAtExistingNodeMutation,
|
||||||
runRemoveEdgeMutation: vi.fn(async () => undefined),
|
runRemoveEdgeMutation: vi.fn(async () => undefined),
|
||||||
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
|
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
|
||||||
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
|
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
|
||||||
@@ -346,6 +355,83 @@ describe("useCanvasConnections", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("splits the existing incoming edge when dropping onto an already-connected adjustment node", async () => {
|
||||||
|
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||||
|
const runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined);
|
||||||
|
const showConnectionRejectedToast = vi.fn();
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<HookHarness
|
||||||
|
helperResult={{
|
||||||
|
sourceNodeId: "node-curves",
|
||||||
|
targetNodeId: "node-light",
|
||||||
|
sourceHandle: undefined,
|
||||||
|
targetHandle: undefined,
|
||||||
|
}}
|
||||||
|
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||||
|
runSplitEdgeAtExistingNodeMutation={runSplitEdgeAtExistingNodeMutation}
|
||||||
|
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||||
|
nodes={[
|
||||||
|
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||||
|
{ id: "node-curves", type: "curves", position: { x: 180, y: 120 }, data: {} },
|
||||||
|
{ id: "node-light", type: "light-adjust", position: { x: 360, y: 120 }, data: {} },
|
||||||
|
]}
|
||||||
|
edges={[
|
||||||
|
{
|
||||||
|
id: "edge-image-light",
|
||||||
|
source: "node-image",
|
||||||
|
target: "node-light",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestHandlersRef.current?.onConnectStart?.(
|
||||||
|
{} as MouseEvent,
|
||||||
|
{
|
||||||
|
nodeId: "node-curves",
|
||||||
|
handleId: null,
|
||||||
|
handleType: "source",
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
latestHandlersRef.current?.onConnectEnd(
|
||||||
|
{ clientX: 400, clientY: 260 } as MouseEvent,
|
||||||
|
{
|
||||||
|
isValid: false,
|
||||||
|
from: { x: 0, y: 0 },
|
||||||
|
fromNode: { id: "node-curves", type: "curves" },
|
||||||
|
fromHandle: { id: null, type: "source" },
|
||||||
|
fromPosition: null,
|
||||||
|
to: { x: 400, y: 260 },
|
||||||
|
toHandle: null,
|
||||||
|
toNode: null,
|
||||||
|
toPosition: null,
|
||||||
|
pointer: null,
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runSplitEdgeAtExistingNodeMutation).toHaveBeenCalledWith({
|
||||||
|
canvasId: "canvas-1",
|
||||||
|
splitEdgeId: "edge-image-light",
|
||||||
|
middleNodeId: "node-curves",
|
||||||
|
splitSourceHandle: undefined,
|
||||||
|
splitTargetHandle: undefined,
|
||||||
|
newNodeSourceHandle: undefined,
|
||||||
|
newNodeTargetHandle: undefined,
|
||||||
|
});
|
||||||
|
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||||
|
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||||
|
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores onConnectEnd when no connect drag is active", async () => {
|
it("ignores onConnectEnd when no connect drag is active", async () => {
|
||||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||||
const showConnectionRejectedToast = vi.fn();
|
const showConnectionRejectedToast = vi.fn();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { act, useEffect } from "react";
|
import React, { act, useEffect } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import type { Edge as RFEdge } from "@xyflow/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -33,26 +34,32 @@ type HookHarnessProps = {
|
|||||||
isSyncOnline?: boolean;
|
isSyncOnline?: boolean;
|
||||||
generateUploadUrl?: ReturnType<typeof vi.fn>;
|
generateUploadUrl?: ReturnType<typeof vi.fn>;
|
||||||
runCreateNodeOnlineOnly?: ReturnType<typeof vi.fn>;
|
runCreateNodeOnlineOnly?: ReturnType<typeof vi.fn>;
|
||||||
|
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
|
||||||
notifyOfflineUnsupported?: ReturnType<typeof vi.fn>;
|
notifyOfflineUnsupported?: ReturnType<typeof vi.fn>;
|
||||||
syncPendingMoveForClientRequest?: ReturnType<typeof vi.fn>;
|
syncPendingMoveForClientRequest?: ReturnType<typeof vi.fn>;
|
||||||
screenToFlowPosition?: (position: { x: number; y: number }) => { x: number; y: number };
|
screenToFlowPosition?: (position: { x: number; y: number }) => { x: number; y: number };
|
||||||
|
edges?: RFEdge[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function HookHarness({
|
function HookHarness({
|
||||||
isSyncOnline = true,
|
isSyncOnline = true,
|
||||||
generateUploadUrl = vi.fn(async () => "https://upload.test"),
|
generateUploadUrl = vi.fn(async () => "https://upload.test"),
|
||||||
runCreateNodeOnlineOnly = vi.fn(async () => "node-1"),
|
runCreateNodeOnlineOnly = vi.fn(async () => "node-1"),
|
||||||
|
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-1"),
|
||||||
notifyOfflineUnsupported = vi.fn(),
|
notifyOfflineUnsupported = vi.fn(),
|
||||||
syncPendingMoveForClientRequest = vi.fn(async () => undefined),
|
syncPendingMoveForClientRequest = vi.fn(async () => undefined),
|
||||||
screenToFlowPosition = (position) => position,
|
screenToFlowPosition = (position) => position,
|
||||||
|
edges = [],
|
||||||
}: HookHarnessProps) {
|
}: HookHarnessProps) {
|
||||||
const handlers = useCanvasDrop({
|
const handlers = useCanvasDrop({
|
||||||
canvasId: asCanvasId("canvas-1"),
|
canvasId: asCanvasId("canvas-1"),
|
||||||
isSyncOnline,
|
isSyncOnline,
|
||||||
t: ((key: string) => key) as (key: string) => string,
|
t: ((key: string) => key) as (key: string) => string,
|
||||||
|
edges,
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
generateUploadUrl,
|
generateUploadUrl,
|
||||||
runCreateNodeOnlineOnly,
|
runCreateNodeOnlineOnly,
|
||||||
|
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||||
notifyOfflineUnsupported,
|
notifyOfflineUnsupported,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
});
|
});
|
||||||
@@ -260,6 +267,72 @@ describe("useCanvasDrop", () => {
|
|||||||
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-video");
|
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-video");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("splits an intersected persisted edge for sidebar node drops", async () => {
|
||||||
|
const runCreateNodeOnlineOnly = vi.fn(async () => "node-note");
|
||||||
|
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-note");
|
||||||
|
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
|
||||||
|
const edgeContainer = document.createElement("g");
|
||||||
|
edgeContainer.classList.add("react-flow__edge");
|
||||||
|
edgeContainer.setAttribute("data-id", "edge-a");
|
||||||
|
const interaction = document.createElement("path");
|
||||||
|
interaction.classList.add("react-flow__edge-interaction");
|
||||||
|
edgeContainer.appendChild(interaction);
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
value: vi.fn(() => [interaction]),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<HookHarness
|
||||||
|
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
|
||||||
|
runCreateNodeWithEdgeSplitOnlineOnly={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||||
|
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
|
||||||
|
edges={[{ id: "edge-a", source: "node-1", target: "node-2" } as RFEdge]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await latestHandlersRef.current?.onDrop({
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
clientX: 120,
|
||||||
|
clientY: 340,
|
||||||
|
dataTransfer: {
|
||||||
|
getData: vi.fn((type: string) =>
|
||||||
|
type === CANVAS_NODE_DND_MIME ? "note" : "",
|
||||||
|
),
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
} as unknown as React.DragEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runCreateNodeWithEdgeSplitOnlineOnly).toHaveBeenCalledWith({
|
||||||
|
canvasId: "canvas-1",
|
||||||
|
type: "note",
|
||||||
|
positionX: 120,
|
||||||
|
positionY: 340,
|
||||||
|
width: NODE_DEFAULTS.note.width,
|
||||||
|
height: NODE_DEFAULTS.note.height,
|
||||||
|
data: {
|
||||||
|
...NODE_DEFAULTS.note.data,
|
||||||
|
canvasId: "canvas-1",
|
||||||
|
},
|
||||||
|
splitEdgeId: "edge-a",
|
||||||
|
newNodeTargetHandle: undefined,
|
||||||
|
newNodeSourceHandle: undefined,
|
||||||
|
splitSourceHandle: undefined,
|
||||||
|
splitTargetHandle: undefined,
|
||||||
|
clientRequestId: "req-1",
|
||||||
|
});
|
||||||
|
expect(runCreateNodeOnlineOnly).not.toHaveBeenCalled();
|
||||||
|
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-note");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows an upload failure toast when the dropped file upload fails", async () => {
|
it("shows an upload failure toast when the dropped file upload fails", async () => {
|
||||||
const generateUploadUrl = vi.fn(async () => "https://upload.test");
|
const generateUploadUrl = vi.fn(async () => "https://upload.test");
|
||||||
const runCreateNodeOnlineOnly = vi.fn(async () => "node-image");
|
const runCreateNodeOnlineOnly = vi.fn(async () => "node-image");
|
||||||
|
|||||||
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>;
|
pendingConnectionCreateIds: Set<string>;
|
||||||
previousConvexNodeIdsSnapshot: Set<string>;
|
previousConvexNodeIdsSnapshot: Set<string>;
|
||||||
pendingLocalPositionPins?: Map<string, { x: number; y: number }>;
|
pendingLocalPositionPins?: Map<string, { x: number; y: number }>;
|
||||||
|
pendingLocalNodeDataPins?: Map<string, unknown>;
|
||||||
preferLocalPositionNodeIds?: Set<string>;
|
preferLocalPositionNodeIds?: Set<string>;
|
||||||
isResizingRefOverride?: { current: boolean };
|
isResizingRefOverride?: { current: boolean };
|
||||||
};
|
};
|
||||||
@@ -78,6 +79,9 @@ function HookHarness(props: HarnessProps) {
|
|||||||
const pendingLocalPositionUntilConvexMatchesRef = useRef(
|
const pendingLocalPositionUntilConvexMatchesRef = useRef(
|
||||||
props.pendingLocalPositionPins ?? new Map<string, { x: number; y: number }>(),
|
props.pendingLocalPositionPins ?? new Map<string, { x: number; y: number }>(),
|
||||||
);
|
);
|
||||||
|
const pendingLocalNodeDataUntilConvexMatchesRef = useRef(
|
||||||
|
props.pendingLocalNodeDataPins ?? new Map<string, unknown>(),
|
||||||
|
);
|
||||||
const preferLocalPositionNodeIdsRef = useRef(
|
const preferLocalPositionNodeIdsRef = useRef(
|
||||||
props.preferLocalPositionNodeIds ?? new Set<string>(),
|
props.preferLocalPositionNodeIds ?? new Set<string>(),
|
||||||
);
|
);
|
||||||
@@ -115,6 +119,7 @@ function HookHarness(props: HarnessProps) {
|
|||||||
resolvedRealIdByClientRequestRef,
|
resolvedRealIdByClientRequestRef,
|
||||||
pendingConnectionCreatesRef,
|
pendingConnectionCreatesRef,
|
||||||
pendingLocalPositionUntilConvexMatchesRef,
|
pendingLocalPositionUntilConvexMatchesRef,
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||||
preferLocalPositionNodeIdsRef,
|
preferLocalPositionNodeIdsRef,
|
||||||
isDragging: isDraggingRef,
|
isDragging: isDraggingRef,
|
||||||
isResizing: isResizingRef,
|
isResizing: isResizingRef,
|
||||||
|
|||||||
@@ -74,4 +74,44 @@ describe("useCanvasSyncEngine", () => {
|
|||||||
expect(controller.pendingResizeAfterCreateRef.current.has("req-2")).toBe(false);
|
expect(controller.pendingResizeAfterCreateRef.current.has("req-2")).toBe(false);
|
||||||
expect(controller.pendingDataAfterCreateRef.current.has("req-2")).toBe(false);
|
expect(controller.pendingDataAfterCreateRef.current.has("req-2")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("pins local node data immediately when queueing an update", async () => {
|
||||||
|
const enqueueSyncMutation = vi.fn(async () => undefined);
|
||||||
|
let nodes = [
|
||||||
|
{
|
||||||
|
id: "node-1",
|
||||||
|
type: "curves",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { blackPoint: 124 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const setNodes = (updater: (current: typeof nodes) => typeof nodes) => {
|
||||||
|
nodes = updater(nodes);
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = createCanvasSyncEngineController({
|
||||||
|
canvasId: asCanvasId("canvas-1"),
|
||||||
|
isSyncOnline: true,
|
||||||
|
getEnqueueSyncMutation: () => enqueueSyncMutation,
|
||||||
|
getRunBatchRemoveNodes: () => vi.fn(async () => undefined),
|
||||||
|
getRunSplitEdgeAtExistingNode: () => vi.fn(async () => undefined),
|
||||||
|
getSetNodes: () => setNodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.queueNodeDataUpdate({
|
||||||
|
nodeId: asNodeId("node-1"),
|
||||||
|
data: { blackPoint: 209 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nodes[0]?.data).toEqual({ blackPoint: 209 });
|
||||||
|
expect(controller.pendingLocalNodeDataUntilConvexMatchesRef.current).toEqual(
|
||||||
|
new Map([["node-1", { blackPoint: 209 }]]),
|
||||||
|
);
|
||||||
|
expect(enqueueSyncMutation).toHaveBeenCalledWith("updateData", {
|
||||||
|
nodeId: asNodeId("node-1"),
|
||||||
|
data: { blackPoint: 209 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -445,4 +445,140 @@ describe("useNodeLocalData preview overrides", () => {
|
|||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps local data when save resolves before Convex catches up", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onSave = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<TestApp
|
||||||
|
nodeId="node-1"
|
||||||
|
data={{ exposure: 0.2, label: "persisted" }}
|
||||||
|
mounted
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestHookRef.current?.applyLocalData({
|
||||||
|
exposure: 0.8,
|
||||||
|
label: "local",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latestHookRef.current?.localData).toEqual({
|
||||||
|
exposure: 0.8,
|
||||||
|
label: "local",
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<TestApp
|
||||||
|
nodeId="node-1"
|
||||||
|
data={{ exposure: 0.2, label: "persisted" }}
|
||||||
|
mounted
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latestHookRef.current?.localData).toEqual({
|
||||||
|
exposure: 0.8,
|
||||||
|
label: "local",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a later normalized server value after blocking a stale rerender", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onSave = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<TestApp
|
||||||
|
nodeId="node-1"
|
||||||
|
data={{ exposure: 0.2, label: "persisted" }}
|
||||||
|
mounted
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestHookRef.current?.applyLocalData({
|
||||||
|
exposure: 0.8,
|
||||||
|
label: "local",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<TestApp
|
||||||
|
nodeId="node-1"
|
||||||
|
data={{ exposure: 0.2, label: "persisted" }}
|
||||||
|
mounted
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latestHookRef.current?.localData).toEqual({
|
||||||
|
exposure: 0.8,
|
||||||
|
label: "local",
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<TestApp
|
||||||
|
nodeId="node-1"
|
||||||
|
data={{ exposure: 0.75, label: "server-normalized" }}
|
||||||
|
mounted
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latestHookRef.current?.localData).toEqual({
|
||||||
|
exposure: 0.75,
|
||||||
|
label: "server-normalized",
|
||||||
|
});
|
||||||
|
expect(latestOverridesRef.current).toEqual(new Map());
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ export type ConnectionDropMenuState = {
|
|||||||
fromHandleType: "source" | "target";
|
fromHandleType: "source" | "target";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CanvasMenuAnchor = {
|
||||||
|
screenX: number;
|
||||||
|
screenY: number;
|
||||||
|
};
|
||||||
|
|
||||||
type CanvasConnectionDropMenuProps = {
|
type CanvasConnectionDropMenuProps = {
|
||||||
state: ConnectionDropMenuState | null;
|
anchor: CanvasMenuAnchor | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (template: CanvasNodeTemplate) => void;
|
onPick: (template: CanvasNodeTemplate) => void;
|
||||||
};
|
};
|
||||||
@@ -33,14 +38,14 @@ const PANEL_MAX_W = 360;
|
|||||||
const PANEL_MAX_H = 420;
|
const PANEL_MAX_H = 420;
|
||||||
|
|
||||||
export function CanvasConnectionDropMenu({
|
export function CanvasConnectionDropMenu({
|
||||||
state,
|
anchor,
|
||||||
onClose,
|
onClose,
|
||||||
onPick,
|
onPick,
|
||||||
}: CanvasConnectionDropMenuProps) {
|
}: CanvasConnectionDropMenuProps) {
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state) return;
|
if (!anchor) return;
|
||||||
|
|
||||||
const onEscape = (e: KeyboardEvent) => {
|
const onEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
@@ -59,9 +64,9 @@ export function CanvasConnectionDropMenu({
|
|||||||
document.removeEventListener("keydown", onEscape);
|
document.removeEventListener("keydown", onEscape);
|
||||||
document.removeEventListener("pointerdown", onPointerDownCapture, true);
|
document.removeEventListener("pointerdown", onPointerDownCapture, true);
|
||||||
};
|
};
|
||||||
}, [state, onClose]);
|
}, [anchor, onClose]);
|
||||||
|
|
||||||
if (!state) return null;
|
if (!anchor) return null;
|
||||||
|
|
||||||
const vw =
|
const vw =
|
||||||
typeof window !== "undefined" ? window.innerWidth : PANEL_MAX_W + 16;
|
typeof window !== "undefined" ? window.innerWidth : PANEL_MAX_W + 16;
|
||||||
@@ -69,11 +74,11 @@ export function CanvasConnectionDropMenu({
|
|||||||
typeof window !== "undefined" ? window.innerHeight : PANEL_MAX_H + 16;
|
typeof window !== "undefined" ? window.innerHeight : PANEL_MAX_H + 16;
|
||||||
const left = Math.max(
|
const left = Math.max(
|
||||||
8,
|
8,
|
||||||
Math.min(state.screenX, vw - PANEL_MAX_W - 8),
|
Math.min(anchor.screenX, vw - PANEL_MAX_W - 8),
|
||||||
);
|
);
|
||||||
const top = Math.max(
|
const top = Math.max(
|
||||||
8,
|
8,
|
||||||
Math.min(state.screenY, vh - PANEL_MAX_H - 8),
|
Math.min(anchor.screenY, vh - PANEL_MAX_H - 8),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -277,6 +277,83 @@ function applyLocalPositionPins(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNodeDataRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeDataIncludesPin(incoming: unknown, pin: unknown): boolean {
|
||||||
|
if (Array.isArray(pin)) {
|
||||||
|
return (
|
||||||
|
Array.isArray(incoming) &&
|
||||||
|
incoming.length === pin.length &&
|
||||||
|
pin.every((pinEntry, index) => nodeDataIncludesPin(incoming[index], pinEntry))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNodeDataRecord(pin)) {
|
||||||
|
if (!isNodeDataRecord(incoming)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(pin).every((key) =>
|
||||||
|
nodeDataIncludesPin(incoming[key], pin[key]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.is(incoming, pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeNodeDataWithPin(incoming: unknown, pin: unknown): unknown {
|
||||||
|
if (Array.isArray(pin)) {
|
||||||
|
return pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNodeDataRecord(pin)) {
|
||||||
|
const base = isNodeDataRecord(incoming) ? incoming : {};
|
||||||
|
const next: Record<string, unknown> = { ...base };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(pin)) {
|
||||||
|
next[key] = mergeNodeDataWithPin(base[key], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLocalNodeDataPins(args: {
|
||||||
|
nodes: RFNode[];
|
||||||
|
pendingLocalNodeDataPins: ReadonlyMap<string, unknown>;
|
||||||
|
}): {
|
||||||
|
nodes: RFNode[];
|
||||||
|
nextPendingLocalNodeDataPins: Map<string, unknown>;
|
||||||
|
} {
|
||||||
|
const nodeIds = new Set(args.nodes.map((node) => node.id));
|
||||||
|
const nextPendingLocalNodeDataPins = new Map(
|
||||||
|
[...args.pendingLocalNodeDataPins].filter(([nodeId]) => nodeIds.has(nodeId)),
|
||||||
|
);
|
||||||
|
const nodes = args.nodes.map((node) => {
|
||||||
|
const pin = nextPendingLocalNodeDataPins.get(node.id);
|
||||||
|
if (pin === undefined) return node;
|
||||||
|
|
||||||
|
if (nodeDataIncludesPin(node.data, pin)) {
|
||||||
|
nextPendingLocalNodeDataPins.delete(node.id);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: mergeNodeDataWithPin(node.data, pin) as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
nextPendingLocalNodeDataPins,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function reconcileCanvasFlowNodes(args: {
|
export function reconcileCanvasFlowNodes(args: {
|
||||||
previousNodes: RFNode[];
|
previousNodes: RFNode[];
|
||||||
incomingNodes: RFNode[];
|
incomingNodes: RFNode[];
|
||||||
@@ -286,11 +363,13 @@ export function reconcileCanvasFlowNodes(args: {
|
|||||||
pendingConnectionCreateIds: ReadonlySet<string>;
|
pendingConnectionCreateIds: ReadonlySet<string>;
|
||||||
preferLocalPositionNodeIds: ReadonlySet<string>;
|
preferLocalPositionNodeIds: ReadonlySet<string>;
|
||||||
pendingLocalPositionPins: ReadonlyMap<string, { x: number; y: number }>;
|
pendingLocalPositionPins: ReadonlyMap<string, { x: number; y: number }>;
|
||||||
|
pendingLocalNodeDataPins?: ReadonlyMap<string, unknown>;
|
||||||
pendingMovePins: ReadonlyMap<string, { x: number; y: number }>;
|
pendingMovePins: ReadonlyMap<string, { x: number; y: number }>;
|
||||||
}): {
|
}): {
|
||||||
nodes: RFNode[];
|
nodes: RFNode[];
|
||||||
inferredRealIdByClientRequest: Map<string, Id<"nodes">>;
|
inferredRealIdByClientRequest: Map<string, Id<"nodes">>;
|
||||||
nextPendingLocalPositionPins: Map<string, { x: number; y: number }>;
|
nextPendingLocalPositionPins: Map<string, { x: number; y: number }>;
|
||||||
|
nextPendingLocalNodeDataPins: Map<string, unknown>;
|
||||||
clearedPreferLocalPositionNodeIds: string[];
|
clearedPreferLocalPositionNodeIds: string[];
|
||||||
} {
|
} {
|
||||||
const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({
|
const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({
|
||||||
@@ -309,8 +388,12 @@ export function reconcileCanvasFlowNodes(args: {
|
|||||||
inferredRealIdByClientRequest,
|
inferredRealIdByClientRequest,
|
||||||
args.preferLocalPositionNodeIds,
|
args.preferLocalPositionNodeIds,
|
||||||
);
|
);
|
||||||
const pinnedNodes = applyLocalPositionPins({
|
const dataPinnedNodes = applyLocalNodeDataPins({
|
||||||
nodes: mergedNodes,
|
nodes: mergedNodes,
|
||||||
|
pendingLocalNodeDataPins: args.pendingLocalNodeDataPins ?? new Map(),
|
||||||
|
});
|
||||||
|
const pinnedNodes = applyLocalPositionPins({
|
||||||
|
nodes: dataPinnedNodes.nodes,
|
||||||
pendingLocalPositionPins: args.pendingLocalPositionPins,
|
pendingLocalPositionPins: args.pendingLocalPositionPins,
|
||||||
});
|
});
|
||||||
const nodes = applyPinnedNodePositionsReadOnly(
|
const nodes = applyPinnedNodePositionsReadOnly(
|
||||||
@@ -335,6 +418,7 @@ export function reconcileCanvasFlowNodes(args: {
|
|||||||
nodes,
|
nodes,
|
||||||
inferredRealIdByClientRequest,
|
inferredRealIdByClientRequest,
|
||||||
nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins,
|
nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins,
|
||||||
|
nextPendingLocalNodeDataPins: dataPinnedNodes.nextPendingLocalNodeDataPins,
|
||||||
clearedPreferLocalPositionNodeIds,
|
clearedPreferLocalPositionNodeIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,121 @@ import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
|||||||
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||||
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||||
|
|
||||||
|
type XYPosition = { x: number; y: number };
|
||||||
|
|
||||||
|
export type ComputeEdgeInsertLayoutArgs = {
|
||||||
|
sourceNode: RFNode;
|
||||||
|
targetNode: RFNode;
|
||||||
|
newNodeWidth: number;
|
||||||
|
newNodeHeight: number;
|
||||||
|
gapPx: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EdgeInsertLayout = {
|
||||||
|
insertPosition: XYPosition;
|
||||||
|
sourcePosition?: XYPosition;
|
||||||
|
targetPosition?: XYPosition;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readNodeDimension(node: RFNode, key: "width" | "height"): number | null {
|
||||||
|
const nodeRecord = node as { width?: unknown; height?: unknown };
|
||||||
|
const direct = nodeRecord[key];
|
||||||
|
if (typeof direct === "number" && Number.isFinite(direct) && direct > 0) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styleValue = node.style?.[key];
|
||||||
|
if (typeof styleValue === "number" && Number.isFinite(styleValue) && styleValue > 0) {
|
||||||
|
return styleValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNodeBox(node: RFNode): {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
hasDimensions: boolean;
|
||||||
|
} {
|
||||||
|
const width = readNodeDimension(node, "width");
|
||||||
|
const height = readNodeDimension(node, "height");
|
||||||
|
return {
|
||||||
|
width: width ?? 0,
|
||||||
|
height: height ?? 0,
|
||||||
|
hasDimensions: width !== null && height !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeEdgeInsertLayout(args: ComputeEdgeInsertLayoutArgs): EdgeInsertLayout {
|
||||||
|
const sourceBox = readNodeBox(args.sourceNode);
|
||||||
|
const targetBox = readNodeBox(args.targetNode);
|
||||||
|
const safeGap = Number.isFinite(args.gapPx) ? Math.max(0, args.gapPx) : 0;
|
||||||
|
const newWidth = Number.isFinite(args.newNodeWidth) ? Math.max(0, args.newNodeWidth) : 0;
|
||||||
|
const newHeight = Number.isFinite(args.newNodeHeight) ? Math.max(0, args.newNodeHeight) : 0;
|
||||||
|
|
||||||
|
const sourceCenter = {
|
||||||
|
x: args.sourceNode.position.x + sourceBox.width / 2,
|
||||||
|
y: args.sourceNode.position.y + sourceBox.height / 2,
|
||||||
|
};
|
||||||
|
const targetCenter = {
|
||||||
|
x: args.targetNode.position.x + targetBox.width / 2,
|
||||||
|
y: args.targetNode.position.y + targetBox.height / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const midpoint = {
|
||||||
|
x: (sourceCenter.x + targetCenter.x) / 2,
|
||||||
|
y: (sourceCenter.y + targetCenter.y) / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const layout: EdgeInsertLayout = {
|
||||||
|
insertPosition: {
|
||||||
|
x: midpoint.x - newWidth / 2,
|
||||||
|
y: midpoint.y - newHeight / 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sourceBox.hasDimensions || !targetBox.hasDimensions) {
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const axisDx = targetCenter.x - sourceCenter.x;
|
||||||
|
const axisDy = targetCenter.y - sourceCenter.y;
|
||||||
|
const axisLength = Math.hypot(axisDx, axisDy);
|
||||||
|
if (axisLength <= Number.EPSILON) {
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ux = axisDx / axisLength;
|
||||||
|
const uy = axisDy / axisLength;
|
||||||
|
|
||||||
|
const extentAlongAxis = (width: number, height: number): number =>
|
||||||
|
Math.abs(ux) * (width / 2) + Math.abs(uy) * (height / 2);
|
||||||
|
|
||||||
|
const sourceExtent = extentAlongAxis(sourceBox.width, sourceBox.height);
|
||||||
|
const targetExtent = extentAlongAxis(targetBox.width, targetBox.height);
|
||||||
|
const newExtent = extentAlongAxis(newWidth, newHeight);
|
||||||
|
const halfAxisLength = axisLength / 2;
|
||||||
|
|
||||||
|
const sourceShift = Math.max(0, sourceExtent + newExtent + safeGap - halfAxisLength);
|
||||||
|
const targetShift = Math.max(0, targetExtent + newExtent + safeGap - halfAxisLength);
|
||||||
|
|
||||||
|
if (sourceShift > 0) {
|
||||||
|
layout.sourcePosition = {
|
||||||
|
x: args.sourceNode.position.x - ux * sourceShift,
|
||||||
|
y: args.sourceNode.position.y - uy * sourceShift,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetShift > 0) {
|
||||||
|
layout.targetPosition = {
|
||||||
|
x: args.targetNode.position.x + ux * targetShift,
|
||||||
|
y: args.targetNode.position.y + uy * targetShift,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
export function createCanvasOpId(): string {
|
export function createCanvasOpId(): string {
|
||||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -78,19 +193,50 @@ export type DroppedConnectionTarget = {
|
|||||||
targetHandle?: string;
|
targetHandle?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getNodeElementAtClientPoint(point: { x: number; y: number }): HTMLElement | null {
|
function describeConnectionDebugElement(element: Element): Record<string, unknown> {
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
return {
|
||||||
|
tagName: element.tagName.toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagName: element.tagName.toLowerCase(),
|
||||||
|
id: element.id || undefined,
|
||||||
|
dataId: element.dataset.id || undefined,
|
||||||
|
className: element.className || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logCanvasConnectionDebug(
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("[Canvas connection debug]", event, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeElementAtClientPoint(
|
||||||
|
point: { x: number; y: number },
|
||||||
|
elementsAtPoint?: Element[],
|
||||||
|
): HTMLElement | null {
|
||||||
if (typeof document === "undefined") {
|
if (typeof document === "undefined") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hit = document.elementsFromPoint(point.x, point.y).find((element) => {
|
const hit = (elementsAtPoint ?? document.elementsFromPoint(point.x, point.y)).find(
|
||||||
if (!(element instanceof HTMLElement)) return false;
|
(element) => {
|
||||||
return (
|
if (!(element instanceof HTMLElement)) return false;
|
||||||
element.classList.contains("react-flow__node") &&
|
return (
|
||||||
typeof element.dataset.id === "string" &&
|
element.classList.contains("react-flow__node") &&
|
||||||
element.dataset.id.length > 0
|
typeof element.dataset.id === "string" &&
|
||||||
);
|
element.dataset.id.length > 0
|
||||||
});
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return hit instanceof HTMLElement ? hit : null;
|
return hit instanceof HTMLElement ? hit : null;
|
||||||
}
|
}
|
||||||
@@ -133,25 +279,52 @@ export function resolveDroppedConnectionTarget(args: {
|
|||||||
nodes: RFNode[];
|
nodes: RFNode[];
|
||||||
edges: RFEdge[];
|
edges: RFEdge[];
|
||||||
}): DroppedConnectionTarget | null {
|
}): DroppedConnectionTarget | null {
|
||||||
const nodeElement = getNodeElementAtClientPoint(args.point);
|
const elementsAtPoint =
|
||||||
|
typeof document === "undefined"
|
||||||
|
? []
|
||||||
|
: document.elementsFromPoint(args.point.x, args.point.y);
|
||||||
|
const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint);
|
||||||
if (!nodeElement) {
|
if (!nodeElement) {
|
||||||
|
logCanvasConnectionDebug("drop-target:node-missed", {
|
||||||
|
point: args.point,
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId ?? null,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement),
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetNodeId = nodeElement.dataset.id;
|
const targetNodeId = nodeElement.dataset.id;
|
||||||
if (!targetNodeId) {
|
if (!targetNodeId) {
|
||||||
|
logCanvasConnectionDebug("drop-target:node-missing-data-id", {
|
||||||
|
point: args.point,
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId ?? null,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
nodeElement: describeConnectionDebugElement(nodeElement),
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetNode = args.nodes.find((node) => node.id === targetNodeId);
|
const targetNode = args.nodes.find((node) => node.id === targetNodeId);
|
||||||
if (!targetNode) {
|
if (!targetNode) {
|
||||||
|
logCanvasConnectionDebug("drop-target:node-not-in-state", {
|
||||||
|
point: args.point,
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId ?? null,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
targetNodeId,
|
||||||
|
nodeCount: args.nodes.length,
|
||||||
|
nodeElement: describeConnectionDebugElement(nodeElement),
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handles = NODE_HANDLE_MAP[targetNode.type ?? ""];
|
const handles = NODE_HANDLE_MAP[targetNode.type ?? ""];
|
||||||
|
|
||||||
if (args.fromHandleType === "source") {
|
if (args.fromHandleType === "source") {
|
||||||
return {
|
const droppedConnection = {
|
||||||
sourceNodeId: args.fromNodeId,
|
sourceNodeId: args.fromNodeId,
|
||||||
targetNodeId,
|
targetNodeId,
|
||||||
sourceHandle: args.fromHandleId,
|
sourceHandle: args.fromHandleId,
|
||||||
@@ -165,14 +338,40 @@ export function resolveDroppedConnectionTarget(args: {
|
|||||||
})
|
})
|
||||||
: handles?.target,
|
: handles?.target,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("drop-target:node-detected", {
|
||||||
|
point: args.point,
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId ?? null,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
targetNodeId,
|
||||||
|
targetNodeType: targetNode.type ?? null,
|
||||||
|
nodeElement: describeConnectionDebugElement(nodeElement),
|
||||||
|
resolvedConnection: droppedConnection,
|
||||||
|
});
|
||||||
|
|
||||||
|
return droppedConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const droppedConnection = {
|
||||||
sourceNodeId: targetNodeId,
|
sourceNodeId: targetNodeId,
|
||||||
targetNodeId: args.fromNodeId,
|
targetNodeId: args.fromNodeId,
|
||||||
sourceHandle: handles?.source,
|
sourceHandle: handles?.source,
|
||||||
targetHandle: args.fromHandleId,
|
targetHandle: args.fromHandleId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("drop-target:node-detected", {
|
||||||
|
point: args.point,
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId ?? null,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
targetNodeId,
|
||||||
|
targetNodeType: targetNode.type ?? null,
|
||||||
|
nodeElement: describeConnectionDebugElement(nodeElement),
|
||||||
|
resolvedConnection: droppedConnection,
|
||||||
|
});
|
||||||
|
|
||||||
|
return droppedConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
|
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ import { useCanvasNodeInteractions } from "./use-canvas-node-interactions";
|
|||||||
import { useCanvasConnections } from "./use-canvas-connections";
|
import { useCanvasConnections } from "./use-canvas-connections";
|
||||||
import { useCanvasDrop } from "./use-canvas-drop";
|
import { useCanvasDrop } from "./use-canvas-drop";
|
||||||
import { useCanvasScissors } from "./canvas-scissors";
|
import { useCanvasScissors } from "./canvas-scissors";
|
||||||
|
import { type DefaultEdgeInsertAnchor } from "./edges/default-edge";
|
||||||
import { CanvasSyncProvider } from "./canvas-sync-context";
|
import { CanvasSyncProvider } from "./canvas-sync-context";
|
||||||
import { useCanvasData } from "./use-canvas-data";
|
import { useCanvasData } from "./use-canvas-data";
|
||||||
|
import { useCanvasEdgeInsertions } from "./use-canvas-edge-insertions";
|
||||||
|
import { useCanvasEdgeTypes } from "./use-canvas-edge-types";
|
||||||
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
|
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
|
||||||
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
|
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
|
||||||
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
||||||
@@ -111,6 +114,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
pendingEdgeSplitByClientRequestRef,
|
pendingEdgeSplitByClientRequestRef,
|
||||||
pendingConnectionCreatesRef,
|
pendingConnectionCreatesRef,
|
||||||
pendingLocalPositionUntilConvexMatchesRef,
|
pendingLocalPositionUntilConvexMatchesRef,
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||||
preferLocalPositionNodeIdsRef,
|
preferLocalPositionNodeIdsRef,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@@ -328,12 +332,55 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
|
runSplitEdgeAtExistingNodeMutation,
|
||||||
runRemoveEdgeMutation,
|
runRemoveEdgeMutation,
|
||||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
edgeInsertMenu,
|
||||||
|
closeEdgeInsertMenu,
|
||||||
|
openEdgeInsertMenu,
|
||||||
|
handleEdgeInsertPick,
|
||||||
|
} = useCanvasEdgeInsertions({
|
||||||
|
canvasId,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||||
|
runBatchMoveNodesMutation,
|
||||||
|
showConnectionRejectedToast,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdgeInsertClick = useCallback(
|
||||||
|
(anchor: DefaultEdgeInsertAnchor) => {
|
||||||
|
closeConnectionDropMenu();
|
||||||
|
openEdgeInsertMenu(anchor);
|
||||||
|
},
|
||||||
|
[closeConnectionDropMenu, openEdgeInsertMenu],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionDropMenu) {
|
||||||
|
closeEdgeInsertMenu();
|
||||||
|
}
|
||||||
|
}, [closeEdgeInsertMenu, connectionDropMenu]);
|
||||||
|
|
||||||
|
const defaultEdgeOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
...DEFAULT_EDGE_OPTIONS,
|
||||||
|
type: "canvas-default" as const,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const edgeTypes = useCanvasEdgeTypes({
|
||||||
|
edgeInsertMenuEdgeId: edgeInsertMenu?.edgeId ?? null,
|
||||||
|
scissorsMode,
|
||||||
|
onInsertClick: handleEdgeInsertClick,
|
||||||
|
});
|
||||||
|
|
||||||
useCanvasFlowReconciliation({
|
useCanvasFlowReconciliation({
|
||||||
convexNodes,
|
convexNodes,
|
||||||
convexEdges,
|
convexEdges,
|
||||||
@@ -351,6 +398,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
resolvedRealIdByClientRequestRef,
|
resolvedRealIdByClientRequestRef,
|
||||||
pendingConnectionCreatesRef,
|
pendingConnectionCreatesRef,
|
||||||
pendingLocalPositionUntilConvexMatchesRef,
|
pendingLocalPositionUntilConvexMatchesRef,
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||||
preferLocalPositionNodeIdsRef,
|
preferLocalPositionNodeIdsRef,
|
||||||
isDragging,
|
isDragging,
|
||||||
isResizing,
|
isResizing,
|
||||||
@@ -411,9 +459,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
canvasId,
|
canvasId,
|
||||||
isSyncOnline,
|
isSyncOnline,
|
||||||
t,
|
t,
|
||||||
|
edges,
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
generateUploadUrl,
|
generateUploadUrl,
|
||||||
runCreateNodeOnlineOnly,
|
runCreateNodeOnlineOnly,
|
||||||
|
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||||
notifyOfflineUnsupported,
|
notifyOfflineUnsupported,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
});
|
});
|
||||||
@@ -473,10 +523,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
<CanvasAppMenu canvasId={canvasId} />
|
<CanvasAppMenu canvasId={canvasId} />
|
||||||
<CanvasCommandPalette />
|
<CanvasCommandPalette />
|
||||||
<CanvasConnectionDropMenu
|
<CanvasConnectionDropMenu
|
||||||
state={connectionDropMenu}
|
anchor={
|
||||||
|
connectionDropMenu
|
||||||
|
? {
|
||||||
|
screenX: connectionDropMenu.screenX,
|
||||||
|
screenY: connectionDropMenu.screenY,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
onClose={closeConnectionDropMenu}
|
onClose={closeConnectionDropMenu}
|
||||||
onPick={handleConnectionDropPick}
|
onPick={handleConnectionDropPick}
|
||||||
/>
|
/>
|
||||||
|
<CanvasConnectionDropMenu
|
||||||
|
anchor={
|
||||||
|
edgeInsertMenu
|
||||||
|
? {
|
||||||
|
screenX: edgeInsertMenu.screenX,
|
||||||
|
screenY: edgeInsertMenu.screenY,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onClose={closeEdgeInsertMenu}
|
||||||
|
onPick={handleEdgeInsertPick}
|
||||||
|
/>
|
||||||
{scissorsMode ? (
|
{scissorsMode ? (
|
||||||
<div className="pointer-events-none absolute top-14 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10">
|
<div className="pointer-events-none absolute top-14 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10">
|
||||||
Scherenmodus — Kante anklicken oder ziehen zum Durchtrennen ·{" "}
|
Scherenmodus — Kante anklicken oder ziehen zum Durchtrennen ·{" "}
|
||||||
@@ -512,9 +581,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onlyRenderVisibleElements
|
onlyRenderVisibleElements
|
||||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
defaultEdgeOptions={defaultEdgeOptions}
|
||||||
connectionLineComponent={CustomConnectionLine}
|
connectionLineComponent={CustomConnectionLine}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
|||||||
@@ -1,3 +1,116 @@
|
|||||||
export default function DefaultEdge() {
|
"use client";
|
||||||
return null;
|
|
||||||
|
import { useMemo, useState, type MouseEvent } from "react";
|
||||||
|
import {
|
||||||
|
BaseEdge,
|
||||||
|
EdgeLabelRenderer,
|
||||||
|
getBezierPath,
|
||||||
|
type EdgeProps,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
export type DefaultEdgeInsertAnchor = {
|
||||||
|
edgeId: string;
|
||||||
|
screenX: number;
|
||||||
|
screenY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DefaultEdgeProps = EdgeProps & {
|
||||||
|
edgeId?: string;
|
||||||
|
isMenuOpen?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onInsertClick?: (anchor: DefaultEdgeInsertAnchor) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DefaultEdge({
|
||||||
|
id,
|
||||||
|
edgeId,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
markerStart,
|
||||||
|
markerEnd,
|
||||||
|
style,
|
||||||
|
interactionWidth,
|
||||||
|
isMenuOpen = false,
|
||||||
|
disabled = false,
|
||||||
|
onInsertClick,
|
||||||
|
}: DefaultEdgeProps) {
|
||||||
|
const [isEdgeHovered, setIsEdgeHovered] = useState(false);
|
||||||
|
const [isButtonHovered, setIsButtonHovered] = useState(false);
|
||||||
|
|
||||||
|
const [edgePath, labelX, labelY] = useMemo(
|
||||||
|
() =>
|
||||||
|
getBezierPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
}),
|
||||||
|
[sourcePosition, sourceX, sourceY, targetPosition, targetX, targetY],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedEdgeId = edgeId ?? id;
|
||||||
|
const canInsert = Boolean(onInsertClick) && !disabled;
|
||||||
|
const isInsertVisible = canInsert && (isMenuOpen || isEdgeHovered || isButtonHovered);
|
||||||
|
|
||||||
|
const handleInsertClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (!onInsertClick || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
onInsertClick({
|
||||||
|
edgeId: resolvedEdgeId,
|
||||||
|
screenX: rect.left + rect.width / 2,
|
||||||
|
screenY: rect.top + rect.height / 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<g
|
||||||
|
data-testid="default-edge"
|
||||||
|
onMouseEnter={() => setIsEdgeHovered(true)}
|
||||||
|
onMouseLeave={() => setIsEdgeHovered(false)}
|
||||||
|
>
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
path={edgePath}
|
||||||
|
style={style}
|
||||||
|
markerStart={markerStart}
|
||||||
|
markerEnd={markerEnd}
|
||||||
|
interactionWidth={interactionWidth}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="default-edge-insert-button"
|
||||||
|
data-visible={isInsertVisible ? "true" : "false"}
|
||||||
|
aria-label="Insert node"
|
||||||
|
aria-hidden={!isInsertVisible}
|
||||||
|
disabled={!canInsert}
|
||||||
|
className="nodrag nopan absolute h-7 w-7 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-opacity"
|
||||||
|
style={{
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
opacity: isInsertVisible ? 1 : 0,
|
||||||
|
pointerEvents: isInsertVisible ? "all" : "none",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setIsButtonHovered(true)}
|
||||||
|
onMouseLeave={() => setIsButtonHovered(false)}
|
||||||
|
onClick={handleInsertClick}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ export function useNodeLocalData<T>({
|
|||||||
useCanvasGraphPreviewOverrides();
|
useCanvasGraphPreviewOverrides();
|
||||||
const [localData, setLocalDataState] = useState<T>(() => normalize(data));
|
const [localData, setLocalDataState] = useState<T>(() => normalize(data));
|
||||||
const localDataRef = useRef(localData);
|
const localDataRef = useRef(localData);
|
||||||
const persistedDataRef = useRef(localData);
|
const acceptedPersistedDataRef = useRef(localData);
|
||||||
const hasPendingLocalChangesRef = useRef(false);
|
const hasPendingLocalChangesRef = useRef(false);
|
||||||
const localChangeVersionRef = useRef(0);
|
const localChangeVersionRef = useRef(0);
|
||||||
|
const acknowledgedSaveVersionRef = useRef(0);
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,7 +61,7 @@ export function useNodeLocalData<T>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPendingLocalChangesRef.current = false;
|
acknowledgedSaveVersionRef.current = savedVersion;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
|
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
|
||||||
@@ -68,34 +69,49 @@ export function useNodeLocalData<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasPendingLocalChangesRef.current = false;
|
hasPendingLocalChangesRef.current = false;
|
||||||
localDataRef.current = persistedDataRef.current;
|
acknowledgedSaveVersionRef.current = 0;
|
||||||
setLocalDataState(persistedDataRef.current);
|
localDataRef.current = acceptedPersistedDataRef.current;
|
||||||
|
setLocalDataState(acceptedPersistedDataRef.current);
|
||||||
clearPreviewNodeDataOverride(nodeId);
|
clearPreviewNodeDataOverride(nodeId);
|
||||||
});
|
});
|
||||||
}, saveDelayMs);
|
}, saveDelayMs);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const incomingData = normalize(data);
|
const incomingData = normalize(data);
|
||||||
persistedDataRef.current = incomingData;
|
|
||||||
const incomingHash = hashNodeData(incomingData);
|
const incomingHash = hashNodeData(incomingData);
|
||||||
const localHash = hashNodeData(localDataRef.current);
|
const localHash = hashNodeData(localDataRef.current);
|
||||||
|
const acceptedPersistedHash = hashNodeData(acceptedPersistedDataRef.current);
|
||||||
|
|
||||||
if (incomingHash === localHash) {
|
if (incomingHash === localHash) {
|
||||||
|
acceptedPersistedDataRef.current = incomingData;
|
||||||
hasPendingLocalChangesRef.current = false;
|
hasPendingLocalChangesRef.current = false;
|
||||||
|
acknowledgedSaveVersionRef.current = 0;
|
||||||
clearPreviewNodeDataOverride(nodeId);
|
clearPreviewNodeDataOverride(nodeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPendingLocalChangesRef.current) {
|
if (hasPendingLocalChangesRef.current) {
|
||||||
logNodeDataDebug("skip-stale-external-data", {
|
const saveAcknowledgedForCurrentVersion =
|
||||||
nodeType: debugLabel,
|
acknowledgedSaveVersionRef.current === localChangeVersionRef.current;
|
||||||
incomingHash,
|
const shouldKeepBlockingIncomingData =
|
||||||
localHash,
|
!saveAcknowledgedForCurrentVersion || incomingHash === acceptedPersistedHash;
|
||||||
});
|
|
||||||
return;
|
if (shouldKeepBlockingIncomingData) {
|
||||||
|
logNodeDataDebug("skip-stale-external-data", {
|
||||||
|
nodeId,
|
||||||
|
nodeType: debugLabel,
|
||||||
|
incomingHash,
|
||||||
|
localHash,
|
||||||
|
saveAcknowledgedForCurrentVersion,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
|
acceptedPersistedDataRef.current = incomingData;
|
||||||
|
hasPendingLocalChangesRef.current = false;
|
||||||
|
acknowledgedSaveVersionRef.current = 0;
|
||||||
localDataRef.current = incomingData;
|
localDataRef.current = incomingData;
|
||||||
setLocalDataState(incomingData);
|
setLocalDataState(incomingData);
|
||||||
clearPreviewNodeDataOverride(nodeId);
|
clearPreviewNodeDataOverride(nodeId);
|
||||||
@@ -123,7 +139,7 @@ export function useNodeLocalData<T>({
|
|||||||
setPreviewNodeDataOverride(nodeId, next);
|
setPreviewNodeDataOverride(nodeId, next);
|
||||||
queueSave();
|
queueSave();
|
||||||
},
|
},
|
||||||
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
|
[nodeId, queueSave, setPreviewNodeDataOverride],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateLocalData = useCallback(
|
const updateLocalData = useCallback(
|
||||||
@@ -137,7 +153,7 @@ export function useNodeLocalData<T>({
|
|||||||
setPreviewNodeDataOverride(nodeId, next);
|
setPreviewNodeDataOverride(nodeId, next);
|
||||||
queueSave();
|
queueSave();
|
||||||
},
|
},
|
||||||
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
|
[nodeId, queueSave, setPreviewNodeDataOverride],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,11 +10,19 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
|
|||||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||||
|
|
||||||
import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers";
|
import {
|
||||||
import { resolveDroppedConnectionTarget } from "./canvas-helpers";
|
getConnectEndClientPoint,
|
||||||
|
hasHandleKey,
|
||||||
|
isOptimisticEdgeId,
|
||||||
|
isOptimisticNodeId,
|
||||||
|
logCanvasConnectionDebug,
|
||||||
|
normalizeHandle,
|
||||||
|
resolveDroppedConnectionTarget,
|
||||||
|
} from "./canvas-helpers";
|
||||||
import {
|
import {
|
||||||
validateCanvasConnection,
|
validateCanvasConnection,
|
||||||
validateCanvasConnectionByType,
|
validateCanvasConnectionByType,
|
||||||
|
validateCanvasEdgeSplit,
|
||||||
} from "./canvas-connection-validation";
|
} from "./canvas-connection-validation";
|
||||||
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
|
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
|
||||||
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
|
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
|
||||||
@@ -43,6 +51,15 @@ type UseCanvasConnectionsParams = {
|
|||||||
sourceHandle?: string;
|
sourceHandle?: string;
|
||||||
targetHandle?: string;
|
targetHandle?: string;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
|
runSplitEdgeAtExistingNodeMutation: (args: {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
splitEdgeId: Id<"edges">;
|
||||||
|
middleNodeId: Id<"nodes">;
|
||||||
|
splitSourceHandle?: string;
|
||||||
|
splitTargetHandle?: string;
|
||||||
|
newNodeSourceHandle?: string;
|
||||||
|
newNodeTargetHandle?: string;
|
||||||
|
}) => Promise<unknown>;
|
||||||
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||||
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
|
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
@@ -92,6 +109,7 @@ export function useCanvasConnections({
|
|||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
|
runSplitEdgeAtExistingNodeMutation,
|
||||||
runRemoveEdgeMutation,
|
runRemoveEdgeMutation,
|
||||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||||
@@ -107,8 +125,13 @@ export function useCanvasConnections({
|
|||||||
connectionDropMenuRef.current = connectionDropMenu;
|
connectionDropMenuRef.current = connectionDropMenu;
|
||||||
}, [connectionDropMenu]);
|
}, [connectionDropMenu]);
|
||||||
|
|
||||||
const onConnectStart = useCallback<OnConnectStart>(() => {
|
const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
|
||||||
isConnectDragActiveRef.current = true;
|
isConnectDragActiveRef.current = true;
|
||||||
|
logCanvasConnectionDebug("connect:start", {
|
||||||
|
nodeId: params.nodeId,
|
||||||
|
handleId: params.handleId,
|
||||||
|
handleType: params.handleType,
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
@@ -116,11 +139,33 @@ export function useCanvasConnections({
|
|||||||
isConnectDragActiveRef.current = false;
|
isConnectDragActiveRef.current = false;
|
||||||
const validationError = validateCanvasConnection(connection, nodes, edges);
|
const validationError = validateCanvasConnection(connection, nodes, edges);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
|
logCanvasConnectionDebug("connect:invalid-direct", {
|
||||||
|
sourceNodeId: connection.source ?? null,
|
||||||
|
targetNodeId: connection.target ?? null,
|
||||||
|
sourceHandle: connection.sourceHandle ?? null,
|
||||||
|
targetHandle: connection.targetHandle ?? null,
|
||||||
|
validationError,
|
||||||
|
});
|
||||||
showConnectionRejectedToast(validationError);
|
showConnectionRejectedToast(validationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!connection.source || !connection.target) return;
|
if (!connection.source || !connection.target) {
|
||||||
|
logCanvasConnectionDebug("connect:missing-endpoint", {
|
||||||
|
sourceNodeId: connection.source ?? null,
|
||||||
|
targetNodeId: connection.target ?? null,
|
||||||
|
sourceHandle: connection.sourceHandle ?? null,
|
||||||
|
targetHandle: connection.targetHandle ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("connect:direct", {
|
||||||
|
sourceNodeId: connection.source,
|
||||||
|
targetNodeId: connection.target,
|
||||||
|
sourceHandle: connection.sourceHandle ?? null,
|
||||||
|
targetHandle: connection.targetHandle ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
void runCreateEdgeMutation({
|
void runCreateEdgeMutation({
|
||||||
canvasId,
|
canvasId,
|
||||||
@@ -136,18 +181,71 @@ export function useCanvasConnections({
|
|||||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||||
(event, connectionState) => {
|
(event, connectionState) => {
|
||||||
if (!isConnectDragActiveRef.current) {
|
if (!isConnectDragActiveRef.current) {
|
||||||
|
logCanvasConnectionDebug("connect:end-ignored", {
|
||||||
|
reason: "drag-not-active",
|
||||||
|
isValid: connectionState.isValid ?? null,
|
||||||
|
fromNodeId: connectionState.fromNode?.id ?? null,
|
||||||
|
fromHandleId: connectionState.fromHandle?.id ?? null,
|
||||||
|
toNodeId: connectionState.toNode?.id ?? null,
|
||||||
|
toHandleId: connectionState.toHandle?.id ?? null,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isConnectDragActiveRef.current = false;
|
isConnectDragActiveRef.current = false;
|
||||||
if (isReconnectDragActiveRef.current) return;
|
if (isReconnectDragActiveRef.current) {
|
||||||
if (connectionState.isValid === true) return;
|
logCanvasConnectionDebug("connect:end-ignored", {
|
||||||
|
reason: "reconnect-active",
|
||||||
|
isValid: connectionState.isValid ?? null,
|
||||||
|
fromNodeId: connectionState.fromNode?.id ?? null,
|
||||||
|
fromHandleId: connectionState.fromHandle?.id ?? null,
|
||||||
|
toNodeId: connectionState.toNode?.id ?? null,
|
||||||
|
toHandleId: connectionState.toHandle?.id ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (connectionState.isValid === true) {
|
||||||
|
logCanvasConnectionDebug("connect:end-ignored", {
|
||||||
|
reason: "react-flow-valid-connection",
|
||||||
|
fromNodeId: connectionState.fromNode?.id ?? null,
|
||||||
|
fromHandleId: connectionState.fromHandle?.id ?? null,
|
||||||
|
toNodeId: connectionState.toNode?.id ?? null,
|
||||||
|
toHandleId: connectionState.toHandle?.id ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fromNode = connectionState.fromNode;
|
const fromNode = connectionState.fromNode;
|
||||||
const fromHandle = connectionState.fromHandle;
|
const fromHandle = connectionState.fromHandle;
|
||||||
if (!fromNode || !fromHandle) return;
|
if (!fromNode || !fromHandle) {
|
||||||
|
logCanvasConnectionDebug("connect:end-aborted", {
|
||||||
|
reason: "missing-from-node-or-handle",
|
||||||
|
fromNodeId: fromNode?.id ?? null,
|
||||||
|
fromHandleId: fromHandle?.id ?? null,
|
||||||
|
toNodeId: connectionState.toNode?.id ?? null,
|
||||||
|
toHandleId: connectionState.toHandle?.id ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pt = getConnectEndClientPoint(event);
|
const pt = getConnectEndClientPoint(event);
|
||||||
if (!pt) return;
|
if (!pt) {
|
||||||
|
logCanvasConnectionDebug("connect:end-aborted", {
|
||||||
|
reason: "missing-client-point",
|
||||||
|
fromNodeId: fromNode.id,
|
||||||
|
fromHandleId: fromHandle.id ?? null,
|
||||||
|
fromHandleType: fromHandle.type,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("connect:end", {
|
||||||
|
point: pt,
|
||||||
|
fromNodeId: fromNode.id,
|
||||||
|
fromHandleId: fromHandle.id ?? null,
|
||||||
|
fromHandleType: fromHandle.type,
|
||||||
|
toNodeId: connectionState.toNode?.id ?? null,
|
||||||
|
toHandleId: connectionState.toHandle?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
||||||
const droppedConnection = resolveDroppedConnectionTarget({
|
const droppedConnection = resolveDroppedConnectionTarget({
|
||||||
@@ -159,6 +257,15 @@ export function useCanvasConnections({
|
|||||||
edges: edgesRef.current,
|
edges: edgesRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("connect:end-drop-result", {
|
||||||
|
point: pt,
|
||||||
|
flow,
|
||||||
|
fromNodeId: fromNode.id,
|
||||||
|
fromHandleId: fromHandle.id ?? null,
|
||||||
|
fromHandleType: fromHandle.type,
|
||||||
|
droppedConnection,
|
||||||
|
});
|
||||||
|
|
||||||
if (droppedConnection) {
|
if (droppedConnection) {
|
||||||
const validationError = validateCanvasConnection(
|
const validationError = validateCanvasConnection(
|
||||||
{
|
{
|
||||||
@@ -171,10 +278,75 @@ export function useCanvasConnections({
|
|||||||
edgesRef.current,
|
edgesRef.current,
|
||||||
);
|
);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
|
const fullFromNode = nodesRef.current.find((node) => node.id === fromNode.id);
|
||||||
|
const splitHandles = NODE_HANDLE_MAP[fullFromNode?.type ?? ""];
|
||||||
|
const incomingEdges = edgesRef.current.filter(
|
||||||
|
(edge) =>
|
||||||
|
edge.target === droppedConnection.targetNodeId &&
|
||||||
|
edge.className !== "temp" &&
|
||||||
|
!isOptimisticEdgeId(edge.id),
|
||||||
|
);
|
||||||
|
const incomingEdge = incomingEdges.length === 1 ? incomingEdges[0] : undefined;
|
||||||
|
const splitValidationError =
|
||||||
|
validationError === "adjustment-incoming-limit" &&
|
||||||
|
droppedConnection.sourceNodeId === fromNode.id &&
|
||||||
|
fromHandle.type === "source" &&
|
||||||
|
fullFromNode !== undefined &&
|
||||||
|
splitHandles !== undefined &&
|
||||||
|
hasHandleKey(splitHandles, "source") &&
|
||||||
|
hasHandleKey(splitHandles, "target") &&
|
||||||
|
incomingEdge !== undefined &&
|
||||||
|
incomingEdge.source !== fullFromNode.id &&
|
||||||
|
incomingEdge.target !== fullFromNode.id
|
||||||
|
? validateCanvasEdgeSplit({
|
||||||
|
nodes: nodesRef.current,
|
||||||
|
edges: edgesRef.current,
|
||||||
|
splitEdge: incomingEdge,
|
||||||
|
middleNode: fullFromNode,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!splitValidationError && incomingEdge && fullFromNode && splitHandles) {
|
||||||
|
logCanvasConnectionDebug("connect:end-auto-split", {
|
||||||
|
point: pt,
|
||||||
|
flow,
|
||||||
|
droppedConnection,
|
||||||
|
splitEdgeId: incomingEdge.id,
|
||||||
|
middleNodeId: fullFromNode.id,
|
||||||
|
});
|
||||||
|
void runSplitEdgeAtExistingNodeMutation({
|
||||||
|
canvasId,
|
||||||
|
splitEdgeId: incomingEdge.id as Id<"edges">,
|
||||||
|
middleNodeId: fullFromNode.id as Id<"nodes">,
|
||||||
|
splitSourceHandle: normalizeHandle(incomingEdge.sourceHandle),
|
||||||
|
splitTargetHandle: normalizeHandle(incomingEdge.targetHandle),
|
||||||
|
newNodeSourceHandle: normalizeHandle(splitHandles.source),
|
||||||
|
newNodeTargetHandle: normalizeHandle(splitHandles.target),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("connect:end-drop-rejected", {
|
||||||
|
point: pt,
|
||||||
|
flow,
|
||||||
|
droppedConnection,
|
||||||
|
validationError,
|
||||||
|
attemptedAutoSplit:
|
||||||
|
validationError === "adjustment-incoming-limit" &&
|
||||||
|
droppedConnection.sourceNodeId === fromNode.id &&
|
||||||
|
fromHandle.type === "source",
|
||||||
|
splitValidationError,
|
||||||
|
});
|
||||||
showConnectionRejectedToast(validationError);
|
showConnectionRejectedToast(validationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("connect:end-create-edge", {
|
||||||
|
point: pt,
|
||||||
|
flow,
|
||||||
|
droppedConnection,
|
||||||
|
});
|
||||||
|
|
||||||
void runCreateEdgeMutation({
|
void runCreateEdgeMutation({
|
||||||
canvasId,
|
canvasId,
|
||||||
sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">,
|
sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">,
|
||||||
@@ -185,6 +357,14 @@ export function useCanvasConnections({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("connect:end-open-menu", {
|
||||||
|
point: pt,
|
||||||
|
flow,
|
||||||
|
fromNodeId: fromNode.id,
|
||||||
|
fromHandleId: fromHandle.id ?? null,
|
||||||
|
fromHandleType: fromHandle.type,
|
||||||
|
});
|
||||||
|
|
||||||
setConnectionDropMenu({
|
setConnectionDropMenu({
|
||||||
screenX: pt.x,
|
screenX: pt.x,
|
||||||
screenY: pt.y,
|
screenY: pt.y,
|
||||||
@@ -201,6 +381,7 @@ export function useCanvasConnections({
|
|||||||
isReconnectDragActiveRef,
|
isReconnectDragActiveRef,
|
||||||
nodesRef,
|
nodesRef,
|
||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
|
runSplitEdgeAtExistingNodeMutation,
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,19 +4,34 @@ import type { Id } from "@/convex/_generated/dataModel";
|
|||||||
import {
|
import {
|
||||||
CANVAS_NODE_DND_MIME,
|
CANVAS_NODE_DND_MIME,
|
||||||
} from "@/lib/canvas-connection-policy";
|
} from "@/lib/canvas-connection-policy";
|
||||||
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
|
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||||
import {
|
import {
|
||||||
isCanvasNodeType,
|
isCanvasNodeType,
|
||||||
type CanvasNodeType,
|
type CanvasNodeType,
|
||||||
} from "@/lib/canvas-node-types";
|
} from "@/lib/canvas-node-types";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getIntersectedEdgeId,
|
||||||
|
hasHandleKey,
|
||||||
|
isOptimisticEdgeId,
|
||||||
|
logCanvasConnectionDebug,
|
||||||
|
normalizeHandle,
|
||||||
|
} from "./canvas-helpers";
|
||||||
import { getImageDimensions } from "./canvas-media-utils";
|
import { getImageDimensions } from "./canvas-media-utils";
|
||||||
|
|
||||||
type UseCanvasDropParams = {
|
type UseCanvasDropParams = {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
isSyncOnline: boolean;
|
isSyncOnline: boolean;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
|
edges: Array<{
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
className?: string;
|
||||||
|
sourceHandle?: string | null;
|
||||||
|
targetHandle?: string | null;
|
||||||
|
}>;
|
||||||
screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
|
screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
|
||||||
generateUploadUrl: () => Promise<string>;
|
generateUploadUrl: () => Promise<string>;
|
||||||
runCreateNodeOnlineOnly: (args: {
|
runCreateNodeOnlineOnly: (args: {
|
||||||
@@ -29,6 +44,21 @@ type UseCanvasDropParams = {
|
|||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
clientRequestId?: string;
|
clientRequestId?: string;
|
||||||
}) => Promise<Id<"nodes">>;
|
}) => Promise<Id<"nodes">>;
|
||||||
|
runCreateNodeWithEdgeSplitOnlineOnly: (args: {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
type: CanvasNodeType;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
splitEdgeId: Id<"edges">;
|
||||||
|
newNodeTargetHandle?: string;
|
||||||
|
newNodeSourceHandle?: string;
|
||||||
|
splitSourceHandle?: string;
|
||||||
|
splitTargetHandle?: string;
|
||||||
|
clientRequestId?: string;
|
||||||
|
}) => Promise<Id<"nodes">>;
|
||||||
notifyOfflineUnsupported: (featureLabel: string) => void;
|
notifyOfflineUnsupported: (featureLabel: string) => void;
|
||||||
syncPendingMoveForClientRequest: (
|
syncPendingMoveForClientRequest: (
|
||||||
clientRequestId: string,
|
clientRequestId: string,
|
||||||
@@ -66,9 +96,11 @@ export function useCanvasDrop({
|
|||||||
canvasId,
|
canvasId,
|
||||||
isSyncOnline,
|
isSyncOnline,
|
||||||
t,
|
t,
|
||||||
|
edges,
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
generateUploadUrl,
|
generateUploadUrl,
|
||||||
runCreateNodeOnlineOnly,
|
runCreateNodeOnlineOnly,
|
||||||
|
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||||
notifyOfflineUnsupported,
|
notifyOfflineUnsupported,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
}: UseCanvasDropParams) {
|
}: UseCanvasDropParams) {
|
||||||
@@ -169,23 +201,92 @@ export function useCanvasDrop({
|
|||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
});
|
});
|
||||||
|
const intersectedEdgeId =
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
typeof document.elementsFromPoint === "function"
|
||||||
|
? getIntersectedEdgeId({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
const defaults = NODE_DEFAULTS[parsedPayload.nodeType] ?? {
|
const defaults = NODE_DEFAULTS[parsedPayload.nodeType] ?? {
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 100,
|
height: 100,
|
||||||
data: {},
|
data: {},
|
||||||
};
|
};
|
||||||
const clientRequestId = crypto.randomUUID();
|
const clientRequestId = crypto.randomUUID();
|
||||||
|
const hitEdge = intersectedEdgeId
|
||||||
|
? edges.find(
|
||||||
|
(edge) =>
|
||||||
|
edge.id === intersectedEdgeId &&
|
||||||
|
edge.className !== "temp" &&
|
||||||
|
!isOptimisticEdgeId(edge.id),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const handles = NODE_HANDLE_MAP[parsedPayload.nodeType];
|
||||||
|
const canSplitEdge =
|
||||||
|
hitEdge !== undefined &&
|
||||||
|
handles !== undefined &&
|
||||||
|
hasHandleKey(handles, "source") &&
|
||||||
|
hasHandleKey(handles, "target");
|
||||||
|
|
||||||
void runCreateNodeOnlineOnly({
|
logCanvasConnectionDebug("node-drop", {
|
||||||
canvasId,
|
nodeType: parsedPayload.nodeType,
|
||||||
type: parsedPayload.nodeType,
|
clientPoint: { x: event.clientX, y: event.clientY },
|
||||||
positionX: position.x,
|
flowPoint: position,
|
||||||
positionY: position.y,
|
intersectedEdgeId,
|
||||||
width: defaults.width,
|
hitEdgeId: hitEdge?.id ?? null,
|
||||||
height: defaults.height,
|
usesEdgeSplitPath: canSplitEdge,
|
||||||
data: { ...defaults.data, ...parsedPayload.payloadData, canvasId },
|
});
|
||||||
clientRequestId,
|
|
||||||
}).then((realId) => {
|
const createNodePromise = canSplitEdge
|
||||||
|
? (() => {
|
||||||
|
logCanvasConnectionDebug("node-drop:split-edge", {
|
||||||
|
nodeType: parsedPayload.nodeType,
|
||||||
|
clientPoint: { x: event.clientX, y: event.clientY },
|
||||||
|
flowPoint: position,
|
||||||
|
intersectedEdgeId,
|
||||||
|
splitEdgeId: hitEdge.id,
|
||||||
|
});
|
||||||
|
return runCreateNodeWithEdgeSplitOnlineOnly({
|
||||||
|
canvasId,
|
||||||
|
type: parsedPayload.nodeType,
|
||||||
|
positionX: position.x,
|
||||||
|
positionY: position.y,
|
||||||
|
width: defaults.width,
|
||||||
|
height: defaults.height,
|
||||||
|
data: { ...defaults.data, ...parsedPayload.payloadData, canvasId },
|
||||||
|
splitEdgeId: hitEdge.id as Id<"edges">,
|
||||||
|
newNodeTargetHandle: normalizeHandle(handles.target),
|
||||||
|
newNodeSourceHandle: normalizeHandle(handles.source),
|
||||||
|
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
|
||||||
|
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
|
||||||
|
clientRequestId,
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
: (() => {
|
||||||
|
if (intersectedEdgeId) {
|
||||||
|
logCanvasConnectionDebug("node-drop:edge-detected-no-split", {
|
||||||
|
nodeType: parsedPayload.nodeType,
|
||||||
|
clientPoint: { x: event.clientX, y: event.clientY },
|
||||||
|
flowPoint: position,
|
||||||
|
intersectedEdgeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCreateNodeOnlineOnly({
|
||||||
|
canvasId,
|
||||||
|
type: parsedPayload.nodeType,
|
||||||
|
positionX: position.x,
|
||||||
|
positionY: position.y,
|
||||||
|
width: defaults.width,
|
||||||
|
height: defaults.height,
|
||||||
|
data: { ...defaults.data, ...parsedPayload.payloadData, canvasId },
|
||||||
|
clientRequestId,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
void createNodePromise.then((realId) => {
|
||||||
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
|
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
console.error("[Canvas] createNode syncPendingMove failed", error);
|
console.error("[Canvas] createNode syncPendingMove failed", error);
|
||||||
@@ -195,9 +296,11 @@ export function useCanvasDrop({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
canvasId,
|
canvasId,
|
||||||
|
edges,
|
||||||
generateUploadUrl,
|
generateUploadUrl,
|
||||||
isSyncOnline,
|
isSyncOnline,
|
||||||
notifyOfflineUnsupported,
|
notifyOfflineUnsupported,
|
||||||
|
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||||
runCreateNodeOnlineOnly,
|
runCreateNodeOnlineOnly,
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
|
|||||||
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<
|
pendingLocalPositionUntilConvexMatchesRef: MutableRefObject<
|
||||||
Map<string, PositionPin>
|
Map<string, PositionPin>
|
||||||
>;
|
>;
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef: MutableRefObject<Map<string, unknown>>;
|
||||||
preferLocalPositionNodeIdsRef: MutableRefObject<Set<string>>;
|
preferLocalPositionNodeIdsRef: MutableRefObject<Set<string>>;
|
||||||
isDragging: MutableRefObject<boolean>;
|
isDragging: MutableRefObject<boolean>;
|
||||||
isResizing: MutableRefObject<boolean>;
|
isResizing: MutableRefObject<boolean>;
|
||||||
@@ -54,6 +55,7 @@ export function useCanvasFlowReconciliation(args: {
|
|||||||
resolvedRealIdByClientRequestRef,
|
resolvedRealIdByClientRequestRef,
|
||||||
pendingConnectionCreatesRef,
|
pendingConnectionCreatesRef,
|
||||||
pendingLocalPositionUntilConvexMatchesRef,
|
pendingLocalPositionUntilConvexMatchesRef,
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||||
preferLocalPositionNodeIdsRef,
|
preferLocalPositionNodeIdsRef,
|
||||||
isDragging,
|
isDragging,
|
||||||
isResizing,
|
isResizing,
|
||||||
@@ -131,6 +133,8 @@ export function useCanvasFlowReconciliation(args: {
|
|||||||
pendingConnectionCreateIds: pendingConnectionCreatesRef.current,
|
pendingConnectionCreateIds: pendingConnectionCreatesRef.current,
|
||||||
preferLocalPositionNodeIds: preferLocalPositionNodeIdsRef.current,
|
preferLocalPositionNodeIds: preferLocalPositionNodeIdsRef.current,
|
||||||
pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current,
|
pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current,
|
||||||
|
pendingLocalNodeDataPins:
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef.current,
|
||||||
pendingMovePins,
|
pendingMovePins,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,6 +142,8 @@ export function useCanvasFlowReconciliation(args: {
|
|||||||
reconciliation.inferredRealIdByClientRequest;
|
reconciliation.inferredRealIdByClientRequest;
|
||||||
pendingLocalPositionUntilConvexMatchesRef.current =
|
pendingLocalPositionUntilConvexMatchesRef.current =
|
||||||
reconciliation.nextPendingLocalPositionPins;
|
reconciliation.nextPendingLocalPositionPins;
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef.current =
|
||||||
|
reconciliation.nextPendingLocalNodeDataPins;
|
||||||
for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) {
|
for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) {
|
||||||
preferLocalPositionNodeIdsRef.current.delete(nodeId);
|
preferLocalPositionNodeIdsRef.current.delete(nodeId);
|
||||||
}
|
}
|
||||||
@@ -155,6 +161,7 @@ export function useCanvasFlowReconciliation(args: {
|
|||||||
isResizing,
|
isResizing,
|
||||||
pendingConnectionCreatesRef,
|
pendingConnectionCreatesRef,
|
||||||
pendingLocalPositionUntilConvexMatchesRef,
|
pendingLocalPositionUntilConvexMatchesRef,
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||||
preferLocalPositionNodeIdsRef,
|
preferLocalPositionNodeIdsRef,
|
||||||
resolvedRealIdByClientRequestRef,
|
resolvedRealIdByClientRequestRef,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -205,6 +205,9 @@ export function createCanvasSyncEngineController({
|
|||||||
const pendingLocalPositionUntilConvexMatchesRef = {
|
const pendingLocalPositionUntilConvexMatchesRef = {
|
||||||
current: new Map<string, { x: number; y: number }>(),
|
current: new Map<string, { x: number; y: number }>(),
|
||||||
};
|
};
|
||||||
|
const pendingLocalNodeDataUntilConvexMatchesRef = {
|
||||||
|
current: new Map<string, unknown>(),
|
||||||
|
};
|
||||||
const preferLocalPositionNodeIdsRef = { current: new Set<string>() };
|
const preferLocalPositionNodeIdsRef = { current: new Set<string>() };
|
||||||
|
|
||||||
const flushPendingResizeForClientRequest = async (
|
const flushPendingResizeForClientRequest = async (
|
||||||
@@ -221,6 +224,21 @@ export function createCanvasSyncEngineController({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pinNodeDataLocally = (nodeId: string, data: unknown): void => {
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef.current.set(nodeId, data);
|
||||||
|
const setNodes = getSetNodes?.();
|
||||||
|
setNodes?.((current) =>
|
||||||
|
current.map((node) =>
|
||||||
|
node.id === nodeId
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
data: data as Record<string, unknown>,
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const flushPendingDataForClientRequest = async (
|
const flushPendingDataForClientRequest = async (
|
||||||
clientRequestId: string,
|
clientRequestId: string,
|
||||||
realId: Id<"nodes">,
|
realId: Id<"nodes">,
|
||||||
@@ -228,6 +246,7 @@ export function createCanvasSyncEngineController({
|
|||||||
if (!pendingDataAfterCreateRef.current.has(clientRequestId)) return;
|
if (!pendingDataAfterCreateRef.current.has(clientRequestId)) return;
|
||||||
const pendingData = pendingDataAfterCreateRef.current.get(clientRequestId);
|
const pendingData = pendingDataAfterCreateRef.current.get(clientRequestId);
|
||||||
pendingDataAfterCreateRef.current.delete(clientRequestId);
|
pendingDataAfterCreateRef.current.delete(clientRequestId);
|
||||||
|
pinNodeDataLocally(realId as string, pendingData);
|
||||||
await getEnqueueSyncMutation()("updateData", {
|
await getEnqueueSyncMutation()("updateData", {
|
||||||
nodeId: realId,
|
nodeId: realId,
|
||||||
data: pendingData,
|
data: pendingData,
|
||||||
@@ -272,6 +291,7 @@ export function createCanvasSyncEngineController({
|
|||||||
data: unknown;
|
data: unknown;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const rawNodeId = args.nodeId as string;
|
const rawNodeId = args.nodeId as string;
|
||||||
|
pinNodeDataLocally(rawNodeId, args.data);
|
||||||
if (!isOptimisticNodeId(rawNodeId) || !getIsSyncOnline()) {
|
if (!isOptimisticNodeId(rawNodeId) || !getIsSyncOnline()) {
|
||||||
await getEnqueueSyncMutation()("updateData", args);
|
await getEnqueueSyncMutation()("updateData", args);
|
||||||
return;
|
return;
|
||||||
@@ -311,6 +331,7 @@ export function createCanvasSyncEngineController({
|
|||||||
pendingMoveAfterCreateRef.current.delete(clientRequestId);
|
pendingMoveAfterCreateRef.current.delete(clientRequestId);
|
||||||
pendingResizeAfterCreateRef.current.delete(clientRequestId);
|
pendingResizeAfterCreateRef.current.delete(clientRequestId);
|
||||||
pendingDataAfterCreateRef.current.delete(clientRequestId);
|
pendingDataAfterCreateRef.current.delete(clientRequestId);
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef.current.delete(realId as string);
|
||||||
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
|
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
|
||||||
pendingConnectionCreatesRef.current.delete(clientRequestId);
|
pendingConnectionCreatesRef.current.delete(clientRequestId);
|
||||||
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
|
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
|
||||||
@@ -459,6 +480,7 @@ export function createCanvasSyncEngineController({
|
|||||||
pendingDeleteAfterCreateClientRequestIdsRef,
|
pendingDeleteAfterCreateClientRequestIdsRef,
|
||||||
pendingConnectionCreatesRef,
|
pendingConnectionCreatesRef,
|
||||||
pendingLocalPositionUntilConvexMatchesRef,
|
pendingLocalPositionUntilConvexMatchesRef,
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef,
|
||||||
preferLocalPositionNodeIdsRef,
|
preferLocalPositionNodeIdsRef,
|
||||||
flushPendingResizeForClientRequest,
|
flushPendingResizeForClientRequest,
|
||||||
flushPendingDataForClientRequest,
|
flushPendingDataForClientRequest,
|
||||||
@@ -998,6 +1020,9 @@ export function useCanvasSyncEngine({
|
|||||||
controller.pendingMoveAfterCreateRef.current.delete(args.clientRequestId);
|
controller.pendingMoveAfterCreateRef.current.delete(args.clientRequestId);
|
||||||
controller.pendingResizeAfterCreateRef.current.delete(args.clientRequestId);
|
controller.pendingResizeAfterCreateRef.current.delete(args.clientRequestId);
|
||||||
controller.pendingDataAfterCreateRef.current.delete(args.clientRequestId);
|
controller.pendingDataAfterCreateRef.current.delete(args.clientRequestId);
|
||||||
|
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete(
|
||||||
|
optimisticNodeId,
|
||||||
|
);
|
||||||
pendingCreatePromiseByClientRequestRef.current.delete(args.clientRequestId);
|
pendingCreatePromiseByClientRequestRef.current.delete(args.clientRequestId);
|
||||||
controller.pendingEdgeSplitByClientRequestRef.current.delete(
|
controller.pendingEdgeSplitByClientRequestRef.current.delete(
|
||||||
args.clientRequestId,
|
args.clientRequestId,
|
||||||
@@ -1083,6 +1108,20 @@ export function useCanvasSyncEngine({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pinnedData =
|
||||||
|
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.get(
|
||||||
|
optimisticNodeId,
|
||||||
|
);
|
||||||
|
if (pinnedData !== undefined) {
|
||||||
|
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete(
|
||||||
|
optimisticNodeId,
|
||||||
|
);
|
||||||
|
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.set(
|
||||||
|
realNodeId,
|
||||||
|
pinnedData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
controller.preferLocalPositionNodeIdsRef.current.has(optimisticNodeId)
|
controller.preferLocalPositionNodeIdsRef.current.has(optimisticNodeId)
|
||||||
) {
|
) {
|
||||||
@@ -1655,6 +1694,10 @@ export function useCanvasSyncEngine({
|
|||||||
for (const nodeId of op.payload.nodeIds) {
|
for (const nodeId of op.payload.nodeIds) {
|
||||||
deletingNodeIds.current.delete(nodeId as string);
|
deletingNodeIds.current.delete(nodeId as string);
|
||||||
}
|
}
|
||||||
|
} else if (op.type === "updateData") {
|
||||||
|
controller.pendingLocalNodeDataUntilConvexMatchesRef.current.delete(
|
||||||
|
op.payload.nodeId as string,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await ackCanvasSyncOp(op.id);
|
await ackCanvasSyncOp(op.id);
|
||||||
resolveCanvasOp(canvasId as string, op.id);
|
resolveCanvasOp(canvasId as string, op.id);
|
||||||
@@ -1767,6 +1810,8 @@ export function useCanvasSyncEngine({
|
|||||||
pendingConnectionCreatesRef: controller.pendingConnectionCreatesRef,
|
pendingConnectionCreatesRef: controller.pendingConnectionCreatesRef,
|
||||||
pendingLocalPositionUntilConvexMatchesRef:
|
pendingLocalPositionUntilConvexMatchesRef:
|
||||||
controller.pendingLocalPositionUntilConvexMatchesRef,
|
controller.pendingLocalPositionUntilConvexMatchesRef,
|
||||||
|
pendingLocalNodeDataUntilConvexMatchesRef:
|
||||||
|
controller.pendingLocalNodeDataUntilConvexMatchesRef,
|
||||||
preferLocalPositionNodeIdsRef: controller.preferLocalPositionNodeIdsRef,
|
preferLocalPositionNodeIdsRef: controller.preferLocalPositionNodeIdsRef,
|
||||||
pendingCreatePromiseByClientRequestRef,
|
pendingCreatePromiseByClientRequestRef,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ export default defineConfig({
|
|||||||
include: [
|
include: [
|
||||||
"tests/**/*.test.ts",
|
"tests/**/*.test.ts",
|
||||||
"components/canvas/__tests__/canvas-helpers.test.ts",
|
"components/canvas/__tests__/canvas-helpers.test.ts",
|
||||||
|
"components/canvas/__tests__/default-edge.test.tsx",
|
||||||
|
"components/canvas/__tests__/canvas-connection-drop-menu.test.tsx",
|
||||||
"components/canvas/__tests__/canvas-connection-drop-target.test.tsx",
|
"components/canvas/__tests__/canvas-connection-drop-target.test.tsx",
|
||||||
"components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts",
|
"components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts",
|
||||||
"components/canvas/__tests__/compare-node.test.tsx",
|
"components/canvas/__tests__/compare-node.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts",
|
"components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts",
|
||||||
"components/canvas/__tests__/use-canvas-drop.test.tsx",
|
"components/canvas/__tests__/use-canvas-drop.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-connections.test.tsx",
|
"components/canvas/__tests__/use-canvas-connections.test.tsx",
|
||||||
|
"components/canvas/__tests__/use-canvas-edge-insertions.test.tsx",
|
||||||
|
"components/canvas/__tests__/use-canvas-edge-types.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
|
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
|
||||||
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user