Skip to content

Commit 8ef52a5

Browse files
committed
experiment(): Add snapping for create connetions
1 parent 5bad678 commit 8ef52a5

File tree

8 files changed

+1381
-10
lines changed

8 files changed

+1381
-10
lines changed

.cursor/plans/port_magnetic_snapping_95725e35.plan.md

Lines changed: 692 additions & 0 deletions
Large diffs are not rendered by default.

src/components/canvas/GraphComponent/index.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ export class GraphComponent<
117117
return this.ports.get(id);
118118
}
119119

120+
/**
121+
* Update port position and metadata
122+
* @param id Port identifier
123+
* @param x New X coordinate (optional)
124+
* @param y New Y coordinate (optional)
125+
* @param meta Port metadata (optional)
126+
*/
127+
public updatePort<T = unknown>(id: TPortId, x?: number, y?: number, meta?: T): void {
128+
const port = this.getPort(id);
129+
port.updatePortWithMeta(x, y, meta);
130+
}
131+
120132
protected setAffectsUsableRect(affectsUsableRect: boolean) {
121133
this.setProps({ affectsUsableRect });
122134
this.setContext({ affectsUsableRect });

src/components/canvas/anchors/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class Anchor<T extends TAnchorProps = TAnchorProps> extends GraphComponen
5959
}
6060

6161
protected willMount(): void {
62-
this.props.port.addObserver(this);
62+
this.props.port.setOwner(this);
6363
this.subscribeSignal(this.connectedState.$selected, (selected) => {
6464
this.setState({ selected });
6565
});
@@ -111,7 +111,7 @@ export class Anchor<T extends TAnchorProps = TAnchorProps> extends GraphComponen
111111
}
112112

113113
protected unmount() {
114-
this.props.port.removeObserver(this);
114+
this.props.port.setOwner(this.connectedState.block.getViewComponent());
115115
super.unmount();
116116
}
117117

src/components/canvas/layers/connectionLayer/ConnectionLayer.ts

Lines changed: 274 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,62 @@
1+
import RBush from "rbush";
2+
13
import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents";
24
import { Layer, LayerContext, LayerProps } from "../../../../services/Layer";
35
import { ESelectionStrategy } from "../../../../services/selection/types";
46
import { AnchorState } from "../../../../store/anchor/Anchor";
57
import { BlockState, TBlockId } from "../../../../store/block/Block";
8+
import { PortState } from "../../../../store/connection/port/Port";
9+
import { createAnchorPortId, createBlockPointPortId } from "../../../../store/connection/port/utils";
610
import { isBlock, isShiftKeyEvent } from "../../../../utils/functions";
711
import { render } from "../../../../utils/renderers/render";
812
import { renderSVG } from "../../../../utils/renderers/svgPath";
913
import { Point, TPoint } from "../../../../utils/types/shapes";
1014
import { Anchor } from "../../../canvas/anchors";
1115
import { Block } from "../../../canvas/blocks/Block";
1216

