@zumer/snapdom-plugins 1.0.2 → 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 +53 -0
- package/package.json +1 -1
- package/prompt-export.js +294 -43
package/README.md
CHANGED
|
@@ -170,6 +170,59 @@ snapdom(el, { plugins: [htmlInCanvas()] });
|
|
|
170
170
|
|
|
171
171
|
---
|
|
172
172
|
|
|
173
|
+
### `prompt-export`
|
|
174
|
+
|
|
175
|
+
Adds a `toPrompt()` export method that returns an LLM-ready package: an annotated screenshot, a structured element map with bounding boxes, and a pre-formatted text description. Useful for vision-language models, browser-agent pipelines, visual QA, and any workflow that pairs a capture with structured metadata.
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
import { promptExport } from '@zumer/snapdom-plugins/prompt-export';
|
|
179
|
+
|
|
180
|
+
const result = await snapdom(el, { plugins: [promptExport()] });
|
|
181
|
+
const { image, elements, dimensions, prompt } = await result.toPrompt();
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The returned object:
|
|
185
|
+
|
|
186
|
+
| Field | Type | Description |
|
|
187
|
+
|-------|------|-------------|
|
|
188
|
+
| `image` | `string` | Data URL of the (optionally annotated) screenshot |
|
|
189
|
+
| `elements` | `Array` | One entry per detected element: `{ id, tag, type, text, bbox, attributes }` |
|
|
190
|
+
| `dimensions` | `{width, height}` | Image dimensions after `maxImageWidth` scaling |
|
|
191
|
+
| `prompt` | `string` | Pre-formatted text describing interactive + semantic elements |
|
|
192
|
+
|
|
193
|
+
`elements` is split into two `type`s:
|
|
194
|
+
- `'interactive'` — buttons, links, inputs, `[role]`/`[tabindex]` targets. These get numbered badges overlaid on the screenshot when `annotate` is on.
|
|
195
|
+
- `'semantic'` — headings, paragraphs, `<nav>`, `<main>`, images with `alt`, table cells, etc. Structural context, not overlaid.
|
|
196
|
+
|
|
197
|
+
Each `bbox` is in pixel coordinates of the returned image (scaled against `maxImageWidth`).
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
// Example — feed a vision-capable LLM
|
|
201
|
+
const { image, elements } = await result.toPrompt({ maxImageWidth: 1024 });
|
|
202
|
+
|
|
203
|
+
// image is a data URL → pass as image input
|
|
204
|
+
// elements is JSON → pass as structured context alongside the image
|
|
205
|
+
// "Click element [3]" → look up elements[3].bbox for real coordinates
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
| Option | Type | Default | Description |
|
|
209
|
+
|--------|------|---------|-------------|
|
|
210
|
+
| `annotate` | `boolean` | `true` | Overlay numbered badges on interactive elements |
|
|
211
|
+
| `imageFormat` | `'png' \| 'jpg' \| 'webp'` | `'png'` | Output image format |
|
|
212
|
+
| `imageQuality` | `number` | `0.8` | Quality for lossy formats (0–1) |
|
|
213
|
+
| `maxImageWidth` | `number` | `1024` | Max width in px; downscales and rescales bboxes if larger |
|
|
214
|
+
| `interactiveSelector` | `string` | see below | CSS selector for the interactive element set |
|
|
215
|
+
| `semanticSelector` | `string` | see below | CSS selector for the semantic element set |
|
|
216
|
+
| `labelStyle` | `object` | `{}` | Override styles for the numbered badges (`position`, `color`, `backgroundColor`, etc.) |
|
|
217
|
+
|
|
218
|
+
Defaults:
|
|
219
|
+
- **interactive**: `a[href], button, input, select, textarea, [role="button"|"link"|"tab"|"menuitem"|"checkbox"|"radio"], [tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]`
|
|
220
|
+
- **semantic**: `h1–h6, p, li, img[alt], nav, main, article, section, header, footer, label, td, th, figcaption, blockquote, legend`
|
|
221
|
+
|
|
222
|
+
Both per-call options (`opts.imageFormat`, etc.) and constructor options are supported; per-call wins.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
173
226
|
## Plugin registration
|
|
174
227
|
|
|
175
228
|
**Global** (applies to all captures):
|
package/package.json
CHANGED
package/prompt-export.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* promptExport – Official SnapDOM Plugin
|
|
3
|
-
* Produces an LLM-ready package: annotated screenshot + structured element
|
|
3
|
+
* Produces an LLM-ready package: annotated screenshot + structured element
|
|
4
|
+
* map + prompt text. Tuned for vision-capable LLMs reading the image + map
|
|
5
|
+
* together (Set-of-Mark pattern).
|
|
4
6
|
*
|
|
5
7
|
* Usage:
|
|
6
8
|
* import { promptExport } from '@zumer/snapdom-plugins/prompt-export';
|
|
@@ -9,12 +11,14 @@
|
|
|
9
11
|
*
|
|
10
12
|
* @param {Object} [options]
|
|
11
13
|
* @param {boolean} [options.annotate=true] - Overlay numbered badges on interactive elements
|
|
12
|
-
* @param {string} [options.imageFormat='png']
|
|
13
|
-
* @param {number} [options.imageQuality=0.8]
|
|
14
|
-
* @param {number} [options.maxImageWidth=1024]
|
|
15
|
-
* @param {string} [options.interactiveSelector]
|
|
16
|
-
* @param {string} [options.semanticSelector]
|
|
17
|
-
* @param {Object} [options.labelStyle={}]
|
|
14
|
+
* @param {string} [options.imageFormat='png'] - Output image format ('png'|'jpg'|'webp')
|
|
15
|
+
* @param {number} [options.imageQuality=0.8] - Quality for lossy formats (0..1)
|
|
16
|
+
* @param {number} [options.maxImageWidth=1024] - Max width in px (downscales if larger)
|
|
17
|
+
* @param {string} [options.interactiveSelector] - Custom CSS selector for interactive elements
|
|
18
|
+
* @param {string} [options.semanticSelector] - Custom CSS selector for semantic elements
|
|
19
|
+
* @param {Object} [options.labelStyle={}] - Override styles for annotation badges
|
|
20
|
+
* @param {'compact'|'verbose'} [options.promptMode='compact'] - Prompt text verbosity
|
|
21
|
+
* @param {boolean} [options.includeCoords=true] - Include bbox in the prompt text
|
|
18
22
|
* @returns {Object} SnapDOM plugin
|
|
19
23
|
*/
|
|
20
24
|
|
|
@@ -27,6 +31,33 @@ const DEFAULT_SEMANTIC =
|
|
|
27
31
|
'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' +
|
|
28
32
|
'header, footer, label, td, th, figcaption, blockquote, legend';
|
|
29
33
|
|
|
34
|
+
const COLLECTED_ATTRS = [
|
|
35
|
+
'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
|
|
36
|
+
'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const VISUAL_FIELDS = [
|
|
40
|
+
'display', 'visibility', 'opacity',
|
|
41
|
+
'color', 'backgroundColor',
|
|
42
|
+
'fontSize', 'fontWeight',
|
|
43
|
+
'cursor', 'overflow',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Common computed-style values that carry no information. Keeping the
|
|
47
|
+
// `styles` object small is the difference between a useful LLM input and
|
|
48
|
+
// token bloat on every element.
|
|
49
|
+
const VISUAL_SKIP = new Set(['initial', 'normal', 'visible', 'auto', 'static', '0']);
|
|
50
|
+
function isDefaultStyleValue(prop, value) {
|
|
51
|
+
if (!value) return true;
|
|
52
|
+
if (VISUAL_SKIP.has(value)) return true;
|
|
53
|
+
if (prop === 'cursor' && value === 'none') return true;
|
|
54
|
+
if (prop === 'color' && value === 'rgb(0, 0, 0)') return true;
|
|
55
|
+
if (prop === 'backgroundColor' && value === 'rgba(0, 0, 0, 0)') return true;
|
|
56
|
+
if (prop === 'fontWeight' && (value === '400' || value === 'normal')) return true;
|
|
57
|
+
if (prop === 'opacity' && value === '1') return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
30
61
|
export function promptExport(options = {}) {
|
|
31
62
|
const {
|
|
32
63
|
annotate = true,
|
|
@@ -36,6 +67,8 @@ export function promptExport(options = {}) {
|
|
|
36
67
|
interactiveSelector = DEFAULT_INTERACTIVE,
|
|
37
68
|
semanticSelector = DEFAULT_SEMANTIC,
|
|
38
69
|
labelStyle = {},
|
|
70
|
+
promptMode = 'compact',
|
|
71
|
+
includeCoords = true,
|
|
39
72
|
} = options;
|
|
40
73
|
|
|
41
74
|
return {
|
|
@@ -43,7 +76,10 @@ export function promptExport(options = {}) {
|
|
|
43
76
|
|
|
44
77
|
afterClone(ctx) {
|
|
45
78
|
const meta = extractMetadata(ctx.element, interactiveSelector, semanticSelector);
|
|
79
|
+
// snapdom spreads a fresh ctx for the export phase from ctx.options,
|
|
80
|
+
// so write to both so the prompt() call below can read it.
|
|
46
81
|
ctx.__promptMetadata = meta;
|
|
82
|
+
if (ctx.options) ctx.options.__promptMetadata = meta;
|
|
47
83
|
|
|
48
84
|
if (annotate) {
|
|
49
85
|
addAnnotations(ctx.clone, meta.elements, labelStyle);
|
|
@@ -66,6 +102,8 @@ export function promptExport(options = {}) {
|
|
|
66
102
|
const format = opts.imageFormat || imageFormat;
|
|
67
103
|
const quality = opts.imageQuality || imageQuality;
|
|
68
104
|
const maxWidth = opts.maxImageWidth || maxImageWidth;
|
|
105
|
+
const mode = opts.promptMode || promptMode;
|
|
106
|
+
const withCoords = opts.includeCoords !== undefined ? opts.includeCoords : includeCoords;
|
|
69
107
|
|
|
70
108
|
const img = new Image();
|
|
71
109
|
img.src = ctx.export.url;
|
|
@@ -74,23 +112,19 @@ export function promptExport(options = {}) {
|
|
|
74
112
|
img.onerror = rej;
|
|
75
113
|
});
|
|
76
114
|
|
|
77
|
-
const ratio =
|
|
78
|
-
img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
|
|
115
|
+
const ratio = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
|
|
79
116
|
const w = Math.round(img.naturalWidth * ratio);
|
|
80
117
|
const h = Math.round(img.naturalHeight * ratio);
|
|
81
118
|
|
|
82
119
|
const canvas = document.createElement('canvas');
|
|
83
120
|
canvas.width = w;
|
|
84
121
|
canvas.height = h;
|
|
85
|
-
|
|
86
|
-
c2d.drawImage(img, 0, 0, w, h);
|
|
122
|
+
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
87
123
|
|
|
88
124
|
const mime =
|
|
89
|
-
format === 'jpg' || format === 'jpeg'
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
? 'image/webp'
|
|
93
|
-
: 'image/png';
|
|
125
|
+
format === 'jpg' || format === 'jpeg' ? 'image/jpeg'
|
|
126
|
+
: format === 'webp' ? 'image/webp'
|
|
127
|
+
: 'image/png';
|
|
94
128
|
const dataURL = canvas.toDataURL(mime, quality);
|
|
95
129
|
|
|
96
130
|
const sx = w / (meta.dimensions.width || 1);
|
|
@@ -106,11 +140,18 @@ export function promptExport(options = {}) {
|
|
|
106
140
|
},
|
|
107
141
|
}));
|
|
108
142
|
|
|
143
|
+
// In compact + annotate, badges already encode positions on the
|
|
144
|
+
// image itself, so repeating coords in the prompt text is noise.
|
|
145
|
+
const emitCoords = mode === 'verbose' ? true : (withCoords && !annotate);
|
|
146
|
+
const prompt = mode === 'verbose'
|
|
147
|
+
? formatPromptVerbose(elements, { width: w, height: h }, emitCoords)
|
|
148
|
+
: formatPromptCompact(elements, { width: w, height: h }, emitCoords);
|
|
149
|
+
|
|
109
150
|
return {
|
|
110
151
|
image: dataURL,
|
|
111
152
|
elements,
|
|
112
153
|
dimensions: { width: w, height: h },
|
|
113
|
-
prompt
|
|
154
|
+
prompt,
|
|
114
155
|
};
|
|
115
156
|
},
|
|
116
157
|
};
|
|
@@ -118,6 +159,134 @@ export function promptExport(options = {}) {
|
|
|
118
159
|
};
|
|
119
160
|
}
|
|
120
161
|
|
|
162
|
+
/* ── Accessible name ──────────────────────────── */
|
|
163
|
+
|
|
164
|
+
function truncate(str, max) {
|
|
165
|
+
if (!str) return '';
|
|
166
|
+
if (str.length <= max) return str;
|
|
167
|
+
return str.slice(0, max - 1) + '…';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Compute the element's accessible name following a simplified WAI-ARIA order.
|
|
172
|
+
* This is what an LLM agent reads first to know what the element IS.
|
|
173
|
+
*/
|
|
174
|
+
function accessibleName(el) {
|
|
175
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
176
|
+
if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim();
|
|
177
|
+
|
|
178
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
179
|
+
if (labelledBy) {
|
|
180
|
+
const root = el.getRootNode();
|
|
181
|
+
const getById = (id) =>
|
|
182
|
+
root && typeof root.getElementById === 'function'
|
|
183
|
+
? root.getElementById(id)
|
|
184
|
+
: document.getElementById(id);
|
|
185
|
+
const parts = labelledBy.trim().split(/\s+/)
|
|
186
|
+
.map((id) => {
|
|
187
|
+
const ref = getById(id);
|
|
188
|
+
return ref ? (ref.textContent || '').trim() : '';
|
|
189
|
+
})
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
if (parts.length) return parts.join(' ');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (el.tagName === 'IMG') {
|
|
195
|
+
const alt = el.getAttribute('alt');
|
|
196
|
+
if (alt && alt.trim()) return alt.trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const title = el.getAttribute('title');
|
|
200
|
+
if (title && title.trim()) return title.trim();
|
|
201
|
+
|
|
202
|
+
if (el.labels && el.labels[0]) {
|
|
203
|
+
const t = (el.labels[0].textContent || '').trim();
|
|
204
|
+
if (t) return t;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const text = (el.textContent || '').trim();
|
|
208
|
+
return text ? truncate(text, 40) : '';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ── DOM state ────────────────────────────────── */
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Capture real runtime state (not just ARIA attributes). This is what
|
|
215
|
+
* separates a useful map from a static screenshot annotation: an agent can
|
|
216
|
+
* see "checkbox is unchecked / select has value=X / details is closed".
|
|
217
|
+
*/
|
|
218
|
+
function computeState(el) {
|
|
219
|
+
const state = {};
|
|
220
|
+
try {
|
|
221
|
+
if (el.matches(':checked')) state.checked = true;
|
|
222
|
+
if (el.matches(':disabled')) state.disabled = true;
|
|
223
|
+
if (el.matches(':focus')) state.focus = true;
|
|
224
|
+
} catch { /* some selectors may fail on exotic nodes */ }
|
|
225
|
+
|
|
226
|
+
const tag = el.tagName;
|
|
227
|
+
if (tag === 'INPUT') {
|
|
228
|
+
// Checkbox/radio: `value` defaults to "on" when no explicit value attr
|
|
229
|
+
// is set — noise. The meaningful signal is `checked`. Skip value here.
|
|
230
|
+
const type = (el.type || 'text').toLowerCase();
|
|
231
|
+
if (type !== 'checkbox' && type !== 'radio' && el.value) {
|
|
232
|
+
state.value = el.value;
|
|
233
|
+
}
|
|
234
|
+
} else if (tag === 'TEXTAREA') {
|
|
235
|
+
if (el.value) state.value = el.value;
|
|
236
|
+
} else if (tag === 'SELECT') {
|
|
237
|
+
state.value = el.value;
|
|
238
|
+
const opt = el.options && el.options[el.selectedIndex];
|
|
239
|
+
if (opt) state.selectedText = opt.text || '';
|
|
240
|
+
} else if (tag === 'DETAILS') {
|
|
241
|
+
state.open = !!el.open;
|
|
242
|
+
} else if (el.hasAttribute && el.hasAttribute('open')) {
|
|
243
|
+
state.open = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return Object.keys(state).length ? state : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ── Visual styles ────────────────────────────── */
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Pull a small set of visually-meaningful computed styles. Reads from the
|
|
253
|
+
* live element (the clone uses class-based styling so its inline style is
|
|
254
|
+
* empty and a detached `getComputedStyle(cloneNode)` returns initial values).
|
|
255
|
+
* The computed style of the original is what snapdom used to build the
|
|
256
|
+
* capture, so the result matches what the screenshot shows.
|
|
257
|
+
*/
|
|
258
|
+
function computeVisualStyles(el) {
|
|
259
|
+
let cs;
|
|
260
|
+
try { cs = getComputedStyle(el); } catch { return null; }
|
|
261
|
+
if (!cs) return null;
|
|
262
|
+
const out = {};
|
|
263
|
+
for (const prop of VISUAL_FIELDS) {
|
|
264
|
+
const v = cs[prop];
|
|
265
|
+
if (isDefaultStyleValue(prop, v)) continue;
|
|
266
|
+
out[prop] = v;
|
|
267
|
+
}
|
|
268
|
+
return Object.keys(out).length ? out : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ── Covered detection ────────────────────────── */
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* True when another element is painted on top of the center of this one.
|
|
275
|
+
* An agent that knows a button is covered by a modal won't try to click it.
|
|
276
|
+
*/
|
|
277
|
+
function isCovered(el, rect) {
|
|
278
|
+
if (!rect.width || !rect.height) return false;
|
|
279
|
+
const cx = rect.left + rect.width / 2;
|
|
280
|
+
const cy = rect.top + rect.height / 2;
|
|
281
|
+
if (cx < 0 || cy < 0) return false;
|
|
282
|
+
const doc = el.ownerDocument || document;
|
|
283
|
+
if (!doc.elementFromPoint) return false;
|
|
284
|
+
const top = doc.elementFromPoint(cx, cy);
|
|
285
|
+
if (!top || top === el) return false;
|
|
286
|
+
if (el.contains && el.contains(top)) return false;
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
121
290
|
/* ── Metadata extraction ──────────────────────── */
|
|
122
291
|
|
|
123
292
|
function extractMetadata(element, interactiveSelector, semanticSelector) {
|
|
@@ -151,11 +320,6 @@ function extractMetadata(element, interactiveSelector, semanticSelector) {
|
|
|
151
320
|
};
|
|
152
321
|
}
|
|
153
322
|
|
|
154
|
-
const COLLECTED_ATTRS = [
|
|
155
|
-
'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
|
|
156
|
-
'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
|
|
157
|
-
];
|
|
158
|
-
|
|
159
323
|
function buildEntry(el, rootRect, id, type) {
|
|
160
324
|
const rect = el.getBoundingClientRect();
|
|
161
325
|
const bbox = {
|
|
@@ -168,15 +332,58 @@ function buildEntry(el, rootRect, id, type) {
|
|
|
168
332
|
if (bbox.width <= 0 && bbox.height <= 0) return null;
|
|
169
333
|
|
|
170
334
|
const tag = el.tagName.toLowerCase();
|
|
171
|
-
const
|
|
335
|
+
const maxText = type === 'interactive' ? 200 : 120;
|
|
336
|
+
const text = (el.textContent || '').trim().slice(0, maxText);
|
|
172
337
|
|
|
173
338
|
const attributes = {};
|
|
174
339
|
for (const attr of COLLECTED_ATTRS) {
|
|
175
340
|
const val = el.getAttribute(attr);
|
|
176
|
-
if (val
|
|
341
|
+
if (val == null || val === '' || val === 'false') continue;
|
|
342
|
+
attributes[attr] = val;
|
|
177
343
|
}
|
|
178
344
|
|
|
179
|
-
|
|
345
|
+
// For <img>, replace the (potentially long, cache-busted) src with just the
|
|
346
|
+
// filename — "logo.svg" is more useful to an LLM than the full URL.
|
|
347
|
+
if (tag === 'img' && el.src) {
|
|
348
|
+
try {
|
|
349
|
+
const base = (el.ownerDocument && el.ownerDocument.location)
|
|
350
|
+
? el.ownerDocument.location.href
|
|
351
|
+
: (typeof location !== 'undefined' ? location.href : undefined);
|
|
352
|
+
const url = base ? new URL(el.src, base) : new URL(el.src);
|
|
353
|
+
attributes.src = url.pathname.split('/').pop() || el.src;
|
|
354
|
+
} catch {
|
|
355
|
+
attributes.src = el.src;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const entry = {
|
|
360
|
+
id,
|
|
361
|
+
tag,
|
|
362
|
+
type,
|
|
363
|
+
name: accessibleName(el),
|
|
364
|
+
text,
|
|
365
|
+
bbox,
|
|
366
|
+
attributes,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const styles = computeVisualStyles(el);
|
|
370
|
+
if (styles) entry.styles = styles;
|
|
371
|
+
|
|
372
|
+
if (type === 'interactive') {
|
|
373
|
+
const state = computeState(el);
|
|
374
|
+
if (state) {
|
|
375
|
+
// Drop state.value when it just echoes attributes.value — no
|
|
376
|
+
// divergence between the initial HTML attribute and the current
|
|
377
|
+
// property, nothing for the LLM to learn from the repeat.
|
|
378
|
+
if (state.value !== undefined && state.value === attributes.value) {
|
|
379
|
+
delete state.value;
|
|
380
|
+
}
|
|
381
|
+
if (Object.keys(state).length) entry.state = state;
|
|
382
|
+
}
|
|
383
|
+
if (isCovered(el, rect)) entry.covered = true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return entry;
|
|
180
387
|
}
|
|
181
388
|
|
|
182
389
|
/* ── Visual annotations ───────────────────────── */
|
|
@@ -202,10 +409,14 @@ function addAnnotations(clone, elements, customStyle) {
|
|
|
202
409
|
const badge = document.createElement('span');
|
|
203
410
|
badge.textContent = String(el.id);
|
|
204
411
|
badge.setAttribute('data-snap-prompt-label', String(el.id));
|
|
412
|
+
// Center the badge on the element's bbox, not on its top-left corner:
|
|
413
|
+
// `translate(-50%, -50%)` offsets from the anchor point.
|
|
414
|
+
const cx = el.bbox.x + el.bbox.width / 2;
|
|
415
|
+
const cy = el.bbox.y + el.bbox.height / 2;
|
|
205
416
|
Object.assign(badge.style, {
|
|
206
417
|
position: 'absolute',
|
|
207
|
-
left: `${
|
|
208
|
-
top: `${
|
|
418
|
+
left: `${cx}px`,
|
|
419
|
+
top: `${cy}px`,
|
|
209
420
|
transform: 'translate(-50%, -50%)',
|
|
210
421
|
minWidth: '18px',
|
|
211
422
|
height: '18px',
|
|
@@ -229,11 +440,56 @@ function addAnnotations(clone, elements, customStyle) {
|
|
|
229
440
|
clone.appendChild(overlay);
|
|
230
441
|
}
|
|
231
442
|
|
|
232
|
-
/* ── Prompt text
|
|
443
|
+
/* ── Prompt text formatters ───────────────────── */
|
|
444
|
+
|
|
445
|
+
function stateToStr(state) {
|
|
446
|
+
if (!state) return '';
|
|
447
|
+
const flags = [];
|
|
448
|
+
const pairs = [];
|
|
449
|
+
for (const k of Object.keys(state)) {
|
|
450
|
+
const v = state[k];
|
|
451
|
+
if (v === true) flags.push(k);
|
|
452
|
+
else pairs.push(`${k}=${JSON.stringify(v)}`);
|
|
453
|
+
}
|
|
454
|
+
if (!flags.length && !pairs.length) return '';
|
|
455
|
+
return ` {${[...flags, ...pairs].join(', ')}}`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function coordsToStr(bbox) {
|
|
459
|
+
return ` (${bbox.x},${bbox.y} ${bbox.width}×${bbox.height})`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function formatPromptCompact(elements, dimensions, withCoords) {
|
|
463
|
+
const lines = [`Screenshot (${dimensions.width}×${dimensions.height}px).`];
|
|
464
|
+
|
|
465
|
+
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
466
|
+
const semantic = elements.filter((e) => e.type === 'semantic');
|
|
233
467
|
|
|
234
|
-
|
|
468
|
+
if (interactive.length) {
|
|
469
|
+
lines.push('', 'Interactive:');
|
|
470
|
+
for (const el of interactive) {
|
|
471
|
+
const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
|
|
472
|
+
const st = stateToStr(el.state);
|
|
473
|
+
const cov = el.covered ? ' (covered)' : '';
|
|
474
|
+
const coords = withCoords ? coordsToStr(el.bbox) : '';
|
|
475
|
+
lines.push(` [${el.id}] ${el.tag}${name}${st}${cov}${coords}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (semantic.length) {
|
|
480
|
+
lines.push('', 'Semantic:');
|
|
481
|
+
for (const el of semantic) {
|
|
482
|
+
const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
|
|
483
|
+
lines.push(` [${el.id}] ${el.tag}${name}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return lines.join('\n');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function formatPromptVerbose(elements, dimensions, withCoords) {
|
|
235
491
|
const lines = [
|
|
236
|
-
`Screenshot of a web page (${dimensions.width}
|
|
492
|
+
`Screenshot of a web page (${dimensions.width}×${dimensions.height}px).`,
|
|
237
493
|
'',
|
|
238
494
|
];
|
|
239
495
|
|
|
@@ -243,13 +499,13 @@ function formatPromptText(elements, dimensions) {
|
|
|
243
499
|
if (interactive.length) {
|
|
244
500
|
lines.push('Interactive elements:');
|
|
245
501
|
for (const el of interactive) {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
);
|
|
249
|
-
const text = el.text ? ` "${truncate(el.text, 60)}"` : '';
|
|
250
|
-
const pos = `(${el.bbox.x},${el.bbox.y} ${el.bbox.width}\u00d7${el.bbox.height})`;
|
|
502
|
+
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
503
|
+
const attrParts = Object.entries(el.attributes).map(([k, v]) => `${k}="${v}"`);
|
|
251
504
|
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
252
|
-
|
|
505
|
+
const pos = withCoords ? coordsToStr(el.bbox) : '';
|
|
506
|
+
const st = stateToStr(el.state);
|
|
507
|
+
const cov = el.covered ? ' (covered)' : '';
|
|
508
|
+
lines.push(` [${el.id}] <${el.tag}>${name}${pos}${attrs}${st}${cov}`);
|
|
253
509
|
}
|
|
254
510
|
lines.push('');
|
|
255
511
|
}
|
|
@@ -257,20 +513,15 @@ function formatPromptText(elements, dimensions) {
|
|
|
257
513
|
if (semantic.length) {
|
|
258
514
|
lines.push('Semantic structure:');
|
|
259
515
|
for (const el of semantic) {
|
|
260
|
-
const
|
|
516
|
+
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
261
517
|
const attrParts = [];
|
|
262
518
|
if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
|
|
263
519
|
if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
|
|
264
520
|
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
265
|
-
lines.push(` [${el.id}] <${el.tag}>${
|
|
521
|
+
lines.push(` [${el.id}] <${el.tag}>${name}${attrs}`);
|
|
266
522
|
}
|
|
267
523
|
lines.push('');
|
|
268
524
|
}
|
|
269
525
|
|
|
270
526
|
return lines.join('\n');
|
|
271
527
|
}
|
|
272
|
-
|
|
273
|
-
function truncate(str, max) {
|
|
274
|
-
if (str.length <= max) return str;
|
|
275
|
-
return str.slice(0, max - 1) + '\u2026';
|
|
276
|
-
}
|