Skip to content

Commit cab0705

Browse files
fix(library): keep corner resize handles on content-sized nodes
Follow-up on the resize-cursor fix: keep the four corner handles on a node with a pinned axis instead of dropping them. React Flow's corners resize both axes, so on a content-sized node they showed a diagonal cursor for a resize that can only go one way. Constrain each corner to the free axis with React Flow's `resizeDirection` and relabel its cursor to that axis, so the familiar corner handles stay, still resize the axis that can change, and never point a direction the drag won't go. Removes the widened line hit-area (the corners are the grab target again) and restores the class-based fixture in the affordance e2e. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef9607a commit cab0705

6 files changed

Lines changed: 106 additions & 82 deletions

File tree

.changeset/fix-resize-cursor-locked-axis.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@tumaet/apollon": patch
33
---
44

5-
Nodes no longer offer a resize cursor on borders that can't resize. A class's height is driven by its attributes and methods, so its top and bottom borders showed a resize cursor and accepted a drag that did nothing. Content-sized nodes — classes, object and communication-object nodes, SFC action tables and the activity fork bars — now show resize controls only on the borders that can move: drag those side borders, which are easier to grab than before, rather than a corner.
5+
Nodes no longer show a resize cursor on borders that can't resize. A class's height is driven by its attributes and methods, so its top and bottom borders showed a vertical-resize cursor and accepted a drag that did nothing. Content-sized nodes — classes, object and communication-object nodes, SFC action tables and the activity fork bars — now resize only along the axis that can change: the corner handles stay, but they and the cursor only ever point that way.

