355 lines
12 KiB
JavaScript
355 lines
12 KiB
JavaScript
/* 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');
|
|
})();
|