feat(extension): hover chain node

This commit is contained in:
Ljy-0827
2026-08-07 15:45:38 +08:00
parent eeab97bcdd
commit 2ccc0aafb9
3 changed files with 783 additions and 75 deletions
@@ -8,6 +8,11 @@ export interface HoverSurfaceContext {
triggerElement: Element;
}
export interface HoverSurfaceState {
element: Element;
signature: string;
}
const HOVER_SURFACE_SELECTOR = [
'[role="menu"]',
'[role="menubar"]',
@@ -22,8 +27,11 @@ const HOVER_SURFACE_SELECTOR = [
].join(",");
const HOVER_SURFACE_WORD_RE =
/\b(menu|dropdown|drop-down|popover|popper|popup|tooltip|flyout|submenu|context-menu|account-menu|user-menu|avatar-menu)\b/i;
/(^|[^a-z0-9])(menu|dropdown|drop-down|popover|popper|popup|tooltip|flyout|submenu|context-menu|account-menu|user-menu|avatar-menu)(?=[^a-z0-9]|$)/i;
const HOVER_SURFACE_ITEM_RE =
/(^|[^a-z0-9])((dropdown|menu|submenu|context-menu)-?item|item-(text|label|content)|item__?(text|label|content))(?=[^a-z0-9]|$)/i;
const HOVER_SURFACE_MAX_AXIS_GAP = 96;
const HOVER_SURFACE_MAX_DIAGONAL_GAP = 16;
const HOVER_SURFACE_MAX_VIEWPORT_AREA_RATIO = 0.5;
function elementTokenText(el: Element): string {
@@ -40,9 +48,12 @@ function elementTokenText(el: Element): string {
.join(" ");
}
function isHoverSurfaceElement(el: Element): boolean {
export function isRecognizedHoverSurfaceElement(el: Element): boolean {
if (el.matches(HOVER_SURFACE_SELECTOR)) return true;
if (HOVER_SURFACE_WORD_RE.test(elementTokenText(el))) return true;
const tokenText = elementTokenText(el);
if (HOVER_SURFACE_WORD_RE.test(tokenText) && !HOVER_SURFACE_ITEM_RE.test(tokenText)) {
return true;
}
const tag = el.tagName.toLowerCase();
if ((tag === "ul" || tag === "ol") && el.querySelector("a,button,[role='menuitem']")) {
return HOVER_SURFACE_WORD_RE.test(elementTokenText(el.parentElement ?? el));
@@ -50,18 +61,27 @@ function isHoverSurfaceElement(el: Element): boolean {
return false;
}
function isVisibleSurfaceElement(el: Element): boolean {
if (!(el instanceof HTMLElement)) return false;
const style = getComputedStyle(el);
if (style.pointerEvents === "none" || style.visibility === "hidden" || style.display === "none") {
return false;
}
return rectFor(el) !== null;
}
export function closestRecognizedHoverSurface(element: Element): Element | null {
let node: Element | null = element;
let depth = 0;
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
if (isHoverSurfaceElement(node)) return node;
if (isRecognizedHoverSurfaceElement(node)) return node;
node = node.parentElement;
depth += 1;
}
return null;
}
function isPositionedFloatingElement(el: Element): boolean {
export function isPositionedFloatingElement(el: Element): boolean {
if (!(el instanceof HTMLElement)) return false;
const style = getComputedStyle(el);
if (!["absolute", "fixed"].includes(style.position)) return false;
@@ -77,6 +97,21 @@ function isPositionedFloatingElement(el: Element): boolean {
);
}
export function isHoverSurfaceCandidateElement(el: Element): boolean {
return isRecognizedHoverSurfaceElement(el) || isPositionedFloatingElement(el);
}
export function closestHoverSurfaceCandidate(element: Element): Element | null {
let node: Element | null = element;
let depth = 0;
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
if (isHoverSurfaceCandidateElement(node)) return node;
node = node.parentElement;
depth += 1;
}
return null;
}
function closestFloatingSurface(clickElement: Element): Element | null {
let node: Element | null = clickElement;
let depth = 0;
@@ -88,6 +123,26 @@ function closestFloatingSurface(clickElement: Element): Element | null {
return null;
}
function hoverSurfaceSignature(el: Element): string {
const rect = rectFor(el);
return [
rect
? `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.width)},${Math.round(rect.height)}`
: "",
(el.textContent ?? "").trim().slice(0, 512),
].join("|");
}
export function collectHoverSurfaceStates(): HoverSurfaceState[] {
const states: HoverSurfaceState[] = [];
for (const el of document.querySelectorAll("*")) {
if (isHoverSurfaceCandidateElement(el) && isVisibleSurfaceElement(el)) {
states.push({ element: el, signature: hoverSurfaceSignature(el) });
}
}
return states;
}
function rectFor(el: Element): DOMRect | null {
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
@@ -98,6 +153,10 @@ function isNearTrigger(surface: Element, trigger: Element): boolean {
const surfaceRect = rectFor(surface);
const triggerRect = rectFor(trigger);
if (!surfaceRect || !triggerRect) return true;
const horizontalOverlap =
Math.min(triggerRect.right, surfaceRect.right) - Math.max(triggerRect.left, surfaceRect.left);
const verticalOverlap =
Math.min(triggerRect.bottom, surfaceRect.bottom) - Math.max(triggerRect.top, surfaceRect.top);
const horizontalGap = Math.max(
0,
triggerRect.left - surfaceRect.right,
@@ -108,7 +167,11 @@ function isNearTrigger(surface: Element, trigger: Element): boolean {
triggerRect.top - surfaceRect.bottom,
surfaceRect.top - triggerRect.bottom,
);
return horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP && verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP;
if (horizontalOverlap > 0 && verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true;
if (verticalOverlap > 0 && horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true;
return (
horizontalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP && verticalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP
);
}
export function decideHoverSurfaceRelation(