@browserbridge/bbx 1.0.0 → 1.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/README.md +6 -4
- package/package.json +53 -53
- package/packages/agent-client/src/cli-helpers.js +43 -5
- package/packages/agent-client/src/cli.js +176 -171
- package/packages/agent-client/src/client.js +66 -21
- package/packages/agent-client/src/command-registry.js +104 -69
- package/packages/agent-client/src/detect.js +162 -54
- package/packages/agent-client/src/install.js +34 -28
- package/packages/agent-client/src/mcp-config.js +40 -40
- package/packages/agent-client/src/runtime.js +41 -20
- package/packages/agent-client/src/setup-status.js +23 -30
- package/packages/mcp-server/src/bin.js +57 -5
- package/packages/mcp-server/src/handlers.js +573 -256
- package/packages/mcp-server/src/server.js +568 -257
- package/packages/native-host/bin/bridge-daemon.js +39 -6
- package/packages/native-host/bin/install-manifest.js +26 -4
- package/packages/native-host/bin/postinstall.js +4 -2
- package/packages/native-host/src/config.js +142 -13
- package/packages/native-host/src/daemon-process.js +396 -0
- package/packages/native-host/src/daemon.js +350 -150
- package/packages/native-host/src/framing.js +131 -11
- package/packages/native-host/src/install-manifest.js +194 -29
- package/packages/native-host/src/native-host.js +154 -102
- package/packages/protocol/src/budget.js +3 -7
- package/packages/protocol/src/capabilities.js +6 -3
- package/packages/protocol/src/defaults.js +1 -0
- package/packages/protocol/src/errors.js +15 -11
- package/packages/protocol/src/payload-cost.js +19 -6
- package/packages/protocol/src/protocol.js +242 -73
- package/packages/protocol/src/registry.js +311 -45
- package/packages/protocol/src/summary.js +260 -109
- package/packages/protocol/src/types.js +29 -4
- package/skills/browser-bridge/SKILL.md +3 -2
- package/skills/browser-bridge/agents/openai.yaml +3 -3
- package/skills/browser-bridge/references/interaction.md +34 -11
- package/skills/browser-bridge/references/patch-workflow.md +3 -0
- package/skills/browser-bridge/references/protocol.md +127 -71
- package/skills/browser-bridge/references/tailwind.md +12 -11
- package/skills/browser-bridge/references/token-efficiency.md +23 -22
- package/skills/browser-bridge/references/ui-workflows.md +8 -0
- package/CHANGELOG.md +0 -55
- package/assets/banner.jpg +0 -0
- package/assets/logo.png +0 -0
- package/assets/logo.svg +0 -65
- package/docs/api-reference.md +0 -157
- package/docs/cli-guide.md +0 -128
- package/docs/index.md +0 -25
- package/docs/manual-setup.md +0 -140
- package/docs/mcp-vs-cli.md +0 -258
- package/docs/publishing.md +0 -114
- package/docs/quickstart.md +0 -104
- package/docs/troubleshooting.md +0 -59
- package/docs/usage-scenarios.md +0 -136
- package/manifest.json +0 -52
- package/packages/extension/assets/icon-128.png +0 -0
- package/packages/extension/assets/icon-16.png +0 -0
- package/packages/extension/assets/icon-32.png +0 -0
- package/packages/extension/assets/icon-48.png +0 -0
- package/packages/extension/src/background-helpers.js +0 -459
- package/packages/extension/src/background-routing.js +0 -91
- package/packages/extension/src/background.js +0 -3227
- package/packages/extension/src/content-script-helpers.js +0 -281
- package/packages/extension/src/content-script.js +0 -1977
- package/packages/extension/src/debugger-coordinator.js +0 -188
- package/packages/extension/src/sidepanel-helpers.js +0 -102
- package/packages/extension/ui/offscreen.html +0 -6
- package/packages/extension/ui/offscreen.js +0 -61
- package/packages/extension/ui/popup.html +0 -35
- package/packages/extension/ui/popup.js +0 -279
- package/packages/extension/ui/sidepanel.html +0 -102
- package/packages/extension/ui/sidepanel.js +0 -1854
- package/packages/extension/ui/ui.css +0 -1159
|
@@ -1,1977 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
(() => {
|
|
4
|
-
const contentScriptGlobal = /** @type {typeof globalThis & { __chromeCodexBridgeContentScriptLoaded?: boolean }} */ (globalThis);
|
|
5
|
-
if (contentScriptGlobal.__chromeCodexBridgeContentScriptLoaded) {
|
|
6
|
-
return;
|
|
7
|
-
}
|
|
8
|
-
contentScriptGlobal.__chromeCodexBridgeContentScriptLoaded = true;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* @typedef {{
|
|
12
|
-
* maxNodes: number,
|
|
13
|
-
* maxDepth: number,
|
|
14
|
-
* textBudget: number,
|
|
15
|
-
* includeBbox: boolean,
|
|
16
|
-
* attributeAllowlist: string[]
|
|
17
|
-
* }} Budget
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* @typedef {{
|
|
22
|
-
* selector: string,
|
|
23
|
-
* withinRef: string | null,
|
|
24
|
-
* budget: Budget
|
|
25
|
-
* }} NormalizedDomQuery
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* @typedef {{
|
|
30
|
-
* elementRef: string,
|
|
31
|
-
* tag: string,
|
|
32
|
-
* role: string | null,
|
|
33
|
-
* name: string | null,
|
|
34
|
-
* textExcerpt: string,
|
|
35
|
-
* attrs: Record<string, string | null>,
|
|
36
|
-
* bbox?: { x: number, y: number, width: number, height: number }
|
|
37
|
-
* }} NodeSummary
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
const elementRegistry = new Map();
|
|
41
|
-
const reverseRegistry = new WeakMap();
|
|
42
|
-
const patchRegistry = new Map();
|
|
43
|
-
const MAX_REGISTRY_SIZE = 5000;
|
|
44
|
-
const MAX_PATCH_REGISTRY_SIZE = 2000;
|
|
45
|
-
let registryPruned = false;
|
|
46
|
-
const contentHelpers = /** @type {typeof globalThis & { __BBX_CONTENT_HELPERS__?: {
|
|
47
|
-
NON_TEXT_INPUT_TYPES: Set<string>,
|
|
48
|
-
applyBudget: (options?: Record<string, any>) => Budget,
|
|
49
|
-
clamp: (value: number | string | null | undefined, minimum: number, maximum: number) => number,
|
|
50
|
-
escapeTailwindSelector: (selector: string) => string,
|
|
51
|
-
extractElementText: (element: Element) => string,
|
|
52
|
-
getImplicitRole: (element: Element) => string,
|
|
53
|
-
getImplicitRoleSelector: (role: string) => string,
|
|
54
|
-
toRect: (rect: DOMRect | DOMRectReadOnly) => { x: number, y: number, width: number, height: number },
|
|
55
|
-
truncateText: (value: string, budget: number) => { value: string, truncated: boolean, omitted: number }
|
|
56
|
-
} }} */ (globalThis).__BBX_CONTENT_HELPERS__;
|
|
57
|
-
if (!contentHelpers) {
|
|
58
|
-
throw new Error('Browser Bridge content-script helpers must load before content-script.js.');
|
|
59
|
-
}
|
|
60
|
-
const {
|
|
61
|
-
NON_TEXT_INPUT_TYPES,
|
|
62
|
-
applyBudget,
|
|
63
|
-
clamp,
|
|
64
|
-
escapeTailwindSelector,
|
|
65
|
-
extractElementText,
|
|
66
|
-
getImplicitRole,
|
|
67
|
-
getImplicitRoleSelector,
|
|
68
|
-
toRect,
|
|
69
|
-
truncateText
|
|
70
|
-
} = contentHelpers;
|
|
71
|
-
|
|
72
|
-
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
73
|
-
if (message?.type === 'bridge.ping') {
|
|
74
|
-
sendResponse({ ok: true });
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (message?.type !== 'bridge.execute') {
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
const result = handleCommand(message.method, message.params);
|
|
84
|
-
Promise.resolve(result).then(sendResponse).catch((err) => {
|
|
85
|
-
sendResponse({ error: err instanceof Error ? err.message : String(err) });
|
|
86
|
-
});
|
|
87
|
-
} catch (error) {
|
|
88
|
-
sendResponse({ error: error instanceof Error ? error.message : String(error) });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return true;
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Dispatch a bridge method within the page context.
|
|
96
|
-
*
|
|
97
|
-
* @param {string} method
|
|
98
|
-
* @param {Record<string, any>} params
|
|
99
|
-
* @returns {unknown}
|
|
100
|
-
*/
|
|
101
|
-
function handleCommand(method, params) {
|
|
102
|
-
switch (method) {
|
|
103
|
-
case 'page.get_state':
|
|
104
|
-
return getPageState();
|
|
105
|
-
case 'page.get_storage':
|
|
106
|
-
return getStorageData(params);
|
|
107
|
-
case 'page.get_text':
|
|
108
|
-
return getFullPageText(params);
|
|
109
|
-
case 'navigation.navigate':
|
|
110
|
-
case 'navigation.reload':
|
|
111
|
-
case 'navigation.go_back':
|
|
112
|
-
case 'navigation.go_forward':
|
|
113
|
-
throw new Error(`Unsupported content-script method ${method}`);
|
|
114
|
-
case 'dom.query':
|
|
115
|
-
return domQuery(params);
|
|
116
|
-
case 'dom.describe':
|
|
117
|
-
return describeElement(resolveElementRefFromParams(params));
|
|
118
|
-
case 'dom.get_text':
|
|
119
|
-
return getText(resolveElementRefFromParams(params), params.textBudget);
|
|
120
|
-
case 'dom.get_attributes':
|
|
121
|
-
return getAttributes(resolveElementRefFromParams(params), params.attributes ?? []);
|
|
122
|
-
case 'dom.wait_for':
|
|
123
|
-
return waitForDom(params);
|
|
124
|
-
case 'dom.find_by_text':
|
|
125
|
-
return findByText(params);
|
|
126
|
-
case 'dom.find_by_role':
|
|
127
|
-
return findByRole(params);
|
|
128
|
-
case 'dom.get_html':
|
|
129
|
-
return getHtml({
|
|
130
|
-
...params,
|
|
131
|
-
elementRef: resolveElementRefFromParams(params)
|
|
132
|
-
});
|
|
133
|
-
case 'layout.get_box_model':
|
|
134
|
-
return getBoxModel(resolveElementRefFromParams(params));
|
|
135
|
-
case 'layout.hit_test':
|
|
136
|
-
return hitTest(params.x, params.y);
|
|
137
|
-
case 'styles.get_computed':
|
|
138
|
-
return getComputedStyles(resolveElementRefFromParams(params), params.properties);
|
|
139
|
-
case 'styles.get_matched_rules':
|
|
140
|
-
return getMatchedRules(resolveElementRefFromParams(params));
|
|
141
|
-
case 'viewport.scroll':
|
|
142
|
-
return scrollViewport(params);
|
|
143
|
-
case 'input.click':
|
|
144
|
-
return clickTarget(params);
|
|
145
|
-
case 'input.focus':
|
|
146
|
-
return focusTarget(params);
|
|
147
|
-
case 'input.type':
|
|
148
|
-
return typeIntoTarget(params);
|
|
149
|
-
case 'input.press_key':
|
|
150
|
-
return pressKeyTarget(params);
|
|
151
|
-
case 'input.set_checked':
|
|
152
|
-
return setCheckedTarget(params);
|
|
153
|
-
case 'input.select_option':
|
|
154
|
-
return selectOptionTarget(params);
|
|
155
|
-
case 'input.hover':
|
|
156
|
-
return hoverTarget(params);
|
|
157
|
-
case 'input.drag':
|
|
158
|
-
return dragTarget(params);
|
|
159
|
-
case 'input.scroll_into_view':
|
|
160
|
-
return scrollIntoViewTarget(params);
|
|
161
|
-
case 'patch.apply_styles':
|
|
162
|
-
return applyStylePatch(params);
|
|
163
|
-
case 'patch.apply_dom':
|
|
164
|
-
return applyDomPatch(params);
|
|
165
|
-
case 'patch.list':
|
|
166
|
-
return listPatches();
|
|
167
|
-
case 'patch.rollback':
|
|
168
|
-
return rollbackPatch(params.patchId);
|
|
169
|
-
case 'patch.commit_session_baseline':
|
|
170
|
-
return { committed: true };
|
|
171
|
-
case 'screenshot.capture_element':
|
|
172
|
-
return getElementRect(resolveElementRefFromParams(params));
|
|
173
|
-
case 'screenshot.capture_full_page':
|
|
174
|
-
return getFullPageDimensions();
|
|
175
|
-
default:
|
|
176
|
-
throw new Error(`Unsupported method ${method}`);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Return lightweight page state useful for browser automation decisions.
|
|
182
|
-
*
|
|
183
|
-
* @returns {{
|
|
184
|
-
* url: string,
|
|
185
|
-
* origin: string,
|
|
186
|
-
* title: string,
|
|
187
|
-
* readyState: DocumentReadyState,
|
|
188
|
-
* focused: boolean,
|
|
189
|
-
* viewport: { width: number, height: number, devicePixelRatio: number },
|
|
190
|
-
* scroll: { x: number, y: number, maxX: number, maxY: number },
|
|
191
|
-
* activeElement: NodeSummary | null,
|
|
192
|
-
* selection: { value: string, truncated: boolean, omitted: number },
|
|
193
|
-
* hints: { tailwind: boolean }
|
|
194
|
-
* }}
|
|
195
|
-
*/
|
|
196
|
-
function getPageState() {
|
|
197
|
-
const scrollingElement =
|
|
198
|
-
document.scrollingElement || document.documentElement || document.body;
|
|
199
|
-
const selection = document.getSelection?.()?.toString() || '';
|
|
200
|
-
|
|
201
|
-
return {
|
|
202
|
-
url: window.location.href,
|
|
203
|
-
origin: window.location.origin,
|
|
204
|
-
title: document.title,
|
|
205
|
-
readyState: document.readyState,
|
|
206
|
-
focused: document.hasFocus(),
|
|
207
|
-
viewport: {
|
|
208
|
-
width: window.innerWidth,
|
|
209
|
-
height: window.innerHeight,
|
|
210
|
-
devicePixelRatio: window.devicePixelRatio || 1,
|
|
211
|
-
},
|
|
212
|
-
scroll: {
|
|
213
|
-
x: window.scrollX,
|
|
214
|
-
y: window.scrollY,
|
|
215
|
-
maxX: Math.max(
|
|
216
|
-
0,
|
|
217
|
-
(scrollingElement?.scrollWidth || document.documentElement.scrollWidth || 0) -
|
|
218
|
-
window.innerWidth,
|
|
219
|
-
),
|
|
220
|
-
maxY: Math.max(
|
|
221
|
-
0,
|
|
222
|
-
(scrollingElement?.scrollHeight || document.documentElement.scrollHeight || 0) -
|
|
223
|
-
window.innerHeight,
|
|
224
|
-
),
|
|
225
|
-
},
|
|
226
|
-
activeElement:
|
|
227
|
-
document.activeElement instanceof Element
|
|
228
|
-
? summarizeNode(
|
|
229
|
-
document.activeElement,
|
|
230
|
-
['id', 'class', 'name', 'type', 'href', 'role'],
|
|
231
|
-
120,
|
|
232
|
-
true,
|
|
233
|
-
).node
|
|
234
|
-
: null,
|
|
235
|
-
selection: truncateText(selection.trim(), 200),
|
|
236
|
-
hints: detectPageHints(),
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Detect CSS frameworks and page characteristics for agent guidance.
|
|
242
|
-
* Lightweight - only checks a few DOM/stylesheet signals.
|
|
243
|
-
*
|
|
244
|
-
* @returns {{ tailwind: boolean }}
|
|
245
|
-
*/
|
|
246
|
-
function detectPageHints() {
|
|
247
|
-
let tailwind = false;
|
|
248
|
-
// Check for Tailwind's characteristic patterns:
|
|
249
|
-
// 1. A <style> or <link> with tailwind-related id/href
|
|
250
|
-
// 2. Elements using Tailwind's arbitrary-value syntax: class="...-[...]"
|
|
251
|
-
// 3. Tailwind's reset styles injected by @tailwind base
|
|
252
|
-
try {
|
|
253
|
-
tailwind = Boolean(
|
|
254
|
-
document.querySelector(
|
|
255
|
-
'link[href*="tailwind"], style[id*="tailwind"], script[src*="tailwindcss"]'
|
|
256
|
-
)
|
|
257
|
-
);
|
|
258
|
-
if (!tailwind) {
|
|
259
|
-
// Check for Tailwind's characteristic class patterns on a sample of elements
|
|
260
|
-
const sample = document.querySelectorAll('[class]');
|
|
261
|
-
const twPattern = /\b(?:flex|grid|bg-|text-|p[xytblr]?-|m[xytblr]?-|w-|h-|rounded|shadow|border)-/;
|
|
262
|
-
for (let i = 0; i < Math.min(sample.length, 30); i++) {
|
|
263
|
-
const cls = sample[i].className;
|
|
264
|
-
if (typeof cls === 'string' && twPattern.test(cls)) {
|
|
265
|
-
tailwind = true;
|
|
266
|
-
break;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
} catch {
|
|
271
|
-
// Ignore - cross-origin or other DOM access issues
|
|
272
|
-
}
|
|
273
|
-
return { tailwind };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Extract the full visible text content of the page.
|
|
278
|
-
*
|
|
279
|
-
* @param {Record<string, any>} params
|
|
280
|
-
* @returns {{ value: string, truncated: boolean, omitted: number, length: number }}
|
|
281
|
-
*/
|
|
282
|
-
function getFullPageText(params) {
|
|
283
|
-
const budget = Number(params.textBudget) || 8000;
|
|
284
|
-
const body = document.body;
|
|
285
|
-
if (!body) {
|
|
286
|
-
return { value: '', truncated: false, omitted: 0, length: 0 };
|
|
287
|
-
}
|
|
288
|
-
const raw = (body.innerText || body.textContent || '').trim();
|
|
289
|
-
const result = truncateText(raw, budget);
|
|
290
|
-
return {
|
|
291
|
-
value: result.value,
|
|
292
|
-
truncated: result.truncated,
|
|
293
|
-
omitted: result.omitted,
|
|
294
|
-
length: raw.length
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Perform a bounded breadth-first DOM summary rooted at a selector or existing
|
|
300
|
-
* element reference.
|
|
301
|
-
*
|
|
302
|
-
* @param {Record<string, any>} params
|
|
303
|
-
* @returns {{ nodes: NodeSummary[], revision: number, truncated?: boolean, registrySize: number, _registryPruned?: boolean }}
|
|
304
|
-
*/
|
|
305
|
-
function domQuery(params) {
|
|
306
|
-
const query = normalizeDomQuery(params);
|
|
307
|
-
const root = query.withinRef
|
|
308
|
-
? getRequiredElement(query.withinRef)
|
|
309
|
-
: document.querySelector(query.selector);
|
|
310
|
-
if (!root) {
|
|
311
|
-
return { nodes: [], revision: getDocumentRevision(), registrySize: elementRegistry.size };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** @type {NodeSummary[]} */
|
|
315
|
-
const nodes = [];
|
|
316
|
-
let remaining = query.budget.textBudget;
|
|
317
|
-
/** @type {Array<{ element: Element, depth: number }>} */
|
|
318
|
-
const queue = [{ element: root, depth: 0 }];
|
|
319
|
-
|
|
320
|
-
while (
|
|
321
|
-
queue.length &&
|
|
322
|
-
nodes.length < query.budget.maxNodes &&
|
|
323
|
-
remaining > 0
|
|
324
|
-
) {
|
|
325
|
-
const next = queue.shift();
|
|
326
|
-
if (!next) {
|
|
327
|
-
continue;
|
|
328
|
-
}
|
|
329
|
-
const { element, depth } = next;
|
|
330
|
-
if (depth > query.budget.maxDepth) {
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const summary = summarizeNode(
|
|
335
|
-
element,
|
|
336
|
-
query.budget.attributeAllowlist,
|
|
337
|
-
remaining,
|
|
338
|
-
query.budget.includeBbox,
|
|
339
|
-
);
|
|
340
|
-
remaining -= summary.textLength;
|
|
341
|
-
nodes.push(summary.node);
|
|
342
|
-
|
|
343
|
-
for (const child of element.children) {
|
|
344
|
-
queue.push({ element: child, depth: depth + 1 });
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const pruned = registryPruned;
|
|
349
|
-
registryPruned = false;
|
|
350
|
-
return {
|
|
351
|
-
nodes,
|
|
352
|
-
revision: getDocumentRevision(),
|
|
353
|
-
truncated: nodes.length >= query.budget.maxNodes || remaining <= 0,
|
|
354
|
-
registrySize: elementRegistry.size,
|
|
355
|
-
...(pruned ? { _registryPruned: true } : {}),
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Create a compact, token-efficient summary for a single element.
|
|
361
|
-
*
|
|
362
|
-
* @param {Element} element
|
|
363
|
-
* @param {string[]} attributeAllowlist
|
|
364
|
-
* @param {number} remainingText
|
|
365
|
-
* @param {boolean} includeBbox
|
|
366
|
-
* @returns {{ textLength: number, node: NodeSummary }}
|
|
367
|
-
*/
|
|
368
|
-
function summarizeNode(element, attributeAllowlist, remainingText, includeBbox) {
|
|
369
|
-
const elementRef = rememberElement(element);
|
|
370
|
-
const text = truncateText(
|
|
371
|
-
extractElementText(element),
|
|
372
|
-
Math.min(Math.max(0, remainingText), 160),
|
|
373
|
-
);
|
|
374
|
-
return {
|
|
375
|
-
textLength: text.value.length,
|
|
376
|
-
node: {
|
|
377
|
-
elementRef,
|
|
378
|
-
tag: element.tagName.toLowerCase(),
|
|
379
|
-
role: element.getAttribute('role'),
|
|
380
|
-
name:
|
|
381
|
-
element.getAttribute('aria-label') ||
|
|
382
|
-
element.getAttribute('name') ||
|
|
383
|
-
null,
|
|
384
|
-
textExcerpt: text.value,
|
|
385
|
-
attrs: summarizeAttributes(element, attributeAllowlist),
|
|
386
|
-
...(includeBbox ? { bbox: toRect(element.getBoundingClientRect()) } : {}),
|
|
387
|
-
},
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Extract only allowlisted attributes from an element.
|
|
393
|
-
*
|
|
394
|
-
* @param {Element} element
|
|
395
|
-
* @param {string[]} attributeAllowlist
|
|
396
|
-
* @returns {Record<string, string | null>}
|
|
397
|
-
*/
|
|
398
|
-
function summarizeAttributes(element, attributeAllowlist) {
|
|
399
|
-
if (!attributeAllowlist.length) {
|
|
400
|
-
return {};
|
|
401
|
-
}
|
|
402
|
-
return attributeAllowlist.reduce((accumulator, attribute) => {
|
|
403
|
-
if (element.hasAttribute(attribute)) {
|
|
404
|
-
accumulator[attribute] = element.getAttribute(attribute);
|
|
405
|
-
}
|
|
406
|
-
return accumulator;
|
|
407
|
-
}, /** @type {Record<string, string | null>} */ ({}));
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Describe a known element reference.
|
|
412
|
-
*
|
|
413
|
-
* @param {string} elementRef
|
|
414
|
-
* @returns {{ elementRef: string, tag: string, text: { value: string, truncated: boolean, omitted: number }, bbox: { x: number, y: number, width: number, height: number } }}
|
|
415
|
-
*/
|
|
416
|
-
function describeElement(elementRef) {
|
|
417
|
-
const element = getRequiredElement(elementRef);
|
|
418
|
-
return {
|
|
419
|
-
elementRef,
|
|
420
|
-
tag: element.tagName.toLowerCase(),
|
|
421
|
-
text: truncateText(extractElementText(element), 300),
|
|
422
|
-
bbox: toRect(element.getBoundingClientRect()),
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Return bounded text content for an element.
|
|
428
|
-
*
|
|
429
|
-
* @param {string} elementRef
|
|
430
|
-
* @param {number} [budget=600]
|
|
431
|
-
* @returns {{ value: string, truncated: boolean, omitted: number }}
|
|
432
|
-
*/
|
|
433
|
-
function getText(elementRef, budget = 600) {
|
|
434
|
-
const element = /** @type {HTMLElement} */ (getRequiredElement(elementRef));
|
|
435
|
-
return truncateText(
|
|
436
|
-
(element.innerText || element.textContent || '').trim(),
|
|
437
|
-
budget,
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Read a selected set of attributes from an element reference.
|
|
443
|
-
*
|
|
444
|
-
* @param {string} elementRef
|
|
445
|
-
* @param {string[]} attributes
|
|
446
|
-
* @returns {Record<string, string | null>}
|
|
447
|
-
*/
|
|
448
|
-
function getAttributes(elementRef, attributes) {
|
|
449
|
-
const element = getRequiredElement(elementRef);
|
|
450
|
-
return attributes.reduce((accumulator, attribute) => {
|
|
451
|
-
if (element.hasAttribute(attribute)) {
|
|
452
|
-
accumulator[attribute] = element.getAttribute(attribute);
|
|
453
|
-
}
|
|
454
|
-
return accumulator;
|
|
455
|
-
}, /** @type {Record<string, string | null>} */ ({}));
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Return the box model rectangle for an element.
|
|
460
|
-
*
|
|
461
|
-
* @param {string} elementRef
|
|
462
|
-
* @returns {{ x: number, y: number, width: number, height: number }}
|
|
463
|
-
*/
|
|
464
|
-
function getBoxModel(elementRef) {
|
|
465
|
-
return toRect(getRequiredElement(elementRef).getBoundingClientRect());
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* Resolve the topmost element at a viewport coordinate into a compact summary.
|
|
470
|
-
*
|
|
471
|
-
* @param {number} x
|
|
472
|
-
* @param {number} y
|
|
473
|
-
* @returns {NodeSummary | null}
|
|
474
|
-
*/
|
|
475
|
-
function hitTest(x, y) {
|
|
476
|
-
const element = document.elementFromPoint(x, y);
|
|
477
|
-
return element ? summarizeNode(element, ['id', 'class'], 120, true).node : null;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* Read computed CSS properties for an element reference.
|
|
482
|
-
*
|
|
483
|
-
* @param {string} elementRef
|
|
484
|
-
* @param {string[]} [properties=[]]
|
|
485
|
-
* @returns {Record<string, string>}
|
|
486
|
-
*/
|
|
487
|
-
function getComputedStyles(elementRef, properties = []) {
|
|
488
|
-
const styles = window.getComputedStyle(getRequiredElement(elementRef));
|
|
489
|
-
const requested = properties.length
|
|
490
|
-
? properties
|
|
491
|
-
: ['display', 'position', 'width', 'height', 'color'];
|
|
492
|
-
return requested.reduce((accumulator, property) => {
|
|
493
|
-
accumulator[property] = styles.getPropertyValue(property);
|
|
494
|
-
return accumulator;
|
|
495
|
-
}, /** @type {Record<string, string>} */ ({}));
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Return simple matched-rule context for an element.
|
|
500
|
-
*
|
|
501
|
-
* @param {string} elementRef
|
|
502
|
-
* @returns {{ elementRef: string, classes: string[], inlineStyle: string }}
|
|
503
|
-
*/
|
|
504
|
-
function getMatchedRules(elementRef) {
|
|
505
|
-
const element = getRequiredElement(elementRef);
|
|
506
|
-
return {
|
|
507
|
-
elementRef,
|
|
508
|
-
classes: [...element.classList],
|
|
509
|
-
inlineStyle: element.getAttribute('style') || '',
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Scroll the window or a specific scrollable element.
|
|
515
|
-
*
|
|
516
|
-
* @param {Record<string, any>} params
|
|
517
|
-
* @returns {{
|
|
518
|
-
* scrolled: boolean,
|
|
519
|
-
* target: string,
|
|
520
|
-
* x: number,
|
|
521
|
-
* y: number,
|
|
522
|
-
* top: number,
|
|
523
|
-
* left: number,
|
|
524
|
-
* behavior: 'auto' | 'smooth',
|
|
525
|
-
* relative: boolean
|
|
526
|
-
* }}
|
|
527
|
-
*/
|
|
528
|
-
function scrollViewport(params) {
|
|
529
|
-
const top = Number(params.top) || 0;
|
|
530
|
-
const left = Number(params.left) || 0;
|
|
531
|
-
const behavior = params.behavior === 'smooth' ? 'smooth' : 'auto';
|
|
532
|
-
const relative = Boolean(params.relative);
|
|
533
|
-
|
|
534
|
-
if (params.target?.elementRef || params.target?.selector) {
|
|
535
|
-
const element = resolveTarget(params.target);
|
|
536
|
-
const scrollTarget = getScrollableElementTarget(element);
|
|
537
|
-
if (relative) {
|
|
538
|
-
scrollTarget.scrollBy({
|
|
539
|
-
top,
|
|
540
|
-
left,
|
|
541
|
-
behavior,
|
|
542
|
-
});
|
|
543
|
-
} else {
|
|
544
|
-
scrollTarget.scrollTo({
|
|
545
|
-
top,
|
|
546
|
-
left,
|
|
547
|
-
behavior,
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
return {
|
|
552
|
-
scrolled: true,
|
|
553
|
-
target: rememberElement(scrollTarget),
|
|
554
|
-
x: scrollTarget.scrollLeft,
|
|
555
|
-
y: scrollTarget.scrollTop,
|
|
556
|
-
top: scrollTarget.scrollTop,
|
|
557
|
-
left: scrollTarget.scrollLeft,
|
|
558
|
-
behavior,
|
|
559
|
-
relative,
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
if (relative) {
|
|
564
|
-
window.scrollBy({
|
|
565
|
-
top,
|
|
566
|
-
left,
|
|
567
|
-
behavior,
|
|
568
|
-
});
|
|
569
|
-
} else {
|
|
570
|
-
window.scrollTo({
|
|
571
|
-
top,
|
|
572
|
-
left,
|
|
573
|
-
behavior,
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
return {
|
|
578
|
-
scrolled: true,
|
|
579
|
-
target: 'window',
|
|
580
|
-
x: window.scrollX,
|
|
581
|
-
y: window.scrollY,
|
|
582
|
-
top: window.scrollY,
|
|
583
|
-
left: window.scrollX,
|
|
584
|
-
behavior,
|
|
585
|
-
relative,
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Trigger a click-like interaction on a target element.
|
|
591
|
-
*
|
|
592
|
-
* @param {Record<string, any>} params
|
|
593
|
-
* @returns {{ elementRef: string, clicked: boolean, button: string, clickCount: number }}
|
|
594
|
-
*/
|
|
595
|
-
function clickTarget(params) {
|
|
596
|
-
const element = resolveTarget(params.target);
|
|
597
|
-
const button = normalizeMouseButton(params.button);
|
|
598
|
-
const clickCount = clamp(params.clickCount ?? 1, 1, 2);
|
|
599
|
-
const modifiers = normalizeModifierState(params.modifiers);
|
|
600
|
-
const point = getViewportPoint(element);
|
|
601
|
-
|
|
602
|
-
scrollTargetIntoView(element);
|
|
603
|
-
focusElement(element);
|
|
604
|
-
dispatchMouseEvent(element, 'mousemove', point, button, 0, modifiers);
|
|
605
|
-
dispatchMouseEvent(element, 'mousedown', point, button, clickCount, modifiers);
|
|
606
|
-
dispatchMouseEvent(element, 'mouseup', point, button, clickCount, modifiers);
|
|
607
|
-
|
|
608
|
-
if (button === 'left') {
|
|
609
|
-
if (element instanceof HTMLElement) {
|
|
610
|
-
element.click();
|
|
611
|
-
if (clickCount === 2) {
|
|
612
|
-
element.click();
|
|
613
|
-
dispatchMouseEvent(element, 'dblclick', point, button, clickCount, modifiers);
|
|
614
|
-
}
|
|
615
|
-
} else {
|
|
616
|
-
dispatchMouseEvent(element, 'click', point, button, clickCount, modifiers);
|
|
617
|
-
if (clickCount === 2) {
|
|
618
|
-
dispatchMouseEvent(element, 'dblclick', point, button, clickCount, modifiers);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
} else if (button === 'right') {
|
|
622
|
-
dispatchMouseEvent(element, 'contextmenu', point, button, clickCount, modifiers);
|
|
623
|
-
} else {
|
|
624
|
-
dispatchMouseEvent(element, 'auxclick', point, button, clickCount, modifiers);
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
return {
|
|
628
|
-
elementRef: rememberElement(element),
|
|
629
|
-
clicked: true,
|
|
630
|
-
button,
|
|
631
|
-
clickCount,
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/**
|
|
636
|
-
* Focus one element so follow-up keyboard input lands consistently.
|
|
637
|
-
*
|
|
638
|
-
* @param {Record<string, any>} params
|
|
639
|
-
* @returns {{ elementRef: string, focused: boolean, tag: string }}
|
|
640
|
-
*/
|
|
641
|
-
function focusTarget(params) {
|
|
642
|
-
const element = resolveTarget(params.target);
|
|
643
|
-
scrollTargetIntoView(element);
|
|
644
|
-
const focused = focusElement(element);
|
|
645
|
-
return {
|
|
646
|
-
elementRef: rememberElement(element),
|
|
647
|
-
focused: isElementFocused(element) || isElementFocused(focused),
|
|
648
|
-
tag: focused.tagName.toLowerCase(),
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Type text into an editable control or contenteditable region.
|
|
654
|
-
*
|
|
655
|
-
* @param {Record<string, any>} params
|
|
656
|
-
* @returns {{ elementRef: string, typed: number, value: string }}
|
|
657
|
-
*/
|
|
658
|
-
function typeIntoTarget(params) {
|
|
659
|
-
const element = resolveTarget(params.target);
|
|
660
|
-
const editable = getEditableTarget(element);
|
|
661
|
-
if (!editable) {
|
|
662
|
-
throw new Error('Target is not an editable control.');
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
scrollTargetIntoView(editable);
|
|
666
|
-
focusElement(editable);
|
|
667
|
-
|
|
668
|
-
if (params.clear) {
|
|
669
|
-
clearEditableValue(editable);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const text = String(params.text ?? '');
|
|
673
|
-
for (const character of text) {
|
|
674
|
-
runKeyAction(editable, character, params.modifiers);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
if (params.submit) {
|
|
678
|
-
submitElement(editable);
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
return {
|
|
682
|
-
elementRef: rememberElement(editable),
|
|
683
|
-
typed: text.length,
|
|
684
|
-
value: getEditableValue(editable),
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
/**
|
|
689
|
-
* Send one keyboard interaction to the currently focused or targeted element.
|
|
690
|
-
*
|
|
691
|
-
* @param {Record<string, any>} params
|
|
692
|
-
* @returns {{ elementRef: string | null, key: string, handled: boolean }}
|
|
693
|
-
*/
|
|
694
|
-
function pressKeyTarget(params) {
|
|
695
|
-
const target =
|
|
696
|
-
params.target?.elementRef || params.target?.selector
|
|
697
|
-
? resolveTarget(params.target)
|
|
698
|
-
: document.activeElement instanceof Element
|
|
699
|
-
? document.activeElement
|
|
700
|
-
: document.body;
|
|
701
|
-
scrollTargetIntoView(target);
|
|
702
|
-
focusElement(target);
|
|
703
|
-
const key = String(params.key ?? '');
|
|
704
|
-
if (!key) {
|
|
705
|
-
throw new Error('A key is required.');
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
const result = runKeyAction(target, key, params.modifiers);
|
|
709
|
-
return {
|
|
710
|
-
elementRef:
|
|
711
|
-
result.target instanceof Element
|
|
712
|
-
? rememberElement(result.target)
|
|
713
|
-
: null,
|
|
714
|
-
key: result.key,
|
|
715
|
-
handled: result.handled,
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
/**
|
|
720
|
-
* Toggle a checkbox-like control to a desired checked state.
|
|
721
|
-
*
|
|
722
|
-
* @param {Record<string, any>} params
|
|
723
|
-
* @returns {{ elementRef: string, checked: boolean, changed: boolean, type: string }}
|
|
724
|
-
*/
|
|
725
|
-
function setCheckedTarget(params) {
|
|
726
|
-
const element = resolveCheckableTarget(params.target);
|
|
727
|
-
const checked = params.checked !== false;
|
|
728
|
-
if (element.type === 'radio' && !checked && element.checked) {
|
|
729
|
-
throw new Error('Radio inputs cannot be unchecked directly.');
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
scrollTargetIntoView(element);
|
|
733
|
-
focusElement(element);
|
|
734
|
-
const changed = element.checked !== checked;
|
|
735
|
-
if (changed) {
|
|
736
|
-
element.click();
|
|
737
|
-
if (element.checked !== checked) {
|
|
738
|
-
element.checked = checked;
|
|
739
|
-
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
740
|
-
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
return {
|
|
745
|
-
elementRef: rememberElement(element),
|
|
746
|
-
checked: element.checked,
|
|
747
|
-
changed,
|
|
748
|
-
type: element.type,
|
|
749
|
-
};
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
/**
|
|
753
|
-
* Select options in a native select control by value, label, or index.
|
|
754
|
-
*
|
|
755
|
-
* @param {Record<string, any>} params
|
|
756
|
-
* @returns {{ elementRef: string, changed: boolean, multiple: boolean, selectedValues: string[] }}
|
|
757
|
-
*/
|
|
758
|
-
function selectOptionTarget(params) {
|
|
759
|
-
const element = resolveSelectTarget(params.target);
|
|
760
|
-
const values = Array.isArray(params.values)
|
|
761
|
-
? params.values.filter((value) => typeof value === 'string')
|
|
762
|
-
: [];
|
|
763
|
-
const labels = Array.isArray(params.labels)
|
|
764
|
-
? params.labels.filter((label) => typeof label === 'string')
|
|
765
|
-
: [];
|
|
766
|
-
const indexes = Array.isArray(params.indexes)
|
|
767
|
-
? params.indexes
|
|
768
|
-
.map((index) => Number(index))
|
|
769
|
-
.filter((index) => Number.isInteger(index) && index >= 0)
|
|
770
|
-
: [];
|
|
771
|
-
|
|
772
|
-
if (!values.length && !labels.length && !indexes.length) {
|
|
773
|
-
throw new Error('At least one option selector is required.');
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
scrollTargetIntoView(element);
|
|
777
|
-
focusElement(element);
|
|
778
|
-
|
|
779
|
-
const options = [...element.options];
|
|
780
|
-
const selectedBefore = getSelectedOptionValues(element);
|
|
781
|
-
const matchingOptions = options.filter((option, index) => {
|
|
782
|
-
return (
|
|
783
|
-
values.includes(option.value) ||
|
|
784
|
-
labels.includes(option.label) ||
|
|
785
|
-
labels.includes(option.text.trim()) ||
|
|
786
|
-
indexes.includes(index)
|
|
787
|
-
);
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
if (!matchingOptions.length) {
|
|
791
|
-
throw new Error('No matching option found.');
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
if (element.multiple) {
|
|
795
|
-
const matchedValues = new Set(matchingOptions.map((option) => option.value));
|
|
796
|
-
for (const option of options) {
|
|
797
|
-
option.selected = matchedValues.has(option.value);
|
|
798
|
-
}
|
|
799
|
-
} else {
|
|
800
|
-
element.value = matchingOptions[0].value;
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
const selectedAfter = getSelectedOptionValues(element);
|
|
804
|
-
const changed = !areStringArraysEqual(selectedBefore, selectedAfter);
|
|
805
|
-
if (changed) {
|
|
806
|
-
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
807
|
-
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
return {
|
|
811
|
-
elementRef: rememberElement(element),
|
|
812
|
-
changed,
|
|
813
|
-
multiple: element.multiple,
|
|
814
|
-
selectedValues: selectedAfter,
|
|
815
|
-
};
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
/**
|
|
819
|
-
* Apply a reversible inline style patch to an element or selector target.
|
|
820
|
-
*
|
|
821
|
-
* @param {Record<string, any>} params
|
|
822
|
-
* @returns {{ patchId: string, applied: boolean, verified?: Record<string, string>, elementRef?: string }}
|
|
823
|
-
*/
|
|
824
|
-
function applyStylePatch(params) {
|
|
825
|
-
const element = /** @type {HTMLElement} */ (resolveTarget(params.target));
|
|
826
|
-
const patchId = params.patchId || `patch_${crypto.randomUUID()}`;
|
|
827
|
-
/** @type {Record<string, string>} */
|
|
828
|
-
const previous = {};
|
|
829
|
-
for (const [property, value] of Object.entries(params.declarations || {})) {
|
|
830
|
-
previous[property] = element.style.getPropertyValue(property);
|
|
831
|
-
element.style.setProperty(
|
|
832
|
-
property,
|
|
833
|
-
value,
|
|
834
|
-
params.important ? 'important' : '',
|
|
835
|
-
);
|
|
836
|
-
}
|
|
837
|
-
pruneRegistry(patchRegistry, MAX_PATCH_REGISTRY_SIZE);
|
|
838
|
-
const elementRef = rememberElement(element);
|
|
839
|
-
patchRegistry.set(patchId, {
|
|
840
|
-
kind: 'style',
|
|
841
|
-
elementRef,
|
|
842
|
-
previous,
|
|
843
|
-
});
|
|
844
|
-
const result = { patchId, applied: true };
|
|
845
|
-
if (params.verify) {
|
|
846
|
-
const computed = globalThis.getComputedStyle(element);
|
|
847
|
-
/** @type {Record<string, string>} */
|
|
848
|
-
const verified = {};
|
|
849
|
-
for (const property of Object.keys(params.declarations || {})) {
|
|
850
|
-
verified[property] = computed.getPropertyValue(property);
|
|
851
|
-
}
|
|
852
|
-
return { ...result, verified, elementRef };
|
|
853
|
-
}
|
|
854
|
-
return result;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
/**
|
|
858
|
-
* Apply a reversible DOM patch to a target element.
|
|
859
|
-
*
|
|
860
|
-
* @param {Record<string, any>} params
|
|
861
|
-
* @returns {{ patchId: string, applied: boolean, verified?: Record<string, unknown>, elementRef?: string }}
|
|
862
|
-
*/
|
|
863
|
-
function applyDomPatch(params) {
|
|
864
|
-
const element = resolveTarget(params.target);
|
|
865
|
-
const patchId = params.patchId || `patch_${crypto.randomUUID()}`;
|
|
866
|
-
const operation = params.operation;
|
|
867
|
-
|
|
868
|
-
/** @type {{ text: string | null, attributes: Record<string, string | null>, toggledClass: string | null, hadClass: boolean | null }} */
|
|
869
|
-
const previous = {
|
|
870
|
-
text: null,
|
|
871
|
-
attributes: {},
|
|
872
|
-
toggledClass: null,
|
|
873
|
-
hadClass: null,
|
|
874
|
-
};
|
|
875
|
-
|
|
876
|
-
switch (operation) {
|
|
877
|
-
case 'set_text':
|
|
878
|
-
previous.text = element.textContent;
|
|
879
|
-
element.textContent = String(params.value ?? '');
|
|
880
|
-
break;
|
|
881
|
-
case 'set_attribute':
|
|
882
|
-
previous.attributes[params.name] = element.getAttribute(params.name);
|
|
883
|
-
element.setAttribute(params.name, String(params.value ?? ''));
|
|
884
|
-
break;
|
|
885
|
-
case 'remove_attribute':
|
|
886
|
-
previous.attributes[params.name] = element.getAttribute(params.name);
|
|
887
|
-
element.removeAttribute(params.name);
|
|
888
|
-
break;
|
|
889
|
-
case 'toggle_class': {
|
|
890
|
-
const className = String(params.value);
|
|
891
|
-
previous.toggledClass = className;
|
|
892
|
-
previous.hadClass = element.classList.contains(className);
|
|
893
|
-
element.classList.toggle(className);
|
|
894
|
-
break;
|
|
895
|
-
}
|
|
896
|
-
default:
|
|
897
|
-
throw new Error(`Unsupported DOM patch operation ${operation}`);
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
pruneRegistry(patchRegistry, MAX_PATCH_REGISTRY_SIZE);
|
|
901
|
-
const elementRef = rememberElement(element);
|
|
902
|
-
patchRegistry.set(patchId, {
|
|
903
|
-
kind: 'dom',
|
|
904
|
-
elementRef,
|
|
905
|
-
operation,
|
|
906
|
-
previous,
|
|
907
|
-
});
|
|
908
|
-
const result = { patchId, applied: true };
|
|
909
|
-
if (params.verify) {
|
|
910
|
-
/** @type {Record<string, unknown>} */
|
|
911
|
-
const verified = {};
|
|
912
|
-
if (operation === 'set_text') {
|
|
913
|
-
verified.textContent = element.textContent;
|
|
914
|
-
} else if (operation === 'set_attribute' || operation === 'remove_attribute') {
|
|
915
|
-
verified[params.name] = element.getAttribute(params.name);
|
|
916
|
-
} else if (operation === 'toggle_class') {
|
|
917
|
-
verified.classList = [...element.classList];
|
|
918
|
-
}
|
|
919
|
-
return { ...result, verified, elementRef };
|
|
920
|
-
}
|
|
921
|
-
return result;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* List currently active reversible patches.
|
|
926
|
-
*
|
|
927
|
-
* @returns {Array<{ patchId: string, kind: string, elementRef: string }>}
|
|
928
|
-
*/
|
|
929
|
-
function listPatches() {
|
|
930
|
-
return [...patchRegistry.entries()].map(([patchId, patch]) => ({
|
|
931
|
-
patchId,
|
|
932
|
-
kind: patch.kind,
|
|
933
|
-
elementRef: patch.elementRef,
|
|
934
|
-
}));
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
/**
|
|
938
|
-
* Roll back a previously applied patch if it still exists.
|
|
939
|
-
*
|
|
940
|
-
* @param {string} patchId
|
|
941
|
-
* @returns {{ patchId: string, rolledBack: boolean }}
|
|
942
|
-
*/
|
|
943
|
-
function rollbackPatch(patchId) {
|
|
944
|
-
const patch = patchRegistry.get(patchId);
|
|
945
|
-
if (!patch) {
|
|
946
|
-
return { patchId, rolledBack: false };
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
const element = getRequiredElement(patch.elementRef);
|
|
950
|
-
if (patch.kind === 'style') {
|
|
951
|
-
const htmlElement = /** @type {HTMLElement} */ (element);
|
|
952
|
-
for (const [property, value] of Object.entries(patch.previous)) {
|
|
953
|
-
if (value) {
|
|
954
|
-
htmlElement.style.setProperty(property, value);
|
|
955
|
-
} else {
|
|
956
|
-
htmlElement.style.removeProperty(property);
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
} else if (patch.kind === 'dom') {
|
|
960
|
-
if (patch.operation === 'set_text' && patch.previous.text !== null) {
|
|
961
|
-
element.textContent = patch.previous.text;
|
|
962
|
-
} else if (patch.operation === 'toggle_class' && patch.previous.toggledClass) {
|
|
963
|
-
const hasNow = element.classList.contains(patch.previous.toggledClass);
|
|
964
|
-
if (hasNow !== patch.previous.hadClass) {
|
|
965
|
-
element.classList.toggle(patch.previous.toggledClass);
|
|
966
|
-
}
|
|
967
|
-
} else {
|
|
968
|
-
if (patch.previous.text !== null && patch.operation === 'set_text') {
|
|
969
|
-
element.textContent = patch.previous.text;
|
|
970
|
-
}
|
|
971
|
-
for (const [name, value] of Object.entries(patch.previous.attributes || {})) {
|
|
972
|
-
if (value == null) {
|
|
973
|
-
element.removeAttribute(name);
|
|
974
|
-
} else {
|
|
975
|
-
element.setAttribute(name, value);
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
patchRegistry.delete(patchId);
|
|
982
|
-
return { patchId, rolledBack: true };
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
/**
|
|
986
|
-
* Return the viewport rect for an element reference.
|
|
987
|
-
*
|
|
988
|
-
* @param {string} elementRef
|
|
989
|
-
* @returns {{ x: number, y: number, width: number, height: number, scale: number }}
|
|
990
|
-
*/
|
|
991
|
-
function getElementRect(elementRef) {
|
|
992
|
-
const el = getRequiredElement(elementRef);
|
|
993
|
-
// Scroll into view so CDP can capture it in the visible viewport
|
|
994
|
-
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
995
|
-
const rect = el.getBoundingClientRect();
|
|
996
|
-
if (rect.width < 1 || rect.height < 1) {
|
|
997
|
-
throw new Error(
|
|
998
|
-
`Element has no visible area (${rect.width}\u00d7${rect.height}). ` +
|
|
999
|
-
'It may be hidden, collapsed, or not yet rendered.'
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
const x = Math.max(0, rect.x);
|
|
1003
|
-
const y = Math.max(0, rect.y);
|
|
1004
|
-
const width = Math.max(0, Math.min(rect.width, window.innerWidth - x));
|
|
1005
|
-
const height = Math.max(0, Math.min(rect.height, window.innerHeight - y));
|
|
1006
|
-
if (width < 1 || height < 1) {
|
|
1007
|
-
throw new Error(
|
|
1008
|
-
'Element is outside the visible viewport after scroll ' +
|
|
1009
|
-
`(${Math.round(rect.x)},${Math.round(rect.y)} ${Math.round(rect.width)}\u00d7${Math.round(rect.height)}). ` +
|
|
1010
|
-
'It may be in a fixed/sticky container or an iframe.'
|
|
1011
|
-
);
|
|
1012
|
-
}
|
|
1013
|
-
return { x, y, width, height, scale: window.devicePixelRatio || 1 };
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
/**
|
|
1017
|
-
* Return the full document dimensions for a full-page screenshot.
|
|
1018
|
-
* Chrome enforces a 16384px maximum on CDP captureScreenshot clip dimensions.
|
|
1019
|
-
*
|
|
1020
|
-
* @returns {{ scrollWidth: number, scrollHeight: number, devicePixelRatio: number }}
|
|
1021
|
-
*/
|
|
1022
|
-
function getFullPageDimensions() {
|
|
1023
|
-
const el = document.scrollingElement || document.documentElement;
|
|
1024
|
-
return {
|
|
1025
|
-
scrollWidth: Math.min(el.scrollWidth, 16384),
|
|
1026
|
-
scrollHeight: Math.min(el.scrollHeight, 16384),
|
|
1027
|
-
devicePixelRatio: window.devicePixelRatio || 1,
|
|
1028
|
-
};
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
// ── New methods: DOM wait, find, HTML, hover, drag, storage ────────
|
|
1032
|
-
|
|
1033
|
-
/**
|
|
1034
|
-
* Wait for a DOM condition using MutationObserver + polling fallback.
|
|
1035
|
-
*
|
|
1036
|
-
* @param {Record<string, any>} params
|
|
1037
|
-
* @returns {Promise<{ found: boolean, elementRef: string | null, duration: number }>}
|
|
1038
|
-
*/
|
|
1039
|
-
function waitForDom(params) {
|
|
1040
|
-
const selector = String(params.selector || '');
|
|
1041
|
-
if (!selector) {
|
|
1042
|
-
throw new Error('selector is required for dom.wait_for');
|
|
1043
|
-
}
|
|
1044
|
-
const text = params.text != null ? String(params.text) : null;
|
|
1045
|
-
const waitState = params.state || 'attached';
|
|
1046
|
-
const timeout = clamp(params.timeoutMs ?? 5000, 100, 30000);
|
|
1047
|
-
const start = Date.now();
|
|
1048
|
-
|
|
1049
|
-
/**
|
|
1050
|
-
* @returns {{ found: boolean, element: Element | null }}
|
|
1051
|
-
*/
|
|
1052
|
-
function check() {
|
|
1053
|
-
if (waitState === 'detached') {
|
|
1054
|
-
const exists = text
|
|
1055
|
-
? findElementWithText(selector, text) !== null
|
|
1056
|
-
: document.querySelector(selector) !== null;
|
|
1057
|
-
return { found: !exists, element: null };
|
|
1058
|
-
}
|
|
1059
|
-
const candidates = document.querySelectorAll(selector);
|
|
1060
|
-
for (const el of candidates) {
|
|
1061
|
-
if (text !== null && !elementMatchesText(el, text)) {
|
|
1062
|
-
continue;
|
|
1063
|
-
}
|
|
1064
|
-
if (waitState === 'visible') {
|
|
1065
|
-
const r = el.getBoundingClientRect();
|
|
1066
|
-
if (r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden') {
|
|
1067
|
-
return { found: true, element: el };
|
|
1068
|
-
}
|
|
1069
|
-
} else if (waitState === 'hidden') {
|
|
1070
|
-
const r = el.getBoundingClientRect();
|
|
1071
|
-
if (r.width === 0 || r.height === 0 || getComputedStyle(el).visibility === 'hidden') {
|
|
1072
|
-
return { found: true, element: el };
|
|
1073
|
-
}
|
|
1074
|
-
} else {
|
|
1075
|
-
return { found: true, element: el };
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
return { found: false, element: null };
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
const immediate = check();
|
|
1082
|
-
if (immediate.found) {
|
|
1083
|
-
return Promise.resolve({
|
|
1084
|
-
found: true,
|
|
1085
|
-
elementRef: immediate.element ? rememberElement(immediate.element) : null,
|
|
1086
|
-
duration: 0,
|
|
1087
|
-
});
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
return new Promise((resolve) => {
|
|
1091
|
-
/** @type {MutationObserver | null} */
|
|
1092
|
-
let observer = null;
|
|
1093
|
-
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
1094
|
-
let timeoutHandle = null;
|
|
1095
|
-
/** @type {ReturnType<typeof setInterval> | null} */
|
|
1096
|
-
let pollHandle = null;
|
|
1097
|
-
|
|
1098
|
-
function cleanup() {
|
|
1099
|
-
if (observer) observer.disconnect();
|
|
1100
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
1101
|
-
if (pollHandle) clearInterval(pollHandle);
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
function tryResolve() {
|
|
1105
|
-
const result = check();
|
|
1106
|
-
if (result.found) {
|
|
1107
|
-
cleanup();
|
|
1108
|
-
resolve({
|
|
1109
|
-
found: true,
|
|
1110
|
-
elementRef: result.element ? rememberElement(result.element) : null,
|
|
1111
|
-
duration: Date.now() - start,
|
|
1112
|
-
});
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
observer = new MutationObserver(tryResolve);
|
|
1117
|
-
observer.observe(document.documentElement, {
|
|
1118
|
-
childList: true,
|
|
1119
|
-
subtree: true,
|
|
1120
|
-
attributes: true,
|
|
1121
|
-
characterData: true,
|
|
1122
|
-
});
|
|
1123
|
-
pollHandle = setInterval(tryResolve, 250);
|
|
1124
|
-
timeoutHandle = setTimeout(() => {
|
|
1125
|
-
cleanup();
|
|
1126
|
-
resolve({
|
|
1127
|
-
found: false,
|
|
1128
|
-
elementRef: null,
|
|
1129
|
-
duration: Date.now() - start,
|
|
1130
|
-
});
|
|
1131
|
-
}, timeout);
|
|
1132
|
-
});
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
/**
|
|
1136
|
-
* Find elements matching visible text content.
|
|
1137
|
-
*
|
|
1138
|
-
* @param {Record<string, any>} params
|
|
1139
|
-
* @returns {{ nodes: NodeSummary[], count: number }}
|
|
1140
|
-
*/
|
|
1141
|
-
function findByText(params) {
|
|
1142
|
-
const searchText = String(params.text || '');
|
|
1143
|
-
if (!searchText) {
|
|
1144
|
-
throw new Error('text is required for dom.find_by_text');
|
|
1145
|
-
}
|
|
1146
|
-
const exact = Boolean(params.exact);
|
|
1147
|
-
const scope = String(params.selector || '*');
|
|
1148
|
-
const maxResults = clamp(params.maxResults ?? 10, 1, 50);
|
|
1149
|
-
const candidates = document.querySelectorAll(scope);
|
|
1150
|
-
const results = [];
|
|
1151
|
-
|
|
1152
|
-
for (const el of candidates) {
|
|
1153
|
-
if (results.length >= maxResults) break;
|
|
1154
|
-
const visibleText = extractElementText(el);
|
|
1155
|
-
if (!visibleText) continue;
|
|
1156
|
-
const matches = exact
|
|
1157
|
-
? visibleText === searchText
|
|
1158
|
-
: visibleText.toLowerCase().includes(searchText.toLowerCase());
|
|
1159
|
-
if (matches) {
|
|
1160
|
-
results.push(summarizeNode(el, ['id', 'class', 'role', 'href', 'data-testid'], 120, true).node);
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
return { nodes: results, count: results.length };
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
/**
|
|
1168
|
-
* Find elements matching ARIA role and optional accessible name.
|
|
1169
|
-
*
|
|
1170
|
-
* @param {Record<string, any>} params
|
|
1171
|
-
* @returns {{ nodes: NodeSummary[], count: number }}
|
|
1172
|
-
*/
|
|
1173
|
-
function findByRole(params) {
|
|
1174
|
-
const role = String(params.role || '');
|
|
1175
|
-
if (!role) {
|
|
1176
|
-
throw new Error('role is required for dom.find_by_role');
|
|
1177
|
-
}
|
|
1178
|
-
const name = params.name ? String(params.name) : null;
|
|
1179
|
-
const scope = String(params.selector || '*');
|
|
1180
|
-
const maxResults = clamp(params.maxResults ?? 10, 1, 50);
|
|
1181
|
-
|
|
1182
|
-
const implicitSelector = getImplicitRoleSelector(role);
|
|
1183
|
-
const attrSelector = `[role="${CSS.escape(role)}"]`;
|
|
1184
|
-
const combinedSelector = scope === '*'
|
|
1185
|
-
? (implicitSelector ? `${attrSelector}, ${implicitSelector}` : attrSelector)
|
|
1186
|
-
: scope;
|
|
1187
|
-
const candidates = document.querySelectorAll(combinedSelector);
|
|
1188
|
-
const results = [];
|
|
1189
|
-
|
|
1190
|
-
for (const el of candidates) {
|
|
1191
|
-
if (results.length >= maxResults) break;
|
|
1192
|
-
const elRole = el.getAttribute('role') || getImplicitRole(el);
|
|
1193
|
-
if (elRole !== role) continue;
|
|
1194
|
-
if (name !== null) {
|
|
1195
|
-
const accName =
|
|
1196
|
-
el.getAttribute('aria-label') ||
|
|
1197
|
-
el.getAttribute('aria-labelledby') ||
|
|
1198
|
-
el.getAttribute('title') ||
|
|
1199
|
-
extractElementText(el);
|
|
1200
|
-
if (!accName || !accName.toLowerCase().includes(name.toLowerCase())) {
|
|
1201
|
-
continue;
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
results.push(summarizeNode(el, ['id', 'class', 'role', 'aria-label', 'href'], 120, true).node);
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
return { nodes: results, count: results.length };
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
/**
|
|
1211
|
-
* Return innerHTML or outerHTML of an element, truncated to budget.
|
|
1212
|
-
*
|
|
1213
|
-
* @param {Record<string, any>} params
|
|
1214
|
-
* @returns {{ html: string, truncated: boolean, omitted: number }}
|
|
1215
|
-
*/
|
|
1216
|
-
function getHtml(params) {
|
|
1217
|
-
const element = getRequiredElement(String(params.elementRef || ''));
|
|
1218
|
-
const outer = Boolean(params.outer);
|
|
1219
|
-
const maxLength = clamp(params.maxLength ?? 2000, 32, 50000);
|
|
1220
|
-
const raw = outer ? element.outerHTML : element.innerHTML;
|
|
1221
|
-
const t = truncateText(raw, maxLength);
|
|
1222
|
-
return { html: t.value, truncated: t.truncated, omitted: t.omitted };
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
/**
|
|
1226
|
-
* Trigger hover state on an element by dispatching mouse events.
|
|
1227
|
-
*
|
|
1228
|
-
* @param {Record<string, any>} params
|
|
1229
|
-
* @returns {Promise<{ elementRef: string, hovered: boolean }> | { elementRef: string, hovered: boolean }}
|
|
1230
|
-
*/
|
|
1231
|
-
function hoverTarget(params) {
|
|
1232
|
-
const element = resolveTarget(params.target);
|
|
1233
|
-
const point = getViewportPoint(element);
|
|
1234
|
-
const modifiers = normalizeModifierState(params.modifiers);
|
|
1235
|
-
const duration = clamp(params.duration ?? 0, 0, 5000);
|
|
1236
|
-
|
|
1237
|
-
scrollTargetIntoView(element);
|
|
1238
|
-
dispatchMouseEvent(element, 'mouseenter', point, 'left', 0, modifiers);
|
|
1239
|
-
dispatchMouseEvent(element, 'mouseover', point, 'left', 0, modifiers);
|
|
1240
|
-
dispatchMouseEvent(element, 'mousemove', point, 'left', 0, modifiers);
|
|
1241
|
-
|
|
1242
|
-
const ref = rememberElement(element);
|
|
1243
|
-
if (duration > 0) {
|
|
1244
|
-
return new Promise((resolve) => {
|
|
1245
|
-
setTimeout(() => {
|
|
1246
|
-
resolve({ elementRef: ref, hovered: true });
|
|
1247
|
-
}, duration);
|
|
1248
|
-
});
|
|
1249
|
-
}
|
|
1250
|
-
return { elementRef: ref, hovered: true };
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
/**
|
|
1254
|
-
* Perform a drag-and-drop operation between two elements.
|
|
1255
|
-
*
|
|
1256
|
-
* @param {Record<string, any>} params
|
|
1257
|
-
* @returns {{ sourceRef: string, destinationRef: string, dragged: boolean }}
|
|
1258
|
-
*/
|
|
1259
|
-
function dragTarget(params) {
|
|
1260
|
-
const source = resolveTarget(params.source);
|
|
1261
|
-
const destination = resolveTarget(params.destination);
|
|
1262
|
-
const sourcePoint = getViewportPoint(source);
|
|
1263
|
-
const destPoint = getViewportPoint(destination);
|
|
1264
|
-
const offsetX = Number(params.offsetX) || 0;
|
|
1265
|
-
const offsetY = Number(params.offsetY) || 0;
|
|
1266
|
-
const endPoint = { x: destPoint.x + offsetX, y: destPoint.y + offsetY };
|
|
1267
|
-
const emptyMods = { altKey: false, ctrlKey: false, metaKey: false, shiftKey: false };
|
|
1268
|
-
|
|
1269
|
-
scrollTargetIntoView(source);
|
|
1270
|
-
|
|
1271
|
-
const dataTransfer = new DataTransfer();
|
|
1272
|
-
|
|
1273
|
-
source.dispatchEvent(new MouseEvent('mousedown', {
|
|
1274
|
-
bubbles: true, cancelable: true, composed: true,
|
|
1275
|
-
clientX: sourcePoint.x, clientY: sourcePoint.y, ...emptyMods,
|
|
1276
|
-
}));
|
|
1277
|
-
source.dispatchEvent(new DragEvent('dragstart', {
|
|
1278
|
-
bubbles: true, cancelable: true, composed: true,
|
|
1279
|
-
clientX: sourcePoint.x, clientY: sourcePoint.y, dataTransfer,
|
|
1280
|
-
}));
|
|
1281
|
-
source.dispatchEvent(new DragEvent('drag', {
|
|
1282
|
-
bubbles: true, cancelable: true, composed: true,
|
|
1283
|
-
clientX: sourcePoint.x, clientY: sourcePoint.y, dataTransfer,
|
|
1284
|
-
}));
|
|
1285
|
-
|
|
1286
|
-
scrollTargetIntoView(destination);
|
|
1287
|
-
|
|
1288
|
-
destination.dispatchEvent(new DragEvent('dragenter', {
|
|
1289
|
-
bubbles: true, cancelable: true, composed: true,
|
|
1290
|
-
clientX: endPoint.x, clientY: endPoint.y, dataTransfer,
|
|
1291
|
-
}));
|
|
1292
|
-
destination.dispatchEvent(new DragEvent('dragover', {
|
|
1293
|
-
bubbles: true, cancelable: true, composed: true,
|
|
1294
|
-
clientX: endPoint.x, clientY: endPoint.y, dataTransfer,
|
|
1295
|
-
}));
|
|
1296
|
-
destination.dispatchEvent(new DragEvent('drop', {
|
|
1297
|
-
bubbles: true, cancelable: true, composed: true,
|
|
1298
|
-
clientX: endPoint.x, clientY: endPoint.y, dataTransfer,
|
|
1299
|
-
}));
|
|
1300
|
-
source.dispatchEvent(new DragEvent('dragend', {
|
|
1301
|
-
bubbles: true, cancelable: true, composed: true,
|
|
1302
|
-
clientX: endPoint.x, clientY: endPoint.y, dataTransfer,
|
|
1303
|
-
}));
|
|
1304
|
-
source.dispatchEvent(new MouseEvent('mouseup', {
|
|
1305
|
-
bubbles: true, cancelable: true, composed: true,
|
|
1306
|
-
clientX: endPoint.x, clientY: endPoint.y, ...emptyMods,
|
|
1307
|
-
}));
|
|
1308
|
-
|
|
1309
|
-
return {
|
|
1310
|
-
sourceRef: rememberElement(source),
|
|
1311
|
-
destinationRef: rememberElement(destination),
|
|
1312
|
-
dragged: true,
|
|
1313
|
-
};
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
/**
|
|
1317
|
-
* Scroll an element into the visible viewport.
|
|
1318
|
-
*
|
|
1319
|
-
* @param {Record<string, any>} params
|
|
1320
|
-
* @returns {{ elementRef: string, scrolled: boolean }}
|
|
1321
|
-
*/
|
|
1322
|
-
function scrollIntoViewTarget(params) {
|
|
1323
|
-
const element = resolveTarget(params.target);
|
|
1324
|
-
scrollTargetIntoView(element);
|
|
1325
|
-
return { elementRef: rememberElement(element), scrolled: true };
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
/**
|
|
1329
|
-
* Read localStorage or sessionStorage entries.
|
|
1330
|
-
*
|
|
1331
|
-
* @param {Record<string, any>} params
|
|
1332
|
-
* @returns {{ type: string, entries: Record<string, string | null>, count: number }}
|
|
1333
|
-
*/
|
|
1334
|
-
function getStorageData(params) {
|
|
1335
|
-
const type = params.type === 'session' ? 'session' : 'local';
|
|
1336
|
-
const storage = type === 'session' ? sessionStorage : localStorage;
|
|
1337
|
-
const keys = Array.isArray(params.keys) ? params.keys.filter((k) => typeof k === 'string') : null;
|
|
1338
|
-
/** @type {Record<string, string | null>} */
|
|
1339
|
-
const result = {};
|
|
1340
|
-
if (keys) {
|
|
1341
|
-
for (const key of keys) {
|
|
1342
|
-
result[key] = storage.getItem(key);
|
|
1343
|
-
}
|
|
1344
|
-
} else {
|
|
1345
|
-
for (let i = 0; i < Math.min(storage.length, 100); i++) {
|
|
1346
|
-
const key = storage.key(i);
|
|
1347
|
-
if (key !== null) {
|
|
1348
|
-
const val = storage.getItem(key);
|
|
1349
|
-
result[key] = val !== null && val.length > 500 ? val.slice(0, 500) + '\u2026' : val;
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
return { type, entries: result, count: Object.keys(result).length };
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
// ── Helpers for new methods ────────────────────────────────────────
|
|
1357
|
-
|
|
1358
|
-
/**
|
|
1359
|
-
* Check whether an element's visible text contains the given string.
|
|
1360
|
-
*
|
|
1361
|
-
* @param {Element} element
|
|
1362
|
-
* @param {string} text
|
|
1363
|
-
* @returns {boolean}
|
|
1364
|
-
*/
|
|
1365
|
-
function elementMatchesText(element, text) {
|
|
1366
|
-
const visible = extractElementText(element);
|
|
1367
|
-
return visible.toLowerCase().includes(text.toLowerCase());
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
/**
|
|
1371
|
-
* Find the first element matching a selector whose text contains a string.
|
|
1372
|
-
*
|
|
1373
|
-
* @param {string} selector
|
|
1374
|
-
* @param {string} text
|
|
1375
|
-
* @returns {Element | null}
|
|
1376
|
-
*/
|
|
1377
|
-
function findElementWithText(selector, text) {
|
|
1378
|
-
for (const el of document.querySelectorAll(selector)) {
|
|
1379
|
-
if (elementMatchesText(el, text)) {
|
|
1380
|
-
return el;
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
return null;
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
/**
|
|
1387
|
-
* Resolve a patch target from either an element reference or a selector.
|
|
1388
|
-
*
|
|
1389
|
-
* @param {{ elementRef?: string, selector?: string }} [target={}]
|
|
1390
|
-
* @returns {Element}
|
|
1391
|
-
*/
|
|
1392
|
-
function resolveTarget(target = {}) {
|
|
1393
|
-
if (target.elementRef) {
|
|
1394
|
-
return getRequiredElement(target.elementRef);
|
|
1395
|
-
}
|
|
1396
|
-
if (target.selector) {
|
|
1397
|
-
const element = document.querySelector(target.selector);
|
|
1398
|
-
if (element) {
|
|
1399
|
-
return element;
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
throw new Error('Target not found.');
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
/**
|
|
1406
|
-
* Resolve element-level read params from either a legacy top-level
|
|
1407
|
-
* `elementRef` or the newer `target` alias.
|
|
1408
|
-
*
|
|
1409
|
-
* @param {{ elementRef?: string, target?: { elementRef?: string, selector?: string } }} [params={}]
|
|
1410
|
-
* @returns {string}
|
|
1411
|
-
*/
|
|
1412
|
-
function resolveElementRefFromParams(params = {}) {
|
|
1413
|
-
if (typeof params.elementRef === 'string' && params.elementRef) {
|
|
1414
|
-
return params.elementRef;
|
|
1415
|
-
}
|
|
1416
|
-
if (params.target && typeof params.target === 'object') {
|
|
1417
|
-
return rememberElement(resolveTarget(params.target));
|
|
1418
|
-
}
|
|
1419
|
-
throw new Error('Element target not found.');
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
/**
|
|
1423
|
-
* @param {{ elementRef?: string, selector?: string }} [target={}]
|
|
1424
|
-
* @returns {HTMLInputElement}
|
|
1425
|
-
*/
|
|
1426
|
-
function resolveCheckableTarget(target = {}) {
|
|
1427
|
-
const element = resolveTarget(target);
|
|
1428
|
-
if (
|
|
1429
|
-
element instanceof HTMLInputElement &&
|
|
1430
|
-
['checkbox', 'radio'].includes(element.type.toLowerCase())
|
|
1431
|
-
) {
|
|
1432
|
-
return element;
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
if (element instanceof HTMLElement) {
|
|
1436
|
-
const nested = element.querySelector('input[type="checkbox"], input[type="radio"]');
|
|
1437
|
-
if (nested instanceof HTMLInputElement) {
|
|
1438
|
-
return nested;
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
throw new Error('Target is not a checkbox or radio input.');
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
/**
|
|
1446
|
-
* @param {{ elementRef?: string, selector?: string }} [target={}]
|
|
1447
|
-
* @returns {HTMLSelectElement}
|
|
1448
|
-
*/
|
|
1449
|
-
function resolveSelectTarget(target = {}) {
|
|
1450
|
-
const element = resolveTarget(target);
|
|
1451
|
-
if (element instanceof HTMLSelectElement) {
|
|
1452
|
-
return element;
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
if (element instanceof HTMLOptionElement && element.parentElement instanceof HTMLSelectElement) {
|
|
1456
|
-
return element.parentElement;
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
if (element instanceof HTMLElement) {
|
|
1460
|
-
const nested = element.querySelector('select');
|
|
1461
|
-
if (nested instanceof HTMLSelectElement) {
|
|
1462
|
-
return nested;
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
throw new Error('Target is not a select control.');
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
/**
|
|
1470
|
-
* @param {Element} element
|
|
1471
|
-
* @returns {HTMLElement}
|
|
1472
|
-
*/
|
|
1473
|
-
function getScrollableElementTarget(element) {
|
|
1474
|
-
if (element instanceof HTMLElement) {
|
|
1475
|
-
return element;
|
|
1476
|
-
}
|
|
1477
|
-
if (document.scrollingElement instanceof HTMLElement) {
|
|
1478
|
-
return document.scrollingElement;
|
|
1479
|
-
}
|
|
1480
|
-
return document.documentElement;
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
/**
|
|
1484
|
-
* Resolve an existing element reference and verify it is still attached.
|
|
1485
|
-
*
|
|
1486
|
-
* @param {string} elementRef
|
|
1487
|
-
* @returns {Element}
|
|
1488
|
-
*/
|
|
1489
|
-
function getRequiredElement(elementRef) {
|
|
1490
|
-
const element = elementRegistry.get(elementRef);
|
|
1491
|
-
if (!element) {
|
|
1492
|
-
throw new Error('Element reference is stale.');
|
|
1493
|
-
}
|
|
1494
|
-
if (!document.contains(element)) {
|
|
1495
|
-
elementRegistry.delete(elementRef);
|
|
1496
|
-
reverseRegistry.delete(element);
|
|
1497
|
-
throw new Error('Element reference is stale.');
|
|
1498
|
-
}
|
|
1499
|
-
return element;
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
/**
|
|
1503
|
-
* Reuse or create a stable element reference for later bridge calls.
|
|
1504
|
-
* Uses a reverse WeakMap for O(1) lookup instead of scanning the registry.
|
|
1505
|
-
*
|
|
1506
|
-
* @param {Element} element
|
|
1507
|
-
* @returns {string}
|
|
1508
|
-
*/
|
|
1509
|
-
function rememberElement(element) {
|
|
1510
|
-
const existing = reverseRegistry.get(element);
|
|
1511
|
-
if (existing && elementRegistry.has(existing)) {
|
|
1512
|
-
return existing;
|
|
1513
|
-
}
|
|
1514
|
-
// Prune stale entries when registry grows too large
|
|
1515
|
-
if (elementRegistry.size >= MAX_REGISTRY_SIZE) {
|
|
1516
|
-
pruneElementRegistry();
|
|
1517
|
-
}
|
|
1518
|
-
const elementRef = `el_${crypto.randomUUID()}`;
|
|
1519
|
-
elementRegistry.set(elementRef, element);
|
|
1520
|
-
reverseRegistry.set(element, elementRef);
|
|
1521
|
-
return elementRef;
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
/**
|
|
1525
|
-
* Remove entries for elements no longer in the document, keeping the
|
|
1526
|
-
* registry bounded.
|
|
1527
|
-
*
|
|
1528
|
-
* @returns {void}
|
|
1529
|
-
*/
|
|
1530
|
-
function pruneElementRegistry() {
|
|
1531
|
-
for (const [ref, element] of elementRegistry.entries()) {
|
|
1532
|
-
if (!document.contains(element)) {
|
|
1533
|
-
elementRegistry.delete(ref);
|
|
1534
|
-
registryPruned = true;
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
/**
|
|
1540
|
-
* @param {Map<any, any>} registry
|
|
1541
|
-
* @param {number} maxSize
|
|
1542
|
-
* @returns {void}
|
|
1543
|
-
*/
|
|
1544
|
-
function pruneRegistry(registry, maxSize) {
|
|
1545
|
-
if (registry.size < maxSize) return;
|
|
1546
|
-
const excess = registry.size - maxSize;
|
|
1547
|
-
let count = 0;
|
|
1548
|
-
for (const key of registry.keys()) {
|
|
1549
|
-
if (count >= excess) break;
|
|
1550
|
-
registry.delete(key);
|
|
1551
|
-
count++;
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
/**
|
|
1556
|
-
* Keep the target visible before dispatching interaction events.
|
|
1557
|
-
*
|
|
1558
|
-
* @param {Element} element
|
|
1559
|
-
* @returns {void}
|
|
1560
|
-
*/
|
|
1561
|
-
function scrollTargetIntoView(element) {
|
|
1562
|
-
element.scrollIntoView({
|
|
1563
|
-
block: 'center',
|
|
1564
|
-
inline: 'center',
|
|
1565
|
-
});
|
|
1566
|
-
}
|
|
1567
|
-
|
|
1568
|
-
/**
|
|
1569
|
-
* Focus an element when the platform allows it.
|
|
1570
|
-
*
|
|
1571
|
-
* @param {Element} element
|
|
1572
|
-
* @returns {Element}
|
|
1573
|
-
*/
|
|
1574
|
-
function focusElement(element) {
|
|
1575
|
-
if ('focus' in element && typeof element.focus === 'function') {
|
|
1576
|
-
element.focus({
|
|
1577
|
-
preventScroll: true,
|
|
1578
|
-
});
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
return document.activeElement instanceof Element
|
|
1582
|
-
? document.activeElement
|
|
1583
|
-
: element;
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
/**
|
|
1587
|
-
* @param {Element} element
|
|
1588
|
-
* @returns {boolean}
|
|
1589
|
-
*/
|
|
1590
|
-
function isElementFocused(element) {
|
|
1591
|
-
return document.activeElement === element || element.contains(document.activeElement);
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
/**
|
|
1595
|
-
* @param {Element} element
|
|
1596
|
-
* @returns {{ x: number, y: number }}
|
|
1597
|
-
*/
|
|
1598
|
-
function getViewportPoint(element) {
|
|
1599
|
-
const rect = element.getBoundingClientRect();
|
|
1600
|
-
return {
|
|
1601
|
-
x: rect.left + rect.width / 2,
|
|
1602
|
-
y: rect.top + rect.height / 2,
|
|
1603
|
-
};
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
/**
|
|
1607
|
-
* @param {unknown} value
|
|
1608
|
-
* @returns {'left' | 'middle' | 'right'}
|
|
1609
|
-
*/
|
|
1610
|
-
function normalizeMouseButton(value) {
|
|
1611
|
-
return value === 'middle' || value === 'right' ? value : 'left';
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
/**
|
|
1615
|
-
* @param {unknown} value
|
|
1616
|
-
* @returns {{ altKey: boolean, ctrlKey: boolean, metaKey: boolean, shiftKey: boolean }}
|
|
1617
|
-
*/
|
|
1618
|
-
function normalizeModifierState(value) {
|
|
1619
|
-
const modifiers = Array.isArray(value)
|
|
1620
|
-
? value.filter((modifier) => typeof modifier === 'string')
|
|
1621
|
-
: [];
|
|
1622
|
-
return {
|
|
1623
|
-
altKey: modifiers.includes('Alt'),
|
|
1624
|
-
ctrlKey: modifiers.includes('Control') || modifiers.includes('Ctrl'),
|
|
1625
|
-
metaKey: modifiers.includes('Meta') || modifiers.includes('Command'),
|
|
1626
|
-
shiftKey: modifiers.includes('Shift'),
|
|
1627
|
-
};
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
/**
|
|
1631
|
-
* @param {'left' | 'middle' | 'right'} button
|
|
1632
|
-
* @returns {{ button: number, buttons: number }}
|
|
1633
|
-
*/
|
|
1634
|
-
function getMouseButtonState(button) {
|
|
1635
|
-
switch (button) {
|
|
1636
|
-
case 'middle':
|
|
1637
|
-
return { button: 1, buttons: 4 };
|
|
1638
|
-
case 'right':
|
|
1639
|
-
return { button: 2, buttons: 2 };
|
|
1640
|
-
default:
|
|
1641
|
-
return { button: 0, buttons: 1 };
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
/**
|
|
1646
|
-
* @param {Element} element
|
|
1647
|
-
* @param {string} type
|
|
1648
|
-
* @param {{ x: number, y: number }} point
|
|
1649
|
-
* @param {'left' | 'middle' | 'right'} button
|
|
1650
|
-
* @param {number} detail
|
|
1651
|
-
* @param {{ altKey: boolean, ctrlKey: boolean, metaKey: boolean, shiftKey: boolean }} modifiers
|
|
1652
|
-
* @returns {boolean}
|
|
1653
|
-
*/
|
|
1654
|
-
function dispatchMouseEvent(element, type, point, button, detail, modifiers) {
|
|
1655
|
-
const buttonState = getMouseButtonState(button);
|
|
1656
|
-
return element.dispatchEvent(
|
|
1657
|
-
new MouseEvent(type, {
|
|
1658
|
-
bubbles: true,
|
|
1659
|
-
cancelable: true,
|
|
1660
|
-
composed: true,
|
|
1661
|
-
clientX: point.x,
|
|
1662
|
-
clientY: point.y,
|
|
1663
|
-
detail,
|
|
1664
|
-
button: buttonState.button,
|
|
1665
|
-
buttons: buttonState.buttons,
|
|
1666
|
-
...modifiers,
|
|
1667
|
-
}),
|
|
1668
|
-
);
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
/**
|
|
1672
|
-
* @param {Element} element
|
|
1673
|
-
* @returns {HTMLInputElement | HTMLTextAreaElement | HTMLElement | null}
|
|
1674
|
-
*/
|
|
1675
|
-
function getEditableTarget(element) {
|
|
1676
|
-
if (isEditableElement(element)) {
|
|
1677
|
-
return /** @type {HTMLInputElement | HTMLTextAreaElement | HTMLElement} */ (element);
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
if (!(element instanceof HTMLElement)) {
|
|
1681
|
-
return null;
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
const editable = element.querySelector("input, textarea, [contenteditable=''], [contenteditable='true']");
|
|
1685
|
-
return editable && isEditableElement(editable)
|
|
1686
|
-
? /** @type {HTMLInputElement | HTMLTextAreaElement | HTMLElement} */ (editable)
|
|
1687
|
-
: null;
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
/**
|
|
1691
|
-
* @param {Element} element
|
|
1692
|
-
* @returns {boolean}
|
|
1693
|
-
*/
|
|
1694
|
-
function isEditableElement(element) {
|
|
1695
|
-
if (element instanceof HTMLTextAreaElement) {
|
|
1696
|
-
return true;
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
if (element instanceof HTMLInputElement) {
|
|
1700
|
-
return !NON_TEXT_INPUT_TYPES.has(element.type.toLowerCase());
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
return element instanceof HTMLElement && element.isContentEditable;
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
/**
|
|
1707
|
-
* @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
|
|
1708
|
-
* @returns {string}
|
|
1709
|
-
*/
|
|
1710
|
-
function getEditableValue(element) {
|
|
1711
|
-
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
1712
|
-
return element.value;
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
return element.innerText || element.textContent || '';
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
/**
|
|
1719
|
-
* @param {HTMLSelectElement} element
|
|
1720
|
-
* @returns {string[]}
|
|
1721
|
-
*/
|
|
1722
|
-
function getSelectedOptionValues(element) {
|
|
1723
|
-
return [...element.selectedOptions].map((option) => option.value);
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
/**
|
|
1727
|
-
* @param {string[]} left
|
|
1728
|
-
* @param {string[]} right
|
|
1729
|
-
* @returns {boolean}
|
|
1730
|
-
*/
|
|
1731
|
-
function areStringArraysEqual(left, right) {
|
|
1732
|
-
if (left.length !== right.length) {
|
|
1733
|
-
return false;
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
return left.every((value, index) => value === right[index]);
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
/**
|
|
1740
|
-
* @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
|
|
1741
|
-
* @returns {void}
|
|
1742
|
-
*/
|
|
1743
|
-
function clearEditableValue(element) {
|
|
1744
|
-
if (!getEditableValue(element)) {
|
|
1745
|
-
return;
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
dispatchKeyboardEvent(element, 'keydown', 'Backspace', {});
|
|
1749
|
-
if (dispatchBeforeInputEvent(element, '', 'deleteContentBackward')) {
|
|
1750
|
-
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
1751
|
-
element.value = '';
|
|
1752
|
-
} else {
|
|
1753
|
-
element.textContent = '';
|
|
1754
|
-
}
|
|
1755
|
-
dispatchInputEvent(element, '', 'deleteContentBackward');
|
|
1756
|
-
}
|
|
1757
|
-
dispatchKeyboardEvent(element, 'keyup', 'Backspace', {});
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
/**
|
|
1761
|
-
* @param {Element} element
|
|
1762
|
-
* @param {string} key
|
|
1763
|
-
* @param {unknown} modifiers
|
|
1764
|
-
* @returns {{ target: Element, key: string, handled: boolean }}
|
|
1765
|
-
*/
|
|
1766
|
-
function runKeyAction(element, key, modifiers) {
|
|
1767
|
-
const normalizedKey = key === 'Space' ? ' ' : key;
|
|
1768
|
-
const keyboardTarget = focusElement(element);
|
|
1769
|
-
const modifierState = normalizeModifierState(modifiers);
|
|
1770
|
-
dispatchKeyboardEvent(keyboardTarget, 'keydown', normalizedKey, modifierState);
|
|
1771
|
-
|
|
1772
|
-
let handled = false;
|
|
1773
|
-
const editable = getEditableTarget(keyboardTarget);
|
|
1774
|
-
if (
|
|
1775
|
-
editable &&
|
|
1776
|
-
normalizedKey.length === 1 &&
|
|
1777
|
-
!modifierState.altKey &&
|
|
1778
|
-
!modifierState.ctrlKey &&
|
|
1779
|
-
!modifierState.metaKey
|
|
1780
|
-
) {
|
|
1781
|
-
handled = insertTextIntoEditable(editable, normalizedKey);
|
|
1782
|
-
} else if (editable && normalizedKey === 'Backspace') {
|
|
1783
|
-
handled = deleteTextFromEditable(editable, 'backward');
|
|
1784
|
-
} else if (editable && normalizedKey === 'Delete') {
|
|
1785
|
-
handled = deleteTextFromEditable(editable, 'forward');
|
|
1786
|
-
} else if (normalizedKey === 'Enter') {
|
|
1787
|
-
handled = handleEnterKey(keyboardTarget);
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
dispatchKeyboardEvent(keyboardTarget, 'keyup', normalizedKey, modifierState);
|
|
1791
|
-
return {
|
|
1792
|
-
target: keyboardTarget,
|
|
1793
|
-
key: normalizedKey,
|
|
1794
|
-
handled,
|
|
1795
|
-
};
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
/**
|
|
1799
|
-
* @param {Element} element
|
|
1800
|
-
* @param {string} type
|
|
1801
|
-
* @param {string} key
|
|
1802
|
-
* @param {{ altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean, shiftKey?: boolean }} modifiers
|
|
1803
|
-
* @returns {boolean}
|
|
1804
|
-
*/
|
|
1805
|
-
function dispatchKeyboardEvent(element, type, key, modifiers) {
|
|
1806
|
-
return element.dispatchEvent(
|
|
1807
|
-
new KeyboardEvent(type, {
|
|
1808
|
-
key,
|
|
1809
|
-
bubbles: true,
|
|
1810
|
-
cancelable: true,
|
|
1811
|
-
composed: true,
|
|
1812
|
-
...modifiers,
|
|
1813
|
-
}),
|
|
1814
|
-
);
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
/**
|
|
1818
|
-
* @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
|
|
1819
|
-
* @param {string} value
|
|
1820
|
-
* @param {string} inputType
|
|
1821
|
-
* @returns {boolean}
|
|
1822
|
-
*/
|
|
1823
|
-
function dispatchBeforeInputEvent(element, value, inputType) {
|
|
1824
|
-
return element.dispatchEvent(
|
|
1825
|
-
new InputEvent('beforeinput', {
|
|
1826
|
-
data: value,
|
|
1827
|
-
inputType,
|
|
1828
|
-
bubbles: true,
|
|
1829
|
-
cancelable: true,
|
|
1830
|
-
composed: true,
|
|
1831
|
-
}),
|
|
1832
|
-
);
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
/**
|
|
1836
|
-
* @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
|
|
1837
|
-
* @param {string} value
|
|
1838
|
-
* @param {string} inputType
|
|
1839
|
-
* @returns {boolean}
|
|
1840
|
-
*/
|
|
1841
|
-
function dispatchInputEvent(element, value, inputType) {
|
|
1842
|
-
return element.dispatchEvent(
|
|
1843
|
-
new InputEvent('input', {
|
|
1844
|
-
data: value,
|
|
1845
|
-
inputType,
|
|
1846
|
-
bubbles: true,
|
|
1847
|
-
composed: true,
|
|
1848
|
-
}),
|
|
1849
|
-
);
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
/**
|
|
1853
|
-
* @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
|
|
1854
|
-
* @param {string} value
|
|
1855
|
-
* @returns {boolean}
|
|
1856
|
-
*/
|
|
1857
|
-
function insertTextIntoEditable(element, value) {
|
|
1858
|
-
if (!dispatchBeforeInputEvent(element, value, 'insertText')) {
|
|
1859
|
-
return false;
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
1863
|
-
const start = element.selectionStart ?? element.value.length;
|
|
1864
|
-
const end = element.selectionEnd ?? element.value.length;
|
|
1865
|
-
element.setRangeText(value, start, end, 'end');
|
|
1866
|
-
} else {
|
|
1867
|
-
element.textContent = `${element.textContent || ''}${value}`;
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
dispatchInputEvent(element, value, 'insertText');
|
|
1871
|
-
return true;
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
/**
|
|
1875
|
-
* @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
|
|
1876
|
-
* @param {'backward' | 'forward'} direction
|
|
1877
|
-
* @returns {boolean}
|
|
1878
|
-
*/
|
|
1879
|
-
function deleteTextFromEditable(element, direction) {
|
|
1880
|
-
const inputType =
|
|
1881
|
-
direction === 'backward' ? 'deleteContentBackward' : 'deleteContentForward';
|
|
1882
|
-
if (!dispatchBeforeInputEvent(element, '', inputType)) {
|
|
1883
|
-
return false;
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
1887
|
-
const start = element.selectionStart ?? element.value.length;
|
|
1888
|
-
const end = element.selectionEnd ?? element.value.length;
|
|
1889
|
-
if (start !== end) {
|
|
1890
|
-
element.setRangeText('', start, end, 'end');
|
|
1891
|
-
} else if (direction === 'backward' && start > 0) {
|
|
1892
|
-
element.setRangeText('', start - 1, start, 'end');
|
|
1893
|
-
} else if (direction === 'forward' && end < element.value.length) {
|
|
1894
|
-
element.setRangeText('', end, end + 1, 'end');
|
|
1895
|
-
}
|
|
1896
|
-
} else {
|
|
1897
|
-
const text = element.textContent || '';
|
|
1898
|
-
element.textContent =
|
|
1899
|
-
direction === 'backward'
|
|
1900
|
-
? text.slice(0, Math.max(0, text.length - 1))
|
|
1901
|
-
: text.slice(1);
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
dispatchInputEvent(element, '', inputType);
|
|
1905
|
-
return true;
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
/**
|
|
1909
|
-
* @param {Element} element
|
|
1910
|
-
* @returns {boolean}
|
|
1911
|
-
*/
|
|
1912
|
-
function handleEnterKey(element) {
|
|
1913
|
-
const editable = getEditableTarget(element);
|
|
1914
|
-
if (editable instanceof HTMLTextAreaElement || (editable instanceof HTMLElement && editable.isContentEditable)) {
|
|
1915
|
-
return insertTextIntoEditable(editable, '\n');
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
if (editable instanceof HTMLInputElement) {
|
|
1919
|
-
submitElement(editable);
|
|
1920
|
-
return true;
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
|
-
if (element instanceof HTMLButtonElement || (element instanceof HTMLInputElement && ['button', 'submit'].includes(element.type))) {
|
|
1924
|
-
element.click();
|
|
1925
|
-
return true;
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
const form = element instanceof HTMLElement ? element.closest('form') : null;
|
|
1929
|
-
if (form) {
|
|
1930
|
-
form.requestSubmit();
|
|
1931
|
-
return true;
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
return false;
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
/**
|
|
1938
|
-
* @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
|
|
1939
|
-
* @returns {void}
|
|
1940
|
-
*/
|
|
1941
|
-
function submitElement(element) {
|
|
1942
|
-
const form = element instanceof HTMLElement ? element.closest('form') : null;
|
|
1943
|
-
if (form) {
|
|
1944
|
-
form.requestSubmit();
|
|
1945
|
-
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
/**
|
|
1950
|
-
* Return a cheap document revision marker for change detection.
|
|
1951
|
-
*
|
|
1952
|
-
* @returns {number}
|
|
1953
|
-
*/
|
|
1954
|
-
function getDocumentRevision() {
|
|
1955
|
-
return (document.body?.textContent || '').length;
|
|
1956
|
-
}
|
|
1957
|
-
|
|
1958
|
-
/**
|
|
1959
|
-
* Keep the content script self-contained because manifest-declared
|
|
1960
|
-
* content scripts are classic scripts, not ES modules.
|
|
1961
|
-
*
|
|
1962
|
-
* @param {Record<string, any>} [params={}]
|
|
1963
|
-
* @returns {NormalizedDomQuery}
|
|
1964
|
-
*/
|
|
1965
|
-
function normalizeDomQuery(params = {}) {
|
|
1966
|
-
const rawSelector =
|
|
1967
|
-
typeof params.selector === 'string' && params.selector.trim()
|
|
1968
|
-
? params.selector
|
|
1969
|
-
: 'body';
|
|
1970
|
-
return {
|
|
1971
|
-
selector: escapeTailwindSelector(rawSelector),
|
|
1972
|
-
withinRef: typeof params.withinRef === 'string' ? params.withinRef : null,
|
|
1973
|
-
budget: applyBudget(params),
|
|
1974
|
-
};
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
})();
|