Skip to content
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ playwright-report
meta.json
.agents/skills
.claude.expect
.mcp.json
43 changes: 43 additions & 0 deletions packages/react-grab/e2e/drag-selection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,49 @@ test.describe("Drag Selection with Scroll", () => {
await reactGrab.page.mouse.up();
});

test("drag bounds height should grow by exactly the scroll delta during auto-scroll", async ({
reactGrab,
}) => {
await reactGrab.activate();

const viewport = await reactGrab.getViewportSize();

// Start drag from the top of the page
const startX = 100;
const startY = 100;
await reactGrab.page.mouse.move(startX, startY);
await reactGrab.page.mouse.down();

// Move near the bottom edge to trigger auto-scroll (threshold is 25px)
const nearBottomY = viewport.height - 10;
await reactGrab.page.mouse.move(startX, nearBottomY, { steps: 5 });
await reactGrab.page.waitForTimeout(200);

// Capture initial state after auto-scroll has started
const scrollBefore = await reactGrab.page.evaluate(() => window.scrollY);
const boundsBefore = await reactGrab.getDragBoxBounds();
expect(boundsBefore).not.toBeNull();

// Wait for auto-scroll to advance several frames
await reactGrab.page.waitForTimeout(800);

const scrollAfter = await reactGrab.page.evaluate(() => window.scrollY);
const boundsAfter = await reactGrab.getDragBoxBounds();
expect(boundsAfter).not.toBeNull();

const scrollDelta = scrollAfter - scrollBefore;
// Auto-scroll should have moved the page
expect(scrollDelta).toBeGreaterThan(0);

// The drag height growth should match the scroll delta (not double-counted).
// Allow a small tolerance for RAF timing between reading scroll and bounds.
const heightGrowth = boundsAfter!.height - boundsBefore!.height;
const drift = Math.abs(heightGrowth - scrollDelta);
expect(drift).toBeLessThanOrEqual(20);

await reactGrab.page.mouse.up();
});

test("drag selection should work in scrollable container", async ({ reactGrab }) => {
await reactGrab.activate();

Expand Down
191 changes: 143 additions & 48 deletions packages/react-grab/src/components/overlay-canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createEffect, onCleanup, onMount, on } from "solid-js";
import type { Component } from "solid-js";
import type { OverlayBounds, SelectionLabelInstance } from "../types.js";
import type { BoxModelBounds, OverlayBounds, SelectionLabelInstance } from "../types.js";
import { lerp } from "../utils/lerp.js";
import {
SELECTION_LERP_FACTOR,
Expand All @@ -15,8 +15,15 @@
OPACITY_CONVERGENCE_THRESHOLD,
OVERLAY_BORDER_COLOR_DEFAULT,
OVERLAY_FILL_COLOR_DEFAULT,
OVERLAY_BORDER_COLOR_INSPECT,
OVERLAY_FILL_COLOR_INSPECT,
BOX_MODEL_MARGIN_HATCH_COLOR,
BOX_MODEL_PADDING_FILL_COLOR,
BOX_MODEL_CONTENT_FILL_COLOR,
BOX_MODEL_GAP_HATCH_COLOR,
HATCH_PATTERN_WIDTH_PX,
HATCH_DASH_LENGTH_PX,
HATCH_DASH_GAP_PX,
HATCH_LINE_WIDTH_PX,
HATCH_ROTATION_DEG,
} from "../constants.js";
import { nativeCancelAnimationFrame, nativeRequestAnimationFrame } from "../utils/native-raf.js";
import { supportsDisplayP3 } from "../utils/supports-display-p3.js";
Expand All @@ -27,12 +34,6 @@
lerpFactor: SELECTION_LERP_FACTOR,
} as const;

const INSPECT_LAYER_STYLE = {
borderColor: OVERLAY_BORDER_COLOR_INSPECT,
fillColor: OVERLAY_FILL_COLOR_INSPECT,
lerpFactor: SELECTION_LERP_FACTOR,
} as const;

const LAYER_STYLES = {
drag: {
borderColor: OVERLAY_BORDER_COLOR_DRAG,
Expand All @@ -41,10 +42,10 @@
},
selection: DEFAULT_LAYER_STYLE,
grabbed: DEFAULT_LAYER_STYLE,
inspect: INSPECT_LAYER_STYLE,
} as const;

type LayerName = "drag" | "selection" | "grabbed" | "inspect";
type BoxModelLayerName = "margin" | "border" | "padding" | "content";

