1+ import RBush from "rbush" ;
2+
13import { GraphMouseEvent , extractNativeGraphMouseEvent } from "../../../../graphEvents" ;
24import { Layer , LayerContext , LayerProps } from "../../../../services/Layer" ;
35import { ESelectionStrategy } from "../../../../services/selection/types" ;
46import { AnchorState } from "../../../../store/anchor/Anchor" ;
57import { BlockState , TBlockId } from "../../../../store/block/Block" ;
8+ import { PortState } from "../../../../store/connection/port/Port" ;
9+ import { createAnchorPortId , createBlockPointPortId } from "../../../../store/connection/port/utils" ;
610import { isBlock , isShiftKeyEvent } from "../../../../utils/functions" ;
711import { render } from "../../../../utils/renderers/render" ;
812import { renderSVG } from "../../../../utils/renderers/svgPath" ;
913import { Point , TPoint } from "../../../../utils/types/shapes" ;
1014import { Anchor } from "../../../canvas/anchors" ;
1115import { 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+
1360type TIcon = {
1461 path : string ;
1562 fill ?: string ;
@@ -27,6 +74,14 @@ type LineStyle = {
2774
2875type 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+
3085type 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}
0 commit comments