Skip to content

Commit 1baf7f0

Browse files
committed
feat: support per-corner border radii in box model overlay
- Parse all 4 corner radii via CSS longhands instead of using the first value from the shorthand, so elements with mixed corner radii (e.g. rounded top, square bottom) render accurately - Compute inner radii per-corner by subtracting the max of each corner's two adjacent sides (border/padding), matching the CSS spec - Change AnimatedBounds.borderRadii from single number to number[] so all overlay layers (selection, drag, grabbed, inspect) benefit
1 parent 8be7680 commit 1baf7f0

2 files changed

Lines changed: 50 additions & 25 deletions

File tree

packages/react-grab/src/components/overlay-canvas.tsx

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ interface AnimatedBounds {
5656
id: string;
5757
current: { x: number; y: number; width: number; height: number };
5858
target: { x: number; y: number; width: number; height: number };
59-
borderRadius: number;
59+
borderRadii: number[];
6060
opacity: number;
6161
targetOpacity: number;
6262
createdAt?: number;
@@ -143,10 +143,12 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
143143
}
144144
};
145145

146-
const parseBorderRadiusValue = (borderRadius: string): number => {
147-
if (!borderRadius) return 0;
148-
const match = borderRadius.match(/^(\d+(?:\.\d+)?)/);
149-
return match ? parseFloat(match[1]) : 0;
146+
const parseBorderRadii = (borderRadius: string): number[] => {
147+
if (!borderRadius) return [0, 0, 0, 0];
148+
const radiusString = borderRadius.split("/")[0].trim();
149+
const values = radiusString.split(/\s+/).map((value) => parseFloat(value) || 0);
150+
const [topLeft = 0, topRight = topLeft, bottomRight = topLeft, bottomLeft = topRight] = values;
151+
return [topLeft, topRight, bottomRight, bottomLeft];
150152
};
151153

152154
const createAnimatedBounds = (
@@ -167,7 +169,7 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
167169
width: bounds.width,
168170
height: bounds.height,
169171
},
170-
borderRadius: parseBorderRadiusValue(bounds.borderRadius),
172+
borderRadii: parseBorderRadii(bounds.borderRadius),
171173
opacity: options?.opacity ?? 1,
172174
targetOpacity: options?.targetOpacity ?? options?.opacity ?? 1,
173175
createdAt: options?.createdAt,
@@ -185,7 +187,7 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
185187
width: bounds.width,
186188
height: bounds.height,
187189
};
188-
animation.borderRadius = parseBorderRadiusValue(bounds.borderRadius);
190+
animation.borderRadii = parseBorderRadii(bounds.borderRadius);
189191
if (targetOpacity !== undefined) {
190192
animation.targetOpacity = targetOpacity;
191193
}
@@ -194,26 +196,28 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
194196
const resolveBoundsArray = (instance: SelectionLabelInstance): OverlayBounds[] =>
195197
instance.boundsMultiple ?? [instance.bounds];
196198

