bb-browser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/dist/chunk-KATHFDYJ.js +20 -0
- package/dist/chunk-KATHFDYJ.js.map +1 -0
- package/dist/cli.js +1800 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon.js +441 -0
- package/dist/daemon.js.map +1 -0
- package/extension/background.js +2943 -0
- package/extension/background.js.map +1 -0
- package/extension/buildDomTree.js +1505 -0
- package/extension/content/trace.js +339 -0
- package/extension/content/trace.js.map +1 -0
- package/extension/dist/background.js +2943 -0
- package/extension/dist/background.js.map +1 -0
- package/extension/dist/buildDomTree.js +1505 -0
- package/extension/dist/content/trace.js +339 -0
- package/extension/dist/content/trace.js.map +1 -0
- package/extension/dist/manifest.json +25 -0
- package/extension/manifest.json +25 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1505 @@
|
|
|
1
|
+
window.buildDomTree = (
|
|
2
|
+
args = {
|
|
3
|
+
showHighlightElements: true,
|
|
4
|
+
focusHighlightIndex: -1,
|
|
5
|
+
viewportExpansion: 0,
|
|
6
|
+
debugMode: false,
|
|
7
|
+
startId: 0,
|
|
8
|
+
startHighlightIndex: 0,
|
|
9
|
+
},
|
|
10
|
+
) => {
|
|
11
|
+
const { showHighlightElements, focusHighlightIndex, viewportExpansion, startHighlightIndex, startId, debugMode } =
|
|
12
|
+
args;
|
|
13
|
+
// Make sure to do highlight elements always, but we can hide the highlights if needed
|
|
14
|
+
const doHighlightElements = true;
|
|
15
|
+
|
|
16
|
+
let highlightIndex = startHighlightIndex; // Reset highlight index
|
|
17
|
+
|
|
18
|
+
// Add caching mechanisms at the top level
|
|
19
|
+
const DOM_CACHE = {
|
|
20
|
+
boundingRects: new WeakMap(),
|
|
21
|
+
clientRects: new WeakMap(),
|
|
22
|
+
computedStyles: new WeakMap(),
|
|
23
|
+
clearCache: () => {
|
|
24
|
+
DOM_CACHE.boundingRects = new WeakMap();
|
|
25
|
+
DOM_CACHE.clientRects = new WeakMap();
|
|
26
|
+
DOM_CACHE.computedStyles = new WeakMap();
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Gets the cached bounding rect for an element.
|
|
32
|
+
*
|
|
33
|
+
* @param {HTMLElement} element - The element to get the bounding rect for.
|
|
34
|
+
* @returns {DOMRect | null} The cached bounding rect, or null if the element is not found.
|
|
35
|
+
*/
|
|
36
|
+
function getCachedBoundingRect(element) {
|
|
37
|
+
if (!element) return null;
|
|
38
|
+
|
|
39
|
+
if (DOM_CACHE.boundingRects.has(element)) {
|
|
40
|
+
return DOM_CACHE.boundingRects.get(element);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rect = element.getBoundingClientRect();
|
|
44
|
+
|
|
45
|
+
if (rect) {
|
|
46
|
+
DOM_CACHE.boundingRects.set(element, rect);
|
|
47
|
+
}
|
|
48
|
+
return rect;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Gets the cached computed style for an element.
|
|
53
|
+
*
|
|
54
|
+
* @param {HTMLElement} element - The element to get the computed style for.
|
|
55
|
+
* @returns {CSSStyleDeclaration | null} The cached computed style, or null if the element is not found.
|
|
56
|
+
*/
|
|
57
|
+
function getCachedComputedStyle(element) {
|
|
58
|
+
if (!element) return null;
|
|
59
|
+
|
|
60
|
+
if (DOM_CACHE.computedStyles.has(element)) {
|
|
61
|
+
return DOM_CACHE.computedStyles.get(element);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const style = window.getComputedStyle(element);
|
|
65
|
+
|
|
66
|
+
if (style) {
|
|
67
|
+
DOM_CACHE.computedStyles.set(element, style);
|
|
68
|
+
}
|
|
69
|
+
return style;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Gets the cached client rects for an element.
|
|
74
|
+
*
|
|
75
|
+
* @param {HTMLElement} element - The element to get the client rects for.
|
|
76
|
+
* @returns {DOMRectList | null} The cached client rects, or null if the element is not found.
|
|
77
|
+
*/
|
|
78
|
+
function getCachedClientRects(element) {
|
|
79
|
+
if (!element) return null;
|
|
80
|
+
|
|
81
|
+
if (DOM_CACHE.clientRects.has(element)) {
|
|
82
|
+
return DOM_CACHE.clientRects.get(element);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rects = element.getClientRects();
|
|
86
|
+
|
|
87
|
+
if (rects) {
|
|
88
|
+
DOM_CACHE.clientRects.set(element, rects);
|
|
89
|
+
}
|
|
90
|
+
return rects;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Hash map of DOM nodes indexed by their highlight index.
|
|
95
|
+
*
|
|
96
|
+
* @type {Object<string, any>}
|
|
97
|
+
*/
|
|
98
|
+
const DOM_HASH_MAP = {};
|
|
99
|
+
|
|
100
|
+
const ID = { current: startId };
|
|
101
|
+
|
|
102
|
+
const HIGHLIGHT_CONTAINER_ID = 'playwright-highlight-container';
|
|
103
|
+
|
|
104
|
+
// Add a WeakMap cache for XPath strings
|
|
105
|
+
const xpathCache = new WeakMap();
|
|
106
|
+
|
|
107
|
+
// // Initialize once and reuse
|
|
108
|
+
// const viewportObserver = new IntersectionObserver(
|
|
109
|
+
// (entries) => {
|
|
110
|
+
// entries.forEach(entry => {
|
|
111
|
+
// elementVisibilityMap.set(entry.target, entry.isIntersecting);
|
|
112
|
+
// });
|
|
113
|
+
// },
|
|
114
|
+
// { rootMargin: `${viewportExpansion}px` }
|
|
115
|
+
// );
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Highlights an element in the DOM and returns the index of the next element.
|
|
119
|
+
*
|
|
120
|
+
* @param {HTMLElement} element - The element to highlight.
|
|
121
|
+
* @param {number} index - The index of the element.
|
|
122
|
+
* @param {HTMLElement | null} parentIframe - The parent iframe node.
|
|
123
|
+
* @returns {number} The index of the next element.
|
|
124
|
+
*/
|
|
125
|
+
function highlightElement(element, index, parentIframe = null) {
|
|
126
|
+
if (!element) return index;
|
|
127
|
+
|
|
128
|
+
const overlays = [];
|
|
129
|
+
/**
|
|
130
|
+
* @type {HTMLElement | null}
|
|
131
|
+
*/
|
|
132
|
+
let label = null;
|
|
133
|
+
let labelWidth = 20;
|
|
134
|
+
let labelHeight = 16;
|
|
135
|
+
let cleanupFn = null;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
// Create or get highlight container
|
|
139
|
+
let container = document.getElementById(HIGHLIGHT_CONTAINER_ID);
|
|
140
|
+
if (!container) {
|
|
141
|
+
container = document.createElement('div');
|
|
142
|
+
container.id = HIGHLIGHT_CONTAINER_ID;
|
|
143
|
+
container.style.position = 'fixed';
|
|
144
|
+
container.style.pointerEvents = 'none';
|
|
145
|
+
container.style.top = '0';
|
|
146
|
+
container.style.left = '0';
|
|
147
|
+
container.style.width = '100%';
|
|
148
|
+
container.style.height = '100%';
|
|
149
|
+
// Use the maximum valid value in zIndex to ensure the element is not blocked by overlapping elements.
|
|
150
|
+
container.style.zIndex = '2147483647';
|
|
151
|
+
container.style.backgroundColor = 'transparent';
|
|
152
|
+
// Show or hide the container based on the showHighlightElements flag
|
|
153
|
+
container.style.display = showHighlightElements ? 'block' : 'none';
|
|
154
|
+
document.body.appendChild(container);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Get element client rects
|
|
158
|
+
const rects = element.getClientRects(); // Use getClientRects()
|
|
159
|
+
|
|
160
|
+
if (!rects || rects.length === 0) return index; // Exit if no rects
|
|
161
|
+
|
|
162
|
+
// Generate a color based on the index
|
|
163
|
+
const colors = [
|
|
164
|
+
'#FF0000',
|
|
165
|
+
'#00FF00',
|
|
166
|
+
'#0000FF',
|
|
167
|
+
'#FFA500',
|
|
168
|
+
'#800080',
|
|
169
|
+
'#008080',
|
|
170
|
+
'#FF69B4',
|
|
171
|
+
'#4B0082',
|
|
172
|
+
'#FF4500',
|
|
173
|
+
'#2E8B57',
|
|
174
|
+
'#DC143C',
|
|
175
|
+
'#4682B4',
|
|
176
|
+
];
|
|
177
|
+
const colorIndex = index % colors.length;
|
|
178
|
+
const baseColor = colors[colorIndex];
|
|
179
|
+
const backgroundColor = baseColor + '1A'; // 10% opacity version of the color
|
|
180
|
+
|
|
181
|
+
// Get iframe offset if necessary
|
|
182
|
+
let iframeOffset = { x: 0, y: 0 };
|
|
183
|
+
if (parentIframe) {
|
|
184
|
+
const iframeRect = parentIframe.getBoundingClientRect(); // Keep getBoundingClientRect for iframe offset
|
|
185
|
+
iframeOffset.x = iframeRect.left;
|
|
186
|
+
iframeOffset.y = iframeRect.top;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Create fragment to hold overlay elements
|
|
190
|
+
const fragment = document.createDocumentFragment();
|
|
191
|
+
|
|
192
|
+
// Create highlight overlays for each client rect
|
|
193
|
+
for (const rect of rects) {
|
|
194
|
+
if (rect.width === 0 || rect.height === 0) continue; // Skip empty rects
|
|
195
|
+
|
|
196
|
+
const overlay = document.createElement('div');
|
|
197
|
+
overlay.style.position = 'fixed';
|
|
198
|
+
overlay.style.border = `2px solid ${baseColor}`;
|
|
199
|
+
overlay.style.backgroundColor = backgroundColor;
|
|
200
|
+
overlay.style.pointerEvents = 'none';
|
|
201
|
+
overlay.style.boxSizing = 'border-box';
|
|
202
|
+
|
|
203
|
+
const top = rect.top + iframeOffset.y;
|
|
204
|
+
const left = rect.left + iframeOffset.x;
|
|
205
|
+
|
|
206
|
+
overlay.style.top = `${top}px`;
|
|
207
|
+
overlay.style.left = `${left}px`;
|
|
208
|
+
overlay.style.width = `${rect.width}px`;
|
|
209
|
+
overlay.style.height = `${rect.height}px`;
|
|
210
|
+
|
|
211
|
+
fragment.appendChild(overlay);
|
|
212
|
+
overlays.push({ element: overlay, initialRect: rect }); // Store overlay and its rect
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Create and position a single label relative to the first rect
|
|
216
|
+
const firstRect = rects[0];
|
|
217
|
+
label = document.createElement('div');
|
|
218
|
+
label.className = 'playwright-highlight-label';
|
|
219
|
+
label.style.position = 'fixed';
|
|
220
|
+
label.style.background = baseColor;
|
|
221
|
+
label.style.color = 'white';
|
|
222
|
+
label.style.padding = '1px 4px';
|
|
223
|
+
label.style.borderRadius = '4px';
|
|
224
|
+
label.style.fontSize = `${Math.min(12, Math.max(8, firstRect.height / 2))}px`;
|
|
225
|
+
label.textContent = index.toString();
|
|
226
|
+
|
|
227
|
+
labelWidth = label.offsetWidth > 0 ? label.offsetWidth : labelWidth; // Update actual width if possible
|
|
228
|
+
labelHeight = label.offsetHeight > 0 ? label.offsetHeight : labelHeight; // Update actual height if possible
|
|
229
|
+
|
|
230
|
+
const firstRectTop = firstRect.top + iframeOffset.y;
|
|
231
|
+
const firstRectLeft = firstRect.left + iframeOffset.x;
|
|
232
|
+
|
|
233
|
+
let labelTop = firstRectTop + 2;
|
|
234
|
+
let labelLeft = firstRectLeft + firstRect.width - labelWidth - 2;
|
|
235
|
+
|
|
236
|
+
// Adjust label position if first rect is too small
|
|
237
|
+
if (firstRect.width < labelWidth + 4 || firstRect.height < labelHeight + 4) {
|
|
238
|
+
labelTop = firstRectTop - labelHeight - 2;
|
|
239
|
+
labelLeft = firstRectLeft + firstRect.width - labelWidth; // Align with right edge
|
|
240
|
+
if (labelLeft < iframeOffset.x) labelLeft = firstRectLeft; // Prevent going off-left
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Ensure label stays within viewport bounds slightly better
|
|
244
|
+
labelTop = Math.max(0, Math.min(labelTop, window.innerHeight - labelHeight));
|
|
245
|
+
labelLeft = Math.max(0, Math.min(labelLeft, window.innerWidth - labelWidth));
|
|
246
|
+
|
|
247
|
+
label.style.top = `${labelTop}px`;
|
|
248
|
+
label.style.left = `${labelLeft}px`;
|
|
249
|
+
|
|
250
|
+
fragment.appendChild(label);
|
|
251
|
+
|
|
252
|
+
// Update positions on scroll/resize
|
|
253
|
+
const updatePositions = () => {
|
|
254
|
+
const newRects = element.getClientRects(); // Get fresh rects
|
|
255
|
+
let newIframeOffset = { x: 0, y: 0 };
|
|
256
|
+
|
|
257
|
+
if (parentIframe) {
|
|
258
|
+
const iframeRect = parentIframe.getBoundingClientRect(); // Keep getBoundingClientRect for iframe
|
|
259
|
+
newIframeOffset.x = iframeRect.left;
|
|
260
|
+
newIframeOffset.y = iframeRect.top;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Update each overlay
|
|
264
|
+
overlays.forEach((overlayData, i) => {
|
|
265
|
+
if (i < newRects.length) {
|
|
266
|
+
// Check if rect still exists
|
|
267
|
+
const newRect = newRects[i];
|
|
268
|
+
const newTop = newRect.top + newIframeOffset.y;
|
|
269
|
+
const newLeft = newRect.left + newIframeOffset.x;
|
|
270
|
+
|
|
271
|
+
overlayData.element.style.top = `${newTop}px`;
|
|
272
|
+
overlayData.element.style.left = `${newLeft}px`;
|
|
273
|
+
overlayData.element.style.width = `${newRect.width}px`;
|
|
274
|
+
overlayData.element.style.height = `${newRect.height}px`;
|
|
275
|
+
overlayData.element.style.display = newRect.width === 0 || newRect.height === 0 ? 'none' : 'block';
|
|
276
|
+
} else {
|
|
277
|
+
// If fewer rects now, hide extra overlays
|
|
278
|
+
overlayData.element.style.display = 'none';
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// If there are fewer new rects than overlays, hide the extras
|
|
283
|
+
if (newRects.length < overlays.length) {
|
|
284
|
+
for (let i = newRects.length; i < overlays.length; i++) {
|
|
285
|
+
overlays[i].element.style.display = 'none';
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Update label position based on the first new rect
|
|
290
|
+
if (label && newRects.length > 0) {
|
|
291
|
+
const firstNewRect = newRects[0];
|
|
292
|
+
const firstNewRectTop = firstNewRect.top + newIframeOffset.y;
|
|
293
|
+
const firstNewRectLeft = firstNewRect.left + newIframeOffset.x;
|
|
294
|
+
|
|
295
|
+
let newLabelTop = firstNewRectTop + 2;
|
|
296
|
+
let newLabelLeft = firstNewRectLeft + firstNewRect.width - labelWidth - 2;
|
|
297
|
+
|
|
298
|
+
if (firstNewRect.width < labelWidth + 4 || firstNewRect.height < labelHeight + 4) {
|
|
299
|
+
newLabelTop = firstNewRectTop - labelHeight - 2;
|
|
300
|
+
newLabelLeft = firstNewRectLeft + firstNewRect.width - labelWidth;
|
|
301
|
+
if (newLabelLeft < newIframeOffset.x) newLabelLeft = firstNewRectLeft;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Ensure label stays within viewport bounds
|
|
305
|
+
newLabelTop = Math.max(0, Math.min(newLabelTop, window.innerHeight - labelHeight));
|
|
306
|
+
newLabelLeft = Math.max(0, Math.min(newLabelLeft, window.innerWidth - labelWidth));
|
|
307
|
+
|
|
308
|
+
label.style.top = `${newLabelTop}px`;
|
|
309
|
+
label.style.left = `${newLabelLeft}px`;
|
|
310
|
+
label.style.display = 'block';
|
|
311
|
+
} else if (label) {
|
|
312
|
+
// Hide label if element has no rects anymore
|
|
313
|
+
label.style.display = 'none';
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const throttleFunction = (func, delay) => {
|
|
318
|
+
let lastCall = 0;
|
|
319
|
+
return (...args) => {
|
|
320
|
+
const now = performance.now();
|
|
321
|
+
if (now - lastCall < delay) return;
|
|
322
|
+
lastCall = now;
|
|
323
|
+
return func(...args);
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const throttledUpdatePositions = throttleFunction(updatePositions, 16); // ~60fps
|
|
328
|
+
window.addEventListener('scroll', throttledUpdatePositions, true);
|
|
329
|
+
window.addEventListener('resize', throttledUpdatePositions);
|
|
330
|
+
|
|
331
|
+
// Add cleanup function
|
|
332
|
+
cleanupFn = () => {
|
|
333
|
+
window.removeEventListener('scroll', throttledUpdatePositions, true);
|
|
334
|
+
window.removeEventListener('resize', throttledUpdatePositions);
|
|
335
|
+
// Remove overlay elements if needed
|
|
336
|
+
overlays.forEach(overlay => overlay.element.remove());
|
|
337
|
+
if (label) label.remove();
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Then add fragment to container in one operation
|
|
341
|
+
container.appendChild(fragment);
|
|
342
|
+
|
|
343
|
+
return index + 1;
|
|
344
|
+
} finally {
|
|
345
|
+
// Store cleanup function for later use
|
|
346
|
+
if (cleanupFn) {
|
|
347
|
+
// Keep a reference to cleanup functions in a global array
|
|
348
|
+
(window._highlightCleanupFunctions = window._highlightCleanupFunctions || []).push(cleanupFn);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// // Add this function to perform cleanup when needed
|
|
354
|
+
// function cleanupHighlights() {
|
|
355
|
+
// if (window._highlightCleanupFunctions && window._highlightCleanupFunctions.length) {
|
|
356
|
+
// window._highlightCleanupFunctions.forEach(fn => fn());
|
|
357
|
+
// window._highlightCleanupFunctions = [];
|
|
358
|
+
// }
|
|
359
|
+
|
|
360
|
+
// // Also remove the container
|
|
361
|
+
// const container = document.getElementById(HIGHLIGHT_CONTAINER_ID);
|
|
362
|
+
// if (container) container.remove();
|
|
363
|
+
// }
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Gets the position of an element in its parent.
|
|
367
|
+
*
|
|
368
|
+
* @param {HTMLElement} currentElement - The element to get the position for.
|
|
369
|
+
* @returns {number} The position of the element in its parent.
|
|
370
|
+
*/
|
|
371
|
+
function getElementPosition(currentElement) {
|
|
372
|
+
if (!currentElement.parentElement) {
|
|
373
|
+
return 0; // No parent means no siblings
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const tagName = currentElement.nodeName.toLowerCase();
|
|
377
|
+
|
|
378
|
+
const siblings = Array.from(currentElement.parentElement.children).filter(
|
|
379
|
+
sib => sib.nodeName.toLowerCase() === tagName,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
if (siblings.length === 1) {
|
|
383
|
+
return 0; // Only element of its type
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const index = siblings.indexOf(currentElement) + 1; // 1-based index
|
|
387
|
+
return index;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function getXPathTree(element, stopAtBoundary = true) {
|
|
391
|
+
if (xpathCache.has(element)) return xpathCache.get(element);
|
|
392
|
+
|
|
393
|
+
const segments = [];
|
|
394
|
+
let currentElement = element;
|
|
395
|
+
|
|
396
|
+
while (currentElement && currentElement.nodeType === Node.ELEMENT_NODE) {
|
|
397
|
+
// Stop if we hit a shadow root or iframe
|
|
398
|
+
if (
|
|
399
|
+
stopAtBoundary &&
|
|
400
|
+
(currentElement.parentNode instanceof ShadowRoot || currentElement.parentNode instanceof HTMLIFrameElement)
|
|
401
|
+
) {
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const position = getElementPosition(currentElement);
|
|
406
|
+
const tagName = currentElement.nodeName.toLowerCase();
|
|
407
|
+
const xpathIndex = position > 0 ? `[${position}]` : '';
|
|
408
|
+
segments.unshift(`${tagName}${xpathIndex}`);
|
|
409
|
+
|
|
410
|
+
currentElement = currentElement.parentNode;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const result = segments.join('/');
|
|
414
|
+
xpathCache.set(element, result);
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Checks if a text node is visible.
|
|
420
|
+
*
|
|
421
|
+
* @param {Text} textNode - The text node to check.
|
|
422
|
+
* @returns {boolean} Whether the text node is visible.
|
|
423
|
+
*/
|
|
424
|
+
function isTextNodeVisible(textNode) {
|
|
425
|
+
try {
|
|
426
|
+
// Special case: when viewportExpansion is -1, consider all text nodes as visible
|
|
427
|
+
if (viewportExpansion === -1) {
|
|
428
|
+
// Still check parent visibility for basic filtering
|
|
429
|
+
const parentElement = textNode.parentElement;
|
|
430
|
+
if (!parentElement) return false;
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
return parentElement.checkVisibility({
|
|
434
|
+
checkOpacity: true,
|
|
435
|
+
checkVisibilityCSS: true,
|
|
436
|
+
});
|
|
437
|
+
} catch (e) {
|
|
438
|
+
// Fallback if checkVisibility is not supported
|
|
439
|
+
const style = window.getComputedStyle(parentElement);
|
|
440
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const range = document.createRange();
|
|
445
|
+
range.selectNodeContents(textNode);
|
|
446
|
+
const rects = range.getClientRects(); // Use getClientRects for Range
|
|
447
|
+
|
|
448
|
+
if (!rects || rects.length === 0) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let isAnyRectVisible = false;
|
|
453
|
+
let isAnyRectInViewport = false;
|
|
454
|
+
|
|
455
|
+
for (const rect of rects) {
|
|
456
|
+
// Check size
|
|
457
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
458
|
+
isAnyRectVisible = true;
|
|
459
|
+
|
|
460
|
+
// Viewport check for this rect
|
|
461
|
+
if (
|
|
462
|
+
!(
|
|
463
|
+
rect.bottom < -viewportExpansion ||
|
|
464
|
+
rect.top > window.innerHeight + viewportExpansion ||
|
|
465
|
+
rect.right < -viewportExpansion ||
|
|
466
|
+
rect.left > window.innerWidth + viewportExpansion
|
|
467
|
+
)
|
|
468
|
+
) {
|
|
469
|
+
isAnyRectInViewport = true;
|
|
470
|
+
break; // Found a visible rect in viewport, no need to check others
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!isAnyRectVisible || !isAnyRectInViewport) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Check parent visibility
|
|
480
|
+
const parentElement = textNode.parentElement;
|
|
481
|
+
if (!parentElement) return false;
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
return parentElement.checkVisibility({
|
|
485
|
+
checkOpacity: true,
|
|
486
|
+
checkVisibilityCSS: true,
|
|
487
|
+
});
|
|
488
|
+
} catch (e) {
|
|
489
|
+
// Fallback if checkVisibility is not supported
|
|
490
|
+
const style = window.getComputedStyle(parentElement);
|
|
491
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
492
|
+
}
|
|
493
|
+
} catch (e) {
|
|
494
|
+
console.warn('Error checking text node visibility:', e);
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Checks if an element is accepted.
|
|
501
|
+
*
|
|
502
|
+
* @param {HTMLElement} element - The element to check.
|
|
503
|
+
* @returns {boolean} Whether the element is accepted.
|
|
504
|
+
*/
|
|
505
|
+
function isElementAccepted(element) {
|
|
506
|
+
if (!element || !element.tagName) return false;
|
|
507
|
+
|
|
508
|
+
// Always accept body and common container elements
|
|
509
|
+
const alwaysAccept = new Set(['body', 'div', 'main', 'article', 'section', 'nav', 'header', 'footer']);
|
|
510
|
+
const tagName = element.tagName.toLowerCase();
|
|
511
|
+
|
|
512
|
+
if (alwaysAccept.has(tagName)) return true;
|
|
513
|
+
|
|
514
|
+
const leafElementDenyList = new Set(['svg', 'script', 'style', 'link', 'meta', 'noscript', 'template']);
|
|
515
|
+
|
|
516
|
+
return !leafElementDenyList.has(tagName);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Checks if an element is visible.
|
|
521
|
+
*
|
|
522
|
+
* @param {HTMLElement} element - The element to check.
|
|
523
|
+
* @returns {boolean} Whether the element is visible.
|
|
524
|
+
*/
|
|
525
|
+
function isElementVisible(element) {
|
|
526
|
+
const style = getCachedComputedStyle(element);
|
|
527
|
+
return (
|
|
528
|
+
element.offsetWidth > 0 && element.offsetHeight > 0 && style?.visibility !== 'hidden' && style?.display !== 'none'
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Checks if an element is interactive.
|
|
534
|
+
*
|
|
535
|
+
* lots of comments, and uncommented code - to show the logic of what we already tried
|
|
536
|
+
*
|
|
537
|
+
* One of the things we tried at the beginning was also to use event listeners, and other fancy class, style stuff -> what actually worked best was just combining most things with computed cursor style :)
|
|
538
|
+
*
|
|
539
|
+
* @param {HTMLElement} element - The element to check.
|
|
540
|
+
*/
|
|
541
|
+
function isInteractiveElement(element) {
|
|
542
|
+
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Cache the tagName and style lookups
|
|
547
|
+
const tagName = element.tagName.toLowerCase();
|
|
548
|
+
const style = getCachedComputedStyle(element);
|
|
549
|
+
|
|
550
|
+
// Define interactive cursors
|
|
551
|
+
const interactiveCursors = new Set([
|
|
552
|
+
'pointer', // Link/clickable elements
|
|
553
|
+
'move', // Movable elements
|
|
554
|
+
'text', // Text selection
|
|
555
|
+
'grab', // Grabbable elements
|
|
556
|
+
'grabbing', // Currently grabbing
|
|
557
|
+
'cell', // Table cell selection
|
|
558
|
+
'copy', // Copy operation
|
|
559
|
+
'alias', // Alias creation
|
|
560
|
+
'all-scroll', // Scrollable content
|
|
561
|
+
'col-resize', // Column resize
|
|
562
|
+
'context-menu', // Context menu available
|
|
563
|
+
'crosshair', // Precise selection
|
|
564
|
+
'e-resize', // East resize
|
|
565
|
+
'ew-resize', // East-west resize
|
|
566
|
+
'help', // Help available
|
|
567
|
+
'n-resize', // North resize
|
|
568
|
+
'ne-resize', // Northeast resize
|
|
569
|
+
'nesw-resize', // Northeast-southwest resize
|
|
570
|
+
'ns-resize', // North-south resize
|
|
571
|
+
'nw-resize', // Northwest resize
|
|
572
|
+
'nwse-resize', // Northwest-southeast resize
|
|
573
|
+
'row-resize', // Row resize
|
|
574
|
+
's-resize', // South resize
|
|
575
|
+
'se-resize', // Southeast resize
|
|
576
|
+
'sw-resize', // Southwest resize
|
|
577
|
+
'vertical-text', // Vertical text selection
|
|
578
|
+
'w-resize', // West resize
|
|
579
|
+
'zoom-in', // Zoom in
|
|
580
|
+
'zoom-out', // Zoom out
|
|
581
|
+
]);
|
|
582
|
+
|
|
583
|
+
// Define non-interactive cursors
|
|
584
|
+
const nonInteractiveCursors = new Set([
|
|
585
|
+
'not-allowed', // Action not allowed
|
|
586
|
+
'no-drop', // Drop not allowed
|
|
587
|
+
'wait', // Processing
|
|
588
|
+
'progress', // In progress
|
|
589
|
+
'initial', // Initial value
|
|
590
|
+
'inherit', // Inherited value
|
|
591
|
+
//? Let's just include all potentially clickable elements that are not specifically blocked
|
|
592
|
+
// 'none', // No cursor
|
|
593
|
+
// 'default', // Default cursor
|
|
594
|
+
// 'auto', // Browser default
|
|
595
|
+
]);
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Checks if an element has an interactive pointer.
|
|
599
|
+
*
|
|
600
|
+
* @param {HTMLElement} element - The element to check.
|
|
601
|
+
* @returns {boolean} Whether the element has an interactive pointer.
|
|
602
|
+
*/
|
|
603
|
+
function doesElementHaveInteractivePointer(element) {
|
|
604
|
+
if (element.tagName.toLowerCase() === 'html') return false;
|
|
605
|
+
|
|
606
|
+
if (style?.cursor && interactiveCursors.has(style.cursor)) return true;
|
|
607
|
+
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
let isInteractiveCursor = doesElementHaveInteractivePointer(element);
|
|
612
|
+
|
|
613
|
+
// Genius fix for almost all interactive elements
|
|
614
|
+
if (isInteractiveCursor) {
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const interactiveElements = new Set([
|
|
619
|
+
'a', // Links
|
|
620
|
+
'button', // Buttons
|
|
621
|
+
'input', // All input types (text, checkbox, radio, etc.)
|
|
622
|
+
'select', // Dropdown menus
|
|
623
|
+
'textarea', // Text areas
|
|
624
|
+
'details', // Expandable details
|
|
625
|
+
'summary', // Summary element (clickable part of details)
|
|
626
|
+
'label', // Form labels (often clickable)
|
|
627
|
+
'option', // Select options
|
|
628
|
+
'optgroup', // Option groups
|
|
629
|
+
'fieldset', // Form fieldsets (can be interactive with legend)
|
|
630
|
+
'legend', // Fieldset legends
|
|
631
|
+
]);
|
|
632
|
+
|
|
633
|
+
// Define explicit disable attributes and properties
|
|
634
|
+
const explicitDisableTags = new Set([
|
|
635
|
+
'disabled', // Standard disabled attribute
|
|
636
|
+
// 'aria-disabled', // ARIA disabled state
|
|
637
|
+
'readonly', // Read-only state
|
|
638
|
+
// 'aria-readonly', // ARIA read-only state
|
|
639
|
+
// 'aria-hidden', // Hidden from accessibility
|
|
640
|
+
// 'hidden', // Hidden attribute
|
|
641
|
+
// 'inert', // Inert attribute
|
|
642
|
+
// 'aria-inert', // ARIA inert state
|
|
643
|
+
// 'tabindex="-1"', // Removed from tab order
|
|
644
|
+
// 'aria-hidden="true"' // Hidden from screen readers
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
// handle inputs, select, checkbox, radio, textarea, button and make sure they are not cursor style disabled/not-allowed
|
|
648
|
+
if (interactiveElements.has(tagName)) {
|
|
649
|
+
// Check for non-interactive cursor
|
|
650
|
+
if (style?.cursor && nonInteractiveCursors.has(style.cursor)) {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Check for explicit disable attributes
|
|
655
|
+
for (const disableTag of explicitDisableTags) {
|
|
656
|
+
if (
|
|
657
|
+
element.hasAttribute(disableTag) ||
|
|
658
|
+
element.getAttribute(disableTag) === 'true' ||
|
|
659
|
+
element.getAttribute(disableTag) === ''
|
|
660
|
+
) {
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Check for disabled property on form elements
|
|
666
|
+
if (element.disabled) {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Check for readonly property on form elements
|
|
671
|
+
if (element.readOnly) {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Check for inert property
|
|
676
|
+
if (element.inert) {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const role = element.getAttribute('role');
|
|
684
|
+
const ariaRole = element.getAttribute('aria-role');
|
|
685
|
+
|
|
686
|
+
// Check for contenteditable attribute
|
|
687
|
+
if (element.getAttribute('contenteditable') === 'true' || element.isContentEditable) {
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Added enhancement to capture dropdown interactive elements
|
|
692
|
+
if (
|
|
693
|
+
element.classList &&
|
|
694
|
+
(element.classList.contains('button') ||
|
|
695
|
+
element.classList.contains('dropdown-toggle') ||
|
|
696
|
+
element.getAttribute('data-index') ||
|
|
697
|
+
element.getAttribute('data-toggle') === 'dropdown' ||
|
|
698
|
+
element.getAttribute('aria-haspopup') === 'true')
|
|
699
|
+
) {
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const interactiveRoles = new Set([
|
|
704
|
+
'button', // Directly clickable element
|
|
705
|
+
// 'link', // Clickable link
|
|
706
|
+
'menu', // Menu container (ARIA menus)
|
|
707
|
+
'menubar', // Menu bar container
|
|
708
|
+
'menuitem', // Clickable menu item
|
|
709
|
+
'menuitemradio', // Radio-style menu item (selectable)
|
|
710
|
+
'menuitemcheckbox', // Checkbox-style menu item (toggleable)
|
|
711
|
+
'radio', // Radio button (selectable)
|
|
712
|
+
'checkbox', // Checkbox (toggleable)
|
|
713
|
+
'tab', // Tab (clickable to switch content)
|
|
714
|
+
'switch', // Toggle switch (clickable to change state)
|
|
715
|
+
'slider', // Slider control (draggable)
|
|
716
|
+
'spinbutton', // Number input with up/down controls
|
|
717
|
+
'combobox', // Dropdown with text input
|
|
718
|
+
'searchbox', // Search input field
|
|
719
|
+
'textbox', // Text input field
|
|
720
|
+
'listbox', // Selectable list
|
|
721
|
+
'option', // Selectable option in a list
|
|
722
|
+
'scrollbar', // Scrollable control
|
|
723
|
+
]);
|
|
724
|
+
|
|
725
|
+
// Basic role/attribute checks
|
|
726
|
+
const hasInteractiveRole =
|
|
727
|
+
interactiveElements.has(tagName) ||
|
|
728
|
+
(role && interactiveRoles.has(role)) ||
|
|
729
|
+
(ariaRole && interactiveRoles.has(ariaRole));
|
|
730
|
+
|
|
731
|
+
if (hasInteractiveRole) return true;
|
|
732
|
+
|
|
733
|
+
// check whether element has event listeners by window.getEventListeners
|
|
734
|
+
try {
|
|
735
|
+
if (typeof getEventListeners === 'function') {
|
|
736
|
+
const listeners = getEventListeners(element);
|
|
737
|
+
const mouseEvents = ['click', 'mousedown', 'mouseup', 'dblclick'];
|
|
738
|
+
for (const eventType of mouseEvents) {
|
|
739
|
+
if (listeners[eventType] && listeners[eventType].length > 0) {
|
|
740
|
+
return true; // Found a mouse interaction listener
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const getEventListenersForNode =
|
|
746
|
+
element?.ownerDocument?.defaultView?.getEventListenersForNode || window.getEventListenersForNode;
|
|
747
|
+
if (typeof getEventListenersForNode === 'function') {
|
|
748
|
+
const listeners = getEventListenersForNode(element);
|
|
749
|
+
const interactionEvents = [
|
|
750
|
+
'click',
|
|
751
|
+
'mousedown',
|
|
752
|
+
'mouseup',
|
|
753
|
+
'keydown',
|
|
754
|
+
'keyup',
|
|
755
|
+
'submit',
|
|
756
|
+
'change',
|
|
757
|
+
'input',
|
|
758
|
+
'focus',
|
|
759
|
+
'blur',
|
|
760
|
+
];
|
|
761
|
+
for (const eventType of interactionEvents) {
|
|
762
|
+
for (const listener of listeners) {
|
|
763
|
+
if (listener.type === eventType) {
|
|
764
|
+
return true; // Found a common interaction listener
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
// Fallback: Check common event attributes if getEventListeners is not available (getEventListeners doesn't work in page.evaluate context)
|
|
770
|
+
const commonMouseAttrs = ['onclick', 'onmousedown', 'onmouseup', 'ondblclick'];
|
|
771
|
+
for (const attr of commonMouseAttrs) {
|
|
772
|
+
if (element.hasAttribute(attr) || typeof element[attr] === 'function') {
|
|
773
|
+
return true;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
} catch (e) {
|
|
777
|
+
// console.warn(`Could not check event listeners for ${element.tagName}:`, e);
|
|
778
|
+
// If checking listeners fails, rely on other checks
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Checks if an element is the topmost element at its position.
|
|
786
|
+
*
|
|
787
|
+
* @param {HTMLElement} element - The element to check.
|
|
788
|
+
* @returns {boolean} Whether the element is the topmost element at its position.
|
|
789
|
+
*/
|
|
790
|
+
function isTopElement(element) {
|
|
791
|
+
// Special case: when viewportExpansion is -1, consider all elements as "top" elements
|
|
792
|
+
if (viewportExpansion === -1) {
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const rects = getCachedClientRects(element); // Replace element.getClientRects()
|
|
797
|
+
|
|
798
|
+
if (!rects || rects.length === 0) {
|
|
799
|
+
return false; // No geometry, cannot be top
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
let isAnyRectInViewport = false;
|
|
803
|
+
for (const rect of rects) {
|
|
804
|
+
// Use the same logic as isInExpandedViewport check
|
|
805
|
+
if (
|
|
806
|
+
rect.width > 0 &&
|
|
807
|
+
rect.height > 0 &&
|
|
808
|
+
!(
|
|
809
|
+
// Only check non-empty rects
|
|
810
|
+
(
|
|
811
|
+
rect.bottom < -viewportExpansion ||
|
|
812
|
+
rect.top > window.innerHeight + viewportExpansion ||
|
|
813
|
+
rect.right < -viewportExpansion ||
|
|
814
|
+
rect.left > window.innerWidth + viewportExpansion
|
|
815
|
+
)
|
|
816
|
+
)
|
|
817
|
+
) {
|
|
818
|
+
isAnyRectInViewport = true;
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (!isAnyRectInViewport) {
|
|
824
|
+
return false; // All rects are outside the viewport area
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Find the correct document context and root element
|
|
828
|
+
let doc = element.ownerDocument;
|
|
829
|
+
|
|
830
|
+
// If we're in an iframe, elements are considered top by default
|
|
831
|
+
if (doc !== window.document) {
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// For shadow DOM, we need to check within its own root context
|
|
836
|
+
const shadowRoot = element.getRootNode();
|
|
837
|
+
if (shadowRoot instanceof ShadowRoot) {
|
|
838
|
+
const centerX = rects[Math.floor(rects.length / 2)].left + rects[Math.floor(rects.length / 2)].width / 2;
|
|
839
|
+
const centerY = rects[Math.floor(rects.length / 2)].top + rects[Math.floor(rects.length / 2)].height / 2;
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
const topEl = shadowRoot.elementFromPoint(centerX, centerY);
|
|
843
|
+
if (!topEl) return false;
|
|
844
|
+
|
|
845
|
+
let current = topEl;
|
|
846
|
+
while (current && current !== shadowRoot) {
|
|
847
|
+
if (current === element) return true;
|
|
848
|
+
current = current.parentElement;
|
|
849
|
+
}
|
|
850
|
+
return false;
|
|
851
|
+
} catch (e) {
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const margin = 5;
|
|
857
|
+
const rect = rects[Math.floor(rects.length / 2)];
|
|
858
|
+
|
|
859
|
+
// For elements in viewport, check if they're topmost. Do the check in the
|
|
860
|
+
// center of the element and at the corners to ensure we catch more cases.
|
|
861
|
+
const checkPoints = [
|
|
862
|
+
// Initially only this was used, but it was not enough
|
|
863
|
+
{ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
|
|
864
|
+
{ x: rect.left + margin, y: rect.top + margin }, // top left
|
|
865
|
+
// { x: rect.right - margin, y: rect.top + margin }, // top right
|
|
866
|
+
// { x: rect.left + margin, y: rect.bottom - margin }, // bottom left
|
|
867
|
+
{ x: rect.right - margin, y: rect.bottom - margin }, // bottom right
|
|
868
|
+
];
|
|
869
|
+
|
|
870
|
+
return checkPoints.some(({ x, y }) => {
|
|
871
|
+
try {
|
|
872
|
+
const topEl = document.elementFromPoint(x, y);
|
|
873
|
+
if (!topEl) return false;
|
|
874
|
+
|
|
875
|
+
let current = topEl;
|
|
876
|
+
while (current && current !== document.documentElement) {
|
|
877
|
+
if (current === element) return true;
|
|
878
|
+
current = current.parentElement;
|
|
879
|
+
}
|
|
880
|
+
return false;
|
|
881
|
+
} catch (e) {
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Checks if an element is within the expanded viewport.
|
|
889
|
+
*
|
|
890
|
+
* @param {HTMLElement} element - The element to check.
|
|
891
|
+
* @param {number} viewportExpansion - The viewport expansion.
|
|
892
|
+
* @returns {boolean} Whether the element is within the expanded viewport.
|
|
893
|
+
*/
|
|
894
|
+
function isInExpandedViewport(element, viewportExpansion) {
|
|
895
|
+
if (viewportExpansion === -1) {
|
|
896
|
+
return true;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const rects = element.getClientRects(); // Use getClientRects
|
|
900
|
+
|
|
901
|
+
if (!rects || rects.length === 0) {
|
|
902
|
+
// Fallback to getBoundingClientRect if getClientRects is empty,
|
|
903
|
+
// useful for elements like <svg> that might not have client rects but have a bounding box.
|
|
904
|
+
const boundingRect = getCachedBoundingRect(element);
|
|
905
|
+
if (!boundingRect || boundingRect.width === 0 || boundingRect.height === 0) {
|
|
906
|
+
return false;
|
|
907
|
+
}
|
|
908
|
+
return !(
|
|
909
|
+
boundingRect.bottom < -viewportExpansion ||
|
|
910
|
+
boundingRect.top > window.innerHeight + viewportExpansion ||
|
|
911
|
+
boundingRect.right < -viewportExpansion ||
|
|
912
|
+
boundingRect.left > window.innerWidth + viewportExpansion
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Check if *any* client rect is within the viewport
|
|
917
|
+
for (const rect of rects) {
|
|
918
|
+
if (rect.width === 0 || rect.height === 0) continue; // Skip empty rects
|
|
919
|
+
|
|
920
|
+
if (
|
|
921
|
+
!(
|
|
922
|
+
rect.bottom < -viewportExpansion ||
|
|
923
|
+
rect.top > window.innerHeight + viewportExpansion ||
|
|
924
|
+
rect.right < -viewportExpansion ||
|
|
925
|
+
rect.left > window.innerWidth + viewportExpansion
|
|
926
|
+
)
|
|
927
|
+
) {
|
|
928
|
+
return true; // Found at least one rect in the viewport
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return false; // No rects were found in the viewport
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// /**
|
|
936
|
+
// * Gets the effective scroll of an element.
|
|
937
|
+
// *
|
|
938
|
+
// * @param {HTMLElement} element - The element to get the effective scroll for.
|
|
939
|
+
// * @returns {Object} The effective scroll of the element.
|
|
940
|
+
// */
|
|
941
|
+
// function getEffectiveScroll(element) {
|
|
942
|
+
// let currentEl = element;
|
|
943
|
+
// let scrollX = 0;
|
|
944
|
+
// let scrollY = 0;
|
|
945
|
+
|
|
946
|
+
// while (currentEl && currentEl !== document.documentElement) {
|
|
947
|
+
// if (currentEl.scrollLeft || currentEl.scrollTop) {
|
|
948
|
+
// scrollX += currentEl.scrollLeft;
|
|
949
|
+
// scrollY += currentEl.scrollTop;
|
|
950
|
+
// }
|
|
951
|
+
// currentEl = currentEl.parentElement;
|
|
952
|
+
// }
|
|
953
|
+
|
|
954
|
+
// scrollX += window.scrollX;
|
|
955
|
+
// scrollY += window.scrollY;
|
|
956
|
+
|
|
957
|
+
// return { scrollX, scrollY };
|
|
958
|
+
// }
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Checks if an element is an interactive candidate.
|
|
962
|
+
*
|
|
963
|
+
* @param {HTMLElement} element - The element to check.
|
|
964
|
+
* @returns {boolean} Whether the element is an interactive candidate.
|
|
965
|
+
*/
|
|
966
|
+
function isInteractiveCandidate(element) {
|
|
967
|
+
if (!element || element.nodeType !== Node.ELEMENT_NODE) return false;
|
|
968
|
+
|
|
969
|
+
const tagName = element.tagName.toLowerCase();
|
|
970
|
+
|
|
971
|
+
// Fast-path for common interactive elements
|
|
972
|
+
const interactiveElements = new Set(['a', 'button', 'input', 'select', 'textarea', 'details', 'summary', 'label']);
|
|
973
|
+
|
|
974
|
+
if (interactiveElements.has(tagName)) return true;
|
|
975
|
+
|
|
976
|
+
// Quick attribute checks without getting full lists
|
|
977
|
+
const hasQuickInteractiveAttr =
|
|
978
|
+
element.hasAttribute('onclick') ||
|
|
979
|
+
element.hasAttribute('role') ||
|
|
980
|
+
element.hasAttribute('tabindex') ||
|
|
981
|
+
element.hasAttribute('aria-') ||
|
|
982
|
+
element.hasAttribute('data-action') ||
|
|
983
|
+
element.getAttribute('contenteditable') === 'true';
|
|
984
|
+
|
|
985
|
+
return hasQuickInteractiveAttr;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// --- Define constants for distinct interaction check ---
|
|
989
|
+
const DISTINCT_INTERACTIVE_TAGS = new Set([
|
|
990
|
+
'a',
|
|
991
|
+
'button',
|
|
992
|
+
'input',
|
|
993
|
+
'select',
|
|
994
|
+
'textarea',
|
|
995
|
+
'summary',
|
|
996
|
+
'details',
|
|
997
|
+
'label',
|
|
998
|
+
'option',
|
|
999
|
+
]);
|
|
1000
|
+
const INTERACTIVE_ROLES = new Set([
|
|
1001
|
+
'button',
|
|
1002
|
+
'link',
|
|
1003
|
+
'menuitem',
|
|
1004
|
+
'menuitemradio',
|
|
1005
|
+
'menuitemcheckbox',
|
|
1006
|
+
'radio',
|
|
1007
|
+
'checkbox',
|
|
1008
|
+
'tab',
|
|
1009
|
+
'switch',
|
|
1010
|
+
'slider',
|
|
1011
|
+
'spinbutton',
|
|
1012
|
+
'combobox',
|
|
1013
|
+
'searchbox',
|
|
1014
|
+
'textbox',
|
|
1015
|
+
'listbox',
|
|
1016
|
+
'option',
|
|
1017
|
+
'scrollbar',
|
|
1018
|
+
]);
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Heuristically determines if an element should be considered as independently interactive,
|
|
1022
|
+
* even if it's nested inside another interactive container.
|
|
1023
|
+
*
|
|
1024
|
+
* This function helps detect deeply nested actionable elements (e.g., menu items within a button)
|
|
1025
|
+
* that may not be picked up by strict interactivity checks.
|
|
1026
|
+
*
|
|
1027
|
+
* @param {HTMLElement} element - The element to check.
|
|
1028
|
+
* @returns {boolean} Whether the element is heuristically interactive.
|
|
1029
|
+
*/
|
|
1030
|
+
function isHeuristicallyInteractive(element) {
|
|
1031
|
+
if (!element || element.nodeType !== Node.ELEMENT_NODE) return false;
|
|
1032
|
+
|
|
1033
|
+
// Skip non-visible elements early for performance
|
|
1034
|
+
if (!isElementVisible(element)) return false;
|
|
1035
|
+
|
|
1036
|
+
// Check for common attributes that often indicate interactivity
|
|
1037
|
+
const hasInteractiveAttributes =
|
|
1038
|
+
element.hasAttribute('role') ||
|
|
1039
|
+
element.hasAttribute('tabindex') ||
|
|
1040
|
+
element.hasAttribute('onclick') ||
|
|
1041
|
+
typeof element.onclick === 'function';
|
|
1042
|
+
|
|
1043
|
+
// Check for semantic class names suggesting interactivity
|
|
1044
|
+
const hasInteractiveClass = /\b(btn|clickable|menu|item|entry|link)\b/i.test(element.className || '');
|
|
1045
|
+
|
|
1046
|
+
// Determine whether the element is inside a known interactive container
|
|
1047
|
+
const isInKnownContainer = Boolean(element.closest('button,a,[role="button"],.menu,.dropdown,.list,.toolbar'));
|
|
1048
|
+
|
|
1049
|
+
// Ensure the element has at least one visible child (to avoid marking empty wrappers)
|
|
1050
|
+
const hasVisibleChildren = [...element.children].some(isElementVisible);
|
|
1051
|
+
|
|
1052
|
+
// Avoid highlighting elements whose parent is <body> (top-level wrappers)
|
|
1053
|
+
const isParentBody = element.parentElement && element.parentElement.isSameNode(document.body);
|
|
1054
|
+
|
|
1055
|
+
return (
|
|
1056
|
+
(isInteractiveElement(element) || hasInteractiveAttributes || hasInteractiveClass) &&
|
|
1057
|
+
hasVisibleChildren &&
|
|
1058
|
+
isInKnownContainer &&
|
|
1059
|
+
!isParentBody
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Checks if an element likely represents a distinct interaction
|
|
1065
|
+
* separate from its parent (if the parent is also interactive).
|
|
1066
|
+
*
|
|
1067
|
+
* @param {HTMLElement} element - The element to check.
|
|
1068
|
+
* @returns {boolean} Whether the element is a distinct interaction.
|
|
1069
|
+
*/
|
|
1070
|
+
function isElementDistinctInteraction(element) {
|
|
1071
|
+
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
|
|
1072
|
+
return false;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const tagName = element.tagName.toLowerCase();
|
|
1076
|
+
const role = element.getAttribute('role');
|
|
1077
|
+
|
|
1078
|
+
// Check if it's an iframe - always distinct boundary
|
|
1079
|
+
if (tagName === 'iframe') {
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Check tag name
|
|
1084
|
+
if (DISTINCT_INTERACTIVE_TAGS.has(tagName)) {
|
|
1085
|
+
return true;
|
|
1086
|
+
}
|
|
1087
|
+
// Check interactive roles
|
|
1088
|
+
if (role && INTERACTIVE_ROLES.has(role)) {
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
// Check contenteditable
|
|
1092
|
+
if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') {
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
// Check for common testing/automation attributes
|
|
1096
|
+
if (element.hasAttribute('data-testid') || element.hasAttribute('data-cy') || element.hasAttribute('data-test')) {
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
// Check for explicit onclick handler (attribute or property)
|
|
1100
|
+
if (element.hasAttribute('onclick') || typeof element.onclick === 'function') {
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// return false
|
|
1105
|
+
|
|
1106
|
+
// Check for other common interaction event listeners
|
|
1107
|
+
try {
|
|
1108
|
+
const getEventListenersForNode =
|
|
1109
|
+
element?.ownerDocument?.defaultView?.getEventListenersForNode || window.getEventListenersForNode;
|
|
1110
|
+
if (typeof getEventListenersForNode === 'function') {
|
|
1111
|
+
const listeners = getEventListenersForNode(element);
|
|
1112
|
+
const interactionEvents = [
|
|
1113
|
+
'click',
|
|
1114
|
+
'mousedown',
|
|
1115
|
+
'mouseup',
|
|
1116
|
+
'keydown',
|
|
1117
|
+
'keyup',
|
|
1118
|
+
'submit',
|
|
1119
|
+
'change',
|
|
1120
|
+
'input',
|
|
1121
|
+
'focus',
|
|
1122
|
+
'blur',
|
|
1123
|
+
];
|
|
1124
|
+
for (const eventType of interactionEvents) {
|
|
1125
|
+
for (const listener of listeners) {
|
|
1126
|
+
if (listener.type === eventType) {
|
|
1127
|
+
return true; // Found a common interaction listener
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
// Fallback: Check common event attributes if getEventListeners is not available (getEventListenersForNode doesn't work in page.evaluate context)
|
|
1133
|
+
const commonEventAttrs = [
|
|
1134
|
+
'onmousedown',
|
|
1135
|
+
'onmouseup',
|
|
1136
|
+
'onkeydown',
|
|
1137
|
+
'onkeyup',
|
|
1138
|
+
'onsubmit',
|
|
1139
|
+
'onchange',
|
|
1140
|
+
'oninput',
|
|
1141
|
+
'onfocus',
|
|
1142
|
+
'onblur',
|
|
1143
|
+
];
|
|
1144
|
+
if (commonEventAttrs.some(attr => element.hasAttribute(attr))) {
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
} catch (e) {
|
|
1148
|
+
// console.warn(`Could not check event listeners for ${element.tagName}:`, e);
|
|
1149
|
+
// If checking listeners fails, rely on other checks
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// if the element is not strictly interactive but appears clickable based on heuristic signals
|
|
1153
|
+
if (isHeuristicallyInteractive(element)) {
|
|
1154
|
+
return true;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Default to false: if it's interactive but doesn't match above,
|
|
1158
|
+
// assume it triggers the same action as the parent.
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
// --- End distinct interaction check ---
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Handles the logic for deciding whether to highlight an element and performing the highlight.
|
|
1165
|
+
* @param {
|
|
1166
|
+
{
|
|
1167
|
+
tagName: string;
|
|
1168
|
+
attributes: Record<string, string>;
|
|
1169
|
+
xpath: any;
|
|
1170
|
+
children: never[];
|
|
1171
|
+
isVisible?: boolean;
|
|
1172
|
+
isTopElement?: boolean;
|
|
1173
|
+
isInteractive?: boolean;
|
|
1174
|
+
isInViewport?: boolean;
|
|
1175
|
+
highlightIndex?: number;
|
|
1176
|
+
shadowRoot?: boolean;
|
|
1177
|
+
}} nodeData - The node data object.
|
|
1178
|
+
* @param {HTMLElement} node - The node to highlight.
|
|
1179
|
+
* @param {HTMLElement | null} parentIframe - The parent iframe node.
|
|
1180
|
+
* @param {boolean} isParentHighlighted - Whether the parent node is highlighted.
|
|
1181
|
+
* @returns {boolean} Whether the element was highlighted.
|
|
1182
|
+
*/
|
|
1183
|
+
function handleHighlighting(nodeData, node, parentIframe, isParentHighlighted) {
|
|
1184
|
+
if (!nodeData.isInteractive) return false; // Not interactive, definitely don't highlight
|
|
1185
|
+
|
|
1186
|
+
let shouldHighlight = false;
|
|
1187
|
+
if (!isParentHighlighted) {
|
|
1188
|
+
// Parent wasn't highlighted, this interactive node can be highlighted.
|
|
1189
|
+
shouldHighlight = true;
|
|
1190
|
+
} else {
|
|
1191
|
+
// Parent *was* highlighted. Only highlight this node if it represents a distinct interaction.
|
|
1192
|
+
if (isElementDistinctInteraction(node)) {
|
|
1193
|
+
shouldHighlight = true;
|
|
1194
|
+
} else {
|
|
1195
|
+
// console.log(`Skipping highlight for ${nodeData.tagName} (parent highlighted)`);
|
|
1196
|
+
shouldHighlight = false;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
if (shouldHighlight) {
|
|
1201
|
+
// Check viewport status before assigning index and highlighting
|
|
1202
|
+
nodeData.isInViewport = isInExpandedViewport(node, viewportExpansion);
|
|
1203
|
+
|
|
1204
|
+
// When viewportExpansion is -1, all interactive elements should get a highlight index
|
|
1205
|
+
// regardless of viewport status
|
|
1206
|
+
if (nodeData.isInViewport || viewportExpansion === -1) {
|
|
1207
|
+
nodeData.highlightIndex = highlightIndex++;
|
|
1208
|
+
|
|
1209
|
+
if (doHighlightElements) {
|
|
1210
|
+
if (focusHighlightIndex >= 0) {
|
|
1211
|
+
if (focusHighlightIndex === nodeData.highlightIndex) {
|
|
1212
|
+
highlightElement(node, nodeData.highlightIndex, parentIframe);
|
|
1213
|
+
}
|
|
1214
|
+
} else {
|
|
1215
|
+
highlightElement(node, nodeData.highlightIndex, parentIframe);
|
|
1216
|
+
}
|
|
1217
|
+
return true; // Successfully highlighted
|
|
1218
|
+
}
|
|
1219
|
+
} else {
|
|
1220
|
+
// console.log(`Skipping highlight for ${nodeData.tagName} (outside viewport)`);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
return false; // Did not highlight
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Creates a node data object for a given node and its descendants.
|
|
1229
|
+
*
|
|
1230
|
+
* @param {HTMLElement} node - The node to process.
|
|
1231
|
+
* @param {HTMLElement | null} parentIframe - The parent iframe node.
|
|
1232
|
+
* @param {boolean} isParentHighlighted - Whether the parent node is highlighted.
|
|
1233
|
+
* @returns {string | null} The ID of the node data object, or null if the node is not processed.
|
|
1234
|
+
*/
|
|
1235
|
+
const MAX_DEPTH = 100;
|
|
1236
|
+
let visitedNodes;
|
|
1237
|
+
|
|
1238
|
+
function buildDomTree(node, parentIframe = null, isParentHighlighted = false, depth = 0) {
|
|
1239
|
+
// Initialize visited nodes tracking on first call
|
|
1240
|
+
if (!visitedNodes) {
|
|
1241
|
+
visitedNodes = new WeakSet();
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Prevent infinite recursion
|
|
1245
|
+
if (depth > MAX_DEPTH) {
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Fast rejection checks first
|
|
1250
|
+
if (
|
|
1251
|
+
!node ||
|
|
1252
|
+
node.id === HIGHLIGHT_CONTAINER_ID ||
|
|
1253
|
+
(node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE)
|
|
1254
|
+
) {
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Prevent circular references (only for valid nodes)
|
|
1259
|
+
if (node.nodeType === Node.ELEMENT_NODE && visitedNodes.has(node)) {
|
|
1260
|
+
return null;
|
|
1261
|
+
}
|
|
1262
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1263
|
+
visitedNodes.add(node);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Special handling for root node (body)
|
|
1267
|
+
if (node === document.body) {
|
|
1268
|
+
const nodeData = {
|
|
1269
|
+
tagName: 'body',
|
|
1270
|
+
attributes: {},
|
|
1271
|
+
xpath: '/body',
|
|
1272
|
+
children: [],
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
// Process children of body
|
|
1276
|
+
for (const child of Array.from(node.childNodes)) {
|
|
1277
|
+
const domElement = buildDomTree(child, parentIframe, false, depth + 1); // Body's children have no highlighted parent initially
|
|
1278
|
+
if (domElement) nodeData.children.push(domElement);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const id = `${ID.current++}`;
|
|
1282
|
+
DOM_HASH_MAP[id] = nodeData;
|
|
1283
|
+
return id;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Early bailout for non-element nodes except text
|
|
1287
|
+
if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE) {
|
|
1288
|
+
return null;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Process text nodes
|
|
1292
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1293
|
+
const textContent = node.textContent?.trim();
|
|
1294
|
+
if (!textContent) {
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Only check visibility for text nodes that might be visible
|
|
1299
|
+
const parentElement = node.parentElement;
|
|
1300
|
+
if (!parentElement || parentElement.tagName.toLowerCase() === 'script') {
|
|
1301
|
+
return null;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const id = `${ID.current++}`;
|
|
1305
|
+
DOM_HASH_MAP[id] = {
|
|
1306
|
+
type: 'TEXT_NODE',
|
|
1307
|
+
text: textContent,
|
|
1308
|
+
isVisible: isTextNodeVisible(node),
|
|
1309
|
+
};
|
|
1310
|
+
return id;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Quick checks for element nodes
|
|
1314
|
+
if (node.nodeType === Node.ELEMENT_NODE && !isElementAccepted(node)) {
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Early viewport check - only filter out elements clearly outside viewport
|
|
1319
|
+
// The getBoundingClientRect() of the Shadow DOM host element may return width/height = 0
|
|
1320
|
+
if (viewportExpansion !== -1 && !node.shadowRoot) {
|
|
1321
|
+
const rect = getCachedBoundingRect(node); // Keep for initial quick check
|
|
1322
|
+
const style = getCachedComputedStyle(node);
|
|
1323
|
+
|
|
1324
|
+
// Skip viewport check for fixed/sticky elements as they may appear anywhere
|
|
1325
|
+
const isFixedOrSticky = style && (style.position === 'fixed' || style.position === 'sticky');
|
|
1326
|
+
|
|
1327
|
+
// Check if element has actual dimensions using offsetWidth/Height (quick check)
|
|
1328
|
+
const hasSize = node.offsetWidth > 0 || node.offsetHeight > 0;
|
|
1329
|
+
|
|
1330
|
+
// Use getBoundingClientRect for the quick OUTSIDE check.
|
|
1331
|
+
// isInExpandedViewport will do the more accurate check later if needed.
|
|
1332
|
+
if (
|
|
1333
|
+
!rect ||
|
|
1334
|
+
(!isFixedOrSticky &&
|
|
1335
|
+
!hasSize &&
|
|
1336
|
+
(rect.bottom < -viewportExpansion ||
|
|
1337
|
+
rect.top > window.innerHeight + viewportExpansion ||
|
|
1338
|
+
rect.right < -viewportExpansion ||
|
|
1339
|
+
rect.left > window.innerWidth + viewportExpansion))
|
|
1340
|
+
) {
|
|
1341
|
+
// console.log("Skipping node outside viewport (quick check):", node.tagName, rect);
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* @type {
|
|
1348
|
+
{
|
|
1349
|
+
tagName: string;
|
|
1350
|
+
attributes: Record<string, string | null>;
|
|
1351
|
+
xpath: any;
|
|
1352
|
+
children: never[];
|
|
1353
|
+
isVisible?: boolean;
|
|
1354
|
+
isTopElement?: boolean;
|
|
1355
|
+
isInteractive?: boolean;
|
|
1356
|
+
isInViewport?: boolean;
|
|
1357
|
+
highlightIndex?: number;
|
|
1358
|
+
shadowRoot?: boolean;
|
|
1359
|
+
}
|
|
1360
|
+
} nodeData - The node data object.
|
|
1361
|
+
*/
|
|
1362
|
+
const nodeData = {
|
|
1363
|
+
tagName: node.tagName.toLowerCase(),
|
|
1364
|
+
attributes: {},
|
|
1365
|
+
xpath: getXPathTree(node, true),
|
|
1366
|
+
children: [],
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
// Get attributes for interactive elements or potential text containers
|
|
1370
|
+
if (
|
|
1371
|
+
isInteractiveCandidate(node) ||
|
|
1372
|
+
node.tagName.toLowerCase() === 'iframe' ||
|
|
1373
|
+
node.tagName.toLowerCase() === 'body'
|
|
1374
|
+
) {
|
|
1375
|
+
const attributeNames = node.getAttributeNames?.() || [];
|
|
1376
|
+
for (const name of attributeNames) {
|
|
1377
|
+
const value = node.getAttribute(name);
|
|
1378
|
+
nodeData.attributes[name] = value;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
let nodeWasHighlighted = false;
|
|
1383
|
+
// Perform visibility, interactivity, and highlighting checks
|
|
1384
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1385
|
+
nodeData.isVisible = isElementVisible(node); // isElementVisible uses offsetWidth/Height, which is fine
|
|
1386
|
+
if (nodeData.isVisible) {
|
|
1387
|
+
nodeData.isTopElement = isTopElement(node);
|
|
1388
|
+
|
|
1389
|
+
// Special handling for ARIA menu containers - check interactivity even if not top element
|
|
1390
|
+
const role = node.getAttribute('role');
|
|
1391
|
+
const isMenuContainer = role === 'menu' || role === 'menubar' || role === 'listbox';
|
|
1392
|
+
|
|
1393
|
+
if (nodeData.isTopElement || isMenuContainer) {
|
|
1394
|
+
nodeData.isInteractive = isInteractiveElement(node);
|
|
1395
|
+
// Call the dedicated highlighting function
|
|
1396
|
+
nodeWasHighlighted = handleHighlighting(nodeData, node, parentIframe, isParentHighlighted);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Process children, with special handling for iframes and rich text editors
|
|
1402
|
+
if (node.tagName) {
|
|
1403
|
+
const tagName = node.tagName.toLowerCase();
|
|
1404
|
+
|
|
1405
|
+
// Handle iframes
|
|
1406
|
+
if (tagName === 'iframe') {
|
|
1407
|
+
const rect = getCachedBoundingRect(node);
|
|
1408
|
+
nodeData.attributes['computedHeight'] = String(Math.ceil(rect.height));
|
|
1409
|
+
nodeData.attributes['computedWidth'] = String(Math.ceil(rect.width));
|
|
1410
|
+
|
|
1411
|
+
// Check if iframe should be skipped (invisible tracking/ad iframes)
|
|
1412
|
+
const shouldSkipIframe =
|
|
1413
|
+
// Invisible iframes (1x1 pixel or similar)
|
|
1414
|
+
(rect.width <= 1 && rect.height <= 1) ||
|
|
1415
|
+
// Positioned off-screen
|
|
1416
|
+
rect.left < -1000 ||
|
|
1417
|
+
rect.top < -1000;
|
|
1418
|
+
|
|
1419
|
+
// Early detection for sandboxed iframes
|
|
1420
|
+
const sandbox = node.getAttribute('sandbox');
|
|
1421
|
+
const isSandboxed = sandbox !== null;
|
|
1422
|
+
const isRestrictiveSandbox = isSandboxed && !sandbox.includes('allow-same-origin');
|
|
1423
|
+
|
|
1424
|
+
if (shouldSkipIframe) {
|
|
1425
|
+
// Skip processing invisible/tracking iframes entirely
|
|
1426
|
+
nodeData.attributes['skipped'] = 'invisible-tracking-iframe';
|
|
1427
|
+
} else if (isRestrictiveSandbox) {
|
|
1428
|
+
// Set error directly for sandboxed iframes we know will fail
|
|
1429
|
+
nodeData.attributes['error'] = 'Cross-origin iframe access blocked by sandbox';
|
|
1430
|
+
} else {
|
|
1431
|
+
// Only attempt access for iframes that might succeed
|
|
1432
|
+
try {
|
|
1433
|
+
const iframeDoc = node.contentDocument || node.contentWindow?.document;
|
|
1434
|
+
if (iframeDoc && iframeDoc.childNodes) {
|
|
1435
|
+
for (const child of Array.from(iframeDoc.childNodes)) {
|
|
1436
|
+
const domElement = buildDomTree(child, node, false, depth + 1);
|
|
1437
|
+
if (domElement) nodeData.children.push(domElement);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
nodeData.attributes['error'] = e.message;
|
|
1442
|
+
// Only log unexpected errors, not predictable ones
|
|
1443
|
+
if (!e.message.includes('cross-origin') && !e.message.includes('origin "null"')) {
|
|
1444
|
+
console.warn('Unable to access iframe:', e);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
// Handle rich text editors and contenteditable elements
|
|
1450
|
+
else if (
|
|
1451
|
+
node.isContentEditable ||
|
|
1452
|
+
node.getAttribute('contenteditable') === 'true' ||
|
|
1453
|
+
node.id === 'tinymce' ||
|
|
1454
|
+
node.classList.contains('mce-content-body') ||
|
|
1455
|
+
(tagName === 'body' && node.getAttribute('data-id')?.startsWith('mce_'))
|
|
1456
|
+
) {
|
|
1457
|
+
// Process all child nodes to capture formatted text
|
|
1458
|
+
for (const child of Array.from(node.childNodes)) {
|
|
1459
|
+
const domElement = buildDomTree(child, parentIframe, nodeWasHighlighted, depth + 1);
|
|
1460
|
+
if (domElement) nodeData.children.push(domElement);
|
|
1461
|
+
}
|
|
1462
|
+
} else {
|
|
1463
|
+
// Handle shadow DOM
|
|
1464
|
+
if (node.shadowRoot) {
|
|
1465
|
+
nodeData.shadowRoot = true;
|
|
1466
|
+
for (const child of Array.from(node.shadowRoot.childNodes)) {
|
|
1467
|
+
const domElement = buildDomTree(child, parentIframe, nodeWasHighlighted, depth + 1);
|
|
1468
|
+
if (domElement) nodeData.children.push(domElement);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
// Handle regular elements
|
|
1472
|
+
for (const child of Array.from(node.childNodes)) {
|
|
1473
|
+
// Pass the highlighted status of the *current* node to its children
|
|
1474
|
+
const passHighlightStatusToChild = nodeWasHighlighted || isParentHighlighted;
|
|
1475
|
+
const domElement = buildDomTree(child, parentIframe, passHighlightStatusToChild, depth + 1);
|
|
1476
|
+
if (domElement) nodeData.children.push(domElement);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Skip empty anchor tags only if they have no dimensions and no children
|
|
1482
|
+
if (nodeData.tagName === 'a' && nodeData.children.length === 0 && !nodeData.attributes.href) {
|
|
1483
|
+
// Check if the anchor has actual dimensions
|
|
1484
|
+
const rect = getCachedBoundingRect(node);
|
|
1485
|
+
const hasSize = (rect && rect.width > 0 && rect.height > 0) || node.offsetWidth > 0 || node.offsetHeight > 0;
|
|
1486
|
+
|
|
1487
|
+
if (!hasSize) {
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const id = `${ID.current++}`;
|
|
1493
|
+
DOM_HASH_MAP[id] = nodeData;
|
|
1494
|
+
return id;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Reset visited nodes for new DOM tree build
|
|
1498
|
+
visitedNodes = null;
|
|
1499
|
+
const rootId = buildDomTree(document.body);
|
|
1500
|
+
|
|
1501
|
+
// Clear the cache before starting
|
|
1502
|
+
DOM_CACHE.clearCache();
|
|
1503
|
+
|
|
1504
|
+
return { rootId, map: DOM_HASH_MAP };
|
|
1505
|
+
};
|