Last active
August 5, 2025 23:06
-
-
Save wv-jessejjohnson/84fc7a6a9580069123f82e763aa88c4a to your computer and use it in GitHub Desktop.
dom utils
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| let browserNameForWorkarounds = "chromium"; | |
| let _jsConsoleLog = console?.log ?? function () {}; // prevent no console.log error | |
| let _jsConsoleError = console?.error ?? _jsConsoleLog; | |
| let _jsConsoleWarn = console?.warn ?? _jsConsoleLog; | |
| 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; | |
| } | |
| } | |
| // Commands for manipulating rects. | |
| 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 () { | |
| const elementComputedStyle = getElementComputedStyle(element, null); | |
| const isInlineZeroFontSize = | |
| 0 === | |
| elementComputedStyle?.getPropertyValue("display").indexOf("inline") && | |
| elementComputedStyle?.getPropertyValue("font-size") === "0px"; | |
| // 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)) { | |
| computedStyle = getElementComputedStyle(child, null); | |
| if (!computedStyle) { | |
| continue; | |
| } | |
| // 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) | |
| computedStyle = getElementComputedStyle(element, null); | |
| if (!computedStyle) { | |
| continue; | |
| } | |
| 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 ( | |
| style && | |
| 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; | |
| } | |
| // from playwright: https://github.com/microsoft/playwright/blob/1b65f26f0287c0352e76673bc5f85bc36c934b55/packages/playwright-core/src/server/injected/domUtils.ts#L76-L98 | |
| function isElementStyleVisibilityVisible(element, style) { | |
| style = style ?? getElementComputedStyle(element); | |
| if (!style) return true; | |
| // 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 | |
| if ( | |
| 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; | |
| } | |
| if (style.visibility !== "visible") return false; | |
| // 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 | |
| // 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)" | |
| ) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| function hasASPClientControl() { | |
| return typeof ASPxClientControl !== "undefined"; | |
| } | |
| // from playwright: https://github.com/microsoft/playwright/blob/1b65f26f0287c0352e76673bc5f85bc36c934b55/packages/playwright-core/src/server/injected/domUtils.ts#L100-L119 | |
| // NOTE: According this logic, some elements with aria-hidden won't be considered as invisible. And the result shows they are indeed interactable. | |
| 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. | |
| if ( | |
| element.tagName.toLowerCase() === "option" || | |
| (element.tagName.toLowerCase() === "input" && | |
| (element.type === "radio" || element.type === "checkbox")) | |
| ) | |
| return element.parentElement && isElementVisible(element.parentElement); | |
| const className = element.className ? element.className.toString() : ""; | |
| if ( | |
| className.includes("select2-offscreen") || | |
| className.includes("select2-hidden") || | |
| className.includes("ui-select-offscreen") | |
| ) { | |
| return false; | |
| } | |
| 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; | |
| if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child)) | |
| return true; | |
| } | |
| return false; | |
| } | |
| if (!isElementStyleVisibilityVisible(element, style)) return false; | |
| const rect = element.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/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] | |
| ) { | |
| const style = getElementComputedStyle(singleElement); | |
| 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 getChildElements(element) { | |
| if (element.childElementCount !== 0) { | |
| return Array.from(element.children); | |
| } else { | |
| return []; | |
| } | |
| } | |
| 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) { | |
| return ["", false]; | |
| } | |
| return [hitElement.getAttribute("unique_id") ?? "", true]; | |
| } | |
| function isHidden(element) { | |
| const style = getElementComputedStyle(element); | |
| 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; | |
| } | |
| function isHiddenOrDisabled(element) { | |
| return isHidden(element) || element.disabled; | |
| } | |
| function isScriptOrStyle(element) { | |
| const tagName = element.tagName.toLowerCase(); | |
| return tagName === "script" || tagName === "style"; | |
| } | |
| 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; | |
| } | |
| 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; | |
| } | |
| function hasAngularClickBinding(element) { | |
| return ( | |
| element.hasAttribute("ng-click") || element.hasAttribute("data-ng-click") | |
| ); | |
| } | |
| 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 | |
| if (role.toLowerCase().trim() === "textbox") { | |
| return !isReadonlyElement(element); | |
| } | |
| const widgetRoles = [ | |
| "button", | |
| "link", | |
| "checkbox", | |
| "menuitem", | |
| "menuitemcheckbox", | |
| "menuitemradio", | |
| "radio", | |
| "tab", | |
| "combobox", | |
| "searchbox", | |
| "slider", | |
| "spinbutton", | |
| "switch", | |
| "gridcell", | |
| "option", | |
| ]; | |
| return widgetRoles.includes(role.toLowerCase().trim()); | |
| } | |
| function isTableRelatedElement(element) { | |
| const tagName = element.tagName.toLowerCase(); | |
| return [ | |
| "table", | |
| "caption", | |
| "thead", | |
| "tbody", | |
| "tfoot", | |
| "tr", | |
| "th", | |
| "td", | |
| "colgroup", | |
| "col", | |
| ].includes(tagName); | |
| } | |
| function isDOMNodeRepresentDiv(element) { | |
| if (element?.tagName?.toLowerCase() !== "div") { | |
| return false; | |
| } | |
| const style = getElementComputedStyle(element); | |
| const children = getChildElements(element); | |
| // flex usually means there are multiple elements in the div as a line or a column | |
| // 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; | |
| } | |
| 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 | |
| if (elementCursor === "auto" || elementCursor === "default") { | |
| // 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)) { | |
| if (element.matches(selector) && styles.cursor === "pointer") { | |
| 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) { | |
| const tagName = element.tagName.toLowerCase(); | |
| if (tagName !== "input") { | |
| // let other checks decide | |
| return false; | |
| } | |
| // 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 ( | |
| isHoverPointerElement(element, hoverStylesMap) || | |
| (!isReadonlyElement(element) && type !== "hidden") | |
| ); | |
| } | |
| function isValidCSSSelector(selector) { | |
| try { | |
| document.querySelector(selector); | |
| return true; | |
| } catch (e) { | |
| return false; | |
| } | |
| } | |
| function isInteractable(element, hoverStylesMap) { | |
| if (!isElementVisible(element)) { | |
| return false; | |
| } | |
| if (isHidden(element)) { | |
| return false; | |
| } | |
| if (isScriptOrStyle(element)) { | |
| return false; | |
| } | |
| if (hasWidgetRole(element)) { | |
| return true; | |
| } | |
| // element with pointer-events: none should not be considered as interactable | |
| // but for elements which are disabled, we should not use this logic to test the interactable | |
| // https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events#none | |
| const elementPointerEvent = getElementComputedStyle(element)?.pointerEvents; | |
| if (elementPointerEvent === "none" && !element.disabled) { | |
| return false; | |
| } | |
| if (isInteractableInput(element, hoverStylesMap)) { | |
| return true; | |
| } | |
| const tagName = element.tagName.toLowerCase(); | |
| if (tagName === "iframe") { | |
| return false; | |
| } | |
| if (tagName === "frameset") { | |
| return false; | |
| } | |
| if (tagName === "frame") { | |
| return false; | |
| } | |
| if (tagName === "a" && element.href) { | |
| return true; | |
| } | |
| // Check if the option's parent (select) is hidden or disabled | |
| if (tagName === "option" && isHiddenOrDisabled(element.parentElement)) { | |
| return false; | |
| } | |
| 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 || | |
| element.hasAttribute("jsaction") | |
| ) { | |
| return true; | |
| } | |
| const className = element.className.toString(); | |
| if (tagName === "div" || tagName === "span") { | |
| if (hasAngularClickBinding(element)) { | |
| return true; | |
| } | |
| if (className.includes("blinking-cursor")) { | |
| return true; | |
| } | |
| // 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 (className.includes("svg-container")) { | |
| return true; | |
| } | |
| } | |
| // support listbox and options underneath it | |
| // div element should be checked here before the css pointer | |
| 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; | |
| } | |
| if ( | |
| tagName === "li" && | |
| (className.includes("ui-menu-item") || className.includes("dropdown-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" && | |
| className.includes("pac-item") && | |
| element.closest('div[class*="pac-container"]') | |
| ) { | |
| return true; | |
| } | |
| if ( | |
| tagName === "div" && | |
| element.hasAttribute("aria-disabled") && | |
| element.getAttribute("aria-disabled").toLowerCase() === "false" | |
| ) { | |
| return true; | |
| } | |
| if (tagName === "span" && element.closest('div[id*="dropdown-container"]')) { | |
| return true; | |
| } | |
| if ( | |
| tagName === "div" || | |
| tagName === "img" || | |
| tagName === "span" || | |
| tagName === "a" || | |
| tagName === "i" || | |
| tagName === "li" || | |
| tagName === "p" || | |
| tagName === "td" || | |
| tagName === "svg" || | |
| tagName === "strong" || | |
| tagName === "h1" || | |
| tagName === "h2" || | |
| tagName === "h3" || | |
| tagName === "h4" | |
| ) { | |
| if (isHoverPointerElement(element, hoverStylesMap)) { | |
| return true; | |
| } | |
| } | |
| if (hasASPClientControl() && tagName === "tr") { | |
| return true; | |
| } | |
| if (tagName === "div" && element.hasAttribute("data-selectable")) { | |
| return true; | |
| } | |
| try { | |
| if (window.jQuery && window.jQuery._data) { | |
| const events = window.jQuery._data(element, "events"); | |
| if (events && "click" in events) { | |
| return true; | |
| } | |
| } | |
| } catch (e) { | |
| _jsConsoleError("Error getting jQuery click events:", e); | |
| } | |
| return false; | |
| } | |
| 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) { | |
| const style = getElementComputedStyle(element); | |
| if (!style) { | |
| return false; | |
| } | |
| return ( | |
| style.overflow === "auto" || | |
| style.overflow === "scroll" || | |
| style.overflowX === "auto" || | |
| style.overflowX === "scroll" || | |
| style.overflowY === "auto" || | |
| style.overflowY === "scroll" | |
| ); | |
| } | |
| function isDatePickerSelector(element) { | |
| const tagName = element.tagName.toLowerCase(); | |
| if ( | |
| tagName === "button" && | |
| element.getAttribute("data-testid")?.includes("date") | |
| ) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| 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; | |
| }; | |
| 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; | |
| }; | |
| 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() | |
| : ""; | |
| const hasExpanded = element.hasAttribute("aria-expanded"); | |
| return ( | |
| tagName === "button" && | |
| type === "button" && | |
| (hasExpanded || haspopup === "listbox") | |
| ); | |
| }; | |
| const isSelect2Dropdown = (element) => { | |
| 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; | |
| }; | |
| const isSelect2MultiChoice = (element) => { | |
| return ( | |
| element.tagName.toLowerCase() === "input" && | |
| element.className.toString().includes("select2-input") | |
| ); | |
| }; | |
| const isReactSelectDropdown = (element) => { | |
| return ( | |
| element.tagName.toLowerCase() === "input" && | |
| element.className.toString().includes("select__input") && | |
| element.getAttribute("role") === "combobox" | |
| ); | |
| }; | |
| function hasNgAttribute(element) { | |
| if (!element.attributes[Symbol.iterator]) { | |
| return false; | |
| } | |
| for (let attr of element.attributes) { | |
| if (attr.name.startsWith("ng-")) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| 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; | |
| } | |
| const isAngularDropdown = (element) => { | |
| if (!hasNgAttribute(element)) { | |
| return false; | |
| } | |
| const tagName = element.tagName.toLowerCase(); | |
| if (tagName === "input" || tagName === "span") { | |
| const ariaLabel = element.hasAttribute("aria-label") | |
| ? element.getAttribute("aria-label").toLowerCase() | |
| : ""; | |
| return ariaLabel.includes("select") || ariaLabel.includes("choose"); | |
| } | |
| return false; | |
| }; | |
| 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 | |
| ); | |
| }; | |
| 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 | |
| ); | |
| } | |
| const checkParentClass = (className) => { | |
| const targetParentClasses = ["field", "entry"]; | |
| for (let i = 0; i < targetParentClasses.length; i++) { | |
| if (className.includes(targetParentClasses[i])) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| }; | |
| 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(); | |
| } | |
| const checkStringIncludeRequire = (str) => { | |
| return ( | |
| str.toLowerCase().includes("*") || | |
| str.toLowerCase().includes("✱") || | |
| str.toLowerCase().includes("require") | |
| ); | |
| }; | |
| const checkRequiredFromStyle = (element) => { | |
| const afterCustomStyle = getElementComputedStyle(element, "::after"); | |
| if (afterCustomStyle) { | |
| const afterCustom = afterCustomStyle | |
| .getPropertyValue("content") | |
| .replace(/"/g, ""); | |
| if (checkStringIncludeRequire(afterCustom)) { | |
| return true; | |
| } | |
| } | |
| if (!element.className || typeof element.className !== "string") { | |
| return false; | |
| } | |
| return element.className.toLowerCase().includes("require"); | |
| }; | |
| function checkDisabledFromStyle(element) { | |
| const className = element.className.toString().toLowerCase(); | |
| if (className.includes("react-datepicker__day--disabled")) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| // element should always be the parent of stopped_element | |
| function getElementContext(element, stopped_element) { | |
| // dfs to collect the non unique_id context | |
| let fullContext = new Array(); | |
| if (element === stopped_element) { | |
| return fullContext; | |
| } | |
| // sometimes '*' shows as an after custom style | |
| 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); | |
| } | |
| } | |
| if (element.childNodes.length === 0) { | |
| return fullContext.join(";"); | |
| } | |
| // if the element already has a context, then add it to the list first | |
| for (var child of element.childNodes) { | |
| let childContext = ""; | |
| if (child.nodeType === Node.TEXT_NODE && isElementVisible(element)) { | |
| if (!element.hasAttribute("unique_id")) { | |
| childContext = getElementText(child).trim(); | |
| } | |
| } else if (child.nodeType === Node.ELEMENT_NODE) { | |
| if (!child.hasAttribute("unique_id") && isElementVisible(child)) { | |
| childContext = getElementContext(child, stopped_element); | |
| } | |
| } | |
| if (childContext.length > 0) { | |
| fullContext.push(childContext); | |
| } | |
| } | |
| return fullContext.join(";"); | |
| } | |
| 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(" "); | |
| } | |
| // 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(";"); | |
| } | |
| function getElementContent(element, skipped_element = null) { | |
| // DFS to get all the text content from all the nodes under the element | |
| if (skipped_element && element === skipped_element) { | |
| return ""; | |
| } | |
| let textContent = getElementText(element); | |
| 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) { | |
| childText = getElementText(child).trim(); | |
| if (childText.length > 0) { | |
| nodeTextContentList.push(childText); | |
| } | |
| } else if (child.nodeType === Node.ELEMENT_NODE) { | |
| // childText = child.textContent.trim(); | |
| childText = getElementContent(child, skipped_element); | |
| } else { | |
| _jsConsoleLog("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 | |
| const charLimit = 5000; | |
| 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 = []; | |
| for (const option of options) { | |
| selectOptions.push({ | |
| optionIndex: option.index, | |
| text: removeMultipleSpaces(option.textContent), | |
| }); | |
| } | |
| const selectedOption = element.querySelector("option:checked"); | |
| if (!selectedOption) { | |
| return [selectOptions, ""]; | |
| } | |
| return [selectOptions, removeMultipleSpaces(selectedOption.textContent)]; | |
| } | |
| function getDOMElementByDomUtilElement(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) { | |
| _jsConsoleLog( | |
| "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}"]`); | |
| } | |
| 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() { | |
| const characters = | |
| "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; | |
| const base = characters.length; | |
| const extraCharacters = "~!@#$%^&*()-_+="; | |
| const extraBase = extraCharacters.length; | |
| let result = ""; | |
| if ( | |
| window.GlobalDomUtilFrameIndex === undefined || | |
| window.GlobalDomUtilFrameIndex < 0 | |
| ) { | |
| const randomIndex = Math.floor(Math.random() * extraBase); | |
| result += extraCharacters[randomIndex]; | |
| } else { | |
| const c1 = window.GlobalDomUtilFrameIndex % base; | |
| result += characters[c1]; | |
| } | |
| 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]; | |
| return result; | |
| } | |
| async function buildElementObject( | |
| frame, | |
| element, | |
| interactable, | |
| purgeable = false, | |
| ) { | |
| var element_id = element.getAttribute("unique_id") ?? (await uniqueId()); | |
| var elementTagNameLower = element.tagName.toLowerCase(); | |
| element.setAttribute("unique_id", element_id); | |
| const attrs = {}; | |
| 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; | |
| } | |
| } | |
| attrs[attr.name] = attrValue; | |
| } | |
| } else { | |
| _jsConsoleWarn( | |
| "element.attributes is not iterable. element_id=" + element_id, | |
| ); | |
| } | |
| if ( | |
| checkDisabledFromStyle(element) && | |
| !attrs["disabled"] && | |
| !attrs["aria-disabled"] | |
| ) { | |
| attrs["disabled"] = true; | |
| } | |
| if ( | |
| checkRequiredFromStyle(element) && | |
| !attrs["required"] && | |
| !attrs["aria-required"] | |
| ) { | |
| attrs["required"] = true; | |
| } | |
| if (elementTagNameLower === "input" || elementTagNameLower === "textarea") { | |
| if (element.type === "password") { | |
| attrs["value"] = element.value ? "*".repeat(element.value.length) : ""; | |
| } else { | |
| attrs["value"] = element.value; | |
| } | |
| } | |
| let elementObj = { | |
| id: element_id, | |
| frame: frame, | |
| frame_index: window.GlobalDomUtilFrameIndex, | |
| interactable: interactable, | |
| tagName: elementTagNameLower, | |
| attributes: attrs, | |
| beforePseudoText: getPseudoContent(element, "::before"), | |
| text: getElementText(element), | |
| afterPseudoText: getPseudoContent(element, "::after"), | |
| children: [], | |
| rect: DomUtils.getVisibleClientRect(element, true), | |
| // if purgeable is True, which means this element is only used for building the tree relationship | |
| purgeable: purgeable, | |
| // don't trim any attr of this element if keepAllAttr=True | |
| keepAllAttr: | |
| elementTagNameLower === "svg" || element.closest("svg") !== null, | |
| isSelectable: | |
| elementTagNameLower === "select" || | |
| isDatePickerSelector(element) || | |
| isDivComboboxDropdown(element) || | |
| isDropdownButton(element) || | |
| isAngularDropdown(element) || | |
| isAngularMaterialDatePicker(element) || | |
| isSelect2Dropdown(element) || | |
| isSelect2MultiChoice(element), | |
| }; | |
| 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) { | |
| shadowHostId = await uniqueId(); | |
| shadowHostEle.setAttribute("unique_id", shadowHostId); | |
| } | |
| elementObj.shadowHost = shadowHostId; | |
| } | |
| // get options for select element or for listbox element | |
| let selectOptions = null; | |
| let selectedValue = ""; | |
| if (elementTagNameLower === "select") { | |
| [selectOptions, selectedValue] = getSelectOptions(element); | |
| } | |
| if (selectOptions) { | |
| elementObj.options = selectOptions; | |
| } | |
| if (selectedValue) { | |
| elementObj.attributes["selected"] = selectedValue; | |
| } | |
| return elementObj; | |
| } | |
| // build the element tree for the body | |
| async function buildTreeFromBody( | |
| frame = "main.frame", | |
| frame_index = undefined, | |
| ) { | |
| if ( | |
| window.GlobalDomUtilFrameIndex === undefined && | |
| frame_index !== undefined | |
| ) { | |
| window.GlobalDomUtilFrameIndex = frame_index; | |
| } | |
| return await buildElementTree(document.body, frame); | |
| } | |
| async function buildElementTree( | |
| starter = document.body, | |
| frame, | |
| full_tree = false, | |
| needContext = true, | |
| hoverStylesMap = undefined, | |
| ) { | |
| // Generate hover styles map at the start | |
| if (hoverStylesMap === undefined) { | |
| hoverStylesMap = await getHoverStylesMap(); | |
| } | |
| if (window.GlobalEnableAllTextualElements === undefined) { | |
| window.GlobalEnableAllTextualElements = false; | |
| } | |
| var elements = []; | |
| var resultArray = []; | |
| async function processElement( | |
| element, | |
| parentId, | |
| parent_xpath, | |
| current_node_index, | |
| ) { | |
| if (element === null) { | |
| _jsConsoleLog("get a null element"); | |
| return; | |
| } | |
| const tagName = element.tagName?.toLowerCase(); | |
| if (!tagName) { | |
| _jsConsoleLog("get a null tagName"); | |
| return; | |
| } | |
| // skip processing option element as they are already added to the select.options | |
| if (tagName === "option") { | |
| return; | |
| } | |
| let current_xpath = null; | |
| if (parent_xpath) { | |
| // 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 = []; | |
| // sometimes the shadowRoot is not visible, but the elements in the shadowRoot are visible | |
| if (element.shadowRoot) { | |
| shadowDOMchildren = getChildElements(element.shadowRoot); | |
| } | |
| const isVisible = isElementVisible(element); | |
| if (isVisible && !isHidden(element) && !isScriptOrStyle(element)) { | |
| let interactable = isInteractable(element, hoverStylesMap); | |
| let elementObj = null; | |
| let isParentSVG = null; | |
| if (interactable) { | |
| elementObj = await buildElementObject(frame, element, interactable); | |
| } else if ( | |
| tagName === "frameset" || | |
| tagName === "iframe" || | |
| tagName === "frame" | |
| ) { | |
| elementObj = await buildElementObject(frame, element, interactable); | |
| } else if (element.shadowRoot) { | |
| elementObj = await buildElementObject(frame, element, interactable); | |
| } else if (isTableRelatedElement(element)) { | |
| // build all table related elements into domutil element | |
| // we need these elements to preserve the DOM structure | |
| elementObj = await buildElementObject(frame, element, interactable); | |
| } else if (hasBeforeOrAfterPseudoContent(element)) { | |
| elementObj = await buildElementObject(frame, element, interactable); | |
| } else if (tagName === "svg") { | |
| elementObj = await buildElementObject(frame, element, interactable); | |
| } else if ( | |
| (isParentSVG = element.closest("svg")) && | |
| isParentSVG.getAttribute("unique_id") | |
| ) { | |
| // if element is the children of the <svg> with an unique_id | |
| elementObj = await buildElementObject(frame, element, interactable); | |
| } else if (tagName === "div" && isDOMNodeRepresentDiv(element)) { | |
| elementObj = await buildElementObject(frame, element, interactable); | |
| } else if ( | |
| getElementText(element).length > 0 && | |
| getElementText(element).length <= 5000 | |
| ) { | |
| if (window.GlobalEnableAllTextualElements) { | |
| // force all textual elements to be interactable | |
| interactable = true; | |
| } | |
| elementObj = await buildElementObject(frame, element, interactable); | |
| } else if (full_tree) { | |
| // when building full tree, we only get text from element itself | |
| // elements without text are purgeable | |
| elementObj = await buildElementObject( | |
| frame, | |
| element, | |
| interactable, | |
| true, | |
| ); | |
| if (elementObj.text.length > 0) { | |
| elementObj.purgeable = false; | |
| } | |
| } | |
| if (elementObj) { | |
| elementObj.xpath = current_xpath; | |
| 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); | |
| } | |
| // 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; | |
| } | |
| } | |
| const children = getChildElements(element); | |
| const xpathMap = new Map(); | |
| for (let i = 0; i < children.length; i++) { | |
| const childElement = children[i]; | |
| 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); | |
| } | |
| return; | |
| } | |
| const getContextByParent = (element, ctx) => { | |
| // for most elements, we're going 5 layers up to see if we can find "label" as a parent | |
| // if found, most likely the context under label is relevant to this element | |
| let targetParentElements = new Set(["label", "fieldset"]); | |
| // look up for 5 levels to find the most contextual parent element | |
| let targetContextualParent = null; | |
| let currentEle = getDOMElementByDomUtilElement(element); | |
| if (!currentEle) { | |
| return ctx; | |
| } | |
| let parentEle = currentEle; | |
| for (var i = 0; i < 5; i++) { | |
| parentEle = parentEle.parentElement; | |
| if (parentEle) { | |
| if ( | |
| targetParentElements.has(parentEle.tagName.toLowerCase()) || | |
| (typeof parentEle.className === "string" && | |
| checkParentClass(parentEle.className.toLowerCase())) | |
| ) { | |
| targetContextualParent = parentEle; | |
| } | |
| } else { | |
| break; | |
| } | |
| } | |
| if (!targetContextualParent) { | |
| return ctx; | |
| } | |
| let context = ""; | |
| var lowerCaseTagName = targetContextualParent.tagName.toLowerCase(); | |
| if (lowerCaseTagName === "fieldset") { | |
| // fieldset is usually within a form or another element that contains the whole context | |
| targetContextualParent = targetContextualParent.parentElement; | |
| if (targetContextualParent) { | |
| context = getElementContext(targetContextualParent, currentEle); | |
| } | |
| } else { | |
| context = getElementContext(targetContextualParent, currentEle); | |
| } | |
| if (context.length > 0) { | |
| ctx.push(context); | |
| } | |
| return ctx; | |
| }; | |
| const getContextByLinked = (element, ctx) => { | |
| let currentEle = getDOMElementByDomUtilElement(element); | |
| if (!currentEle) { | |
| return ctx; | |
| } | |
| const document = currentEle.getRootNode(); | |
| // 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) { | |
| try { | |
| linkedElements = [ | |
| ...document.querySelectorAll(`label[for="${elementId}"]`), | |
| ]; | |
| } catch (e) { | |
| _jsConsoleLog("failed to query labels: ", e); | |
| } | |
| } | |
| 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); | |
| } | |
| } | |
| 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(";"); | |
| if (context.length > 0) { | |
| ctx.push(context); | |
| } | |
| return ctx; | |
| }; | |
| 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)) { | |
| let curElement = getDOMElementByDomUtilElement(element); | |
| if (!curElement) { | |
| return ctx; | |
| } | |
| let parentElement = curElement.parentElement; | |
| if (!parentElement) { | |
| return ctx; | |
| } | |
| if ( | |
| parentTagsThatDelegateParentContext.has( | |
| parentElement.tagName.toLowerCase(), | |
| ) | |
| ) { | |
| let grandParentElement = parentElement.parentElement; | |
| if (grandParentElement) { | |
| let context = getElementContext(grandParentElement, curElement); | |
| if (context.length > 0) { | |
| ctx.push(context); | |
| } | |
| } | |
| } | |
| let context = getElementContext(parentElement, curElement); | |
| 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; | |
| } | |
| } | |
| }); | |
| }; | |
| // some elements without children nodes should be removed out, such as <label> | |
| const removeOrphanNode = (results) => { | |
| const trimmedResults = []; | |
| for (let i = 0; i < results.length; i++) { | |
| const element = results[i]; | |
| element.children = removeOrphanNode(element.children); | |
| if (element.tagName === "label") { | |
| const labelElement = document.querySelector( | |
| element.tagName + '[unique_id="' + element.id + '"]', | |
| ); | |
| if ( | |
| labelElement && | |
| labelElement.childElementCount === 0 && | |
| !labelElement.getAttribute("for") && | |
| !element.text | |
| ) { | |
| continue; | |
| } | |
| } | |
| trimmedResults.push(element); | |
| } | |
| return trimmedResults; | |
| }; | |
| let current_xpath = null; | |
| if (starter === document.body) { | |
| current_xpath = "/html[1]"; | |
| } | |
| // setup before parsing the dom | |
| await processElement(starter, null, current_xpath, 1); | |
| 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 | |
| _jsConsoleLog( | |
| "input element with required attribute and no value", | |
| element, | |
| ); | |
| } | |
| let ctxList = []; | |
| if (needContext) { | |
| try { | |
| ctxList = getContextByLinked(element, ctxList); | |
| } catch (e) { | |
| _jsConsoleError("failed to get context by linked: ", e); | |
| } | |
| try { | |
| ctxList = getContextByParent(element, ctxList); | |
| } catch (e) { | |
| _jsConsoleError("failed to get context by parent: ", e); | |
| } | |
| try { | |
| ctxList = getContextByTable(element, ctxList); | |
| } catch (e) { | |
| _jsConsoleError("failed to get context by table: ", e); | |
| } | |
| const context = ctxList.join(";"); | |
| if (context && context.length <= 5000) { | |
| element.context = context; | |
| } | |
| // FIXME: skip <a> for now to prevent navigating to other page by mistake | |
| if (element.tagName !== "a" && checkStringIncludeRequire(context)) { | |
| if ( | |
| !element.attributes["required"] && | |
| !element.attributes["aria-required"] | |
| ) { | |
| element.attributes["required"] = true; | |
| } | |
| } | |
| } | |
| } | |
| resultArray = removeOrphanNode(resultArray); | |
| resultArray.forEach((root) => { | |
| trimDuplicatedText(root); | |
| if (needContext) { | |
| trimDuplicatedContext(root); | |
| } | |
| }); | |
| return [elements, resultArray]; | |
| } | |
| function drawBoundingBoxes(elements) { | |
| // draw a red border around the elements | |
| var groups = groupElementsVisually(elements); | |
| var hintMarkers = createHintMarkersForGroups(groups); | |
| addHintMarkersToPage(hintMarkers); | |
| } | |
| async function buildElementsAndDrawBoundingBoxes( | |
| frame = "main.frame", | |
| frame_index = undefined, | |
| ) { | |
| var elementsAndResultArray = await buildTreeFromBody(frame, frame_index); | |
| drawBoundingBoxes(elementsAndResultArray[0]); | |
| } | |
| function captchaSolvedCallback() { | |
| _jsConsoleLog("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) { | |
| _jsConsoleLog("No groups found, not adding hint markers to page."); | |
| return []; | |
| } | |
| const hintMarkers = groups | |
| .filter((group) => group.elements.some((element) => element.interactable)) | |
| .map((group) => createHintMarkerForGroup(group)); | |
| // fill in marker text | |
| // const hintStrings = generateHintStrings(hintMarkers.length); | |
| for (let i = 0; i < hintMarkers.length; i++) { | |
| const hintMarker = hintMarkers[i]; | |
| 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 = ""; | |
| } | |
| try { | |
| hintMarker.element.innerHTML = hintMarker.hintString; | |
| } catch (e) { | |
| // Ensure trustedTypes is available | |
| if (typeof trustedTypes !== "undefined") { | |
| try { | |
| const escapeHTMLPolicy = trustedTypes.createPolicy("hint-policy", { | |
| createHTML: (string) => string, | |
| }); | |
| hintMarker.element.innerHTML = escapeHTMLPolicy.createHTML( | |
| hintMarker.hintString.toUpperCase(), | |
| ); | |
| } catch (policyError) { | |
| _jsConsoleWarn("Could not create trusted types policy:", policyError); | |
| // Skip updating the hint marker if policy creation fails | |
| } | |
| } else { | |
| _jsConsoleError("trustedTypes is not supported in this environment."); | |
| } | |
| } | |
| } | |
| return hintMarkers; | |
| } | |
| function createHintMarkerForGroup(group) { | |
| // Calculate the position of the element relative to the document | |
| var scrollTop = window.pageYOffset || document.documentElement.scrollTop; | |
| var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; | |
| const marker = {}; | |
| // yellow annotation box with string | |
| const el = document.createElement("div"); | |
| el.style.position = "absolute"; | |
| el.style.left = group.rect.left + scrollLeft + "px"; | |
| el.style.top = group.rect.top + scrollTop + "px"; | |
| // 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) { | |
| parent.appendChild(hintMarker.element); | |
| parent.appendChild(hintMarker.boundingBox); | |
| } | |
| document.documentElement.appendChild(parent); | |
| } | |
| function removeBoundingBoxes() { | |
| var hintMarkerContainer = document.querySelector("#boundingBoxContainer"); | |
| if (hintMarkerContainer) { | |
| hintMarkerContainer.remove(); | |
| } | |
| } | |
| function safeWindowScroll(x, y) { | |
| if (typeof window.scroll === "function") { | |
| window.scroll({ left: x, top: y, behavior: "instant" }); | |
| } else if (typeof window.scrollTo === "function") { | |
| window.scrollTo({ left: x, top: y, behavior: "instant" }); | |
| } else { | |
| _jsConsoleError("window.scroll and window.scrollTo are both not supported"); | |
| } | |
| } | |
| async function safeScrollToTop( | |
| draw_boxes, | |
| frame = "main.frame", | |
| frame_index = undefined, | |
| ) { | |
| removeBoundingBoxes(); | |
| safeWindowScroll(0, 0); | |
| if (draw_boxes) { | |
| await buildElementsAndDrawBoundingBoxes(frame, frame_index); | |
| } | |
| return window.scrollY; | |
| } | |
| function getScrollXY() { | |
| return [window.scrollX, window.scrollY]; | |
| } | |
| function scrollToXY(x, y) { | |
| safeWindowScroll(x, y); | |
| } | |
| async function scrollToNextPage( | |
| draw_boxes, | |
| frame = "main.frame", | |
| frame_index = undefined, | |
| need_overlap = true, | |
| ) { | |
| // 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(); | |
| window.scrollBy({ | |
| left: 0, | |
| top: need_overlap ? window.innerHeight - 200 : window.innerHeight, | |
| behavior: "instant", | |
| }); | |
| if (draw_boxes) { | |
| await buildElementsAndDrawBoundingBoxes(frame, frame_index); | |
| } | |
| return window.scrollY; | |
| } | |
| function isWindowScrollable() { | |
| const documentBody = document.body; | |
| const documentElement = document.documentElement; | |
| if (!documentBody || !documentElement) { | |
| return false; | |
| } | |
| // Check if the body's overflow style is set to hidden | |
| const bodyOverflow = getElementComputedStyle(documentBody)?.overflow; | |
| const htmlOverflow = getElementComputedStyle(documentElement)?.overflow; | |
| // 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; | |
| } | |
| function scrollToElementBottom(element, page_by_page = false) { | |
| const top = page_by_page | |
| ? element.clientHeight + element.scrollTop | |
| : element.scrollHeight; | |
| element.scroll({ | |
| top: top, | |
| left: 0, | |
| behavior: "smooth", | |
| }); | |
| } | |
| function scrollToElementTop(element) { | |
| element.scroll({ | |
| top: 0, | |
| left: 0, | |
| behavior: "instant", | |
| }); | |
| } | |
| /** | |
| * 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 | |
| */ | |
| async function getHoverStylesMap() { | |
| const hoverMap = new Map(); | |
| 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(); | |
| // 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]]), | |
| ), | |
| }); | |
| } | |
| // only need the style which includes the cursor attribute. | |
| if (!("cursor" in styles)) { | |
| continue; | |
| } | |
| hoverMap.set(baseSelector, styles); | |
| } | |
| } | |
| } | |
| } | |
| }; | |
| 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 { | |
| const oldLink = sheet.ownerNode; | |
| const url = new URL(sheet.href); | |
| _jsConsoleLog("recreating the link element: ", sheet.href); | |
| newLink = document.createElement("link"); | |
| newLink.rel = "stylesheet"; | |
| url.searchParams.set("v", Date.now()); | |
| newLink.href = url.toString(); | |
| 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(); | |
| } | |
| } | |
| } | |
| }), | |
| ); | |
| } catch (e) { | |
| _jsConsoleError("Error processing stylesheets:", e); | |
| } | |
| return hoverMap; | |
| } | |
| // 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) { | |
| _jsConsoleLog("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; | |
| } | |
| 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 = []; | |
| } | |
| if (window.globalDomDepthMap === undefined) { | |
| window.globalDomDepthMap = new Map(); | |
| } | |
| function isClassNameIncludesHidden(className) { | |
| // 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") | |
| ); | |
| } | |
| 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") | |
| ); | |
| } | |
| function waitForNextFrame() { | |
| return new Promise((resolve) => { | |
| requestAnimationFrame(() => resolve()); | |
| }); | |
| } | |
| function asyncSleepFor(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| async function addIncrementalNodeToMap(parentNode, childrenNode) { | |
| const maxParsedElement = 3000; | |
| const maxElementToWait = 100; | |
| if ((await window.globalParsedElementCounter.get()) > maxParsedElement) { | |
| _jsConsoleWarn( | |
| "Too many elements parsed, stopping the observer to parse the elements", | |
| ); | |
| await window.globalParsedElementCounter.add(); | |
| return; | |
| } | |
| // 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); | |
| } | |
| try { | |
| for (const child of childrenNode) { | |
| // sleep for a while until animation ends | |
| if ( | |
| (await window.globalParsedElementCounter.get()) < maxElementToWait | |
| ) { | |
| await asyncSleepFor(300); | |
| } | |
| // Pass -1 as frame_index to indicate the frame number is not sensitive in this case | |
| const [_, newNodeTree] = await buildElementTree( | |
| child, | |
| "", | |
| true, | |
| false, | |
| window.globalHoverStylesMap, | |
| ); | |
| if (newNodeTree.length > 0) { | |
| newNodesTreeList.push(...newNodeTree); | |
| } | |
| } | |
| } catch (error) { | |
| _jsConsoleError("Error building incremental element node:", error); | |
| } | |
| window.globalDomDepthMap.set(depth, newNodesTreeList); | |
| } | |
| await window.globalParsedElementCounter.add(); | |
| } | |
| if (window.globalObserverForDOMIncrement === undefined) { | |
| window.globalObserverForDOMIncrement = new MutationObserver(async function ( | |
| mutationsList, | |
| observer, | |
| ) { | |
| // TODO: how to detect duplicated recreate element? | |
| for (const mutation of mutationsList) { | |
| const node = mutation.target; | |
| if (node.nodeType === Node.TEXT_NODE) continue; | |
| const tagName = node.tagName?.toLowerCase(); | |
| // ignore unique_id change to avoid infinite loop about DOM changes | |
| if (mutation.attributeName === "unique_id") continue; | |
| // 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; | |
| if (getElementComputedStyle(node)?.display !== "none") { | |
| 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; | |
| if (getElementComputedStyle(node)?.display !== "none") { | |
| window.globalOneTimeIncrementElements.push({ | |
| targetNode: node, | |
| newNodes: [node], | |
| }); | |
| await addIncrementalNodeToMap(node, [node]); | |
| } | |
| break; | |
| } | |
| } | |
| break; | |
| } | |
| 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); | |
| } | |
| } | |
| if ( | |
| newNodes.length == 0 && | |
| (tagName === "ul" || | |
| (tagName === "div" && | |
| node.hasAttribute("role") && | |
| node.getAttribute("role").toLowerCase() === "listbox")) | |
| ) { | |
| newNodes.push(node); | |
| } | |
| if (newNodes.length > 0) { | |
| changedNode.newNodes = newNodes; | |
| window.globalOneTimeIncrementElements.push(changedNode); | |
| await addIncrementalNodeToMap( | |
| changedNode.targetNode, | |
| changedNode.newNodes, | |
| ); | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| async function startGlobalIncrementalObserver(element = null) { | |
| window.globalListnerFlag = true; | |
| window.globalDomDepthMap = new Map(); | |
| window.globalOneTimeIncrementElements = []; | |
| window.globalHoverStylesMap = await getHoverStylesMap(); | |
| window.globalParsedElementCounter = new SafeCounter(); | |
| window.globalObserverForDOMIncrement.takeRecords(); // cleanup the older data | |
| window.globalObserverForDOMIncrement.observe(document.body, { | |
| attributes: true, | |
| attributeOldValue: true, | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| }); | |
| // 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, | |
| }); | |
| } | |
| } | |
| async function stopGlobalIncrementalObserver() { | |
| window.globalListnerFlag = false; | |
| window.globalObserverForDOMIncrement.disconnect(); | |
| window.globalObserverForDOMIncrement.takeRecords(); // cleanup the older data | |
| while ( | |
| window.globalParsedElementCounter && | |
| window.globalOneTimeIncrementElements && | |
| (await window.globalParsedElementCounter.get()) < | |
| window.globalOneTimeIncrementElements.length | |
| ) { | |
| await asyncSleepFor(100); | |
| } | |
| window.globalOneTimeIncrementElements = []; | |
| window.globalDomDepthMap = new Map(); | |
| } | |
| async function getIncrementElements(wait_until_finished = true) { | |
| if (wait_until_finished) { | |
| while ( | |
| (await window.globalParsedElementCounter.get()) < | |
| window.globalOneTimeIncrementElements.length | |
| ) { | |
| await asyncSleepFor(100); | |
| } | |
| } | |
| // cleanup the children 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 = []; | |
| const sortedDepth = Array.from(window.globalDomDepthMap.keys()).sort( | |
| (a, b) => a - b, | |
| ); | |
| for (let idx = 0; idx < sortedDepth.length; idx++) { | |
| const depth = sortedDepth[idx]; | |
| const treeList = window.globalDomDepthMap.get(depth); | |
| const removeDupAndConcatChildren = async (element) => { | |
| let children = element.children; | |
| for (let i = 0; i < children.length; i++) { | |
| const child = children[i]; | |
| // FIXME: skip to update the element if it is in shadow DOM, since document.querySelector will not work | |
| if (child.shadowHost) { | |
| continue; | |
| } | |
| 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) { | |
| let newChild = await buildElementObject( | |
| "", | |
| domElement, | |
| child.interactable, | |
| child.purgeable, | |
| ); | |
| newChild.children = child.children; | |
| children[i] = newChild; | |
| } else { | |
| children[i].interactable = false; | |
| } | |
| } | |
| 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]; | |
| await removeDupAndConcatChildren(child); | |
| } | |
| }; | |
| for (let treeHeadElement of treeList) { | |
| // 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}"]`, | |
| ); | |
| // 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; | |
| } | |
| } | |
| // check if the element is existed | |
| if (!idToElement.has(treeHeadElement.id)) { | |
| cleanedTreeList.push(treeHeadElement); | |
| } | |
| await removeDupAndConcatChildren(treeHeadElement); | |
| } | |
| } | |
| return [Array.from(idToElement.values()), cleanedTreeList]; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Example usages | |
| // Get all interactable elements and draw boxes | |
| buildElementsAndDrawBoundingBoxes(); | |
| // Remove the boxes | |
| removeBoundingBoxes(); | |
| // Get the element tree | |
| const [elements, tree] = buildTreeFromBody(); | |
| _jsConsoleLog(elements); // All elements | |
| _jsConsoleLog(tree); // Tree structure | |
| // Test if a specific element is interactable | |
| const element = document.querySelector('button'); | |
| const hoverMap = getHoverStylesMap(); | |
| _jsConsoleLog(isInteractable(element, hoverMap)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment