Skip to content

Commit 7d979fc

Browse files
committed
feat: add prehit spatial index as safe fallback accelerator
Add a pre-indexed element hit-testing system that replaces the expensive elementsFromPoint scan when elementFromPoint returns a non-grabbable element (decorative overlays, dev tools, etc.). Safety model: browser-native elementFromPoint is always tried first and trusted for all CSS edge cases. The spatial index only runs as a fallback, replacing the O(n) elementsFromPoint call with an O(log n) R-tree query + stacking order sort. Infrastructure: - HilbertRTree: packed R-tree with Hilbert curve ordering for spatial queries - compareStackingOrder: CSS stacking context comparator (z-index, opacity, transform, contain, backdrop-filter, perspective, clip-path) - isVisibleAtPoint: ancestor clip-chain walker with WeakMap cache - isDecorativeOverlay: filters empty positioned elements (now also used in isValidGrabbableElement for all detection paths) - Fixed elements cached with viewport rects and z-indexes at build time Includes 57 new e2e tests covering overflow clipping, CSS containment, stacking order, inline elements, decorative overlays, fixed position, transforms, dynamic DOM, and fixed-only pages.
1 parent 746c261 commit 7d979fc

12 files changed

Lines changed: 1910 additions & 19 deletions

apps/e2e-app/src/App.tsx

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,242 @@ const PointerUpModalSection = () => {
585585
);
586586
};
587587

588+
const OverflowClippingSection = () => {
589+
return (
590+
<section className="border rounded-lg p-4" data-testid="overflow-clipping-section">
591+
<h2 className="text-lg font-bold mb-4">Overflow Clipping</h2>
592+
<div className="space-y-4">
593+
<div
594+
className="overflow-hidden w-40 h-20 border-2 border-red-400 relative"
595+
data-testid="overflow-hidden-container"
596+
>
597+
<div className="absolute w-80 h-10 bg-blue-200 p-2" data-testid="overflow-clipped-wide">
598+
Wide clipped content
599+
</div>
600+
<div className="p-2 bg-green-200" data-testid="overflow-visible-child">
601+
Visible child
602+
</div>
603+
</div>
604+
605+
<div
606+
className="overflow-auto w-40 h-24 border-2 border-orange-400"
607+
data-testid="overflow-auto-container"
608+
>
609+
<div className="w-80 h-40 bg-yellow-100 p-2">
610+
<span data-testid="overflow-auto-inner">Scrollable inner content</span>
611+
</div>
612+
</div>
613+
614+
<div
615+
className="overflow-hidden border-2 border-purple-400"
616+
style={{ width: "200px", height: "40px" }}
617+
data-testid="overflow-nested-outer"
618+
>
619+
<div
620+
className="overflow-hidden border border-purple-200"
621+
style={{ width: "150px", height: "30px" }}
622+
data-testid="overflow-nested-inner"
623+
>
624+
<div
625+
className="bg-purple-100 p-1"
626+
style={{ width: "300px", height: "60px" }}
627+
data-testid="overflow-nested-content"
628+
>
629+
Nested clipped content
630+
</div>
631+
</div>
632+
</div>
633+
</div>
634+
</section>
635+
);
636+
};
637+
638+
const ContainPaintSection = () => {
639+
return (
640+
<section className="border rounded-lg p-4" data-testid="contain-paint-section">
641+
<h2 className="text-lg font-bold mb-4">CSS Containment</h2>
642+
<div className="space-y-4">
643+
<div
644+
style={{ contain: "paint", width: "150px", height: "40px" }}
645+
className="border-2 border-teal-400 relative"
646+
data-testid="contain-paint-container"
647+
>
648+
<div
649+
className="absolute bg-teal-200 p-2"
650+
style={{ width: "300px", height: "30px" }}
651+
data-testid="contain-paint-clipped"
652+
>
653+
Paint-contained clipped
654+
</div>
655+
</div>
656+
657+
<div
658+
style={{ contain: "strict", width: "150px", height: "40px" }}
659+
className="border-2 border-cyan-400 relative"
660+
data-testid="contain-strict-container"
661+
>
662+
<div className="bg-cyan-200 p-2" data-testid="contain-strict-child">
663+
Strict contained
664+
</div>
665+
</div>
666+
667+
<div
668+
style={{ contain: "content", width: "150px", height: "40px" }}
669+
className="border-2 border-sky-400 relative"
670+
data-testid="contain-content-container"
671+
>
672+
<div className="bg-sky-200 p-2" data-testid="contain-content-child">
673+
Content contained
674+
</div>
675+
</div>
676+
</div>
677+
</section>
678+
);
679+
};
680+
681+
const StackingOrderSection = () => {
682+
return (
683+
<section className="border rounded-lg p-4" data-testid="stacking-order-section">
684+
<h2 className="text-lg font-bold mb-4">Stacking Order</h2>
685+
<div className="relative" style={{ height: "120px" }}>
686+
<div
687+
className="absolute bg-red-300 p-4"
688+
style={{ top: "0", left: "0", width: "200px", height: "100px", zIndex: 1 }}
689+
data-testid="stacking-bottom"
690+
>
691+
Bottom (z-index: 1)
692+
</div>
693+
<div
694+
className="absolute bg-green-300 p-4"
695+
style={{ top: "20px", left: "20px", width: "200px", height: "100px", zIndex: 2 }}
696+
data-testid="stacking-middle"
697+
>
698+
Middle (z-index: 2)
699+
</div>
700+
<div
701+
className="absolute bg-blue-300 p-4"
702+
style={{ top: "40px", left: "40px", width: "200px", height: "100px", zIndex: 3 }}
703+
data-testid="stacking-top"
704+
>
705+
Top (z-index: 3)
706+
</div>
707+
</div>
708+
709+
<div className="relative mt-8" style={{ height: "80px" }}>
710+
<div
711+
className="absolute bg-amber-200 p-4"
712+
style={{ top: "0", left: "0", width: "250px", height: "60px" }}
713+
data-testid="stacking-large-behind"
714+
>
715+
Large element behind
716+
</div>
717+
<div
718+
className="absolute bg-amber-500 text-white p-2"
719+
style={{ top: "10px", left: "10px", width: "100px", height: "40px", zIndex: 1 }}
720+
data-testid="stacking-small-front"
721+
>
722+
Small in front
723+
</div>
724+
</div>
725+
</section>
726+
);
727+
};
728+
729+
const DecorativeOverlaySection = () => {
730+
return (
731+
<section className="border rounded-lg p-4" data-testid="decorative-overlay-section">
732+
<h2 className="text-lg font-bold mb-4">Decorative Overlays</h2>
733+
<div className="relative" style={{ height: "80px" }}>
734+
<div className="bg-indigo-200 p-4" data-testid="decorative-content">
735+
This content should be selectable
736+
</div>
737+
<div
738+
className="absolute inset-0"
739+
style={{ pointerEvents: "none" }}
740+
data-testid="decorative-empty-overlay"
741+
/>
742+
</div>
743+
744+
<div className="relative mt-4" style={{ height: "80px" }}>
745+
<p className="bg-pink-200 p-4" data-testid="decorative-text-content">
746+
Text content under a positioned empty div
747+
</p>
748+
<div
749+
className="absolute"
750+
style={{ top: "0", left: "0", width: "100%", height: "100%" }}
751+
data-testid="decorative-positioned-empty"
752+
/>
753+
</div>
754+
</section>
755+
);
756+
};
757+
758+
const InlineElementsSection = () => {
759+
return (
760+
<section className="border rounded-lg p-4" data-testid="inline-elements-section">
761+
<h2 className="text-lg font-bold mb-4">Inline Elements</h2>
762+
<p className="mb-2" data-testid="inline-paragraph">
763+
This paragraph has <span data-testid="inline-span">an inline span</span> and{" "}
764+
<a href="#" data-testid="inline-link" className="text-blue-500 underline">
765+
an inline link
766+
</a>{" "}
767+
and <em data-testid="inline-em">emphasized text</em> and{" "}
768+
<strong data-testid="inline-strong">strong text</strong> inside it.
769+
</p>
770+
<p className="mb-2">
771+
<code className="bg-gray-100 px-1" data-testid="inline-code">
772+
inline code element
773+
</code>
774+
</p>
775+
</section>
776+
);
777+
};
778+
779+
const TransformStackingSection = () => {
780+
return (
781+
<section className="border rounded-lg p-4" data-testid="transform-stacking-section">
782+
<h2 className="text-lg font-bold mb-4">Transform & Opacity Stacking</h2>
783+
<div className="space-y-4">
784+
<div
785+
className="bg-rose-200 p-4"
786+
style={{ transform: "translateZ(0)" }}
787+
data-testid="transform-element"
788+
>
789+
Transformed element (creates stacking context)
790+
</div>
791+
792+
<div className="bg-violet-200 p-4" style={{ opacity: 0.99 }} data-testid="opacity-element">
793+
Opacity element (creates stacking context)
794+
</div>
795+
796+
<div className="relative" style={{ height: "80px" }}>
797+
<div
798+
className="absolute bg-lime-200 p-4"
799+
style={{ top: "0", left: "0", width: "200px", height: "60px" }}
800+
data-testid="transform-behind"
801+
>
802+
Behind
803+
</div>
804+
<div
805+
className="absolute bg-lime-500 text-white p-2"
806+
style={{
807+
top: "10px",
808+
left: "10px",
809+
width: "120px",
810+
height: "40px",
811+
transform: "scale(1)",
812+
zIndex: 1,
813+
}}
814+
data-testid="transform-front"
815+
>
816+
Front (transform)
817+
</div>
818+
</div>
819+
</div>
820+
</section>
821+
);
822+
};
823+
588824
const HiddenToggleSection = () => {
589825
const [isVisible, setIsVisible] = useState(true);
590826
const elementRef = useRef<HTMLDivElement>(null);
@@ -648,6 +884,18 @@ export default function App() {
648884

649885
<PointerUpModalSection />
650886

887+
<OverflowClippingSection />
888+
889+
<ContainPaintSection />
890+
891+
<StackingOrderSection />
892+
893+
<DecorativeOverlaySection />
894+
895+
<InlineElementsSection />
896+
897+
<TransformStackingSection />
898+
651899
<HiddenToggleSection />
652900

653901
<div

0 commit comments

Comments
 (0)