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.
- package/README.md +427 -0
- package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
- package/dist/fonts/Phosphor.ttf +0 -0
- package/dist/fonts/Phosphor.woff +0 -0
- package/dist/fonts/Phosphor.woff2 +0 -0
- package/dist/handhold.min.js +1 -0
- package/package.json +114 -0
- package/src/HandHold.js +1599 -0
- package/src/agent/VENDOR.md +203 -0
- package/src/agent/agent/AgentLoop.js +366 -0
- package/src/agent/agent/prompts.js +163 -0
- package/src/agent/agent/tools.js +148 -0
- package/src/agent/controller.js +235 -0
- package/src/agent/dom/actions.js +456 -0
- package/src/agent/dom/domTree.js +1761 -0
- package/src/agent/dom/redact.js +98 -0
- package/src/agent/dom/serializer.js +332 -0
- package/src/agent/dom/utils.js +99 -0
- package/src/agent/index.js +169 -0
- package/src/agent/llm/connector.js +143 -0
- package/src/agent/package.json +3 -0
- package/src/agent/policy/PolicyGuard.js +172 -0
- package/src/agent/site/manifest.js +145 -0
- package/src/agent/ui/ChatWidget.js +219 -0
- package/src/agent-mode/AgentMode.js +429 -0
- package/src/api/APIClient.js +258 -0
- package/src/core/Config.js +207 -0
- package/src/core/UiState.js +83 -0
- package/src/core/defaults.js +20 -0
- package/src/events.js +59 -0
- package/src/index.js +77 -0
- package/src/intent/ActionVerbMap.js +324 -0
- package/src/intent/IntentExtractor.js +317 -0
- package/src/observers/ElementScanner.js +284 -0
- package/src/observers/NavigationObserver.js +182 -0
- package/src/observers/PageObserver.js +293 -0
- package/src/session/SessionManager.js +402 -0
- package/src/session/SessionStorage.js +148 -0
- package/src/ui/HelpWidget.js +1189 -0
- package/src/ui/UICoach.js +1725 -0
- package/src/ui/components/Button.css +137 -0
- package/src/ui/components/Button.js +102 -0
- package/src/ui/widget.css +599 -0
- package/src/utils/Logger.js +132 -0
- package/src/utils/MarkdownRenderer.js +39 -0
- package/src/utils/domUtils.js +350 -0
- package/src/utils/eventBus.js +102 -0
- package/src/utils/index.js +8 -0
- package/src/utils/stringUtils.js +202 -0
- package/types/index.d.ts +538 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* actions.js — ported from alibaba/page-agent packages/page-controller/src/actions.ts (MIT).
|
|
3
|
+
* Audit before porting (2026-07-05): clean — dispatchEvent/innerText/execCommand only,
|
|
4
|
+
* no eval, no innerHTML, no network. This file keeps upstream logic intact because it
|
|
5
|
+
* encodes real-world compatibility work:
|
|
6
|
+
* - full W3C pointer+mouse event order so React/Vue/antd handlers fire
|
|
7
|
+
* - native prototype value setter so React controlled inputs update
|
|
8
|
+
* - contenteditable Plan A (synthetic InputEvents) with verified fallback to
|
|
9
|
+
* Plan B (execCommand) for Quill/Slate-style editors
|
|
10
|
+
* Changes vs upstream: mask CustomEvents → optional hooks (see utils.js).
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
clickPointer,
|
|
14
|
+
disablePassThrough,
|
|
15
|
+
enablePassThrough,
|
|
16
|
+
getNativeValueSetter,
|
|
17
|
+
isHTMLElement,
|
|
18
|
+
isInputElement,
|
|
19
|
+
isSelectElement,
|
|
20
|
+
isTextAreaElement,
|
|
21
|
+
movePointerToElement,
|
|
22
|
+
waitFor,
|
|
23
|
+
} from './utils.js'
|
|
24
|
+
|
|
25
|
+
/** Get the HTMLElement by index from a selectorMap. */
|
|
26
|
+
export function getElementByIndex(selectorMap, index) {
|
|
27
|
+
const interactiveNode = selectorMap.get(index)
|
|
28
|
+
if (!interactiveNode) {
|
|
29
|
+
throw new Error(`No interactive element found at index ${index}`)
|
|
30
|
+
}
|
|
31
|
+
const element = interactiveNode.ref
|
|
32
|
+
if (!element) {
|
|
33
|
+
throw new Error(`Element at index ${index} does not have a reference`)
|
|
34
|
+
}
|
|
35
|
+
if (!isHTMLElement(element)) {
|
|
36
|
+
throw new Error(`Element at index ${index} is not an HTMLElement`)
|
|
37
|
+
}
|
|
38
|
+
return element
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let lastClickedElement = null
|
|
42
|
+
|
|
43
|
+
function blurLastClickedElement() {
|
|
44
|
+
if (lastClickedElement) {
|
|
45
|
+
lastClickedElement.dispatchEvent(new PointerEvent('pointerout', { bubbles: true }))
|
|
46
|
+
lastClickedElement.dispatchEvent(new PointerEvent('pointerleave', { bubbles: false }))
|
|
47
|
+
lastClickedElement.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))
|
|
48
|
+
lastClickedElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }))
|
|
49
|
+
lastClickedElement.blur()
|
|
50
|
+
lastClickedElement = null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Simulate a full click following W3C Pointer Events + UI Events spec order:
|
|
56
|
+
* pointerover/enter → mouseover/enter → pointerdown → mousedown → [focus] →
|
|
57
|
+
* pointerup → mouseup → click
|
|
58
|
+
*/
|
|
59
|
+
export async function clickElement(element) {
|
|
60
|
+
blurLastClickedElement()
|
|
61
|
+
lastClickedElement = element
|
|
62
|
+
|
|
63
|
+
await scrollIntoViewIfNeeded(element)
|
|
64
|
+
const frame = element.ownerDocument.defaultView?.frameElement
|
|
65
|
+
if (frame) await scrollIntoViewIfNeeded(frame)
|
|
66
|
+
|
|
67
|
+
const rect = element.getBoundingClientRect()
|
|
68
|
+
const x = rect.left + rect.width / 2
|
|
69
|
+
const y = rect.top + rect.height / 2
|
|
70
|
+
|
|
71
|
+
await movePointerToElement(element, x, y)
|
|
72
|
+
await clickPointer()
|
|
73
|
+
|
|
74
|
+
await waitFor(0.1)
|
|
75
|
+
|
|
76
|
+
// Hit-test to find the deepest element at click coordinates, matching
|
|
77
|
+
// real browser behavior where events target the innermost element.
|
|
78
|
+
const doc = element.ownerDocument
|
|
79
|
+
await enablePassThrough()
|
|
80
|
+
const hitTarget = doc.elementFromPoint ? doc.elementFromPoint(x, y) : null
|
|
81
|
+
await disablePassThrough()
|
|
82
|
+
const target = isHTMLElement(hitTarget) && element.contains(hitTarget) ? hitTarget : element
|
|
83
|
+
|
|
84
|
+
const pointerOpts = { bubbles: true, cancelable: true, clientX: x, clientY: y, pointerType: 'mouse' }
|
|
85
|
+
const mouseOpts = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }
|
|
86
|
+
|
|
87
|
+
// Hover — pointer events first, then mouse events (spec order)
|
|
88
|
+
target.dispatchEvent(new PointerEvent('pointerover', pointerOpts))
|
|
89
|
+
target.dispatchEvent(new PointerEvent('pointerenter', { ...pointerOpts, bubbles: false }))
|
|
90
|
+
target.dispatchEvent(new MouseEvent('mouseover', mouseOpts))
|
|
91
|
+
target.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false }))
|
|
92
|
+
|
|
93
|
+
// Press
|
|
94
|
+
target.dispatchEvent(new PointerEvent('pointerdown', pointerOpts))
|
|
95
|
+
target.dispatchEvent(new MouseEvent('mousedown', mouseOpts))
|
|
96
|
+
|
|
97
|
+
// Focus the original element (nearest focusable ancestor), matching browsers.
|
|
98
|
+
element.focus({ preventScroll: true })
|
|
99
|
+
|
|
100
|
+
// Release
|
|
101
|
+
target.dispatchEvent(new PointerEvent('pointerup', pointerOpts))
|
|
102
|
+
target.dispatchEvent(new MouseEvent('mouseup', mouseOpts))
|
|
103
|
+
|
|
104
|
+
// Click — activation behavior (navigation, form submit, etc.) triggers
|
|
105
|
+
// via bubbling from target up to the interactive ancestor.
|
|
106
|
+
target.click()
|
|
107
|
+
|
|
108
|
+
await waitFor(0.2)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function inputTextElement(element, text) {
|
|
112
|
+
const isContentEditable = element.isContentEditable
|
|
113
|
+
if (!isInputElement(element) && !isTextAreaElement(element) && !isContentEditable) {
|
|
114
|
+
throw new Error('Element is not an input, textarea, or contenteditable')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await clickElement(element)
|
|
118
|
+
|
|
119
|
+
if (isContentEditable) {
|
|
120
|
+
// Plan A: synthetic InputEvents (React contenteditable, Quill).
|
|
121
|
+
if (
|
|
122
|
+
element.dispatchEvent(
|
|
123
|
+
new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'deleteContent' })
|
|
124
|
+
)
|
|
125
|
+
) {
|
|
126
|
+
element.innerText = ''
|
|
127
|
+
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContent' }))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
element.dispatchEvent(
|
|
132
|
+
new InputEvent('beforeinput', {
|
|
133
|
+
bubbles: true,
|
|
134
|
+
cancelable: true,
|
|
135
|
+
inputType: 'insertText',
|
|
136
|
+
data: text,
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
) {
|
|
140
|
+
element.innerText = text
|
|
141
|
+
element.dispatchEvent(
|
|
142
|
+
new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text })
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Verify Plan A actually inserted the text.
|
|
147
|
+
const planASucceeded = element.innerText.trim() === text.trim()
|
|
148
|
+
|
|
149
|
+
if (!planASucceeded) {
|
|
150
|
+
// Plan B: execCommand fallback (deprecated but widely supported;
|
|
151
|
+
// integrates with the browser undo stack, handled natively by
|
|
152
|
+
// most rich-text editors: Quill, Slate.js, react-contenteditable).
|
|
153
|
+
element.focus()
|
|
154
|
+
const doc = element.ownerDocument
|
|
155
|
+
const selection = (doc.defaultView || window).getSelection()
|
|
156
|
+
const range = doc.createRange()
|
|
157
|
+
range.selectNodeContents(element)
|
|
158
|
+
selection?.removeAllRanges()
|
|
159
|
+
selection?.addRange(range)
|
|
160
|
+
|
|
161
|
+
doc.execCommand('delete', false)
|
|
162
|
+
doc.execCommand('insertText', false, text)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
element.dispatchEvent(new Event('change', { bubbles: true }))
|
|
166
|
+
element.blur()
|
|
167
|
+
} else {
|
|
168
|
+
// Native prototype setter so React controlled components see the change.
|
|
169
|
+
getNativeValueSetter(element).call(element, text)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!isContentEditable) {
|
|
173
|
+
element.dispatchEvent(new Event('input', { bubbles: true }))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await waitFor(0.1)
|
|
177
|
+
blurLastClickedElement()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function selectOptionElement(selectElement, optionText) {
|
|
181
|
+
if (!isSelectElement(selectElement)) {
|
|
182
|
+
throw new Error('Element is not a select element')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const options = Array.from(selectElement.options)
|
|
186
|
+
const option = options.find((opt) => opt.textContent?.trim() === optionText.trim())
|
|
187
|
+
|
|
188
|
+
if (!option) {
|
|
189
|
+
throw new Error(`Option with text "${optionText}" not found in select element`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
selectElement.value = option.value
|
|
193
|
+
selectElement.dispatchEvent(new Event('change', { bubbles: true }))
|
|
194
|
+
|
|
195
|
+
await waitFor(0.1)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function scrollIntoViewIfNeeded(element) {
|
|
199
|
+
if (typeof element.scrollIntoViewIfNeeded === 'function') {
|
|
200
|
+
element.scrollIntoViewIfNeeded()
|
|
201
|
+
} else if (typeof element.scrollIntoView === 'function') {
|
|
202
|
+
element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function scrollVertically(scroll_amount, element) {
|
|
207
|
+
// Element-specific scrolling if element is provided
|
|
208
|
+
if (element) {
|
|
209
|
+
const targetElement = element
|
|
210
|
+
let currentElement = targetElement
|
|
211
|
+
let scrollSuccess = false
|
|
212
|
+
let scrolledElement = null
|
|
213
|
+
let scrollDelta = 0
|
|
214
|
+
let attempts = 0
|
|
215
|
+
const dy = scroll_amount
|
|
216
|
+
|
|
217
|
+
while (currentElement && attempts < 10) {
|
|
218
|
+
const computedStyle = window.getComputedStyle(currentElement)
|
|
219
|
+
const hasScrollableY =
|
|
220
|
+
/(auto|scroll|overlay)/.test(computedStyle.overflowY) ||
|
|
221
|
+
(computedStyle.scrollbarWidth && computedStyle.scrollbarWidth !== 'auto') ||
|
|
222
|
+
(computedStyle.scrollbarGutter && computedStyle.scrollbarGutter !== 'auto')
|
|
223
|
+
const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight
|
|
224
|
+
|
|
225
|
+
if (hasScrollableY && canScrollVertically) {
|
|
226
|
+
const beforeScroll = currentElement.scrollTop
|
|
227
|
+
const maxScroll = currentElement.scrollHeight - currentElement.clientHeight
|
|
228
|
+
|
|
229
|
+
let scrollAmount = dy / 3
|
|
230
|
+
if (scrollAmount > 0) {
|
|
231
|
+
scrollAmount = Math.min(scrollAmount, maxScroll - beforeScroll)
|
|
232
|
+
} else {
|
|
233
|
+
scrollAmount = Math.max(scrollAmount, -beforeScroll)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
currentElement.scrollTop = beforeScroll + scrollAmount
|
|
237
|
+
|
|
238
|
+
const afterScroll = currentElement.scrollTop
|
|
239
|
+
const actualScrollDelta = afterScroll - beforeScroll
|
|
240
|
+
|
|
241
|
+
if (Math.abs(actualScrollDelta) > 0.5) {
|
|
242
|
+
scrollSuccess = true
|
|
243
|
+
scrolledElement = currentElement
|
|
244
|
+
scrollDelta = actualScrollDelta
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (currentElement === document.body || currentElement === document.documentElement) {
|
|
250
|
+
break
|
|
251
|
+
}
|
|
252
|
+
currentElement = currentElement.parentElement
|
|
253
|
+
attempts++
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (scrollSuccess) {
|
|
257
|
+
return `Scrolled container (${scrolledElement?.tagName}) by ${scrollDelta}px`
|
|
258
|
+
} else {
|
|
259
|
+
return `No scrollable container found for element (${targetElement.tagName})`
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Page-level scrolling (default or fallback)
|
|
264
|
+
const dy = scroll_amount
|
|
265
|
+
const bigEnough = (el) => el.clientHeight >= window.innerHeight * 0.5
|
|
266
|
+
const canScroll = (el) =>
|
|
267
|
+
Boolean(
|
|
268
|
+
el &&
|
|
269
|
+
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
|
|
270
|
+
el.scrollHeight > el.clientHeight &&
|
|
271
|
+
bigEnough(el)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
let el = document.activeElement
|
|
275
|
+
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
|
|
276
|
+
|
|
277
|
+
el = canScroll(el)
|
|
278
|
+
? el
|
|
279
|
+
: Array.from(document.querySelectorAll('*')).find(canScroll) ||
|
|
280
|
+
document.scrollingElement ||
|
|
281
|
+
document.documentElement
|
|
282
|
+
|
|
283
|
+
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
|
284
|
+
const scrollBefore = window.scrollY
|
|
285
|
+
const scrollMax = document.documentElement.scrollHeight - window.innerHeight
|
|
286
|
+
|
|
287
|
+
window.scrollBy(0, dy)
|
|
288
|
+
|
|
289
|
+
const scrollAfter = window.scrollY
|
|
290
|
+
const scrolled = scrollAfter - scrollBefore
|
|
291
|
+
|
|
292
|
+
if (Math.abs(scrolled) < 1) {
|
|
293
|
+
return dy > 0
|
|
294
|
+
? `⚠️ Already at the bottom of the page, cannot scroll down further.`
|
|
295
|
+
: `⚠️ Already at the top of the page, cannot scroll up further.`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
|
299
|
+
const reachedTop = dy < 0 && scrollAfter <= 1
|
|
300
|
+
|
|
301
|
+
if (reachedBottom) return `✅ Scrolled page by ${scrolled}px. Reached the bottom of the page.`
|
|
302
|
+
if (reachedTop) return `✅ Scrolled page by ${scrolled}px. Reached the top of the page.`
|
|
303
|
+
return `✅ Scrolled page by ${scrolled}px.`
|
|
304
|
+
} else {
|
|
305
|
+
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
|
|
306
|
+
|
|
307
|
+
const scrollBefore = el.scrollTop
|
|
308
|
+
|
|
309
|
+
el.scrollBy({ top: dy, behavior: 'smooth' })
|
|
310
|
+
await waitFor(0.1)
|
|
311
|
+
|
|
312
|
+
const scrollMax = el.scrollHeight - el.clientHeight
|
|
313
|
+
const scrollAfter = el.scrollTop
|
|
314
|
+
const scrolled = scrollAfter - scrollBefore
|
|
315
|
+
|
|
316
|
+
if (Math.abs(scrolled) < 1) {
|
|
317
|
+
return dy > 0
|
|
318
|
+
? `⚠️ ${warningMsg} Already at the bottom of container (${el.tagName}), cannot scroll down further.`
|
|
319
|
+
: `⚠️ ${warningMsg} Already at the top of container (${el.tagName}), cannot scroll up further.`
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
|
323
|
+
const reachedTop = dy < 0 && scrollAfter <= 1
|
|
324
|
+
|
|
325
|
+
if (reachedBottom)
|
|
326
|
+
return `✅ ${warningMsg} Scrolled container (${el.tagName}) by ${scrolled}px. Reached the bottom.`
|
|
327
|
+
if (reachedTop)
|
|
328
|
+
return `✅ ${warningMsg} Scrolled container (${el.tagName}) by ${scrolled}px. Reached the top.`
|
|
329
|
+
return `✅ ${warningMsg} Scrolled container (${el.tagName}) by ${scrolled}px.`
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function scrollHorizontally(scroll_amount, element) {
|
|
334
|
+
if (element) {
|
|
335
|
+
const targetElement = element
|
|
336
|
+
let currentElement = targetElement
|
|
337
|
+
let scrollSuccess = false
|
|
338
|
+
let scrolledElement = null
|
|
339
|
+
let scrollDelta = 0
|
|
340
|
+
let attempts = 0
|
|
341
|
+
const dx = scroll_amount
|
|
342
|
+
|
|
343
|
+
while (currentElement && attempts < 10) {
|
|
344
|
+
const computedStyle = window.getComputedStyle(currentElement)
|
|
345
|
+
const hasScrollableX =
|
|
346
|
+
/(auto|scroll|overlay)/.test(computedStyle.overflowX) ||
|
|
347
|
+
(computedStyle.scrollbarWidth && computedStyle.scrollbarWidth !== 'auto') ||
|
|
348
|
+
(computedStyle.scrollbarGutter && computedStyle.scrollbarGutter !== 'auto')
|
|
349
|
+
const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth
|
|
350
|
+
|
|
351
|
+
if (hasScrollableX && canScrollHorizontally) {
|
|
352
|
+
const beforeScroll = currentElement.scrollLeft
|
|
353
|
+
const maxScroll = currentElement.scrollWidth - currentElement.clientWidth
|
|
354
|
+
|
|
355
|
+
let scrollAmount = dx / 3
|
|
356
|
+
if (scrollAmount > 0) {
|
|
357
|
+
scrollAmount = Math.min(scrollAmount, maxScroll - beforeScroll)
|
|
358
|
+
} else {
|
|
359
|
+
scrollAmount = Math.max(scrollAmount, -beforeScroll)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
currentElement.scrollLeft = beforeScroll + scrollAmount
|
|
363
|
+
|
|
364
|
+
const afterScroll = currentElement.scrollLeft
|
|
365
|
+
const actualScrollDelta = afterScroll - beforeScroll
|
|
366
|
+
|
|
367
|
+
if (Math.abs(actualScrollDelta) > 0.5) {
|
|
368
|
+
scrollSuccess = true
|
|
369
|
+
scrolledElement = currentElement
|
|
370
|
+
scrollDelta = actualScrollDelta
|
|
371
|
+
break
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (currentElement === document.body || currentElement === document.documentElement) {
|
|
376
|
+
break
|
|
377
|
+
}
|
|
378
|
+
currentElement = currentElement.parentElement
|
|
379
|
+
attempts++
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (scrollSuccess) {
|
|
383
|
+
return `Scrolled container (${scrolledElement?.tagName}) horizontally by ${scrollDelta}px`
|
|
384
|
+
} else {
|
|
385
|
+
return `No horizontally scrollable container found for element (${targetElement.tagName})`
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const dx = scroll_amount
|
|
390
|
+
const bigEnough = (el) => el.clientWidth >= window.innerWidth * 0.5
|
|
391
|
+
const canScroll = (el) =>
|
|
392
|
+
Boolean(
|
|
393
|
+
el &&
|
|
394
|
+
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
|
|
395
|
+
el.scrollWidth > el.clientWidth &&
|
|
396
|
+
bigEnough(el)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
let el = document.activeElement
|
|
400
|
+
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
|
|
401
|
+
|
|
402
|
+
el = canScroll(el)
|
|
403
|
+
? el
|
|
404
|
+
: Array.from(document.querySelectorAll('*')).find(canScroll) ||
|
|
405
|
+
document.scrollingElement ||
|
|
406
|
+
document.documentElement
|
|
407
|
+
|
|
408
|
+
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
|
409
|
+
const scrollBefore = window.scrollX
|
|
410
|
+
const scrollMax = document.documentElement.scrollWidth - window.innerWidth
|
|
411
|
+
|
|
412
|
+
window.scrollBy(dx, 0)
|
|
413
|
+
|
|
414
|
+
const scrollAfter = window.scrollX
|
|
415
|
+
const scrolled = scrollAfter - scrollBefore
|
|
416
|
+
|
|
417
|
+
if (Math.abs(scrolled) < 1) {
|
|
418
|
+
return dx > 0
|
|
419
|
+
? `⚠️ Already at the right edge of the page, cannot scroll right further.`
|
|
420
|
+
: `⚠️ Already at the left edge of the page, cannot scroll left further.`
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
|
424
|
+
const reachedLeft = dx < 0 && scrollAfter <= 1
|
|
425
|
+
|
|
426
|
+
if (reachedRight) return `✅ Scrolled page by ${scrolled}px. Reached the right edge of the page.`
|
|
427
|
+
if (reachedLeft) return `✅ Scrolled page by ${scrolled}px. Reached the left edge of the page.`
|
|
428
|
+
return `✅ Scrolled page horizontally by ${scrolled}px.`
|
|
429
|
+
} else {
|
|
430
|
+
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
|
|
431
|
+
|
|
432
|
+
const scrollBefore = el.scrollLeft
|
|
433
|
+
|
|
434
|
+
el.scrollBy({ left: dx, behavior: 'smooth' })
|
|
435
|
+
await waitFor(0.1)
|
|
436
|
+
|
|
437
|
+
const scrollMax = el.scrollWidth - el.clientWidth
|
|
438
|
+
const scrollAfter = el.scrollLeft
|
|
439
|
+
const scrolled = scrollAfter - scrollBefore
|
|
440
|
+
|
|
441
|
+
if (Math.abs(scrolled) < 1) {
|
|
442
|
+
return dx > 0
|
|
443
|
+
? `⚠️ ${warningMsg} Already at the right edge of container (${el.tagName}), cannot scroll right further.`
|
|
444
|
+
: `⚠️ ${warningMsg} Already at the left edge of container (${el.tagName}), cannot scroll left further.`
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
|
448
|
+
const reachedLeft = dx < 0 && scrollAfter <= 1
|
|
449
|
+
|
|
450
|
+
if (reachedRight)
|
|
451
|
+
return `✅ ${warningMsg} Scrolled container (${el.tagName}) by ${scrolled}px. Reached the right edge.`
|
|
452
|
+
if (reachedLeft)
|
|
453
|
+
return `✅ ${warningMsg} Scrolled container (${el.tagName}) by ${scrolled}px. Reached the left edge.`
|
|
454
|
+
return `✅ ${warningMsg} Scrolled container (${el.tagName}) horizontally by ${scrolled}px.`
|
|
455
|
+
}
|
|
456
|
+
}
|