From ded951b8a0d531a6e57222429ff1e356e960e95e Mon Sep 17 00:00:00 2001 From: Aidan Date: Wed, 10 Jun 2026 17:37:04 -0700 Subject: [PATCH] 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. --- .../components/editor/SelectionOverlay.tsx | 28 ++++--- .../editor/useDomElementSelector.ts | 77 ++++++++++++++++--- 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/components/editor/SelectionOverlay.tsx b/frontend/src/app/components/editor/SelectionOverlay.tsx index 51e534e4..54da1d50 100644 --- a/frontend/src/app/components/editor/SelectionOverlay.tsx +++ b/frontend/src/app/components/editor/SelectionOverlay.tsx @@ -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 = ({ 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 = ({ overlay, dragRect, dragPreview = []
= ({ overlay, dragRect, dragPreview = []
= ({ overlay, dragRect, dragPreview = []
= { @@ -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 = {}; 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 = {}; 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, }); }); }, []);