Implement whole-node easy-connect handle system with floating edges

Migrated from 4-position handle system (top/right/bottom/left) to React Flow's
easy-connect pattern where the entire node surface is connectable and edges
dynamically route to the nearest point on the node border.

Key changes:
- Migration utility removes old 4-position handle references for backwards compatibility
- Full-coverage invisible handles on CustomNode and GroupNode (maximized state)
- Floating edges use node.measured dimensions and node.internals.positionAbsolute
- useInternalNode hook for correct absolute positioning of nodes in groups
- All edges now omit handle fields, allowing dynamic border calculations

This improves UX by making nodes easier to connect (whole surface vs tiny handles)
and edges intelligently route to optimal connection points.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2026-01-24 13:01:04 +01:00
parent ae552a9fbd
commit c9c888d0ac
11 changed files with 567 additions and 123 deletions

View file

@ -4,11 +4,10 @@ import {
getBezierPath,
EdgeLabelRenderer,
BaseEdge,
useNodes,
useInternalNode,
} from '@xyflow/react';
import { useGraphStore } from '../../stores/graphStore';
import type { Relation } from '../../types';
import type { Group } from '../../types';
import LabelBadge from '../Common/LabelBadge';
import { getFloatingEdgeParams } from '../../utils/edgeUtils';
import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters';
@ -45,18 +44,11 @@ const CustomEdge = ({
// Get active filters based on mode (editing vs presentation)
const filters = useActiveFilters();
// Get all nodes to check if source/target are minimized groups
const nodes = useNodes();
const sourceNode = nodes.find((n) => n.id === source);
const targetNode = nodes.find((n) => n.id === target);
// Get internal nodes for floating edge calculations with correct absolute positioning
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
// Check if either endpoint is a minimized group
const sourceIsMinimizedGroup = sourceNode?.type === 'group' && (sourceNode.data as Group['data']).minimized;
const targetIsMinimizedGroup = targetNode?.type === 'group' && (targetNode.data as Group['data']).minimized;
// Calculate floating edge parameters if needed
// When connecting to groups (especially minimized ones), we need to use floating edges
// because groups don't have specific handles
// Always use floating edges for easy-connect (dynamic border point calculation)
let finalSourceX = sourceX;
let finalSourceY = sourceY;
let finalTargetX = targetX;
@ -64,26 +56,16 @@ const CustomEdge = ({
let finalSourcePosition = sourcePosition;
let finalTargetPosition = targetPosition;
// Check if we need to use floating edge calculations
const needsFloatingEdge = (sourceIsMinimizedGroup || targetIsMinimizedGroup) && sourceNode && targetNode;
if (needsFloatingEdge) {
// Use floating edge calculations for ALL edges to get smart border connection
if (sourceNode && targetNode) {
const floatingParams = getFloatingEdgeParams(sourceNode, targetNode);
// When either endpoint is a minimized group, use floating positions for that side
// IMPORTANT: When BOTH are groups, we must use floating for BOTH sides
if (sourceIsMinimizedGroup) {
finalSourceX = floatingParams.sx;
finalSourceY = floatingParams.sy;
finalSourcePosition = floatingParams.sourcePos;
}
if (targetIsMinimizedGroup) {
finalTargetX = floatingParams.tx;
finalTargetY = floatingParams.ty;
finalTargetPosition = floatingParams.targetPos;
}
}
// Calculate the bezier path
const [edgePath, labelX, labelY] = getBezierPath({

View file

@ -1,5 +1,5 @@
import { memo, useMemo } from "react";
import { Handle, Position, NodeProps, useConnection } from "@xyflow/react";
import { Handle, Position, NodeProps } from "@xyflow/react";
import { useGraphStore } from "../../stores/graphStore";
import {
getContrastColor,
@ -16,7 +16,7 @@ import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilte
*
* Features:
* - Visual representation with type-based coloring
* - Connection handles (top, right, bottom, left)
* - Easy-connect: whole node is connectable, edges auto-route to nearest border point
* - Label display
* - Type badge
*
@ -29,10 +29,6 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
// Get active filters based on mode (editing vs presentation)
const filters = useActiveFilters();
// Check if any connection is being made (to show handles)
const connection = useConnection();
const isConnecting = !!connection.inProgress;
// Find the node type configuration
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type);
const nodeColor = nodeTypeConfig?.color || "#6b7280";
@ -46,9 +42,6 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
? adjustColorBrightness(nodeColor, -20)
: nodeColor;
// Show handles when selected or when connecting
const showHandles = selected || isConnecting;
// Check if this node matches the filter criteria
const isMatch = useMemo(() => {
return nodeMatchesFilters(
@ -85,64 +78,39 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
opacity: nodeOpacity,
}}
>
{/* Connection handles - shown only when selected or connecting */}
{/* Invisible handles for easy-connect - floating edges calculate actual connection points */}
{/* Target handle - full node coverage for incoming connections */}
<Handle
type="source"
type="target"
position={Position.Top}
id="top"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
width: '100%',
height: '100%',
top: 0,
left: 0,
borderRadius: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
}}
/>
<Handle
type="source"
position={Position.Right}
id="right"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
{/* Source handle - full node coverage for outgoing connections */}
<Handle
type="source"
position={Position.Bottom}
id="bottom"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
<Handle
type="source"
position={Position.Left}
id="left"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
width: '100%',
height: '100%',
top: 0,
left: 0,
borderRadius: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
}}
/>

View file

@ -220,6 +220,42 @@ const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
position: 'relative',
}}
>
{/* Invisible handles for easy-connect - floating edges calculate actual connection points */}
{/* Target handle - full node coverage for incoming connections */}
<Handle
type="target"
position={Position.Top}
isConnectable={true}
style={{
width: '100%',
height: '100%',
top: 0,
left: 0,
borderRadius: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
}}
/>
{/* Source handle - full node coverage for outgoing connections */}
<Handle
type="source"
position={Position.Bottom}
isConnectable={true}
style={{
width: '100%',
height: '100%',
top: 0,
left: 0,
borderRadius: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
}}
/>
{/* Background color overlay - uses group's custom color */}
<div
style={{

View file

@ -1256,6 +1256,69 @@ describe('graphStore', () => {
const loadedNode = state.nodes[0];
expect(loadedNode.parentId).toBe('group-1');
});
it('should migrate old 4-position handle references by removing handles', () => {
const { loadGraphState } = useGraphStore.getState();
// Create edges with old handle format
const edgeWithOldHandles: Relation = {
...createMockEdge('edge-1', 'node-1', 'node-2'),
sourceHandle: 'right',
targetHandle: 'left',
};
const edgeWithTopBottom: Relation = {
...createMockEdge('edge-2', 'node-1', 'node-2'),
sourceHandle: 'top',
targetHandle: 'bottom',
};
loadGraphState({
nodes: [createMockNode('node-1'), createMockNode('node-2')],
edges: [edgeWithOldHandles, edgeWithTopBottom],
groups: [],
nodeTypes: [],
edgeTypes: [],
labels: [],
});
const state = useGraphStore.getState();
// Both edges should have handles removed (undefined) for floating edge pattern
expect(state.edges[0].sourceHandle).toBeUndefined();
expect(state.edges[0].targetHandle).toBeUndefined();
expect(state.edges[1].sourceHandle).toBeUndefined();
expect(state.edges[1].targetHandle).toBeUndefined();
// Other fields should be preserved
expect(state.edges[0].id).toBe('edge-1');
expect(state.edges[0].source).toBe('node-1');
expect(state.edges[0].target).toBe('node-2');
});
it('should preserve undefined/null handles', () => {
const { loadGraphState } = useGraphStore.getState();
// Create edge without handles (new format)
const edgeWithoutHandles: Relation = {
...createMockEdge('edge-1', 'node-1', 'node-2'),
};
loadGraphState({
nodes: [createMockNode('node-1'), createMockNode('node-2')],
edges: [edgeWithoutHandles],
groups: [],
nodeTypes: [],
edgeTypes: [],
labels: [],
});
const state = useGraphStore.getState();
// Handles should remain undefined
expect(state.edges[0].sourceHandle).toBeUndefined();
expect(state.edges[0].targetHandle).toBeUndefined();
});
});
});

