Skip to content

Commit 29afd3b

Browse files
Merge pull request #501 from ryujunghy3on/main
Improve corner radius handle UX and positioning
2 parents cae9021 + 5ded81e commit 29afd3b

File tree

6 files changed

+227
-31
lines changed

6 files changed

+227
-31
lines changed

editor/grida-canvas-react/viewport/ui/corner-radius-handle.tsx

Lines changed: 189 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React from "react";
1+
import React, { useMemo } from "react";
22
import { useGesture } from "@use-gesture/react";
33
import { useCurrentEditor } from "../../use-editor";
4-
import { useNode } from "../../provider";
5-
import type cmath from "@grida/cmath";
4+
import { useNode, useGestureState, useTransformState } from "../../provider";
5+
import cmath from "@grida/cmath";
66

77
export function NodeOverlayCornerRadiusHandle({
88
node_id,
@@ -16,34 +16,202 @@ export function NodeOverlayCornerRadiusHandle({
1616
size?: number;
1717
}) {
1818
const editor = useCurrentEditor();
19+
const { gesture } = useGestureState();
20+
const { transform } = useTransformState();
1921

2022
const bind = useGesture({
2123
onDragStart: ({ event }) => {
2224
event.preventDefault();
23-
editor.surface.surfaceStartCornerRadiusGesture(node_id, anchor);
25+
const altKey = (event as PointerEvent).altKey || false;
26+
editor.surface.surfaceStartCornerRadiusGesture(node_id, anchor, altKey);
2427
},
2528
});
2629

2730
const node = useNode(node_id);
28-
const radii = typeof node.corner_radius === "number" ? node.corner_radius : 0;
29-
const minmargin = Math.max(radii + size, margin);
31+
32+
// Get current radius value for this corner
33+
const currentRadius = useMemo(() => {
34+
if (
35+
node.type === "rectangle" ||
36+
node.type === "container" ||
37+
node.type === "component" ||
38+
node.type === "image" ||
39+
node.type === "video"
40+
) {
41+
const keyMap = {
42+
nw: "rectangular_corner_radius_top_left",
43+
ne: "rectangular_corner_radius_top_right",
44+
se: "rectangular_corner_radius_bottom_right",
45+
sw: "rectangular_corner_radius_bottom_left",
46+
} as const;
47+
return (node as any)[keyMap[anchor]] ?? 0;
48+
}
49+
return typeof node.corner_radius === "number" ? node.corner_radius : 0;
50+
}, [node, anchor]);
51+
52+
// Mathematical constants: computed once per size change
53+
const labelOffsets = useMemo(() => ({
54+
X: size / 2 + 4,
55+
Y_TOP: size / 2 + 4,
56+
Y_BOTTOM: size / 2 + 20,
57+
}), [size]);
58+
59+
// Shared geometry calculations: compute once, use multiple times
60+
const geometry = useMemo(() => {
61+
const br = editor.geometryProvider.getNodeAbsoluteBoundingRect(node_id);
62+
if (!br) return null;
63+
64+
const boundingSurfaceRect = cmath.rect.transform(br, transform);
65+
const [scaleX, scaleY] = cmath.transform.getScale(transform);
66+
const w = boundingSurfaceRect.width;
67+
const h = boundingSurfaceRect.height;
68+
const minmargin = Math.max(currentRadius + size, margin);
69+
const useMarginBased = currentRadius < margin;
70+
71+
// Corner coordinates: C = (C_x, C_y)
72+
const corners = {
73+
nw: [0, 0],
74+
ne: [w, 0],
75+
se: [w, h],
76+
sw: [0, h],
77+
} as const;
78+
const [C_x, C_y] = corners[anchor];
79+
80+
// Arc center offset: O = (O_x, O_y) = (r * s_x * sign_x, r * s_y * sign_y)
81+
const offsets = {
82+
nw: [currentRadius * scaleX, currentRadius * scaleY],
83+
ne: [-currentRadius * scaleX, currentRadius * scaleY],
84+
se: [-currentRadius * scaleX, -currentRadius * scaleY],
85+
sw: [currentRadius * scaleX, -currentRadius * scaleY],
86+
} as const;
87+
const [O_x, O_y] = offsets[anchor];
88+
89+
// Center coordinates: M = (M_x, M_y) = (w/2, h/2)
90+
const M_x = w / 2;
91+
const M_y = h / 2;
92+
93+
// Handle position relative to center: H = (H_x, H_y) = (C + O - M)
94+
const H_x = C_x + O_x - M_x;
95+
const H_y = C_y + O_y - M_y;
96+
97+
return {
98+
w,
99+
h,
100+
scaleX,
101+
scaleY,
102+
minmargin,
103+
useMarginBased,
104+
H_x,
105+
H_y,
106+
M_x,
107+
M_y,
108+
};
109+
}, [editor.geometryProvider, node_id, anchor, currentRadius, transform, size, margin]);
110+
111+
// Calculate handle position: at arc center O when radius >= margin, otherwise at corner with margin
112+
const handleStyle = useMemo(() => {
113+
if (!geometry) return null;
114+
115+
const { useMarginBased, H_x, H_y, minmargin } = geometry;
116+
117+
if (!useMarginBased && currentRadius > 0) {
118+
// Handle at arc center: H = (C + O - M) relative to center
119+
return {
120+
left: `calc(50% + ${H_x}px)`,
121+
top: `calc(50% + ${H_y}px)`,
122+
transform: "translate(-50%, -50%)",
123+
};
124+
}
125+
126+
// Handle at corner with margin: position = minmargin from edge
127+
const positions = {
128+
nw: { top: `${minmargin}px`, left: `${minmargin}px` },
129+
ne: { top: `${minmargin}px`, right: `${minmargin}px` },
130+
se: { bottom: `${minmargin}px`, right: `${minmargin}px` },
131+
sw: { bottom: `${minmargin}px`, left: `${minmargin}px` },
132+
} as const;
133+
134+
return {
135+
...positions[anchor],
136+
transform: `translate(${anchor[1] === "w" ? "-50%" : "50%"}, ${anchor[0] === "n" ? "-50%" : "50%"})`,
137+
};
138+
}, [geometry, anchor, currentRadius]);
139+
140+
// Only show label for the specific handle being dragged
141+
const isDragging =
142+
gesture.type === "corner-radius" &&
143+
gesture.node_id === node_id &&
144+
gesture.anchor === anchor;
145+
146+
// Label position relative to handle (inside direction, toward center)
147+
const labelStyle = useMemo(() => {
148+
if (!isDragging || !geometry) return null;
149+
150+
const { useMarginBased, H_x, H_y, M_x, minmargin } = geometry;
151+
152+
if (!useMarginBased && currentRadius > 0) {
153+
// Label offset from handle: L_offset = (L_x, L_y) in inside direction
154+
const labelOffsetMap = {
155+
nw: [labelOffsets.X, labelOffsets.Y_TOP],
156+
ne: [-labelOffsets.X, labelOffsets.Y_TOP],
157+
se: [-labelOffsets.X, -labelOffsets.Y_BOTTOM],
158+
sw: [labelOffsets.X, -labelOffsets.Y_BOTTOM],
159+
} as const;
160+
const [L_x, L_y] = labelOffsetMap[anchor];
161+
162+
// Label position relative to center: L = H + L_offset
163+
const L_x_center = H_x + L_x;
164+
const L_y_center = H_y + L_y;
165+
166+
// For right-side corners (ne, se), use 'right' instead of 'left' to maintain consistency
167+
if (anchor === "ne" || anchor === "se") {
168+
// Convert from center-relative to right-edge distance: right = M_x - L_x_center
169+
return {
170+
right: `${M_x - L_x_center}px`,
171+
top: `calc(50% + ${L_y_center}px)`,
172+
};
173+
}
174+
175+
return {
176+
left: `calc(50% + ${L_x_center}px)`,
177+
top: `calc(50% + ${L_y_center}px)`,
178+
};
179+
}
180+
181+
// Margin-based: label inside direction from handle
182+
const labelPositions = {
183+
nw: { top: `${minmargin + labelOffsets.Y_TOP}px`, left: `${minmargin + labelOffsets.X}px` },
184+
ne: { top: `${minmargin + labelOffsets.Y_TOP}px`, right: `${minmargin + labelOffsets.X}px` },
185+
se: { bottom: `${minmargin + labelOffsets.X}px`, right: `${minmargin + labelOffsets.X}px` },
186+
sw: { bottom: `${minmargin + labelOffsets.X}px`, left: `${minmargin + labelOffsets.X}px` },
187+
} as const;
188+
189+
return labelPositions[anchor];
190+
}, [isDragging, geometry, anchor, currentRadius, labelOffsets]);
191+
192+
if (!handleStyle) return null;
30193

31194
return (
32-
<div
33-
{...bind()}
34-
className="hidden group-hover:block border rounded-full bg-white border-workbench-accent-sky absolute z-10 pointer-events-auto"
35-
style={{
36-
top: anchor[0] === "n" ? minmargin : "auto",
37-
bottom: anchor[0] === "s" ? minmargin : "auto",
38-
left: anchor[1] === "w" ? minmargin : "auto",
39-
right: anchor[1] === "e" ? minmargin : "auto",
40-
width: size,
41-
height: size,
42-
transform: `translate(${anchor[1] === "w" ? "-50%" : "50%"}, ${anchor[0] === "n" ? "-50%" : "50%"})`,
43-
cursor: "pointer",
44-
touchAction: "none",
45-
}}
46-
/>
195+
<>
196+
<div
197+
{...bind()}
198+
className="hidden group-hover:block border rounded-full bg-white border-workbench-accent-sky absolute z-10 pointer-events-auto"
199+
style={{
200+
...handleStyle,
201+
width: size,
202+
height: size,
203+
cursor: "pointer",
204+
touchAction: "none",
205+
}}
206+
/>
207+
{isDragging && labelStyle && (
208+
<div className="absolute pointer-events-none z-20" style={labelStyle}>
209+
<div className="bg-workbench-accent-sky text-white text-xs px-1.5 py-0.5 rounded-sm shadow whitespace-nowrap">
210+
Radius {currentRadius}
211+
</div>
212+
</div>
213+
)}
214+
</>
47215
);
48216
}
49217

editor/grida-canvas/action.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,7 @@ export type EditorSurface_StartGesture = {
931931
selection: string | string[];
932932
})
933933
| Pick<editor.gesture.GesturePadding, "type" | "node_id" | "side">
934-
| Pick<editor.gesture.GestureCornerRadius, "type" | "node_id" | "anchor">
934+
| Pick<editor.gesture.GestureCornerRadius, "type" | "node_id" | "anchor" | "altKey">
935935
| Pick<
936936
editor.gesture.GestureCurve,
937937
"type" | "control" | "node_id" | "segment"

editor/grida-canvas/editor.i.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2011,6 +2011,7 @@ export namespace editor.gesture {
20112011
readonly type: "corner-radius";
20122012
readonly node_id: string;
20132013
readonly anchor?: cmath.IntercardinalDirection;
2014+
readonly altKey?: boolean;
20142015
readonly initial_bounding_rectangle: cmath.Rectangle | null;
20152016
};
20162017

@@ -4110,7 +4111,8 @@ export namespace editor.api {
41104111
): void;
41114112
surfaceStartCornerRadiusGesture(
41124113
selection: string,
4113-
anchor?: cmath.IntercardinalDirection
4114+
anchor?: cmath.IntercardinalDirection,
4115+
altKey?: boolean
41144116
): void;
41154117
surfaceStartRotateGesture(selection: string): void;
41164118
surfaceStartTranslateVectorNetwork(node_id: string): void;

editor/grida-canvas/editor.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4687,14 +4687,16 @@ export class EditorSurface
46874687
// #region drag resize handle
46884688
surfaceStartCornerRadiusGesture(
46894689
selection: string,
4690-
anchor?: cmath.IntercardinalDirection
4690+
anchor?: cmath.IntercardinalDirection,
4691+
altKey?: boolean
46914692
) {
46924693
this._editor.doc.dispatch({
46934694
type: "surface/gesture/start",
46944695
gesture: {
46954696
type: "corner-radius",
46964697
node_id: selection,
46974698
anchor,
4699+
altKey: altKey ?? false,
46984700
},
46994701
});
47004702
}

editor/grida-canvas/reducers/event-target.reducer.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,7 @@ function __self_evt_on_drag(
732732
break;
733733
}
734734
case "corner-radius": {
735-
const { node_id, anchor } = draft.gesture;
735+
const { node_id, anchor, altKey = false } = draft.gesture;
736736
const [dx, dy] = delta;
737737
const node = dq.__getNodeById(draft, node_id);
738738

@@ -791,15 +791,38 @@ function __self_evt_on_drag(
791791

792792
const key = keyMap[anchor];
793793
const current = (node as any)[key] ?? 0;
794+
795+
// Check if all corners have the same value
796+
const tl = node.rectangular_corner_radius_top_left ?? 0;
797+
const tr = node.rectangular_corner_radius_top_right ?? 0;
798+
const br = node.rectangular_corner_radius_bottom_right ?? 0;
799+
const bl = node.rectangular_corner_radius_bottom_left ?? 0;
800+
const isUniform = tl === tr && tr === br && br === bl;
801+
794802
const nextRadius = current + d;
795803
const nextRadiusClamped = Math.floor(
796804
Math.min(maxRadius, Math.max(0, nextRadius))
797805
);
798-
draft.document.nodes[node_id] = nodeReducer(node, {
799-
type: "node/change/*",
800-
[key]: nextRadiusClamped,
801-
node_id,
802-
});
806+
807+
// If Alt key is not pressed and all corners are uniform, adjust all corners
808+
// Otherwise, adjust only the clicked corner
809+
if (!altKey && isUniform) {
810+
draft.document.nodes[node_id] = nodeReducer(node, {
811+
type: "node/change/*",
812+
corner_radius: nextRadiusClamped,
813+
rectangular_corner_radius_top_left: nextRadiusClamped,
814+
rectangular_corner_radius_top_right: nextRadiusClamped,
815+
rectangular_corner_radius_bottom_right: nextRadiusClamped,
816+
rectangular_corner_radius_bottom_left: nextRadiusClamped,
817+
node_id,
818+
});
819+
} else {
820+
draft.document.nodes[node_id] = nodeReducer(node, {
821+
type: "node/change/*",
822+
[key]: nextRadiusClamped,
823+
node_id,
824+
});
825+
}
803826
} else {
804827
const current =
805828
typeof node.corner_radius == "number" ? node.corner_radius : 0;

editor/grida-canvas/reducers/surface.reducer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ function __self_start_gesture(
538538
break;
539539
}
540540
case "corner-radius": {
541-
const { node_id, anchor } = gesture;
541+
const { node_id, anchor, altKey } = gesture;
542542

543543
self_selectNode(draft, "reset", node_id);
544544
draft.gesture = {
@@ -550,6 +550,7 @@ function __self_start_gesture(
550550
context.geometry.getNodeAbsoluteBoundingRect(node_id)!,
551551
node_id: node_id,
552552
anchor,
553+
altKey: altKey ?? false,
553554
};
554555
break;
555556
}

0 commit comments

Comments
 (0)