/** * 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).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, 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(); 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(); const scored: Array = []; 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(); 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(); 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(); 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(); const scored: Array = []; 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(); 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(), }; }