2024-11-14 02:33:44 +08:00
// we only use chromium browser for now
let browserNameForWorkarounds = "chromium" ;
2025-02-18 08:58:23 +08:00
class SafeCounter {
constructor ( ) {
this . value = 0 ;
this . lock = Promise . resolve ( ) ;
}
async add ( ) {
await this . lock ;
this . lock = new Promise ( ( resolve ) => {
this . value += 1 ;
resolve ( ) ;
} ) ;
return this . value ;
}
async get ( ) {
await this . lock ;
return this . value ;
}
}
2024-03-01 10:09:30 -08:00
// Commands for manipulating rects.
2024-04-10 23:31:17 -07:00
// Want to debug this? Run chromium, go to sources, and create a new snippet with the code in domUtils.js
2024-03-01 10:09:30 -08:00
class Rect {
// Create a rect given the top left and bottom right corners.
static create ( x1 , y1 , x2 , y2 ) {
return {
bottom : y2 ,
top : y1 ,
left : x1 ,
right : x2 ,
width : x2 - x1 ,
height : y2 - y1 ,
} ;
}
static copy ( rect ) {
return {
bottom : rect . bottom ,
top : rect . top ,
left : rect . left ,
right : rect . right ,
width : rect . width ,
height : rect . height ,
} ;
}
// Translate a rect by x horizontally and y vertically.
static translate ( rect , x , y ) {
if ( x == null ) x = 0 ;
if ( y == null ) y = 0 ;
return {
bottom : rect . bottom + y ,
top : rect . top + y ,
left : rect . left + x ,
right : rect . right + x ,
width : rect . width ,
height : rect . height ,
} ;
}
// Determine whether two rects overlap.
static intersects ( rect1 , rect2 ) {
return (
rect1 . right > rect2 . left &&
rect1 . left < rect2 . right &&
rect1 . bottom > rect2 . top &&
rect1 . top < rect2 . bottom
) ;
}
static equals ( rect1 , rect2 ) {
for ( const property of [
"top" ,
"bottom" ,
"left" ,
"right" ,
"width" ,
"height" ,
] ) {
if ( rect1 [ property ] !== rect2 [ property ] ) return false ;
}
return true ;
}
}
class DomUtils {
//
// Bounds the rect by the current viewport dimensions. If the rect is offscreen or has a height or
// width < 3 then null is returned instead of a rect.
//
static cropRectToVisible ( rect ) {
const boundedRect = Rect . create (
Math . max ( rect . left , 0 ) ,
Math . max ( rect . top , 0 ) ,
rect . right ,
rect . bottom ,
) ;
if (
boundedRect . top >= window . innerHeight - 4 ||
boundedRect . left >= window . innerWidth - 4
) {
return null ;
} else {
return boundedRect ;
}
}
static getVisibleClientRect ( element , testChildren ) {
// Note: this call will be expensive if we modify the DOM in between calls.
let clientRect ;
if ( testChildren == null ) testChildren = false ;
const clientRects = ( ( ) => {
const result = [ ] ;
for ( clientRect of element . getClientRects ( ) ) {
result . push ( Rect . copy ( clientRect ) ) ;
}
return result ;
} ) ( ) ;
// Inline elements with font-size: 0px; will declare a height of zero, even if a child with
// non-zero font-size contains text.
let isInlineZeroHeight = function ( ) {
2024-12-17 13:32:07 +08:00
const elementComputedStyle = getElementComputedStyle ( element , null ) ;
2024-03-01 10:09:30 -08:00
const isInlineZeroFontSize =
0 ===
2024-12-17 13:32:07 +08:00
elementComputedStyle ? . getPropertyValue ( "display" ) . indexOf ( "inline" ) &&
elementComputedStyle ? . getPropertyValue ( "font-size" ) === "0px" ;
2024-03-01 10:09:30 -08:00
// Override the function to return this value for the rest of this context.
isInlineZeroHeight = ( ) => isInlineZeroFontSize ;
return isInlineZeroFontSize ;
} ;
for ( clientRect of clientRects ) {
// If the link has zero dimensions, it may be wrapping visible but floated elements. Check for
// this.
let computedStyle ;
if ( ( clientRect . width === 0 || clientRect . height === 0 ) && testChildren ) {
for ( const child of Array . from ( element . children ) ) {
2024-12-17 13:32:07 +08:00
computedStyle = getElementComputedStyle ( child , null ) ;
if ( ! computedStyle ) {
continue ;
}
2024-03-01 10:09:30 -08:00
// Ignore child elements which are not floated and not absolutely positioned for parent
// elements with zero width/height, as long as the case described at isInlineZeroHeight
// does not apply.
// NOTE(mrmr1993): This ignores floated/absolutely positioned descendants nested within
// inline children.
const position = computedStyle . getPropertyValue ( "position" ) ;
if (
computedStyle . getPropertyValue ( "float" ) === "none" &&
! [ "absolute" , "fixed" ] . includes ( position ) &&
! (
clientRect . height === 0 &&
isInlineZeroHeight ( ) &&
0 === computedStyle . getPropertyValue ( "display" ) . indexOf ( "inline" )
)
) {
continue ;
}
const childClientRect = this . getVisibleClientRect ( child , true ) ;
if (
childClientRect === null ||
childClientRect . width < 3 ||
childClientRect . height < 3
)
continue ;
return childClientRect ;
}
} else {
clientRect = this . cropRectToVisible ( clientRect ) ;
if (
clientRect === null ||
clientRect . width < 3 ||
clientRect . height < 3
)
continue ;
// eliminate invisible elements (see test_harnesses/visibility_test.html)
2024-12-17 13:32:07 +08:00
computedStyle = getElementComputedStyle ( element , null ) ;
if ( ! computedStyle ) {
continue ;
}
2024-03-01 10:09:30 -08:00
if ( computedStyle . getPropertyValue ( "visibility" ) !== "visible" )
continue ;
return clientRect ;
}
}
return null ;
}
static getViewportTopLeft ( ) {
const box = document . documentElement ;
const style = getComputedStyle ( box ) ;
const rect = box . getBoundingClientRect ( ) ;
if (
2024-12-17 13:32:07 +08:00
style &&
2024-03-01 10:09:30 -08:00
style . position === "static" &&
! /content|paint|strict/ . test ( style . contain || "" )
) {
// The margin is included in the client rect, so we need to subtract it back out.
const marginTop = parseInt ( style . marginTop ) ;
const marginLeft = parseInt ( style . marginLeft ) ;
return {
top : - rect . top + marginTop ,
left : - rect . left + marginLeft ,
} ;
} else {
const { clientTop , clientLeft } = box ;
return {
top : - rect . top - clientTop ,
left : - rect . left - clientLeft ,
} ;
}
}
}
// from playwright
function getElementComputedStyle ( element , pseudo ) {
return element . ownerDocument && element . ownerDocument . defaultView
? element . ownerDocument . defaultView . getComputedStyle ( element , pseudo )
: undefined ;
}
2024-11-14 02:33:44 +08:00
// from playwright: https://github.com/microsoft/playwright/blob/1b65f26f0287c0352e76673bc5f85bc36c934b55/packages/playwright-core/src/server/injected/domUtils.ts#L76-L98
2024-03-01 10:09:30 -08:00
function isElementStyleVisibilityVisible ( element , style ) {
style = style ? ? getElementComputedStyle ( element ) ;
if ( ! style ) return true ;
2024-11-14 02:33:44 +08:00
// Element.checkVisibility checks for content-visibility and also looks at
// styles up the flat tree including user-agent ShadowRoots, such as the
// details element for example.
// All the browser implement it, but WebKit has a bug which prevents us from using it:
// https://bugs.webkit.org/show_bug.cgi?id=264733
// @ts-ignore
2024-03-01 10:09:30 -08:00
if (
2024-11-14 02:33:44 +08:00
Element . prototype . checkVisibility &&
browserNameForWorkarounds !== "webkit"
) {
if ( ! element . checkVisibility ( ) ) return false ;
} else {
// Manual workaround for WebKit that does not have checkVisibility.
const detailsOrSummary = element . closest ( "details,summary" ) ;
if (
detailsOrSummary !== element &&
detailsOrSummary ? . nodeName === "DETAILS" &&
! detailsOrSummary . open
)
return false ;
}
2024-03-01 10:09:30 -08:00
if ( style . visibility !== "visible" ) return false ;
2024-10-09 18:33:03 +08:00
// TODO: support style.clipPath and style.clipRule?
// if element is clipped with rect(0px, 0px, 0px, 0px), it means it's invisible on the page
2025-03-05 14:37:15 -05:00
// FIXME: need a better algorithm to calculate the visible rect area, using (right-left)*(bottom-top) from rect(top, right, bottom, left)
if (
style . clip === "rect(0px, 0px, 0px, 0px)" ||
style . clip === "rect(1px, 1px, 1px, 1px)"
) {
2024-10-09 18:33:03 +08:00
return false ;
}
2024-03-01 10:09:30 -08:00
return true ;
}
2024-11-11 18:57:59 +08:00
function hasASPClientControl ( ) {
return typeof ASPxClientControl !== "undefined" ;
}
2024-11-14 02:33:44 +08:00
// from playwright: https://github.com/microsoft/playwright/blob/1b65f26f0287c0352e76673bc5f85bc36c934b55/packages/playwright-core/src/server/injected/domUtils.ts#L100-L119
2024-03-01 10:09:30 -08:00
function isElementVisible ( element ) {
// TODO: This is a hack to not check visibility for option elements
// because they are not visible by default. We check their parent instead for visibility.
2024-07-01 21:24:52 -07:00
if (
element . tagName . toLowerCase ( ) === "option" ||
2024-11-15 23:04:02 +08:00
( element . tagName . toLowerCase ( ) === "input" &&
( element . type === "radio" || element . type === "checkbox" ) )
2024-07-01 21:24:52 -07:00
)
2024-03-01 10:09:30 -08:00
return element . parentElement && isElementVisible ( element . parentElement ) ;
2024-11-24 11:33:54 +08:00
const className = element . className ? element . className . toString ( ) : "" ;
2024-08-13 09:21:19 +08:00
if (
2024-09-13 17:57:36 -07:00
className . includes ( "select2-offscreen" ) ||
className . includes ( "select2-hidden" ) ||
className . includes ( "ui-select-offscreen" )
2024-08-13 09:21:19 +08:00
) {
2024-06-18 11:34:52 +08:00
return false ;
}
2024-03-01 10:09:30 -08:00
const style = getElementComputedStyle ( element ) ;
if ( ! style ) return true ;
if ( style . display === "contents" ) {
// display:contents is not rendered itself, but its child nodes are.
for ( let child = element . firstChild ; child ; child = child . nextSibling ) {
if (
child . nodeType === 1 /* Node.ELEMENT_NODE */ &&
isElementVisible ( child )
)
return true ;
2024-11-14 02:33:44 +08:00
if ( child . nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode ( child ) )
return true ;
2024-03-01 10:09:30 -08:00
}
return false ;
}
if ( ! isElementStyleVisibilityVisible ( element , style ) ) return false ;
const rect = element . getBoundingClientRect ( ) ;
2024-05-08 10:25:32 +08:00
if ( rect . width <= 0 || rect . height <= 0 ) {
return false ;
}
2024-09-25 09:46:37 +08:00
// if the center point of the element is not in the page, we tag it as an non-interactable element
// FIXME: sometimes there could be an overflow element blocking the default scrolling, making Y coordinate be wrong. So we currently only check for X
const center _x = ( rect . left + rect . width ) / 2 + window . scrollX ;
if ( center _x < 0 ) {
2024-05-08 10:25:32 +08:00
return false ;
}
2024-09-25 09:46:37 +08:00
// const center_y = (rect.top + rect.height) / 2 + window.scrollY;
// if (center_x < 0 || center_y < 0) {
// return false;
// }
2024-05-08 10:25:32 +08:00
return true ;
2024-03-01 10:09:30 -08:00
}
2024-11-14 02:33:44 +08:00
// from playwright: https://github.com/microsoft/playwright/blob/1b65f26f0287c0352e76673bc5f85bc36c934b55/packages/playwright-core/src/server/injected/domUtils.ts#L121-L127
function isVisibleTextNode ( node ) {
// https://stackoverflow.com/questions/1461059/is-there-an-equivalent-to-getboundingclientrect-for-text-nodes
const range = node . ownerDocument . createRange ( ) ;
range . selectNode ( node ) ;
const rect = range . getBoundingClientRect ( ) ;
if ( rect . width <= 0 || rect . height <= 0 ) {
return false ;
}
// if the center point of the element is not in the page, we tag it as an non-interactable element
// FIXME: sometimes there could be an overflow element blocking the default scrolling, making Y coordinate be wrong. So we currently only check for X
const center _x = ( rect . left + rect . width ) / 2 + window . scrollX ;
if ( center _x < 0 ) {
return false ;
}
// const center_y = (rect.top + rect.height) / 2 + window.scrollY;
// if (center_x < 0 || center_y < 0) {
// return false;
// }
return true ;
}
// from playwright: https://github.com/microsoft/playwright/blob/d685763c491e06be38d05675ef529f5c230388bb/packages/playwright-core/src/server/injected/domUtils.ts#L37-L44
function parentElementOrShadowHost ( element ) {
if ( element . parentElement ) return element . parentElement ;
if ( ! element . parentNode ) return ;
if (
element . parentNode . nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ &&
element . parentNode . host
)
return element . parentNode . host ;
}
// from playwright: https://github.com/microsoft/playwright/blob/d685763c491e06be38d05675ef529f5c230388bb/packages/playwright-core/src/server/injected/domUtils.ts#L46-L52
function enclosingShadowRootOrDocument ( element ) {
let node = element ;
while ( node . parentNode ) node = node . parentNode ;
if (
node . nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ ||
node . nodeType === 9 /* Node.DOCUMENT_NODE */
)
return node ;
}
// from playwright: https://github.com/microsoft/playwright/blob/d685763c491e06be38d05675ef529f5c230388bb/packages/playwright-core/src/server/injected/injectedScript.ts#L799-L859
function expectHitTarget ( hitPoint , targetElement ) {
const roots = [ ] ;
// Get all component roots leading to the target element.
// Go from the bottom to the top to make it work with closed shadow roots.
let parentElement = targetElement ;
while ( parentElement ) {
const root = enclosingShadowRootOrDocument ( parentElement ) ;
if ( ! root ) break ;
roots . push ( root ) ;
if ( root . nodeType === 9 /* Node.DOCUMENT_NODE */ ) break ;
parentElement = root . host ;
}
// Hit target in each component root should point to the next component root.
// Hit target in the last component root should point to the target or its descendant.
let hitElement ;
for ( let index = roots . length - 1 ; index >= 0 ; index -- ) {
const root = roots [ index ] ;
// All browsers have different behavior around elementFromPoint and elementsFromPoint.
// https://github.com/w3c/csswg-drafts/issues/556
// http://crbug.com/1188919
const elements = root . elementsFromPoint ( hitPoint . x , hitPoint . y ) ;
const singleElement = root . elementFromPoint ( hitPoint . x , hitPoint . y ) ;
if (
singleElement &&
elements [ 0 ] &&
parentElementOrShadowHost ( singleElement ) === elements [ 0 ]
) {
2024-12-17 13:32:07 +08:00
const style = getElementComputedStyle ( singleElement ) ;
2024-11-14 02:33:44 +08:00
if ( style ? . display === "contents" ) {
// Workaround a case where elementsFromPoint misses the inner-most element with display:contents.
// https://bugs.chromium.org/p/chromium/issues/detail?id=1342092
elements . unshift ( singleElement ) ;
}
}
if (
elements [ 0 ] &&
elements [ 0 ] . shadowRoot === root &&
elements [ 1 ] === singleElement
) {
// Workaround webkit but where first two elements are swapped:
// <host>
// #shadow root
// <target>
// elementsFromPoint produces [<host>, <target>], while it should be [<target>, <host>]
// In this case, just ignore <host>.
elements . shift ( ) ;
}
const innerElement = elements [ 0 ] ;
if ( ! innerElement ) break ;
hitElement = innerElement ;
if ( index && innerElement !== roots [ index - 1 ] . host ) break ;
}
// Check whether hit target is the target or its descendant.
const hitParents = [ ] ;
while ( hitElement && hitElement !== targetElement ) {
hitParents . push ( hitElement ) ;
hitElement = parentElementOrShadowHost ( hitElement ) ;
}
if ( hitElement === targetElement ) return null ;
return hitParents [ 0 ] || document . documentElement ;
}
function isParent ( parent , child ) {
return parent . contains ( child ) ;
}
function isSibling ( el1 , el2 ) {
return el1 . parentElement === el2 . parentElement ;
}
function getBlockElementUniqueID ( element ) {
const rect = element . getBoundingClientRect ( ) ;
const hitElement = expectHitTarget (
{
x : rect . left + rect . width / 2 ,
y : rect . top + rect . height / 2 ,
} ,
element ,
) ;
if ( ! hitElement ) {
2024-11-27 22:44:05 +08:00
return [ "" , false ] ;
2024-11-14 02:33:44 +08:00
}
2024-11-27 22:44:05 +08:00
return [ hitElement . getAttribute ( "unique_id" ) ? ? "" , true ] ;
2024-11-14 02:33:44 +08:00
}
2024-05-14 18:43:06 +08:00
function isHidden ( element ) {
2024-03-01 10:09:30 -08:00
const style = getElementComputedStyle ( element ) ;
2024-10-03 23:40:05 -07:00
if ( style ? . display === "none" ) {
return true ;
}
if ( element . hidden ) {
if (
style ? . cursor === "pointer" &&
element . tagName . toLowerCase ( ) === "input" &&
( element . type === "submit" || element . type === "button" )
) {
// there are cases where the input is a "submit" button and the cursor is a pointer but the element has the hidden attr.
// such an element is not really hidden
return false ;
}
return true ;
}
return false ;
2024-05-14 18:43:06 +08:00
}
function isHiddenOrDisabled ( element ) {
return isHidden ( element ) || element . disabled ;
2024-03-01 10:09:30 -08:00
}
function isScriptOrStyle ( element ) {
const tagName = element . tagName . toLowerCase ( ) ;
return tagName === "script" || tagName === "style" ;
}
2024-09-11 11:02:50 +08:00
function isReadonlyElement ( element ) {
if ( element . readOnly ) {
return true ;
}
if ( element . hasAttribute ( "readonly" ) ) {
return true ;
}
if ( element . hasAttribute ( "aria-readonly" ) ) {
// only aria-readonly="false" should be considered as "not readonly"
return (
element . getAttribute ( "aria-readonly" ) . toLowerCase ( ) . trim ( ) !== "false"
) ;
}
return false ;
}
2024-08-14 22:52:58 +03:00
function hasAngularClickBinding ( element ) {
return (
element . hasAttribute ( "ng-click" ) || element . hasAttribute ( "data-ng-click" )
) ;
}
2024-03-01 10:09:30 -08:00
function hasWidgetRole ( element ) {
const role = element . getAttribute ( "role" ) ;
if ( ! role ) {
return false ;
}
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles#2._widget_roles
// Not all roles make sense for the time being so we only check for the ones that do
2024-09-11 11:02:50 +08:00
if ( role . toLowerCase ( ) . trim ( ) === "textbox" ) {
return ! isReadonlyElement ( element ) ;
}
2024-03-01 10:09:30 -08:00
const widgetRoles = [
"button" ,
"link" ,
"checkbox" ,
"menuitem" ,
"menuitemcheckbox" ,
"menuitemradio" ,
"radio" ,
"tab" ,
"combobox" ,
"searchbox" ,
"slider" ,
"spinbutton" ,
"switch" ,
"gridcell" ,
] ;
return widgetRoles . includes ( role . toLowerCase ( ) . trim ( ) ) ;
}
2024-08-15 09:32:18 +08:00
function isTableRelatedElement ( element ) {
const tagName = element . tagName . toLowerCase ( ) ;
return [
"table" ,
"caption" ,
"thead" ,
"tbody" ,
"tfoot" ,
"tr" ,
"th" ,
"td" ,
"colgroup" ,
"col" ,
] . includes ( tagName ) ;
}
2024-03-01 10:09:30 -08:00
function isInteractableInput ( element ) {
const tagName = element . tagName . toLowerCase ( ) ;
2024-04-10 23:31:17 -07:00
if ( tagName !== "input" ) {
2024-03-01 10:09:30 -08:00
// let other checks decide
return false ;
}
2024-09-12 11:11:37 -07:00
// Browsers default to "text" when the type is not set or is invalid
// Here's the list of valid types: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types
// Examples of unrecognized types that we've seen and caused issues because we didn't mark them interactable:
// "city", "state", "zip", "country"
// That's the reason I (Kerem) removed the valid input types check
var type = element . getAttribute ( "type" ) ? . toLowerCase ( ) . trim ( ) ? ? "text" ;
return ! isReadonlyElement ( element ) && type !== "hidden" ;
2024-03-01 10:09:30 -08:00
}
2025-01-04 21:32:41 -08:00
function isValidCSSSelector ( selector ) {
try {
document . querySelector ( selector ) ;
return true ;
} catch ( e ) {
return false ;
}
}
function isInteractable ( element , hoverStylesMap ) {
2024-07-05 02:54:49 +08:00
if ( element . shadowRoot ) {
return false ;
}
2024-03-01 10:09:30 -08:00
if ( ! isElementVisible ( element ) ) {
return false ;
}
2024-11-12 15:35:08 +08:00
if ( isHidden ( element ) ) {
2024-03-01 10:09:30 -08:00
return false ;
}
if ( isScriptOrStyle ( element ) ) {
return false ;
}
2025-02-26 22:39:16 -08:00
// element with pointer-events: none should not be considered as interactable
// https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events#none
const elementPointerEvent = getElementComputedStyle ( element ) ? . pointerEvents ;
if ( elementPointerEvent === "none" ) {
return false ;
}
2024-03-01 10:09:30 -08:00
if ( hasWidgetRole ( element ) ) {
return true ;
}
if ( isInteractableInput ( element ) ) {
return true ;
}
const tagName = element . tagName . toLowerCase ( ) ;
2024-06-06 10:07:32 +08:00
if ( tagName === "iframe" ) {
return false ;
}
2025-01-24 14:22:47 +08:00
if ( tagName === "frameset" ) {
return false ;
}
2025-01-27 22:01:15 +08:00
if ( tagName === "frame" ) {
return false ;
}
2024-03-01 10:09:30 -08:00
if ( tagName === "a" && element . href ) {
return true ;
}
2024-07-03 01:38:50 -07:00
// Check if the option's parent (select) is hidden or disabled
if ( tagName === "option" && isHiddenOrDisabled ( element . parentElement ) ) {
return false ;
}
2024-03-01 10:09:30 -08:00
if (
tagName === "button" ||
tagName === "select" ||
tagName === "option" ||
tagName === "textarea"
) {
return true ;
}
if ( tagName === "label" && element . control && ! element . control . disabled ) {
return true ;
}
if (
element . hasAttribute ( "onclick" ) ||
element . isContentEditable ||
2024-05-14 00:23:43 -07:00
element . hasAttribute ( "jsaction" )
2024-03-01 10:09:30 -08:00
) {
return true ;
}
2024-09-16 08:39:27 -07:00
if ( tagName === "div" || tagName === "span" ) {
if ( hasAngularClickBinding ( element ) ) {
return true ;
}
2025-01-02 22:10:51 +08:00
if ( element . className . toString ( ) . includes ( "blinking-cursor" ) ) {
return true ;
}
2024-10-23 22:23:36 -07:00
// https://www.oxygenxml.com/dita/1.3/specs/langRef/technicalContent/svg-container.html
// svg-container is usually used for clickable elements that wrap SVGs
if ( element . className . toString ( ) . includes ( "svg-container" ) ) {
return true ;
}
2024-08-14 22:52:58 +03:00
}
2024-03-12 11:37:41 -07:00
// support listbox and options underneath it
2024-08-06 13:30:52 +08:00
// div element should be checked here before the css pointer
2024-03-12 11:37:41 -07:00
if (
( tagName === "ul" || tagName === "div" ) &&
element . hasAttribute ( "role" ) &&
element . getAttribute ( "role" ) . toLowerCase ( ) === "listbox"
) {
return true ;
}
if (
( tagName === "li" || tagName === "div" ) &&
element . hasAttribute ( "role" ) &&
element . getAttribute ( "role" ) . toLowerCase ( ) === "option"
) {
return true ;
}
2024-11-12 12:11:16 +08:00
if (
tagName === "li" &&
element . className . toString ( ) . includes ( "ui-menu-item" )
) {
return true ;
}
// google map address auto complete
// https://developers.google.com/maps/documentation/javascript/place-autocomplete#style-autocomplete
// demo: https://developers.google.com/maps/documentation/javascript/examples/places-autocomplete-addressform
if (
tagName === "div" &&
element . className . toString ( ) . includes ( "pac-item" ) &&
element . closest ( 'div[class*="pac-container"]' )
) {
return true ;
}
2024-08-06 13:30:52 +08:00
if (
tagName === "div" &&
element . hasAttribute ( "aria-disabled" ) &&
element . getAttribute ( "aria-disabled" ) . toLowerCase ( ) === "false"
) {
return true ;
}
2024-11-12 12:11:16 +08:00
if ( tagName === "span" && element . closest ( 'div[id*="dropdown-container"]' ) ) {
2024-11-12 00:25:43 +08:00
return true ;
}
2024-08-06 13:30:52 +08:00
if (
tagName === "div" ||
tagName === "img" ||
tagName === "span" ||
tagName === "a" ||
2024-10-09 18:33:03 +08:00
tagName === "i" ||
2024-11-18 17:15:09 -07:00
tagName === "li" ||
2025-01-19 19:10:51 -08:00
tagName === "p" ||
2025-03-10 12:04:45 -07:00
tagName === "td" ||
tagName === "svg"
2024-08-06 13:30:52 +08:00
) {
2024-12-17 13:32:07 +08:00
const elementCursor = getElementComputedStyle ( element ) ? . cursor ;
if ( elementCursor === "pointer" ) {
2024-09-25 02:01:26 +08:00
return true ;
}
2025-01-04 21:32:41 -08:00
// Check if element has hover styles that change cursor to pointer
// This is to handle the case where an element's cursor is "auto", but resolves to "pointer" on hover
if ( elementCursor === "auto" ) {
2025-01-22 22:43:50 +08:00
// TODO: we need a better algorithm to match the selector with better performance
2025-01-04 21:32:41 -08:00
for ( const [ selector , styles ] of hoverStylesMap ) {
2025-01-22 22:43:50 +08:00
let shouldMatch = false ;
for ( const className of element . classList ) {
if ( selector . includes ( className ) ) {
shouldMatch = true ;
break ;
}
}
if ( shouldMatch || selector . includes ( tagName ) ) {
if ( element . matches ( selector ) && styles . cursor === "pointer" ) {
return true ;
}
2025-01-04 21:32:41 -08:00
}
}
}
2024-09-25 02:01:26 +08:00
// FIXME: hardcode to fix the bug about hover style now
if ( element . className . toString ( ) . includes ( "hover:cursor-pointer" ) ) {
return true ;
}
2024-08-06 13:30:52 +08:00
}
2024-11-11 18:57:59 +08:00
if ( hasASPClientControl ( ) && tagName === "tr" ) {
return true ;
}
2024-03-01 10:09:30 -08:00
return false ;
}
2024-08-06 13:30:52 +08:00
function isScrollable ( element ) {
const scrollHeight = element . scrollHeight || 0 ;
const clientHeight = element . clientHeight || 0 ;
const scrollWidth = element . scrollWidth || 0 ;
const clientWidth = element . clientWidth || 0 ;
const hasScrollableContent =
scrollHeight > clientHeight || scrollWidth > clientWidth ;
const hasScrollableOverflow = isScrollableOverflow ( element ) ;
return hasScrollableContent && hasScrollableOverflow ;
}
function isScrollableOverflow ( element ) {
2024-12-17 13:32:07 +08:00
const style = getElementComputedStyle ( element ) ;
if ( ! style ) {
return false ;
}
2024-08-06 13:30:52 +08:00
return (
style . overflow === "auto" ||
style . overflow === "scroll" ||
style . overflowX === "auto" ||
style . overflowX === "scroll" ||
style . overflowY === "auto" ||
style . overflowY === "scroll"
) ;
}
2025-01-09 16:14:31 +08:00
function isDatePickerSelector ( element ) {
const tagName = element . tagName . toLowerCase ( ) ;
if (
tagName === "button" &&
element . getAttribute ( "data-testid" ) ? . includes ( "date" )
) {
return true ;
}
return false ;
}
2024-04-18 15:06:46 +08:00
const isComboboxDropdown = ( element ) => {
if ( element . tagName . toLowerCase ( ) !== "input" ) {
return false ;
}
const role = element . getAttribute ( "role" )
? element . getAttribute ( "role" ) . toLowerCase ( )
: "" ;
const haspopup = element . getAttribute ( "aria-haspopup" )
? element . getAttribute ( "aria-haspopup" ) . toLowerCase ( )
: "" ;
const readonly =
element . getAttribute ( "readonly" ) &&
element . getAttribute ( "readonly" ) . toLowerCase ( ) !== "false" ;
const controls = element . hasAttribute ( "aria-controls" ) ;
return role && haspopup && controls && readonly ;
} ;
2025-01-08 14:27:50 +08:00
const isDivComboboxDropdown = ( element ) => {
const tagName = element . tagName . toLowerCase ( ) ;
if ( tagName !== "div" ) {
return false ;
}
const role = element . getAttribute ( "role" )
? element . getAttribute ( "role" ) . toLowerCase ( )
: "" ;
const haspopup = element . getAttribute ( "aria-haspopup" )
? element . getAttribute ( "aria-haspopup" ) . toLowerCase ( )
: "" ;
const controls = element . hasAttribute ( "aria-controls" ) ;
return role === "combobox" && controls && haspopup ;
} ;
2024-08-30 01:24:38 +08:00
const isDropdownButton = ( element ) => {
const tagName = element . tagName . toLowerCase ( ) ;
const type = element . getAttribute ( "type" )
? element . getAttribute ( "type" ) . toLowerCase ( )
: "" ;
const haspopup = element . getAttribute ( "aria-haspopup" )
? element . getAttribute ( "aria-haspopup" ) . toLowerCase ( )
: "" ;
2025-01-16 05:38:28 -08:00
const hasExpanded = element . hasAttribute ( "aria-expanded" ) ;
return (
tagName === "button" &&
type === "button" &&
( hasExpanded || haspopup === "listbox" )
) ;
2024-08-30 01:24:38 +08:00
} ;
2024-06-18 11:34:52 +08:00
const isSelect2Dropdown = ( element ) => {
2024-08-13 09:21:19 +08:00
const tagName = element . tagName . toLowerCase ( ) ;
const className = element . className . toString ( ) ;
const role = element . getAttribute ( "role" )
? element . getAttribute ( "role" ) . toLowerCase ( )
: "" ;
if ( tagName === "a" ) {
return className . includes ( "select2-choice" ) ;
}
if ( tagName === "span" ) {
return className . includes ( "select2-selection" ) && role === "combobox" ;
}
return false ;
2024-06-18 11:34:52 +08:00
} ;
const isSelect2MultiChoice = ( element ) => {
return (
element . tagName . toLowerCase ( ) === "input" &&
element . className . toString ( ) . includes ( "select2-input" )
) ;
} ;
2024-07-27 01:32:35 +08:00
const isReactSelectDropdown = ( element ) => {
return (
element . tagName . toLowerCase ( ) === "input" &&
element . className . toString ( ) . includes ( "select__input" ) &&
element . getAttribute ( "role" ) === "combobox"
) ;
} ;
2024-09-13 17:57:36 -07:00
function hasNgAttribute ( element ) {
2024-12-11 00:05:16 +08:00
if ( ! element . attributes [ Symbol . iterator ] ) {
return false ;
}
2024-09-13 17:57:36 -07:00
for ( let attr of element . attributes ) {
if ( attr . name . startsWith ( "ng-" ) ) {
return true ;
}
}
return false ;
}
2025-03-17 12:12:16 -07:00
function isAngularMaterial ( element ) {
if ( ! element . attributes [ Symbol . iterator ] ) {
return false ;
}
for ( let attr of element . attributes ) {
if ( attr . name . startsWith ( "mat" ) ) {
return true ;
}
}
return false ;
}
2024-09-13 17:57:36 -07:00
const isAngularDropdown = ( element ) => {
if ( ! hasNgAttribute ( element ) ) {
return false ;
}
const tagName = element . tagName . toLowerCase ( ) ;
2024-09-16 08:39:27 -07:00
if ( tagName === "input" || tagName === "span" ) {
2024-09-13 17:57:36 -07:00
const ariaLabel = element . hasAttribute ( "aria-label" )
? element . getAttribute ( "aria-label" ) . toLowerCase ( )
: "" ;
return ariaLabel . includes ( "select" ) || ariaLabel . includes ( "choose" ) ;
}
return false ;
} ;
2025-03-17 12:12:16 -07:00
const isAngularMaterialDatePicker = ( element ) => {
if ( ! isAngularMaterial ( element ) ) {
return false ;
}
const tagName = element . tagName . toLowerCase ( ) ;
if ( tagName !== "input" ) return false ;
return (
( element . closest ( "mat-datepicker" ) ||
element . closest ( "mat-formio-date" ) ) !== null
) ;
} ;
2024-10-28 19:30:11 +08:00
function getPseudoContent ( element , pseudo ) {
const pseudoStyle = getElementComputedStyle ( element , pseudo ) ;
if ( ! pseudoStyle ) {
return null ;
}
const content = pseudoStyle
. getPropertyValue ( "content" )
. replace ( /"/g , "" )
. trim ( ) ;
if ( content === "none" || ! content ) {
return null ;
}
return content ;
}
function hasBeforeOrAfterPseudoContent ( element ) {
return (
getPseudoContent ( element , "::before" ) != null ||
getPseudoContent ( element , "::after" ) != null
) ;
}
2024-04-21 22:30:37 +08:00
const checkParentClass = ( className ) => {
const targetParentClasses = [ "field" , "entry" ] ;
for ( let i = 0 ; i < targetParentClasses . length ; i ++ ) {
if ( className . includes ( targetParentClasses [ i ] ) ) {
return true ;
}
}
return false ;
} ;
2024-03-01 10:09:30 -08:00
function removeMultipleSpaces ( str ) {
if ( ! str ) {
return str ;
}
return str . replace ( /\s+/g , " " ) ;
}
function cleanupText ( text ) {
return removeMultipleSpaces (
text . replace ( "SVGs not supported by this browser." , "" ) ,
) . trim ( ) ;
}
2024-04-21 22:30:37 +08:00
const checkStringIncludeRequire = ( str ) => {
return (
str . toLowerCase ( ) . includes ( "*" ) ||
str . toLowerCase ( ) . includes ( "✱" ) ||
str . toLowerCase ( ) . includes ( "require" )
) ;
} ;
const checkRequiredFromStyle = ( element ) => {
2024-12-17 13:32:07 +08:00
const afterCustomStyle = getElementComputedStyle ( element , "::after" ) ;
if ( afterCustomStyle ) {
const afterCustom = afterCustomStyle
. getPropertyValue ( "content" )
. replace ( /"/g , "" ) ;
if ( checkStringIncludeRequire ( afterCustom ) ) {
return true ;
}
2024-04-21 22:30:37 +08:00
}
2024-05-14 18:43:06 +08:00
if ( ! element . className || typeof element . className !== "string" ) {
return false ;
}
2024-04-21 22:30:37 +08:00
return element . className . toLowerCase ( ) . includes ( "require" ) ;
} ;
2024-10-31 00:12:13 +08:00
function checkDisabledFromStyle ( element ) {
const className = element . className . toString ( ) . toLowerCase ( ) ;
if ( className . includes ( "react-datepicker__day--disabled" ) ) {
return true ;
}
return false ;
}
2024-12-05 14:10:30 +08:00
// element should always be the parent of stopped_element
function getElementContext ( element , stopped _element ) {
2024-03-01 10:09:30 -08:00
// dfs to collect the non unique_id context
2024-04-21 22:30:37 +08:00
let fullContext = new Array ( ) ;
2024-12-05 14:10:30 +08:00
if ( element === stopped _element ) {
return fullContext ;
}
2024-04-21 22:30:37 +08:00
// sometimes '*' shows as an after custom style
2024-12-17 13:32:07 +08:00
const afterCustomStyle = getElementComputedStyle ( element , "::after" ) ;
if ( afterCustomStyle ) {
const afterCustom = afterCustomStyle
. getPropertyValue ( "content" )
. replace ( /"/g , "" ) ;
if (
afterCustom . toLowerCase ( ) . includes ( "*" ) ||
afterCustom . toLowerCase ( ) . includes ( "require" )
) {
fullContext . push ( afterCustom ) ;
}
2024-04-21 22:30:37 +08:00
}
2024-12-17 13:32:07 +08:00
2024-03-01 10:09:30 -08:00
if ( element . childNodes . length === 0 ) {
2024-04-21 22:30:37 +08:00
return fullContext . join ( ";" ) ;
2024-03-01 10:09:30 -08:00
}
2024-04-18 03:37:04 -07:00
// if the element already has a context, then add it to the list first
2024-03-01 10:09:30 -08:00
for ( var child of element . childNodes ) {
let childContext = "" ;
2024-06-11 22:33:37 -07:00
if ( child . nodeType === Node . TEXT _NODE && isElementVisible ( element ) ) {
2024-03-01 10:09:30 -08:00
if ( ! element . hasAttribute ( "unique_id" ) ) {
2025-01-27 22:01:15 +08:00
childContext = getElementText ( child ) . trim ( ) ;
2024-03-01 10:09:30 -08:00
}
} else if ( child . nodeType === Node . ELEMENT _NODE ) {
2024-06-11 22:33:37 -07:00
if ( ! child . hasAttribute ( "unique_id" ) && isElementVisible ( child ) ) {
2024-12-05 14:10:30 +08:00
childContext = getElementContext ( child , stopped _element ) ;
2024-03-01 10:09:30 -08:00
}
}
if ( childContext . length > 0 ) {
2024-04-21 22:30:37 +08:00
fullContext . push ( childContext ) ;
2024-03-01 10:09:30 -08:00
}
}
2024-04-21 22:30:37 +08:00
return fullContext . join ( ";" ) ;
2024-03-01 10:09:30 -08:00
}
2024-06-11 22:33:37 -07:00
function getVisibleText ( element ) {
let visibleText = [ ] ;
function collectVisibleText ( node ) {
if (
node . nodeType === Node . TEXT _NODE &&
isElementVisible ( node . parentElement )
) {
const trimmedText = node . data . trim ( ) ;
if ( trimmedText . length > 0 ) {
visibleText . push ( trimmedText ) ;
}
} else if ( node . nodeType === Node . ELEMENT _NODE && isElementVisible ( node ) ) {
for ( let child of node . childNodes ) {
collectVisibleText ( child ) ;
}
}
}
collectVisibleText ( element ) ;
return visibleText . join ( " " ) ;
}
2025-01-27 22:01:15 +08:00
// only get text from element itself
function getElementText ( element ) {
if ( element . nodeType === Node . TEXT _NODE ) {
return element . data . trim ( ) ;
}
let visibleText = [ ] ;
for ( let i = 0 ; i < element . childNodes . length ; i ++ ) {
var node = element . childNodes [ i ] ;
let nodeText = "" ;
if ( node . nodeType === Node . TEXT _NODE && ( nodeText = node . data . trim ( ) ) ) {
visibleText . push ( nodeText ) ;
}
}
return visibleText . join ( ";" ) ;
}
2024-04-16 15:46:04 +08:00
function getElementContent ( element , skipped _element = null ) {
2024-03-01 10:09:30 -08:00
// DFS to get all the text content from all the nodes under the element
2024-04-16 15:46:04 +08:00
if ( skipped _element && element === skipped _element ) {
return "" ;
}
2024-03-01 10:09:30 -08:00
2025-01-27 22:01:15 +08:00
let textContent = getElementText ( element ) ;
2024-03-01 10:09:30 -08:00
let nodeContent = "" ;
// if element has children, then build a list of text and join with a semicolon
if ( element . childNodes . length > 0 ) {
let childTextContentList = new Array ( ) ;
let nodeTextContentList = new Array ( ) ;
for ( var child of element . childNodes ) {
let childText = "" ;
if ( child . nodeType === Node . TEXT _NODE ) {
2025-01-27 22:01:15 +08:00
childText = getElementText ( child ) . trim ( ) ;
2024-06-11 22:33:37 -07:00
if ( childText . length > 0 ) {
nodeTextContentList . push ( childText ) ;
}
2024-03-01 10:09:30 -08:00
} else if ( child . nodeType === Node . ELEMENT _NODE ) {
// childText = child.textContent.trim();
2024-04-16 15:46:04 +08:00
childText = getElementContent ( child , skipped _element ) ;
2024-03-01 10:09:30 -08:00
} else {
console . log ( "Unhandled node type: " , child . nodeType ) ;
}
if ( childText . length > 0 ) {
childTextContentList . push ( childText ) ;
}
}
textContent = childTextContentList . join ( ";" ) ;
nodeContent = cleanupText ( nodeTextContentList . join ( ";" ) ) ;
}
let finalTextContent = cleanupText ( textContent ) ;
// Currently we don't support too much context. Character limit is 1000 per element.
// we don't think element context has to be that big
2024-04-25 09:38:39 +08:00
const charLimit = 5000 ;
2024-03-01 10:09:30 -08:00
if ( finalTextContent . length > charLimit ) {
if ( nodeContent . length <= charLimit ) {
finalTextContent = nodeContent ;
} else {
finalTextContent = "" ;
}
}
return finalTextContent ;
}
function getSelectOptions ( element ) {
const options = Array . from ( element . options ) ;
const selectOptions = [ ] ;
2024-06-03 16:38:08 +05:00
2024-03-01 10:09:30 -08:00
for ( const option of options ) {
selectOptions . push ( {
optionIndex : option . index ,
text : removeMultipleSpaces ( option . textContent ) ,
} ) ;
}
2024-07-16 01:41:56 +08:00
const selectedOption = element . querySelector ( "option:checked" ) ;
if ( ! selectedOption ) {
return [ selectOptions , "" ] ;
}
return [ selectOptions , removeMultipleSpaces ( selectedOption . textContent ) ] ;
2024-03-01 10:09:30 -08:00
}
2024-07-05 02:54:49 +08:00
function getDOMElementBySkyvenElement ( elementObj ) {
// if element has shadowHost set, we need to find the shadowHost element first then find the element
if ( elementObj . shadowHost ) {
let shadowHostEle = document . querySelector (
` [unique_id=" ${ elementObj . shadowHost } "] ` ,
) ;
if ( ! shadowHostEle ) {
console . log (
"Could not find shadowHost element with unique_id: " ,
elementObj . shadowHost ,
) ;
return null ;
}
return shadowHostEle . shadowRoot . querySelector (
` [unique_id=" ${ elementObj . id } "] ` ,
) ;
}
return document . querySelector ( ` [unique_id=" ${ elementObj . id } "] ` ) ;
}
2025-02-18 08:58:23 +08:00
if ( window . elementIdCounter === undefined ) {
window . elementIdCounter = new SafeCounter ( ) ;
}
// generate a unique id for the element
// length is 4, the first character is from the frame index, the last 3 characters are from the counter,
async function uniqueId ( ) {
2024-06-03 16:38:08 +05:00
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" ;
2025-02-18 08:58:23 +08:00
const base = characters . length ;
const extraCharacters = "~!@#$%^&*()-_+=" ;
const extraBase = extraCharacters . length ;
2024-06-03 16:38:08 +05:00
let result = "" ;
2025-02-18 08:58:23 +08:00
if (
window . GlobalSkyvernFrameIndex === undefined ||
window . GlobalSkyvernFrameIndex < 0
) {
const randomIndex = Math . floor ( Math . random ( ) * extraBase ) ;
result += extraCharacters [ randomIndex ] ;
} else {
const c1 = window . GlobalSkyvernFrameIndex % base ;
result += characters [ c1 ] ;
2024-06-03 16:38:08 +05:00
}
2025-02-18 08:58:23 +08:00
const countPart =
( await window . elementIdCounter . add ( ) ) % ( base * base * base ) ;
const c2 = Math . floor ( countPart / ( base * base ) ) ;
result += characters [ c2 ] ;
const c3 = Math . floor ( countPart / base ) % base ;
result += characters [ c3 ] ;
const c4 = countPart % base ;
result += characters [ c4 ] ;
2024-06-03 16:38:08 +05:00
return result ;
}
2025-02-18 08:58:23 +08:00
async function buildElementObject (
frame ,
element ,
interactable ,
purgeable = false ,
) {
var element _id = element . getAttribute ( "unique_id" ) ? ? ( await uniqueId ( ) ) ;
2024-08-21 10:54:32 +08:00
var elementTagNameLower = element . tagName . toLowerCase ( ) ;
element . setAttribute ( "unique_id" , element _id ) ;
const attrs = { } ;
2024-12-11 00:05:16 +08:00
if ( element . attributes [ Symbol . iterator ] ) {
for ( const attr of element . attributes ) {
var attrValue = attr . value ;
if (
attr . name === "required" ||
attr . name === "aria-required" ||
attr . name === "checked" ||
attr . name === "aria-checked" ||
attr . name === "selected" ||
attr . name === "aria-selected" ||
attr . name === "readonly" ||
attr . name === "aria-readonly" ||
attr . name === "disabled" ||
attr . name === "aria-disabled"
) {
if ( attrValue && attrValue . toLowerCase ( ) === "false" ) {
attrValue = false ;
} else {
attrValue = true ;
}
2024-07-03 01:38:50 -07:00
}
2024-12-11 00:05:16 +08:00
attrs [ attr . name ] = attrValue ;
2024-03-01 10:09:30 -08:00
}
2024-12-11 00:05:16 +08:00
} else {
console . warn (
"element.attributes is not iterable. element_id=" + element _id ,
) ;
2024-08-21 10:54:32 +08:00
}
2024-03-01 10:09:30 -08:00
2024-10-31 00:12:13 +08:00
if (
checkDisabledFromStyle ( element ) &&
! attrs [ "disabled" ] &&
! attrs [ "aria-disabled" ]
) {
attrs [ "disabled" ] = true ;
}
2024-08-21 10:54:32 +08:00
if (
checkRequiredFromStyle ( element ) &&
! attrs [ "required" ] &&
! attrs [ "aria-required" ]
) {
attrs [ "required" ] = true ;
}
2024-03-01 10:09:30 -08:00
2024-08-21 10:54:32 +08:00
if ( elementTagNameLower === "input" || elementTagNameLower === "textarea" ) {
2024-12-17 17:42:36 +08:00
if ( element . type === "password" ) {
attrs [ "value" ] = element . value ? "*" . repeat ( element . value . length ) : "" ;
} else {
attrs [ "value" ] = element . value ;
}
2024-08-21 10:54:32 +08:00
}
2024-07-05 02:54:49 +08:00
2024-08-21 10:54:32 +08:00
let elementObj = {
id : element _id ,
frame : frame ,
2025-02-18 08:58:23 +08:00
frame _index : window . GlobalSkyvernFrameIndex ,
2024-08-21 10:54:32 +08:00
interactable : interactable ,
tagName : elementTagNameLower ,
attributes : attrs ,
2024-10-28 19:30:11 +08:00
beforePseudoText : getPseudoContent ( element , "::before" ) ,
2025-01-27 22:01:15 +08:00
text : getElementText ( element ) ,
2024-10-28 19:30:11 +08:00
afterPseudoText : getPseudoContent ( element , "::after" ) ,
2024-08-21 10:54:32 +08:00
children : [ ] ,
rect : DomUtils . getVisibleClientRect ( element , true ) ,
2024-09-07 09:34:33 +08:00
// if purgeable is True, which means this element is only used for building the tree relationship
purgeable : purgeable ,
2024-08-21 10:54:32 +08:00
// don't trim any attr of this element if keepAllAttr=True
keepAllAttr :
elementTagNameLower === "svg" || element . closest ( "svg" ) !== null ,
isSelectable :
elementTagNameLower === "select" ||
2025-01-09 16:14:31 +08:00
isDatePickerSelector ( element ) ||
2025-01-08 14:27:50 +08:00
isDivComboboxDropdown ( element ) ||
2024-08-30 01:24:38 +08:00
isDropdownButton ( element ) ||
2024-09-13 17:57:36 -07:00
isAngularDropdown ( element ) ||
2025-03-17 12:12:16 -07:00
isAngularMaterialDatePicker ( element ) ||
2024-08-21 10:54:32 +08:00
isSelect2Dropdown ( element ) ||
isSelect2MultiChoice ( element ) ,
} ;
2024-08-06 13:30:52 +08:00
2024-08-21 10:54:32 +08:00
let isInShadowRoot = element . getRootNode ( ) instanceof ShadowRoot ;
if ( isInShadowRoot ) {
let shadowHostEle = element . getRootNode ( ) . host ;
let shadowHostId = shadowHostEle . getAttribute ( "unique_id" ) ;
// assign shadowHostId to the shadowHost element if it doesn't have unique_id
if ( ! shadowHostId ) {
2025-02-18 08:58:23 +08:00
shadowHostId = await uniqueId ( ) ;
2024-08-21 10:54:32 +08:00
shadowHostEle . setAttribute ( "unique_id" , shadowHostId ) ;
2024-07-16 01:41:56 +08:00
}
2024-08-21 10:54:32 +08:00
elementObj . shadowHost = shadowHostId ;
}
// get options for select element or for listbox element
let selectOptions = null ;
let selectedValue = "" ;
if ( elementTagNameLower === "select" ) {
[ selectOptions , selectedValue ] = getSelectOptions ( element ) ;
}
2024-03-01 10:09:30 -08:00
2024-08-21 10:54:32 +08:00
if ( selectOptions ) {
elementObj . options = selectOptions ;
2024-03-01 10:09:30 -08:00
}
2024-08-21 10:54:32 +08:00
if ( selectedValue ) {
elementObj . attributes [ "selected" ] = selectedValue ;
}
return elementObj ;
}
2025-02-18 08:58:23 +08:00
// build the element tree for the body
async function buildTreeFromBody (
frame = "main.frame" ,
frame _index = undefined ,
) {
if (
window . GlobalSkyvernFrameIndex === undefined &&
frame _index !== undefined
) {
window . GlobalSkyvernFrameIndex = frame _index ;
}
return await buildElementTree ( document . body , frame ) ;
2024-08-21 10:54:32 +08:00
}
2025-02-18 08:58:23 +08:00
async function buildElementTree (
starter = document . body ,
frame ,
full _tree = false ,
) {
2025-01-04 21:32:41 -08:00
// Generate hover styles map at the start
const hoverStylesMap = getHoverStylesMap ( ) ;
2024-08-21 10:54:32 +08:00
var elements = [ ] ;
var resultArray = [ ] ;
2024-03-01 10:09:30 -08:00
function getChildElements ( element ) {
if ( element . childElementCount !== 0 ) {
return Array . from ( element . children ) ;
} else {
return [ ] ;
}
}
2025-02-18 08:58:23 +08:00
async function processElement ( element , parentId ) {
2024-05-28 15:25:13 +08:00
if ( element === null ) {
console . log ( "get a null element" ) ;
return ;
}
2025-01-27 22:01:15 +08:00
const tagName = element . tagName . toLowerCase ( ) ;
2024-07-05 02:54:49 +08:00
// skip proccessing option element as they are already added to the select.options
2025-01-27 22:01:15 +08:00
if ( tagName === "option" ) {
2024-07-05 02:54:49 +08:00
return ;
}
2024-06-15 20:56:23 -07:00
// if element is an "a" tag and has a target="_blank" attribute, remove the target attribute
// We're doing this so that skyvern can do all the navigation in a single page/tab and not open new tab
2025-01-27 22:01:15 +08:00
if ( tagName === "a" ) {
2024-06-15 20:56:23 -07:00
if ( element . getAttribute ( "target" ) === "_blank" ) {
element . removeAttribute ( "target" ) ;
}
}
2025-01-27 22:01:15 +08:00
let children = [ ] ;
const isVisible = isElementVisible ( element ) ;
if ( isVisible && ! isHidden ( element ) && ! isScriptOrStyle ( element ) ) {
const interactable = isInteractable ( element , hoverStylesMap ) ;
let elementObj = null ;
let isParentSVG = null ;
if ( interactable ) {
2025-02-18 08:58:23 +08:00
elementObj = await buildElementObject ( frame , element , interactable ) ;
2025-01-27 22:01:15 +08:00
} else if (
tagName === "frameset" ||
tagName === "iframe" ||
tagName === "frame"
2024-05-14 18:43:06 +08:00
) {
2025-02-18 08:58:23 +08:00
elementObj = await buildElementObject ( frame , element , interactable ) ;
2025-01-27 22:01:15 +08:00
} else if ( element . shadowRoot ) {
2025-02-18 08:58:23 +08:00
elementObj = await buildElementObject ( frame , element , interactable ) ;
2025-01-27 22:01:15 +08:00
children = getChildElements ( element . shadowRoot ) ;
} else if ( isTableRelatedElement ( element ) ) {
// build all table related elements into skyvern element
// we need these elements to preserve the DOM structure
2025-02-18 08:58:23 +08:00
elementObj = await buildElementObject ( frame , element , interactable ) ;
2025-01-27 22:01:15 +08:00
} else if ( hasBeforeOrAfterPseudoContent ( element ) ) {
2025-02-18 08:58:23 +08:00
elementObj = await buildElementObject ( frame , element , interactable ) ;
2025-01-27 22:01:15 +08:00
} else if ( tagName === "svg" ) {
2025-02-18 08:58:23 +08:00
elementObj = await buildElementObject ( frame , element , interactable ) ;
2025-01-27 22:01:15 +08:00
} else if (
( isParentSVG = element . closest ( "svg" ) ) &&
isParentSVG . getAttribute ( "unique_id" )
) {
// if elemnet is the children of the <svg> with an unique_id
2025-02-18 08:58:23 +08:00
elementObj = await buildElementObject ( frame , element , interactable ) ;
2025-01-27 22:01:15 +08:00
} else if (
getElementText ( element ) . length > 0 &&
getElementText ( element ) . length <= 5000
) {
2025-02-18 08:58:23 +08:00
elementObj = await buildElementObject ( frame , element , interactable ) ;
2025-01-27 22:01:15 +08:00
} else if ( full _tree ) {
// when building full tree, we only get text from element itself
// elements without text are purgeable
2025-02-18 08:58:23 +08:00
elementObj = await buildElementObject (
frame ,
element ,
interactable ,
true ,
) ;
2025-01-27 22:01:15 +08:00
if ( elementObj . text . length > 0 ) {
elementObj . purgeable = false ;
2024-05-14 18:43:06 +08:00
}
2025-01-27 22:01:15 +08:00
}
2024-05-14 18:43:06 +08:00
2025-01-27 22:01:15 +08:00
if ( elementObj ) {
elements . push ( elementObj ) ;
// If the element is interactable but has no interactable parent,
// then it starts a new tree, so add it to the result array
// and set its id as the interactable parent id for the next elements
// under it
if ( parentId === null ) {
resultArray . push ( elementObj ) ;
2024-05-14 18:43:06 +08:00
}
2025-01-27 22:01:15 +08:00
// If the element is interactable and has an interactable parent,
// then add it to the children of the parent
else {
// TODO: use dict/object so that we access these in O(1) instead
elements
. find ( ( element ) => element . id === parentId )
. children . push ( elementObj ) ;
}
parentId = elementObj . id ;
2024-05-14 18:43:06 +08:00
}
2025-01-27 22:01:15 +08:00
}
2024-06-18 11:34:52 +08:00
2025-01-27 22:01:15 +08:00
children = children . concat ( getChildElements ( element ) ) ;
for ( let i = 0 ; i < children . length ; i ++ ) {
const childElement = children [ i ] ;
2025-02-18 08:58:23 +08:00
await processElement ( childElement , parentId ) ;
2024-03-01 10:09:30 -08:00
}
2025-01-27 22:01:15 +08:00
return ;
2024-03-01 10:09:30 -08:00
}
2024-04-21 22:30:37 +08:00
const getContextByParent = ( element , ctx ) => {
2024-12-05 14:10:30 +08:00
// for most elements, we're going 5 layers up to see if we can find "label" as a parent
2024-03-01 10:09:30 -08:00
// if found, most likely the context under label is relevant to this element
let targetParentElements = new Set ( [ "label" , "fieldset" ] ) ;
2024-12-05 14:10:30 +08:00
// look up for 5 levels to find the most contextual parent element
2024-03-01 10:09:30 -08:00
let targetContextualParent = null ;
2024-07-05 02:54:49 +08:00
let currentEle = getDOMElementBySkyvenElement ( element ) ;
if ( ! currentEle ) {
return ctx ;
}
2024-03-01 10:09:30 -08:00
let parentEle = currentEle ;
2024-12-05 14:10:30 +08:00
for ( var i = 0 ; i < 5 ; i ++ ) {
2024-03-01 10:09:30 -08:00
parentEle = parentEle . parentElement ;
if ( parentEle ) {
2024-04-21 22:30:37 +08:00
if (
targetParentElements . has ( parentEle . tagName . toLowerCase ( ) ) ||
2024-05-14 18:43:06 +08:00
( typeof parentEle . className === "string" &&
checkParentClass ( parentEle . className . toLowerCase ( ) ) )
2024-04-21 22:30:37 +08:00
) {
2024-03-01 10:09:30 -08:00
targetContextualParent = parentEle ;
}
} else {
break ;
}
}
2024-04-16 15:46:04 +08:00
if ( ! targetContextualParent ) {
2024-04-21 22:30:37 +08:00
return ctx ;
2024-04-16 15:46:04 +08:00
}
let context = "" ;
var lowerCaseTagName = targetContextualParent . tagName . toLowerCase ( ) ;
2024-04-21 22:30:37 +08:00
if ( lowerCaseTagName === "fieldset" ) {
2024-04-16 15:46:04 +08:00
// fieldset is usually within a form or another element that contains the whole context
targetContextualParent = targetContextualParent . parentElement ;
if ( targetContextualParent ) {
2024-12-05 14:10:30 +08:00
context = getElementContext ( targetContextualParent , currentEle ) ;
2024-03-01 10:09:30 -08:00
}
2024-04-21 22:30:37 +08:00
} else {
2024-12-05 14:10:30 +08:00
context = getElementContext ( targetContextualParent , currentEle ) ;
2024-04-16 15:46:04 +08:00
}
2024-04-21 22:30:37 +08:00
if ( context . length > 0 ) {
ctx . push ( context ) ;
}
return ctx ;
2024-04-16 15:46:04 +08:00
} ;
2024-04-21 22:30:37 +08:00
const getContextByLinked = ( element , ctx ) => {
2024-07-05 02:54:49 +08:00
let currentEle = getDOMElementBySkyvenElement ( element ) ;
if ( ! currentEle ) {
return ctx ;
}
const document = currentEle . getRootNode ( ) ;
2024-04-16 15:46:04 +08:00
// check labels pointed to this element
// 1. element id -> labels pointed to this id
// 2. by attr "aria-labelledby" -> only one label with this id
let linkedElements = new Array ( ) ;
const elementId = currentEle . getAttribute ( "id" ) ;
if ( elementId ) {
2024-05-27 15:18:22 +08:00
try {
linkedElements = [
... document . querySelectorAll ( ` label[for=" ${ elementId } "] ` ) ,
] ;
} catch ( e ) {
console . log ( "failed to query labels: " , e ) ;
}
2024-04-16 15:46:04 +08:00
}
const labelled = currentEle . getAttribute ( "aria-labelledby" ) ;
if ( labelled ) {
const label = document . getElementById ( labelled ) ;
if ( label ) {
linkedElements . push ( label ) ;
}
}
const described = currentEle . getAttribute ( "aria-describedby" ) ;
if ( described ) {
const describe = document . getElementById ( described ) ;
if ( describe ) {
linkedElements . push ( describe ) ;
2024-03-01 10:09:30 -08:00
}
}
2024-04-16 15:46:04 +08:00
const fullContext = new Array ( ) ;
for ( let i = 0 ; i < linkedElements . length ; i ++ ) {
const linked = linkedElements [ i ] ;
// if the element is a child of the label, we should stop to get context before the element
const content = getElementContent ( linked , currentEle ) ;
if ( content ) {
fullContext . push ( content ) ;
}
}
const context = fullContext . join ( ";" ) ;
2024-04-21 22:30:37 +08:00
if ( context . length > 0 ) {
ctx . push ( context ) ;
2024-04-16 15:46:04 +08:00
}
2024-04-21 22:30:37 +08:00
return ctx ;
} ;
2024-04-16 15:46:04 +08:00
2024-04-21 22:30:37 +08:00
const getContextByTable = ( element , ctx ) => {
// pass element's parent's context to the element for listed tags
let tagsWithDirectParentContext = new Set ( [ "a" ] ) ;
// if the element is a child of a td, th, or tr, then pass the grandparent's context to the element
let parentTagsThatDelegateParentContext = new Set ( [ "td" , "th" , "tr" ] ) ;
if ( tagsWithDirectParentContext . has ( element . tagName ) ) {
2024-07-05 02:54:49 +08:00
let curElement = getDOMElementBySkyvenElement ( element ) ;
if ( ! curElement ) {
return ctx ;
}
let parentElement = curElement . parentElement ;
2024-04-21 22:30:37 +08:00
if ( ! parentElement ) {
return ctx ;
}
if (
parentTagsThatDelegateParentContext . has (
parentElement . tagName . toLowerCase ( ) ,
)
) {
let grandParentElement = parentElement . parentElement ;
if ( grandParentElement ) {
2024-12-05 14:10:30 +08:00
let context = getElementContext ( grandParentElement , curElement ) ;
2024-04-21 22:30:37 +08:00
if ( context . length > 0 ) {
ctx . push ( context ) ;
}
}
}
2024-12-05 14:10:30 +08:00
let context = getElementContext ( parentElement , curElement ) ;
2024-04-21 22:30:37 +08:00
if ( context . length > 0 ) {
ctx . push ( context ) ;
}
}
return ctx ;
} ;
const trimDuplicatedText = ( element ) => {
if ( element . children . length === 0 && ! element . options ) {
return ;
}
// if the element has options, text will be duplicated with the option text
if ( element . options ) {
element . options . forEach ( ( option ) => {
element . text = element . text . replace ( option . text , "" ) ;
} ) ;
}
// BFS to delete duplicated text
element . children . forEach ( ( child ) => {
// delete duplicated text in the tree
element . text = element . text . replace ( child . text , "" ) ;
trimDuplicatedText ( child ) ;
} ) ;
// trim multiple ";"
element . text = element . text . replace ( /;+/g , ";" ) ;
// trimleft and trimright ";"
element . text = element . text . replace ( new RegExp ( ` ^;+|;+ $ ` , "g" ) , "" ) ;
} ;
const trimDuplicatedContext = ( element ) => {
if ( element . children . length === 0 ) {
return ;
}
// DFS to delete duplicated context
element . children . forEach ( ( child ) => {
trimDuplicatedContext ( child ) ;
if ( element . context === child . context ) {
delete child . context ;
}
if ( child . context ) {
child . context = child . context . replace ( element . text , "" ) ;
if ( ! child . context ) {
delete child . context ;
}
}
} ) ;
} ;
2024-05-08 10:25:32 +08:00
// some elements without children nodes should be removed out, such as <label>
2024-04-21 22:30:37 +08:00
const removeOrphanNode = ( results ) => {
const trimmedResults = [ ] ;
for ( let i = 0 ; i < results . length ; i ++ ) {
const element = results [ i ] ;
element . children = removeOrphanNode ( element . children ) ;
2024-05-08 10:25:32 +08:00
if ( element . tagName === "label" ) {
const labelElement = document . querySelector (
element . tagName + '[unique_id="' + element . id + '"]' ,
) ;
2024-07-01 21:24:52 -07:00
if (
labelElement &&
labelElement . childElementCount === 0 &&
2024-12-24 02:44:09 +08:00
! labelElement . getAttribute ( "for" ) &&
! element . text
2024-07-01 21:24:52 -07:00
) {
2024-05-08 10:25:32 +08:00
continue ;
}
2024-04-21 22:30:37 +08:00
}
trimmedResults . push ( element ) ;
}
return trimmedResults ;
2024-04-16 15:46:04 +08:00
} ;
// setup before parsing the dom
2025-02-18 08:58:23 +08:00
await processElement ( starter , null ) ;
2024-04-16 15:46:04 +08:00
for ( var element of elements ) {
if (
( ( element . tagName === "input" && element . attributes [ "type" ] === "text" ) ||
element . tagName === "textarea" ) &&
( element . attributes [ "required" ] || element . attributes [ "aria-required" ] ) &&
element . attributes . value === ""
) {
// TODO (kerem): we may want to pass these elements to the LLM as empty but required fields in the future
console . log (
"input element with required attribute and no value" ,
element ,
) ;
}
2024-04-21 22:30:37 +08:00
let ctxList = [ ] ;
2024-09-25 09:50:55 +08:00
try {
ctxList = getContextByLinked ( element , ctxList ) ;
} catch ( e ) {
console . error ( "failed to get context by linked: " , e ) ;
}
try {
ctxList = getContextByParent ( element , ctxList ) ;
} catch ( e ) {
console . error ( "failed to get context by parent: " , e ) ;
}
try {
ctxList = getContextByTable ( element , ctxList ) ;
} catch ( e ) {
console . error ( "failed to get context by table: " , e ) ;
}
2024-04-21 22:30:37 +08:00
const context = ctxList . join ( ";" ) ;
2024-04-25 09:38:39 +08:00
if ( context && context . length <= 5000 ) {
2024-04-16 15:46:04 +08:00
element . context = context ;
}
2024-04-18 03:37:04 -07:00
2024-05-21 10:49:21 +08:00
// FIXME: skip <a> for now to prevent navigating to other page by mistake
if ( element . tagName !== "a" && checkStringIncludeRequire ( context ) ) {
2024-04-18 03:37:04 -07:00
if (
2024-04-21 22:30:37 +08:00
! element . attributes [ "required" ] &&
! element . attributes [ "aria-required" ]
2024-04-18 03:37:04 -07:00
) {
2024-04-21 22:30:37 +08:00
element . attributes [ "required" ] = true ;
2024-04-18 03:37:04 -07:00
}
}
2024-03-01 10:09:30 -08:00
}
2024-04-21 22:30:37 +08:00
resultArray = removeOrphanNode ( resultArray ) ;
resultArray . forEach ( ( root ) => {
trimDuplicatedText ( root ) ;
trimDuplicatedContext ( root ) ;
} ) ;
2024-03-01 10:09:30 -08:00
return [ elements , resultArray ] ;
}
function drawBoundingBoxes ( elements ) {
// draw a red border around the elements
var groups = groupElementsVisually ( elements ) ;
var hintMarkers = createHintMarkersForGroups ( groups ) ;
addHintMarkersToPage ( hintMarkers ) ;
}
2025-02-18 08:58:23 +08:00
async function buildElementsAndDrawBoundingBoxes (
frame = "main.frame" ,
frame _index = undefined ,
) {
var elementsAndResultArray = await buildTreeFromBody ( frame , frame _index ) ;
2024-09-04 02:31:04 +08:00
drawBoundingBoxes ( elementsAndResultArray [ 0 ] ) ;
}
2024-03-01 10:09:30 -08:00
function captchaSolvedCallback ( ) {
console . log ( "captcha solved" ) ;
if ( ! window [ "captchaSolvedCounter" ] ) {
window [ "captchaSolvedCounter" ] = 0 ;
}
// For some reason this isn't being called.. TODO figure out why
window [ "captchaSolvedCounter" ] = window [ "captchaSolvedCounter" ] + 1 ;
}
function getCaptchaSolves ( ) {
if ( ! window [ "captchaSolvedCounter" ] ) {
window [ "captchaSolvedCounter" ] = 0 ;
}
return window [ "captchaSolvedCounter" ] ;
}
function groupElementsVisually ( elements ) {
const groups = [ ] ;
// o n^2
// go through each hint and see if it overlaps with any other hints, if it does, add it to the group of the other hint
// *** if we start from the bigger elements (top -> bottom) we can avoid merging groups
for ( const element of elements ) {
if ( ! element . rect ) {
continue ;
}
const group = groups . find ( ( group ) => {
for ( const groupElement of group . elements ) {
if ( Rect . intersects ( groupElement . rect , element . rect ) ) {
return true ;
}
}
return false ;
} ) ;
if ( group ) {
group . elements . push ( element ) ;
} else {
groups . push ( {
elements : [ element ] ,
} ) ;
}
}
// go through each group and create a rectangle that encompasses all the hints in the group
for ( const group of groups ) {
group . rect = createRectangleForGroup ( group ) ;
}
return groups ;
}
function createRectangleForGroup ( group ) {
const rects = group . elements . map ( ( element ) => element . rect ) ;
const top = Math . min ( ... rects . map ( ( rect ) => rect . top ) ) ;
const left = Math . min ( ... rects . map ( ( rect ) => rect . left ) ) ;
const bottom = Math . max ( ... rects . map ( ( rect ) => rect . bottom ) ) ;
const right = Math . max ( ... rects . map ( ( rect ) => rect . right ) ) ;
return Rect . create ( left , top , right , bottom ) ;
}
function generateHintStrings ( count ) {
const hintCharacters = "sadfjklewcmpgh" ;
let hintStrings = [ "" ] ;
let offset = 0 ;
while ( hintStrings . length - offset < count || hintStrings . length === 1 ) {
const hintString = hintStrings [ offset ++ ] ;
for ( const ch of hintCharacters ) {
hintStrings . push ( ch + hintString ) ;
}
}
hintStrings = hintStrings . slice ( offset , offset + count ) ;
// Shuffle the hints so that they're scattered; hints starting with the same character and short
// hints are spread evenly throughout the array.
return hintStrings . sort ( ) ; // .map((str) => str.reverse())
}
function createHintMarkersForGroups ( groups ) {
if ( groups . length === 0 ) {
console . log ( "No groups found, not adding hint markers to page." ) ;
return [ ] ;
}
2024-07-01 21:24:52 -07:00
const hintMarkers = groups
. filter ( ( group ) => group . elements . some ( ( element ) => element . interactable ) )
. map ( ( group ) => createHintMarkerForGroup ( group ) ) ;
2024-03-01 10:09:30 -08:00
// fill in marker text
2024-07-01 21:24:52 -07:00
// const hintStrings = generateHintStrings(hintMarkers.length);
2024-03-01 10:09:30 -08:00
for ( let i = 0 ; i < hintMarkers . length ; i ++ ) {
const hintMarker = hintMarkers [ i ] ;
2024-07-01 21:24:52 -07:00
let interactableElementFound = false ;
for ( let i = 0 ; i < hintMarker . group . elements . length ; i ++ ) {
if ( hintMarker . group . elements [ i ] . interactable ) {
hintMarker . hintString = hintMarker . group . elements [ i ] . id ;
interactableElementFound = true ;
break ;
}
}
if ( ! interactableElementFound ) {
hintMarker . hintString = "" ;
}
2024-06-10 17:15:11 -04:00
try {
2024-07-01 21:24:52 -07:00
hintMarker . element . innerHTML = hintMarker . hintString ;
2024-06-10 17:12:58 -04:00
} catch ( e ) {
// Ensure trustedTypes is available
2024-06-10 17:15:11 -04:00
if ( typeof trustedTypes !== "undefined" ) {
2025-03-27 01:03:25 -07:00
try {
const escapeHTMLPolicy = trustedTypes . createPolicy ( "hint-policy" , {
createHTML : ( string ) => string ,
} ) ;
hintMarker . element . innerHTML = escapeHTMLPolicy . createHTML (
hintMarker . hintString . toUpperCase ( ) ,
) ;
} catch ( policyError ) {
console . warn ( "Could not create trusted types policy:" , policyError ) ;
// Skip updating the hint marker if policy creation fails
}
2024-06-10 17:12:58 -04:00
} else {
console . error ( "trustedTypes is not supported in this environment." ) ;
}
}
2024-03-01 10:09:30 -08:00
}
return hintMarkers ;
}
function createHintMarkerForGroup ( group ) {
2024-07-01 21:24:52 -07:00
// Calculate the position of the element relative to the document
var scrollTop = window . pageYOffset || document . documentElement . scrollTop ;
var scrollLeft = window . pageXOffset || document . documentElement . scrollLeft ;
2024-03-01 10:09:30 -08:00
const marker = { } ;
// yellow annotation box with string
const el = document . createElement ( "div" ) ;
2024-07-01 21:24:52 -07:00
el . style . position = "absolute" ;
el . style . left = group . rect . left + scrollLeft + "px" ;
el . style . top = group . rect . top + scrollTop + "px" ;
2024-03-01 10:09:30 -08:00
// Each group is assigned a different incremental z-index, we use the same z-index for the
// bounding box and the hint marker
el . style . zIndex = this . currentZIndex ;
// The bounding box around the group of hints.
const boundingBox = document . createElement ( "div" ) ;
// Set styles for the bounding box
boundingBox . style . position = "absolute" ;
boundingBox . style . display = "display" ;
boundingBox . style . left = group . rect . left + scrollLeft + "px" ;
boundingBox . style . top = group . rect . top + scrollTop + "px" ;
boundingBox . style . width = group . rect . width + "px" ;
boundingBox . style . height = group . rect . height + "px" ;
boundingBox . style . bottom = boundingBox . style . top + boundingBox . style . height ;
boundingBox . style . right = boundingBox . style . left + boundingBox . style . width ;
boundingBox . style . border = "2px solid blue" ; // Change the border color as needed
boundingBox . style . pointerEvents = "none" ; // Ensures the box doesn't interfere with other interactions
boundingBox . style . zIndex = this . currentZIndex ++ ;
return Object . assign ( marker , {
element : el ,
boundingBox : boundingBox ,
group : group ,
} ) ;
}
function addHintMarkersToPage ( hintMarkers ) {
const parent = document . createElement ( "div" ) ;
parent . id = "boundingBoxContainer" ;
for ( const hintMarker of hintMarkers ) {
2024-07-01 21:24:52 -07:00
parent . appendChild ( hintMarker . element ) ;
2024-03-01 10:09:30 -08:00
parent . appendChild ( hintMarker . boundingBox ) ;
}
document . documentElement . appendChild ( parent ) ;
}
function removeBoundingBoxes ( ) {
var hintMarkerContainer = document . querySelector ( "#boundingBoxContainer" ) ;
if ( hintMarkerContainer ) {
hintMarkerContainer . remove ( ) ;
}
}
2025-03-27 00:44:49 -07:00
function safeWindowScroll ( x , y ) {
if ( typeof window . scroll === "function" ) {
2025-03-27 01:11:19 -07:00
window . scroll ( { left : x , top : y , behavior : "instant" } ) ;
2025-03-27 00:44:49 -07:00
} else if ( typeof window . scrollTo === "function" ) {
2025-03-27 01:11:19 -07:00
window . scrollTo ( { left : x , top : y , behavior : "instant" } ) ;
2025-03-27 00:44:49 -07:00
} else {
console . error ( "window.scroll and window.scrollTo are both not supported" ) ;
}
}
2025-03-27 00:47:01 -07:00
async function safeScrollToTop (
2025-02-18 08:58:23 +08:00
draw _boxes ,
frame = "main.frame" ,
frame _index = undefined ,
) {
2024-03-01 10:09:30 -08:00
removeBoundingBoxes ( ) ;
2025-03-27 00:44:49 -07:00
safeWindowScroll ( 0 , 0 ) ;
2024-03-01 10:09:30 -08:00
if ( draw _boxes ) {
2025-02-18 08:58:23 +08:00
await buildElementsAndDrawBoundingBoxes ( frame , frame _index ) ;
2024-03-01 10:09:30 -08:00
}
return window . scrollY ;
}
2024-09-21 21:05:40 +08:00
function getScrollXY ( ) {
return [ window . scrollX , window . scrollY ] ;
}
function scrollToXY ( x , y ) {
2025-03-27 00:44:49 -07:00
safeWindowScroll ( x , y ) ;
2024-09-21 21:05:40 +08:00
}
2025-02-18 08:58:23 +08:00
async function scrollToNextPage (
draw _boxes ,
frame = "main.frame" ,
frame _index = undefined ,
) {
2024-03-01 10:09:30 -08:00
// remove bounding boxes, scroll to next page with 200px overlap, then draw bounding boxes again
// return true if there is a next page, false otherwise
removeBoundingBoxes ( ) ;
2024-05-10 12:07:03 +08:00
window . scrollBy ( {
left : 0 ,
top : window . innerHeight - 200 ,
behavior : "instant" ,
} ) ;
2024-03-01 10:09:30 -08:00
if ( draw _boxes ) {
2025-02-18 08:58:23 +08:00
await buildElementsAndDrawBoundingBoxes ( frame , frame _index ) ;
2024-03-01 10:09:30 -08:00
}
return window . scrollY ;
}
2024-06-18 11:34:52 +08:00
2024-09-04 02:31:04 +08:00
function isWindowScrollable ( ) {
2025-03-27 00:26:41 -07:00
const documentBody = document . body ;
const documentElement = document . documentElement ;
if ( ! documentBody || ! documentElement ) {
return false ;
}
2024-09-04 02:31:04 +08:00
// Check if the body's overflow style is set to hidden
2025-03-27 00:26:41 -07:00
const bodyOverflow = getElementComputedStyle ( documentBody ) ? . overflow ;
const htmlOverflow = getElementComputedStyle ( documentElement ) ? . overflow ;
2024-09-04 02:31:04 +08:00
// Check if the document height is greater than the window height
const isScrollable =
document . documentElement . scrollHeight > window . innerHeight ;
// If the overflow is set to 'hidden' or there is no content to scroll, return false
if ( bodyOverflow === "hidden" || htmlOverflow === "hidden" || ! isScrollable ) {
return false ;
}
return true ;
}
2024-08-30 01:24:38 +08:00
function scrollToElementBottom ( element , page _by _page = false ) {
const top = page _by _page
? element . clientHeight + element . scrollTop
: element . scrollHeight ;
2024-08-06 13:30:52 +08:00
element . scroll ( {
2024-08-30 01:24:38 +08:00
top : top ,
2024-08-06 13:30:52 +08:00
left : 0 ,
2024-08-28 14:51:05 +08:00
behavior : "smooth" ,
2024-08-06 13:30:52 +08:00
} ) ;
}
function scrollToElementTop ( element ) {
element . scroll ( {
top : 0 ,
left : 0 ,
behavior : "instant" ,
} ) ;
}
2025-01-04 21:32:41 -08:00
/ * *
* Get all styles associated with : hover selectors
*
* Chrome doesn ' t allow you to compute these in run - time because hover is a protected attribute ( from JS code )
*
* Instead of checking the hover state , we can look at the stylesheet and find all the : hover selectors
* and try to infer styles associated with them
*
* It 's not 100% accurate, but it' s a good start
*
* References :
* https : //stackoverflow.com/questions/23040926/how-can-i-get-elementhover-style
* https : //stackoverflow.com/questions/7013559/is-there-a-way-to-get-element-hover-style-while-the-element-not-in-hover-state
* https : //stackoverflow.com/questions/17226676/how-to-simulate-a-mouseover-in-pure-javascript-that-activates-the-css-hover
* /
function getHoverStylesMap ( ) {
const hoverMap = new Map ( ) ;
const sheets = document . styleSheets ;
try {
for ( const sheet of sheets ) {
try {
const rules = sheet . cssRules || sheet . rules ;
for ( const rule of rules ) {
if ( rule . type === 1 && rule . selectorText ) {
// Split multiple selectors (e.g., "a:hover, button:hover")
const selectors = rule . selectorText . split ( "," ) . map ( ( s ) => s . trim ( ) ) ;
for ( const selector of selectors ) {
// Check if this is a hover rule
if ( selector . includes ( ":hover" ) ) {
// Get all parts of the selector
const parts = selector . split ( /\s*[>+~]\s*/ ) ;
// Get the main hoverable element (the one with :hover)
const hoverPart = parts . find ( ( part ) => part . includes ( ":hover" ) ) ;
if ( ! hoverPart ) continue ;
// Get base selector without :hover
const baseSelector = hoverPart . replace ( /:hover/g , "" ) . trim ( ) ;
// Skip invalid selectors
if ( ! isValidCSSSelector ( baseSelector ) ) {
continue ;
}
// Get or create styles object for this selector
let styles = hoverMap . get ( baseSelector ) || { } ;
// Add all style properties
for ( const prop of rule . style ) {
styles [ prop ] = rule . style [ prop ] ;
}
// If this is a nested selector (like :hover > .something)
// store it in a special format
if ( parts . length > 1 ) {
const fullSelector = selector ;
styles [ "__nested__" ] = styles [ "__nested__" ] || [ ] ;
styles [ "__nested__" ] . push ( {
selector : fullSelector ,
styles : Object . fromEntries (
[ ... rule . style ] . map ( ( prop ) => [ prop , rule . style [ prop ] ] ) ,
) ,
} ) ;
}
2025-01-24 16:18:42 +08:00
// only need the style which includes the cursor attribute.
2025-01-24 16:31:59 +08:00
if ( ! ( "cursor" in styles ) ) {
continue ;
2025-01-24 16:18:42 +08:00
}
2025-01-24 16:31:59 +08:00
hoverMap . set ( baseSelector , styles ) ;
2025-01-04 21:32:41 -08:00
}
}
}
}
} catch ( e ) {
console . warn ( "Could not access stylesheet:" , e ) ;
continue ;
}
}
} catch ( e ) {
console . error ( "Error processing stylesheets:" , e ) ;
}
return hoverMap ;
}
2024-07-03 01:38:50 -07:00
// Helper method for debugging
function findNodeById ( arr , targetId , path = [ ] ) {
for ( let i = 0 ; i < arr . length ; i ++ ) {
const currentPath = [ ... path , arr [ i ] . id ] ;
if ( arr [ i ] . id === targetId ) {
console . log ( "Lineage:" , currentPath . join ( " -> " ) ) ;
return arr [ i ] ;
}
if ( arr [ i ] . children && arr [ i ] . children . length > 0 ) {
const result = findNodeById ( arr [ i ] . children , targetId , currentPath ) ;
if ( result ) {
return result ;
}
}
}
return null ;
}
2024-08-06 13:30:52 +08:00
function getElementDomDepth ( elementNode ) {
let depth = 0 ;
const rootElement = elementNode . getRootNode ( ) . firstElementChild ;
while ( elementNode !== rootElement && elementNode . parentElement ) {
depth ++ ;
elementNode = elementNode . parentElement ;
}
return depth ;
}
if ( window . globalOneTimeIncrementElements === undefined ) {
window . globalOneTimeIncrementElements = [ ] ;
}
2024-08-28 14:51:05 +08:00
if ( window . globalDomDepthMap === undefined ) {
window . globalDomDepthMap = new Map ( ) ;
}
2024-09-03 10:54:11 +08:00
function isClassNameIncludesHidden ( className ) {
// some hidden elements are with the classname like `class="select-items select-hide"`
2024-09-03 11:02:16 +08:00
return className . toLowerCase ( ) . includes ( "hide" ) ;
2024-09-03 10:54:11 +08:00
}
2025-01-28 21:14:31 +08:00
function waitForNextFrame ( ) {
return new Promise ( ( resolve ) => {
requestAnimationFrame ( ( ) => resolve ( ) ) ;
} ) ;
}
2025-03-25 02:28:31 -07:00
function asyncSleepFor ( ms ) {
2025-01-28 21:14:31 +08:00
return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
}
async function addIncrementalNodeToMap ( parentNode , childrenNode ) {
// make the dom parser async
await waitForNextFrame ( ) ;
if ( window . globalListnerFlag ) {
// calculate the depth of targetNode element for sorting
const depth = getElementDomDepth ( parentNode ) ;
let newNodesTreeList = [ ] ;
if ( window . globalDomDepthMap . has ( depth ) ) {
newNodesTreeList = window . globalDomDepthMap . get ( depth ) ;
}
2025-02-05 01:27:49 +08:00
try {
for ( const child of childrenNode ) {
2025-02-18 08:58:23 +08:00
// Pass -1 as frame_index to indicate the frame number is not sensitive in this case
const [ _ , newNodeTree ] = await buildElementTree ( child , "" , true ) ;
2025-02-05 01:27:49 +08:00
if ( newNodeTree . length > 0 ) {
newNodesTreeList . push ( ... newNodeTree ) ;
}
2025-01-28 21:14:31 +08:00
}
2025-02-05 01:27:49 +08:00
} catch ( error ) {
console . error ( "Error building incremental element node:" , error ) ;
2024-08-28 14:51:05 +08:00
}
2025-01-28 21:14:31 +08:00
window . globalDomDepthMap . set ( depth , newNodesTreeList ) ;
2024-08-28 14:51:05 +08:00
}
2025-01-28 21:14:31 +08:00
await window . globalParsedElementCounter . add ( ) ;
2024-08-28 14:51:05 +08:00
}
2024-08-06 13:30:52 +08:00
if ( window . globalObserverForDOMIncrement === undefined ) {
2025-01-28 21:14:31 +08:00
window . globalObserverForDOMIncrement = new MutationObserver ( async function (
2024-08-06 13:30:52 +08:00
mutationsList ,
observer ,
) {
2024-08-28 14:51:05 +08:00
// TODO: how to detect duplicated recreate element?
2024-08-06 13:30:52 +08:00
for ( const mutation of mutationsList ) {
if ( mutation . type === "attributes" ) {
2024-10-08 17:19:31 +08:00
if ( mutation . attributeName === "hidden" ) {
const node = mutation . target ;
if ( ! node . hidden ) {
window . globalOneTimeIncrementElements . push ( {
targetNode : node ,
newNodes : [ node ] ,
} ) ;
2025-01-28 21:14:31 +08:00
await addIncrementalNodeToMap ( node , [ node ] ) ;
2024-10-08 17:19:31 +08:00
}
}
2024-08-06 13:30:52 +08:00
if ( mutation . attributeName === "style" ) {
// TODO: need to confirm that elemnent is hidden previously
2024-09-03 11:02:16 +08:00
const node = mutation . target ;
2024-08-06 13:30:52 +08:00
if ( node . nodeType === Node . TEXT _NODE ) continue ;
2025-01-28 21:14:31 +08:00
if ( node . tagName . toLowerCase ( ) === "body" ) continue ;
2024-12-17 13:32:07 +08:00
const newStyle = getElementComputedStyle ( node ) ;
const newDisplay = newStyle ? . display ;
2024-08-06 13:30:52 +08:00
if ( newDisplay !== "none" ) {
window . globalOneTimeIncrementElements . push ( {
targetNode : node ,
newNodes : [ node ] ,
} ) ;
2025-01-28 21:14:31 +08:00
await addIncrementalNodeToMap ( node , [ node ] ) ;
2024-08-06 13:30:52 +08:00
}
}
2024-09-03 10:54:11 +08:00
if ( mutation . attributeName === "class" ) {
2024-09-03 11:02:16 +08:00
const node = mutation . target ;
2025-01-08 14:27:50 +08:00
if ( node . nodeType === Node . TEXT _NODE ) continue ;
if ( node . tagName . toLowerCase ( ) === "body" ) continue ;
if ( ! mutation . oldValue ) continue ;
2024-09-03 10:54:11 +08:00
if (
2025-01-08 14:27:50 +08:00
! isClassNameIncludesHidden ( mutation . oldValue ) &&
! node . hasAttribute ( "data-menu-uid" ) // google framework use this to trace dropdown menu
2024-09-03 10:54:11 +08:00
)
continue ;
2024-12-17 13:32:07 +08:00
const newStyle = getElementComputedStyle ( node ) ;
const newDisplay = newStyle ? . display ;
2024-09-03 10:54:11 +08:00
if ( newDisplay !== "none" ) {
window . globalOneTimeIncrementElements . push ( {
targetNode : node ,
newNodes : [ node ] ,
} ) ;
2025-01-28 21:14:31 +08:00
await addIncrementalNodeToMap ( node , [ node ] ) ;
2024-09-03 10:54:11 +08:00
}
}
2024-08-06 13:30:52 +08:00
}
if ( mutation . type === "childList" ) {
2025-01-14 13:08:35 +08:00
if ( mutation . target . nodeType === Node . TEXT _NODE ) continue ;
const node = mutation . target ;
2024-08-06 13:30:52 +08:00
let changedNode = {
2025-01-14 13:08:35 +08:00
targetNode : node , // TODO: for future usage, when we want to parse new elements into a tree
2024-08-06 13:30:52 +08:00
} ;
let newNodes = [ ] ;
2025-01-28 21:14:31 +08:00
if ( mutation . addedNodes && mutation . addedNodes . length > 0 ) {
for ( const node of mutation . addedNodes ) {
// skip the text nodes, they won't be interactable
if ( node . nodeType === Node . TEXT _NODE ) continue ;
newNodes . push ( node ) ;
}
}
2025-01-14 13:08:35 +08:00
if (
2025-01-28 21:14:31 +08:00
newNodes . length == 0 &&
( node . tagName . toLowerCase ( ) === "ul" ||
( node . tagName . toLowerCase ( ) === "div" &&
node . hasAttribute ( "role" ) &&
node . getAttribute ( "role" ) . toLowerCase ( ) === "listbox" ) )
2025-01-14 13:08:35 +08:00
) {
newNodes . push ( node ) ;
2024-08-06 13:30:52 +08:00
}
2025-01-28 21:14:31 +08:00
2024-08-06 13:30:52 +08:00
if ( newNodes . length > 0 ) {
changedNode . newNodes = newNodes ;
window . globalOneTimeIncrementElements . push ( changedNode ) ;
2025-01-28 21:14:31 +08:00
await addIncrementalNodeToMap (
changedNode . targetNode ,
changedNode . newNodes ,
) ;
2024-08-06 13:30:52 +08:00
}
}
}
} ) ;
}
function startGlobalIncrementalObserver ( ) {
2025-01-28 21:14:31 +08:00
window . globalListnerFlag = true ;
2024-08-28 14:51:05 +08:00
window . globalDomDepthMap = new Map ( ) ;
2024-08-06 13:30:52 +08:00
window . globalOneTimeIncrementElements = [ ] ;
2025-01-28 21:14:31 +08:00
window . globalParsedElementCounter = new SafeCounter ( ) ;
2024-08-06 13:30:52 +08:00
window . globalObserverForDOMIncrement . takeRecords ( ) ; // cleanup the older data
window . globalObserverForDOMIncrement . observe ( document . body , {
attributes : true ,
attributeOldValue : true ,
childList : true ,
subtree : true ,
characterData : true ,
} ) ;
}
2025-01-28 21:14:31 +08:00
async function stopGlobalIncrementalObserver ( ) {
window . globalListnerFlag = false ;
2024-08-06 13:30:52 +08:00
window . globalObserverForDOMIncrement . disconnect ( ) ;
window . globalObserverForDOMIncrement . takeRecords ( ) ; // cleanup the older data
2025-01-28 21:14:31 +08:00
while (
( await window . globalParsedElementCounter . get ( ) ) <
window . globalOneTimeIncrementElements . length
) {
2025-03-25 02:28:31 -07:00
await asyncSleepFor ( 100 ) ;
2025-01-28 21:14:31 +08:00
}
2024-08-06 13:30:52 +08:00
window . globalOneTimeIncrementElements = [ ] ;
2025-01-28 21:14:31 +08:00
window . globalDomDepthMap = new Map ( ) ;
2024-08-06 13:30:52 +08:00
}
2025-01-28 21:14:31 +08:00
async function getIncrementElements ( ) {
while (
( await window . globalParsedElementCounter . get ( ) ) <
window . globalOneTimeIncrementElements . length
) {
2025-03-25 02:28:31 -07:00
await asyncSleepFor ( 100 ) ;
2025-01-28 21:14:31 +08:00
}
2024-08-06 13:30:52 +08:00
// cleanup the chidren tree, remove the duplicated element
// search starting from the shallowest node:
// 1. if deeper, the node could only be the children of the shallower one or no related one.
// 2. if depth is same, the node could only be duplicated one or no related one.
const idToElement = new Map ( ) ;
const cleanedTreeList = [ ] ;
2024-08-28 14:51:05 +08:00
const sortedDepth = Array . from ( window . globalDomDepthMap . keys ( ) ) . sort (
( a , b ) => a - b ,
) ;
2024-08-06 13:30:52 +08:00
for ( let idx = 0 ; idx < sortedDepth . length ; idx ++ ) {
const depth = sortedDepth [ idx ] ;
2024-08-28 14:51:05 +08:00
const treeList = window . globalDomDepthMap . get ( depth ) ;
2025-02-18 08:58:23 +08:00
const removeDupAndConcatChildren = async ( element ) => {
2024-09-10 17:10:47 +08:00
let children = element . children ;
for ( let i = 0 ; i < children . length ; i ++ ) {
const child = children [ i ] ;
const domElement = document . querySelector ( ` [unique_id=" ${ child . id } "] ` ) ;
// if the element is still on the page, we rebuild the element to update the information
if ( domElement ) {
2025-02-18 08:58:23 +08:00
let newChild = await buildElementObject (
2024-09-10 17:10:47 +08:00
"" ,
domElement ,
child . interactable ,
child . purgeable ,
) ;
newChild . children = child . children ;
children [ i ] = newChild ;
}
}
2024-08-28 14:51:05 +08:00
if ( idToElement . has ( element . id ) ) {
element = idToElement . get ( element . id ) ;
for ( let i = 0 ; i < children . length ; i ++ ) {
const child = children [ i ] ;
if ( ! idToElement . get ( child . id ) ) {
element . children . push ( child ) ;
}
}
}
idToElement . set ( element . id , element ) ;
for ( let i = 0 ; i < children . length ; i ++ ) {
const child = children [ i ] ;
2025-02-18 08:58:23 +08:00
await removeDupAndConcatChildren ( child ) ;
2024-08-28 14:51:05 +08:00
}
} ;
2024-08-06 13:30:52 +08:00
2024-09-10 17:10:47 +08:00
for ( let treeHeadElement of treeList ) {
const domElement = document . querySelector (
` [unique_id=" ${ treeHeadElement . id } "] ` ,
) ;
// if the element is still on the page, we rebuild the element to update the information
if ( domElement ) {
2025-02-18 08:58:23 +08:00
let newHead = await buildElementObject (
2024-09-10 17:10:47 +08:00
"" ,
domElement ,
treeHeadElement . interactable ,
treeHeadElement . purgeable ,
) ;
newHead . children = treeHeadElement . children ;
treeHeadElement = newHead ;
}
2024-08-06 13:30:52 +08:00
// check if the element is existed
2024-08-28 14:51:05 +08:00
if ( ! idToElement . has ( treeHeadElement . id ) ) {
cleanedTreeList . push ( treeHeadElement ) ;
2024-08-06 13:30:52 +08:00
}
2025-02-18 08:58:23 +08:00
await removeDupAndConcatChildren ( treeHeadElement ) ;
2024-08-06 13:30:52 +08:00
}
}
return [ Array . from ( idToElement . values ( ) ) , cleanedTreeList ] ;
}
2025-01-04 21:32:41 -08:00
/ * *
// How to run the code:
// Get all interactable elements and draw boxes
buildElementsAndDrawBoundingBoxes ( ) ;
// Remove the boxes
removeBoundingBoxes ( ) ;
// Get the element tree
const [ elements , tree ] = buildTreeFromBody ( ) ;
console . log ( elements ) ; // All elements
console . log ( tree ) ; // Tree structure
// Test if a specific element is interactable
const element = document . querySelector ( 'button' ) ;
const hoverMap = getHoverStylesMap ( ) ;
console . log ( isInteractable ( element , hoverMap ) ) ;
* /