interface OffscreenLayer {
canvas: OffscreenCanvas | null;
Expand All @@ -55,7 +56,7 @@
id: string;
current: { x: number; y: number; width: number; height: number };
target: { x: number; y: number; width: number; height: number };
borderRadius: number;
borderRadii: number[];
opacity: number;
targetOpacity: number;
createdAt?: number;
Expand All @@ -71,6 +72,7 @@

inspectVisible?: boolean;
inspectBounds?: OverlayBounds[];
inspectBoxModel?: BoxModelBounds;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unused inspectBounds prop after refactor to box model

Low Severity

The inspectBounds prop in OverlayCanvasProps is declared and passed through the component hierarchy but never consumed. The createEffect that previously read props.inspectBounds was replaced with one that reads props.inspectBoxModel, making the prop dead code. The inspectBounds() memo in core/index.tsx still runs createElementBounds on every ancestor each time the viewport changes, solely to power the inspectBounds().length > 0 visibility check — which could use the already-existing inspectAncestorElements().length > 0 instead, avoiding that unnecessary computation.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b9a52c1. Configure here.

Copy link
Copy Markdown
Contributor

@vercel vercel bot Apr 8, 2026

Choose a reason for hiding this comment

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

The inspectBounds prop is declared in multiple interfaces and generated/passed in the core component but never actually used in the overlay-canvas component

Fix on Vercel


dragVisible?: boolean;
dragBounds?: OverlayBounds;
Expand All @@ -85,7 +87,7 @@
}

export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
let canvasRef: HTMLCanvasElement | undefined;

Check warning on line 90 in packages/react-grab/src/components/overlay-canvas.tsx

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unassigned-vars)

'canvasRef' is always 'undefined' because it's never assigned.
let mainContext: CanvasRenderingContext2D | null = null;
let canvasWidth = 0;
let canvasHeight = 0;
Expand All @@ -102,7 +104,7 @@
let selectionAnimations: AnimatedBounds[] = [];
let dragAnimation: AnimatedBounds | null = null;
let grabbedAnimations: AnimatedBounds[] = [];
let inspectAnimations: AnimatedBounds[] = [];
let boxModelAnimations: Partial<Record<BoxModelLayerName, AnimatedBounds>> = {};

const canvasColorSpace: PredefinedColorSpace = supportsDisplayP3() ? "display-p3" : "srgb";

Expand Down Expand Up @@ -141,10 +143,12 @@
}
};

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

const createAnimatedBounds = (
Expand All @@ -165,7 +169,7 @@
width: bounds.width,
height: bounds.height,
},
borderRadius: parseBorderRadiusValue(bounds.borderRadius),
borderRadii: parseBorderRadii(bounds.borderRadius),
opacity: options?.opacity ?? 1,
targetOpacity: options?.targetOpacity ?? options?.opacity ?? 1,
createdAt: options?.createdAt,
Expand All @@ -183,7 +187,7 @@
width: bounds.width,
height: bounds.height,
};
animation.borderRadius = parseBorderRadiusValue(bounds.borderRadius);
animation.borderRadii = parseBorderRadii(bounds.borderRadius);
if (targetOpacity !== undefined) {
animation.targetOpacity = targetOpacity;
}
Expand All @@ -192,29 +196,27 @@
const resolveBoundsArray = (instance: SelectionLabelInstance): OverlayBounds[] =>
instance.boundsMultiple ?? [instance.bounds];

const clampRadii = (radii: number[], halfWidth: number, halfHeight: number): number[] =>
radii.map((radius) => Math.min(radius, halfWidth, halfHeight));

