btcp-browser-agent 0.1.0 → 0.1.1
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/package.json +8 -9
- package/packages/core/dist/actions.d.ts +97 -0
- package/packages/core/dist/actions.js +940 -0
- package/packages/core/dist/errors.d.ts +138 -0
- package/packages/core/dist/errors.js +157 -0
- package/packages/core/dist/index.d.ts +120 -0
- package/packages/core/dist/index.js +134 -0
- package/packages/core/dist/ref-map.d.ts +16 -0
- package/packages/core/dist/ref-map.js +91 -0
- package/packages/core/dist/snapshot.d.ts +37 -0
- package/packages/core/dist/snapshot.js +751 -0
- package/packages/core/dist/types.d.ts +396 -0
- package/packages/core/dist/types.js +7 -0
- package/packages/extension/dist/background.d.ts +227 -0
- package/packages/extension/dist/background.js +737 -0
- package/packages/extension/dist/content.d.ts +18 -0
- package/packages/extension/dist/content.js +149 -0
- package/packages/extension/dist/index.d.ts +228 -0
- package/packages/extension/dist/index.js +350 -0
- package/packages/extension/dist/session-manager.d.ts +87 -0
- package/packages/extension/dist/session-manager.js +322 -0
- package/packages/extension/{src/session-types.ts → dist/session-types.d.ts} +113 -144
- package/packages/extension/dist/session-types.js +5 -0
- package/packages/extension/dist/types.d.ts +88 -0
- package/packages/extension/dist/types.js +7 -0
- package/CLAUDE.md +0 -230
- package/SKILL.md +0 -143
- package/SNAPSHOT_IMPROVEMENTS.md +0 -302
- package/USAGE.md +0 -146
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/docs/browser-cli-design.md +0 -500
- package/examples/chrome-extension/CHANGELOG.md +0 -210
- package/examples/chrome-extension/DEBUG.md +0 -231
- package/examples/chrome-extension/ERROR_FIXED.md +0 -147
- package/examples/chrome-extension/QUICK_TEST.md +0 -189
- package/examples/chrome-extension/README.md +0 -149
- package/examples/chrome-extension/SESSION_ONLY_MODE.md +0 -305
- package/examples/chrome-extension/TEST_WITH_YOUR_TABS.md +0 -97
- package/examples/chrome-extension/build.js +0 -43
- package/examples/chrome-extension/manifest.json +0 -37
- package/examples/chrome-extension/package-lock.json +0 -1063
- package/examples/chrome-extension/package.json +0 -21
- package/examples/chrome-extension/popup.html +0 -195
- package/examples/chrome-extension/src/background.ts +0 -12
- package/examples/chrome-extension/src/content.ts +0 -7
- package/examples/chrome-extension/src/popup.ts +0 -303
- package/examples/chrome-extension/src/scenario-google-github.ts +0 -389
- package/examples/chrome-extension/test-page.html +0 -127
- package/examples/chrome-extension/tests/README.md +0 -206
- package/examples/chrome-extension/tests/scenario-google-to-github-star.ts +0 -380
- package/examples/chrome-extension/tsconfig.json +0 -14
- package/examples/snapshots/README.md +0 -207
- package/examples/snapshots/amazon-com-detail.html +0 -9528
- package/examples/snapshots/amazon-com-detail.snapshot.txt +0 -997
- package/examples/snapshots/convert-snapshots.ts +0 -97
- package/examples/snapshots/edition-cnn-com.html +0 -13292
- package/examples/snapshots/edition-cnn-com.snapshot.txt +0 -562
- package/examples/snapshots/github-com-microsoft-vscode.html +0 -2916
- package/examples/snapshots/github-com-microsoft-vscode.snapshot.txt +0 -455
- package/examples/snapshots/google-search.html +0 -20012
- package/examples/snapshots/google-search.snapshot.txt +0 -195
- package/examples/snapshots/metadata.json +0 -86
- package/examples/snapshots/npr-org-templates.html +0 -2031
- package/examples/snapshots/npr-org-templates.snapshot.txt +0 -224
- package/examples/snapshots/stackoverflow-com.html +0 -5216
- package/examples/snapshots/stackoverflow-com.snapshot.txt +0 -2404
- package/examples/snapshots/test-all-mode.html +0 -46
- package/examples/snapshots/test-all-mode.snapshot.txt +0 -5
- package/examples/snapshots/validate.test.ts +0 -296
- package/packages/cli/package.json +0 -42
- package/packages/cli/src/__tests__/cli.test.ts +0 -434
- package/packages/cli/src/__tests__/errors.test.ts +0 -226
- package/packages/cli/src/__tests__/executor.test.ts +0 -275
- package/packages/cli/src/__tests__/formatter.test.ts +0 -260
- package/packages/cli/src/__tests__/parser.test.ts +0 -288
- package/packages/cli/src/__tests__/suggestions.test.ts +0 -255
- package/packages/cli/src/commands/back.ts +0 -22
- package/packages/cli/src/commands/check.ts +0 -33
- package/packages/cli/src/commands/clear.ts +0 -33
- package/packages/cli/src/commands/click.ts +0 -32
- package/packages/cli/src/commands/closetab.ts +0 -31
- package/packages/cli/src/commands/eval.ts +0 -41
- package/packages/cli/src/commands/fill.ts +0 -30
- package/packages/cli/src/commands/focus.ts +0 -33
- package/packages/cli/src/commands/forward.ts +0 -22
- package/packages/cli/src/commands/goto.ts +0 -34
- package/packages/cli/src/commands/help.ts +0 -162
- package/packages/cli/src/commands/hover.ts +0 -34
- package/packages/cli/src/commands/index.ts +0 -129
- package/packages/cli/src/commands/newtab.ts +0 -35
- package/packages/cli/src/commands/press.ts +0 -40
- package/packages/cli/src/commands/reload.ts +0 -25
- package/packages/cli/src/commands/screenshot.ts +0 -27
- package/packages/cli/src/commands/scroll.ts +0 -64
- package/packages/cli/src/commands/select.ts +0 -35
- package/packages/cli/src/commands/snapshot.ts +0 -21
- package/packages/cli/src/commands/tab.ts +0 -32
- package/packages/cli/src/commands/tabs.ts +0 -26
- package/packages/cli/src/commands/text.ts +0 -27
- package/packages/cli/src/commands/title.ts +0 -17
- package/packages/cli/src/commands/type.ts +0 -38
- package/packages/cli/src/commands/uncheck.ts +0 -33
- package/packages/cli/src/commands/url.ts +0 -17
- package/packages/cli/src/commands/wait.ts +0 -54
- package/packages/cli/src/errors.ts +0 -164
- package/packages/cli/src/executor.ts +0 -68
- package/packages/cli/src/formatter.ts +0 -215
- package/packages/cli/src/index.ts +0 -257
- package/packages/cli/src/parser.ts +0 -195
- package/packages/cli/src/suggestions.ts +0 -207
- package/packages/cli/src/terminal/Terminal.ts +0 -365
- package/packages/cli/src/terminal/index.ts +0 -5
- package/packages/cli/src/types.ts +0 -155
- package/packages/cli/tsconfig.json +0 -20
- package/packages/core/package.json +0 -35
- package/packages/core/src/actions.ts +0 -1210
- package/packages/core/src/errors.ts +0 -296
- package/packages/core/src/index.test.ts +0 -638
- package/packages/core/src/index.ts +0 -220
- package/packages/core/src/ref-map.ts +0 -107
- package/packages/core/src/snapshot.ts +0 -873
- package/packages/core/src/types.ts +0 -536
- package/packages/core/tsconfig.json +0 -23
- package/packages/extension/README.md +0 -129
- package/packages/extension/package.json +0 -43
- package/packages/extension/src/background.ts +0 -888
- package/packages/extension/src/content.ts +0 -172
- package/packages/extension/src/index.ts +0 -579
- package/packages/extension/src/session-manager.ts +0 -385
- package/packages/extension/src/types.ts +0 -162
- package/packages/extension/tsconfig.json +0 -28
- package/src/index.ts +0 -64
- package/tsconfig.build.json +0 -12
- package/tsconfig.json +0 -26
- package/vitest.config.ts +0 -13
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @btcp/core - DOM Actions
|
|
3
|
+
*
|
|
4
|
+
* Element interaction handlers using native browser APIs.
|
|
5
|
+
*/
|
|
6
|
+
import { createSnapshot } from './snapshot.js';
|
|
7
|
+
import { DetailedError, createElementNotFoundError, createElementNotCompatibleError, createTimeoutError, createInvalidParametersError, } from './errors.js';
|
|
8
|
+
/**
|
|
9
|
+
* DOM Actions executor
|
|
10
|
+
*/
|
|
11
|
+
export class DOMActions {
|
|
12
|
+
document;
|
|
13
|
+
window;
|
|
14
|
+
refMap;
|
|
15
|
+
lastSnapshotData = null;
|
|
16
|
+
overlayContainer = null;
|
|
17
|
+
scrollListener = null;
|
|
18
|
+
rafId = null;
|
|
19
|
+
constructor(doc, win, refMap) {
|
|
20
|
+
this.document = doc;
|
|
21
|
+
this.window = win;
|
|
22
|
+
this.refMap = refMap;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Execute a command and return a response
|
|
26
|
+
*/
|
|
27
|
+
async execute(command) {
|
|
28
|
+
try {
|
|
29
|
+
const data = await this.dispatch(command);
|
|
30
|
+
return { id: command.id, success: true, data };
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
// Include structured error data if available
|
|
35
|
+
if (error instanceof DetailedError) {
|
|
36
|
+
return {
|
|
37
|
+
id: command.id,
|
|
38
|
+
success: false,
|
|
39
|
+
error: message,
|
|
40
|
+
errorCode: error.code,
|
|
41
|
+
errorContext: error.context,
|
|
42
|
+
suggestions: error.suggestions,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return { id: command.id, success: false, error: message };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async dispatch(command) {
|
|
49
|
+
switch (command.action) {
|
|
50
|
+
case 'click':
|
|
51
|
+
return this.click(command.selector, {
|
|
52
|
+
button: command.button,
|
|
53
|
+
clickCount: command.clickCount,
|
|
54
|
+
modifiers: command.modifiers,
|
|
55
|
+
});
|
|
56
|
+
case 'dblclick':
|
|
57
|
+
return this.dblclick(command.selector);
|
|
58
|
+
case 'type':
|
|
59
|
+
return this.type(command.selector, command.text, {
|
|
60
|
+
delay: command.delay,
|
|
61
|
+
clear: command.clear,
|
|
62
|
+
});
|
|
63
|
+
case 'fill':
|
|
64
|
+
return this.fill(command.selector, command.value);
|
|
65
|
+
case 'clear':
|
|
66
|
+
return this.clear(command.selector);
|
|
67
|
+
case 'check':
|
|
68
|
+
return this.check(command.selector);
|
|
69
|
+
case 'uncheck':
|
|
70
|
+
return this.uncheck(command.selector);
|
|
71
|
+
case 'select':
|
|
72
|
+
return this.select(command.selector, command.values);
|
|
73
|
+
case 'focus':
|
|
74
|
+
return this.focus(command.selector);
|
|
75
|
+
case 'blur':
|
|
76
|
+
return this.blur(command.selector);
|
|
77
|
+
case 'hover':
|
|
78
|
+
return this.hover(command.selector);
|
|
79
|
+
case 'scroll':
|
|
80
|
+
return this.scroll(command.selector, {
|
|
81
|
+
x: command.x,
|
|
82
|
+
y: command.y,
|
|
83
|
+
direction: command.direction,
|
|
84
|
+
amount: command.amount,
|
|
85
|
+
});
|
|
86
|
+
case 'scrollIntoView':
|
|
87
|
+
return this.scrollIntoView(command.selector, command.block);
|
|
88
|
+
case 'snapshot':
|
|
89
|
+
return this.snapshot({
|
|
90
|
+
selector: command.selector,
|
|
91
|
+
maxDepth: command.maxDepth,
|
|
92
|
+
includeHidden: command.includeHidden,
|
|
93
|
+
interactive: command.interactive,
|
|
94
|
+
compact: command.compact,
|
|
95
|
+
all: command.all,
|
|
96
|
+
format: command.format,
|
|
97
|
+
grep: command.grep,
|
|
98
|
+
});
|
|
99
|
+
case 'querySelector':
|
|
100
|
+
return this.querySelector(command.selector);
|
|
101
|
+
case 'querySelectorAll':
|
|
102
|
+
return this.querySelectorAll(command.selector);
|
|
103
|
+
case 'getText':
|
|
104
|
+
return this.getText(command.selector);
|
|
105
|
+
case 'getAttribute':
|
|
106
|
+
return this.getAttribute(command.selector, command.attribute);
|
|
107
|
+
case 'getProperty':
|
|
108
|
+
return this.getProperty(command.selector, command.property);
|
|
109
|
+
case 'getBoundingBox':
|
|
110
|
+
return this.getBoundingBox(command.selector);
|
|
111
|
+
case 'isVisible':
|
|
112
|
+
return this.isVisible(command.selector);
|
|
113
|
+
case 'isEnabled':
|
|
114
|
+
return this.isEnabled(command.selector);
|
|
115
|
+
case 'isChecked':
|
|
116
|
+
return this.isChecked(command.selector);
|
|
117
|
+
case 'press':
|
|
118
|
+
return this.press(command.key, command.selector, command.modifiers);
|
|
119
|
+
case 'keyDown':
|
|
120
|
+
return this.keyDown(command.key);
|
|
121
|
+
case 'keyUp':
|
|
122
|
+
return this.keyUp(command.key);
|
|
123
|
+
case 'wait':
|
|
124
|
+
return this.wait(command.selector, {
|
|
125
|
+
state: command.state,
|
|
126
|
+
timeout: command.timeout,
|
|
127
|
+
});
|
|
128
|
+
case 'evaluate':
|
|
129
|
+
return this.evaluate(command.script, command.args);
|
|
130
|
+
case 'validateElement':
|
|
131
|
+
return this.validateElement(command.selector, {
|
|
132
|
+
expectedType: command.expectedType,
|
|
133
|
+
capabilities: command.capabilities,
|
|
134
|
+
});
|
|
135
|
+
case 'validateRefs':
|
|
136
|
+
return this.validateRefs(command.refs);
|
|
137
|
+
case 'highlight':
|
|
138
|
+
return this.highlight();
|
|
139
|
+
case 'clearHighlight':
|
|
140
|
+
return this.clearHighlight();
|
|
141
|
+
default:
|
|
142
|
+
throw new Error(`Unknown action: ${command.action}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// --- Element Resolution ---
|
|
146
|
+
getElement(selector) {
|
|
147
|
+
const element = this.queryElement(selector);
|
|
148
|
+
if (!element) {
|
|
149
|
+
const isRef = selector.startsWith('@ref:');
|
|
150
|
+
const similarSelectors = isRef ? [] : this.findSimilarSelectors(selector);
|
|
151
|
+
const nearbyElements = this.getNearbyInteractiveElements();
|
|
152
|
+
throw createElementNotFoundError(selector, {
|
|
153
|
+
similarSelectors,
|
|
154
|
+
nearbyElements: nearbyElements.slice(0, 5),
|
|
155
|
+
isRef,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return element;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Find selectors similar to the given selector
|
|
162
|
+
*/
|
|
163
|
+
findSimilarSelectors(selector) {
|
|
164
|
+
const results = [];
|
|
165
|
+
try {
|
|
166
|
+
// Try to extract ID or class from selector
|
|
167
|
+
const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/);
|
|
168
|
+
const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/);
|
|
169
|
+
if (idMatch) {
|
|
170
|
+
// Look for similar IDs
|
|
171
|
+
const targetId = idMatch[1].toLowerCase();
|
|
172
|
+
const allElements = this.document.querySelectorAll('[id]');
|
|
173
|
+
allElements.forEach(el => {
|
|
174
|
+
const elId = el.id.toLowerCase();
|
|
175
|
+
if (elId !== targetId && (elId.includes(targetId) || targetId.includes(elId))) {
|
|
176
|
+
const role = el.getAttribute('role') || el.tagName.toLowerCase();
|
|
177
|
+
const name = el.textContent?.trim().substring(0, 30) || el.getAttribute('aria-label') || '';
|
|
178
|
+
results.push({
|
|
179
|
+
selector: `#${el.id}`,
|
|
180
|
+
role,
|
|
181
|
+
name,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (classMatch && results.length < 3) {
|
|
187
|
+
// Look for similar classes
|
|
188
|
+
const targetClass = classMatch[1].toLowerCase();
|
|
189
|
+
const allElements = this.document.querySelectorAll('[class]');
|
|
190
|
+
allElements.forEach(el => {
|
|
191
|
+
const classes = Array.from(el.classList).map(c => c.toLowerCase());
|
|
192
|
+
const similarClass = classes.find(c => c !== targetClass && (c.includes(targetClass) || targetClass.includes(c)));
|
|
193
|
+
if (similarClass) {
|
|
194
|
+
const role = el.getAttribute('role') || el.tagName.toLowerCase();
|
|
195
|
+
const name = el.textContent?.trim().substring(0, 30) || el.getAttribute('aria-label') || '';
|
|
196
|
+
results.push({
|
|
197
|
+
selector: `.${similarClass}`,
|
|
198
|
+
role,
|
|
199
|
+
name,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
// Ignore errors in similarity search
|
|
207
|
+
}
|
|
208
|
+
return results.slice(0, 3);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Get nearby interactive elements
|
|
212
|
+
*/
|
|
213
|
+
getNearbyInteractiveElements() {
|
|
214
|
+
const results = [];
|
|
215
|
+
try {
|
|
216
|
+
const interactiveSelectors = [
|
|
217
|
+
'button',
|
|
218
|
+
'a[href]',
|
|
219
|
+
'input',
|
|
220
|
+
'textarea',
|
|
221
|
+
'select',
|
|
222
|
+
'[role="button"]',
|
|
223
|
+
'[role="link"]',
|
|
224
|
+
'[tabindex]'
|
|
225
|
+
];
|
|
226
|
+
const elements = this.document.querySelectorAll(interactiveSelectors.join(','));
|
|
227
|
+
elements.forEach(el => {
|
|
228
|
+
if (el instanceof HTMLElement) {
|
|
229
|
+
const style = this.window.getComputedStyle(el);
|
|
230
|
+
const isVisible = style.display !== 'none' && style.visibility !== 'hidden';
|
|
231
|
+
if (isVisible) {
|
|
232
|
+
const ref = this.refMap.generateRef(el);
|
|
233
|
+
const role = el.getAttribute('role') || el.tagName.toLowerCase();
|
|
234
|
+
const name = el.textContent?.trim().substring(0, 30) ||
|
|
235
|
+
el.getAttribute('aria-label') ||
|
|
236
|
+
el.value?.substring(0, 30) ||
|
|
237
|
+
el.placeholder ||
|
|
238
|
+
'';
|
|
239
|
+
results.push({ ref, role, name });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
// Ignore errors in nearby element search
|
|
246
|
+
}
|
|
247
|
+
return results.slice(0, 10);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get available actions for an element based on its type
|
|
251
|
+
*/
|
|
252
|
+
getAvailableActionsForElement(element) {
|
|
253
|
+
const actions = [];
|
|
254
|
+
// All elements can be queried and inspected
|
|
255
|
+
actions.push('querySelector', 'getText', 'getAttribute', 'getProperty', 'getBoundingBox', 'isVisible');
|
|
256
|
+
// Clickable elements
|
|
257
|
+
if (element instanceof HTMLButtonElement ||
|
|
258
|
+
element instanceof HTMLAnchorElement ||
|
|
259
|
+
element.getAttribute('role') === 'button' ||
|
|
260
|
+
element.getAttribute('role') === 'link' ||
|
|
261
|
+
element.hasAttribute('onclick')) {
|
|
262
|
+
actions.push('click', 'dblclick', 'hover');
|
|
263
|
+
}
|
|
264
|
+
// Input elements
|
|
265
|
+
if (element instanceof HTMLInputElement) {
|
|
266
|
+
actions.push('fill', 'clear', 'focus', 'blur', 'isEnabled');
|
|
267
|
+
if (element.type === 'checkbox' || element.type === 'radio') {
|
|
268
|
+
actions.push('check', 'uncheck', 'isChecked');
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
actions.push('type');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Textarea elements
|
|
275
|
+
if (element instanceof HTMLTextAreaElement) {
|
|
276
|
+
actions.push('type', 'fill', 'clear', 'focus', 'blur');
|
|
277
|
+
}
|
|
278
|
+
// Select elements
|
|
279
|
+
if (element instanceof HTMLSelectElement) {
|
|
280
|
+
actions.push('select', 'focus', 'blur');
|
|
281
|
+
}
|
|
282
|
+
// Focusable elements
|
|
283
|
+
if (element instanceof HTMLElement) {
|
|
284
|
+
actions.push('focus', 'blur', 'scroll', 'scrollIntoView', 'press');
|
|
285
|
+
}
|
|
286
|
+
return actions;
|
|
287
|
+
}
|
|
288
|
+
queryElement(selector) {
|
|
289
|
+
// Check if it's a ref
|
|
290
|
+
if (selector.startsWith('@ref:')) {
|
|
291
|
+
return this.refMap.get(selector);
|
|
292
|
+
}
|
|
293
|
+
// CSS selector
|
|
294
|
+
return this.document.querySelector(selector);
|
|
295
|
+
}
|
|
296
|
+
queryElements(selector) {
|
|
297
|
+
if (selector.startsWith('@ref:')) {
|
|
298
|
+
const el = this.refMap.get(selector);
|
|
299
|
+
return el ? [el] : [];
|
|
300
|
+
}
|
|
301
|
+
return Array.from(this.document.querySelectorAll(selector));
|
|
302
|
+
}
|
|
303
|
+
// --- Actions ---
|
|
304
|
+
async click(selector, options = {}) {
|
|
305
|
+
const element = this.getElement(selector);
|
|
306
|
+
const { button = 'left', clickCount = 1, modifiers = [] } = options;
|
|
307
|
+
if (element instanceof HTMLElement) {
|
|
308
|
+
element.focus();
|
|
309
|
+
}
|
|
310
|
+
const buttonCode = button === 'right' ? 2 : button === 'middle' ? 1 : 0;
|
|
311
|
+
const eventInit = {
|
|
312
|
+
bubbles: true,
|
|
313
|
+
cancelable: true,
|
|
314
|
+
button: buttonCode,
|
|
315
|
+
altKey: modifiers.includes('Alt'),
|
|
316
|
+
ctrlKey: modifiers.includes('Control'),
|
|
317
|
+
metaKey: modifiers.includes('Meta'),
|
|
318
|
+
shiftKey: modifiers.includes('Shift'),
|
|
319
|
+
};
|
|
320
|
+
for (let i = 0; i < clickCount; i++) {
|
|
321
|
+
element.dispatchEvent(new MouseEvent('mousedown', eventInit));
|
|
322
|
+
element.dispatchEvent(new MouseEvent('mouseup', eventInit));
|
|
323
|
+
element.dispatchEvent(new MouseEvent('click', eventInit));
|
|
324
|
+
}
|
|
325
|
+
return { clicked: true };
|
|
326
|
+
}
|
|
327
|
+
async dblclick(selector) {
|
|
328
|
+
const element = this.getElement(selector);
|
|
329
|
+
element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
|
|
330
|
+
return { clicked: true };
|
|
331
|
+
}
|
|
332
|
+
async type(selector, text, options = {}) {
|
|
333
|
+
const element = this.getElement(selector);
|
|
334
|
+
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
|
|
335
|
+
const actualType = element.tagName.toLowerCase();
|
|
336
|
+
const availableActions = this.getAvailableActionsForElement(element);
|
|
337
|
+
throw createElementNotCompatibleError(selector, 'type', actualType, ['input', 'textarea'], availableActions);
|
|
338
|
+
}
|
|
339
|
+
element.focus();
|
|
340
|
+
if (options.clear) {
|
|
341
|
+
element.value = '';
|
|
342
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
343
|
+
}
|
|
344
|
+
for (const char of text) {
|
|
345
|
+
element.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true }));
|
|
346
|
+
element.dispatchEvent(new KeyboardEvent('keypress', { key: char, bubbles: true }));
|
|
347
|
+
element.value += char;
|
|
348
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
349
|
+
element.dispatchEvent(new KeyboardEvent('keyup', { key: char, bubbles: true }));
|
|
350
|
+
if (options.delay) {
|
|
351
|
+
await this.sleep(options.delay);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
355
|
+
return { typed: true };
|
|
356
|
+
}
|
|
357
|
+
async fill(selector, value) {
|
|
358
|
+
const element = this.getElement(selector);
|
|
359
|
+
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
|
|
360
|
+
const actualType = element.tagName.toLowerCase();
|
|
361
|
+
const availableActions = this.getAvailableActionsForElement(element);
|
|
362
|
+
throw createElementNotCompatibleError(selector, 'fill', actualType, ['input', 'textarea'], availableActions);
|
|
363
|
+
}
|
|
364
|
+
element.focus();
|
|
365
|
+
element.value = value;
|
|
366
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
367
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
368
|
+
return { filled: true };
|
|
369
|
+
}
|
|
370
|
+
async clear(selector) {
|
|
371
|
+
const element = this.getElement(selector);
|
|
372
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
373
|
+
element.value = '';
|
|
374
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
375
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
376
|
+
}
|
|
377
|
+
return { cleared: true };
|
|
378
|
+
}
|
|
379
|
+
async check(selector) {
|
|
380
|
+
const element = this.getElement(selector);
|
|
381
|
+
if (!(element instanceof HTMLInputElement)) {
|
|
382
|
+
const actualType = element.tagName.toLowerCase();
|
|
383
|
+
const availableActions = this.getAvailableActionsForElement(element);
|
|
384
|
+
throw createElementNotCompatibleError(selector, 'check', actualType, ['input[type=checkbox]', 'input[type=radio]'], availableActions);
|
|
385
|
+
}
|
|
386
|
+
if (!element.checked) {
|
|
387
|
+
element.click();
|
|
388
|
+
}
|
|
389
|
+
return { checked: true };
|
|
390
|
+
}
|
|
391
|
+
async uncheck(selector) {
|
|
392
|
+
const element = this.getElement(selector);
|
|
393
|
+
if (!(element instanceof HTMLInputElement)) {
|
|
394
|
+
const actualType = element.tagName.toLowerCase();
|
|
395
|
+
const availableActions = this.getAvailableActionsForElement(element);
|
|
396
|
+
throw createElementNotCompatibleError(selector, 'uncheck', actualType, ['input[type=checkbox]'], availableActions);
|
|
397
|
+
}
|
|
398
|
+
if (element.checked) {
|
|
399
|
+
element.click();
|
|
400
|
+
}
|
|
401
|
+
return { unchecked: true };
|
|
402
|
+
}
|
|
403
|
+
async select(selector, values) {
|
|
404
|
+
const element = this.getElement(selector);
|
|
405
|
+
if (!(element instanceof HTMLSelectElement)) {
|
|
406
|
+
const actualType = element.tagName.toLowerCase();
|
|
407
|
+
const availableActions = this.getAvailableActionsForElement(element);
|
|
408
|
+
throw createElementNotCompatibleError(selector, 'select', actualType, ['select'], availableActions);
|
|
409
|
+
}
|
|
410
|
+
const valueArray = Array.isArray(values) ? values : [values];
|
|
411
|
+
for (const option of element.options) {
|
|
412
|
+
option.selected = valueArray.includes(option.value);
|
|
413
|
+
}
|
|
414
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
415
|
+
return { selected: valueArray };
|
|
416
|
+
}
|
|
417
|
+
async focus(selector) {
|
|
418
|
+
const element = this.getElement(selector);
|
|
419
|
+
if (element instanceof HTMLElement) {
|
|
420
|
+
element.focus();
|
|
421
|
+
}
|
|
422
|
+
return { focused: true };
|
|
423
|
+
}
|
|
424
|
+
async blur(selector) {
|
|
425
|
+
const element = this.getElement(selector);
|
|
426
|
+
if (element instanceof HTMLElement) {
|
|
427
|
+
element.blur();
|
|
428
|
+
}
|
|
429
|
+
return { blurred: true };
|
|
430
|
+
}
|
|
431
|
+
async hover(selector) {
|
|
432
|
+
const element = this.getElement(selector);
|
|
433
|
+
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
434
|
+
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
435
|
+
return { hovered: true };
|
|
436
|
+
}
|
|
437
|
+
async scroll(selector, options) {
|
|
438
|
+
// Validate parameter combinations
|
|
439
|
+
const hasXY = options.x !== undefined || options.y !== undefined;
|
|
440
|
+
const hasDirection = options.direction !== undefined;
|
|
441
|
+
if (hasXY && hasDirection) {
|
|
442
|
+
throw createInvalidParametersError('Scroll command has conflicting parameters', ['x/y', 'direction'], 'Use either { x, y } for absolute scrolling OR { direction, amount } for relative scrolling, not both');
|
|
443
|
+
}
|
|
444
|
+
let deltaX = options.x ?? 0;
|
|
445
|
+
let deltaY = options.y ?? 0;
|
|
446
|
+
if (options.direction) {
|
|
447
|
+
const amount = options.amount ?? 100;
|
|
448
|
+
switch (options.direction) {
|
|
449
|
+
case 'up':
|
|
450
|
+
deltaY = -amount;
|
|
451
|
+
break;
|
|
452
|
+
case 'down':
|
|
453
|
+
deltaY = amount;
|
|
454
|
+
break;
|
|
455
|
+
case 'left':
|
|
456
|
+
deltaX = -amount;
|
|
457
|
+
break;
|
|
458
|
+
case 'right':
|
|
459
|
+
deltaX = amount;
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (selector) {
|
|
464
|
+
const element = this.getElement(selector);
|
|
465
|
+
element.scrollBy(deltaX, deltaY);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
this.window.scrollBy(deltaX, deltaY);
|
|
469
|
+
}
|
|
470
|
+
return { scrolled: true };
|
|
471
|
+
}
|
|
472
|
+
async scrollIntoView(selector, block = 'center') {
|
|
473
|
+
const element = this.getElement(selector);
|
|
474
|
+
element.scrollIntoView({ behavior: 'smooth', block });
|
|
475
|
+
return { scrolled: true };
|
|
476
|
+
}
|
|
477
|
+
async snapshot(options) {
|
|
478
|
+
const root = options.selector
|
|
479
|
+
? this.getElement(options.selector)
|
|
480
|
+
: this.document.body;
|
|
481
|
+
const snapshotData = createSnapshot(this.document, this.refMap, {
|
|
482
|
+
root,
|
|
483
|
+
maxDepth: options.maxDepth,
|
|
484
|
+
includeHidden: options.includeHidden,
|
|
485
|
+
interactive: options.interactive,
|
|
486
|
+
compact: options.compact,
|
|
487
|
+
all: options.all,
|
|
488
|
+
format: options.format,
|
|
489
|
+
grep: options.grep,
|
|
490
|
+
});
|
|
491
|
+
// Store snapshot data for highlight command (preserve refs internally)
|
|
492
|
+
this.lastSnapshotData = snapshotData;
|
|
493
|
+
// Return only the tree string
|
|
494
|
+
return snapshotData.tree;
|
|
495
|
+
}
|
|
496
|
+
async querySelector(selector) {
|
|
497
|
+
const element = this.queryElement(selector);
|
|
498
|
+
if (!element) {
|
|
499
|
+
return { found: false };
|
|
500
|
+
}
|
|
501
|
+
const ref = this.refMap.generateRef(element);
|
|
502
|
+
return { found: true, ref };
|
|
503
|
+
}
|
|
504
|
+
async querySelectorAll(selector) {
|
|
505
|
+
const elements = this.queryElements(selector);
|
|
506
|
+
const refs = elements.map((el) => this.refMap.generateRef(el));
|
|
507
|
+
return { count: elements.length, refs };
|
|
508
|
+
}
|
|
509
|
+
async getText(selector) {
|
|
510
|
+
const element = this.getElement(selector);
|
|
511
|
+
return { text: element.textContent };
|
|
512
|
+
}
|
|
513
|
+
async getAttribute(selector, attribute) {
|
|
514
|
+
const element = this.getElement(selector);
|
|
515
|
+
return { value: element.getAttribute(attribute) };
|
|
516
|
+
}
|
|
517
|
+
async getProperty(selector, property) {
|
|
518
|
+
const element = this.getElement(selector);
|
|
519
|
+
return { value: element[property] };
|
|
520
|
+
}
|
|
521
|
+
async getBoundingBox(selector) {
|
|
522
|
+
const element = this.getElement(selector);
|
|
523
|
+
const rect = element.getBoundingClientRect();
|
|
524
|
+
return {
|
|
525
|
+
box: {
|
|
526
|
+
x: rect.x,
|
|
527
|
+
y: rect.y,
|
|
528
|
+
width: rect.width,
|
|
529
|
+
height: rect.height,
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
async isVisible(selector) {
|
|
534
|
+
const element = this.queryElement(selector);
|
|
535
|
+
if (!element || !(element instanceof HTMLElement)) {
|
|
536
|
+
return { visible: false };
|
|
537
|
+
}
|
|
538
|
+
const style = this.window.getComputedStyle(element);
|
|
539
|
+
const visible = style.display !== 'none' &&
|
|
540
|
+
style.visibility !== 'hidden' &&
|
|
541
|
+
style.opacity !== '0';
|
|
542
|
+
return { visible };
|
|
543
|
+
}
|
|
544
|
+
async isEnabled(selector) {
|
|
545
|
+
const element = this.getElement(selector);
|
|
546
|
+
const enabled = !element.disabled;
|
|
547
|
+
return { enabled };
|
|
548
|
+
}
|
|
549
|
+
async isChecked(selector) {
|
|
550
|
+
const element = this.getElement(selector);
|
|
551
|
+
const checked = element.checked ?? false;
|
|
552
|
+
return { checked };
|
|
553
|
+
}
|
|
554
|
+
async press(key, selector, modifiers = []) {
|
|
555
|
+
const target = selector
|
|
556
|
+
? this.getElement(selector)
|
|
557
|
+
: this.document.activeElement || this.document.body;
|
|
558
|
+
const eventInit = {
|
|
559
|
+
key,
|
|
560
|
+
code: key,
|
|
561
|
+
bubbles: true,
|
|
562
|
+
cancelable: true,
|
|
563
|
+
altKey: modifiers.includes('Alt'),
|
|
564
|
+
ctrlKey: modifiers.includes('Control'),
|
|
565
|
+
metaKey: modifiers.includes('Meta'),
|
|
566
|
+
shiftKey: modifiers.includes('Shift'),
|
|
567
|
+
};
|
|
568
|
+
target.dispatchEvent(new KeyboardEvent('keydown', eventInit));
|
|
569
|
+
target.dispatchEvent(new KeyboardEvent('keypress', eventInit));
|
|
570
|
+
target.dispatchEvent(new KeyboardEvent('keyup', eventInit));
|
|
571
|
+
return { pressed: true };
|
|
572
|
+
}
|
|
573
|
+
async keyDown(key) {
|
|
574
|
+
const target = this.document.activeElement || this.document.body;
|
|
575
|
+
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
|
576
|
+
return { down: true };
|
|
577
|
+
}
|
|
578
|
+
async keyUp(key) {
|
|
579
|
+
const target = this.document.activeElement || this.document.body;
|
|
580
|
+
target.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
|
581
|
+
return { up: true };
|
|
582
|
+
}
|
|
583
|
+
async wait(selector, options = {}) {
|
|
584
|
+
const { state = 'visible', timeout = 5000 } = options;
|
|
585
|
+
if (!selector) {
|
|
586
|
+
await this.sleep(timeout);
|
|
587
|
+
return { waited: true };
|
|
588
|
+
}
|
|
589
|
+
const startTime = Date.now();
|
|
590
|
+
let lastState;
|
|
591
|
+
while (Date.now() - startTime < timeout) {
|
|
592
|
+
const element = this.queryElement(selector);
|
|
593
|
+
// Track element state for error reporting
|
|
594
|
+
if (element instanceof HTMLElement) {
|
|
595
|
+
const style = this.window.getComputedStyle(element);
|
|
596
|
+
lastState = {
|
|
597
|
+
attached: true,
|
|
598
|
+
visible: style.display !== 'none' && style.visibility !== 'hidden',
|
|
599
|
+
enabled: !element.disabled,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
else if (element) {
|
|
603
|
+
lastState = {
|
|
604
|
+
attached: true,
|
|
605
|
+
visible: false,
|
|
606
|
+
enabled: true,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
let conditionMet = false;
|
|
610
|
+
switch (state) {
|
|
611
|
+
case 'attached':
|
|
612
|
+
conditionMet = element !== null;
|
|
613
|
+
break;
|
|
614
|
+
case 'detached':
|
|
615
|
+
conditionMet = element === null;
|
|
616
|
+
break;
|
|
617
|
+
case 'visible':
|
|
618
|
+
if (element instanceof HTMLElement) {
|
|
619
|
+
const style = this.window.getComputedStyle(element);
|
|
620
|
+
conditionMet =
|
|
621
|
+
style.display !== 'none' &&
|
|
622
|
+
style.visibility !== 'hidden';
|
|
623
|
+
}
|
|
624
|
+
break;
|
|
625
|
+
case 'hidden':
|
|
626
|
+
conditionMet =
|
|
627
|
+
!element ||
|
|
628
|
+
(element instanceof HTMLElement &&
|
|
629
|
+
this.window.getComputedStyle(element).display === 'none');
|
|
630
|
+
break;
|
|
631
|
+
case 'enabled':
|
|
632
|
+
conditionMet = element !== null && !element.disabled;
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
if (conditionMet) {
|
|
636
|
+
return { waited: true };
|
|
637
|
+
}
|
|
638
|
+
await this.sleep(100);
|
|
639
|
+
}
|
|
640
|
+
// Provide detailed timeout error with current state
|
|
641
|
+
throw createTimeoutError(selector, state, lastState);
|
|
642
|
+
}
|
|
643
|
+
async evaluate(script, args) {
|
|
644
|
+
const fn = new Function(...(args?.map((_, i) => `arg${i}`) || []), `return (${script})`);
|
|
645
|
+
const result = fn.call(this.window, ...(args || []));
|
|
646
|
+
return { result };
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Validate element capabilities before attempting an action
|
|
650
|
+
*/
|
|
651
|
+
async validateElement(selector, options = {}) {
|
|
652
|
+
const element = this.getElement(selector);
|
|
653
|
+
const actualRole = element.getAttribute('role') || element.tagName.toLowerCase();
|
|
654
|
+
const actualType = element instanceof HTMLInputElement ? element.type : undefined;
|
|
655
|
+
// Get element capabilities
|
|
656
|
+
const capabilities = this.getAvailableActionsForElement(element);
|
|
657
|
+
// Get element state
|
|
658
|
+
const style = element instanceof HTMLElement ? this.window.getComputedStyle(element) : null;
|
|
659
|
+
const state = {
|
|
660
|
+
attached: true,
|
|
661
|
+
visible: style ? style.display !== 'none' && style.visibility !== 'hidden' : false,
|
|
662
|
+
enabled: !element.disabled,
|
|
663
|
+
};
|
|
664
|
+
// Check type compatibility
|
|
665
|
+
let compatible = true;
|
|
666
|
+
let suggestion;
|
|
667
|
+
if (options.expectedType) {
|
|
668
|
+
const typeMatch = options.expectedType === actualRole ||
|
|
669
|
+
(options.expectedType === 'input' && element instanceof HTMLInputElement) ||
|
|
670
|
+
(options.expectedType === 'textarea' && element instanceof HTMLTextAreaElement) ||
|
|
671
|
+
(options.expectedType === 'button' && element instanceof HTMLButtonElement) ||
|
|
672
|
+
(options.expectedType === 'link' && element instanceof HTMLAnchorElement) ||
|
|
673
|
+
(options.expectedType === 'select' && element instanceof HTMLSelectElement);
|
|
674
|
+
if (!typeMatch) {
|
|
675
|
+
compatible = false;
|
|
676
|
+
suggestion = `Element is ${actualRole}, not ${options.expectedType}. Available actions: ${capabilities.slice(0, 5).join(', ')}`;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// Check capability requirements
|
|
680
|
+
if (options.capabilities && compatible) {
|
|
681
|
+
const capabilityMap = {
|
|
682
|
+
clickable: element instanceof HTMLButtonElement ||
|
|
683
|
+
element instanceof HTMLAnchorElement ||
|
|
684
|
+
element.getAttribute('role') === 'button' ||
|
|
685
|
+
element.hasAttribute('onclick'),
|
|
686
|
+
editable: element instanceof HTMLInputElement ||
|
|
687
|
+
element instanceof HTMLTextAreaElement,
|
|
688
|
+
checkable: element instanceof HTMLInputElement &&
|
|
689
|
+
(element.type === 'checkbox' || element.type === 'radio'),
|
|
690
|
+
hoverable: element instanceof HTMLElement,
|
|
691
|
+
};
|
|
692
|
+
for (const cap of options.capabilities) {
|
|
693
|
+
if (!capabilityMap[cap]) {
|
|
694
|
+
compatible = false;
|
|
695
|
+
suggestion = `Element does not support capability: ${cap}. Available actions: ${capabilities.slice(0, 5).join(', ')}`;
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
compatible,
|
|
702
|
+
actualRole,
|
|
703
|
+
actualType,
|
|
704
|
+
capabilities,
|
|
705
|
+
state,
|
|
706
|
+
suggestion,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Validate that refs are still valid
|
|
711
|
+
*/
|
|
712
|
+
async validateRefs(refs) {
|
|
713
|
+
const valid = [];
|
|
714
|
+
const invalid = [];
|
|
715
|
+
const reasons = {};
|
|
716
|
+
for (const ref of refs) {
|
|
717
|
+
const element = this.refMap.get(ref);
|
|
718
|
+
if (element) {
|
|
719
|
+
// Check if element is still in the DOM
|
|
720
|
+
if (this.document.contains(element)) {
|
|
721
|
+
valid.push(ref);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
invalid.push(ref);
|
|
725
|
+
reasons[ref] = 'Element has been removed from the DOM';
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
invalid.push(ref);
|
|
730
|
+
reasons[ref] = 'Ref not found. Refs are cleared on each snapshot() call.';
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
valid,
|
|
735
|
+
invalid,
|
|
736
|
+
reasons,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Display visual overlay labels for interactive elements
|
|
741
|
+
*/
|
|
742
|
+
async highlight() {
|
|
743
|
+
// Verify snapshot exists
|
|
744
|
+
if (!this.lastSnapshotData || !this.lastSnapshotData.refs) {
|
|
745
|
+
throw new Error('No snapshot data available. Please run snapshot() command first.');
|
|
746
|
+
}
|
|
747
|
+
// Clear any existing highlights
|
|
748
|
+
this.clearExistingOverlay();
|
|
749
|
+
// Create overlay container with absolute positioning covering entire document
|
|
750
|
+
this.overlayContainer = this.document.createElement('div');
|
|
751
|
+
this.overlayContainer.id = 'btcp-highlight-overlay';
|
|
752
|
+
this.overlayContainer.style.cssText = `
|
|
753
|
+
position: absolute;
|
|
754
|
+
top: 0;
|
|
755
|
+
left: 0;
|
|
756
|
+
width: ${this.document.documentElement.scrollWidth}px;
|
|
757
|
+
height: ${this.document.documentElement.scrollHeight}px;
|
|
758
|
+
pointer-events: none;
|
|
759
|
+
z-index: 999999;
|
|
760
|
+
contain: layout style paint;
|
|
761
|
+
`;
|
|
762
|
+
let highlightedCount = 0;
|
|
763
|
+
// Create border overlays and labels for each ref
|
|
764
|
+
for (const [ref, _refData] of Object.entries(this.lastSnapshotData.refs)) {
|
|
765
|
+
const element = this.refMap.get(ref);
|
|
766
|
+
// Skip if element no longer exists or is disconnected
|
|
767
|
+
if (!element || !element.isConnected) {
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
// Get current bounding box (element might have moved)
|
|
772
|
+
const bbox = element.getBoundingClientRect();
|
|
773
|
+
// Skip elements with no dimensions
|
|
774
|
+
if (bbox.width === 0 && bbox.height === 0) {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
// Create border overlay
|
|
778
|
+
const border = this.document.createElement('div');
|
|
779
|
+
border.className = 'btcp-ref-border';
|
|
780
|
+
border.dataset.ref = ref;
|
|
781
|
+
border.style.cssText = `
|
|
782
|
+
position: absolute;
|
|
783
|
+
width: ${bbox.width}px;
|
|
784
|
+
height: ${bbox.height}px;
|
|
785
|
+
transform: translate3d(${bbox.left + this.window.scrollX}px, ${bbox.top + this.window.scrollY}px, 0);
|
|
786
|
+
border: 2px solid rgba(59, 130, 246, 0.8);
|
|
787
|
+
border-radius: 2px;
|
|
788
|
+
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
|
|
789
|
+
pointer-events: none;
|
|
790
|
+
will-change: transform;
|
|
791
|
+
contain: layout style paint;
|
|
792
|
+
`;
|
|
793
|
+
// Create label
|
|
794
|
+
const label = this.document.createElement('div');
|
|
795
|
+
label.className = 'btcp-ref-label';
|
|
796
|
+
label.dataset.ref = ref;
|
|
797
|
+
// Extract number from ref (e.g., "@ref:5" -> "5")
|
|
798
|
+
label.textContent = ref.replace('@ref:', '');
|
|
799
|
+
label.style.cssText = `
|
|
800
|
+
position: absolute;
|
|
801
|
+
transform: translate3d(${bbox.left + this.window.scrollX}px, ${bbox.top + this.window.scrollY}px, 0);
|
|
802
|
+
background: rgba(59, 130, 246, 0.9);
|
|
803
|
+
color: white;
|
|
804
|
+
padding: 2px 6px;
|
|
805
|
+
border-radius: 3px;
|
|
806
|
+
font-family: monospace;
|
|
807
|
+
font-size: 11px;
|
|
808
|
+
font-weight: bold;
|
|
809
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
810
|
+
pointer-events: none;
|
|
811
|
+
white-space: nowrap;
|
|
812
|
+
will-change: transform;
|
|
813
|
+
contain: layout style paint;
|
|
814
|
+
`;
|
|
815
|
+
this.overlayContainer.appendChild(border);
|
|
816
|
+
this.overlayContainer.appendChild(label);
|
|
817
|
+
highlightedCount++;
|
|
818
|
+
}
|
|
819
|
+
catch (error) {
|
|
820
|
+
// Skip elements that throw errors
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// Inject overlay into page
|
|
825
|
+
this.document.body.appendChild(this.overlayContainer);
|
|
826
|
+
// Set up scroll listener with rAF throttling
|
|
827
|
+
let ticking = false;
|
|
828
|
+
this.scrollListener = () => {
|
|
829
|
+
if (!ticking) {
|
|
830
|
+
this.rafId = this.window.requestAnimationFrame(() => {
|
|
831
|
+
this.updateHighlightPositions();
|
|
832
|
+
ticking = false;
|
|
833
|
+
});
|
|
834
|
+
ticking = true;
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
// Use passive listener for better scroll performance
|
|
838
|
+
this.window.addEventListener('scroll', this.scrollListener, { passive: true });
|
|
839
|
+
return { highlighted: highlightedCount };
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Invalidate snapshot data (called on navigation or manual clear)
|
|
843
|
+
*/
|
|
844
|
+
invalidateSnapshot() {
|
|
845
|
+
this.lastSnapshotData = null;
|
|
846
|
+
this.clearHighlight();
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Update highlight positions on scroll (GPU-accelerated)
|
|
850
|
+
*/
|
|
851
|
+
updateHighlightPositions() {
|
|
852
|
+
if (!this.overlayContainer || !this.lastSnapshotData) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
// Batch DOM reads
|
|
856
|
+
const updates = [];
|
|
857
|
+
// Read phase - get all bounding boxes first
|
|
858
|
+
const borders = this.overlayContainer.querySelectorAll('.btcp-ref-border');
|
|
859
|
+
const labels = this.overlayContainer.querySelectorAll('.btcp-ref-label');
|
|
860
|
+
borders.forEach((borderEl) => {
|
|
861
|
+
const ref = borderEl.dataset.ref;
|
|
862
|
+
if (!ref)
|
|
863
|
+
return;
|
|
864
|
+
const element = this.refMap.get(ref);
|
|
865
|
+
if (!element || !element.isConnected)
|
|
866
|
+
return;
|
|
867
|
+
const bbox = element.getBoundingClientRect();
|
|
868
|
+
if (bbox.width === 0 && bbox.height === 0)
|
|
869
|
+
return;
|
|
870
|
+
updates.push({
|
|
871
|
+
element: borderEl,
|
|
872
|
+
x: bbox.left + this.window.scrollX,
|
|
873
|
+
y: bbox.top + this.window.scrollY,
|
|
874
|
+
width: bbox.width,
|
|
875
|
+
height: bbox.height,
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
labels.forEach((labelEl) => {
|
|
879
|
+
const ref = labelEl.dataset.ref;
|
|
880
|
+
if (!ref)
|
|
881
|
+
return;
|
|
882
|
+
const element = this.refMap.get(ref);
|
|
883
|
+
if (!element || !element.isConnected)
|
|
884
|
+
return;
|
|
885
|
+
const bbox = element.getBoundingClientRect();
|
|
886
|
+
if (bbox.width === 0 && bbox.height === 0)
|
|
887
|
+
return;
|
|
888
|
+
updates.push({
|
|
889
|
+
element: labelEl,
|
|
890
|
+
x: bbox.left + this.window.scrollX,
|
|
891
|
+
y: bbox.top + this.window.scrollY,
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
// Write phase - update transforms
|
|
895
|
+
updates.forEach(({ element, x, y, width, height }) => {
|
|
896
|
+
element.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
897
|
+
if (width !== undefined && height !== undefined) {
|
|
898
|
+
element.style.width = `${width}px`;
|
|
899
|
+
element.style.height = `${height}px`;
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Remove visual overlay labels
|
|
905
|
+
*/
|
|
906
|
+
async clearHighlight() {
|
|
907
|
+
this.clearExistingOverlay();
|
|
908
|
+
return { cleared: true };
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Remove existing overlay if it exists
|
|
912
|
+
*/
|
|
913
|
+
clearExistingOverlay() {
|
|
914
|
+
// Remove scroll listener
|
|
915
|
+
if (this.scrollListener) {
|
|
916
|
+
this.window.removeEventListener('scroll', this.scrollListener);
|
|
917
|
+
this.scrollListener = null;
|
|
918
|
+
}
|
|
919
|
+
// Cancel any pending animation frame
|
|
920
|
+
if (this.rafId !== null) {
|
|
921
|
+
this.window.cancelAnimationFrame(this.rafId);
|
|
922
|
+
this.rafId = null;
|
|
923
|
+
}
|
|
924
|
+
// Remove overlay container
|
|
925
|
+
if (this.overlayContainer && this.overlayContainer.parentNode) {
|
|
926
|
+
this.overlayContainer.parentNode.removeChild(this.overlayContainer);
|
|
927
|
+
}
|
|
928
|
+
this.overlayContainer = null;
|
|
929
|
+
// Also remove any orphaned overlays
|
|
930
|
+
const existingOverlay = this.document.getElementById('btcp-highlight-overlay');
|
|
931
|
+
if (existingOverlay && existingOverlay.parentNode) {
|
|
932
|
+
existingOverlay.parentNode.removeChild(existingOverlay);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
// --- Utilities ---
|
|
936
|
+
sleep(ms) {
|
|
937
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
//# sourceMappingURL=actions.js.map
|