/* eslint-disable */ // interactive-elements-helper.js // This script is injected into the page to find interactive elements. // Final version by Calvin, featuring a multi-layered fallback strategy // and comprehensive element support, built on a performant and reliable core. (function () { // Prevent re-initialization if (window.__INTERACTIVE_ELEMENTS_HELPER_INITIALIZED__) { return; } window.__INTERACTIVE_ELEMENTS_HELPER_INITIALIZED__ = true; /** * @typedef {Object} ElementInfo * @property {string} type - The type of the element (e.g., 'button', 'link'). * @property {string} selector - A CSS selector to uniquely identify the element. * @property {string} text - The visible text or accessible name of the element. * @property {boolean} isInteractive - Whether the element is currently interactive. * @property {Object} [coordinates] - The coordinates of the element if requested. * @property {boolean} [disabled] - For elements that can be disabled. * @property {string} [href] - For links. * @property {boolean} [checked] - for checkboxes and radio buttons. */ /** * Configuration for element types and their corresponding selectors. * Now more comprehensive with common ARIA roles. */ const ELEMENT_CONFIG = { button: 'button, input[type="button"], input[type="submit"], [role="button"]', link: 'a[href], [role="link"]', input: 'input:not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"])', checkbox: 'input[type="checkbox"], [role="checkbox"]', radio: 'input[type="radio"], [role="radio"]', textarea: 'textarea', select: 'select', tab: '[role="tab"]', // Generic interactive elements: combines tabindex, common roles, and explicit handlers. // This is the key to finding custom-built interactive components. interactive: `[onclick], [tabindex]:not([tabindex^="-"]), [role="menuitem"], [role="slider"], [role="option"], [role="treeitem"]`, }; // A combined selector for ANY interactive element, used in the fallback logic. const ANY_INTERACTIVE_SELECTOR = Object.values(ELEMENT_CONFIG).join(', '); // --- Core Helper Functions --- /** * Checks if an element is genuinely visible on the page. * "Visible" means it's not styled with display:none, visibility:hidden, etc. * This check intentionally IGNORES whether the element is within the current viewport. * @param {Element} el The element to check. * @returns {boolean} True if the element is visible. */ function isElementVisible(el) { if (!el || !el.isConnected) return false; const style = window.getComputedStyle(el); if ( style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0 ) { return false; } const rect = el.getBoundingClientRect(); return rect.width > 0 || rect.height > 0 || el.tagName === 'A'; // Allow zero-size anchors as they can still be navigated } /** * Checks if an element is considered interactive (not disabled or hidden from accessibility). * @param {Element} el The element to check. * @returns {boolean} True if the element is interactive. */ function isElementInteractive(el) { if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') { return false; } if (el.closest('[aria-hidden="true"]')) { return false; } return true; } /** * Generates a reasonably stable CSS selector for a given element. * @param {Element} el The element. * @returns {string} A CSS selector. */ function generateSelector(el) { if (!(el instanceof Element)) return ''; if (el.id) { const idSelector = `#${CSS.escape(el.id)}`; if (document.querySelectorAll(idSelector).length === 1) return idSelector; } for (const attr of ['data-testid', 'data-cy', 'name']) { const attrValue = el.getAttribute(attr); if (attrValue) { const attrSelector = `[${attr}="${CSS.escape(attrValue)}"]`; if (document.querySelectorAll(attrSelector).length === 1) return attrSelector; } } let path = ''; let current = el; while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') { let selector = current.tagName.toLowerCase(); const parent = current.parentElement; if (parent) { const siblings = Array.from(parent.children).filter( (child) => child.tagName === current.tagName, ); if (siblings.length > 1) { const index = siblings.indexOf(current) + 1; selector += `:nth-of-type(${index})`; } } path = path ? `${selector} > ${path}` : selector; current = parent; } return path ? `body > ${path}` : 'body'; } /** * Finds the accessible name for an element (label, aria-label, etc.). * @param {Element} el The element. * @returns {string} The accessible name. */ function getAccessibleName(el) { const labelledby = el.getAttribute('aria-labelledby'); if (labelledby) { const labelElement = document.getElementById(labelledby); if (labelElement) return labelElement.textContent?.trim() || ''; } const ariaLabel = el.getAttribute('aria-label'); if (ariaLabel) return ariaLabel.trim(); if (el.id) { const label = document.querySelector(`label[for="${el.id}"]`); if (label) return label.textContent?.trim() || ''; } const parentLabel = el.closest('label'); if (parentLabel) return parentLabel.textContent?.trim() || ''; return ( el.getAttribute('placeholder') || el.getAttribute('value') || el.textContent?.trim() || el.getAttribute('title') || '' ); } /** * Simple subsequence matching for fuzzy search. * @param {string} text The text to search within. * @param {string} query The query subsequence. * @returns {boolean} */ function fuzzyMatch(text, query) { if (!text || !query) return false; const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); let textIndex = 0; let queryIndex = 0; while (textIndex < lowerText.length && queryIndex < lowerQuery.length) { if (lowerText[textIndex] === lowerQuery[queryIndex]) { queryIndex++; } textIndex++; } return queryIndex === lowerQuery.length; } /** * Creates the standardized info object for an element. * Modified to handle the new 'text' type from the final fallback. */ function createElementInfo(el, type, includeCoordinates, isInteractiveOverride = null) { const isActuallyInteractive = isElementInteractive(el); const info = { type, selector: generateSelector(el), text: getAccessibleName(el) || el.textContent?.trim(), isInteractive: isInteractiveOverride !== null ? isInteractiveOverride : isActuallyInteractive, disabled: el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true', }; if (includeCoordinates) { const rect = el.getBoundingClientRect(); info.coordinates = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, }, }; } return info; } /** * [CORE UTILITY] Finds interactive elements based on a set of types. * This is our high-performance Layer 1 search function. */ function findInteractiveElements(options = {}) { const { textQuery, includeCoordinates = true, types = Object.keys(ELEMENT_CONFIG) } = options; const selectorsToFind = types .map((type) => ELEMENT_CONFIG[type]) .filter(Boolean) .join(', '); if (!selectorsToFind) return []; const targetElements = Array.from(document.querySelectorAll(selectorsToFind)); const uniqueElements = new Set(targetElements); const results = []; for (const el of uniqueElements) { if (!isElementVisible(el) || !isElementInteractive(el)) continue; const accessibleName = getAccessibleName(el); if (textQuery && !fuzzyMatch(accessibleName, textQuery)) continue; let elementType = 'unknown'; for (const [type, typeSelector] of Object.entries(ELEMENT_CONFIG)) { if (el.matches(typeSelector)) { elementType = type; break; } } results.push(createElementInfo(el, elementType, includeCoordinates)); } return results; } /** * [ORCHESTRATOR] The main entry point that implements the 3-layer fallback logic. * @param {object} options - The main search options. * @returns {ElementInfo[]} */ function findElementsByTextWithFallback(options = {}) { const { textQuery, includeCoordinates = true } = options; if (!textQuery) { return findInteractiveElements({ ...options, types: Object.keys(ELEMENT_CONFIG) }); } // --- Layer 1: High-reliability search for interactive elements matching text --- let results = findInteractiveElements({ ...options, types: Object.keys(ELEMENT_CONFIG) }); if (results.length > 0) { return results; } // --- Layer 2: Find text, then find its interactive ancestor --- const lowerCaseText = textQuery.toLowerCase(); const xPath = `//text()[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${lowerCaseText}')]`; const textNodes = document.evaluate( xPath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null, ); const interactiveElements = new Set(); if (textNodes.snapshotLength > 0) { for (let i = 0; i < textNodes.snapshotLength; i++) { const parentElement = textNodes.snapshotItem(i).parentElement; if (parentElement) { const interactiveAncestor = parentElement.closest(ANY_INTERACTIVE_SELECTOR); if ( interactiveAncestor && isElementVisible(interactiveAncestor) && isElementInteractive(interactiveAncestor) ) { interactiveElements.add(interactiveAncestor); } } } if (interactiveElements.size > 0) { return Array.from(interactiveElements).map((el) => { let elementType = 'interactive'; for (const [type, typeSelector] of Object.entries(ELEMENT_CONFIG)) { if (el.matches(typeSelector)) { elementType = type; break; } } return createElementInfo(el, elementType, includeCoordinates); }); } } // --- Layer 3: Final fallback, return any element containing the text --- const leafElements = new Set(); for (let i = 0; i < textNodes.snapshotLength; i++) { const parentElement = textNodes.snapshotItem(i).parentElement; if (parentElement && isElementVisible(parentElement)) { leafElements.add(parentElement); } } const finalElements = Array.from(leafElements).filter((el) => { return ![...leafElements].some((otherEl) => el !== otherEl && el.contains(otherEl)); }); return finalElements.map((el) => createElementInfo(el, 'text', includeCoordinates, true)); } // --- Chrome Message Listener --- chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { if (request.action === 'getInteractiveElements') { try { let elements; if (request.selector) { // If a selector is provided, bypass the text-based logic and use a direct query. const foundEls = Array.from(document.querySelectorAll(request.selector)); elements = foundEls.map((el) => createElementInfo( el, 'selected', request.includeCoordinates !== false, isElementInteractive(el), ), ); } else { // Otherwise, use our powerful multi-layered text search elements = findElementsByTextWithFallback(request); } sendResponse({ success: true, elements }); } catch (error) { console.error('Error in getInteractiveElements:', error); sendResponse({ success: false, error: error.message }); } return true; // Async response } else if (request.action === 'chrome_get_interactive_elements_ping') { sendResponse({ status: 'pong' }); return false; } }); console.log('Interactive elements helper script loaded'); })();