17+
const DEFAULT_MAGNET_WIDTH = 40;
18+
const DEFAULT_MAGNET_HEIGHT = 40;
19+
20+
/**
21+
* Magnet condition function type
22+
* Used by ConnectionLayer to determine if a port can snap to another port
23+
* Note: sourceComponent and targetComponent can be accessed via sourcePort.component and targetPort.component
24+
*/
25+
export type TPortMagnetCondition = (context: {
26+
sourcePort: PortState;
27+
targetPort: PortState;
28+
cursorPosition: TPoint;
29+
distance: number;
30+
}) => boolean;
31+
32+
/**
33+
* Optional metadata structure for magnetic ports
34+
* ConnectionLayer interprets this structure for port snapping
35+
*
36+
* @example
37+
* ```typescript
38+
* const magnetMeta: IPortMagnetMeta = {
39+
* magnetWidthArea: 50,
40+
* magnetHeightArea: 50,
41+
* magnetCondition: (ctx) => {
42+
* // Access components via ports
43+
* const sourceComponent = ctx.sourcePort.component;
44+
* const targetComponent = ctx.targetPort.component;
45+
* // Custom validation logic
46+
* return true;
47+
* }
48+
* };
49+
* ```
50+
*/
51+
export interface IPortMagnetMeta {
52+
/** Width of the magnet area (default 40) */
53+
magnetWidthArea?: number;
54+
/** Height of the magnet area (default 40) */
55+
magnetHeightArea?: number;
56+
/** Custom condition for snapping - access components via sourcePort.component and targetPort.component */
57+
magnetCondition?: TPortMagnetCondition;
58+
}
59+
1360
type TIcon = {
1461
path: string;
1562
fill?: string;
@@ -27,6 +74,14 @@ type LineStyle = {
2774

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

77+
type MagneticPortBox = {
78+
minX: number;
79+
minY: number;
80+
maxX: number;
81+
maxY: number;
82+
port: PortState;
83+
};
84+
3085
type ConnectionLayerProps = LayerProps & {
3186
createIcon?: TIcon;
3287
point?: TIcon;
@@ -122,6 +177,11 @@ export class ConnectionLayer extends Layer<
122177
protected enabled: boolean;
123178
private declare eventAborter: AbortController;
124179

180+
// Magnetic ports support
181+
private magneticPortsTree: RBush<MagneticPortBox> | null = null;
182+
private isMagneticTreeOutdated = true;
183+
private portsUnsubscribe?: () => void;
184+
125185
constructor(props: ConnectionLayerProps) {
126186
super({
127187
canvas: {
@@ -161,6 +221,16 @@ export class ConnectionLayer extends Layer<
161221
capture: true,
162222
});
163223

224+
// Subscribe to ports changes to mark magnetic tree as outdated
225+
// We'll mark the tree as outdated when ports change by polling
226+
// Note: Direct subscription to internal signal requires access to connectionsList.ports
227+
const checkPortsChanged = () => {
228+
this.isMagneticTreeOutdated = true;
229+
};
230+
231+
// Subscribe through the Layer's onSignal helper which handles cleanup
232+
this.portsUnsubscribe = this.onSignal(this.context.graph.rootStore.connectionsList.ports.$ports, checkPortsChanged);
233+
164234
// Call parent afterInit to ensure proper initialization
165235
super.afterInit();
166236
}
@@ -328,10 +398,26 @@ export class ConnectionLayer extends Layer<
328398
return;
329399
}
330400

331-
const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]);
401+
// Get source port
402+
const sourcePort = this.getSourcePort(this.sourceComponent);
403+
404+
// Try to snap to magnetic port first
405+
const magnetResult = this.findNearestMagneticPort(point, sourcePort);
406+
407+
let actualEndPoint = point;
408+
let newTargetComponent: Block | Anchor;
409+
410+
if (magnetResult) {
411+
// Snap to magnetic port
412+
actualEndPoint = new Point(magnetResult.snapPoint.x, magnetResult.snapPoint.y);
413+
newTargetComponent = this.getComponentByPort(magnetResult.port);
414+
} else {
415+
// Use existing logic - find element over point
416+
newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]);
417+
}
332418

333419
// Use world coordinates from point instead of screen coordinates
334-
this.endState = new Point(point.x, point.y);
420+
this.endState = new Point(actualEndPoint.x, actualEndPoint.y);
335421
this.performRender();
336422