const drawRoundedRectangle = (
context: OffscreenCanvasRenderingContext2D,
rectX: number,
rectY: number,
rectWidth: number,
rectHeight: number,
cornerRadius: number,
cornerRadii: number[],
fillColor: string,
strokeColor: string,
opacity: number = 1,
) => {
if (rectWidth <= 0 || rectHeight <= 0) return;

const maxCornerRadius = Math.min(rectWidth / 2, rectHeight / 2);
const clampedCornerRadius = Math.min(cornerRadius, maxCornerRadius);
const clamped = clampRadii(cornerRadii, rectWidth / 2, rectHeight / 2);

context.globalAlpha = opacity;
context.beginPath();
if (clampedCornerRadius > 0) {
context.roundRect(rectX, rectY, rectWidth, rectHeight, clampedCornerRadius);
} else {
context.rect(rectX, rectY, rectWidth, rectHeight);
}
context.roundRect(rectX, rectY, rectWidth, rectHeight, clamped);
context.fillStyle = fillColor;
context.fill();
context.strokeStyle = strokeColor;
Expand All @@ -239,7 +241,7 @@
dragAnimation.current.y,
dragAnimation.current.width,
dragAnimation.current.height,
dragAnimation.borderRadius,
dragAnimation.borderRadii,
style.fillColor,
style.borderColor,
);
Expand All @@ -264,7 +266,7 @@
animation.current.y,
animation.current.width,
animation.current.height,
animation.borderRadius,
animation.borderRadii,
style.fillColor,
style.borderColor,
effectiveOpacity,
Expand All @@ -291,14 +293,110 @@
animation.current.y,
animation.current.width,
animation.current.height,
animation.borderRadius,
animation.borderRadii,
style.fillColor,
style.borderColor,
animation.opacity,
);
}
};

const hatchPatternCache = new Map<string, CanvasPattern>();

const getOrCreateHatchPattern = (
context: OffscreenCanvasRenderingContext2D,
color: string,
): CanvasPattern | null => {
const cached = hatchPatternCache.get(color);
if (cached) return cached;

const patternCanvas = new OffscreenCanvas(
HATCH_PATTERN_WIDTH_PX,
HATCH_DASH_LENGTH_PX + HATCH_DASH_GAP_PX,
);
const patternContext = patternCanvas.getContext("2d");
if (!patternContext) return null;

patternContext.clearRect(0, 0, patternCanvas.width, patternCanvas.height);
patternContext.fillStyle = color;
patternContext.fillRect(0, 0, HATCH_LINE_WIDTH_PX, HATCH_DASH_LENGTH_PX);

const pattern = context.createPattern(patternCanvas, "repeat");
if (pattern) {
pattern.setTransform(new DOMMatrix().rotate(HATCH_ROTATION_DEG));
hatchPatternCache.set(color, pattern);
}
return pattern;
};

// Chromium bug: mixing roundRect and rect sub-paths on the same Path2D
// breaks the "evenodd" fill rule, clipping the top-left of the ring.
// Always use roundRect (even with [0,0,0,0] radii) to keep both
// sub-paths using the same drawing primitive.
const appendBoundsToPath = (path: Path2D, animation: AnimatedBounds) => {
const { x, y, width, height } = animation.current;
if (width <= 0 || height <= 0) return;
const clamped = clampRadii(animation.borderRadii, width / 2, height / 2);
path.roundRect(x, y, width, height, clamped);
};

const buildBoundsPath = (animation: AnimatedBounds): Path2D => {
const path = new Path2D();
appendBoundsToPath(path, animation);
return path;
};

const buildRingPath = (outer: AnimatedBounds, inner: AnimatedBounds): Path2D => {
const path = new Path2D();
appendBoundsToPath(path, outer);
appendBoundsToPath(path, inner);
return path;
};

const fillWithHatch = (
context: OffscreenCanvasRenderingContext2D,
path: Path2D,
color: string,
) => {
const pattern = getOrCreateHatchPattern(context, color);
if (!pattern) return;
context.fillStyle = pattern;
context.fill(path, "evenodd");
};

