Skip to content
Draft
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
692 changes: 692 additions & 0 deletions .cursor/plans/port_magnetic_snapping_95725e35.plan.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions src/components/canvas/GraphComponent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,26 @@ export class GraphComponent<
return this.ports.get(id);
}

/**
* Get all ports of this component
* @returns Array of all port states
*/
public getPorts(): PortState[] {
return Array.from(this.ports.values());
}

/**
* Update port position and metadata
* @param id Port identifier
* @param x New X coordinate (optional)
* @param y New Y coordinate (optional)
* @param meta Port metadata (optional)
*/
public updatePort<T = unknown>(id: TPortId, x?: number, y?: number, meta?: T): void {
const port = this.getPort(id);
port.updatePortWithMeta(x, y, meta);
}

protected setAffectsUsableRect(affectsUsableRect: boolean) {
this.setProps({ affectsUsableRect });
this.setContext({ affectsUsableRect });
Expand Down
8 changes: 6 additions & 2 deletions src/components/canvas/anchors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class Anchor<T extends TAnchorProps = TAnchorProps> extends GraphComponen
}

protected willMount(): void {
this.props.port.addObserver(this);
this.props.port.setOwner(this);
this.subscribeSignal(this.connectedState.$selected, (selected) => {
this.setState({ selected });
});
Expand All @@ -70,6 +70,10 @@ export class Anchor<T extends TAnchorProps = TAnchorProps> extends GraphComponen
this.setHitBox(point.x - this.shift, point.y - this.shift, point.x + this.shift, point.y + this.shift);
};

public override getPorts(): PortState[] {
return [this.props.port];
}

public getPosition() {
return this.props.port.getPoint();
}
Expand Down Expand Up @@ -111,7 +115,7 @@ export class Anchor<T extends TAnchorProps = TAnchorProps> extends GraphComponen
}

protected unmount() {
this.props.port.removeObserver(this);
this.props.port.removeOwner();
super.unmount();
}

Expand Down
280 changes: 277 additions & 3 deletions src/components/canvas/layers/connectionLayer/ConnectionLayer.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,62 @@
import RBush from "rbush";

import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents";
import { Layer, LayerContext, LayerProps } from "../../../../services/Layer";
import { ESelectionStrategy } from "../../../../services/selection/types";
import { AnchorState } from "../../../../store/anchor/Anchor";
import { BlockState, TBlockId } from "../../../../store/block/Block";
import { isBlock, isShiftKeyEvent } from "../../../../utils/functions";
import { PortState } from "../../../../store/connection/port/Port";
import { createAnchorPortId, createBlockPointPortId } from "../../../../store/connection/port/utils";
import { isBlock, isShiftKeyEvent, vectorDistance } from "../../../../utils/functions";
import { render } from "../../../../utils/renderers/render";
import { renderSVG } from "../../../../utils/renderers/svgPath";
import { Point, TPoint } from "../../../../utils/types/shapes";
import { Anchor } from "../../../canvas/anchors";
import { Block } from "../../../canvas/blocks/Block";
import { GraphComponent } from "../../GraphComponent";

/**
* Default search radius for port snapping in pixels
* Ports within this radius will be considered for snapping
*/
const SNAP_SEARCH_RADIUS = 20;

/**
* Snap condition function type
* Used by ConnectionLayer to determine if a port can snap to another port
* Note: sourceComponent and targetComponent can be accessed via sourcePort.component and targetPort.component
*/
export type TPortSnapCondition = (context: {
sourcePort: PortState;
targetPort: PortState;
cursorPosition: TPoint;
distance: number;
}) => boolean;

