From c9c888d0ac00d9d8ec2b88cbf03eb6638d68b744 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 24 Jan 2026 13:01:04 +0100 Subject: [PATCH] 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 --- src/components/Edges/CustomEdge.tsx | 44 +-- src/components/Nodes/CustomNode.tsx | 80 ++---- src/components/Nodes/GroupNode.tsx | 36 +++ src/stores/graphStore.test.ts | 63 +++++ src/stores/graphStore.ts | 6 +- src/stores/workspace/documentUtils.ts | 28 +- src/stores/workspace/useActiveDocument.ts | 8 +- src/stores/workspaceStore.ts | 31 +++ src/utils/__tests__/handleMigration.test.ts | 291 ++++++++++++++++++++ src/utils/edgeUtils.ts | 60 ++-- src/utils/handleMigration.ts | 43 +++ 11 files changed, 567 insertions(+), 123 deletions(-) create mode 100644 src/utils/__tests__/handleMigration.test.ts create mode 100644 src/utils/handleMigration.ts diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index 1b6c1e7..3f5362c 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -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,25 +56,15 @@ 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; - } + finalSourceX = floatingParams.sx; + finalSourceY = floatingParams.sy; + finalSourcePosition = floatingParams.sourcePos; + finalTargetX = floatingParams.tx; + finalTargetY = floatingParams.ty; + finalTargetPosition = floatingParams.targetPos; } // Calculate the bezier path diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index 049010a..005f21b 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -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) => { // 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) => { ? 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) => { 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 */} - - - + {/* Source handle - full node coverage for outgoing connections */} - - diff --git a/src/components/Nodes/GroupNode.tsx b/src/components/Nodes/GroupNode.tsx index 9500f9d..9982915 100644 --- a/src/components/Nodes/GroupNode.tsx +++ b/src/components/Nodes/GroupNode.tsx @@ -220,6 +220,42 @@ const GroupNode = ({ id, data, selected }: NodeProps) => { position: 'relative', }} > + {/* Invisible handles for easy-connect - floating edges calculate actual connection points */} + {/* Target handle - full node coverage for incoming connections */} + + {/* Source handle - full node coverage for outgoing connections */} + + {/* Background color overlay - uses group's custom color */}
{ 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(); + }); }); }); diff --git a/src/stores/graphStore.ts b/src/stores/graphStore.ts index 2b47c1d..e6ec99a 100644 --- a/src/stores/graphStore.ts +++ b/src/stores/graphStore.ts @@ -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((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, diff --git a/src/stores/workspace/documentUtils.ts b/src/stores/workspace/documentUtils.ts index f603232..e34cefe 100644 --- a/src/stores/workspace/documentUtils.ts +++ b/src/stores/workspace/documentUtils.ts @@ -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 => ({ - id: relation.id, - source: relation.source, - target: relation.target, - type: relation.type, - data: relation.data, - sourceHandle: relation.sourceHandle, - targetHandle: relation.targetHandle, - })); + return relations.map(relation => { + const serialized: SerializedRelation = { + id: relation.id, + source: relation.source, + target: relation.target, + type: relation.type, + data: relation.data, + }; + + // 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; + }); } /** diff --git a/src/stores/workspace/useActiveDocument.ts b/src/stores/workspace/useActiveDocument.ts index ddf951a..48d9b88 100644 --- a/src/stores/workspace/useActiveDocument.ts +++ b/src/stores/workspace/useActiveDocument.ts @@ -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[], diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts index b812336..1679816 100644 --- a/src/stores/workspaceStore.ts +++ b/src/stores/workspaceStore.ts @@ -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((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((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((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 = { diff --git a/src/utils/__tests__/handleMigration.test.ts b/src/utils/__tests__/handleMigration.test.ts new file mode 100644 index 0000000..699dfe7 --- /dev/null +++ b/src/utils/__tests__/handleMigration.test.ts @@ -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); + }); + }); +}); diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index 1b19ca9..ccc12be 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -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; diff --git a/src/utils/handleMigration.ts b/src/utils/handleMigration.ts new file mode 100644 index 0000000..a693865 --- /dev/null +++ b/src/utils/handleMigration.ts @@ -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); +}