diff --git a/.gitignore b/.gitignore index dbdf017e7..dc25a289e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ playwright-report meta.json .agents/skills .claude.expect +.mcp.json diff --git a/apps/website/public/r/index.json b/apps/website/public/r/index.json index 5af13f369..189b74f10 100644 --- a/apps/website/public/r/index.json +++ b/apps/website/public/r/index.json @@ -8,9 +8,7 @@ "type": "registry:component", "title": "React Grab", "description": "Loads React Grab as early as possible — select context for coding agents directly from your website.", - "dependencies": [ - "react-grab" - ], + "dependencies": ["react-grab"], "files": [ { "path": "react-grab.tsx", diff --git a/apps/website/public/r/react-grab.json b/apps/website/public/r/react-grab.json index d1da7b2fa..e959f2950 100644 --- a/apps/website/public/r/react-grab.json +++ b/apps/website/public/r/react-grab.json @@ -4,9 +4,7 @@ "type": "registry:component", "title": "React Grab", "description": "Loads React Grab as early as possible — select context for coding agents directly from your website.", - "dependencies": [ - "react-grab" - ], + "dependencies": ["react-grab"], "files": [ { "path": "components/react-grab.tsx", diff --git a/packages/react-grab/e2e/drag-selection.spec.ts b/packages/react-grab/e2e/drag-selection.spec.ts index a6edf9b0a..7a036fe26 100644 --- a/packages/react-grab/e2e/drag-selection.spec.ts +++ b/packages/react-grab/e2e/drag-selection.spec.ts @@ -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(); diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index 4ee57e2b1..6dfe305c7 100644 --- a/packages/react-grab/package.json +++ b/packages/react-grab/package.json @@ -102,7 +102,6 @@ "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-typescript": "^7.28.5", - "solid-js": "^1.9.10", "@playwright/test": "^1.40.0", "@tailwindcss/cli": "^4.1.17", "@types/babel__core": "^7.20.5", @@ -111,6 +110,7 @@ "babel-preset-solid": "^1.9.10", "concurrently": "^9.1.2", "expect-sdk": "0.0.0-canary-20260405095424", + "solid-js": "^1.9.10", "tailwindcss": "^4.1.0", "tsx": "^4.21.0" }, diff --git a/packages/react-grab/src/components/overlay-canvas.tsx b/packages/react-grab/src/components/overlay-canvas.tsx index b589a96ae..fc8ec6fbe 100644 --- a/packages/react-grab/src/components/overlay-canvas.tsx +++ b/packages/react-grab/src/components/overlay-canvas.tsx @@ -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, @@ -15,8 +15,15 @@ import { 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"; @@ -27,12 +34,6 @@ const DEFAULT_LAYER_STYLE = { 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, @@ -41,10 +42,10 @@ const LAYER_STYLES = { }, 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; @@ -55,7 +56,7 @@ interface AnimatedBounds { 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; @@ -71,6 +72,7 @@ interface OverlayCanvasProps { inspectVisible?: boolean; inspectBounds?: OverlayBounds[]; + inspectBoxModel?: BoxModelBounds; dragVisible?: boolean; dragBounds?: OverlayBounds; @@ -102,7 +104,7 @@ export const OverlayCanvas: Component = (props) => { let selectionAnimations: AnimatedBounds[] = []; let dragAnimation: AnimatedBounds | null = null; let grabbedAnimations: AnimatedBounds[] = []; - let inspectAnimations: AnimatedBounds[] = []; + let boxModelAnimations: Partial> = {}; const canvasColorSpace: PredefinedColorSpace = supportsDisplayP3() ? "display-p3" : "srgb"; @@ -141,10 +143,12 @@ export const OverlayCanvas: Component = (props) => { } }; - 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 = ( @@ -165,7 +169,7 @@ export const OverlayCanvas: Component = (props) => { 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, @@ -183,7 +187,7 @@ export const OverlayCanvas: Component = (props) => { width: bounds.width, height: bounds.height, }; - animation.borderRadius = parseBorderRadiusValue(bounds.borderRadius); + animation.borderRadii = parseBorderRadii(bounds.borderRadius); if (targetOpacity !== undefined) { animation.targetOpacity = targetOpacity; } @@ -192,29 +196,27 @@ export const OverlayCanvas: Component = (props) => { 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; @@ -239,7 +241,7 @@ export const OverlayCanvas: Component = (props) => { dragAnimation.current.y, dragAnimation.current.width, dragAnimation.current.height, - dragAnimation.borderRadius, + dragAnimation.borderRadii, style.fillColor, style.borderColor, ); @@ -264,7 +266,7 @@ export const OverlayCanvas: Component = (props) => { animation.current.y, animation.current.width, animation.current.height, - animation.borderRadius, + animation.borderRadii, style.fillColor, style.borderColor, effectiveOpacity, @@ -291,7 +293,7 @@ export const OverlayCanvas: Component = (props) => { animation.current.y, animation.current.width, animation.current.height, - animation.borderRadius, + animation.borderRadii, style.fillColor, style.borderColor, animation.opacity, @@ -299,6 +301,102 @@ export const OverlayCanvas: Component = (props) => { } }; + const hatchPatternCache = new Map(); + + 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); + } + }; + const compositeAllLayers = () => { if (!mainContext || !canvasRef) return; @@ -309,7 +407,7 @@ export const OverlayCanvas: Component = (props) => { renderDragLayer(); renderSelectionLayer(); renderBoundsLayer("grabbed", grabbedAnimations); - renderBoundsLayer("inspect", inspectAnimations); + renderInspectLayer(); const layerRenderOrder: LayerName[] = ["inspect", "drag", "selection", "grabbed"]; for (const layerName of layerRenderOrder) { @@ -411,9 +509,9 @@ export const OverlayCanvas: Component = (props) => { 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; } } @@ -580,27 +678,24 @@ export const OverlayCanvas: Component = (props) => { 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; + for (const layer of layers) { + 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(); }, diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index 7b1057529..933c6ea0c 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -29,6 +29,7 @@ export const ReactGrabRenderer: Component = (props) => { selectionIsFading={props.selectionLabelStatus === "fading"} inspectVisible={props.inspectVisible} inspectBounds={props.inspectBounds} + inspectBoxModel={props.inspectBoxModel} dragVisible={props.dragVisible} dragBounds={props.dragBounds} grabbedBoxes={props.grabbedBoxes} diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 9e4d92561..d0ec25487 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -54,6 +54,22 @@ export const OVERLAY_FILL_COLOR_DEFAULT = overlayColor(0.08); export const OVERLAY_BORDER_COLOR_INSPECT = overlayColor(0.3); export const OVERLAY_FILL_COLOR_INSPECT = overlayColor(0.04); export const FROZEN_GLOW_COLOR = overlayColor(0.15); + +// Box model overlay colors +export const BOX_MODEL_MARGIN_HATCH_COLOR = overlayColor(0.6); +export const BOX_MODEL_PADDING_FILL_COLOR = overlayColor(0.15); +export const BOX_MODEL_CONTENT_FILL_COLOR = overlayColor(0.04); +export const BOX_MODEL_GAP_HATCH_COLOR = overlayColor(0.45); + +// Box model hatch pattern dimensions (matches Chrome DevTools) +export const HATCH_PATTERN_WIDTH_PX = 10; +export const HATCH_DASH_LENGTH_PX = 5; +export const HATCH_DASH_GAP_PX = 3; +export const HATCH_LINE_WIDTH_PX = 1; +export const HATCH_ROTATION_DEG = -45; + +// Minimum gap size to consider visible +export const BOX_MODEL_GAP_THRESHOLD_PX = 0.5; export const FROZEN_GLOW_EDGE_PX = 50; export const ARROW_HEIGHT_PX = 8; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 504c30579..0c665f655 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -40,6 +40,7 @@ import { isElementConnected } from "../utils/is-element-connected.js"; import { getElementsInDrag } from "../utils/get-elements-in-drag.js"; import { getAncestorElements } from "../utils/get-ancestor-elements.js"; import { createElementBounds } from "../utils/create-element-bounds.js"; +import { createBoxModelBounds } from "../utils/create-box-model-bounds.js"; import { createElementSelector } from "../utils/create-element-selector.js"; import { getVisibleBoundsCenter } from "../utils/get-visible-bounds-center.js"; import { invalidateInteractionCaches } from "../utils/invalidate-interaction-caches.js"; @@ -231,7 +232,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const isDragging = createMemo( () => store.current.state === "active" && - (store.current.phase === "dragging-select" || store.current.phase === "dragging-reposition"), + (store.current.phase === "dragging-select" || + store.current.phase === "dragging-reposition"), ); const isDragRepositioning = createMemo( () => store.current.state === "active" && store.current.phase === "dragging-reposition", @@ -2125,6 +2127,17 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; }); + const inspectBoxModel = createMemo(() => { + if (!isInspectMode()) return undefined; + const ancestors = inspectAncestorElements(); + const activeIdx = inspectActiveIndex(); + const element = + activeIdx >= 0 && activeIdx < ancestors.length ? ancestors[activeIdx] : effectiveElement(); + if (!element) return undefined; + void store.viewportVersion; + return createBoxModelBounds(element); + }); + const handleInspectSelect = (index: number) => { setInspectActiveIndex(index); }; @@ -3702,6 +3715,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } inspectVisible={isInspectMode() && inspectBounds().length > 0} inspectBounds={inspectBounds()} + inspectBoxModel={inspectBoxModel()} selectionElementsCount={store.frozenElements.length} selectionFilePath={store.selectionFilePath ?? undefined} selectionLineNumber={store.selectionLineNumber ?? undefined} diff --git a/packages/react-grab/src/core/store.ts b/packages/react-grab/src/core/store.ts index 825c24f15..7121799ae 100644 --- a/packages/react-grab/src/core/store.ts +++ b/packages/react-grab/src/core/store.ts @@ -12,12 +12,7 @@ interface FrozenDragRect { height: number; } -type GrabPhase = - | "hovering" - | "frozen" - | "dragging-select" - | "dragging-reposition" - | "justDragged"; +type GrabPhase = "hovering" | "frozen" | "dragging-select" | "dragging-reposition" | "justDragged"; type GrabState = | { state: "idle" } diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 489ac3244..76ca4ea58 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -323,6 +323,21 @@ export interface OverlayBounds { y: number; } +export interface GapRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface BoxModelBounds { + margin: OverlayBounds; + border: OverlayBounds; + padding: OverlayBounds; + content: OverlayBounds; + gaps: GapRect[]; +} + export type SelectionLabelStatus = "idle" | "copying" | "copied" | "fading" | "error"; export interface SelectionLabelInstance { @@ -366,6 +381,7 @@ export interface ReactGrabRendererProps { selectionShouldSnap?: boolean; inspectVisible?: boolean; inspectBounds?: OverlayBounds[]; + inspectBoxModel?: BoxModelBounds; selectionElementsCount?: number; selectionFilePath?: string; selectionLineNumber?: number; diff --git a/packages/react-grab/src/utils/create-box-model-bounds.ts b/packages/react-grab/src/utils/create-box-model-bounds.ts new file mode 100644 index 000000000..e3756126f --- /dev/null +++ b/packages/react-grab/src/utils/create-box-model-bounds.ts @@ -0,0 +1,161 @@ +import type { BoxModelBounds, GapRect, OverlayBounds } from "../types.js"; +import { createElementBounds } from "./create-element-bounds.js"; +import { BOX_MODEL_GAP_THRESHOLD_PX } from "../constants.js"; + +interface BoxSides { + top: number; + right: number; + bottom: number; + left: number; +} + +const GRID_DISPLAYS = new Set(["grid", "inline-grid"]); +const LAYOUT_DISPLAYS = new Set(["flex", "inline-flex", "grid", "inline-grid"]); +const OUT_OF_FLOW_POSITIONS = new Set(["absolute", "fixed"]); + +const parseSides = (style: CSSStyleDeclaration, property: string): BoxSides => ({ + top: parseFloat(style.getPropertyValue(`${property}-top`)) || 0, + right: parseFloat(style.getPropertyValue(`${property}-right`)) || 0, + bottom: parseFloat(style.getPropertyValue(`${property}-bottom`)) || 0, + left: parseFloat(style.getPropertyValue(`${property}-left`)) || 0, +}); + +const insetBounds = ( + bounds: OverlayBounds, + inset: BoxSides, + borderRadius: string, +): OverlayBounds => ({ + ...bounds, + x: bounds.x + inset.left, + y: bounds.y + inset.top, + width: bounds.width - inset.left - inset.right, + height: bounds.height - inset.top - inset.bottom, + borderRadius, +}); + +const outsetBounds = ( + bounds: OverlayBounds, + outset: BoxSides, + borderRadius: string, +): OverlayBounds => ({ + ...bounds, + x: bounds.x - outset.left, + y: bounds.y - outset.top, + width: bounds.width + outset.left + outset.right, + height: bounds.height + outset.top + outset.bottom, + borderRadius, +}); + +interface CornerRadii { + topLeft: number; + topRight: number; + bottomRight: number; + bottomLeft: number; +} + +const parseCornerRadii = (style: CSSStyleDeclaration): CornerRadii => ({ + topLeft: parseFloat(style.borderTopLeftRadius) || 0, + topRight: parseFloat(style.borderTopRightRadius) || 0, + bottomRight: parseFloat(style.borderBottomRightRadius) || 0, + bottomLeft: parseFloat(style.borderBottomLeftRadius) || 0, +}); + +const insetCornerRadii = (radii: CornerRadii, sides: BoxSides): CornerRadii => ({ + topLeft: Math.max(0, radii.topLeft - Math.max(sides.top, sides.left)), + topRight: Math.max(0, radii.topRight - Math.max(sides.top, sides.right)), + bottomRight: Math.max(0, radii.bottomRight - Math.max(sides.bottom, sides.right)), + bottomLeft: Math.max(0, radii.bottomLeft - Math.max(sides.bottom, sides.left)), +}); + +const formatCornerRadii = (radii: CornerRadii): string => + `${radii.topLeft}px ${radii.topRight}px ${radii.bottomRight}px ${radii.bottomLeft}px`; + +const isInFlowChild = (child: Element): boolean => { + const childStyle = window.getComputedStyle(child); + return childStyle.display !== "none" && !OUT_OF_FLOW_POSITIONS.has(childStyle.position); +}; + +const hasVisibleSize = (rect: DOMRect): boolean => rect.width > 0 || rect.height > 0; + +const computeAxisGaps = ( + childRects: DOMRect[], + contentBounds: OverlayBounds, + axis: "row" | "column", +): GapRect[] => { + const isColumnAxis = axis === "column"; + + const sortedRects = [...childRects].sort((a, b) => + isColumnAxis ? a.top - b.top : a.left - b.left, + ); + + const gaps: GapRect[] = []; + for (let childIndex = 0; childIndex < sortedRects.length - 1; childIndex++) { + const currentRect = sortedRects[childIndex]; + const nextRect = sortedRects[childIndex + 1]; + const gapStart = isColumnAxis ? currentRect.bottom : currentRect.right; + const gapEnd = isColumnAxis ? nextRect.top : nextRect.left; + const gapSize = gapEnd - gapStart; + + if (gapSize > BOX_MODEL_GAP_THRESHOLD_PX) { + gaps.push( + isColumnAxis + ? { x: contentBounds.x, y: gapStart, width: contentBounds.width, height: gapSize } + : { x: gapStart, y: contentBounds.y, width: gapSize, height: contentBounds.height }, + ); + } + } + + return gaps; +}; + +const computeChildGaps = ( + element: Element, + style: CSSStyleDeclaration, + contentBounds: OverlayBounds, +): GapRect[] => { + if (!LAYOUT_DISPLAYS.has(style.display) || element.children.length < 2) { + return []; + } + + const childRects = Array.from(element.children) + .filter(isInFlowChild) + .map((child) => child.getBoundingClientRect()) + .filter(hasVisibleSize); + + if (childRects.length < 2) return []; + + if (GRID_DISPLAYS.has(style.display)) { + return [ + ...computeAxisGaps(childRects, contentBounds, "row"), + ...computeAxisGaps(childRects, contentBounds, "column"), + ]; + } + + const isColumn = style.flexDirection === "column" || style.flexDirection === "column-reverse"; + return computeAxisGaps(childRects, contentBounds, isColumn ? "column" : "row"); +}; + +export const createBoxModelBounds = (element: Element): BoxModelBounds => { + const borderBounds = createElementBounds(element); + const style = window.getComputedStyle(element); + + const marginSides = parseSides(style, "margin"); + const paddingSides = parseSides(style, "padding"); + const borderSides: BoxSides = { + top: parseFloat(style.borderTopWidth) || 0, + right: parseFloat(style.borderRightWidth) || 0, + bottom: parseFloat(style.borderBottomWidth) || 0, + left: parseFloat(style.borderLeftWidth) || 0, + }; + + const outerRadii = parseCornerRadii(style); + const paddingRadii = insetCornerRadii(outerRadii, borderSides); + const contentRadii = insetCornerRadii(paddingRadii, paddingSides); + + const margin = outsetBounds(borderBounds, marginSides, "0px"); + const padding = insetBounds(borderBounds, borderSides, formatCornerRadii(paddingRadii)); + const content = insetBounds(padding, paddingSides, formatCornerRadii(contentRadii)); + const gaps = computeChildGaps(element, style, content); + + return { margin, border: borderBounds, padding, content, gaps }; +};