/**
* Optional metadata structure for port snapping
* ConnectionLayer interprets this structure for port snapping behavior
*
* @example
* ```typescript
* const snapMeta: IPortSnapMeta = {
* snappable: true,
* snapCondition: (ctx) => {
* // Access components via ports
* const sourceComponent = ctx.sourcePort.component;
* const targetComponent = ctx.targetPort.component;
* // Custom validation logic
* return true;
* }
* };
* ```
*/
export interface IPortSnapMeta {
/** Enable snapping for this port. If false or undefined, port will not participate in snapping */
snappable?: boolean;
/** Custom condition for snapping - access components via sourcePort.component and targetPort.component */
snapCondition?: TPortSnapCondition;
}

type TIcon = {
path: string;
Expand All @@ -27,6 +75,14 @@ type LineStyle = {

type DrawLineFunction = (start: TPoint, end: TPoint) => { path: Path2D; style: LineStyle };

type SnappingPortBox = {
minX: number;
minY: number;
maxX: number;
maxY: number;
port: PortState;
};

type ConnectionLayerProps = LayerProps & {
createIcon?: TIcon;
point?: TIcon;
Expand Down Expand Up @@ -122,6 +178,11 @@ export class ConnectionLayer extends Layer<
protected enabled: boolean;
private declare eventAborter: AbortController;

// Port snapping support
private snappingPortsTree: RBush<SnappingPortBox> | null = null;
private isSnappingTreeOutdated = true;
private portsUnsubscribe?: () => void;

constructor(props: ConnectionLayerProps) {
super({
canvas: {
Expand Down Expand Up @@ -161,6 +222,21 @@ export class ConnectionLayer extends Layer<
capture: true,
});

// Subscribe to ports changes to mark snapping tree as outdated
// We'll mark the tree as outdated when ports change by polling
// Note: Direct subscription to internal signal requires access to connectionsList.ports
const checkPortsChanged = () => {
this.isSnappingTreeOutdated = true;
};

// Subscribe through the Layer's onSignal helper which handles cleanup
this.portsUnsubscribe = this.onSignal(this.context.graph.rootStore.connectionsList.ports.$ports, checkPortsChanged);

// Subscribe to camera changes to invalidate tree when viewport changes
this.onGraphEvent("camera-change", () => {
this.isSnappingTreeOutdated = true;
});

// Call parent afterInit to ensure proper initialization
super.afterInit();
}
Expand Down Expand Up @@ -328,10 +404,26 @@ export class ConnectionLayer extends Layer<
return;
}

const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]);
// Get source port
const sourcePort = this.getSourcePort(this.sourceComponent);

// Try to snap to nearby port first
const snapResult = this.findNearestSnappingPort(point, sourcePort);

let actualEndPoint = point;
let newTargetComponent: Block | Anchor;

if (snapResult) {
// Snap to port
actualEndPoint = new Point(snapResult.snapPoint.x, snapResult.snapPoint.y);
newTargetComponent = this.getComponentByPort(snapResult.port);
} else {
// Use existing logic - find element over point
newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]);
}

// Use world coordinates from point instead of screen coordinates
this.endState = new Point(point.x, point.y);
this.endState = new Point(actualEndPoint.x, actualEndPoint.y);
this.performRender();

if (!newTargetComponent || !newTargetComponent.connectedState) {
Expand Down Expand Up @@ -430,4 +522,186 @@ export class ConnectionLayer extends Layer<
() => {}
);
}

/**
* Get the source port from a component (block or anchor)
* @param component Block or Anchor component
* @returns Port state or undefined
*/
private getSourcePort(component: BlockState | AnchorState): PortState | undefined {
const connectionsList = this.context.graph.rootStore.connectionsList;

if (component instanceof AnchorState) {
return connectionsList.getPort(createAnchorPortId(component.blockId, component.id));
}

// For block, use output port
return connectionsList.getPort(createBlockPointPortId(component.id, false));
}

/**
* Get the component (Block or Anchor) that owns a port
* @param port Port state
* @returns Block or Anchor component
*/
private getComponentByPort(port: PortState): Block | Anchor | undefined {
const component = port.component;
if (!component) {
return undefined;
}

// Check if component is Block or Anchor by checking instance
if (component instanceof Block || component instanceof Anchor) {
return component;
}

return undefined;
}

