UNPKG

donobu

Version:

Create browser automations with an LLM agent and replay them as Playwright scripts.

405 lines 17 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.installInteractiveElementsTracker = installInteractiveElementsTracker; function installInteractiveElementsTracker() { // Namespace should be initialized by donobu-namespace.ts. const dnb = window.__donobu; if (!dnb) { throw new Error('[Donobu] __donobu namespace missing; interactive-elements-tracker cannot initialize.'); } else if (dnb.getInteractiveElements) { return; } // Monkey-patch MutationObserver to filter out donobu related attributes. // This lets Donobu modify a webpage in certain ways without freaking out the // webpage's normal code. const OriginalMutationObserver = window.MutationObserver; window.MutationObserver = class FilteredMutationObserver { constructor(callback) { // Wrap the user's callback to filter out donobu-related mutations const wrappedCallback = (mutations, observer) => { const filteredMutations = []; for (const mutation of mutations) { // Filter out attribute mutations for data-donobu-* attributes if (mutation.type === 'attributes') { if (mutation.attributeName?.startsWith('data-donobu-')) { // Skip this mutation entirely continue; } } // Filter out childList mutations where the only changes are donobu elements if (mutation.type === 'childList') { const hasNonDonobuAdditions = Array.from(mutation.addedNodes).some((node) => { if (!(node instanceof Element)) { return true; } // Check if this is a donobu-created element (like annotation-shadow-container) return node.id !== 'annotation-shadow-container'; }); const hasNonDonobuRemovals = Array.from(mutation.removedNodes).some((node) => { if (!(node instanceof Element)) { return true; } return node.id !== 'annotation-shadow-container'; }); // If there are no non-donobu changes, skip this mutation if (!hasNonDonobuAdditions && !hasNonDonobuRemovals) { continue; } } // Pass through all other mutations filteredMutations.push(mutation); } // Only invoke the callback if there are non-donobu mutations if (filteredMutations.length > 0) { callback(filteredMutations, observer); } }; // Create the actual observer with our wrapped callback this._observer = new OriginalMutationObserver(wrappedCallback); } observe(...args) { return this._observer.observe(...args); } disconnect(...args) { return this._observer.disconnect(...args); } takeRecords(...args) { // Filter donobu mutations from takeRecords as well const records = this._observer.takeRecords(...args); return records.filter((mutation) => { if (mutation.type === 'attributes') { const attrName = mutation.attributeName; return !attrName?.startsWith('data-donobu-'); } if (mutation.type === 'childList') { const hasRealChanges = Array.from(mutation.addedNodes).some((n) => !(n instanceof Element) || n.id !== 'annotation-shadow-container') || Array.from(mutation.removedNodes).some((n) => !(n instanceof Element) || n.id !== 'annotation-shadow-container'); return hasRealChanges; } return true; }); } }; // Store all elements deemed to be interactive const interactiveElements = new Set(); // ---- listener-count bookkeeping -------------------------------------- const listenerCounts = new WeakMap(); // el -> { type: n, ... } const bumpCount = (el, type, delta) => { let map = listenerCounts.get(el); if (!map) { if (delta < 0) { // nothing to decrement return; } else { map = Object.create(null); listenerCounts.set(el, map); } } map[type] = (map[type] || 0) + delta; if (map[type] <= 0) { delete map[type]; } if (Object.keys(map).length === 0) { listenerCounts.delete(el); } }; const totalListenerCount = (el) => { const map = listenerCounts.get(el); if (!map) { return 0; } return Object.values(map).reduce((sum, count) => sum + count, 0); }; // ---------------------------------------------------------------------- // Original method references for monkey patching const originalAddEventListener = EventTarget.prototype.addEventListener; const originalRemoveEventListener = EventTarget.prototype.removeEventListener; // Events that indicate interactivity const pointerEvents = [ 'click', 'mousedown', 'pointerdown', 'mouseup', 'pointerup', 'touchstart', 'focus', 'blur', 'change', 'input', 'submit', ]; // Helper function to check if an element is in shadow DOM const isInShadowDOM = (element) => { return (element instanceof Element && element.getRootNode() instanceof ShadowRoot); }; // Function to check if an element has interactive CSS properties const hasInteractiveStyling = (element) => { if (!(element instanceof Element)) { return false; } try { const computedStyle = window.getComputedStyle(element); // Check for style properties that suggest interactivity const tabindex = element.getAttribute('tabindex') ?? ''; return (computedStyle.cursor === 'pointer' || computedStyle.cursor === 'hand' || element.getAttribute('role') === 'button' || element.getAttribute('role') === 'link' || element.getAttribute('aria-haspopup') === 'true' || tabindex >= '0' || element.getAttribute('contenteditable') === 'true'); } catch (_error) { return false; // Handle potential errors in computed style } }; // Function to check for React-specific attributes that suggest interactivity const hasReactInteractiveProps = (element) => { if (!(element instanceof Element)) { return false; } try { // Check for React event handler attributes and data attributes const reactEventAttrs = [ 'onClick', 'onMouseDown', 'onMouseUp', 'onPointerDown', 'onPointerUp', 'onTouchStart', 'onChange', 'onInput', 'onFocus', 'onBlur', 'onSubmit', ]; // Check for React component CSS classes that suggest interactivity const interactiveClasses = [ 'cursor-pointer', 'hover:bg-', // Common Tailwind hover classes 'active:bg-', 'focus:outline-', 'hover:text-', ]; const dataset = element instanceof HTMLElement ? element.dataset : null; // Check for attributes for (const attr of reactEventAttrs) { if (element.hasAttribute(attr) || element.getAttribute(attr.toLowerCase()) !== null || (dataset && attr in dataset)) { return true; } } // Check for class names suggesting interactivity const className = element.className || ''; if (typeof className === 'string') { for (const cls of interactiveClasses) { if (className.includes(cls)) { return true; } } } return false; } catch (_error) { return false; // Handle any potential errors } }; // Monkey patch addEventListener to track elements with event listeners EventTarget.prototype.addEventListener = function (type, listener, options) { if (pointerEvents.includes(type) && this !== window && this !== document) { interactiveElements.add(this); bumpCount(this, type, 1); // If this element is in shadow DOM, ensure it's accessible if (isInShadowDOM(this)) { try { const shadowRoot = this.getRootNode(); if (shadowRoot instanceof ShadowRoot && shadowRoot.host) { interactiveElements.add(shadowRoot.host); } } catch (_error) { // Ignore errors if getRootNode fails } } } return originalAddEventListener.call(this, type, listener, options); }; // monkey-patch removeEventListener (count only, no deletion here) EventTarget.prototype.removeEventListener = function (type, listener, options) { if (pointerEvents.includes(type) && this !== window && this !== document) { bumpCount(this, type, -1); if (totalListenerCount(this) <= 0 && !hasInteractiveStyling(this) && !hasReactInteractiveProps(this)) { removeFromInteractiveSet(this); } } return originalRemoveEventListener.call(this, type, listener, options); }; // Scan DOM for potentially interactive elements const scanForInteractiveElements = () => { try { // Common interactive element selectors const interactiveSelectors = [ 'a', 'th', 'tr', 'button', 'input', 'select', 'textarea', 'summary', '[role="button"]', '[role="link"]', '[role="menuitem"]', '[role="tab"]', '[tabindex]', '[contenteditable="true"]', '.cursor-pointer', ]; // Only proceed if document is available if (document?.body) { // Query for potential interactive elements const potentialElements = document.querySelectorAll(interactiveSelectors.join(',')); potentialElements.forEach((element) => { // Add element if it has interactive styling or React props if (hasInteractiveStyling(element) || hasReactInteractiveProps(element)) { interactiveElements.add(element); } }); // Handle shadow DOM document.querySelectorAll('*').forEach((element) => { if (element.shadowRoot) { try { const shadowElements = element.shadowRoot.querySelectorAll(interactiveSelectors.join(',')); shadowElements.forEach((shadowEl) => { if (hasInteractiveStyling(shadowEl) || hasReactInteractiveProps(shadowEl)) { interactiveElements.add(shadowEl); interactiveElements.add(element); // Add the host element as well } }); } catch (_error) { // Continue if there's an error with a particular shadow root } } }); } } catch (error) { console.error('Error scanning for interactive elements:', error); } }; const removeFromInteractiveSet = (node) => { if (interactiveElements.delete(node) && node instanceof Element) { if (node.shadowRoot) { // also drop any interactive nodes inside a shadow tree Array.from(node.shadowRoot.querySelectorAll('*')).forEach((el) => interactiveElements.delete(el)); } } if (node instanceof Element || node instanceof DocumentFragment) { node .querySelectorAll('*') .forEach((child) => interactiveElements.delete(child)); } }; // Initialize MutationObserver safely let observer = null; const initializeMutationObserver = () => { if (!document?.body) { return; } try { observer = new MutationObserver((mutations) => { let shouldRescan = false; for (const mutation of mutations) { if (mutation.type === 'childList') { // detached nodes mutation.removedNodes.forEach(removeFromInteractiveSet); if (mutation.addedNodes.length > 0) { shouldRescan = true; } } else if (mutation.type === 'attributes') { const target = mutation.target; if (target instanceof Element) { if (hasInteractiveStyling(target) || hasReactInteractiveProps(target)) { interactiveElements.add(target); } else if (totalListenerCount(target) <= 0) { removeFromInteractiveSet(target); } } } } if (shouldRescan) { scanForInteractiveElements(); } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: [ 'class', 'role', 'tabindex', 'style', 'aria-haspopup', ], }); } catch (error) { console.error('Error initializing MutationObserver:', error); } }; // Add components without overwriting the namespace Object.defineProperty(dnb, 'getInteractiveElements', { value: () => { // Trigger a scan to ensure we have the latest elements scanForInteractiveElements(); const allInteractiveElements = []; interactiveElements.forEach((element) => { if (element instanceof Element) { allInteractiveElements.push(element); // Include shadow DOM elements if (element.shadowRoot) { try { const shadowElements = Array.from(element.shadowRoot.querySelectorAll('*')).filter((el) => interactiveElements.has(el)); allInteractiveElements.push(...shadowElements); } catch (_error) { // Continue if there's an error with a particular shadow element } } } }); return allInteractiveElements; }, enumerable: false, writable: false, configurable: false, }); // Initialize things safely after DOM content is loaded const initialize = () => { scanForInteractiveElements(); initializeMutationObserver(); }; // Set up a safer initialization approach if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { // DOM already ready, initialize now initialize(); } } //# sourceMappingURL=interactive-elements-tracker.js.map