199+
const clampRadii = (radii: number[], halfWidth: number, halfHeight: number): number[] =>
200+
radii.map((radius) => Math.min(radius, halfWidth, halfHeight));
201+
197202
const drawRoundedRectangle = (
198203
context: OffscreenCanvasRenderingContext2D,
199204
rectX: number,
200205
rectY: number,
201206
rectWidth: number,
202207
rectHeight: number,
203-
cornerRadius: number,
208+
cornerRadii: number[],
204209
fillColor: string,
205210
strokeColor: string,
206211
opacity: number = 1,
207212
) => {
208213
if (rectWidth <= 0 || rectHeight <= 0) return;
209214

210-
const maxCornerRadius = Math.min(rectWidth / 2, rectHeight / 2);
211-
const clampedCornerRadius = Math.min(cornerRadius, maxCornerRadius);
215+
const clamped = clampRadii(cornerRadii, rectWidth / 2, rectHeight / 2);
212216

213217
context.globalAlpha = opacity;
214218
context.beginPath();
215-
if (clampedCornerRadius > 0) {
216-
context.roundRect(rectX, rectY, rectWidth, rectHeight, clampedCornerRadius);
219+
if (clamped.some((radius) => radius > 0)) {
220+
context.roundRect(rectX, rectY, rectWidth, rectHeight, clamped);
217221
} else {
218222
context.rect(rectX, rectY, rectWidth, rectHeight);
219223
}
@@ -241,7 +245,7 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
241245
dragAnimation.current.y,
242246
dragAnimation.current.width,
243247
dragAnimation.current.height,
244-
dragAnimation.borderRadius,
248+
dragAnimation.borderRadii,
245249
style.fillColor,
246250
style.borderColor,
247251
);
@@ -266,7 +270,7 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
266270
animation.current.y,
267271
animation.current.width,
268272
animation.current.height,
269-
animation.borderRadius,
273+
animation.borderRadii,
270274
style.fillColor,
271275
style.borderColor,
272276
effectiveOpacity,
@@ -293,7 +297,7 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
293297
animation.current.y,
294298
animation.current.width,
295299
animation.current.height,
296-
animation.borderRadius,
300+
animation.borderRadii,
297301
style.fillColor,
298302
style.borderColor,
299303
animation.opacity,
@@ -335,9 +339,9 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
335339
const appendBoundsToPath = (path: Path2D, animation: AnimatedBounds) => {
336340
const { x, y, width, height } = animation.current;
337341
if (width <= 0 || height <= 0) return;
338-
const clampedRadius = Math.min(animation.borderRadius, width / 2, height / 2);
339-
if (clampedRadius > 0) {
340-
path.roundRect(x, y, width, height, clampedRadius);
342+
const clamped = clampRadii(animation.borderRadii, width / 2, height / 2);
343+
if (clamped.some((radius) => radius > 0)) {
344+
path.roundRect(x, y, width, height, clamped);
341345
} else {
342346
path.rect(x, y, width, height);
343347
}

packages/react-grab/src/utils/create-box-model-bounds.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,29 @@ const outsetBounds = (
4646
borderRadius,
4747
});
4848

49-
const maxSide = (sides: BoxSides): number =>
50-
Math.max(sides.top, sides.right, sides.bottom, sides.left);
49+
interface CornerRadii {
50+
topLeft: number;
51+
topRight: number;
52+
bottomRight: number;
53+
bottomLeft: number;
54+
}
55+
56+
const parseCornerRadii = (style: CSSStyleDeclaration): CornerRadii => ({
57+
topLeft: parseFloat(style.borderTopLeftRadius) || 0,
58+
topRight: parseFloat(style.borderTopRightRadius) || 0,
59+
bottomRight: parseFloat(style.borderBottomRightRadius) || 0,
60+
bottomLeft: parseFloat(style.borderBottomLeftRadius) || 0,
61+
});
62+
63+
const insetCornerRadii = (radii: CornerRadii, sides: BoxSides): CornerRadii => ({
64+
topLeft: Math.max(0, radii.topLeft - Math.max(sides.top, sides.left)),
65+
topRight: Math.max(0, radii.topRight - Math.max(sides.top, sides.right)),
66+
bottomRight: Math.max(0, radii.bottomRight - Math.max(sides.bottom, sides.right)),
67+
bottomLeft: Math.max(0, radii.bottomLeft - Math.max(sides.bottom, sides.left)),
68+
});
69+
70+
const formatCornerRadii = (radii: CornerRadii): string =>
71+
`${radii.topLeft}px ${radii.topRight}px ${radii.bottomRight}px ${radii.bottomLeft}px`;
5172

5273
const isInFlowChild = (child: Element): boolean => {
5374
const childStyle = window.getComputedStyle(child);
@@ -127,13 +148,13 @@ export const createBoxModelBounds = (element: Element): BoxModelBounds => {
127148
left: parseFloat(style.borderLeftWidth) || 0,
128149
};
129150

130-
const outerRadius = parseFloat(style.borderRadius) || 0;
131-
const paddingRadius = Math.max(0, outerRadius - maxSide(borderSides));
132-
const contentRadius = Math.max(0, paddingRadius - maxSide(paddingSides));
151+
const outerRadii = parseCornerRadii(style);
152+
const paddingRadii = insetCornerRadii(outerRadii, borderSides);
153+
const contentRadii = insetCornerRadii(paddingRadii, paddingSides);
133154

134155
const margin = outsetBounds(borderBounds, marginSides, "0px");
135-
const padding = insetBounds(borderBounds, borderSides, `${paddingRadius}px`);
136-
const content = insetBounds(padding, paddingSides, `${contentRadius}px`);
156+
const padding = insetBounds(borderBounds, borderSides, formatCornerRadii(paddingRadii));
157+
const content = insetBounds(padding, paddingSides, formatCornerRadii(contentRadii));
137158
const gaps = computeChildGaps(element, style, content);
138159

139160
return { margin, border: borderBounds, padding, content, gaps };

0 commit comments

Comments
 (0)