Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions e2e/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ import "../src/react-components/Anchor.css";

// Re-export everything from main index
export * from "../src/index";

// Re-export bezier helpers for e2e tests
export { generateBezierParams, getPointOfBezierCurve } from "../src/components/canvas/connections/bezierHelpers";
77 changes: 75 additions & 2 deletions e2e/page-objects/GraphConnectionComponentObject.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Page } from "@playwright/test";
import type { GraphPageObject } from "./GraphPageObject";
import type { WorldCoordinates } from "./GraphBlockComponentObject";

export interface ConnectionState {
id: string | number;
Expand Down Expand Up @@ -59,8 +60,80 @@ export class GraphConnectionComponentObject {
*/
async isSelected(): Promise<boolean> {
return await this.page.evaluate((id) => {
const conn = window.graph.connections.getConnection(id);
return conn?.selected || false;
const connState = window.graph.connections.getConnectionState(id);
if (!connState) return false;
return connState.$selected.value;
}, this.connectionId);
}

/**
* Get the world coordinates of a point on the connection's bezier curve at a given time (0..1).
* Falls back to linear interpolation for non-bezier connections.
*/
async getPointOnCurve(time: number = 0.5): Promise<WorldCoordinates> {
return await this.page.evaluate(
({ connectionId, curveTime }) => {
const connState = window.graph.connections.getConnectionState(connectionId);
if (!connState) {
throw new Error(`Connection ${connectionId} not found`);
}
const view = connState.getViewComponent();
if (!view || !view.connectionPoints) {
throw new Error(`Connection ${connectionId} has no rendered view`);
}
const start = view.connectionPoints[0];
const end = view.connectionPoints[1];

const settings = window.graph.rootStore.settings;
const useBezier = settings.getConfigFlag("useBezierConnections");

if (useBezier) {
const direction = settings.getConfigFlag("bezierConnectionDirection") || "horizontal";
const { generateBezierParams } = window.GraphModule;
const [p0, p1, p2, p3] = generateBezierParams(start, end, direction);
const inverseTime = 1 - curveTime;
const startWeight = Math.pow(inverseTime, 3);
const startControlWeight = 3 * Math.pow(inverseTime, 2) * curveTime;
const endControlWeight = 3 * inverseTime * Math.pow(curveTime, 2);
const endWeight = Math.pow(curveTime, 3);
return {
x: startWeight * p0.x + startControlWeight * p1.x + endControlWeight * p2.x + endWeight * p3.x,
y: startWeight * p0.y + startControlWeight * p1.y + endControlWeight * p2.y + endWeight * p3.y,
};
}

// Linear fallback
return {
x: start.x + (end.x - start.x) * curveTime,
y: start.y + (end.y - start.y) * curveTime,
};
},
{ connectionId: this.connectionId, curveTime: time }
);
}

/**
* Click on the connection at a point along its curve
*/
async click(options?: {
shift?: boolean;
ctrl?: boolean;
meta?: boolean;
waitFrames?: number;
curveTime?: number;
}): Promise<void> {
const point = await this.getPointOnCurve(options?.curveTime ?? 0.5);
await this.graphPO.click(point.x, point.y, options);
}

/**
* Hover over the connection at a point along its curve
*/
async hover(options?: {
waitFrames?: number;
curveTime?: number;
}): Promise<void> {
const point = await this.getPointOnCurve(options?.curveTime ?? 0.5);
await this.graphPO.hover(point.x, point.y, options);
}
}
210 changes: 210 additions & 0 deletions e2e/tests/connection/bezier-hitbox.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { test, expect } from "@playwright/test";
import { GraphPageObject } from "../../page-objects/GraphPageObject";

test.describe("Bezier Connection Hitbox", () => {
test.describe("Horizontal graph — target to the right of source", () => {
let graphPO: GraphPageObject;

test.beforeEach(async ({ page }) => {
graphPO = new GraphPageObject(page);

// Source at top-right, target at bottom-left.
// Connection goes from source's right side, loops around, enters target's left side.
// Bezier control points extend far right of source and far left of target —
// this is the edge case where the hitbox must include bezier control points.
await graphPO.initialize({
blocks: [
{
id: "source",
is: "Block",
x: 500,
y: 50,
width: 200,
height: 100,
name: "Source",
anchors: [],
selected: false,
},
{
id: "target",
is: "Block",
x: 0,
y: 300,
width: 200,
height: 100,
name: "Target",
anchors: [],
selected: false,
},
],
connections: [
{
sourceBlockId: "source",
targetBlockId: "target",
},
],
settings: {
useBezierConnections: true,
bezierConnectionDirection: "horizontal",
},
});
});

test("should select connection on click near source control point", async () => {
const conn = graphPO.getConnectionCOM("source:target");

expect(await conn.exists()).toBe(true);
expect(await conn.isSelected()).toBe(false);

// t≈0.15 — curve near the source, where it extends to the right beyond the source block
await conn.click({ curveTime: 0.15 });

expect(await conn.isSelected()).toBe(true);
});

test("should select connection on click near target control point", async () => {
const conn = graphPO.getConnectionCOM("source:target");

// t≈0.85 — curve near the target, where it extends to the left beyond the target block
await conn.click({ curveTime: 0.85 });

expect(await conn.isSelected()).toBe(true);
});

test("should not select connection on click away from curve", async () => {
const conn = graphPO.getConnectionCOM("source:target");

// Click far from any connection or block
await graphPO.click(-200, -200);

expect(await conn.isSelected()).toBe(false);
});
});

test.describe("Vertical graph — target above source", () => {
let graphPO: GraphPageObject;

test.beforeEach(async ({ page }) => {
graphPO = new GraphPageObject(page);

// For vertical graphs, connection points are top/bottom of blocks.
// We register a custom VerticalBlock that overrides getConnectionPoint.
await page.goto("/base.html");

await page.waitForFunction(() => {
return (window as any).graphLibraryLoaded === true;
});

await page.evaluate(() => {
const rootEl = document.getElementById("root");
if (!rootEl) {
throw new Error("Root element not found");
}

const { Graph, CanvasBlock } = (window as any).GraphModule;

// Custom block with vertical connection points (out=bottom, in=top)
class VerticalBlock extends CanvasBlock {
getConnectionPoint(direction: "in" | "out") {
return {
x: (this.connectedState.x + this.connectedState.width / 2) | 0,
y: this.connectedState.y + (direction === "out" ? this.connectedState.height : 0),
};
}
}

const config = {
configurationName: "vertical-e2e",
settings: {
useBezierConnections: true,
bezierConnectionDirection: "vertical",
blockComponents: {
verticalBlock: VerticalBlock,
},
},
};

const graph = new Graph(config, rootEl);

// Source at bottom, target above and to the right.
// Connection goes from source bottom to target top.
// With vertical bezier, the curve bows sideways.
graph.setEntities({
blocks: [
{
id: "source",
is: "verticalBlock",
x: 200,
y: 400,
width: 200,
height: 150,
name: "Source",
anchors: [],
selected: false,
},
{
id: "target",
is: "verticalBlock",
x: 500,
y: 0,
width: 200,
height: 150,
name: "Target",
anchors: [],
selected: false,
},
],
connections: [
{
sourceBlockId: "source",
targetBlockId: "target",
},
],
});

graph.start();
graph.zoomTo("center");

window.graph = graph;
window.graphInitialized = true;
});

await page.waitForFunction(
() => window.graphInitialized === true,
{ timeout: 5000 }
);

await graphPO.waitForFrames(3);
});

test("should select connection on click near source control point", async () => {
const conn = graphPO.getConnectionCOM("source:target");

expect(await conn.exists()).toBe(true);
expect(await conn.isSelected()).toBe(false);

// t≈0.15 — curve near source, extending below the source block
await conn.click({ curveTime: 0.15 });

expect(await conn.isSelected()).toBe(true);
});

test("should select connection on click near target control point", async () => {
const conn = graphPO.getConnectionCOM("source:target");

// t≈0.85 — curve near target, extending above the target block
await conn.click({ curveTime: 0.85 });

expect(await conn.isSelected()).toBe(true);
});

test("should not select connection on click away from curve", async () => {
const conn = graphPO.getConnectionCOM("source:target");

// Click far from any connection or block
await graphPO.click(-300, -300);

expect(await conn.isSelected()).toBe(false);
});
});
});
24 changes: 17 additions & 7 deletions src/components/canvas/connections/BaseConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,23 +229,33 @@ export class BaseConnection<
];
}

// Calculate bounding box from valid coordinates
const x = [this.connectionPoints[0].x, this.connectionPoints[1].x].filter(Number.isFinite);
const y = [this.connectionPoints[0].y, this.connectionPoints[1].y].filter(Number.isFinite);
// Calculate bounding box from connection points, additional points, and subclass points
const points = this.collectBBoxPoints();

if (additionalPoints) {
additionalPoints.forEach((point) => {
x.push(point.x);
y.push(point.y);
});
points.push(...additionalPoints);
}

const x = points.map((p) => p.x).filter(Number.isFinite);
const y = points.map((p) => p.y).filter(Number.isFinite);

this.bBox = [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];

// Update interaction area
this.updateHitBox();
}

/**
* Collects points that define the bounding box of the connection.
* Override in subclasses to include additional points (e.g., bezier control points, labels).
*/
protected collectBBoxPoints(): TPoint[] {
if (!this.connectionPoints) {
return [];
}
return [this.connectionPoints[0], this.connectionPoints[1]];
}

/**
* Get the current bounding box of the connection
* @returns Readonly tuple of [sourceX, sourceY, targetX, targetY]
Expand Down
Loading
Loading