@zumer/snapdom-plugins 1.1.0 → 2.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 +48 -33
- package/agent-map.js +376 -0
- package/html-in-canvas.js +33 -17
- package/index.js +1 -1
- package/package.json +2 -2
- package/prompt-export.js +0 -527
package/README.md
CHANGED
|
@@ -170,56 +170,71 @@ snapdom(el, { plugins: [htmlInCanvas()] });
|
|
|
170
170
|
|
|
171
171
|
---
|
|
172
172
|
|
|
173
|
-
### `
|
|
173
|
+
### `agent-map`
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
Produces a Set-of-Mark package for **visual agents**: an annotated screenshot with numbered badges on interactive elements, plus a compact JSON map from badge index → role / accessible name / bbox / state. One call, fully client-side.
|
|
176
176
|
|
|
177
177
|
```js
|
|
178
|
-
import {
|
|
178
|
+
import { agentMap } from '@zumer/snapdom-plugins/agent-map';
|
|
179
179
|
|
|
180
|
-
const result = await snapdom(el, { plugins: [
|
|
181
|
-
const { image,
|
|
182
|
-
```
|
|
180
|
+
const result = await snapdom(el, { plugins: [agentMap()] });
|
|
181
|
+
const { image, map, dimensions } = await result.toAgentMap();
|
|
183
182
|
|
|
184
|
-
|
|
183
|
+
// image: data URL of the screenshot with numbered red badges overlaid
|
|
184
|
+
// map: [{ i, n, r, b, s? }, …] — index, name, role, bbox, state
|
|
185
|
+
// Agent says "click element 2" → map[2].b gives [x, y, w, h]
|
|
186
|
+
```
|
|
185
187
|
|
|
186
|
-
|
|
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 |
|
|
188
|
+
Map entry shape (default `fields: 'minimal'`):
|
|
192
189
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
190
|
+
| Key | Type | Description |
|
|
191
|
+
|-----|------|-------------|
|
|
192
|
+
| `i` | `number` | Index matching the badge drawn on the image |
|
|
193
|
+
| `n` | `string` | Accessible name (aria-label → labelledby → alt → title → labels → textContent, truncated to 60 chars) |
|
|
194
|
+
| `r` | `string` | ARIA-style role (`button`, `link`, `checkbox`, `radio`, `textbox`, `combobox`, `slider`, `heading`, …) — derived from `role` attribute or implicit role of the element |
|
|
195
|
+
| `b` | `[x, y, w, h]` | Bounding box in pixels, scaled against `maxImageWidth` |
|
|
196
|
+
| `s` | `object?` | State: included only when at least one key is meaningful — `checked`, `disabled`, `focus`, `expanded`, `pressed`, `selected`, `value`, `open`, `selectedText`, `covered` |
|
|
196
197
|
|
|
197
|
-
|
|
198
|
+
Example map for a checkout form:
|
|
198
199
|
|
|
199
200
|
```js
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
201
|
+
[
|
|
202
|
+
{ i: 0, n: 'Email', r: 'textbox', b: [28, 80, 280, 34], s: { value: 'ada@example.com' } },
|
|
203
|
+
{ i: 1, n: 'Send product updates', r: 'checkbox', b: [28, 134, 13, 13], s: { checked: true } },
|
|
204
|
+
{ i: 2, n: 'Apply coupon', r: 'button', b: [28, 176, 114, 38], s: { expanded: false } },
|
|
205
|
+
{ i: 3, n: 'Remove coupon', r: 'button', b: [150, 176, 140, 38], s: { disabled: true } },
|
|
206
|
+
{ i: 4, n: 'Pay $53.90', r: 'button', b: [28, 220, 97, 38] }
|
|
207
|
+
]
|
|
206
208
|
```
|
|
207
209
|
|
|
210
|
+
#### Options
|
|
211
|
+
|
|
208
212
|
| Option | Type | Default | Description |
|
|
209
213
|
|--------|------|---------|-------------|
|
|
210
|
-
| `
|
|
211
|
-
| `
|
|
212
|
-
| `
|
|
213
|
-
| `maxImageWidth` | `number` | `1024` |
|
|
214
|
-
| `
|
|
215
|
-
| `
|
|
216
|
-
| `
|
|
214
|
+
| `image` | `'annotated' \| 'raw' \| false` | `'annotated'` | `'annotated'` overlays numbered badges; `'raw'` skips badges; `false` skips image generation entirely (no canvas draw, no toDataURL — cheapest path). |
|
|
215
|
+
| `fields` | `'minimal' \| 'full'` | `'minimal'` | `'full'` adds `t` (raw text content) and `a` (meaningful attributes) per entry. |
|
|
216
|
+
| `semantic` | `boolean` | `false` | Include non-interactive structural elements (headings, paragraphs, landmarks). Off by default — agents act on interactive. |
|
|
217
|
+
| `maxImageWidth` | `number` | `1024` | Downscale target for the image; bboxes rescale to match. |
|
|
218
|
+
| `imageFormat` | `'png' \| 'jpg' \| 'webp'` | `'png'` | Image format (only used when image is rendered). |
|
|
219
|
+
| `imageQuality` | `number` | `0.8` | Quality for lossy formats. |
|
|
220
|
+
| `interactiveSelector` | `string` | see below | CSS selector for interactive elements. |
|
|
221
|
+
| `semanticSelector` | `string` | see below | CSS selector for semantic elements (used when `semantic: true`). |
|
|
222
|
+
| `labelStyle` | `object` | `{}` | Override badge styles. |
|
|
217
223
|
|
|
218
224
|
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,
|
|
225
|
+
- **interactive**: `a[href], button, input, select, textarea, [role="button"|"link"|"tab"|"menuitem"|"checkbox"|"radio"|"switch"|"slider"|"combobox"|"textbox"], [tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]`
|
|
226
|
+
- **semantic**: `h1–h6, nav, main, article, section, header, footer, figcaption, blockquote, legend, p`
|
|
227
|
+
|
|
228
|
+
Per-call options override constructor options (e.g. `result.toAgentMap({ image: false })`).
|
|
229
|
+
|
|
230
|
+
#### When to use
|
|
231
|
+
|
|
232
|
+
- Visual agents using Set-of-Mark prompting — one call gives you both the labelled image and the coordinate lookup table.
|
|
233
|
+
- Computer-use / browser-agent harnesses that need click coordinates for a vision model's output.
|
|
234
|
+
- Visual QA with an LLM judge — compare before/after captures with structured element identity.
|
|
235
|
+
- Dataset generation for vision-LLM fine-tuning — (image, map) pairs.
|
|
221
236
|
|
|
222
|
-
|
|
237
|
+
Because it runs entirely in the browser, it works in contexts where Playwright / Puppeteer can't: Chrome extensions, SaaS web apps capturing the user's own page, Electron apps capturing their own window.
|
|
223
238
|
|
|
224
239
|
---
|
|
225
240
|
|
package/agent-map.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentMap – Official SnapDOM Plugin
|
|
3
|
+
*
|
|
4
|
+
* Produces a Set-of-Mark package for visual agents: an annotated screenshot
|
|
5
|
+
* with numbered badges on interactive elements, plus a compact JSON map from
|
|
6
|
+
* badge index → role / name / bbox / state. Designed for one-call capture
|
|
7
|
+
* on the client side — visual agents, computer-use harnesses, dataset
|
|
8
|
+
* generation for vision training, visual QA.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { agentMap } from '@zumer/snapdom-plugins/agent-map';
|
|
12
|
+
* const result = await snapdom(el, { plugins: [agentMap()] });
|
|
13
|
+
* const { image, map, dimensions } = await result.toAgentMap();
|
|
14
|
+
*
|
|
15
|
+
* // model reply: "click element 2" → map[2].b gives [x, y, w, h]
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} [options]
|
|
18
|
+
* @param {'annotated'|'raw'|false} [options.image='annotated'] Image output
|
|
19
|
+
* mode. 'annotated' draws numbered badges on the rendered image, 'raw'
|
|
20
|
+
* returns the image without badges, false skips image generation (cheapest).
|
|
21
|
+
* @param {'minimal'|'full'} [options.fields='minimal'] Per-entry shape.
|
|
22
|
+
* 'minimal' returns {i, n, r, b, s?}. 'full' adds {t (text), a (attrs)}.
|
|
23
|
+
* @param {boolean} [options.semantic=false] Include non-interactive semantic
|
|
24
|
+
* elements (headings, paragraphs, nav, main, landmarks). Off by default —
|
|
25
|
+
* agents typically only act on interactive.
|
|
26
|
+
* @param {number} [options.maxImageWidth=1024] Downscale target for the image.
|
|
27
|
+
* @param {'png'|'jpg'|'webp'} [options.imageFormat='png'] Image format.
|
|
28
|
+
* @param {number} [options.imageQuality=0.8] Quality for lossy formats.
|
|
29
|
+
* @param {string} [options.interactiveSelector] CSS selector (default below).
|
|
30
|
+
* @param {string} [options.semanticSelector] CSS selector (default below).
|
|
31
|
+
* @param {Object} [options.labelStyle={}] Override badge styles.
|
|
32
|
+
* @returns {Object} SnapDOM plugin
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const DEFAULT_INTERACTIVE =
|
|
36
|
+
'a[href], button, input, select, textarea, ' +
|
|
37
|
+
'[role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [role="slider"], [role="combobox"], [role="textbox"], ' +
|
|
38
|
+
'[tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]';
|
|
39
|
+
|
|
40
|
+
const DEFAULT_SEMANTIC =
|
|
41
|
+
'h1, h2, h3, h4, h5, h6, nav, main, article, section, header, footer, ' +
|
|
42
|
+
'figcaption, blockquote, legend, p';
|
|
43
|
+
|
|
44
|
+
export function agentMap(options = {}) {
|
|
45
|
+
const {
|
|
46
|
+
image = 'annotated',
|
|
47
|
+
fields = 'minimal',
|
|
48
|
+
semantic = false,
|
|
49
|
+
maxImageWidth = 1024,
|
|
50
|
+
imageFormat = 'png',
|
|
51
|
+
imageQuality = 0.8,
|
|
52
|
+
interactiveSelector = DEFAULT_INTERACTIVE,
|
|
53
|
+
semanticSelector = DEFAULT_SEMANTIC,
|
|
54
|
+
labelStyle = {},
|
|
55
|
+
} = options;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
name: 'agent-map',
|
|
59
|
+
|
|
60
|
+
afterClone(ctx) {
|
|
61
|
+
const meta = extractMap(
|
|
62
|
+
ctx.element,
|
|
63
|
+
interactiveSelector,
|
|
64
|
+
semantic ? semanticSelector : null,
|
|
65
|
+
fields
|
|
66
|
+
);
|
|
67
|
+
// snapdom's export ctx is a fresh spread of ctx.options, so we stash on
|
|
68
|
+
// both for the agentMap() call below to find it.
|
|
69
|
+
ctx.__agentMapMeta = meta;
|
|
70
|
+
if (ctx.options) ctx.options.__agentMapMeta = meta;
|
|
71
|
+
|
|
72
|
+
if (image === 'annotated') {
|
|
73
|
+
addAnnotations(ctx.clone, meta.map, labelStyle);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
defineExports() {
|
|
78
|
+
return {
|
|
79
|
+
agentMap: async (ctx, opts = {}) => {
|
|
80
|
+
const meta = ctx.__agentMapMeta;
|
|
81
|
+
const wantImage = opts.image !== undefined ? opts.image : image;
|
|
82
|
+
|
|
83
|
+
if (!meta || !meta.map.length) {
|
|
84
|
+
const out = { dimensions: { width: 0, height: 0 }, map: [] };
|
|
85
|
+
if (wantImage) out.image = ctx.export.url;
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const format = opts.imageFormat || imageFormat;
|
|
90
|
+
const quality = opts.imageQuality || imageQuality;
|
|
91
|
+
const maxWidth = opts.maxImageWidth || maxImageWidth;
|
|
92
|
+
|
|
93
|
+
// Scale dimensions — whether we rasterize or not, bboxes get resized
|
|
94
|
+
// to the target output size so callers can overlay them on the image.
|
|
95
|
+
let w, h, dataURL;
|
|
96
|
+
if (wantImage) {
|
|
97
|
+
const img = new Image();
|
|
98
|
+
img.src = ctx.export.url;
|
|
99
|
+
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
|
|
100
|
+
const ratio = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
|
|
101
|
+
w = Math.round(img.naturalWidth * ratio);
|
|
102
|
+
h = Math.round(img.naturalHeight * ratio);
|
|
103
|
+
const canvas = document.createElement('canvas');
|
|
104
|
+
canvas.width = w;
|
|
105
|
+
canvas.height = h;
|
|
106
|
+
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
107
|
+
const mime =
|
|
108
|
+
format === 'jpg' || format === 'jpeg' ? 'image/jpeg'
|
|
109
|
+
: format === 'webp' ? 'image/webp'
|
|
110
|
+
: 'image/png';
|
|
111
|
+
dataURL = canvas.toDataURL(mime, quality);
|
|
112
|
+
} else {
|
|
113
|
+
const sourceW = meta.dimensions.width || 1;
|
|
114
|
+
const ratio = sourceW > maxWidth ? maxWidth / sourceW : 1;
|
|
115
|
+
w = Math.round(sourceW * ratio);
|
|
116
|
+
h = Math.round(meta.dimensions.height * ratio);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sx = w / (meta.dimensions.width || 1);
|
|
120
|
+
const sy = h / (meta.dimensions.height || 1);
|
|
121
|
+
|
|
122
|
+
const scaledMap = meta.map.map(e => {
|
|
123
|
+
const scaled = { ...e, b: [
|
|
124
|
+
Math.round(e.b[0] * sx),
|
|
125
|
+
Math.round(e.b[1] * sy),
|
|
126
|
+
Math.round(e.b[2] * sx),
|
|
127
|
+
Math.round(e.b[3] * sy),
|
|
128
|
+
] };
|
|
129
|
+
return scaled;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const out = { dimensions: { width: w, height: h }, map: scaledMap };
|
|
133
|
+
if (wantImage) out.image = dataURL;
|
|
134
|
+
return out;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ── Role derivation ────────────────────────────── */
|
|
142
|
+
|
|
143
|
+
function deriveRole(el) {
|
|
144
|
+
const explicit = el.getAttribute('role');
|
|
145
|
+
if (explicit) return explicit;
|
|
146
|
+
const tag = el.tagName.toLowerCase();
|
|
147
|
+
const type = (el.type || '').toLowerCase();
|
|
148
|
+
if (tag === 'button') return 'button';
|
|
149
|
+
if (tag === 'a' && el.hasAttribute('href')) return 'link';
|
|
150
|
+
if (tag === 'input') {
|
|
151
|
+
if (type === 'checkbox') return 'checkbox';
|
|
152
|
+
if (type === 'radio') return 'radio';
|
|
153
|
+
if (type === 'range') return 'slider';
|
|
154
|
+
if (type === 'file') return 'file';
|
|
155
|
+
if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image') return 'button';
|
|
156
|
+
return 'textbox';
|
|
157
|
+
}
|
|
158
|
+
if (tag === 'select') return 'combobox';
|
|
159
|
+
if (tag === 'textarea') return 'textbox';
|
|
160
|
+
if (tag === 'summary') return 'button';
|
|
161
|
+
if (tag === 'details') return 'group';
|
|
162
|
+
if (/^h[1-6]$/.test(tag)) return 'heading';
|
|
163
|
+
if (tag === 'nav') return 'navigation';
|
|
164
|
+
if (tag === 'main') return 'main';
|
|
165
|
+
if (tag === 'header') return 'banner';
|
|
166
|
+
if (tag === 'footer') return 'contentinfo';
|
|
167
|
+
if (tag === 'article') return 'article';
|
|
168
|
+
if (tag === 'section') return 'region';
|
|
169
|
+
if (tag === 'p') return 'paragraph';
|
|
170
|
+
if (tag === 'img') return 'image';
|
|
171
|
+
return tag;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ── Accessible name ────────────────────────────── */
|
|
175
|
+
|
|
176
|
+
function accessibleName(el) {
|
|
177
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
178
|
+
if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim();
|
|
179
|
+
|
|
180
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
181
|
+
if (labelledBy) {
|
|
182
|
+
const root = el.getRootNode();
|
|
183
|
+
const getById = (id) =>
|
|
184
|
+
root && typeof root.getElementById === 'function'
|
|
185
|
+
? root.getElementById(id) : document.getElementById(id);
|
|
186
|
+
const parts = labelledBy.trim().split(/\s+/)
|
|
187
|
+
.map(id => { const r = getById(id); return r ? (r.textContent || '').trim() : ''; })
|
|
188
|
+
.filter(Boolean);
|
|
189
|
+
if (parts.length) return parts.join(' ');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (el.tagName === 'IMG' || (el.tagName === 'INPUT' && (el.type || '').toLowerCase() === 'image')) {
|
|
193
|
+
const alt = el.getAttribute('alt');
|
|
194
|
+
if (alt && alt.trim()) return alt.trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const title = el.getAttribute('title');
|
|
198
|
+
if (title && title.trim()) return title.trim();
|
|
199
|
+
|
|
200
|
+
if (el.labels && el.labels[0]) {
|
|
201
|
+
const t = (el.labels[0].textContent || '').trim();
|
|
202
|
+
if (t) return t;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
206
|
+
if (text) return text.length > 60 ? text.slice(0, 59) + '…' : text;
|
|
207
|
+
return '';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* ── State extraction ───────────────────────────── */
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Builds the `s` (state) object. Only meaningful states — never default
|
|
214
|
+
* values that add no signal for an agent. Critically, aria-expanded and
|
|
215
|
+
* aria-pressed are included for BOTH values (true and false) because
|
|
216
|
+
* "pressed: false" on a toggle is meaningful information.
|
|
217
|
+
*/
|
|
218
|
+
function deriveState(el, role, rect) {
|
|
219
|
+
const s = {};
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (el.matches(':checked')) s.checked = true;
|
|
223
|
+
else if (role === 'checkbox' || role === 'radio') {
|
|
224
|
+
// include checked:false for form groups where an agent needs to
|
|
225
|
+
// know "unchecked" is a valid state distinct from "not a checkbox".
|
|
226
|
+
s.checked = false;
|
|
227
|
+
}
|
|
228
|
+
if (el.matches(':disabled')) s.disabled = true;
|
|
229
|
+
if (el.matches(':focus')) s.focus = true;
|
|
230
|
+
} catch { /* exotic nodes */ }
|
|
231
|
+
|
|
232
|
+
const expanded = el.getAttribute('aria-expanded');
|
|
233
|
+
if (expanded === 'true') s.expanded = true;
|
|
234
|
+
else if (expanded === 'false') s.expanded = false;
|
|
235
|
+
|
|
236
|
+
const pressed = el.getAttribute('aria-pressed');
|
|
237
|
+
if (pressed === 'true') s.pressed = true;
|
|
238
|
+
else if (pressed === 'false') s.pressed = false;
|
|
239
|
+
|
|
240
|
+
const selected = el.getAttribute('aria-selected');
|
|
241
|
+
if (selected === 'true') s.selected = true;
|
|
242
|
+
else if (selected === 'false' && (role === 'tab' || role === 'option')) s.selected = false;
|
|
243
|
+
|
|
244
|
+
if (el.tagName === 'INPUT') {
|
|
245
|
+
const type = (el.type || 'text').toLowerCase();
|
|
246
|
+
if (type !== 'checkbox' && type !== 'radio' && type !== 'submit' && type !== 'button' && type !== 'reset' && el.value) {
|
|
247
|
+
s.value = el.value;
|
|
248
|
+
}
|
|
249
|
+
} else if (el.tagName === 'TEXTAREA') {
|
|
250
|
+
if (el.value) s.value = el.value;
|
|
251
|
+
} else if (el.tagName === 'SELECT') {
|
|
252
|
+
s.value = el.value;
|
|
253
|
+
const opt = el.options && el.options[el.selectedIndex];
|
|
254
|
+
if (opt) s.selectedText = opt.text || '';
|
|
255
|
+
} else if (el.tagName === 'DETAILS') {
|
|
256
|
+
s.open = !!el.open;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Covered — element visually occluded by something else at its center.
|
|
260
|
+
if (rect && rect.width && rect.height) {
|
|
261
|
+
const cx = rect.left + rect.width / 2;
|
|
262
|
+
const cy = rect.top + rect.height / 2;
|
|
263
|
+
if (cx >= 0 && cy >= 0) {
|
|
264
|
+
const doc = el.ownerDocument || document;
|
|
265
|
+
if (doc.elementFromPoint) {
|
|
266
|
+
const top = doc.elementFromPoint(cx, cy);
|
|
267
|
+
if (top && top !== el && !(el.contains && el.contains(top))) {
|
|
268
|
+
s.covered = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return Object.keys(s).length ? s : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ── Map extraction ─────────────────────────────── */
|
|
278
|
+
|
|
279
|
+
function extractMap(element, interactiveSelector, semanticSelector, fields) {
|
|
280
|
+
const rootRect = element.getBoundingClientRect();
|
|
281
|
+
const map = [];
|
|
282
|
+
let i = 0;
|
|
283
|
+
const tracked = new Set();
|
|
284
|
+
|
|
285
|
+
for (const el of element.querySelectorAll(interactiveSelector)) {
|
|
286
|
+
const entry = buildEntry(el, rootRect, i, fields, 'interactive');
|
|
287
|
+
if (entry) { map.push(entry); tracked.add(el); i++; }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (semanticSelector) {
|
|
291
|
+
for (const el of element.querySelectorAll(semanticSelector)) {
|
|
292
|
+
if (tracked.has(el)) continue;
|
|
293
|
+
const entry = buildEntry(el, rootRect, i, fields, 'semantic');
|
|
294
|
+
if (entry) { map.push(entry); i++; }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
map,
|
|
300
|
+
dimensions: { width: rootRect.width, height: rootRect.height },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function buildEntry(el, rootRect, i, fields, kind) {
|
|
305
|
+
const rect = el.getBoundingClientRect();
|
|
306
|
+
const b = [
|
|
307
|
+
Math.round(rect.left - rootRect.left),
|
|
308
|
+
Math.round(rect.top - rootRect.top),
|
|
309
|
+
Math.round(rect.width),
|
|
310
|
+
Math.round(rect.height),
|
|
311
|
+
];
|
|
312
|
+
if (b[2] <= 0 && b[3] <= 0) return null;
|
|
313
|
+
|
|
314
|
+
const role = deriveRole(el);
|
|
315
|
+
const n = accessibleName(el);
|
|
316
|
+
|
|
317
|
+
const entry = { i, n, r: role, b };
|
|
318
|
+
|
|
319
|
+
if (kind === 'interactive') {
|
|
320
|
+
const s = deriveState(el, role, rect);
|
|
321
|
+
if (s) entry.s = s;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (fields === 'full') {
|
|
325
|
+
const t = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
326
|
+
if (t && t !== n) entry.t = t.length > 160 ? t.slice(0, 159) + '…' : t;
|
|
327
|
+
const a = {};
|
|
328
|
+
for (const name of ['href', 'type', 'name', 'placeholder', 'alt', 'title', 'role', 'aria-label']) {
|
|
329
|
+
const v = el.getAttribute(name);
|
|
330
|
+
if (v && v !== 'false') a[name] = v;
|
|
331
|
+
}
|
|
332
|
+
if (Object.keys(a).length) entry.a = a;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return entry;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* ── Annotations ────────────────────────────────── */
|
|
339
|
+
|
|
340
|
+
function addAnnotations(clone, entries, customStyle) {
|
|
341
|
+
const interactive = entries.filter(e => !e.isSemanticOnly);
|
|
342
|
+
if (!interactive.length) return;
|
|
343
|
+
|
|
344
|
+
const overlay = document.createElement('div');
|
|
345
|
+
overlay.setAttribute('data-snap-agent-overlay', 'true');
|
|
346
|
+
Object.assign(overlay.style, {
|
|
347
|
+
position: 'absolute',
|
|
348
|
+
top: '0', left: '0', width: '100%', height: '100%',
|
|
349
|
+
pointerEvents: 'none',
|
|
350
|
+
zIndex: '2147483647',
|
|
351
|
+
overflow: 'visible',
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
for (const e of interactive) {
|
|
355
|
+
const badge = document.createElement('span');
|
|
356
|
+
badge.textContent = String(e.i);
|
|
357
|
+
const cx = e.b[0] + e.b[2] / 2;
|
|
358
|
+
const cy = e.b[1] + e.b[3] / 2;
|
|
359
|
+
Object.assign(badge.style, {
|
|
360
|
+
position: 'absolute',
|
|
361
|
+
left: cx + 'px', top: cy + 'px',
|
|
362
|
+
transform: 'translate(-50%, -50%)',
|
|
363
|
+
minWidth: '18px', height: '18px',
|
|
364
|
+
lineHeight: '18px', fontSize: '11px', fontWeight: '700',
|
|
365
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
366
|
+
color: '#fff', backgroundColor: 'rgba(220, 38, 38, 0.92)',
|
|
367
|
+
borderRadius: '9px', textAlign: 'center', padding: '0 4px',
|
|
368
|
+
boxSizing: 'border-box', boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
|
369
|
+
...customStyle,
|
|
370
|
+
});
|
|
371
|
+
overlay.appendChild(badge);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
clone.style.position = 'relative';
|
|
375
|
+
clone.appendChild(overlay);
|
|
376
|
+
}
|
package/html-in-canvas.js
CHANGED
|
@@ -9,13 +9,21 @@
|
|
|
9
9
|
|
|
10
10
|
const PLUGIN_NAME = 'html-in-canvas'
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The WICG canvas-place-element spec evolved: Chrome ~130+ exposes drawElement(),
|
|
14
|
+
* earlier flagged builds shipped drawElementImage(). Detect either.
|
|
15
|
+
* @returns {'drawElement'|'drawElementImage'|null}
|
|
16
|
+
*/
|
|
17
|
+
function detectDrawApi() {
|
|
13
18
|
try {
|
|
14
19
|
const c = document.createElement('canvas')
|
|
15
20
|
const ctx = c.getContext('2d')
|
|
16
|
-
|
|
21
|
+
if (!ctx) return null
|
|
22
|
+
if (typeof ctx.drawElement === 'function') return 'drawElement'
|
|
23
|
+
if (typeof ctx.drawElementImage === 'function') return 'drawElementImage'
|
|
24
|
+
return null
|
|
17
25
|
} catch {
|
|
18
|
-
return
|
|
26
|
+
return null
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
|
|
@@ -23,9 +31,10 @@ function isDrawElementImageAvailable() {
|
|
|
23
31
|
* @returns {import('../../src/core/plugins.js').Plugin}
|
|
24
32
|
*/
|
|
25
33
|
export function htmlInCanvasPlugin() {
|
|
26
|
-
const
|
|
34
|
+
const drawApi = detectDrawApi()
|
|
35
|
+
const available = !!drawApi
|
|
27
36
|
if (!available) {
|
|
28
|
-
console.warn('[snapdom] html-in-canvas plugin: drawElementImage not available. Enable chrome://flags/#canvas-draw-element')
|
|
37
|
+
console.warn('[snapdom] html-in-canvas plugin: drawElement / drawElementImage not available. Enable chrome://flags/#canvas-draw-element')
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
return {
|
|
@@ -92,27 +101,34 @@ export function htmlInCanvasPlugin() {
|
|
|
92
101
|
|
|
93
102
|
canvas.appendChild(wrapper)
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
// Append directly to body, taken out of flow with position:fixed + z-index:-1
|
|
105
|
+
// so it sits behind the page's content (covered by body/main backgrounds)
|
|
106
|
+
// while still being painted. visibility:hidden / opacity:0 / left:-9999px
|
|
107
|
+
// skip the paint pass and trigger "No cached paint record".
|
|
108
|
+
canvas.style.cssText = 'position:fixed;top:0;left:0;z-index:-1;'
|
|
109
|
+
document.body.appendChild(canvas)
|
|
100
110
|
|
|
101
111
|
try {
|
|
102
|
-
|
|
112
|
+
canvas.getBoundingClientRect()
|
|
113
|
+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
114
|
+
|
|
103
115
|
const ctx2d = canvas.getContext('2d')
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
const fn = ctx2d && (ctx2d[drawApi] || ctx2d.drawElement || ctx2d.drawElementImage)
|
|
117
|
+
if (typeof fn !== 'function') {
|
|
118
|
+
throw new Error('drawElement / drawElementImage not available on this canvas context')
|
|
106
119
|
}
|
|
107
120
|
ctx2d.save()
|
|
108
121
|
ctx2d.scale(dpr * scale, dpr * scale)
|
|
109
|
-
|
|
122
|
+
fn.call(ctx2d, wrapper, 0, 0, width, height)
|
|
110
123
|
ctx2d.restore()
|
|
111
124
|
return canvas
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (e && /paint record/i.test(e.message || '')) {
|
|
127
|
+
throw new Error('Browser had no paint record for the element. Make sure the document is fully loaded and visible before calling html-in-canvas (drawElement requires a real paint pass).')
|
|
128
|
+
}
|
|
129
|
+
throw e
|
|
112
130
|
} finally {
|
|
113
|
-
try {
|
|
114
|
-
document.body.removeChild(container)
|
|
115
|
-
} catch {}
|
|
131
|
+
try { canvas.remove() } catch {}
|
|
116
132
|
}
|
|
117
133
|
}
|
|
118
134
|
}
|
package/index.js
CHANGED
|
@@ -12,5 +12,5 @@ export { replaceText } from './replace-text.js';
|
|
|
12
12
|
export { filter } from './filter.js';
|
|
13
13
|
export { colorTint } from './color-tint.js';
|
|
14
14
|
export { pdfImage } from './pdf-image.js';
|
|
15
|
-
export {
|
|
15
|
+
export { agentMap } from './agent-map.js';
|
|
16
16
|
// export { htmlInCanvas } from './html-in-canvas.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zumer/snapdom-plugins",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Official plugins for SnapDOM",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"./color-tint": "./color-tint.js",
|
|
13
13
|
"./html-in-canvas": "./html-in-canvas.js",
|
|
14
14
|
"./pdf-image": "./pdf-image.js",
|
|
15
|
-
"./
|
|
15
|
+
"./agent-map": "./agent-map.js"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"*.js",
|
package/prompt-export.js
DELETED
|
@@ -1,527 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* promptExport – Official SnapDOM Plugin
|
|
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).
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* import { promptExport } from '@zumer/snapdom-plugins/prompt-export';
|
|
9
|
-
* const result = await snapdom(el, { plugins: [promptExport()] });
|
|
10
|
-
* const { image, elements, dimensions, prompt } = await result.toPrompt();
|
|
11
|
-
*
|
|
12
|
-
* @param {Object} [options]
|
|
13
|
-
* @param {boolean} [options.annotate=true] - Overlay numbered badges on interactive elements
|
|
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
|
|
22
|
-
* @returns {Object} SnapDOM plugin
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
const DEFAULT_INTERACTIVE =
|
|
26
|
-
'a[href], button, input, select, textarea, ' +
|
|
27
|
-
'[role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], ' +
|
|
28
|
-
'[tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]';
|
|
29
|
-
|
|
30
|
-
const DEFAULT_SEMANTIC =
|
|
31
|
-
'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' +
|
|
32
|
-
'header, footer, label, td, th, figcaption, blockquote, legend';
|
|
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
|
-
|
|
61
|
-
export function promptExport(options = {}) {
|
|
62
|
-
const {
|
|
63
|
-
annotate = true,
|
|
64
|
-
imageFormat = 'png',
|
|
65
|
-
imageQuality = 0.8,
|
|
66
|
-
maxImageWidth = 1024,
|
|
67
|
-
interactiveSelector = DEFAULT_INTERACTIVE,
|
|
68
|
-
semanticSelector = DEFAULT_SEMANTIC,
|
|
69
|
-
labelStyle = {},
|
|
70
|
-
promptMode = 'compact',
|
|
71
|
-
includeCoords = true,
|
|
72
|
-
} = options;
|
|
73
|
-
|
|
74
|
-
return {
|
|
75
|
-
name: 'prompt-export',
|
|
76
|
-
|
|
77
|
-
afterClone(ctx) {
|
|
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.
|
|
81
|
-
ctx.__promptMetadata = meta;
|
|
82
|
-
if (ctx.options) ctx.options.__promptMetadata = meta;
|
|
83
|
-
|
|
84
|
-
if (annotate) {
|
|
85
|
-
addAnnotations(ctx.clone, meta.elements, labelStyle);
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
|
-
|
|
89
|
-
defineExports() {
|
|
90
|
-
return {
|
|
91
|
-
prompt: async (ctx, opts = {}) => {
|
|
92
|
-
const meta = ctx.__promptMetadata;
|
|
93
|
-
if (!meta || !meta.elements.length) {
|
|
94
|
-
return {
|
|
95
|
-
image: ctx.export.url,
|
|
96
|
-
elements: [],
|
|
97
|
-
dimensions: { width: 0, height: 0 },
|
|
98
|
-
prompt: '',
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const format = opts.imageFormat || imageFormat;
|
|
103
|
-
const quality = opts.imageQuality || imageQuality;
|
|
104
|
-
const maxWidth = opts.maxImageWidth || maxImageWidth;
|
|
105
|
-
const mode = opts.promptMode || promptMode;
|
|
106
|
-
const withCoords = opts.includeCoords !== undefined ? opts.includeCoords : includeCoords;
|
|
107
|
-
|
|
108
|
-
const img = new Image();
|
|
109
|
-
img.src = ctx.export.url;
|
|
110
|
-
await new Promise((res, rej) => {
|
|
111
|
-
img.onload = res;
|
|
112
|
-
img.onerror = rej;
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
const ratio = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
|
|
116
|
-
const w = Math.round(img.naturalWidth * ratio);
|
|
117
|
-
const h = Math.round(img.naturalHeight * ratio);
|
|
118
|
-
|
|
119
|
-
const canvas = document.createElement('canvas');
|
|
120
|
-
canvas.width = w;
|
|
121
|
-
canvas.height = h;
|
|
122
|
-
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
123
|
-
|
|
124
|
-
const mime =
|
|
125
|
-
format === 'jpg' || format === 'jpeg' ? 'image/jpeg'
|
|
126
|
-
: format === 'webp' ? 'image/webp'
|
|
127
|
-
: 'image/png';
|
|
128
|
-
const dataURL = canvas.toDataURL(mime, quality);
|
|
129
|
-
|
|
130
|
-
const sx = w / (meta.dimensions.width || 1);
|
|
131
|
-
const sy = h / (meta.dimensions.height || 1);
|
|
132
|
-
|
|
133
|
-
const elements = meta.elements.map((el) => ({
|
|
134
|
-
...el,
|
|
135
|
-
bbox: {
|
|
136
|
-
x: Math.round(el.bbox.x * sx),
|
|
137
|
-
y: Math.round(el.bbox.y * sy),
|
|
138
|
-
width: Math.round(el.bbox.width * sx),
|
|
139
|
-
height: Math.round(el.bbox.height * sy),
|
|
140
|
-
},
|
|
141
|
-
}));
|
|
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
|
-
|
|
150
|
-
return {
|
|
151
|
-
image: dataURL,
|
|
152
|
-
elements,
|
|
153
|
-
dimensions: { width: w, height: h },
|
|
154
|
-
prompt,
|
|
155
|
-
};
|
|
156
|
-
},
|
|
157
|
-
};
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
|
-
}
|
|
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
|
-
|
|
290
|
-
/* ── Metadata extraction ──────────────────────── */
|
|
291
|
-
|
|
292
|
-
function extractMetadata(element, interactiveSelector, semanticSelector) {
|
|
293
|
-
const rootRect = element.getBoundingClientRect();
|
|
294
|
-
const elements = [];
|
|
295
|
-
let id = 0;
|
|
296
|
-
|
|
297
|
-
const tracked = new Set();
|
|
298
|
-
|
|
299
|
-
for (const el of element.querySelectorAll(interactiveSelector)) {
|
|
300
|
-
const entry = buildEntry(el, rootRect, id, 'interactive');
|
|
301
|
-
if (entry) {
|
|
302
|
-
elements.push(entry);
|
|
303
|
-
tracked.add(el);
|
|
304
|
-
id++;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
for (const el of element.querySelectorAll(semanticSelector)) {
|
|
309
|
-
if (tracked.has(el)) continue;
|
|
310
|
-
const entry = buildEntry(el, rootRect, id, 'semantic');
|
|
311
|
-
if (entry) {
|
|
312
|
-
elements.push(entry);
|
|
313
|
-
id++;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
return {
|
|
318
|
-
elements,
|
|
319
|
-
dimensions: { width: rootRect.width, height: rootRect.height },
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function buildEntry(el, rootRect, id, type) {
|
|
324
|
-
const rect = el.getBoundingClientRect();
|
|
325
|
-
const bbox = {
|
|
326
|
-
x: Math.round(rect.left - rootRect.left),
|
|
327
|
-
y: Math.round(rect.top - rootRect.top),
|
|
328
|
-
width: Math.round(rect.width),
|
|
329
|
-
height: Math.round(rect.height),
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
if (bbox.width <= 0 && bbox.height <= 0) return null;
|
|
333
|
-
|
|
334
|
-
const tag = el.tagName.toLowerCase();
|
|
335
|
-
const maxText = type === 'interactive' ? 200 : 120;
|
|
336
|
-
const text = (el.textContent || '').trim().slice(0, maxText);
|
|
337
|
-
|
|
338
|
-
const attributes = {};
|
|
339
|
-
for (const attr of COLLECTED_ATTRS) {
|
|
340
|
-
const val = el.getAttribute(attr);
|
|
341
|
-
if (val == null || val === '' || val === 'false') continue;
|
|
342
|
-
attributes[attr] = val;
|
|
343
|
-
}
|
|
344
|
-
|
|
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;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/* ── Visual annotations ───────────────────────── */
|
|
390
|
-
|
|
391
|
-
function addAnnotations(clone, elements, customStyle) {
|
|
392
|
-
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
393
|
-
if (!interactive.length) return;
|
|
394
|
-
|
|
395
|
-
const overlay = document.createElement('div');
|
|
396
|
-
overlay.setAttribute('data-snap-prompt-overlay', 'true');
|
|
397
|
-
Object.assign(overlay.style, {
|
|
398
|
-
position: 'absolute',
|
|
399
|
-
top: '0',
|
|
400
|
-
left: '0',
|
|
401
|
-
width: '100%',
|
|
402
|
-
height: '100%',
|
|
403
|
-
pointerEvents: 'none',
|
|
404
|
-
zIndex: '2147483647',
|
|
405
|
-
overflow: 'visible',
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
for (const el of interactive) {
|
|
409
|
-
const badge = document.createElement('span');
|
|
410
|
-
badge.textContent = String(el.id);
|
|
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;
|
|
416
|
-
Object.assign(badge.style, {
|
|
417
|
-
position: 'absolute',
|
|
418
|
-
left: `${cx}px`,
|
|
419
|
-
top: `${cy}px`,
|
|
420
|
-
transform: 'translate(-50%, -50%)',
|
|
421
|
-
minWidth: '18px',
|
|
422
|
-
height: '18px',
|
|
423
|
-
lineHeight: '18px',
|
|
424
|
-
fontSize: '11px',
|
|
425
|
-
fontWeight: '700',
|
|
426
|
-
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
427
|
-
color: '#fff',
|
|
428
|
-
backgroundColor: 'rgba(220, 38, 38, 0.92)',
|
|
429
|
-
borderRadius: '9px',
|
|
430
|
-
textAlign: 'center',
|
|
431
|
-
padding: '0 4px',
|
|
432
|
-
boxSizing: 'border-box',
|
|
433
|
-
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
|
434
|
-
...customStyle,
|
|
435
|
-
});
|
|
436
|
-
overlay.appendChild(badge);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
clone.style.position = 'relative';
|
|
440
|
-
clone.appendChild(overlay);
|
|
441
|
-
}
|
|
442
|
-
|
|
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');
|
|
467
|
-
|
|
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) {
|
|
491
|
-
const lines = [
|
|
492
|
-
`Screenshot of a web page (${dimensions.width}×${dimensions.height}px).`,
|
|
493
|
-
'',
|
|
494
|
-
];
|
|
495
|
-
|
|
496
|
-
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
497
|
-
const semantic = elements.filter((e) => e.type === 'semantic');
|
|
498
|
-
|
|
499
|
-
if (interactive.length) {
|
|
500
|
-
lines.push('Interactive elements:');
|
|
501
|
-
for (const el of interactive) {
|
|
502
|
-
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
503
|
-
const attrParts = Object.entries(el.attributes).map(([k, v]) => `${k}="${v}"`);
|
|
504
|
-
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
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}`);
|
|
509
|
-
}
|
|
510
|
-
lines.push('');
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (semantic.length) {
|
|
514
|
-
lines.push('Semantic structure:');
|
|
515
|
-
for (const el of semantic) {
|
|
516
|
-
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
517
|
-
const attrParts = [];
|
|
518
|
-
if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
|
|
519
|
-
if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
|
|
520
|
-
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
521
|
-
lines.push(` [${el.id}] <${el.tag}>${name}${attrs}`);
|
|
522
|
-
}
|
|
523
|
-
lines.push('');
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
return lines.join('\n');
|
|
527
|
-
}
|