library/lib/nodes/wrappers/NodeResizer.tsx

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import {
1313
// React Flow's corner handles default to 5x5; every Apollon node wants 8x8.
1414
const HANDLE_STYLE = { width: 8, height: 8 }
1515

16+
const CORNERS = [
17+
"top-left",
18+
"top-right",
19+
"bottom-left",
20+
"bottom-right",
21+
] as const
22+
1623
// A node pins a content-sized dimension by bounding it from both sides — a
1724
// Class's height is driven by its attribute/method rows. Bounds that cross leave
1825
// no range to drag either, so they count as pinned; a side left unbounded never
@@ -22,14 +29,17 @@ const isAxisLocked = (min?: number, max?: number): boolean =>
2229

2330
/**
2431
* React Flow's `<NodeResizer>` always renders four edge lines and four corner
25-
* handles, so a pinned axis still shows a resize cursor and accepts a drag that
26-
* does nothing. Rendering only the controls that can move is the fix; the cursor
27-
* is a signifier, and repainting it would leave the dead control behind.
32+
* handles, and its stylesheet paints each with a resize cursor. On a node with a
33+
* pinned axis, the two edge lines of that axis promise a resize that can't
34+
* happen (issue #629), and every corner shows a diagonal cursor even though only
35+
* one axis can move.
2836
*
29-
* `resizeDirection` is not an alternative: it sits on `NodeResizeControl`, and
30-
* `ResizeControlLineProps` omits it outright. It constrains a corner's drag
31-
* while leaving the diagonal cursor that lies about it, so corners go rather
32-
* than get constrained.
37+
* So on a pinned axis this drops that axis's two edge lines and keeps the
38+
* corners — the familiar, chunky grab target — but constrains each corner to the
39+
* free axis with `resizeDirection` and relabels its cursor (`apollon-resize-
40+
* corner--*` in app.css) to that axis. The result: a content-sized node still
41+
* looks and works resizable on the axis it can change, and no cursor anywhere
42+
* points a direction the drag won't go.
3343
*/
3444
export function NodeResizer(props: NodeResizerProps) {
3545
const {
@@ -38,16 +48,11 @@ export function NodeResizer(props: NodeResizerProps) {
3848
minHeight,
3949
maxWidth,
4050
maxHeight,
41-
nodeId,
42-
color,
43-
keepAspectRatio,
44-
autoScale,
45-
shouldResize,
46-
onResizeStart,
47-
onResize,
48-
onResizeEnd,
51+
handleStyle,
52+
handleClassName: _handleClassName,
4953
lineStyle,
5054
lineClassName,
55+
...resizeParams
5156
} = props
5257

5358
// `isVisible` exists only on NodeResizerProps — NodeResizeControl has no such
@@ -61,39 +66,40 @@ export function NodeResizer(props: NodeResizerProps) {
6166
return <ReactFlowNodeResizer handleStyle={HANDLE_STYLE} {...props} />
6267
}
6368

64-
// Without this, a fully pinned node reads as height-locked below and gets the
65-
// side controls it equally can't honour.
69+
// Both axes pinned: nothing to resize.
6670
if (widthLocked && heightLocked) return null
6771

68-
const positions = heightLocked
72+
const shared = { minWidth, minHeight, maxWidth, maxHeight, ...resizeParams }
73+
const lines = heightLocked
6974
? (["left", "right"] as const)
7075
: (["top", "bottom"] as const)
76+
// The axis that can still move, for both React Flow's constraint and the cursor.
77+
const freeAxis = heightLocked ? "horizontal" : "vertical"
78+
const cornerClass = heightLocked
79+
? "apollon-resize-corner--x"
80+
: "apollon-resize-corner--y"
7181

72-
// `keepAspectRatio` is the one prop that cannot mean anything here — it would
73-
// ask the pinned axis to follow the free one. No node passes it.
7482
return (
7583
<>
76-
{positions.map((position) => (
84+
{lines.map((position) => (
7785
<NodeResizeControl
7886
key={position}
7987
position={position}
8088
variant={ResizeControlVariant.Line}
81-
nodeId={nodeId}
82-
color={color}
83-
minWidth={minWidth}
84-
minHeight={minHeight}
85-
maxWidth={maxWidth}
86-
maxHeight={maxHeight}
87-
keepAspectRatio={keepAspectRatio}
88-
autoScale={autoScale}
89-
shouldResize={shouldResize}
90-
onResizeStart={onResizeStart}
91-
onResize={onResize}
92-
onResizeEnd={onResizeEnd}
9389
style={lineStyle}
94-
className={["apollon-resize-line", lineClassName]
95-
.filter(Boolean)
96-
.join(" ")}
90+
className={lineClassName}
91+
{...shared}
92+
/>
93+
))}
94+
{CORNERS.map((position) => (
95+
<NodeResizeControl
96+
key={position}
97+
position={position}
98+
variant={ResizeControlVariant.Handle}
99+
resizeDirection={freeAxis}
100+
className={cornerClass}
101+
style={{ ...HANDLE_STYLE, ...handleStyle }}
102+
{...shared}
97103
/>
98104
))}
99105
</>

library/lib/styles/app.css

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -705,17 +705,18 @@
705705
z-index: 20;
706706
}
707707

708-
/* React Flow draws its resize lines 1px wide. A node with a pinned axis has no
709-
corner handles to fall back on (they promise a diagonal resize it can't
710-
honour, so NodeResizer omits them), which would leave its one free axis the
711-
hardest thing to grab in the editor. Widen the hit area without moving the
712-
visible 1px border. Kept small because the target straddles the border, so it
713-
also overhangs whatever the node sits on; the hover gate above is what keeps
714-
that overhang from swallowing the neighbour's clicks. */
715-
.apollon-resize-line::before {
716-
content: "";
717-
position: absolute;
718-
inset: -3px;
708+
/* A content-sized node keeps its corner handles (NodeResizer constrains each to
709+
the node's free axis with `resizeDirection`), but React Flow hard-codes a
710+
diagonal nwse/nesw cursor on every corner. Relabel it to the free axis's
711+
cursor so the pointer tells the truth about which way a drag will go. The
712+
fourth class (`nodrag`, present on every control) outranks React Flow's
713+
three-class corner rules regardless of stylesheet order. */
714+
.react-flow__resize-control.nodrag.handle.apollon-resize-corner--x {
715+
cursor: ew-resize;
716+
}
717+
718+
.react-flow__resize-control.nodrag.handle.apollon-resize-corner--y {
719+
cursor: ns-resize;
719720
}
720721

721722
.react-flow__resize-control {

library/tests/unit/NodeResizer.test.tsx

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,18 @@ const renderResizer = (props: Parameters<typeof NodeResizer>[0]) =>
1515
// no keyboard path — so a position/variant class is the only handle on them, and
1616
// it is the same published class React Flow's own stylesheet keys the resize
1717
// cursor off. (It also means dropping a control costs assistive tech nothing:
18-
// there was never anything there to reach.)
18+
// there was never anything there to reach.) `apollon-*` marker classes are
19+
// dropped here so the set reads as pure geometry; a dedicated test covers them.
1920
const controlClasses = (container: HTMLElement): string[] =>
2021
[...container.querySelectorAll(".react-flow__resize-control")]
2122
.map((el) =>
2223
[...el.classList]
23-
.filter((c) => c !== "react-flow__resize-control" && c !== "nodrag")
24+
.filter(
25+
(c) =>
26+
c !== "react-flow__resize-control" &&
27+
c !== "nodrag" &&
28+
!c.startsWith("apollon-")
29+
)
2430
.join(".")
2531
)
2632
.sort()
@@ -51,32 +57,58 @@ describe("<NodeResizer>", () => {
5157
)
5258
})
5359

54-
// `apollon-resize-line` widens the hit area: with no corner handles these
55-
// lines are the only grab target, and React Flow draws them 1px wide.
56-
it("renders only the left/right lines when height is locked (issue #629)", () => {
60+
// Height pinned: keep the corners, drop the top/bottom lines that promise a
61+
// vertical resize (issue #629).
62+
it("keeps corners but only the side lines when height is locked", () => {
5763
const { container } = renderResizer({
5864
minWidth: 100,
5965
minHeight: 60,
6066
maxHeight: 60,
6167
})
6268

6369
expect(controlClasses(container)).toEqual([
64-
"left.line.apollon-resize-line",
65-
"right.line.apollon-resize-line",
70+
"bottom.left.handle",
71+
"bottom.right.handle",
72+
"left.line",
73+
"right.line",
74+
"top.left.handle",
75+
"top.right.handle",
6676
])
6777
})
6878

69-
it("renders only the top/bottom lines when width is locked", () => {
79+
it("labels a locked node's corners with the free axis's cursor", () => {
80+
const { container } = renderResizer({
81+
minWidth: 100,
82+
minHeight: 60,
83+
maxHeight: 60,
84+
})
85+
86+
// Only width can move, so the corners get the horizontal-cursor marker (and
87+
// never the vertical one).
88+
expect(
89+
container.querySelectorAll(".apollon-resize-corner--x")
90+
).toHaveLength(4)
91+
expect(container.querySelector(".apollon-resize-corner--y")).toBeNull()
92+
})
93+
94+
it("keeps corners but only the top/bottom lines when width is locked", () => {
7095
const { container } = renderResizer({
7196
minWidth: 20,
7297
maxWidth: 20,
7398
minHeight: 40,
7499
})
75100

76101
expect(controlClasses(container)).toEqual([
77-
"bottom.line.apollon-resize-line",
78-
"top.line.apollon-resize-line",
102+
"bottom.left.handle",
103+
"bottom.line",
104+
"bottom.right.handle",
105+
"top.left.handle",
106+
"top.line",
107+
"top.right.handle",
79108
])
109+
expect(
110+
container.querySelectorAll(".apollon-resize-corner--y")
111+
).toHaveLength(4)
80112
})
81113

82114
it("renders nothing when both axes are locked", () => {

standalone/webapp/tests/e2e/node-affordance-click-through.spec.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,6 @@ const OVERLAP_MODEL = {
3939
],
4040
}
4141

42-
// Same overlap, but packages: a class's height is content-driven, so it renders
43-
// no corner resize handles at all. Only a node that is resizable on both axes
44-
// has a corner that can overhang onto its neighbour.
45-
const OVERLAP_MODEL_RESIZABLE = {
46-
...OVERLAP_MODEL,
47-
id: "e2e-affordance-blocking-resizable",
48-
nodes: OVERLAP_MODEL.nodes.map((n) => ({
49-
...n,
50-
type: "package",
51-
data: { name: n.data.name },
52-
})),
53-
}
54-
5542
const CONNECT_MODEL = {
5643
id: "e2e-affordance-connect",
5744
type: "ClassDiagram",
@@ -119,10 +106,7 @@ test("a selected node's connection handles don't block an overlapping node", asy
119106
test("a selected node's resize handle doesn't block an overlapping node", async ({
120107
page,
121108
}) => {
122-
await openFixtureInLocalEditor(
123-
page,
124-
OVERLAP_MODEL_RESIZABLE as Record<string, unknown>
125-
)
109+
await openFixtureInLocalEditor(page, OVERLAP_MODEL as Record<string, unknown>)
126110
await waitForCanvasReady(page)
127111

128112
const alpha = node(page, "Alpha")

standalone/webapp/tests/e2e/resize-cursor-locked-axis.spec.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,19 @@ const resizeCursorsIn = (page: Page, name: string) =>
5757
.filter((cursor) => cursor.includes("resize"))
5858
}, name)
5959

60-
test("a class exposes no resize cursor, because its height is content-sized", async ({
60+
test("a class only ever exposes a horizontal resize cursor", async ({
6161
page,
6262
}) => {
6363
await openFixtureInLocalEditor(page, MODEL as Record<string, unknown>)
6464
await waitForCanvasReady(page)
6565

66-
// Only width can change, so `ew-resize` on the side borders is honest; any
67-
// `ns-resize` is the false affordance from issue #629.
68-
expect(await resizeCursorsIn(page, "Locked")).toEqual([
69-
"ew-resize",
70-
"ew-resize",
71-
])
66+
const cursors = await resizeCursorsIn(page, "Locked")
67+
68+
// Its height is content-sized, so only width can change: the side lines and
69+
// the four corners all read `ew-resize`. A vertical (`ns-`) or diagonal
70+
// (`nwse-`/`nesw-`) cursor anywhere is the false affordance from issue #629.
71+
expect(cursors).toContain("ew-resize")
72+
expect(cursors.every((cursor) => cursor === "ew-resize")).toBe(true)
7273
})
7374

7475
test("a package still exposes resize cursors on every border", async ({

0 commit comments

Comments
 (0)