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