@zumer/snapdom-plugins 1.2.0 → 2.2.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 +119 -53
- package/agent-map.js +376 -0
- package/gif-export.js +279 -0
- package/html-export.js +63 -0
- package/html-in-canvas.js +33 -17
- package/index.js +5 -2
- package/package.json +6 -3
- package/video-export.js +138 -0
- package/prompt-export.js +0 -550
package/prompt-export.js
DELETED
|
@@ -1,550 +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
|
-
* @param {string[]} [options.include] - Which fields to return. Default
|
|
23
|
-
* ['elements', 'prompt']. For vision-dependent tasks (chart content, layout QA,
|
|
24
|
-
* canvas) pass ['image', 'elements', 'prompt'] or add 'image' to the array. For
|
|
25
|
-
* text-only agent prompts pass ['prompt'] (cheapest — skips canvas draw entirely).
|
|
26
|
-
* Accepted values: 'image', 'elements', 'prompt'.
|
|
27
|
-
* @returns {Object} SnapDOM plugin
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
const DEFAULT_INTERACTIVE =
|
|
31
|
-
'a[href], button, input, select, textarea, ' +
|
|
32
|
-
'[role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], ' +
|
|
33
|
-
'[tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]';
|
|
34
|
-
|
|
35
|
-
const DEFAULT_SEMANTIC =
|
|
36
|
-
'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' +
|
|
37
|
-
'header, footer, label, td, th, figcaption, blockquote, legend';
|
|
38
|
-
|
|
39
|
-
const COLLECTED_ATTRS = [
|
|
40
|
-
'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
|
|
41
|
-
'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
const VISUAL_FIELDS = [
|
|
45
|
-
'display', 'visibility', 'opacity',
|
|
46
|
-
'color', 'backgroundColor',
|
|
47
|
-
'fontSize', 'fontWeight',
|
|
48
|
-
'cursor', 'overflow',
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
// Common computed-style values that carry no information. Keeping the
|
|
52
|
-
// `styles` object small is the difference between a useful LLM input and
|
|
53
|
-
// token bloat on every element.
|
|
54
|
-
const VISUAL_SKIP = new Set(['initial', 'normal', 'visible', 'auto', 'static', '0']);
|
|
55
|
-
function isDefaultStyleValue(prop, value) {
|
|
56
|
-
if (!value) return true;
|
|
57
|
-
if (VISUAL_SKIP.has(value)) return true;
|
|
58
|
-
if (prop === 'cursor' && value === 'none') return true;
|
|
59
|
-
if (prop === 'color' && value === 'rgb(0, 0, 0)') return true;
|
|
60
|
-
if (prop === 'backgroundColor' && value === 'rgba(0, 0, 0, 0)') return true;
|
|
61
|
-
if (prop === 'fontWeight' && (value === '400' || value === 'normal')) return true;
|
|
62
|
-
if (prop === 'opacity' && value === '1') return true;
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Default omits 'image'. Benchmarking showed the text + JSON map is enough
|
|
67
|
-
// to answer most UI-inspection questions and uses ~14× fewer tokens. Pass
|
|
68
|
-
// `include: ['image', 'elements', 'prompt']` explicitly when the task truly
|
|
69
|
-
// depends on vision (charts, canvas content, layout QA).
|
|
70
|
-
const DEFAULT_INCLUDE = ['elements', 'prompt'];
|
|
71
|
-
|
|
72
|
-
export function promptExport(options = {}) {
|
|
73
|
-
const {
|
|
74
|
-
annotate = true,
|
|
75
|
-
imageFormat = 'png',
|
|
76
|
-
imageQuality = 0.8,
|
|
77
|
-
maxImageWidth = 1024,
|
|
78
|
-
interactiveSelector = DEFAULT_INTERACTIVE,
|
|
79
|
-
semanticSelector = DEFAULT_SEMANTIC,
|
|
80
|
-
labelStyle = {},
|
|
81
|
-
promptMode = 'compact',
|
|
82
|
-
includeCoords = true,
|
|
83
|
-
include = DEFAULT_INCLUDE,
|
|
84
|
-
} = options;
|
|
85
|
-
|
|
86
|
-
return {
|
|
87
|
-
name: 'prompt-export',
|
|
88
|
-
|
|
89
|
-
afterClone(ctx) {
|
|
90
|
-
const meta = extractMetadata(ctx.element, interactiveSelector, semanticSelector);
|
|
91
|
-
// snapdom spreads a fresh ctx for the export phase from ctx.options,
|
|
92
|
-
// so write to both so the prompt() call below can read it.
|
|
93
|
-
ctx.__promptMetadata = meta;
|
|
94
|
-
if (ctx.options) ctx.options.__promptMetadata = meta;
|
|
95
|
-
|
|
96
|
-
if (annotate) {
|
|
97
|
-
addAnnotations(ctx.clone, meta.elements, labelStyle);
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
|
|
101
|
-
defineExports() {
|
|
102
|
-
return {
|
|
103
|
-
prompt: async (ctx, opts = {}) => {
|
|
104
|
-
const meta = ctx.__promptMetadata;
|
|
105
|
-
const wantSet = new Set(opts.include || include || DEFAULT_INCLUDE);
|
|
106
|
-
const wantImage = wantSet.has('image');
|
|
107
|
-
const wantElements = wantSet.has('elements');
|
|
108
|
-
const wantPrompt = wantSet.has('prompt');
|
|
109
|
-
|
|
110
|
-
if (!meta || !meta.elements.length) {
|
|
111
|
-
const empty = { dimensions: { width: 0, height: 0 } };
|
|
112
|
-
if (wantImage) empty.image = ctx.export.url;
|
|
113
|
-
if (wantElements) empty.elements = [];
|
|
114
|
-
if (wantPrompt) empty.prompt = '';
|
|
115
|
-
return empty;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const format = opts.imageFormat || imageFormat;
|
|
119
|
-
const quality = opts.imageQuality || imageQuality;
|
|
120
|
-
const maxWidth = opts.maxImageWidth || maxImageWidth;
|
|
121
|
-
const mode = opts.promptMode || promptMode;
|
|
122
|
-
const withCoords = opts.includeCoords !== undefined ? opts.includeCoords : includeCoords;
|
|
123
|
-
|
|
124
|
-
// Only load + rasterize the SVG when the caller actually wants the
|
|
125
|
-
// image. Skipping saves the img decode + canvas draw + toDataURL —
|
|
126
|
-
// the most expensive steps of this export.
|
|
127
|
-
let w, h, dataURL;
|
|
128
|
-
if (wantImage) {
|
|
129
|
-
const img = new Image();
|
|
130
|
-
img.src = ctx.export.url;
|
|
131
|
-
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
|
|
132
|
-
const ratio = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
|
|
133
|
-
w = Math.round(img.naturalWidth * ratio);
|
|
134
|
-
h = Math.round(img.naturalHeight * ratio);
|
|
135
|
-
const canvas = document.createElement('canvas');
|
|
136
|
-
canvas.width = w;
|
|
137
|
-
canvas.height = h;
|
|
138
|
-
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
139
|
-
const mime =
|
|
140
|
-
format === 'jpg' || format === 'jpeg' ? 'image/jpeg'
|
|
141
|
-
: format === 'webp' ? 'image/webp'
|
|
142
|
-
: 'image/png';
|
|
143
|
-
dataURL = canvas.toDataURL(mime, quality);
|
|
144
|
-
} else {
|
|
145
|
-
// No image — scale bboxes to the same target width the image would
|
|
146
|
-
// have used, so downstream callers can still render the map over
|
|
147
|
-
// a separately-rendered screenshot at the same scale.
|
|
148
|
-
const sourceW = meta.dimensions.width || 1;
|
|
149
|
-
const ratio = sourceW > maxWidth ? maxWidth / sourceW : 1;
|
|
150
|
-
w = Math.round(sourceW * ratio);
|
|
151
|
-
h = Math.round(meta.dimensions.height * ratio);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const sx = w / (meta.dimensions.width || 1);
|
|
155
|
-
const sy = h / (meta.dimensions.height || 1);
|
|
156
|
-
|
|
157
|
-
const scaledElements = meta.elements.map((el) => ({
|
|
158
|
-
...el,
|
|
159
|
-
bbox: {
|
|
160
|
-
x: Math.round(el.bbox.x * sx),
|
|
161
|
-
y: Math.round(el.bbox.y * sy),
|
|
162
|
-
width: Math.round(el.bbox.width * sx),
|
|
163
|
-
height: Math.round(el.bbox.height * sy),
|
|
164
|
-
},
|
|
165
|
-
}));
|
|
166
|
-
|
|
167
|
-
const emitCoords = mode === 'verbose' ? true : (withCoords && !annotate);
|
|
168
|
-
const promptText = (wantPrompt)
|
|
169
|
-
? (mode === 'verbose'
|
|
170
|
-
? formatPromptVerbose(scaledElements, { width: w, height: h }, emitCoords)
|
|
171
|
-
: formatPromptCompact(scaledElements, { width: w, height: h }, emitCoords))
|
|
172
|
-
: null;
|
|
173
|
-
|
|
174
|
-
const out = { dimensions: { width: w, height: h } };
|
|
175
|
-
if (wantImage) out.image = dataURL;
|
|
176
|
-
if (wantElements) out.elements = scaledElements;
|
|
177
|
-
if (wantPrompt) out.prompt = promptText;
|
|
178
|
-
return out;
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
},
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/* ── Accessible name ──────────────────────────── */
|
|
186
|
-
|
|
187
|
-
function truncate(str, max) {
|
|
188
|
-
if (!str) return '';
|
|
189
|
-
if (str.length <= max) return str;
|
|
190
|
-
return str.slice(0, max - 1) + '…';
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Compute the element's accessible name following a simplified WAI-ARIA order.
|
|
195
|
-
* This is what an LLM agent reads first to know what the element IS.
|
|
196
|
-
*/
|
|
197
|
-
function accessibleName(el) {
|
|
198
|
-
const ariaLabel = el.getAttribute('aria-label');
|
|
199
|
-
if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim();
|
|
200
|
-
|
|
201
|
-
const labelledBy = el.getAttribute('aria-labelledby');
|
|
202
|
-
if (labelledBy) {
|
|
203
|
-
const root = el.getRootNode();
|
|
204
|
-
const getById = (id) =>
|
|
205
|
-
root && typeof root.getElementById === 'function'
|
|
206
|
-
? root.getElementById(id)
|
|
207
|
-
: document.getElementById(id);
|
|
208
|
-
const parts = labelledBy.trim().split(/\s+/)
|
|
209
|
-
.map((id) => {
|
|
210
|
-
const ref = getById(id);
|
|
211
|
-
return ref ? (ref.textContent || '').trim() : '';
|
|
212
|
-
})
|
|
213
|
-
.filter(Boolean);
|
|
214
|
-
if (parts.length) return parts.join(' ');
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (el.tagName === 'IMG') {
|
|
218
|
-
const alt = el.getAttribute('alt');
|
|
219
|
-
if (alt && alt.trim()) return alt.trim();
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const title = el.getAttribute('title');
|
|
223
|
-
if (title && title.trim()) return title.trim();
|
|
224
|
-
|
|
225
|
-
if (el.labels && el.labels[0]) {
|
|
226
|
-
const t = (el.labels[0].textContent || '').trim();
|
|
227
|
-
if (t) return t;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const text = (el.textContent || '').trim();
|
|
231
|
-
return text ? truncate(text, 40) : '';
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/* ── DOM state ────────────────────────────────── */
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Capture real runtime state (not just ARIA attributes). This is what
|
|
238
|
-
* separates a useful map from a static screenshot annotation: an agent can
|
|
239
|
-
* see "checkbox is unchecked / select has value=X / details is closed".
|
|
240
|
-
*/
|
|
241
|
-
function computeState(el) {
|
|
242
|
-
const state = {};
|
|
243
|
-
try {
|
|
244
|
-
if (el.matches(':checked')) state.checked = true;
|
|
245
|
-
if (el.matches(':disabled')) state.disabled = true;
|
|
246
|
-
if (el.matches(':focus')) state.focus = true;
|
|
247
|
-
} catch { /* some selectors may fail on exotic nodes */ }
|
|
248
|
-
|
|
249
|
-
const tag = el.tagName;
|
|
250
|
-
if (tag === 'INPUT') {
|
|
251
|
-
// Checkbox/radio: `value` defaults to "on" when no explicit value attr
|
|
252
|
-
// is set — noise. The meaningful signal is `checked`. Skip value here.
|
|
253
|
-
const type = (el.type || 'text').toLowerCase();
|
|
254
|
-
if (type !== 'checkbox' && type !== 'radio' && el.value) {
|
|
255
|
-
state.value = el.value;
|
|
256
|
-
}
|
|
257
|
-
} else if (tag === 'TEXTAREA') {
|
|
258
|
-
if (el.value) state.value = el.value;
|
|
259
|
-
} else if (tag === 'SELECT') {
|
|
260
|
-
state.value = el.value;
|
|
261
|
-
const opt = el.options && el.options[el.selectedIndex];
|
|
262
|
-
if (opt) state.selectedText = opt.text || '';
|
|
263
|
-
} else if (tag === 'DETAILS') {
|
|
264
|
-
state.open = !!el.open;
|
|
265
|
-
} else if (el.hasAttribute && el.hasAttribute('open')) {
|
|
266
|
-
state.open = true;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return Object.keys(state).length ? state : null;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/* ── Visual styles ────────────────────────────── */
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Pull a small set of visually-meaningful computed styles. Reads from the
|
|
276
|
-
* live element (the clone uses class-based styling so its inline style is
|
|
277
|
-
* empty and a detached `getComputedStyle(cloneNode)` returns initial values).
|
|
278
|
-
* The computed style of the original is what snapdom used to build the
|
|
279
|
-
* capture, so the result matches what the screenshot shows.
|
|
280
|
-
*/
|
|
281
|
-
function computeVisualStyles(el) {
|
|
282
|
-
let cs;
|
|
283
|
-
try { cs = getComputedStyle(el); } catch { return null; }
|
|
284
|
-
if (!cs) return null;
|
|
285
|
-
const out = {};
|
|
286
|
-
for (const prop of VISUAL_FIELDS) {
|
|
287
|
-
const v = cs[prop];
|
|
288
|
-
if (isDefaultStyleValue(prop, v)) continue;
|
|
289
|
-
out[prop] = v;
|
|
290
|
-
}
|
|
291
|
-
return Object.keys(out).length ? out : null;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/* ── Covered detection ────────────────────────── */
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* True when another element is painted on top of the center of this one.
|
|
298
|
-
* An agent that knows a button is covered by a modal won't try to click it.
|
|
299
|
-
*/
|
|
300
|
-
function isCovered(el, rect) {
|
|
301
|
-
if (!rect.width || !rect.height) return false;
|
|
302
|
-
const cx = rect.left + rect.width / 2;
|
|
303
|
-
const cy = rect.top + rect.height / 2;
|
|
304
|
-
if (cx < 0 || cy < 0) return false;
|
|
305
|
-
const doc = el.ownerDocument || document;
|
|
306
|
-
if (!doc.elementFromPoint) return false;
|
|
307
|
-
const top = doc.elementFromPoint(cx, cy);
|
|
308
|
-
if (!top || top === el) return false;
|
|
309
|
-
if (el.contains && el.contains(top)) return false;
|
|
310
|
-
return true;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/* ── Metadata extraction ──────────────────────── */
|
|
314
|
-
|
|
315
|
-
function extractMetadata(element, interactiveSelector, semanticSelector) {
|
|
316
|
-
const rootRect = element.getBoundingClientRect();
|
|
317
|
-
const elements = [];
|
|
318
|
-
let id = 0;
|
|
319
|
-
|
|
320
|
-
const tracked = new Set();
|
|
321
|
-
|
|
322
|
-
for (const el of element.querySelectorAll(interactiveSelector)) {
|
|
323
|
-
const entry = buildEntry(el, rootRect, id, 'interactive');
|
|
324
|
-
if (entry) {
|
|
325
|
-
elements.push(entry);
|
|
326
|
-
tracked.add(el);
|
|
327
|
-
id++;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
for (const el of element.querySelectorAll(semanticSelector)) {
|
|
332
|
-
if (tracked.has(el)) continue;
|
|
333
|
-
const entry = buildEntry(el, rootRect, id, 'semantic');
|
|
334
|
-
if (entry) {
|
|
335
|
-
elements.push(entry);
|
|
336
|
-
id++;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return {
|
|
341
|
-
elements,
|
|
342
|
-
dimensions: { width: rootRect.width, height: rootRect.height },
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function buildEntry(el, rootRect, id, type) {
|
|
347
|
-
const rect = el.getBoundingClientRect();
|
|
348
|
-
const bbox = {
|
|
349
|
-
x: Math.round(rect.left - rootRect.left),
|
|
350
|
-
y: Math.round(rect.top - rootRect.top),
|
|
351
|
-
width: Math.round(rect.width),
|
|
352
|
-
height: Math.round(rect.height),
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
if (bbox.width <= 0 && bbox.height <= 0) return null;
|
|
356
|
-
|
|
357
|
-
const tag = el.tagName.toLowerCase();
|
|
358
|
-
const maxText = type === 'interactive' ? 200 : 120;
|
|
359
|
-
const text = (el.textContent || '').trim().slice(0, maxText);
|
|
360
|
-
|
|
361
|
-
const attributes = {};
|
|
362
|
-
for (const attr of COLLECTED_ATTRS) {
|
|
363
|
-
const val = el.getAttribute(attr);
|
|
364
|
-
if (val == null || val === '' || val === 'false') continue;
|
|
365
|
-
attributes[attr] = val;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// For <img>, replace the (potentially long, cache-busted) src with just the
|
|
369
|
-
// filename — "logo.svg" is more useful to an LLM than the full URL.
|
|
370
|
-
if (tag === 'img' && el.src) {
|
|
371
|
-
try {
|
|
372
|
-
const base = (el.ownerDocument && el.ownerDocument.location)
|
|
373
|
-
? el.ownerDocument.location.href
|
|
374
|
-
: (typeof location !== 'undefined' ? location.href : undefined);
|
|
375
|
-
const url = base ? new URL(el.src, base) : new URL(el.src);
|
|
376
|
-
attributes.src = url.pathname.split('/').pop() || el.src;
|
|
377
|
-
} catch {
|
|
378
|
-
attributes.src = el.src;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const entry = {
|
|
383
|
-
id,
|
|
384
|
-
tag,
|
|
385
|
-
type,
|
|
386
|
-
name: accessibleName(el),
|
|
387
|
-
text,
|
|
388
|
-
bbox,
|
|
389
|
-
attributes,
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
const styles = computeVisualStyles(el);
|
|
393
|
-
if (styles) entry.styles = styles;
|
|
394
|
-
|
|
395
|
-
if (type === 'interactive') {
|
|
396
|
-
const state = computeState(el);
|
|
397
|
-
if (state) {
|
|
398
|
-
// Drop state.value when it just echoes attributes.value — no
|
|
399
|
-
// divergence between the initial HTML attribute and the current
|
|
400
|
-
// property, nothing for the LLM to learn from the repeat.
|
|
401
|
-
if (state.value !== undefined && state.value === attributes.value) {
|
|
402
|
-
delete state.value;
|
|
403
|
-
}
|
|
404
|
-
if (Object.keys(state).length) entry.state = state;
|
|
405
|
-
}
|
|
406
|
-
if (isCovered(el, rect)) entry.covered = true;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
return entry;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/* ── Visual annotations ───────────────────────── */
|
|
413
|
-
|
|
414
|
-
function addAnnotations(clone, elements, customStyle) {
|
|
415
|
-
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
416
|
-
if (!interactive.length) return;
|
|
417
|
-
|
|
418
|
-
const overlay = document.createElement('div');
|
|
419
|
-
overlay.setAttribute('data-snap-prompt-overlay', 'true');
|
|
420
|
-
Object.assign(overlay.style, {
|
|
421
|
-
position: 'absolute',
|
|
422
|
-
top: '0',
|
|
423
|
-
left: '0',
|
|
424
|
-
width: '100%',
|
|
425
|
-
height: '100%',
|
|
426
|
-
pointerEvents: 'none',
|
|
427
|
-
zIndex: '2147483647',
|
|
428
|
-
overflow: 'visible',
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
for (const el of interactive) {
|
|
432
|
-
const badge = document.createElement('span');
|
|
433
|
-
badge.textContent = String(el.id);
|
|
434
|
-
badge.setAttribute('data-snap-prompt-label', String(el.id));
|
|
435
|
-
// Center the badge on the element's bbox, not on its top-left corner:
|
|
436
|
-
// `translate(-50%, -50%)` offsets from the anchor point.
|
|
437
|
-
const cx = el.bbox.x + el.bbox.width / 2;
|
|
438
|
-
const cy = el.bbox.y + el.bbox.height / 2;
|
|
439
|
-
Object.assign(badge.style, {
|
|
440
|
-
position: 'absolute',
|
|
441
|
-
left: `${cx}px`,
|
|
442
|
-
top: `${cy}px`,
|
|
443
|
-
transform: 'translate(-50%, -50%)',
|
|
444
|
-
minWidth: '18px',
|
|
445
|
-
height: '18px',
|
|
446
|
-
lineHeight: '18px',
|
|
447
|
-
fontSize: '11px',
|
|
448
|
-
fontWeight: '700',
|
|
449
|
-
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
450
|
-
color: '#fff',
|
|
451
|
-
backgroundColor: 'rgba(220, 38, 38, 0.92)',
|
|
452
|
-
borderRadius: '9px',
|
|
453
|
-
textAlign: 'center',
|
|
454
|
-
padding: '0 4px',
|
|
455
|
-
boxSizing: 'border-box',
|
|
456
|
-
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
|
457
|
-
...customStyle,
|
|
458
|
-
});
|
|
459
|
-
overlay.appendChild(badge);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
clone.style.position = 'relative';
|
|
463
|
-
clone.appendChild(overlay);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/* ── Prompt text formatters ───────────────────── */
|
|
467
|
-
|
|
468
|
-
function stateToStr(state) {
|
|
469
|
-
if (!state) return '';
|
|
470
|
-
const flags = [];
|
|
471
|
-
const pairs = [];
|
|
472
|
-
for (const k of Object.keys(state)) {
|
|
473
|
-
const v = state[k];
|
|
474
|
-
if (v === true) flags.push(k);
|
|
475
|
-
else pairs.push(`${k}=${JSON.stringify(v)}`);
|
|
476
|
-
}
|
|
477
|
-
if (!flags.length && !pairs.length) return '';
|
|
478
|
-
return ` {${[...flags, ...pairs].join(', ')}}`;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function coordsToStr(bbox) {
|
|
482
|
-
return ` (${bbox.x},${bbox.y} ${bbox.width}×${bbox.height})`;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function formatPromptCompact(elements, dimensions, withCoords) {
|
|
486
|
-
const lines = [`Screenshot (${dimensions.width}×${dimensions.height}px).`];
|
|
487
|
-
|
|
488
|
-
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
489
|
-
const semantic = elements.filter((e) => e.type === 'semantic');
|
|
490
|
-
|
|
491
|
-
if (interactive.length) {
|
|
492
|
-
lines.push('', 'Interactive:');
|
|
493
|
-
for (const el of interactive) {
|
|
494
|
-
const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
|
|
495
|
-
const st = stateToStr(el.state);
|
|
496
|
-
const cov = el.covered ? ' (covered)' : '';
|
|
497
|
-
const coords = withCoords ? coordsToStr(el.bbox) : '';
|
|
498
|
-
lines.push(` [${el.id}] ${el.tag}${name}${st}${cov}${coords}`);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (semantic.length) {
|
|
503
|
-
lines.push('', 'Semantic:');
|
|
504
|
-
for (const el of semantic) {
|
|
505
|
-
const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
|
|
506
|
-
lines.push(` [${el.id}] ${el.tag}${name}`);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
return lines.join('\n');
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function formatPromptVerbose(elements, dimensions, withCoords) {
|
|
514
|
-
const lines = [
|
|
515
|
-
`Screenshot of a web page (${dimensions.width}×${dimensions.height}px).`,
|
|
516
|
-
'',
|
|
517
|
-
];
|
|
518
|
-
|
|
519
|
-
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
520
|
-
const semantic = elements.filter((e) => e.type === 'semantic');
|
|
521
|
-
|
|
522
|
-
if (interactive.length) {
|
|
523
|
-
lines.push('Interactive elements:');
|
|
524
|
-
for (const el of interactive) {
|
|
525
|
-
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
526
|
-
const attrParts = Object.entries(el.attributes).map(([k, v]) => `${k}="${v}"`);
|
|
527
|
-
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
528
|
-
const pos = withCoords ? coordsToStr(el.bbox) : '';
|
|
529
|
-
const st = stateToStr(el.state);
|
|
530
|
-
const cov = el.covered ? ' (covered)' : '';
|
|
531
|
-
lines.push(` [${el.id}] <${el.tag}>${name}${pos}${attrs}${st}${cov}`);
|
|
532
|
-
}
|
|
533
|
-
lines.push('');
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
if (semantic.length) {
|
|
537
|
-
lines.push('Semantic structure:');
|
|
538
|
-
for (const el of semantic) {
|
|
539
|
-
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
540
|
-
const attrParts = [];
|
|
541
|
-
if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
|
|
542
|
-
if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
|
|
543
|
-
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
544
|
-
lines.push(` [${el.id}] <${el.tag}>${name}${attrs}`);
|
|
545
|
-
}
|
|
546
|
-
lines.push('');
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
return lines.join('\n');
|
|
550
|
-
}
|