337423
if (!newTargetComponent || !newTargetComponent.connectedState) {
@@ -430,4 +516,190 @@ export class ConnectionLayer extends Layer<
430516
() => {}
431517
);
432518
}
519+
520+
/**
521+
* Get the source port from a component (block or anchor)
522+
* @param component Block or Anchor component
523+
* @returns Port state or undefined
524+
*/
525+
private getSourcePort(component: BlockState | AnchorState): PortState | undefined {
526+
const connectionsList = this.context.graph.rootStore.connectionsList;
527+
528+
if (component instanceof AnchorState) {
529+
return connectionsList.getPort(createAnchorPortId(component.blockId, component.id));
530+
}
531+
532+
// For block, use output port
533+
return connectionsList.getPort(createBlockPointPortId(component.id, false));
534+
}
535+
536+
/**
537+
* Get the component (Block or Anchor) that owns a port
538+
* @param port Port state
539+
* @returns Block or Anchor component
540+
*/
541+
private getComponentByPort(port: PortState): Block | Anchor | undefined {
542+
const component = port.component;
543+
if (!component) {
544+
return undefined;
545+
}
546+
547+
// Check if component is Block or Anchor by checking instance
548+
if (component instanceof Block || component instanceof Anchor) {
549+
return component;
550+
}
551+
552+
return undefined;
553+
}
554+
555+
/**
556+
* Create a magnetic port bounding box for RBush spatial indexing
557+
* @param port Port to create bounding box for
558+
* @returns MagneticPortBox or null if port is not magnetic
559+
*/
560+
private createMagneticPortBox(port: PortState): MagneticPortBox | null {
561+
const meta = port.meta as IPortMagnetMeta | undefined;
562+
563+
// Check if port has magnetic metadata
564+
if (!meta?.magnetWidthArea && !meta?.magnetHeightArea) {
565+
return null; // Port is not magnetic
566+
}
567+
568+
const widthArea = meta.magnetWidthArea ?? DEFAULT_MAGNET_WIDTH;
569+
const heightArea = meta.magnetHeightArea ?? DEFAULT_MAGNET_HEIGHT;
570+
571+
return {
572+
minX: port.x - widthArea / 2,
573+
minY: port.y - heightArea / 2,
574+
maxX: port.x + widthArea / 2,
575+
maxY: port.y + heightArea / 2,
576+
port: port,
577+
};
578+
}
579+
580+
/**
581+
* Find the nearest magnetic port to a given point
582+
* @param point Point to search from
583+
* @param sourcePort Source port to exclude from search
584+
* @returns Nearest magnetic port and snap point, or null if none found
585+
*/
586+
private findNearestMagneticPort(
587+
point: TPoint,
588+
sourcePort?: PortState
589+
): { port: PortState; snapPoint: TPoint } | null {
590+
// Rebuild RBush if outdated
591+
this.rebuildMagneticTree();
592+
593+
if (!this.magneticPortsTree) {
594+
return null;
595+
}
596+
597+
// Search for ports in the area around cursor
598+
const searchRadius = 100; // Search radius in pixels
599+
const candidates = this.magneticPortsTree.search({
600+
minX: point.x - searchRadius,
601+
minY: point.y - searchRadius,
602+
maxX: point.x + searchRadius,
603+
maxY: point.y + searchRadius,
604+
});
605+
606+
if (candidates.length === 0) {
607+
return null;
608+
}
609+
610+
// Find the nearest port by vector distance
611+
let nearestPort: PortState | null = null;
612+
let nearestDistance = Infinity;
613+
614+
for (const candidate of candidates) {
615+
const port = candidate.port;
616+
617+
// Skip source port
618+
if (sourcePort && port.id === sourcePort.id) {
619+
continue;
620+
}
621+
622+
// Calculate vector distance
623+
const dx = port.x - point.x;
624+
const dy = port.y - point.y;
625+
const distance = Math.sqrt(dx * dx + dy * dy);
626+
627+
// Check if point is inside magnetic area
628+
const meta = port.meta as IPortMagnetMeta | undefined;
629+
const widthArea = meta?.magnetWidthArea ?? DEFAULT_MAGNET_WIDTH;
630+
const heightArea = meta?.magnetHeightArea ?? DEFAULT_MAGNET_HEIGHT;
631+
632+
if (Math.abs(dx) > widthArea / 2 || Math.abs(dy) > heightArea / 2) {
633+
continue; // Outside magnetic area
634+
}
635+
636+
// Check custom condition if provided
637+
if (meta?.magnetCondition && sourcePort) {
638+
const canSnap = meta.magnetCondition({
639+
sourcePort: sourcePort,
640+
targetPort: port,
641+
cursorPosition: point,
642+
distance,
643+
});
644+
645+
if (!canSnap) {
646+
continue;
647+
}
648+
}
649+
650+
// Update nearest port
651+
if (distance < nearestDistance) {
652+
nearestDistance = distance;
653+
nearestPort = port;
654+
}
655+
}
656+
657+
if (!nearestPort) {
658+
return null;
659+
}
660+
661+
return {
662+
port: nearestPort,
663+
snapPoint: { x: nearestPort.x, y: nearestPort.y },
664+
};
665+
}
666+
667+
/**
668+
* Rebuild the RBush spatial index for magnetic ports (lazy rebuild)
669+
*/
670+
private rebuildMagneticTree(): void {
671+
if (!this.isMagneticTreeOutdated) {
672+
return;
673+
}
674+
675+
const magneticBoxes: MagneticPortBox[] = [];
676+
const connectionsList = this.context.graph.rootStore.connectionsList;
677+
678+
// Get all ports from connectionsList
679+
const allPorts = connectionsList.getAllPorts();
680+
for (const port of allPorts) {
681+
const box = this.createMagneticPortBox(port);
682+
if (box) {
683+
magneticBoxes.push(box);
684+
}
685+
}
686+
687+
this.magneticPortsTree = new RBush<MagneticPortBox>(9);
688+
if (magneticBoxes.length > 0) {
689+
this.magneticPortsTree.load(magneticBoxes);
690+
}
691+
692+
this.isMagneticTreeOutdated = false;
693+
}
694+
695+
public override unmount(): void {
696+
// Cleanup ports subscription
697+
if (this.portsUnsubscribe) {
698+
this.portsUnsubscribe();
699+
this.portsUnsubscribe = undefined;
700+
}
701+
// Clear magnetic tree
702+
this.magneticPortsTree = null;
703+
super.unmount();
704+
}
433705
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export { EAnchorType } from "./store/anchor/Anchor";
1313
export type { BlockState, TBlockId } from "./store/block/Block";
1414
export type { ConnectionState, TConnection, TConnectionId } from "./store/connection/ConnectionState";
1515
export type { AnchorState } from "./store/anchor/Anchor";
16+
export type { TPort, TPortId } from "./store/connection/port/Port";
17+
export { createAnchorPortId, createBlockPointPortId, createPortId } from "./store/connection/port/utils";
18+
export type { IPortMagnetMeta, TPortMagnetCondition } from "./components/canvas/layers/connectionLayer/ConnectionLayer";
1619
export { ECanChangeBlockGeometry, ECanDrag } from "./store/settings";
1720
export { type TMeasureTextOptions, type TWrapText } from "./utils/functions/text";
1821
export { ESchedulerPriority } from "./lib/Scheduler";

0 commit comments

Comments
 (0)