View file

@ -14,6 +14,7 @@ import type {
} from '../types';
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
import { migrateRelationHandlesArray } from '../utils/handleMigration';
/**
* IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS
@ -641,10 +642,13 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
? migrateTangibleConfigs(data.tangibles)
: [];
// Apply handle migration for backward compatibility (remove old 4-position handles)
const migratedEdges = migrateRelationHandlesArray(data.edges);
// Atomic update: all state changes happen in a single set() call
set({
nodes: sanitizedNodes,
edges: data.edges,
edges: migratedEdges,
groups: data.groups || [],
nodeTypes: data.nodeTypes,
edgeTypes: data.edgeTypes,

View file

@ -225,15 +225,25 @@ export function serializeActors(actors: Actor[]): SerializedActor[] {
* Serialize relations for storage (strip React Flow internals)
*/
export function serializeRelations(relations: Relation[]): SerializedRelation[] {
return relations.map(relation => ({
return relations.map(relation => {
const serialized: SerializedRelation = {
id: relation.id,
source: relation.source,
target: relation.target,
type: relation.type,
data: relation.data,
sourceHandle: relation.sourceHandle,
targetHandle: relation.targetHandle,
}));
};
// Only include handles if they exist and are non-null/non-undefined
if (relation.sourceHandle != null) {
serialized.sourceHandle = relation.sourceHandle;
}
if (relation.targetHandle != null) {
serialized.targetHandle = relation.targetHandle;
}
return serialized;
});
}
/**

View file

@ -4,6 +4,7 @@ import { useGraphStore } from '../graphStore';
import { useTimelineStore } from '../timelineStore';
import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig, TangibleConfig } from '../../types';
import { getCurrentGraphFromDocument } from './documentUtils';
import { migrateRelationHandlesArray } from '../../utils/handleMigration';
/**
* useActiveDocument Hook
@ -97,8 +98,11 @@ export function useActiveDocument() {
isLoadingRef.current = true;
lastLoadedDocIdRef.current = activeDocumentId;
// Apply handle migration for backward compatibility (remove old 4-position handles)
const migratedEdges = migrateRelationHandlesArray(currentGraph.edges);
setNodes(currentGraph.nodes as never[]);
setEdges(currentGraph.edges as never[]);
setEdges(migratedEdges as never[]);
setGroups(currentGraph.groups as never[]);
setNodeTypes(currentGraph.nodeTypes as never[]);
setEdgeTypes(currentGraph.edgeTypes as never[]);
@ -109,7 +113,7 @@ export function useActiveDocument() {
lastSyncedStateRef.current = {
documentId: activeDocumentId,
nodes: currentGraph.nodes as Actor[],
edges: currentGraph.edges as Relation[],
edges: migratedEdges as Relation[],
groups: currentGraph.groups as Group[],
nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[],
edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[],

View file

@ -33,6 +33,7 @@ import { Cite } from '@citation-js/core';
import type { CSLReference } from '../types/bibliography';
import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage';
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
import { migrateRelationHandlesArray } from '../utils/handleMigration';
/**
* Workspace Store
@ -318,6 +319,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
}
// Apply handle migration to all timeline states for backward compatibility
if (doc.timeline && doc.timeline.states) {
Object.keys(doc.timeline.states).forEach((stateId) => {
const state = doc.timeline.states[stateId];
if (state && state.graph && state.graph.edges) {
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
}
});
}
// Load timeline if it exists
if (doc.timeline) {
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
@ -626,6 +637,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
importedDoc.tangibles = migrateTangibleConfigs(importedDoc.tangibles);
}
// Apply handle migration to all timeline states for backward compatibility
if (importedDoc.timeline && importedDoc.timeline.states) {
Object.keys(importedDoc.timeline.states).forEach((stateId) => {
const state = importedDoc.timeline.states[stateId];
if (state && state.graph && state.graph.edges) {
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
}
});
}
const metadata: DocumentMetadata = {
id: documentId,
title: importedDoc.metadata.title || 'Imported Analysis',
@ -938,6 +959,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
}
// Apply handle migration to all timeline states for backward compatibility
if (doc.timeline && doc.timeline.states) {
Object.keys(doc.timeline.states).forEach((stateId) => {
const state = doc.timeline.states[stateId];
if (state && state.graph && state.graph.edges) {
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
}
});
}
saveDocumentToStorage(docId, doc);
const metadata = {

View file

@ -0,0 +1,291 @@
import { describe, it, expect } from 'vitest';
import { migrateRelationHandles, migrateRelationHandlesArray } from '../handleMigration';
import type { SerializedRelation } from '../../stores/persistence/types';
describe('handleMigration', () => {
describe('migrateRelationHandles', () => {
it('should migrate old "top" source handle by removing handles', () => {
const oldFormat: SerializedRelation = {
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
targetHandle: 'bottom',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
expect(result.id).toBe('edge-1');
expect(result.source).toBe('node-1');
expect(result.target).toBe('node-2');
});
it('should migrate old "right" source handle', () => {
const oldFormat: SerializedRelation = {
id: 'edge-2',
source: 'node-1',
target: 'node-2',
sourceHandle: 'right',
targetHandle: 'left',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate old "bottom" source handle', () => {
const oldFormat: SerializedRelation = {
id: 'edge-3',
source: 'node-1',
target: 'node-2',
sourceHandle: 'bottom',
targetHandle: 'top',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate old "left" source handle', () => {
const oldFormat: SerializedRelation = {
id: 'edge-4',
source: 'node-1',
target: 'node-2',
sourceHandle: 'left',
targetHandle: 'right',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate when only source handle is old format', () => {
const mixed: SerializedRelation = {
id: 'edge-5',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
};
const result = migrateRelationHandles(mixed);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate when only target handle is old format', () => {
const mixed: SerializedRelation = {
id: 'edge-6',
source: 'node-1',
target: 'node-2',
targetHandle: 'bottom',
};
const result = migrateRelationHandles(mixed);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should leave relations with undefined handles unchanged', () => {
const newFormat: SerializedRelation = {
id: 'edge-7',
source: 'node-1',
target: 'node-2',
};
const result = migrateRelationHandles(newFormat);
expect(result).toEqual(newFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should leave relations with null handles unchanged', () => {
const newFormat: SerializedRelation = {
id: 'edge-8',
source: 'node-1',
target: 'node-2',
sourceHandle: null,
targetHandle: null,
};
const result = migrateRelationHandles(newFormat);
expect(result).toEqual(newFormat);
expect(result.sourceHandle).toBeNull();
expect(result.targetHandle).toBeNull();
});
it('should leave relations with custom handle IDs unchanged', () => {
const customHandles: SerializedRelation = {
id: 'edge-9',
source: 'node-1',
target: 'node-2',
sourceHandle: 'custom-source-1',
targetHandle: 'custom-target-1',
};
const result = migrateRelationHandles(customHandles);
expect(result).toEqual(customHandles);
expect(result.sourceHandle).toBe('custom-source-1');
expect(result.targetHandle).toBe('custom-target-1');
});
it('should preserve type and data fields', () => {
const withData: SerializedRelation = {
id: 'edge-10',
source: 'node-1',
target: 'node-2',
sourceHandle: 'right',
targetHandle: 'left',
type: 'custom-edge',
data: {
label: 'Test Edge',
description: 'Test description',
},
};
const result = migrateRelationHandles(withData);
expect(result.type).toBe('custom-edge');
expect(result.data).toEqual({
label: 'Test Edge',
description: 'Test description',
});
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should be idempotent (running twice produces same result)', () => {
const oldFormat: SerializedRelation = {
id: 'edge-11',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
targetHandle: 'bottom',
};
const firstMigration = migrateRelationHandles(oldFormat);
const secondMigration = migrateRelationHandles(firstMigration);
expect(firstMigration).toEqual(secondMigration);
expect(secondMigration.sourceHandle).toBeUndefined();
expect(secondMigration.targetHandle).toBeUndefined();
});
it('should handle mixed old and custom handles', () => {
const mixed: SerializedRelation = {
id: 'edge-12',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top', // Old format
targetHandle: 'custom-target', // Custom handle
};
const result = migrateRelationHandles(mixed);
// Should migrate because sourceHandle is old format (removes both handles)
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
});
describe('migrateRelationHandlesArray', () => {
it('should migrate an array of relations', () => {
const relations: SerializedRelation[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: 'right',
targetHandle: 'left',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
// Already new format (undefined)
},
{
id: 'edge-3',
source: 'node-3',
target: 'node-4',
sourceHandle: 'custom-handle',
targetHandle: 'custom-target',
},
];
const result = migrateRelationHandlesArray(relations);
expect(result).toHaveLength(3);
// First should be migrated (old format removed)
expect(result[0].sourceHandle).toBeUndefined();
expect(result[0].targetHandle).toBeUndefined();
// Second should remain unchanged
expect(result[1]).toEqual(relations[1]);
// Third should remain unchanged (custom handles)
expect(result[2]).toEqual(relations[2]);
});
it('should handle empty array', () => {
const result = migrateRelationHandlesArray([]);
expect(result).toEqual([]);
});
it('should handle array with all old format relations', () => {
const relations: SerializedRelation[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
targetHandle: 'bottom',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
sourceHandle: 'right',
targetHandle: 'left',
},
];
const result = migrateRelationHandlesArray(relations);
expect(result).toHaveLength(2);
result.forEach((relation) => {
expect(relation.sourceHandle).toBeUndefined();
expect(relation.targetHandle).toBeUndefined();
});
});
it('should handle array with all new format relations', () => {
const relations: SerializedRelation[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
},
];
const result = migrateRelationHandlesArray(relations);
expect(result).toEqual(relations);
});
});
});

View file

@ -1,7 +1,6 @@
import type { Relation, RelationData } from '../types';
import type { Node } from '@xyflow/react';
import { Position } from '@xyflow/react';
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
/**
* Generates a unique ID for edges
@ -15,27 +14,33 @@ export const generateEdgeId = (source: string, target: string): string => {
* Used for floating edges to connect at the closest point on the node
*/
function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
const {
width: intersectionNodeWidth,
height: intersectionNodeHeight,
position: intersectionNodePosition,
} = intersectionNode;
const targetPosition = targetNode.position;
// Use positionAbsolute for correct positioning of nodes inside groups
// positionAbsolute accounts for parent group offset, while position is relative
// @ts-ignore - internals.positionAbsolute exists at runtime but not in public types
const intersectionNodePosition = intersectionNode.internals?.positionAbsolute ?? intersectionNode.position;
// @ts-ignore - internals.positionAbsolute exists at runtime but not in public types
const targetPosition = targetNode.internals?.positionAbsolute ?? targetNode.position;
// Use fallback dimensions if width/height are not set (e.g., for groups without measured dimensions)
const w = (intersectionNodeWidth ?? MINIMIZED_GROUP_WIDTH) / 2;
const h = (intersectionNodeHeight ?? MINIMIZED_GROUP_HEIGHT) / 2;
// Use measured dimensions from React Flow (stored in node.measured)
// If undefined, node hasn't been measured yet - return center
const intersectionNodeWidth = intersectionNode.measured?.width ?? intersectionNode.width;
const intersectionNodeHeight = intersectionNode.measured?.height ?? intersectionNode.height;
const targetNodeWidth = targetNode.measured?.width ?? targetNode.width;
const targetNodeHeight = targetNode.measured?.height ?? targetNode.height;
if (!intersectionNodeWidth || !intersectionNodeHeight || !targetNodeWidth || !targetNodeHeight) {
const centerX = intersectionNodePosition.x + (intersectionNodeWidth ?? 0) / 2;
const centerY = intersectionNodePosition.y + (intersectionNodeHeight ?? 0) / 2;
return { x: centerX, y: centerY };
}
const w = intersectionNodeWidth / 2;
const h = intersectionNodeHeight / 2;
const x2 = intersectionNodePosition.x + w;
const y2 = intersectionNodePosition.y + h;
const x1 = targetPosition.x + (targetNode.width ?? MINIMIZED_GROUP_WIDTH) / 2;
const y1 = targetPosition.y + (targetNode.height ?? MINIMIZED_GROUP_HEIGHT) / 2;
// Guard against division by zero
if (w === 0 || h === 0) {
// If node has no dimensions, return center point
return { x: x2, y: y2 };
}
const x1 = targetPosition.x + targetNodeWidth / 2;
const y1 = targetPosition.y + targetNodeHeight / 2;
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
@ -52,15 +57,22 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
* Get the position (top, right, bottom, left) of the handle based on the intersection point
*/
function getEdgePosition(node: Node, intersectionPoint: { x: number; y: number }) {
const n = { ...node.position, ...node };
const nx = Math.round(n.x);
const ny = Math.round(n.y);
// Use positionAbsolute for correct positioning of nodes inside groups
// @ts-ignore - internals.positionAbsolute exists at runtime but not in public types
const nodePosition = node.internals?.positionAbsolute ?? node.position;
const nx = Math.round(nodePosition.x);
const ny = Math.round(nodePosition.y);
const px = Math.round(intersectionPoint.x);
const py = Math.round(intersectionPoint.y);
// Use fallback dimensions if not set (same as getNodeIntersection)
const width = node.width ?? MINIMIZED_GROUP_WIDTH;
const height = node.height ?? MINIMIZED_GROUP_HEIGHT;
// Use measured dimensions from React Flow (stored in node.measured)
// If not available, default to Top
const width = node.measured?.width ?? node.width;
const height = node.measured?.height ?? node.height;
if (!width || !height) {
return Position.Top;
}
if (px <= nx + 1) {
return Position.Left;

View file

@ -0,0 +1,43 @@
import type { SerializedRelation } from '../stores/persistence/types';
/**
* List of old 4-position handle identifiers that should be migrated
*/
const OLD_HANDLE_POSITIONS = ['top', 'right', 'bottom', 'left'] as const;
/**
* Migrates a relation from the old 4-position handle system to the new easy-connect handle system.
* This function ensures backward compatibility with existing constellation files.
*
* Old format uses specific position handles: "top", "right", "bottom", "left"
* New format omits handle fields entirely, allowing floating edges to calculate connection points dynamically
*
* @param relation - The relation to migrate
* @returns The migrated relation
*/
export function migrateRelationHandles(relation: SerializedRelation): SerializedRelation {
// Check if either handle uses old format
const hasOldSourceHandle =
relation.sourceHandle != null && OLD_HANDLE_POSITIONS.includes(relation.sourceHandle as any);
const hasOldTargetHandle =
relation.targetHandle != null && OLD_HANDLE_POSITIONS.includes(relation.targetHandle as any);
// If old format detected, remove handle fields entirely for floating edge pattern
if (hasOldSourceHandle || hasOldTargetHandle) {
const { sourceHandle, targetHandle, ...relationWithoutHandles } = relation;
return relationWithoutHandles;
}
// Otherwise return unchanged
return relation;
}
/**
* Migrates an array of relations.
*
* @param relations - Array of relations to migrate
* @returns Array of migrated relations
*/
export function migrateRelationHandlesArray(relations: SerializedRelation[]): SerializedRelation[] {
return relations.map(migrateRelationHandles);
}