Spaces:
Running
Running
marked_elements_convergence = []; | |
const interactiveTags = new Set([ | |
'a', 'button', 'details', 'embed', 'input', 'label', | |
'menu', 'menuitem', 'object', 'select', 'textarea', 'summary', | |
'video', 'audio', 'option', 'iframe' | |
]); | |
const interactiveRoles = new Set([ | |
'button', 'menu', 'menuitem', 'link', 'checkbox', 'radio', | |
'slider', 'tab', 'tabpanel', 'textbox', 'combobox', 'grid', | |
'listbox', 'option', 'progressbar', 'scrollbar', 'searchbox', | |
'switch', 'tree', 'treeitem', 'spinbutton', 'tooltip', | |
'a-button-inner', 'a-dropdown-button', 'click', | |
'menuitemcheckbox', 'menuitemradio', 'a-button-text', | |
'button-text', 'button-icon', 'button-icon-only', | |
'button-text-icon-only', 'dropdown', 'combobox' | |
]); | |
findPOIsConvergence = (input = null) => { | |
let rootElement = input ? input : document.documentElement; | |
function isScrollable(element) { | |
if ((input === null) && (element === document.documentElement)) { | |
// we can always scroll the full page | |
return false; | |
} | |
const style = window.getComputedStyle(element); | |
const hasScrollableYContent = element.scrollHeight > element.clientHeight | |
const overflowYScroll = style.overflowY === 'scroll' || style.overflowY === 'auto'; | |
const hasScrollableXContent = element.scrollWidth > element.clientWidth; | |
const overflowXScroll = style.overflowX === 'scroll' || style.overflowX === 'auto'; | |
return (hasScrollableYContent && overflowYScroll) || (hasScrollableXContent && overflowXScroll); | |
} | |
function getEventListeners(element) { | |
try { | |
return window.getEventListeners?.(element) || {}; | |
} catch (e) { | |
return {}; | |
} | |
} | |
function isInteractive(element) { | |
if (!element) return false; | |
return (hasInteractiveTag(element) || | |
hasInteractiveAttributes(element) || | |
hasInteractiveEventListeners(element)) || | |
isScrollable(element); | |
} | |
function hasInteractiveTag(element) { | |
return interactiveTags.has(element.tagName.toLowerCase()); | |
} | |
function hasInteractiveAttributes(element) { | |
const role = element.getAttribute('role'); | |
const ariaRole = element.getAttribute('aria-role'); | |
const tabIndex = element.getAttribute('tabindex'); | |
const onAttribute = element.getAttribute('on'); | |
if (element.getAttribute('contenteditable') === 'true') return true; | |
if ((role && interactiveRoles.has(role)) || | |
(ariaRole && interactiveRoles.has(ariaRole))) return true; | |
if (tabIndex !== null && tabIndex !== '-1') return true; | |
// Add check for AMP's 'on' attribute that starts with 'tap:' | |
if (onAttribute && onAttribute.startsWith('tap:')) return true; | |
const hasAriaProps = element.hasAttribute('aria-expanded') || | |
element.hasAttribute('aria-pressed') || | |
element.hasAttribute('aria-selected') || | |
element.hasAttribute('aria-checked'); | |
return hasAriaProps; | |
} | |
function hasInteractiveEventListeners(element) { | |
const hasClickHandler = element.onclick !== null || | |
element.getAttribute('onclick') !== null || | |
element.hasAttribute('ng-click') || | |
element.hasAttribute('@click') || | |
element.hasAttribute('v-on:click'); | |
if (hasClickHandler) return true; | |
const listeners = getEventListeners(element); | |
return listeners && ( | |
listeners.click?.length > 0 || | |
listeners.mousedown?.length > 0 || | |
listeners.mouseup?.length > 0 || | |
listeners.touchstart?.length > 0 || | |
listeners.touchend?.length > 0 | |
); | |
} | |
function calculateArea(rects) { | |
return rects.reduce((acc, rect) => acc + rect.width * rect.height, 0); | |
} | |
function getElementRects(element, context) { | |
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); | |
const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); | |
let rects = [...element.getClientRects()]; | |
// If rects are empty (likely due to Shadow DOM), try to estimate position | |
if (rects.length === 0 && element.getBoundingClientRect) { | |
rects = [element.getBoundingClientRect()]; | |
} | |
// Get iframe offset if element is in an iframe | |
let iframeOffset = { x: 0, y: 0 }; | |
if (context !== document && context?.defaultView?.frameElement) { | |
const iframe = context.defaultView.frameElement; | |
if (iframe) { | |
const iframeRect = iframe.getBoundingClientRect(); | |
iframeOffset = { | |
x: iframeRect.left, | |
y: iframeRect.top | |
}; | |
} | |
} | |
return rects.filter(bb => { | |
const center_x = bb.left + bb.width / 2 + iframeOffset.x; | |
const center_y = bb.top + bb.height / 2 + iframeOffset.y; | |
const elAtCenter = context.elementFromPoint(center_x - iframeOffset.x, center_y - iframeOffset.y); | |
return elAtCenter === element || element.contains(elAtCenter); | |
}).map(bb => { | |
const rect = { | |
left: Math.max(0, bb.left + iframeOffset.x), | |
top: Math.max(0, bb.top + iframeOffset.y), | |
right: Math.min(vw, bb.right + iframeOffset.x), | |
bottom: Math.min(vh, bb.bottom + iframeOffset.y) | |
}; | |
return { | |
...rect, | |
width: rect.right - rect.left, | |
height: rect.bottom - rect.top | |
}; | |
}); | |
} | |
function isElementVisible(element) { | |
const style = window.getComputedStyle(element); | |
return element.offsetWidth > 0 && | |
element.offsetHeight > 0 && | |
style.visibility !== 'hidden' && | |
style.display !== 'none'; | |
} | |
function isTopElement(element) { | |
let doc = element.ownerDocument; | |
if (doc !== window.document) { | |
// If in an iframe's document, treat as top | |
return true; | |
} | |
const shadowRoot = element.getRootNode(); | |
if (shadowRoot instanceof ShadowRoot) { | |
const rect = element.getBoundingClientRect(); | |
const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; | |
try { | |
const topEl = shadowRoot.elementFromPoint(point.x, point.y); | |
if (!topEl) return false; | |
let current = topEl; | |
while (current && current !== shadowRoot) { | |
if (current === element) return true; | |
current = current.parentElement; | |
} | |
return false; | |
} catch (e) { | |
return true; | |
} | |
} | |
const rect = element.getBoundingClientRect(); | |
const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; | |
try { | |
const topEl = document.elementFromPoint(point.x, point.y); | |
if (!topEl) return false; | |
let current = topEl; | |
while (current && current !== document.documentElement) { | |
if (current === element) return true; | |
current = current.parentElement; | |
} | |
return false; | |
} catch (e) { | |
return true; | |
} | |
} | |
function getVisibleText(element, marked_elements_convergence = []) { | |
const blockLikeDisplays = [ | |
// Basic block elements | |
'block', 'flow-root', 'inline-block', | |
// Lists | |
'list-item', | |
// Table elements | |
'table', 'inline-table', 'table-row', 'table-cell', | |
'table-caption', 'table-header-group', 'table-footer-group', | |
'table-row-group', | |
// Modern layouts | |
'flex', 'inline-flex', 'grid', 'inline-grid' | |
]; | |
// Check if element is hidden | |
const style = window.getComputedStyle(element); | |
if (style.display === 'none' || style.visibility === 'hidden') { | |
return ''; | |
} | |
let collectedText = []; | |
function isMarkedInteractive(el) { | |
return marked_elements_convergence.includes(el); | |
} | |
function traverse(node) { | |
if ( | |
node.nodeType === Node.ELEMENT_NODE && | |
node !== element && | |
isMarkedInteractive(node) | |
) { | |
return false; | |
} | |
if (node.nodeType === Node.TEXT_NODE) { | |
const trimmed = node.textContent.trim(); | |
if (trimmed) { | |
collectedText.push(trimmed); | |
} | |
} else if (node.nodeType === Node.ELEMENT_NODE) { | |
// Skip noscript elements | |
if (node.tagName === 'NOSCRIPT') { | |
return true; | |
} | |
const nodeStyle = window.getComputedStyle(node); | |
// Skip hidden elements | |
if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') { | |
return true; | |
} | |
// Add newline before block elements if we have text | |
if (blockLikeDisplays.includes(nodeStyle.display) && collectedText.length > 0) { | |
collectedText.push('\n'); | |
} | |
if (node.tagName === 'IMG') { | |
const textParts = []; | |
const alt = node.getAttribute('alt'); | |
const title = node.getAttribute('title'); | |
const ariaLabel = node.getAttribute('aria-label'); | |
// Add more as needed (e.g., 'aria-describedby', 'data-caption', etc.) | |
if (alt) textParts.push(`alt="${alt}"`); | |
if (title) textParts.push(`title="${title}"`); | |
if (ariaLabel) textParts.push(`aria-label="${ariaLabel}"`); | |
if (textParts.length > 0) { | |
collectedText.push(`[img - ${textParts.join(' ')}]`); | |
} | |
return true; | |
} | |
for (const child of node.childNodes) { | |
const shouldContinue = traverse(child); | |
if (shouldContinue === false) { | |
return false; | |
} | |
} | |
// Add newline after block elements | |
if (blockLikeDisplays.includes(nodeStyle.display)) { | |
collectedText.push('\n'); | |
} | |
} | |
return true; | |
} | |
traverse(element); | |
// Join text and normalize whitespace | |
return collectedText.join(' ').trim().replace(/\s{2,}/g, ' ').trim(); | |
} | |
function extractInteractiveItems(rootElement) { | |
const items = []; | |
function processElement(element, context) { | |
if (!element) return; | |
// Recursively process elements | |
if (element.nodeType === Node.ELEMENT_NODE && isInteractive(element) && isElementVisible(element) && isTopElement(element)) { | |
const rects = getElementRects(element, context); | |
const area = calculateArea(rects); | |
items.push({ | |
element: element, | |
area, | |
rects, | |
is_scrollable: isScrollable(element), | |
}); | |
} | |
if (element.shadowRoot) { | |
// if it's shadow DOM, process elements in the shadow DOM | |
Array.from(element.shadowRoot.childNodes || []).forEach(child => { | |
processElement(child, element.shadowRoot); | |
}); | |
} | |
if (element.tagName === 'SLOT') { | |
// Handle both assigned elements and nodes | |
const assigned = element.assignedNodes ? element.assignedNodes() : element.assignedElements(); | |
assigned.forEach(child => { | |
processElement(child, context); | |
}); | |
} | |
else if (element.tagName === 'IFRAME') { | |
try { | |
const iframeDoc = element.contentDocument || element.contentWindow?.document; | |
if (iframeDoc && iframeDoc.body) { | |
// Process elements inside iframe | |
processElement(iframeDoc.body, iframeDoc); | |
} | |
} catch (e) { | |
console.warn('Unable to access iframe contents:', e); | |
} | |
} else { | |
// if it's regular child elements, process regular child elements | |
Array.from(element.children || []).forEach(child => { | |
processElement(child, context); | |
}); | |
} | |
} | |
processElement(rootElement, document); | |
return items; | |
} | |
if (marked_elements_convergence) { | |
marked_elements_convergence = []; | |
} | |
let mark_centres = []; | |
let marked_element_descriptions = []; | |
var items = extractInteractiveItems(rootElement); | |
// Lets create a floating border on top of these elements that will always be visible | |
let index = 0; | |
items.forEach(function (item) { | |
item.rects.forEach((bbox) => { | |
marked_elements_convergence.push(item.element); | |
mark_centres.push({ | |
x: Math.round((bbox.left + bbox.right) / 2), | |
y: Math.round((bbox.top + bbox.bottom) / 2), | |
left: bbox.left, | |
top: bbox.top, | |
right: bbox.right, | |
bottom: bbox.bottom, | |
}); | |
marked_element_descriptions.push({ | |
tag: item.element.tagName, | |
text: getVisibleText(item.element), | |
// NOTE: all other attributes will be shown to the model when present | |
// TODO: incorperate child attributes, e.g. <img alt="..."> when img is a child of the link element | |
value: item.element.value, | |
placeholder: item.element.getAttribute("placeholder"), | |
element_type: item.element.getAttribute("type"), | |
aria_label: item.element.getAttribute("aria-label"), | |
name: item.element.getAttribute("name"), | |
required: item.element.getAttribute("required"), | |
disabled: item.element.getAttribute("disabled"), | |
pattern: item.element.getAttribute("pattern"), | |
checked: item.element.getAttribute("checked"), | |
minlength: item.element.getAttribute("minlength"), | |
maxlength: item.element.getAttribute("maxlength"), | |
role: item.element.getAttribute("role"), | |
title: item.element.getAttribute("title"), | |
scrollable: item.is_scrollable | |
}); | |
index++; | |
}); | |
}); | |
return { | |
element_descriptions: marked_element_descriptions, | |
element_centroids: mark_centres | |
}; | |
} | |