/**
* Create a snapping port bounding box for RBush spatial indexing
* @param port Port to create bounding box for
* @param searchRadius Search radius for snapping area
* @returns SnappingPortBox or null if port doesn't have snapping enabled
*/
private createSnappingPortBox(port: PortState, searchRadius: number): SnappingPortBox | null {
const meta = port.meta as IPortSnapMeta | undefined;

// Check if port has snapping enabled
if (!meta?.snappable) {
return null; // Port doesn't participate in snapping
}

return {
minX: port.x - searchRadius,
minY: port.y - searchRadius,
maxX: port.x + searchRadius,
maxY: port.y + searchRadius,
port: port,
};
}

/**
* Find the nearest snapping port to a given point
* @param point Point to search from
* @param sourcePort Source port to exclude from search
* @returns Nearest snapping port and snap point, or null if none found
*/
private findNearestSnappingPort(
point: TPoint,
sourcePort?: PortState
): { port: PortState; snapPoint: TPoint } | null {
// Rebuild RBush if outdated
this.rebuildSnappingTree();

if (!this.snappingPortsTree) {
return null;
}

// Search for ports in the area around cursor
const candidates = this.snappingPortsTree.search({
minX: point.x - SNAP_SEARCH_RADIUS,
minY: point.y - SNAP_SEARCH_RADIUS,
maxX: point.x + SNAP_SEARCH_RADIUS,
maxY: point.y + SNAP_SEARCH_RADIUS,
});

if (candidates.length === 0) {
return null;
}

// Find the nearest port by vector distance
let nearestPort: PortState | null = null;
let nearestDistance = Infinity;

for (const candidate of candidates) {
const port = candidate.port;

// Skip source port
if (sourcePort && port.id === sourcePort.id) {
continue;
}

// Calculate vector distance
const distance = vectorDistance(point, port);

// Check custom condition if provided
const meta = port.meta as IPortSnapMeta | undefined;
if (meta?.snapCondition && sourcePort) {
const canSnap = meta.snapCondition({
sourcePort: sourcePort,
targetPort: port,
cursorPosition: point,
distance,
});

if (!canSnap) {
continue;
}
}

// Update nearest port
if (distance < nearestDistance) {
nearestDistance = distance;
nearestPort = port;
}
}

if (!nearestPort) {
return null;
}

return {
port: nearestPort,
snapPoint: { x: nearestPort.x, y: nearestPort.y },
};
}

/**
* Rebuild the RBush spatial index for snapping ports
* Optimization: Only includes ports from components visible in viewport + padding
*/
private rebuildSnappingTree(): void {
if (!this.isSnappingTreeOutdated) {
return;
}

const snappingBoxes: SnappingPortBox[] = [];

// Get only visible components in viewport (with padding already applied)
const visibleComponents = this.context.graph.getElementsInViewport([GraphComponent]);

// Collect ports from visible components only
for (const component of visibleComponents) {
const ports = component.getPorts();

for (const port of ports) {
// Skip ports in lookup state (no valid coordinates)
if (port.lookup) continue;

const box = this.createSnappingPortBox(port, SNAP_SEARCH_RADIUS);
if (box) {
snappingBoxes.push(box);
}
}
}

this.snappingPortsTree = new RBush<SnappingPortBox>(9);
if (snappingBoxes.length > 0) {
this.snappingPortsTree.load(snappingBoxes);
}

this.isSnappingTreeOutdated = false;
}

public override unmount(): void {
// Cleanup ports subscription
if (this.portsUnsubscribe) {
this.portsUnsubscribe();
this.portsUnsubscribe = undefined;
}
// Clear snapping tree
this.snappingPortsTree = null;
super.unmount();
}
}
Loading
Loading