Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 189 additions & 21 deletions editor/grida-canvas-react/viewport/ui/corner-radius-handle.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react";
import React, { useMemo } from "react";
import { useGesture } from "@use-gesture/react";
import { useCurrentEditor } from "../../use-editor";
import { useNode } from "../../provider";
import type cmath from "@grida/cmath";
import { useNode, useGestureState, useTransformState } from "../../provider";
import cmath from "@grida/cmath";

export function NodeOverlayCornerRadiusHandle({
node_id,
Expand All @@ -16,34 +16,202 @@ export function NodeOverlayCornerRadiusHandle({
size?: number;
}) {
const editor = useCurrentEditor();
const { gesture } = useGestureState();
const { transform } = useTransformState();

const bind = useGesture({
onDragStart: ({ event }) => {
event.preventDefault();
editor.surface.surfaceStartCornerRadiusGesture(node_id, anchor);
const altKey = (event as PointerEvent).altKey || false;
editor.surface.surfaceStartCornerRadiusGesture(node_id, anchor, altKey);
},
});

const node = useNode(node_id);
const radii = typeof node.corner_radius === "number" ? node.corner_radius : 0;
const minmargin = Math.max(radii + size, margin);

// Get current radius value for this corner
const currentRadius = useMemo(() => {
if (
node.type === "rectangle" ||
node.type === "container" ||
node.type === "component" ||
node.type === "image" ||
node.type === "video"
) {
const keyMap = {
nw: "rectangular_corner_radius_top_left",
ne: "rectangular_corner_radius_top_right",
se: "rectangular_corner_radius_bottom_right",
sw: "rectangular_corner_radius_bottom_left",
} as const;
return (node as any)[keyMap[anchor]] ?? 0;
}
return typeof node.corner_radius === "number" ? node.corner_radius : 0;
}, [node, anchor]);

// Mathematical constants: computed once per size change
const labelOffsets = useMemo(() => ({
X: size / 2 + 4,
Y_TOP: size / 2 + 4,
Y_BOTTOM: size / 2 + 20,
}), [size]);

// Shared geometry calculations: compute once, use multiple times
const geometry = useMemo(() => {
const br = editor.geometryProvider.getNodeAbsoluteBoundingRect(node_id);
if (!br) return null;

const boundingSurfaceRect = cmath.rect.transform(br, transform);
const [scaleX, scaleY] = cmath.transform.getScale(transform);
const w = boundingSurfaceRect.width;
const h = boundingSurfaceRect.height;
const minmargin = Math.max(currentRadius + size, margin);
const useMarginBased = currentRadius < margin;

// Corner coordinates: C = (C_x, C_y)
const corners = {
nw: [0, 0],
ne: [w, 0],
se: [w, h],
sw: [0, h],
} as const;
const [C_x, C_y] = corners[anchor];

// Arc center offset: O = (O_x, O_y) = (r * s_x * sign_x, r * s_y * sign_y)
const offsets = {
nw: [currentRadius * scaleX, currentRadius * scaleY],
ne: [-currentRadius * scaleX, currentRadius * scaleY],
se: [-currentRadius * scaleX, -currentRadius * scaleY],
sw: [currentRadius * scaleX, -currentRadius * scaleY],
} as const;
const [O_x, O_y] = offsets[anchor];

// Center coordinates: M = (M_x, M_y) = (w/2, h/2)
const M_x = w / 2;
const M_y = h / 2;

// Handle position relative to center: H = (H_x, H_y) = (C + O - M)
const H_x = C_x + O_x - M_x;
const H_y = C_y + O_y - M_y;

return {
w,
h,
scaleX,
scaleY,
minmargin,
useMarginBased,
H_x,
H_y,
M_x,
M_y,
};
}, [editor.geometryProvider, node_id, anchor, currentRadius, transform, size, margin]);

// Calculate handle position: at arc center O when radius >= margin, otherwise at corner with margin
const handleStyle = useMemo(() => {
if (!geometry) return null;

const { useMarginBased, H_x, H_y, minmargin } = geometry;

if (!useMarginBased && currentRadius > 0) {
// Handle at arc center: H = (C + O - M) relative to center
return {
left: `calc(50% + ${H_x}px)`,
top: `calc(50% + ${H_y}px)`,
transform: "translate(-50%, -50%)",
};
}

// Handle at corner with margin: position = minmargin from edge
const positions = {
nw: { top: `${minmargin}px`, left: `${minmargin}px` },
ne: { top: `${minmargin}px`, right: `${minmargin}px` },
se: { bottom: `${minmargin}px`, right: `${minmargin}px` },
sw: { bottom: `${minmargin}px`, left: `${minmargin}px` },
} as const;

return {
...positions[anchor],
transform: `translate(${anchor[1] === "w" ? "-50%" : "50%"}, ${anchor[0] === "n" ? "-50%" : "50%"})`,
};
}, [geometry, anchor, currentRadius]);
Comment on lines +60 to +138
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Scale-aware margin logic needed for zoomed/anisotropic transforms.
useMarginBased / minmargin compare an unscaled radius against pixel margins, so at zoom ≠ 1 the handle can jump to the wrong mode.

🧩 Suggested fix (use scaled radius in px)
-    const minmargin = Math.max(currentRadius + size, margin);
-    const useMarginBased = currentRadius < margin;
+    const radiusPx = Math.min(
+      Math.abs(currentRadius * scaleX),
+      Math.abs(currentRadius * scaleY)
+    );
+    const minmargin = Math.max(radiusPx + size, margin);
+    const useMarginBased = radiusPx < margin;
@@
-    return {
+    return {
       w,
       h,
       scaleX,
       scaleY,
+      radiusPx,
       minmargin,
       useMarginBased,
       H_x,
       H_y,
       M_x,
       M_y,
     };
@@
-    const { useMarginBased, H_x, H_y, minmargin } = geometry;
+    const { useMarginBased, H_x, H_y, minmargin, radiusPx } = geometry;
@@
-    if (!useMarginBased && currentRadius > 0) {
+    if (!useMarginBased && radiusPx > 0) {
🤖 Prompt for AI Agents
In `@editor/grida-canvas-react/viewport/ui/corner-radius-handle.tsx` around lines
60 - 138, The geometry calculation currently compares an unscaled currentRadius
against pixel margins, which breaks under zoom/anisotropic transforms; update
geometry's radius math to compute a scaled radius in pixels (e.g., scaledRadiusX
= currentRadius * scaleX, scaledRadiusY = currentRadius * scaleY, scaledRadiusPx
= Math.max(|scaledRadiusX|, |scaledRadiusY|)), then use scaledRadiusPx when
computing minmargin and useMarginBased (minmargin = Math.max(scaledRadiusPx +
size, margin); useMarginBased = scaledRadiusPx < margin). Keep the existing
offsets (they already use currentRadius * scaleX/scaleY) and ensure these
renamed/added scaled values are used in the same useMemo that defines
minmargin/useMarginBased and referenced by handleStyle.


// Only show label for the specific handle being dragged
const isDragging =
gesture.type === "corner-radius" &&
gesture.node_id === node_id &&
gesture.anchor === anchor;

// Label position relative to handle (inside direction, toward center)
const labelStyle = useMemo(() => {
if (!isDragging || !geometry) return null;

const { useMarginBased, H_x, H_y, M_x, minmargin } = geometry;

if (!useMarginBased && currentRadius > 0) {
// Label offset from handle: L_offset = (L_x, L_y) in inside direction
const labelOffsetMap = {
nw: [labelOffsets.X, labelOffsets.Y_TOP],
ne: [-labelOffsets.X, labelOffsets.Y_TOP],
se: [-labelOffsets.X, -labelOffsets.Y_BOTTOM],
sw: [labelOffsets.X, -labelOffsets.Y_BOTTOM],
} as const;
const [L_x, L_y] = labelOffsetMap[anchor];

// Label position relative to center: L = H + L_offset
const L_x_center = H_x + L_x;
const L_y_center = H_y + L_y;

// For right-side corners (ne, se), use 'right' instead of 'left' to maintain consistency
if (anchor === "ne" || anchor === "se") {
// Convert from center-relative to right-edge distance: right = M_x - L_x_center
return {
right: `${M_x - L_x_center}px`,
top: `calc(50% + ${L_y_center}px)`,
};
}

return {
left: `calc(50% + ${L_x_center}px)`,
top: `calc(50% + ${L_y_center}px)`,
};
}

// Margin-based: label inside direction from handle
const labelPositions = {
nw: { top: `${minmargin + labelOffsets.Y_TOP}px`, left: `${minmargin + labelOffsets.X}px` },
ne: { top: `${minmargin + labelOffsets.Y_TOP}px`, right: `${minmargin + labelOffsets.X}px` },
se: { bottom: `${minmargin + labelOffsets.X}px`, right: `${minmargin + labelOffsets.X}px` },
sw: { bottom: `${minmargin + labelOffsets.X}px`, left: `${minmargin + labelOffsets.X}px` },
} as const;

return labelPositions[anchor];
Comment on lines +181 to +189
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Bottom label offsets use the X constant (likely typo).
For se/sw, the vertical offset should probably use Y_BOTTOM to match the top-corner spacing and avoid the label sitting too close.

🔧 Suggested fix
-      se: { bottom: `${minmargin + labelOffsets.X}px`, right: `${minmargin + labelOffsets.X}px` },
-      sw: { bottom: `${minmargin + labelOffsets.X}px`, left: `${minmargin + labelOffsets.X}px` },
+      se: { bottom: `${minmargin + labelOffsets.Y_BOTTOM}px`, right: `${minmargin + labelOffsets.X}px` },
+      sw: { bottom: `${minmargin + labelOffsets.Y_BOTTOM}px`, left: `${minmargin + labelOffsets.X}px` },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Margin-based: label inside direction from handle
const labelPositions = {
nw: { top: `${minmargin + labelOffsets.Y_TOP}px`, left: `${minmargin + labelOffsets.X}px` },
ne: { top: `${minmargin + labelOffsets.Y_TOP}px`, right: `${minmargin + labelOffsets.X}px` },
se: { bottom: `${minmargin + labelOffsets.X}px`, right: `${minmargin + labelOffsets.X}px` },
sw: { bottom: `${minmargin + labelOffsets.X}px`, left: `${minmargin + labelOffsets.X}px` },
} as const;
return labelPositions[anchor];
// Margin-based: label inside direction from handle
const labelPositions = {
nw: { top: `${minmargin + labelOffsets.Y_TOP}px`, left: `${minmargin + labelOffsets.X}px` },
ne: { top: `${minmargin + labelOffsets.Y_TOP}px`, right: `${minmargin + labelOffsets.X}px` },
se: { bottom: `${minmargin + labelOffsets.Y_BOTTOM}px`, right: `${minmargin + labelOffsets.X}px` },
sw: { bottom: `${minmargin + labelOffsets.Y_BOTTOM}px`, left: `${minmargin + labelOffsets.X}px` },
} as const;
return labelPositions[anchor];
🤖 Prompt for AI Agents
In `@editor/grida-canvas-react/viewport/ui/corner-radius-handle.tsx` around lines
181 - 189, The labelPositions object uses labelOffsets.X for bottom vertical
offsets in the 'se' and 'sw' entries causing bottom labels to use the X constant
instead of the vertical Y offset; update the 'se' and 'sw' entries to use
labelOffsets.Y_BOTTOM for their bottom values (keeping left/right as
labelOffsets.X and top entries using labelOffsets.Y_TOP), so the returned value
from labelPositions[anchor] places bottom labels with the correct vertical
spacing (references: labelPositions, anchor, labelOffsets.Y_BOTTOM,
labelOffsets.Y_TOP, minmargin).

}, [isDragging, geometry, anchor, currentRadius, labelOffsets]);

if (!handleStyle) return null;

return (
<div
{...bind()}
className="hidden group-hover:block border rounded-full bg-white border-workbench-accent-sky absolute z-10 pointer-events-auto"
style={{
top: anchor[0] === "n" ? minmargin : "auto",
bottom: anchor[0] === "s" ? minmargin : "auto",
left: anchor[1] === "w" ? minmargin : "auto",
right: anchor[1] === "e" ? minmargin : "auto",
width: size,
height: size,
transform: `translate(${anchor[1] === "w" ? "-50%" : "50%"}, ${anchor[0] === "n" ? "-50%" : "50%"})`,
cursor: "pointer",
touchAction: "none",
}}
/>
<>
<div
{...bind()}
className="hidden group-hover:block border rounded-full bg-white border-workbench-accent-sky absolute z-10 pointer-events-auto"
style={{
...handleStyle,
width: size,
height: size,
cursor: "pointer",
touchAction: "none",
}}
/>
{isDragging && labelStyle && (
<div className="absolute pointer-events-none z-20" style={labelStyle}>
<div className="bg-workbench-accent-sky text-white text-xs px-1.5 py-0.5 rounded-sm shadow whitespace-nowrap">
Radius {currentRadius}
</div>
</div>
)}
</>
);
}

Expand Down
2 changes: 1 addition & 1 deletion editor/grida-canvas/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@ export type EditorSurface_StartGesture = {
selection: string | string[];
})
| Pick<editor.gesture.GesturePadding, "type" | "node_id" | "side">
| Pick<editor.gesture.GestureCornerRadius, "type" | "node_id" | "anchor">
| Pick<editor.gesture.GestureCornerRadius, "type" | "node_id" | "anchor" | "altKey">
| Pick<
editor.gesture.GestureCurve,
"type" | "control" | "node_id" | "segment"
Expand Down
4 changes: 3 additions & 1 deletion editor/grida-canvas/editor.i.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2008,6 +2008,7 @@ export namespace editor.gesture {
readonly type: "corner-radius";
readonly node_id: string;
readonly anchor?: cmath.IntercardinalDirection;
readonly altKey?: boolean;
readonly initial_bounding_rectangle: cmath.Rectangle | null;
};

Expand Down Expand Up @@ -4107,7 +4108,8 @@ export namespace editor.api {
): void;
surfaceStartCornerRadiusGesture(
selection: string,
anchor?: cmath.IntercardinalDirection
anchor?: cmath.IntercardinalDirection,
altKey?: boolean
): void;
surfaceStartRotateGesture(selection: string): void;
surfaceStartTranslateVectorNetwork(node_id: string): void;
Expand Down
4 changes: 3 additions & 1 deletion editor/grida-canvas/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4492,14 +4492,16 @@ export class EditorSurface
// #region drag resize handle
surfaceStartCornerRadiusGesture(
selection: string,
anchor?: cmath.IntercardinalDirection
anchor?: cmath.IntercardinalDirection,
altKey?: boolean
) {
this._editor.doc.dispatch({
type: "surface/gesture/start",
gesture: {
type: "corner-radius",
node_id: selection,
anchor,
altKey: altKey ?? false,
},
});
}
Expand Down
35 changes: 29 additions & 6 deletions editor/grida-canvas/reducers/event-target.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,7 +732,7 @@ function __self_evt_on_drag(
break;
}
case "corner-radius": {
const { node_id, anchor } = draft.gesture;
const { node_id, anchor, altKey = false } = draft.gesture;
const [dx, dy] = delta;
const node = dq.__getNodeById(draft, node_id);

Expand Down Expand Up @@ -791,15 +791,38 @@ function __self_evt_on_drag(

const key = keyMap[anchor];
const current = (node as any)[key] ?? 0;

// Check if all corners have the same value
const tl = node.rectangular_corner_radius_top_left ?? 0;
const tr = node.rectangular_corner_radius_top_right ?? 0;
const br = node.rectangular_corner_radius_bottom_right ?? 0;
const bl = node.rectangular_corner_radius_bottom_left ?? 0;
const isUniform = tl === tr && tr === br && br === bl;

const nextRadius = current + d;
const nextRadiusClamped = Math.floor(
Math.min(maxRadius, Math.max(0, nextRadius))
);
draft.document.nodes[node_id] = nodeReducer(node, {
type: "node/change/*",
[key]: nextRadiusClamped,
node_id,
});

// If Alt key is not pressed and all corners are uniform, adjust all corners
// Otherwise, adjust only the clicked corner
if (!altKey && isUniform) {
draft.document.nodes[node_id] = nodeReducer(node, {
type: "node/change/*",
corner_radius: nextRadiusClamped,
rectangular_corner_radius_top_left: nextRadiusClamped,
rectangular_corner_radius_top_right: nextRadiusClamped,
rectangular_corner_radius_bottom_right: nextRadiusClamped,
rectangular_corner_radius_bottom_left: nextRadiusClamped,
node_id,
});
} else {
draft.document.nodes[node_id] = nodeReducer(node, {
type: "node/change/*",
[key]: nextRadiusClamped,
node_id,
});
}
} else {
const current =
typeof node.corner_radius == "number" ? node.corner_radius : 0;
Expand Down
3 changes: 2 additions & 1 deletion editor/grida-canvas/reducers/surface.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ function __self_start_gesture(
break;
}
case "corner-radius": {
const { node_id, anchor } = gesture;
const { node_id, anchor, altKey } = gesture;

self_selectNode(draft, "reset", node_id);
draft.gesture = {
Expand All @@ -550,6 +550,7 @@ function __self_start_gesture(
context.geometry.getNodeAbsoluteBoundingRect(node_id)!,
node_id: node_id,
anchor,
altKey: altKey ?? false,
};
break;
}
Expand Down