2024-11-14 02:33:44 +08:00
// we only use chromium browser for now
let browserNameForWorkarounds = "chromium" ;
2025-05-01 09:04:20 -07:00
let _jsConsoleLog = console ? . log ? ? function ( ) { } ; // prevent no console.log error
let _jsConsoleError = console ? . error ? ? _jsConsoleLog ;
let _jsConsoleWarn = console ? . warn ? ? _jsConsoleLog ;
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 {
2025-08-15 01:40:39 +08:00
static elementListCache = [ ] ;
2025-08-14 14:51:43 +08:00
static visibleClientRectCache = new WeakMap ( ) ;
2024-03-01 10:09:30 -08:00
//
// 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 ;
}
}
2025-08-14 14:51:43 +08:00
// add cache to optimize performance
2025-08-14 15:04:15 +08:00
static getVisibleClientRect ( element , testChildren = false ) {
2025-08-14 14:51:43 +08:00
// check cache
const cacheKey = ` ${ testChildren } ` ;
if ( DomUtils . visibleClientRectCache . has ( element ) ) {
const elementCache = DomUtils . visibleClientRectCache . get ( element ) ;
if ( elementCache . has ( cacheKey ) ) {
_jsConsoleLog ( "hit cache to get the rect of element" ) ;
return elementCache . get ( cacheKey ) ;
}
}
2024-03-01 10:09:30 -08:00
// Note: this call will be expensive if we modify the DOM in between calls.
let clientRect ;
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 ;
} ;
2025-08-14 14:51:43 +08:00
let result = null ;
2024-03-01 10:09:30 -08:00
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 ;
2025-08-14 14:51:43 +08:00
result = childClientRect ;
break ;
2024-03-01 10:09:30 -08:00
}
2025-08-14 14:51:43 +08:00
if ( result ) break ;
2024-03-01 10:09:30 -08:00
} 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 ;
2025-08-14 14:51:43 +08:00
result = clientRect ;
break ;
2024-03-01 10:09:30 -08:00
}
}
2025-08-14 14:51:43 +08:00
// cache result
if ( ! DomUtils . visibleClientRectCache . has ( element ) ) {
DomUtils . visibleClientRectCache . set ( element , new Map ( ) ) ;
}
DomUtils . visibleClientRectCache . get ( element ) . set ( cacheKey , result ) ;
return result ;
}
// clear cache
static clearVisibleClientRectCache ( ) {
DomUtils . visibleClientRectCache = new WeakMap ( ) ;
2024-03-01 10:09:30 -08:00
}
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 ,
} ;
}
}
}
2025-08-14 14:24:21 +08:00
class QuadTreeNode {
constructor ( bounds , maxElements = 10 , maxDepth = 4 ) {
this . bounds = bounds ; // {x, y, width, height}
this . maxElements = maxElements ;
this . maxDepth = maxDepth ;
this . elements = [ ] ;
this . children = null ;
this . depth = 0 ;
}
insert ( element ) {
if ( ! this . contains ( element . rect ) ) {
return false ;
}
if ( this . children === null && this . elements . length < this . maxElements ) {
this . elements . push ( element ) ;
return true ;
}
if ( this . children === null ) {
this . subdivide ( ) ;
}
for ( const child of this . children ) {
if ( child . insert ( element ) ) {
return true ;
}
}
this . elements . push ( element ) ;
return true ;
}
subdivide ( ) {
const x = this . bounds . x ;
const y = this . bounds . y ;
const w = this . bounds . width / 2 ;
const h = this . bounds . height / 2 ;
this . children = [
new QuadTreeNode (
{ x , y , width : w , height : h } ,
this . maxElements ,
this . maxDepth ,
) ,
new QuadTreeNode (
{ x : x + w , y , width : w , height : h } ,
this . maxElements ,
this . maxDepth ,
) ,
new QuadTreeNode (
{ x , y : y + h , width : w , height : h } ,
this . maxElements ,
this . maxDepth ,
) ,
new QuadTreeNode (
{ x : x + w , y : y + h , width : w , height : h } ,
this . maxElements ,
this . maxDepth ,
) ,
] ;
for ( const child of this . children ) {
child . depth = this . depth + 1 ;
}
}
contains ( rect ) {
return (
rect . left >= this . bounds . x &&
rect . right <= this . bounds . x + this . bounds . width &&
rect . top >= this . bounds . y &&
rect . bottom <= this . bounds . y + this . bounds . height
) ;
}
query ( rect ) {
const result = [ ] ;
this . queryRecursive ( rect , result ) ;
return result ;
}
queryRecursive ( rect , result ) {
if ( ! this . intersects ( rect ) ) {
return ;
}
result . push ( ... this . elements ) ;
if ( this . children ) {
for ( const child of this . children ) {
child . queryRecursive ( rect , result ) ;
}
}
}
intersects ( rect ) {
return (
rect . left < this . bounds . x + this . bounds . width &&
rect . right > this . bounds . x &&
rect . top < this . bounds . y + this . bounds . height &&
rect . bottom > this . bounds . y
) ;
}
}
2024-03-01 10:09:30 -08:00
// 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
2025-05-08 22:52:12 -07:00
// NOTE: According this logic, some elements with aria-hidden won't be considered as invisible. And the result shows they are indeed interactable.
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 ;
}
2025-05-14 01:24:37 -07:00
function getChildElements ( element ) {
if ( element . childElementCount !== 0 ) {
return Array . from ( element . children ) ;
} else {
return [ ] ;
}
}
2024-11-14 02:33:44 +08:00
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 ;
}
2025-04-28 16:16:36 +08:00
function isDropdownRelatedElement ( element ) {
const tagName = element . tagName ? . toLowerCase ( ) ;
if ( tagName === "select" ) {
return true ;
}
const role = element . getAttribute ( "role" ) ? . toLowerCase ( ) ;
if ( role === "option" || role === "listbox" ) {
return true ;
}
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" ,
2025-04-21 22:36:56 +08:00
"option" ,
2024-03-01 10:09:30 -08:00
] ;
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 ) ;
}
2025-05-14 01:24:37 -07:00
function isDOMNodeRepresentDiv ( element ) {
if ( element ? . tagName ? . toLowerCase ( ) !== "div" ) {
return false ;
}
const style = getElementComputedStyle ( element ) ;
const children = getChildElements ( element ) ;
2025-06-27 21:26:21 -04:00
// flex usually means there are multiple elements in the div as a line or a column
2025-05-14 01:24:37 -07:00
// if the children elements are not just one, we should keep it in the HTML tree to represent a tree structure
if ( style ? . display === "flex" && children . length > 1 ) {
return true ;
}
return false ;
}
2025-06-08 23:45:32 -07:00
function isHoverPointerElement ( element , hoverStylesMap ) {
const tagName = element . tagName . toLowerCase ( ) ;
const elementClassName = element . className . toString ( ) ;
const elementCursor = getElementComputedStyle ( element ) ? . cursor ;
if ( elementCursor === "pointer" ) {
return true ;
}
// 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
2025-07-15 03:31:34 +08:00
if ( elementCursor === "auto" || elementCursor === "default" ) {
2025-06-08 23:45:32 -07:00
// TODO: we need a better algorithm to match the selector with better performance
for ( const [ selector , styles ] of hoverStylesMap ) {
let shouldMatch = false ;
for ( const className of element . classList ) {
if ( selector . includes ( className ) ) {
shouldMatch = true ;
break ;
}
}
if ( shouldMatch || selector . includes ( tagName ) ) {
2025-10-01 11:38:07 -07:00
if ( element . matches ( selector ) && styles . cursor === "pointer" ) {
2025-06-08 23:45:32 -07:00
return true ;
}
}
}
}
// FIXME: hardcode to fix the bug about hover style now
if ( elementClassName . includes ( "hover:cursor-pointer" ) ) {
return true ;
}
return false ;
}
function isInteractableInput ( element , hoverStylesMap ) {
2024-03-01 10:09:30 -08:00
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" ;
2025-06-08 23:45:32 -07:00
return (
isHoverPointerElement ( element , hoverStylesMap ) ||
( ! 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-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-04-24 16:30:41 +08:00
if ( hasWidgetRole ( element ) ) {
return true ;
}
2025-02-26 22:39:16 -08:00
// element with pointer-events: none should not be considered as interactable
2025-05-05 09:33:54 -07:00
// but for elements which are disabled, we should not use this logic to test the interactable
2025-02-26 22:39:16 -08:00
// https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events#none
const elementPointerEvent = getElementComputedStyle ( element ) ? . pointerEvents ;
2025-05-05 09:33:54 -07:00
if ( elementPointerEvent === "none" && ! element . disabled ) {
2025-02-26 22:39:16 -08:00
return false ;
}
2025-06-08 23:45:32 -07:00
if ( isInteractableInput ( element , hoverStylesMap ) ) {
2024-03-01 10:09:30 -08:00
return true ;
}
const tagName = element . tagName . toLowerCase ( ) ;
2025-08-20 10:58:18 +08:00
if ( tagName === "html" ) {
return false ;
}
2024-03-01 10:09:30 -08:00
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 ;
}
2025-08-15 01:40:39 +08:00
const className = element . className ? . toString ( ) ? ? "" ;
2025-05-29 08:03:39 -07:00
2024-09-16 08:39:27 -07:00
if ( tagName === "div" || tagName === "span" ) {
if ( hasAngularClickBinding ( element ) ) {
return true ;
}
2025-05-29 08:03:39 -07:00
if ( className . includes ( "blinking-cursor" ) ) {
2025-01-02 22:10:51 +08:00
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
2025-05-29 08:03:39 -07:00
if ( className . includes ( "svg-container" ) ) {
2024-10-23 22:23:36 -07:00
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" &&
2025-05-29 08:03:39 -07:00
( className . includes ( "ui-menu-item" ) || className . includes ( "dropdown-item" ) )
2024-11-12 12:11:16 +08:00
) {
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" &&
2025-05-29 08:03:39 -07:00
className . includes ( "pac-item" ) &&
2024-11-12 12:11:16 +08:00
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" ||
2025-04-14 10:50:11 -07:00
tagName === "svg" ||
2025-06-30 15:20:20 +09:00
tagName === "strong" ||
tagName === "h1" ||
tagName === "h2" ||
tagName === "h3" ||
tagName === "h4"
2024-08-06 13:30:52 +08:00
) {
2025-06-08 23:45:32 -07:00
if ( isHoverPointerElement ( element , hoverStylesMap ) ) {
2024-09-25 02:01:26 +08:00
return true ;
}
2024-08-06 13:30:52 +08:00
}
2024-11-11 18:57:59 +08:00
if ( hasASPClientControl ( ) && tagName === "tr" ) {
return true ;
}
2025-04-07 05:04:18 -04:00
if ( tagName === "div" && element . hasAttribute ( "data-selectable" ) ) {
return true ;
}
2025-06-04 23:17:28 -07:00
try {
if ( window . jQuery && window . jQuery . _data ) {
const events = window . jQuery . _data ( element , "events" ) ;
if ( events && "click" in events ) {
return true ;
}
2025-06-04 23:05:34 -07:00
}
2025-06-04 23:17:28 -07:00
} catch ( e ) {
_jsConsoleError ( "Error getting jQuery click events:" , e ) ;
2025-06-04 23:05:34 -07:00
}
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 ) {
2025-08-15 01:55:59 +08:00
// Optimization: check for empty values early
if ( ! str || str . length === 0 ) {
2024-03-01 10:09:30 -08:00
return str ;
}
2025-08-15 01:55:59 +08:00
// Optimization: check if contains multiple spaces to avoid unnecessary regex replacement
if (
str . indexOf ( " " ) === - 1 &&
str . indexOf ( "\t" ) === - 1 &&
str . indexOf ( "\n" ) === - 1
) {
return str ;
}
2024-03-01 10:09:30 -08:00
return str . replace ( /\s+/g , " " ) ;
}
function cleanupText ( text ) {
2025-08-15 01:55:59 +08:00
// Optimization: check for empty values early to avoid unnecessary processing
if ( ! text || text . length === 0 ) {
return "" ;
}
// Optimization: use more efficient string replacement
let cleanedText = text ;
// Remove specific SVG error message
if ( cleanedText . includes ( "SVGs not supported by this browser." ) ) {
cleanedText = cleanedText . replace (
"SVGs not supported by this browser." ,
"" ,
) ;
}
// Optimization: combine space processing and trim operations
return removeMultipleSpaces ( cleanedText ) . trim ( ) ;
2024-03-01 10:09:30 -08:00
}
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-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 ( ) ;
}
2025-08-15 01:55:59 +08:00
const childNodes = element . childNodes ;
const childNodesLength = childNodes . length ;
2025-01-27 22:01:15 +08:00
2025-08-15 01:55:59 +08:00
// If no child nodes, return empty string directly
if ( childNodesLength === 0 ) {
2024-04-16 15:46:04 +08:00
return "" ;
}
2024-03-01 10:09:30 -08:00
2025-08-15 01:55:59 +08:00
const visibleText = [ ] ;
let hasText = false ;
for ( let i = 0 ; i < childNodesLength ; i ++ ) {
const node = childNodes [ i ] ;
if ( node . nodeType === Node . TEXT _NODE ) {
const nodeText = node . data . trim ( ) ;
if ( nodeText . length > 0 ) {
visibleText . push ( nodeText ) ;
hasText = true ;
2024-03-01 10:09:30 -08:00
}
}
}
2025-08-15 01:55:59 +08:00
return hasText ? visibleText . join ( ";" ) : "" ;
2024-03-01 10:09:30 -08:00
}
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 ) {
2025-05-01 09:04:20 -07:00
_jsConsoleLog (
2024-07-05 02:54:49 +08:00
"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 {
2025-05-01 09:04:20 -07:00
_jsConsoleWarn (
2024-12-11 00:05:16 +08:00
"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 : [ ] ,
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 ;
}
2025-08-20 10:58:18 +08:00
const elementsAndResultArray = await buildElementTree (
document . documentElement ,
frame ,
) ;
2025-08-15 02:06:08 +08:00
DomUtils . elementListCache = elementsAndResultArray [ 0 ] ;
2025-08-15 01:55:59 +08:00
return elementsAndResultArray ;
2024-08-21 10:54:32 +08:00
}
2025-02-18 08:58:23 +08:00
async function buildElementTree (
2025-08-20 10:58:18 +08:00
starter = document . documentElement ,
2025-02-18 08:58:23 +08:00
frame ,
2025-05-14 01:54:04 -07:00
full _tree = false ,
2025-04-23 01:44:14 +08:00
hoverStylesMap = undefined ,
2025-02-18 08:58:23 +08:00
) {
2025-01-04 21:32:41 -08:00
// Generate hover styles map at the start
2025-04-23 01:44:14 +08:00
if ( hoverStylesMap === undefined ) {
2025-05-29 18:51:59 -07:00
hoverStylesMap = await getHoverStylesMap ( ) ;
2025-04-23 01:44:14 +08:00
}
2025-01-04 21:32:41 -08:00
2025-07-14 13:09:40 +08:00
if ( window . GlobalEnableAllTextualElements === undefined ) {
window . GlobalEnableAllTextualElements = false ;
}
2024-08-21 10:54:32 +08:00
var elements = [ ] ;
var resultArray = [ ] ;
2024-03-01 10:09:30 -08:00
2025-05-13 11:11:16 -07:00
async function processElement (
element ,
parentId ,
parent _xpath ,
current _node _index ,
) {
2024-05-28 15:25:13 +08:00
if ( element === null ) {
2025-05-01 09:04:20 -07:00
_jsConsoleLog ( "get a null element" ) ;
2024-05-28 15:25:13 +08:00
return ;
}
2025-04-22 18:48:01 +08:00
const tagName = element . tagName ? . toLowerCase ( ) ;
if ( ! tagName ) {
2025-05-01 09:04:20 -07:00
_jsConsoleLog ( "get a null tagName" ) ;
2025-04-22 18:48:01 +08:00
return ;
}
2025-01-27 22:01:15 +08:00
2025-08-20 10:58:18 +08:00
if ( tagName === "head" ) {
return ;
}
2025-06-27 21:26:21 -04:00
// skip processing 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 ;
}
2025-05-13 11:11:16 -07:00
let current _xpath = null ;
2025-08-20 10:58:18 +08:00
if ( parent _xpath !== null ) {
2025-05-13 11:11:16 -07:00
// ignore the namespace, otherwise the xpath sometimes won't find anything, specially for SVG elements
current _xpath =
parent _xpath +
"/" +
'*[name()="' +
tagName +
'"]' +
"[" +
current _node _index +
"]" ;
}
let shadowDOMchildren = [ ] ;
2025-06-27 21:26:21 -04:00
// sometimes the shadowRoot is not visible, but the elements in the shadowRoot are visible
2025-05-05 12:10:10 -07:00
if ( element . shadowRoot ) {
2025-05-13 11:11:16 -07:00
shadowDOMchildren = getChildElements ( element . shadowRoot ) ;
2025-05-05 12:10:10 -07:00
}
2025-01-27 22:01:15 +08:00
const isVisible = isElementVisible ( element ) ;
if ( isVisible && ! isHidden ( element ) && ! isScriptOrStyle ( element ) ) {
2025-07-14 13:09:40 +08:00
let interactable = isInteractable ( element , hoverStylesMap ) ;
2025-01-27 22:01:15 +08:00
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
} 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" )
) {
2025-06-27 21:26:21 -04:00
// if element 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-05-14 01:54:04 -07:00
} else if ( tagName === "div" && isDOMNodeRepresentDiv ( element ) ) {
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-07-14 13:09:40 +08:00
if ( window . GlobalEnableAllTextualElements ) {
// force all textual elements to be interactable
interactable = true ;
}
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-05-14 01:54:04 -07:00
if ( elementObj . text . length > 0 ) {
2025-01-27 22:01:15 +08:00
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 ) {
2025-05-13 11:11:16 -07:00
elementObj . xpath = current _xpath ;
2025-01-27 22:01:15 +08:00
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-05-13 11:11:16 -07:00
const children = getChildElements ( element ) ;
const xpathMap = new Map ( ) ;
2025-01-27 22:01:15 +08:00
for ( let i = 0 ; i < children . length ; i ++ ) {
const childElement = children [ i ] ;
2025-05-13 11:11:16 -07:00
const tagName = childElement ? . tagName ? . toLowerCase ( ) ;
if ( ! tagName ) {
_jsConsoleLog ( "get a null tagName" ) ;
continue ;
}
let current _node _index = xpathMap . get ( tagName ) ;
if ( current _node _index == undefined ) {
current _node _index = 1 ;
} else {
current _node _index = current _node _index + 1 ;
}
xpathMap . set ( tagName , current _node _index ) ;
await processElement (
childElement ,
parentId ,
current _xpath ,
current _node _index ,
) ;
}
// FIXME: xpath won't work when the element is in shadow DOM
for ( let i = 0 ; i < shadowDOMchildren . length ; i ++ ) {
const childElement = shadowDOMchildren [ i ] ;
await processElement ( childElement , parentId , null , 0 ) ;
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 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" ) , "" ) ;
} ;
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
} ;
2025-05-13 11:11:16 -07:00
let current _xpath = null ;
2025-08-20 10:58:18 +08:00
if ( starter === document . documentElement ) {
current _xpath = "" ;
2025-05-13 11:11:16 -07:00
}
2024-04-16 15:46:04 +08:00
// setup before parsing the dom
2025-05-13 11:11:16 -07:00
await processElement ( starter , null , current _xpath , 1 ) ;
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
2025-05-01 09:04:20 -07:00
_jsConsoleLog (
2024-04-16 15:46:04 +08:00
"input element with required attribute and no value" ,
element ,
) ;
}
2024-03-01 10:09:30 -08:00
}
2024-04-21 22:30:37 +08:00
resultArray = removeOrphanNode ( resultArray ) ;
resultArray . forEach ( ( root ) => {
trimDuplicatedText ( root ) ;
} ) ;
2024-03-01 10:09:30 -08:00
return [ elements , resultArray ] ;
}
function drawBoundingBoxes ( elements ) {
// draw a red border around the elements
2025-08-14 14:51:43 +08:00
DomUtils . clearVisibleClientRectCache ( ) ;
elements . forEach ( ( element ) => {
const ele = getDOMElementBySkyvenElement ( element ) ;
2025-08-14 15:04:15 +08:00
element . rect = ele ? DomUtils . getVisibleClientRect ( ele , true ) : null ;
2025-08-14 14:51:43 +08:00
} ) ;
2024-03-01 10:09:30 -08:00
var groups = groupElementsVisually ( elements ) ;
var hintMarkers = createHintMarkersForGroups ( groups ) ;
addHintMarkersToPage ( hintMarkers ) ;
2025-08-14 14:51:43 +08:00
DomUtils . clearVisibleClientRectCache ( ) ;
2024-03-01 10:09:30 -08:00
}
2025-02-18 08:58:23 +08:00
async function buildElementsAndDrawBoundingBoxes (
frame = "main.frame" ,
frame _index = undefined ,
) {
2025-08-15 01:40:39 +08:00
if ( DomUtils . elementListCache . length > 0 ) {
drawBoundingBoxes ( DomUtils . elementListCache ) ;
return ;
}
_jsConsoleWarn ( "no element list cache, drawBoundingBoxes from scratch" ) ;
2025-02-18 08:58:23 +08:00
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 ( ) {
2025-05-01 09:04:20 -07:00
_jsConsoleLog ( "captcha solved" ) ;
2024-03-01 10:09:30 -08:00
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 ) {
2025-08-14 14:24:21 +08:00
// Quadtree O(n log n)
const validElements = elements . filter ( ( element ) => element . rect ) ;
if ( validElements . length === 0 ) return [ ] ;
// Calculate bounds
const bounds = calculateBounds ( validElements ) ;
// Create quadtree
const quadTree = new QuadTreeNode ( bounds ) ;
validElements . forEach ( ( element ) => quadTree . insert ( element ) ) ;
2024-03-01 10:09:30 -08:00
const groups = [ ] ;
2025-08-14 14:24:21 +08:00
const processed = new Set ( ) ;
for ( const element of validElements ) {
if ( processed . has ( element ) ) continue ;
const group = { elements : [ element ] , rect : null } ;
processed . add ( element ) ;
// Find all elements overlapping with current element
const overlapping = findOverlappingElements (
element ,
validElements ,
quadTree ,
processed ,
) ;
for ( const overlappingElement of overlapping ) {
group . elements . push ( overlappingElement ) ;
processed . add ( overlappingElement ) ;
2024-03-01 10:09:30 -08:00
}
group . rect = createRectangleForGroup ( group ) ;
2025-08-14 14:24:21 +08:00
groups . push ( group ) ;
2024-03-01 10:09:30 -08:00
}
return groups ;
}
2025-08-14 14:24:21 +08:00
// Helper functions
function calculateBounds ( elements ) {
const rects = elements . map ( ( el ) => el . rect ) ;
const left = Math . min ( ... rects . map ( ( r ) => r . left ) ) ;
const top = Math . min ( ... rects . map ( ( r ) => r . top ) ) ;
const right = Math . max ( ... rects . map ( ( r ) => r . right ) ) ;
const bottom = Math . max ( ... rects . map ( ( r ) => r . bottom ) ) ;
return {
x : left ,
y : top ,
width : right - left ,
height : bottom - top ,
} ;
}
function findOverlappingElements ( element , allElements , quadTree , processed ) {
const result = [ ] ;
const queue = [ element ] ;
while ( queue . length > 0 ) {
const current = queue . shift ( ) ;
// Use quadtree to query nearby elements
const nearby = quadTree . query ( current . rect ) ;
for ( const nearbyElement of nearby ) {
if (
! processed . has ( nearbyElement ) &&
nearbyElement !== current &&
Rect . intersects ( current . rect , nearbyElement . rect )
) {
result . push ( nearbyElement ) ;
processed . add ( nearbyElement ) ;
queue . push ( nearbyElement ) ;
}
}
}
return result ;
}
2024-03-01 10:09:30 -08:00
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 ) {
2025-05-01 09:04:20 -07:00
_jsConsoleLog ( "No groups found, not adding hint markers to page." ) ;
2024-03-01 10:09:30 -08:00
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 ) {
2025-05-01 09:04:20 -07:00
_jsConsoleWarn ( "Could not create trusted types policy:" , policyError ) ;
2025-03-27 01:03:25 -07:00
// Skip updating the hint marker if policy creation fails
}
2024-06-10 17:12:58 -04:00
} else {
2025-05-01 09:04:20 -07:00
_jsConsoleError ( "trustedTypes is not supported in this environment." ) ;
2024-06-10 17:12:58 -04:00
}
}
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 {
2025-05-01 09:04:20 -07:00
_jsConsoleError ( "window.scroll and window.scrollTo are both not supported" ) ;
2025-03-27 00:44:49 -07:00
}
}
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 ;
}
2025-08-15 01:40:39 +08:00
function getScrollWidthAndHeight ( ) {
return [
document . documentElement . scrollWidth ,
document . documentElement . scrollHeight ,
] ;
}
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 ,
2025-06-13 23:59:50 -07:00
need _overlap = true ,
2025-02-18 08:58:23 +08:00
) {
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 ,
2025-06-13 23:59:50 -07:00
top : need _overlap ? window . innerHeight - 200 : window . innerHeight ,
2024-05-10 12:07:03 +08:00
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
* /
2025-05-29 18:51:59 -07:00
async function getHoverStylesMap ( ) {
2025-01-04 21:32:41 -08:00
const hoverMap = new Map ( ) ;
2025-05-29 18:51:59 -07:00
const sheets = [ ... document . styleSheets ] ;
const parseCssSheet = ( sheet ) => {
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 ( ) ;
2025-10-01 11:51:07 -07:00
// Skip invalid selectors
if ( ! isValidCSSSelector ( baseSelector ) ) {
continue ;
}
2025-05-29 18:51:59 -07:00
// 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 ;
2025-10-01 11:38:07 -07:00
styles [ "__nested__" ] = styles [ "__nested__" ] || [ ] ;
styles [ "__nested__" ] . push ( {
selector : fullSelector ,
styles : Object . fromEntries (
[ ... rule . style ] . map ( ( prop ) => [ prop , rule . style [ prop ] ] ) ,
) ,
} ) ;
2025-01-04 21:32:41 -08:00
}
2025-05-29 18:51:59 -07:00
// only need the style which includes the cursor attribute.
if ( ! ( "cursor" in styles ) ) {
continue ;
}
hoverMap . set ( baseSelector , styles ) ;
2025-01-04 21:32:41 -08:00
}
}
}
}
2025-05-29 18:51:59 -07:00
} ;
try {
await Promise . all (
sheets . map ( async ( sheet ) => {
try {
parseCssSheet ( sheet ) ;
} catch ( e ) {
_jsConsoleWarn ( "Could not access stylesheet:" , e ) ;
if ( ( e . name !== "SecurityError" && e . code !== 18 ) || ! sheet . href ) {
return ;
}
let newLink = null ;
try {
2025-07-25 00:50:06 +08:00
const oldLink = sheet . ownerNode ;
const url = new URL ( sheet . href ) ;
2025-05-29 18:51:59 -07:00
_jsConsoleLog ( "recreating the link element: " , sheet . href ) ;
newLink = document . createElement ( "link" ) ;
newLink . rel = "stylesheet" ;
2025-07-25 00:50:06 +08:00
url . searchParams . set ( "v" , Date . now ( ) ) ;
newLink . href = url . toString ( ) ;
2025-05-29 18:51:59 -07:00
newLink . crossOrigin = "anonymous" ;
// until the new link loaded, removing the old one
document . head . append ( newLink ) ;
// wait for a while until the sheet is fully loaded
await asyncSleepFor ( 1500 ) ;
const newSheets = [ ... document . styleSheets ] ;
const refreshedSheet = newSheets . find (
( s ) => s . href === newLink . href ,
) ;
if ( ! refreshedSheet ) {
newLink . remove ( ) ;
return ;
}
_jsConsoleLog ( "parsing recreated the link element: " , newLink . href ) ;
parseCssSheet ( refreshedSheet ) ;
oldLink . remove ( ) ;
} catch ( e ) {
_jsConsoleWarn ( "Error recreating the link element:" , e ) ;
if ( newLink ) {
newLink . remove ( ) ;
}
}
}
} ) ,
) ;
2025-01-04 21:32:41 -08:00
} catch ( e ) {
2025-05-01 09:04:20 -07:00
_jsConsoleError ( "Error processing stylesheets:" , e ) ;
2025-01-04 21:32:41 -08:00
}
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 ) {
2025-05-01 09:04:20 -07:00
_jsConsoleLog ( "Lineage:" , currentPath . join ( " -> " ) ) ;
2024-07-03 01:38:50 -07:00
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 ) {
2025-04-02 11:36:06 -04:00
// some hidden elements are with the classname like `class="select-items select-hide"` or `class="dropdown-container dropdown-invisible"`
return (
className . toLowerCase ( ) . includes ( "hide" ) ||
className . toLowerCase ( ) . includes ( "invisible" )
) ;
2024-09-03 10:54:11 +08:00
}
2025-04-21 12:44:48 +08:00
function isClassNameIncludesActivatedStatus ( className ) {
// some elements are with the classname like `class="open"` or `class="active"` should be considered as activated by the click
return (
className . toLowerCase ( ) . includes ( "open" ) ||
className . toLowerCase ( ) . includes ( "active" )
) ;
}
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 ) {
2025-04-11 19:55:46 -07:00
const maxParsedElement = 3000 ;
2025-04-23 01:44:14 +08:00
const maxElementToWait = 100 ;
2025-04-11 19:55:46 -07:00
if ( ( await window . globalParsedElementCounter . get ( ) ) > maxParsedElement ) {
2025-05-01 09:04:20 -07:00
_jsConsoleWarn (
2025-04-11 19:55:46 -07:00
"Too many elements parsed, stopping the observer to parse the elements" ,
) ;
await window . globalParsedElementCounter . add ( ) ;
return ;
}
2025-01-28 21:14:31 +08:00
// 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-04-16 10:52:21 -07:00
// sleep for a while until animation ends
2025-04-23 01:44:14 +08:00
if (
( await window . globalParsedElementCounter . get ( ) ) < maxElementToWait
) {
await asyncSleepFor ( 300 ) ;
}
2025-02-18 08:58:23 +08:00
// Pass -1 as frame_index to indicate the frame number is not sensitive in this case
2025-04-23 01:44:14 +08:00
const [ _ , newNodeTree ] = await buildElementTree (
child ,
"" ,
true ,
window . globalHoverStylesMap ,
) ;
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 ) {
2025-05-01 09:04:20 -07:00
_jsConsoleError ( "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 ) {
2025-04-28 16:16:36 +08:00
const node = mutation . target ;
if ( node . nodeType === Node . TEXT _NODE ) continue ;
const tagName = node . tagName ? . toLowerCase ( ) ;
2025-05-14 10:22:38 -07:00
// ignore unique_id change to avoid infinite loop about DOM changes
if ( mutation . attributeName === "unique_id" ) continue ;
2025-04-28 16:16:36 +08:00
// if the changing element is dropdown related elements, we should consider
// they're the new element as long as the element is still visible on the page
if (
isDropdownRelatedElement ( node ) &&
getElementComputedStyle ( node ) ? . display !== "none"
) {
window . globalOneTimeIncrementElements . push ( {
targetNode : node ,
newNodes : [ node ] ,
} ) ;
await addIncrementalNodeToMap ( node , [ node ] ) ;
continue ;
}
// if they're not the dropdown related elements
// we detect the element based on the following rules
switch ( mutation . type ) {
case "attributes" : {
switch ( mutation . attributeName ) {
case "hidden" : {
if ( ! node . hidden ) {
window . globalOneTimeIncrementElements . push ( {
targetNode : node ,
newNodes : [ node ] ,
} ) ;
await addIncrementalNodeToMap ( node , [ node ] ) ;
}
break ;
}
case "style" : {
// TODO: need to confirm that elemnent is hidden previously
if ( tagName === "body" ) continue ;
2025-04-28 16:34:25 +08:00
if ( getElementComputedStyle ( node ) ? . display !== "none" ) {
2025-04-28 16:16:36 +08:00
window . globalOneTimeIncrementElements . push ( {
targetNode : node ,
newNodes : [ node ] ,
} ) ;
await addIncrementalNodeToMap ( node , [ node ] ) ;
}
break ;
}
case "class" : {
if ( tagName === "body" ) continue ;
if ( ! mutation . oldValue ) continue ;
const currentClassName = node . className
? node . className . toString ( )
: "" ;
if (
! isClassNameIncludesHidden ( mutation . oldValue ) &&
! isClassNameIncludesActivatedStatus ( currentClassName ) &&
! node . hasAttribute ( "data-menu-uid" ) && // google framework use this to trace dropdown menu
! mutation . oldValue . includes ( "select__items" ) &&
! (
node . hasAttribute ( "data-testid" ) &&
node . getAttribute ( "data-testid" ) . includes ( "select-dropdown" )
)
)
continue ;
2025-04-28 16:34:25 +08:00
if ( getElementComputedStyle ( node ) ? . display !== "none" ) {
2025-04-28 16:16:36 +08:00
window . globalOneTimeIncrementElements . push ( {
targetNode : node ,
newNodes : [ node ] ,
} ) ;
await addIncrementalNodeToMap ( node , [ node ] ) ;
}
break ;
}
2024-10-08 17:19:31 +08:00
}
2025-05-14 10:22:38 -07:00
break ;
2024-10-08 17:19:31 +08:00
}
2025-04-28 16:16:36 +08:00
case "childList" : {
let changedNode = {
targetNode : node , // TODO: for future usage, when we want to parse new elements into a tree
} ;
let newNodes = [ ] ;
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 ) ;
}
2024-08-06 13:30:52 +08:00
}
2024-09-03 10:54:11 +08:00
if (
2025-04-28 16:16:36 +08:00
newNodes . length == 0 &&
( tagName === "ul" ||
( tagName === "div" &&
node . hasAttribute ( "role" ) &&
node . getAttribute ( "role" ) . toLowerCase ( ) === "listbox" ) )
) {
2025-01-28 21:14:31 +08:00
newNodes . push ( node ) ;
}
2025-04-28 16:16:36 +08:00
if ( newNodes . length > 0 ) {
changedNode . newNodes = newNodes ;
window . globalOneTimeIncrementElements . push ( changedNode ) ;
await addIncrementalNodeToMap (
changedNode . targetNode ,
changedNode . newNodes ,
) ;
}
break ;
2024-08-06 13:30:52 +08:00
}
}
}
} ) ;
}
2025-05-29 18:51:59 -07:00
async function startGlobalIncrementalObserver ( element = null ) {
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-05-29 18:51:59 -07:00
window . globalHoverStylesMap = await getHoverStylesMap ( ) ;
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-04-21 22:36:56 +08:00
// if the element is in shadow DOM, we need to observe the shadow DOM as well
if ( element && element . getRootNode ( ) instanceof ShadowRoot ) {
window . globalObserverForDOMIncrement . observe ( element . getRootNode ( ) , {
attributes : true ,
attributeOldValue : true ,
childList : true ,
subtree : true ,
characterData : true ,
} ) ;
}
2024-08-06 13:30:52 +08:00
}
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 (
2025-08-04 11:10:49 +08:00
window . globalParsedElementCounter &&
window . globalOneTimeIncrementElements &&
2025-01-28 21:14:31 +08:00
( await window . globalParsedElementCounter . get ( ) ) <
2025-08-04 11:10:49 +08:00
window . globalOneTimeIncrementElements . length
2025-01-28 21:14:31 +08:00
) {
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-04-09 11:36:27 -07:00
async function getIncrementElements ( wait _until _finished = true ) {
if ( wait _until _finished ) {
while (
( await window . globalParsedElementCounter . get ( ) ) <
window . globalOneTimeIncrementElements . length
) {
await asyncSleepFor ( 100 ) ;
}
2025-01-28 21:14:31 +08:00
}
2025-06-27 21:26:21 -04:00
// cleanup the children tree, remove the duplicated element
2024-08-06 13:30:52 +08:00
// 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 ] ;
2025-04-21 22:36:56 +08:00
// FIXME: skip to update the element if it is in shadow DOM, since document.querySelector will not work
if ( child . shadowHost ) {
continue ;
}
2024-09-10 17:10:47 +08:00
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 ;
2025-04-14 10:50:11 -07:00
} else {
children [ i ] . interactable = false ;
2024-09-10 17:10:47 +08:00
}
}
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 ) {
2025-04-21 22:36:56 +08:00
// FIXME: skip to update the element if it is in shadow DOM, since document.querySelector will not work
if ( ! treeHeadElement . shadowHost ) {
const domElement = document . querySelector (
` [unique_id=" ${ treeHeadElement . id } "] ` ,
2024-09-10 17:10:47 +08:00
) ;
2025-04-21 22:36:56 +08:00
// if the element is still on the page, we rebuild the element to update the information
if ( domElement ) {
let newHead = await buildElementObject (
"" ,
domElement ,
treeHeadElement . interactable ,
treeHeadElement . purgeable ,
) ;
newHead . children = treeHeadElement . children ;
treeHeadElement = newHead ;
} else {
treeHeadElement . interactable = false ;
}
2024-09-10 17:10:47 +08:00
}
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
2025-08-15 15:24:54 +08:00
function isAnimationFinished ( ) {
const animations = document . getAnimations ( { subtree : true } ) ;
const unfinishedAnimations = animations . filter (
( a ) => a . playState !== "finished" ,
) ;
if ( ! unfinishedAnimations || unfinishedAnimations . length == 0 ) {
return true ;
}
2025-08-19 14:26:25 +08:00
return false ;
2025-08-15 15:24:54 +08:00
}
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 ( ) ;
2025-05-01 09:04:20 -07:00
_jsConsoleLog ( elements ) ; // All elements
_jsConsoleLog ( tree ) ; // Tree structure
2025-01-04 21:32:41 -08:00
// Test if a specific element is interactable
const element = document . querySelector ( 'button' ) ;
const hoverMap = getHoverStylesMap ( ) ;
2025-05-01 09:04:20 -07:00
_jsConsoleLog ( isInteractable ( element , hoverMap ) ) ;
2025-01-04 21:32:41 -08:00
* /