fix: clip selection overlay to the chat scroller for tall messages

SelectionOverlay portals to document.body and drew highlights at the raw getBoundingClientRect() of the selected element, so a tall message bubble let the blue overlay bleed past the chat panel onto the rest of the UI. Walk up every overflow-clipping ancestor, intersect the rect with each, and skip rendering when fully out of view. Applied to hover, persistent, and drag-preview rects, with labels clamped to the clip top/left so they never paint above the chat container.
This commit is contained in:
Aidan
2026-06-10 17:37:04 -07:00
committed by GitHub
parent ea2f1c36c5
commit ded951b8a0
2 changed files with 85 additions and 20 deletions
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import ReactDOM from 'react-dom';
import { OverlayState, DragRect, DragPreviewElement } from './useDomElementSelector';
import { OverlayState, DragRect, DragPreviewElement, clipRectToAncestors } from './useDomElementSelector';
import { useElementSelection } from './ElementSelectionContext';
const HIGHLIGHT_COLOR = '#3b82f6';
@@ -20,6 +20,8 @@ interface PersistentRect {
width: number;
height: number;
label: string;
clipLeft: number;
clipTop: number;
}
interface Props {
@@ -52,13 +54,17 @@ const SelectionOverlay: React.FC<Props> = ({ overlay, dragRect, dragPreview = []
const domEl = document.querySelector(sel.selectorPath);
if (domEl) {
const rect = domEl.getBoundingClientRect();
const clipped = clipRectToAncestors(domEl, rect);
if (clipped.hidden) continue;
rects.push({
id: sel.id,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
top: clipped.top,
left: clipped.left,
width: clipped.width,
height: clipped.height,
label: sel.semanticLabel || sel.tagName,
clipLeft: clipped.clipLeft,
clipTop: clipped.clipTop,
});
}
} catch {
@@ -105,8 +111,8 @@ const SelectionOverlay: React.FC<Props> = ({ overlay, dragRect, dragPreview = []
<div
style={{
position: 'fixed',
top: Math.max(0, r.top),
left: Math.max(0, r.left),
top: Math.max(r.clipTop, r.top),
left: Math.max(r.clipLeft, r.left),
background: HIGHLIGHT_COLOR,
color: '#fff',
fontSize: 9,
@@ -171,8 +177,8 @@ const SelectionOverlay: React.FC<Props> = ({ overlay, dragRect, dragPreview = []
<div
style={{
position: 'fixed',
top: Math.max(0, p.top),
left: Math.max(0, p.left),
top: Math.max(p.clipTop, p.top),
left: Math.max(p.clipLeft, p.left),
background: labelBg,
color: '#fff',
fontSize: 9,
@@ -214,8 +220,8 @@ const SelectionOverlay: React.FC<Props> = ({ overlay, dragRect, dragPreview = []
<div
style={{
position: 'fixed',
top: Math.max(0, overlay.top),
left: Math.max(0, overlay.left),
top: Math.max(overlay.clipTop, overlay.top),
left: Math.max(overlay.clipLeft, overlay.left),
background: HIGHLIGHT_COLOR,
color: '#fff',
fontSize: 10,
@@ -16,6 +16,8 @@ export interface OverlayState {
width: number;
height: number;
label: string;
clipLeft: number;
clipTop: number;
}
export interface DragRect {
@@ -26,7 +28,7 @@ export interface DragRect {
height: number;
}
const EMPTY_OVERLAY: OverlayState = { visible: false, top: 0, left: 0, width: 0, height: 0, label: '' };
const EMPTY_OVERLAY: OverlayState = { visible: false, top: 0, left: 0, width: 0, height: 0, label: '', clipLeft: 0, clipTop: 0 };
const EMPTY_DRAG: DragRect = { visible: false, top: 0, left: 0, width: 0, height: 0 };
const SEMANTIC_LABELS: Record<string, string> = {
@@ -69,6 +71,50 @@ function rectsIntersect(
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
export interface ClippedRect {
top: number;
left: number;
width: number;
height: number;
hidden: boolean;
clipLeft: number;
clipTop: number;
}
export function clipRectToAncestors(el: Element, raw: DOMRect): ClippedRect {
let left = raw.left;
let top = raw.top;
let right = raw.right;
let bottom = raw.bottom;
let clipLeft = -Infinity;
let clipTop = -Infinity;
let p: Element | null = el.parentElement;
while (p && p !== document.body && p !== document.documentElement) {
const s = getComputedStyle(p);
if (s.overflowX !== 'visible' || s.overflowY !== 'visible') {
const r = p.getBoundingClientRect();
left = Math.max(left, r.left);
top = Math.max(top, r.top);
right = Math.min(right, r.right);
bottom = Math.min(bottom, r.bottom);
clipLeft = Math.max(clipLeft, r.left);
clipTop = Math.max(clipTop, r.top);
}
p = p.parentElement;
}
const width = right - left;
const height = bottom - top;
return {
top,
left,
width,
height,
hidden: width <= 0 || height <= 0,
clipLeft: clipLeft === -Infinity ? raw.left : clipLeft,
clipTop: clipTop === -Infinity ? raw.top : clipTop,
};
}
function buildSelectedElement(el: Element): SelectedElement {
const type = el.getAttribute(SELECT_ATTR) || '';
const selectId = el.getAttribute(SELECT_ID_ATTR) || '';
@@ -99,6 +145,8 @@ export interface DragPreviewElement {
height: number;
label: string;
action: 'add' | 'remove';
clipLeft: number;
clipTop: number;
}
const DRAG_THRESHOLD = 5;
@@ -182,18 +230,22 @@ export function useDomElementSelector(): DomSelectorState {
const rect = el.getBoundingClientRect();
if (rectsIntersect(b, { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom })) {
if (seen.has(selectId)) return;
const clipped = clipRectToAncestors(el, rect);
if (clipped.hidden) return;
seen.add(selectId);
const type = el.getAttribute(SELECT_ATTR) || '';
let meta: Record<string, any> = {};
try { meta = JSON.parse(el.getAttribute(SELECT_META_ATTR) || '{}'); } catch {}
preview.push({
selectId,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
top: clipped.top,
left: clipped.left,
width: clipped.width,
height: clipped.height,
label: buildSemanticLabel(type, meta),
action: selectedIdsRef.current.has(selectId) ? 'remove' : 'add',
clipLeft: clipped.clipLeft,
clipTop: clipped.clipTop,
});
}
});
@@ -228,17 +280,24 @@ export function useDomElementSelector(): DomSelectorState {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const rect = selectable.getBoundingClientRect();
const clipped = clipRectToAncestors(selectable, rect);
if (clipped.hidden) {
setOverlay(EMPTY_OVERLAY);
return;
}
const type = selectable.getAttribute(SELECT_ATTR) || '';
let meta: Record<string, any> = {};
try { meta = JSON.parse(selectable.getAttribute(SELECT_META_ATTR) || '{}'); } catch {}
const label = buildSemanticLabel(type, meta);
setOverlay({
visible: true,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
top: clipped.top,
left: clipped.left,
width: clipped.width,
height: clipped.height,
label,
clipLeft: clipped.clipLeft,
clipTop: clipped.clipTop,
});
});
}, []);