Skip to content

Commit f19aa2e

Browse files
authored
fix: helper lines for nested elements (#680)
1 parent 7991210 commit f19aa2e

4 files changed

Lines changed: 164 additions & 17 deletions

File tree

library/lib/utils/alignmentUtils.ts

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Node } from "@xyflow/react"
22
import { AlignmentGuide } from "@/store/alignmentGuidesStore"
3+
import { getPositionOnCanvas, isParentNodeType } from "@/utils/nodeUtils"
34

45
const ALIGNMENT_THRESHOLD = 10 // pixels within which guides appear
56

@@ -15,9 +16,12 @@ export type AlignmentInfo = {
1516
/**
1617
* Get the bounds of a node (considering its position and dimensions)
1718
*/
18-
export const getNodeBounds = (node: Node) => {
19-
const x = node.position.x
20-
const y = node.position.y
19+
export const getNodeBounds = (node: Node, allNodes?: Node[]) => {
20+
const position = allNodes
21+
? getPositionOnCanvas(node, allNodes)
22+
: node.position
23+
const x = position.x
24+
const y = position.y
2125
const width = node.measured?.width || 100
2226
const height = node.measured?.height || 100
2327

@@ -31,6 +35,78 @@ export const getNodeBounds = (node: Node) => {
3135
}
3236
}
3337

38+
const getContainingParentId = (
39+
node: Node,
40+
allNodes: Node[],
41+
excludeParentId?: string
42+
) => {
43+
if (node.parentId) {
44+
return node.parentId
45+
}
46+
47+
const nodeBounds = getNodeBounds(node, allNodes)
48+
let bestParent: Node | undefined
49+
let bestArea = Infinity
50+
51+
for (const candidate of allNodes) {
52+
if (
53+
candidate.id === node.id ||
54+
candidate.id === excludeParentId ||
55+
!isParentNodeType(candidate.type)
56+
) {
57+
continue
58+
}
59+
60+
const parentBounds = getNodeBounds(candidate, allNodes)
61+
const contains =
62+
nodeBounds.left >= parentBounds.left &&
63+
nodeBounds.right <= parentBounds.right &&
64+
nodeBounds.top >= parentBounds.top &&
65+
nodeBounds.bottom <= parentBounds.bottom
66+
67+
if (!contains) {
68+
continue
69+
}
70+
71+
const area =
72+
(parentBounds.right - parentBounds.left) *
73+
(parentBounds.bottom - parentBounds.top)
74+
if (area < bestArea) {
75+
bestArea = area
76+
bestParent = candidate
77+
}
78+
}
79+
80+
return bestParent?.id
81+
}
82+
83+
const shouldUseAsGuideTarget = (
84+
draggedNode: Node,
85+
node: Node,
86+
allNodes: Node[],
87+
draggedParentId: string | undefined
88+
) => {
89+
const nodeParentId = getContainingParentId(node, allNodes, draggedNode.id)
90+
91+
if (!draggedParentId) {
92+
// Dragged node is top-level: align with everything except nodes that
93+
// are children of a parent the dragged node has no relation to...
94+
// actually align with all nodes including parent frames
95+
return true
96+
}
97+
98+
// Dragged node is a child: skip the parent container it belongs to
99+
// (no point snapping to your own frame), but allow all other nodes
100+
if (node.id === draggedParentId) {
101+
return false
102+
}
103+
104+
const isSibling = nodeParentId === draggedParentId
105+
const isTopLevel = !nodeParentId
106+
107+
return isSibling || isTopLevel
108+
}
109+
34110
/**
35111
* Calculate alignment guides based on dragged node and other nodes
36112
*/
@@ -39,14 +115,22 @@ export const calculateAlignmentGuides = (
39115
allNodes: Node[],
40116
threshold: number = ALIGNMENT_THRESHOLD
41117
): AlignmentGuide[] => {
42-
const draggedBounds = getNodeBounds(draggedNode)
118+
const nodesWithDrag = allNodes.map((node) =>
119+
node.id === draggedNode.id ? draggedNode : node
120+
)
121+
const draggedParentId = draggedNode.parentId
122+
const draggedBounds = getNodeBounds(draggedNode, nodesWithDrag)
43123
const guides: AlignmentGuide[] = []
44124
const alignedPositions = new Set<number>()
45125

46-
const otherNodes = allNodes.filter((n) => n.id !== draggedNode.id)
126+
const otherNodes = nodesWithDrag.filter(
127+
(node) =>
128+
node.id !== draggedNode.id &&
129+
shouldUseAsGuideTarget(draggedNode, node, nodesWithDrag, draggedParentId)
130+
)
47131

48132
for (const node of otherNodes) {
49-
const nodeBounds = getNodeBounds(node)
133+
const nodeBounds = getNodeBounds(node, nodesWithDrag)
50134

51135
// Vertical alignment (left, center, right edges)
52136
const verticalAlignments = [
@@ -139,48 +223,52 @@ export const calculateAlignmentGuides = (
139223
export const snapNodeToGuides = (
140224
draggedNode: Node,
141225
guides: AlignmentGuide[],
142-
threshold: number = ALIGNMENT_THRESHOLD
226+
threshold: number = ALIGNMENT_THRESHOLD,
227+
allNodes?: Node[]
143228
): { x?: number; y?: number } => {
144-
const draggedBounds = getNodeBounds(draggedNode)
229+
const draggedBounds = getNodeBounds(draggedNode, allNodes)
230+
const draggedPosition = allNodes
231+
? getPositionOnCanvas(draggedNode, allNodes)
232+
: draggedNode.position
145233
const snappedPosition: { x?: number; y?: number } = {}
146234

147235
for (const guide of guides) {
148236
if (guide.type === "vertical") {
149237
// Snap left edge
150238
if (Math.abs(draggedBounds.left - guide.position) < threshold) {
151-
snappedPosition.x = guide.position - draggedNode.position.x
239+
snappedPosition.x = guide.position - draggedPosition.x
152240
}
153241
// Snap center
154242
if (Math.abs(draggedBounds.centerX - guide.position) < threshold) {
155243
snappedPosition.x =
156244
guide.position -
157-
draggedNode.position.x -
245+
draggedPosition.x -
158246
(draggedNode.measured?.width || 100) / 2
159247
}
160248
// Snap right edge
161249
if (Math.abs(draggedBounds.right - guide.position) < threshold) {
162250
snappedPosition.x =
163251
guide.position -
164-
draggedNode.position.x -
252+
draggedPosition.x -
165253
(draggedNode.measured?.width || 100)
166254
}
167255
} else if (guide.type === "horizontal") {
168256
// Snap top edge
169257
if (Math.abs(draggedBounds.top - guide.position) < threshold) {
170-
snappedPosition.y = guide.position - draggedNode.position.y
258+
snappedPosition.y = guide.position - draggedPosition.y
171259
}
172260
// Snap center
173261
if (Math.abs(draggedBounds.centerY - guide.position) < threshold) {
174262
snappedPosition.y =
175263
guide.position -
176-
draggedNode.position.y -
264+
draggedPosition.y -
177265
(draggedNode.measured?.height || 100) / 2
178266
}
179267
// Snap bottom edge
180268
if (Math.abs(draggedBounds.bottom - guide.position) < threshold) {
181269
snappedPosition.y =
182270
guide.position -
183-
draggedNode.position.y -
271+
draggedPosition.y -
184272
(draggedNode.measured?.height || 100)
185273
}
186274
}

library/tests/unit/alignmentUtils.test.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,17 @@ function makeNode(
1515
x: number,
1616
y: number,
1717
width?: number,
18-
height?: number
18+
height?: number,
19+
overrides: Partial<Node> = {}
1920
): Node {
21+
const measured =
22+
width !== undefined ? { width, height: height ?? width } : undefined
2023
return {
2124
id,
2225
position: { x, y },
2326
data: {},
24-
measured:
25-
width !== undefined ? { width, height: height ?? width } : undefined,
27+
...overrides,
28+
measured: overrides.measured ?? measured,
2629
} as Node
2730
}
2831

@@ -142,6 +145,62 @@ describe("calculateAlignmentGuides", () => {
142145
const verticals = guides.filter((g) => g.type === "vertical")
143146
expect(verticals.some((g) => g.position === 100)).toBe(true)
144147
})
148+
149+
it("targets siblings but skips its parent when dragging a child", () => {
150+
const parent = makeNode("p", 0, 0, 400, 300, { type: "package" })
151+
const dragged = makeNode("c1", 0, 50, 100, 80, {
152+
parentId: "p",
153+
type: "class",
154+
})
155+
const sibling = makeNode("c2", 200, 50, 100, 80, {
156+
parentId: "p",
157+
type: "class",
158+
})
159+
160+
const guides = calculateAlignmentGuides(dragged, [parent, dragged, sibling])
161+
expect(guides.some((g) => g.type === "vertical" && g.position === 0)).toBe(
162+
false
163+
)
164+
expect(
165+
guides.some((g) => g.type === "horizontal" && g.position === 50)
166+
).toBe(true)
167+
})
168+
169+
it("includes contained nodes when dragging a top-level node", () => {
170+
const pkg = makeNode("p", 200, 0, 200, 200, { type: "package" })
171+
const contained = makeNode("c", 220, 30, 80, 60, { type: "class" })
172+
const dragged = makeNode("d", 220, 30, 100, 50, { type: "class" })
173+
174+
const guides = calculateAlignmentGuides(dragged, [dragged, pkg, contained])
175+
expect(
176+
guides.some((g) => g.type === "horizontal" && g.position === 30)
177+
).toBe(true)
178+
expect(
179+
guides.some((g) => g.type === "vertical" && g.position === 220)
180+
).toBe(true)
181+
})
182+
183+
it("targets top-level and contained nodes when dragging a package", () => {
184+
const dragged = makeNode("p1", 300, 40, 200, 200, { type: "package" })
185+
const otherPackage = makeNode("p2", 300, 0, 200, 200, { type: "package" })
186+
const contained = makeNode("c", 305, 40, 80, 60, { type: "class" })
187+
188+
const guides = calculateAlignmentGuides(dragged, [
189+
dragged,
190+
otherPackage,
191+
contained,
192+
])
193+
194+
expect(
195+
guides.some((g) => g.type === "vertical" && g.position === 300)
196+
).toBe(true)
197+
expect(
198+
guides.some((g) => g.type === "vertical" && g.position === 305)
199+
).toBe(true)
200+
expect(
201+
guides.some((g) => g.type === "horizontal" && g.position === 40)
202+
).toBe(true)
203+
})
145204
})
146205

147206
// ---------------------------------------------------------------------------
24.5 KB
Loading
10.3 KB
Loading

0 commit comments

Comments
 (0)