donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
405 lines • 17 kB
JavaScript
;
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