hangwin--mcp-chrome
893 行
26 KiB
TypeScript
893 行
26 KiB
TypeScript
/**
|
|
* Selection Engine (Phase 1.6 - Basic)
|
|
*
|
|
* Heuristic-based target picking to reduce noisy selections.
|
|
*
|
|
* Goals:
|
|
* - Skip invisible/transparent elements
|
|
* - De-prioritize "wrapper-only" elements (single-child, no visual boundary)
|
|
* - Prefer interactive elements (button/link/input/etc.)
|
|
* - Prefer elements with visual boundaries (border/background/shadow)
|
|
* - Support basic parent drilling via Alt modifier
|
|
*
|
|
* Scoring system:
|
|
* - Positive scores: interactive elements, visual boundaries, appropriate size
|
|
* - Negative scores: wrapper-only, too small/large, SVG internals
|
|
* - Candidates sorted by score descending, then by DOM order
|
|
*/
|
|
|
|
import { Disposer } from '../utils/disposables';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
/** Options for creating the selection engine */
|
|
export interface SelectionEngineOptions {
|
|
/** Check if a DOM node belongs to the editor overlay */
|
|
isOverlayElement: (node: unknown) => boolean;
|
|
}
|
|
|
|
/** A scored selection candidate */
|
|
export interface SelectionCandidate {
|
|
/** The candidate element */
|
|
element: Element;
|
|
/** Heuristic score (higher = better target) */
|
|
score: number;
|
|
/** Debug reasons explaining the score */
|
|
reasons: string[];
|
|
/** Whether this element is a wrapper-only container (no visual meaning) */
|
|
wrapperOnly?: boolean;
|
|
}
|
|
|
|
/** Keyboard modifiers for selection behavior */
|
|
export interface Modifiers {
|
|
alt: boolean;
|
|
shift: boolean;
|
|
ctrl: boolean;
|
|
meta: boolean;
|
|
}
|
|
|
|
/** Selection engine public interface */
|
|
export interface SelectionEngine {
|
|
/** Find the best target at a viewport point with modifier support */
|
|
findBestTarget(x: number, y: number, modifiers: Modifiers): Element | null;
|
|
/**
|
|
* Find the best target from an Event (Shadow DOM aware via composedPath).
|
|
* Intended for click/selection only; hover should stay coordinate-based for performance.
|
|
*
|
|
* - Uses composedPath() to access elements inside Shadow DOM
|
|
* - Ctrl/Cmd + Click: selects innermost visible element (drill-in)
|
|
* - Alt + Click: selects parent element (drill-up)
|
|
*/
|
|
findBestTargetFromEvent(event: Event, modifiers: Modifiers): Element | null;
|
|
/** Get scored candidates at a point (for debugging or drill-up UI) */
|
|
getCandidatesAtPoint(x: number, y: number): SelectionCandidate[];
|
|
/** Get a meaningful parent candidate (for Alt drill-up) */
|
|
getParentCandidate(current: Element): Element | null;
|
|
/** Cleanup */
|
|
dispose(): void;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Constants
|
|
// =============================================================================
|
|
|
|
/** Max elements from elementsFromPoint to process */
|
|
const MAX_HIT_ELEMENTS = 8;
|
|
|
|
/** Max ancestor depth to traverse */
|
|
const MAX_ANCESTOR_DEPTH = 6;
|
|
|
|
/** Max total candidates to consider */
|
|
const MAX_CANDIDATES = 60;
|
|
|
|
/** Epsilon for rect comparisons */
|
|
const RECT_EPSILON = 0.5;
|
|
|
|
/** Tags that are inherently interactive */
|
|
const INTERACTIVE_TAGS = new Set([
|
|
'A',
|
|
'BUTTON',
|
|
'INPUT',
|
|
'SELECT',
|
|
'TEXTAREA',
|
|
'LABEL',
|
|
'SUMMARY',
|
|
'DETAILS',
|
|
]);
|
|
|
|
/** ARIA roles that indicate interactivity */
|
|
const INTERACTIVE_ROLES = new Set([
|
|
'button',
|
|
'link',
|
|
'checkbox',
|
|
'radio',
|
|
'switch',
|
|
'tab',
|
|
'menuitem',
|
|
'option',
|
|
'combobox',
|
|
'textbox',
|
|
]);
|
|
|
|
/** Tags commonly used as layout wrappers */
|
|
const WRAPPER_TAGS = new Set([
|
|
'DIV',
|
|
'SPAN',
|
|
'SECTION',
|
|
'ARTICLE',
|
|
'MAIN',
|
|
'HEADER',
|
|
'FOOTER',
|
|
'NAV',
|
|
'ASIDE',
|
|
]);
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Parse a CSS numeric value
|
|
*/
|
|
function parseNumber(value: string): number {
|
|
const n = Number.parseFloat(value);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
/**
|
|
* Check if a color is effectively transparent
|
|
*/
|
|
function isTransparentColor(value: string): boolean {
|
|
const v = value.trim().toLowerCase();
|
|
if (v === 'transparent') return true;
|
|
|
|
// Check rgba() with alpha <= 0.01
|
|
const rgba = v.match(/^rgba?\((.+)\)$/);
|
|
if (rgba) {
|
|
const parts = rgba[1].split(',').map((p) => p.trim());
|
|
if (parts.length >= 4) {
|
|
const alpha = Number.parseFloat(parts[3]);
|
|
return Number.isFinite(alpha) && alpha <= 0.01;
|
|
}
|
|
// rgb() without alpha is opaque
|
|
return false;
|
|
}
|
|
|
|
// Check hsla() with alpha <= 0.01
|
|
const hsla = v.match(/^hsla?\((.+)\)$/);
|
|
if (hsla) {
|
|
const parts = hsla[1].split(',').map((p) => p.trim());
|
|
if (parts.length >= 4) {
|
|
const alpha = Number.parseFloat(parts[3]);
|
|
return Number.isFinite(alpha) && alpha <= 0.01;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// hex and other formats are opaque
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if element has direct text content (not just whitespace)
|
|
*/
|
|
function hasDirectNonWhitespaceText(element: Element): boolean {
|
|
for (const node of Array.from(element.childNodes)) {
|
|
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get parent element, crossing Shadow DOM boundaries
|
|
*/
|
|
function getParentElementOrHost(element: Element): Element | null {
|
|
if (element.parentElement) return element.parentElement;
|
|
|
|
try {
|
|
const root = element.getRootNode?.();
|
|
if (root instanceof ShadowRoot) {
|
|
return root.host;
|
|
}
|
|
} catch {
|
|
// Ignore and fall back to null
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get elements at a viewport point
|
|
*/
|
|
function getHitElementsAtPoint(x: number, y: number): Element[] {
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return [];
|
|
|
|
try {
|
|
if (typeof document.elementsFromPoint === 'function') {
|
|
return document.elementsFromPoint(x, y);
|
|
}
|
|
} catch {
|
|
// Fall back to elementFromPoint
|
|
}
|
|
|
|
const el = document.elementFromPoint(x, y);
|
|
return el ? [el] : [];
|
|
}
|
|
|
|
/**
|
|
* Get viewport area for size ratio calculations
|
|
*/
|
|
function getViewportArea(): number {
|
|
const w = Math.max(1, window.innerWidth || 1);
|
|
const h = Math.max(1, window.innerHeight || 1);
|
|
return w * h;
|
|
}
|
|
|
|
/**
|
|
* Safely read element rect
|
|
*/
|
|
function readRect(element: Element): DOMRectReadOnly | null {
|
|
try {
|
|
const rect = element.getBoundingClientRect();
|
|
if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top)) return null;
|
|
if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) return null;
|
|
return rect;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if element is effectively invisible
|
|
*/
|
|
function isEffectivelyInvisible(style: CSSStyleDeclaration, rect: DOMRectReadOnly): boolean {
|
|
if (style.display === 'none') return true;
|
|
if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
|
|
if (parseNumber(style.opacity) <= 0.01) return true;
|
|
|
|
// Check contentVisibility (non-standard property)
|
|
const contentVisibility = (style as unknown as Record<string, unknown>).contentVisibility;
|
|
if (contentVisibility === 'hidden') return true;
|
|
|
|
// Zero-dimension elements
|
|
if (rect.width <= RECT_EPSILON || rect.height <= RECT_EPSILON) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Score element based on visual boundary presence
|
|
*/
|
|
function getVisualBoundaryScore(
|
|
element: Element,
|
|
style: CSSStyleDeclaration,
|
|
): { points: number; reasons: string[] } {
|
|
let points = 0;
|
|
const reasons: string[] = [];
|
|
|
|
// Background color or image
|
|
if (!isTransparentColor(style.backgroundColor) || style.backgroundImage !== 'none') {
|
|
points += 2;
|
|
reasons.push('visual:background:+2');
|
|
}
|
|
|
|
// Border
|
|
const borderWidths = [
|
|
parseNumber(style.borderTopWidth),
|
|
parseNumber(style.borderRightWidth),
|
|
parseNumber(style.borderBottomWidth),
|
|
parseNumber(style.borderLeftWidth),
|
|
];
|
|
const hasBorder =
|
|
borderWidths.some((w) => w > RECT_EPSILON) &&
|
|
(style.borderTopStyle !== 'none' ||
|
|
style.borderRightStyle !== 'none' ||
|
|
style.borderBottomStyle !== 'none' ||
|
|
style.borderLeftStyle !== 'none');
|
|
if (hasBorder) {
|
|
points += 3;
|
|
reasons.push('visual:border:+3');
|
|
}
|
|
|
|
// Box shadow
|
|
if (style.boxShadow && style.boxShadow !== 'none') {
|
|
points += 2;
|
|
reasons.push('visual:shadow:+2');
|
|
}
|
|
|
|
// Outline
|
|
if (style.outlineStyle !== 'none' && parseNumber(style.outlineWidth) > RECT_EPSILON) {
|
|
points += 1;
|
|
reasons.push('visual:outline:+1');
|
|
}
|
|
|
|
// Media elements are visually meaningful
|
|
const tag = element.tagName.toUpperCase();
|
|
if (tag === 'IMG' || tag === 'VIDEO' || tag === 'CANVAS' || tag === 'SVG') {
|
|
points += 2;
|
|
reasons.push('visual:media:+2');
|
|
}
|
|
|
|
// SVG sub-elements usually aren't meaningful targets
|
|
if (element instanceof SVGElement && tag !== 'SVG') {
|
|
points -= 1;
|
|
reasons.push('visual:svg-sub:-1');
|
|
}
|
|
|
|
return { points, reasons };
|
|
}
|
|
|
|
/**
|
|
* Score element based on interactivity
|
|
*/
|
|
function getInteractivityScore(
|
|
element: Element,
|
|
style: CSSStyleDeclaration,
|
|
): { points: number; reasons: string[] } {
|
|
let points = 0;
|
|
const reasons: string[] = [];
|
|
|
|
const tag = element.tagName.toUpperCase();
|
|
|
|
// Interactive tags
|
|
if (INTERACTIVE_TAGS.has(tag)) {
|
|
points += 6;
|
|
reasons.push(`type:${tag.toLowerCase()}:+6`);
|
|
}
|
|
|
|
// Interactive roles
|
|
const role = element.getAttribute('role')?.toLowerCase() ?? '';
|
|
if (role && INTERACTIVE_ROLES.has(role)) {
|
|
points += 4;
|
|
reasons.push(`role:${role}:+4`);
|
|
}
|
|
|
|
// Anchor with href
|
|
if (element instanceof HTMLAnchorElement && element.href) {
|
|
points += 2;
|
|
reasons.push('attr:href:+2');
|
|
}
|
|
|
|
// Content editable
|
|
if (element instanceof HTMLElement) {
|
|
if (element.isContentEditable) {
|
|
points += 5;
|
|
reasons.push('attr:contenteditable:+5');
|
|
}
|
|
|
|
// Focusable
|
|
if (element.tabIndex >= 0) {
|
|
points += 2;
|
|
reasons.push('focusable:+2');
|
|
}
|
|
}
|
|
|
|
// Pointer cursor often indicates clickability
|
|
if (style.cursor === 'pointer') {
|
|
points += 2;
|
|
reasons.push('cursor:pointer:+2');
|
|
}
|
|
|
|
return { points, reasons };
|
|
}
|
|
|
|
/**
|
|
* Score element based on size
|
|
*/
|
|
function getSizeScore(
|
|
rect: DOMRectReadOnly,
|
|
viewportArea: number,
|
|
): { points: number; reasons: string[] } {
|
|
let points = 0;
|
|
const reasons: string[] = [];
|
|
|
|
const area = rect.width * rect.height;
|
|
if (!Number.isFinite(area) || area <= 0) {
|
|
points -= 6;
|
|
reasons.push('size:invalid:-6');
|
|
return { points, reasons };
|
|
}
|
|
|
|
// Too small: hard to interact with
|
|
if (rect.width < 4 || rect.height < 4) {
|
|
points -= 6;
|
|
reasons.push('size:tiny:-6');
|
|
} else if (area < 16 * 16) {
|
|
points -= 4;
|
|
reasons.push('size:small:-4');
|
|
} else if (area < 44 * 44) {
|
|
// Below recommended tap target size
|
|
points -= 1;
|
|
reasons.push('size:below-tap-target:-1');
|
|
}
|
|
|
|
// Too large: likely a layout container
|
|
const ratio = viewportArea > 0 ? area / viewportArea : 0;
|
|
if (ratio > 0.85) {
|
|
points -= 8;
|
|
reasons.push('size:huge:-8');
|
|
} else if (ratio > 0.6) {
|
|
points -= 4;
|
|
reasons.push('size:very-large:-4');
|
|
}
|
|
|
|
return { points, reasons };
|
|
}
|
|
|
|
/**
|
|
* Check if element has meaningful padding
|
|
*/
|
|
function hasMeaningfulPadding(style: CSSStyleDeclaration): boolean {
|
|
return (
|
|
parseNumber(style.paddingTop) > RECT_EPSILON ||
|
|
parseNumber(style.paddingRight) > RECT_EPSILON ||
|
|
parseNumber(style.paddingBottom) > RECT_EPSILON ||
|
|
parseNumber(style.paddingLeft) > RECT_EPSILON
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if element is a wrapper-only container
|
|
*/
|
|
function isWrapperOnly(
|
|
element: Element,
|
|
style: CSSStyleDeclaration,
|
|
visualScore: number,
|
|
interactivityScore: number,
|
|
): boolean {
|
|
// display: contents has no box
|
|
if (style.display === 'contents') return true;
|
|
|
|
// Interactive elements are never pure wrappers
|
|
if (interactivityScore > 0) return false;
|
|
|
|
// Only check common wrapper tags
|
|
const tag = element.tagName.toUpperCase();
|
|
if (!WRAPPER_TAGS.has(tag)) return false;
|
|
|
|
// Must have exactly one child element
|
|
if (element.children.length !== 1) return false;
|
|
|
|
// Has direct text content = meaningful
|
|
if (hasDirectNonWhitespaceText(element)) return false;
|
|
|
|
// Has visual boundary = meaningful
|
|
if (visualScore > 0) return false;
|
|
|
|
// Has padding = meaningful
|
|
if (hasMeaningfulPadding(style)) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Metadata for candidate ordering */
|
|
interface CandidateMeta {
|
|
hitOrder: number;
|
|
depthFromHit: number;
|
|
}
|
|
|
|
/**
|
|
* Compare candidate metadata for ordering
|
|
*/
|
|
function compareMeta(a: CandidateMeta, b: CandidateMeta): number {
|
|
if (a.hitOrder !== b.hitOrder) return a.hitOrder - b.hitOrder;
|
|
return a.depthFromHit - b.depthFromHit;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Implementation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create a selection engine for intelligent element picking.
|
|
*/
|
|
export function createSelectionEngine(options: SelectionEngineOptions): SelectionEngine {
|
|
const disposer = new Disposer();
|
|
const { isOverlayElement } = options;
|
|
|
|
/**
|
|
* Score a single element
|
|
*/
|
|
function scoreElement(
|
|
element: Element,
|
|
styleCache: Map<Element, CSSStyleDeclaration>,
|
|
viewportArea: number,
|
|
): SelectionCandidate | null {
|
|
// Basic filters
|
|
if (!element.isConnected) return null;
|
|
if (isOverlayElement(element)) return null;
|
|
|
|
const tag = element.tagName.toUpperCase();
|
|
if (tag === 'HTML' || tag === 'BODY') return null;
|
|
|
|
const rect = readRect(element);
|
|
if (!rect) return null;
|
|
|
|
// Get or cache computed style
|
|
let style = styleCache.get(element);
|
|
if (!style) {
|
|
style = window.getComputedStyle(element);
|
|
styleCache.set(element, style);
|
|
}
|
|
|
|
if (isEffectivelyInvisible(style, rect)) return null;
|
|
|
|
// Calculate scores
|
|
const reasons: string[] = [];
|
|
let score = 0;
|
|
|
|
const interactivity = getInteractivityScore(element, style);
|
|
score += interactivity.points;
|
|
reasons.push(...interactivity.reasons);
|
|
|
|
const visual = getVisualBoundaryScore(element, style);
|
|
score += visual.points;
|
|
reasons.push(...visual.reasons);
|
|
|
|
const size = getSizeScore(rect, viewportArea);
|
|
score += size.points;
|
|
reasons.push(...size.reasons);
|
|
|
|
// Check wrapper-only status and penalize
|
|
const wrapperOnly = isWrapperOnly(element, style, visual.points, interactivity.points);
|
|
if (wrapperOnly) {
|
|
score -= 8;
|
|
reasons.push('wrapperOnly:-8');
|
|
}
|
|
|
|
// De-prioritize generic inline spans
|
|
if (tag === 'SPAN' && interactivity.points === 0 && visual.points === 0) {
|
|
score -= 2;
|
|
reasons.push('inline:span:-2');
|
|
}
|
|
|
|
// Large fixed elements are often overlays/headers
|
|
const area = rect.width * rect.height;
|
|
const ratio = viewportArea > 0 ? area / viewportArea : 0;
|
|
if (style.position === 'fixed' && ratio > 0.3) {
|
|
score -= 2;
|
|
reasons.push('position:fixed-large:-2');
|
|
}
|
|
|
|
return { element, score, reasons, wrapperOnly };
|
|
}
|
|
|
|
/**
|
|
* Get all scored candidates at a point
|
|
*/
|
|
function getCandidatesAtPoint(x: number, y: number): SelectionCandidate[] {
|
|
const hit = getHitElementsAtPoint(x, y);
|
|
if (hit.length === 0) return [];
|
|
|
|
// Collect candidates with metadata
|
|
const map = new Map<Element, CandidateMeta>();
|
|
|
|
function addCandidate(element: Element, meta: CandidateMeta): void {
|
|
if (isOverlayElement(element)) return;
|
|
if (map.size >= MAX_CANDIDATES && !map.has(element)) return;
|
|
|
|
const prev = map.get(element);
|
|
if (!prev || compareMeta(meta, prev) < 0) {
|
|
map.set(element, meta);
|
|
}
|
|
}
|
|
|
|
// Process hit elements and their ancestors
|
|
const limit = Math.min(hit.length, MAX_HIT_ELEMENTS);
|
|
for (let i = 0; i < limit; i++) {
|
|
const el = hit[i];
|
|
addCandidate(el, { hitOrder: i, depthFromHit: 0 });
|
|
|
|
// Traverse ancestors
|
|
let current: Element | null = el;
|
|
for (let depth = 1; depth <= MAX_ANCESTOR_DEPTH; depth++) {
|
|
current = current ? getParentElementOrHost(current) : null;
|
|
if (!current) break;
|
|
addCandidate(current, { hitOrder: i, depthFromHit: depth });
|
|
}
|
|
}
|
|
|
|
// Score all candidates
|
|
const viewportArea = getViewportArea();
|
|
const styleCache = new Map<Element, CSSStyleDeclaration>();
|
|
|
|
const scored: Array<SelectionCandidate & CandidateMeta> = [];
|
|
for (const [element, meta] of map) {
|
|
const candidate = scoreElement(element, styleCache, viewportArea);
|
|
if (!candidate) continue;
|
|
scored.push({ ...candidate, ...meta });
|
|
}
|
|
|
|
// Sort by score (descending), then by DOM order
|
|
scored.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
return compareMeta(a, b);
|
|
});
|
|
|
|
// Strip metadata from result
|
|
return scored.map(({ hitOrder: _, depthFromHit: __, ...c }) => c);
|
|
}
|
|
|
|
/**
|
|
* Get a meaningful parent candidate for drill-up
|
|
*/
|
|
function getParentCandidate(current: Element): Element | null {
|
|
let parent = getParentElementOrHost(current);
|
|
if (!parent) return null;
|
|
|
|
const viewportArea = getViewportArea();
|
|
const styleCache = new Map<Element, CSSStyleDeclaration>();
|
|
|
|
while (parent) {
|
|
if (isOverlayElement(parent)) return null;
|
|
|
|
const tag = parent.tagName.toUpperCase();
|
|
if (tag === 'HTML' || tag === 'BODY') return null;
|
|
|
|
const rect = readRect(parent);
|
|
if (!rect) {
|
|
parent = getParentElementOrHost(parent);
|
|
continue;
|
|
}
|
|
|
|
let style = styleCache.get(parent);
|
|
if (!style) {
|
|
style = window.getComputedStyle(parent);
|
|
styleCache.set(parent, style);
|
|
}
|
|
|
|
if (isEffectivelyInvisible(style, rect)) {
|
|
parent = getParentElementOrHost(parent);
|
|
continue;
|
|
}
|
|
|
|
const interactivity = getInteractivityScore(parent, style);
|
|
const visual = getVisualBoundaryScore(parent, style);
|
|
|
|
// Return first non-wrapper parent
|
|
if (!isWrapperOnly(parent, style, visual.points, interactivity.points)) {
|
|
return parent;
|
|
}
|
|
|
|
parent = getParentElementOrHost(parent);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find the best target at a point with modifier support
|
|
*/
|
|
function findBestTarget(x: number, y: number, modifiers: Modifiers): Element | null {
|
|
const candidates = getCandidatesAtPoint(x, y);
|
|
const best = candidates[0]?.element ?? null;
|
|
if (!best) return null;
|
|
|
|
// Alt modifier: drill up to parent
|
|
if (modifiers.alt) {
|
|
return getParentCandidate(best) ?? best;
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Shadow DOM (composedPath) Support - Phase 2.1
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Extract Element nodes from an event's composedPath(), filtering overlay elements.
|
|
* Returns elements ordered from innermost to outermost.
|
|
*
|
|
* Why composedPath?
|
|
* - When events bubble from inside Shadow DOM, they get "retargeted" at shadow boundaries
|
|
* - By the time a document-level listener receives the event, event.target points to the shadow host
|
|
* - composedPath() exposes the original event path before retargeting
|
|
*/
|
|
function getComposedPathElements(event: Event): Element[] {
|
|
try {
|
|
const path = typeof event.composedPath === 'function' ? event.composedPath() : null;
|
|
if (!Array.isArray(path) || path.length === 0) return [];
|
|
|
|
const elements: Element[] = [];
|
|
for (const node of path) {
|
|
// Skip non-Element nodes (Text, Document, Window, etc.)
|
|
if (!(node instanceof Element)) continue;
|
|
// Skip overlay UI elements
|
|
if (isOverlayElement(node)) continue;
|
|
// Skip HTML and BODY
|
|
const tag = node.tagName.toUpperCase();
|
|
if (tag === 'HTML' || tag === 'BODY') continue;
|
|
|
|
elements.push(node);
|
|
}
|
|
return elements;
|
|
} catch {
|
|
// composedPath() may throw in edge cases (e.g., detached nodes)
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract viewport coordinates from a MouseEvent/PointerEvent.
|
|
*/
|
|
function extractClientPoint(event: Event): { x: number; y: number } | null {
|
|
const e = event as unknown as { clientX?: unknown; clientY?: unknown };
|
|
const x = typeof e.clientX === 'number' ? e.clientX : Number.NaN;
|
|
const y = typeof e.clientY === 'number' ? e.clientY : Number.NaN;
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
return { x, y };
|
|
}
|
|
|
|
/** Max elements to scan for innermost visible (performance guard) */
|
|
const MAX_INNERMOST_SCAN = 32;
|
|
|
|
/**
|
|
* Find innermost visible element from composedPath (for Ctrl/Cmd + Click drill-in).
|
|
* Returns the first element that passes visibility checks.
|
|
* Limited to MAX_INNERMOST_SCAN elements to prevent performance issues in deep DOMs.
|
|
*/
|
|
function findInnermostVisible(pathElements: Element[]): Element | null {
|
|
const viewportArea = getViewportArea();
|
|
const styleCache = new Map<Element, CSSStyleDeclaration>();
|
|
const limit = Math.min(pathElements.length, MAX_INNERMOST_SCAN);
|
|
|
|
for (let i = 0; i < limit; i++) {
|
|
const element = pathElements[i];
|
|
// scoreElement returns null for invisible/invalid elements
|
|
const candidate = scoreElement(element, styleCache, viewportArea);
|
|
if (candidate) return candidate.element;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get candidates from composedPath elements using the same scoring logic.
|
|
* Merges with point-based candidates for comprehensive selection.
|
|
*/
|
|
function getCandidatesFromPath(
|
|
pathElements: Element[],
|
|
point: { x: number; y: number } | null,
|
|
): SelectionCandidate[] {
|
|
if (pathElements.length === 0 && !point) return [];
|
|
|
|
const map = new Map<Element, CandidateMeta>();
|
|
|
|
function addCandidate(element: Element, meta: CandidateMeta): void {
|
|
if (isOverlayElement(element)) return;
|
|
if (map.size >= MAX_CANDIDATES && !map.has(element)) return;
|
|
|
|
const prev = map.get(element);
|
|
if (!prev || compareMeta(meta, prev) < 0) {
|
|
map.set(element, meta);
|
|
}
|
|
}
|
|
|
|
// Add composedPath elements with high priority (hitOrder = 0)
|
|
// These are the "seeds" from the actual event path
|
|
for (let i = 0; i < pathElements.length && i < MAX_HIT_ELEMENTS; i++) {
|
|
const el = pathElements[i];
|
|
addCandidate(el, { hitOrder: 0, depthFromHit: i });
|
|
|
|
// Also traverse ancestors (cross Shadow DOM boundaries)
|
|
let current: Element | null = el;
|
|
for (let depth = 1; depth <= MAX_ANCESTOR_DEPTH; depth++) {
|
|
current = current ? getParentElementOrHost(current) : null;
|
|
if (!current) break;
|
|
addCandidate(current, { hitOrder: 0, depthFromHit: i + depth });
|
|
}
|
|
}
|
|
|
|
// Merge with point-based candidates if available (for better coverage)
|
|
if (point) {
|
|
const hitElements = getHitElementsAtPoint(point.x, point.y);
|
|
const limit = Math.min(hitElements.length, MAX_HIT_ELEMENTS);
|
|
for (let i = 0; i < limit; i++) {
|
|
const el = hitElements[i];
|
|
// hitOrder = 1 to give composedPath elements priority in tie-breaking
|
|
addCandidate(el, { hitOrder: 1, depthFromHit: 0 });
|
|
|
|
let current: Element | null = el;
|
|
for (let depth = 1; depth <= MAX_ANCESTOR_DEPTH; depth++) {
|
|
current = current ? getParentElementOrHost(current) : null;
|
|
if (!current) break;
|
|
addCandidate(current, { hitOrder: 1, depthFromHit: depth });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Score all candidates
|
|
const viewportArea = getViewportArea();
|
|
const styleCache = new Map<Element, CSSStyleDeclaration>();
|
|
|
|
const scored: Array<SelectionCandidate & CandidateMeta> = [];
|
|
for (const [element, meta] of map) {
|
|
const candidate = scoreElement(element, styleCache, viewportArea);
|
|
if (!candidate) continue;
|
|
scored.push({ ...candidate, ...meta });
|
|
}
|
|
|
|
// Sort by score (descending), then by DOM order
|
|
scored.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
return compareMeta(a, b);
|
|
});
|
|
|
|
return scored.map(({ hitOrder: _, depthFromHit: __, ...c }) => c);
|
|
}
|
|
|
|
/**
|
|
* Event-aware selection entry point (Shadow DOM support).
|
|
*
|
|
* Uses composedPath() to access elements inside Shadow DOM that would
|
|
* otherwise be inaccessible due to event retargeting.
|
|
*
|
|
* Strategy: "Hit-first, climb-if-meaningless"
|
|
* - Default: Select the direct hit element (what user clicked)
|
|
* - If direct hit is wrapper-only (no visual meaning), climb to first meaningful parent
|
|
* - This ensures clicking on boxC selects boxC, not boxA
|
|
*
|
|
* Modifier behavior:
|
|
* - Ctrl/Cmd + Click: Select innermost visible element (drill-in)
|
|
* - Alt + Click: Select parent of best target (drill-up)
|
|
*/
|
|
function findBestTargetFromEvent(event: Event, modifiers: Modifiers): Element | null {
|
|
const pathElements = getComposedPathElements(event);
|
|
const point = extractClientPoint(event);
|
|
|
|
// Ctrl/Cmd + Click: drill-in to innermost visible element
|
|
// Takes precedence over Alt (if both pressed, drill-in wins)
|
|
if (modifiers.ctrl || modifiers.meta) {
|
|
const innermost = findInnermostVisible(pathElements);
|
|
if (innermost) return innermost;
|
|
// Fallback to point-based selection
|
|
return point ? findBestTarget(point.x, point.y, modifiers) : null;
|
|
}
|
|
|
|
// Get the direct hit element (what user actually clicked)
|
|
// Priority: composedPath[0] > elementsFromPoint[0]
|
|
const directHit =
|
|
pathElements[0] ?? (point ? getHitElementsAtPoint(point.x, point.y)[0] : null);
|
|
|
|
if (!directHit) {
|
|
// No hit at all, fallback to old scoring-based approach
|
|
return point ? findBestTarget(point.x, point.y, modifiers) : null;
|
|
}
|
|
|
|
// Score the direct hit to check if it's meaningful
|
|
const viewportArea = getViewportArea();
|
|
const styleCache = new Map<Element, CSSStyleDeclaration>();
|
|
const directCandidate = scoreElement(directHit, styleCache, viewportArea);
|
|
|
|
// Determine the base target:
|
|
// - If direct hit is valid and not a wrapper-only, use it
|
|
// - Otherwise, climb to the first meaningful parent
|
|
let base: Element;
|
|
if (directCandidate && !directCandidate.wrapperOnly) {
|
|
base = directHit;
|
|
} else {
|
|
// Direct hit is invalid or wrapper-only, climb to meaningful parent
|
|
base = getParentCandidate(directHit) ?? directHit;
|
|
}
|
|
|
|
// Alt + Click: drill-up to parent
|
|
if (modifiers.alt) {
|
|
return getParentCandidate(base) ?? base;
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
return {
|
|
findBestTarget,
|
|
findBestTargetFromEvent,
|
|
getCandidatesAtPoint,
|
|
getParentCandidate,
|
|
dispose: () => disposer.dispose(),
|
|
};
|
|
}
|