const renderInspectLayer = () => {
const layer = layers.inspect;
if (!layer.context) return;

const context = layer.context;
context.clearRect(0, 0, canvasWidth, canvasHeight);

if (!props.inspectVisible) return;

const { margin, border, padding, content } = boxModelAnimations;
if (!margin || !border || !padding || !content) return;

fillWithHatch(context, buildRingPath(margin, border), BOX_MODEL_MARGIN_HATCH_COLOR);

context.fillStyle = BOX_MODEL_PADDING_FILL_COLOR;
context.fill(buildRingPath(padding, content), "evenodd");

const contentPath = buildBoundsPath(content);
context.fillStyle = BOX_MODEL_CONTENT_FILL_COLOR;
context.fill(contentPath);

const gapRects = props.inspectBoxModel?.gaps;
if (gapRects && gapRects.length > 0) {
const gapPath = new Path2D();
for (const gapRect of gapRects) {
if (gapRect.width > 0 && gapRect.height > 0) {
gapPath.rect(gapRect.x, gapRect.y, gapRect.width, gapRect.height);
}
}
fillWithHatch(context, gapPath, BOX_MODEL_GAP_HATCH_COLOR);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gap rects not animated, visually desync during transitions

Medium Severity

Gap rects in renderInspectLayer are read directly from props.inspectBoxModel?.gaps (raw, non-animated positions), while the margin/border/padding/content layers are all smoothly animated via boxModelAnimations. When the user moves the cursor to a different flex/grid container, the gap hatching instantly jumps to the new element's positions while the content region is still lerping from the old position. This can cause gap hatching to render outside the content area during the animation transition.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e7ea6dc. Configure here.

Copy link
Copy Markdown
Contributor

@vercel vercel bot Apr 8, 2026

Choose a reason for hiding this comment

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

Gap rects in renderInspectLayer are read directly from props without animation while margin/border/padding/content layers animate smoothly, causing visual desynchronization during element transitions.

Fix on Vercel

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Grid gap hatching creates holes at intersections

Medium Severity

For grid containers, computeChildGaps returns gap rects from both axes — full-height vertical bands (column gaps) and full-width horizontal bands (row gaps). These rects overlap at intersection points. All gap rects are added to one Path2D and filled via fillWithHatch, which unconditionally uses the "evenodd" fill rule. At overlapping regions, the crossing count is 2 (even), so the hatching is not drawn, creating visible un-hatched holes at every grid gap intersection. The "evenodd" rule is correct for the ring paths (margin/padding) but incorrect for the gap rects, which need "nonzero".

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 17c5eae. Configure here.

};

const compositeAllLayers = () => {
if (!mainContext || !canvasRef) return;

Expand All @@ -309,7 +407,7 @@
renderDragLayer();
renderSelectionLayer();
renderBoundsLayer("grabbed", grabbedAnimations);
renderBoundsLayer("inspect", inspectAnimations);
renderInspectLayer();

const layerRenderOrder: LayerName[] = ["inspect", "drag", "selection", "grabbed"];
for (const layerName of layerRenderOrder) {
Expand Down Expand Up @@ -411,9 +509,9 @@
return animation.opacity > 0;
});

for (const animation of inspectAnimations) {
for (const animation of Object.values(boxModelAnimations)) {
if (animation.isInitialized) {
if (interpolateBounds(animation, LAYER_STYLES.inspect.lerpFactor)) {
if (interpolateBounds(animation, SELECTION_LERP_FACTOR)) {
shouldContinueAnimating = true;
}
}
Expand Down Expand Up @@ -580,27 +678,24 @@

createEffect(
on(
() => [props.inspectVisible, props.inspectBounds] as const,
([isVisible, bounds]) => {
if (!isVisible || !bounds || bounds.length === 0) {
inspectAnimations = [];
() => [props.inspectVisible, props.inspectBoxModel] as const,
([isVisible, boxModel]) => {
if (!isVisible || !boxModel) {
boxModelAnimations = {};
scheduleAnimationFrame();
return;
}

inspectAnimations = bounds.map((ancestorBounds, index) => {
const animationId = `inspect-${index}`;
const existingAnimation = inspectAnimations.find(
(animation) => animation.id === animationId,
);

if (existingAnimation) {
updateAnimationTarget(existingAnimation, ancestorBounds);
return existingAnimation;
const layers = ["margin", "border", "padding", "content"] as const;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Variable shadows outer offscreen layers record

Low Severity

The const layers array on this line shadows the outer const layers: Record<LayerName, OffscreenLayer> defined at component scope (line 96). While no current code in this createEffect callback accesses the outer record, the name collision makes the code fragile — any future addition that needs the offscreen layers record inside this callback would silently reference the wrong variable.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 17c5eae. Configure here.

for (const layer of layers) {
Comment on lines +689 to +690
Copy link
Copy Markdown
Contributor

@vercel vercel bot Apr 8, 2026

Choose a reason for hiding this comment

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

Inner variable 'layers' shadows outer 'layers' Record in effect callback, creating a maintenance hazard for future code modifications

Fix on Vercel

const bounds = boxModel[layer];
const existing = boxModelAnimations[layer];
if (existing) {
updateAnimationTarget(existing, bounds);
} else {
boxModelAnimations[layer] = createAnimatedBounds(`boxmodel-${layer}`, bounds);
}

return createAnimatedBounds(animationId, ancestorBounds);
});
}

scheduleAnimationFrame();
},
Expand Down
Loading
Loading