@zumer/snapdom-plugins 1.0.0 → 1.0.2
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 +210 -0
- package/index.js +1 -1
- package/package.json +20 -6
- package/prompt-export.js +276 -0
- package/picture-resolver.js +0 -306
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# @zumer/snapdom-plugins
|
|
2
|
+
|
|
3
|
+
Official plugins for [SnapDOM](https://github.com/zumerlab/snapdom) — extend and transform DOM captures with zero core changes.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @zumer/snapdom-plugins
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Import plugins individually (recommended for tree-shaking):
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { snapdom } from '@zumer/snapdom';
|
|
17
|
+
import { filter } from '@zumer/snapdom-plugins/filter';
|
|
18
|
+
import { timestampOverlay } from '@zumer/snapdom-plugins/timestamp-overlay';
|
|
19
|
+
|
|
20
|
+
const result = await snapdom(element, {
|
|
21
|
+
plugins: [filter({ preset: 'grayscale' }), timestampOverlay()]
|
|
22
|
+
});
|
|
23
|
+
const png = await result.toPng();
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or import everything at once:
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import { filter, timestampOverlay, replaceText } from '@zumer/snapdom-plugins';
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
CDN (no install):
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { snapdom } from 'https://esm.sh/@zumer/snapdom';
|
|
36
|
+
import { filter } from 'https://esm.sh/@zumer/snapdom-plugins/filter';
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Plugins
|
|
42
|
+
|
|
43
|
+
### `filter`
|
|
44
|
+
|
|
45
|
+
Applies CSS filter effects to the captured clone.
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { filter } from '@zumer/snapdom-plugins/filter';
|
|
49
|
+
|
|
50
|
+
snapdom(el, { plugins: [filter({ preset: 'grayscale' })] });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Option | Type | Default | Description |
|
|
54
|
+
|--------|------|---------|-------------|
|
|
55
|
+
| `preset` | `string` | — | `'grayscale'` \| `'sepia'` \| `'blur'` \| `'invert'` \| `'vintage'` \| `'dramatic'` |
|
|
56
|
+
| `filter` | `string` | `''` | Raw CSS filter string, e.g. `'blur(2px) contrast(1.2)'` |
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### `timestamp-overlay`
|
|
61
|
+
|
|
62
|
+
Adds a translucent timestamp label to the captured clone.
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
import { timestampOverlay } from '@zumer/snapdom-plugins/timestamp-overlay';
|
|
66
|
+
|
|
67
|
+
snapdom(el, { plugins: [timestampOverlay({ position: 'top-right', format: 'date' })] });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Option | Type | Default | Description |
|
|
71
|
+
|--------|------|---------|-------------|
|
|
72
|
+
| `format` | `string \| function` | `'datetime'` | `'datetime'` \| `'date'` \| `'time'` \| `'iso'` \| custom `(Date) => string` |
|
|
73
|
+
| `position` | `string` | `'bottom-right'` | `'top-left'` \| `'top-right'` \| `'bottom-left'` \| `'bottom-right'` |
|
|
74
|
+
| `background` | `string` | `'rgba(0,0,0,0.6)'` | Label background color |
|
|
75
|
+
| `color` | `string` | `'#fff'` | Label text color |
|
|
76
|
+
| `fontSize` | `number` | `11` | Font size in px |
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
### `replace-text`
|
|
81
|
+
|
|
82
|
+
Find-and-replace text in the captured clone. Supports strings and regex.
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
import { replaceText } from '@zumer/snapdom-plugins/replace-text';
|
|
86
|
+
|
|
87
|
+
snapdom(el, {
|
|
88
|
+
plugins: [replaceText({
|
|
89
|
+
replacements: [
|
|
90
|
+
{ find: 'DRAFT', replace: 'APPROVED' },
|
|
91
|
+
{ find: /\d{4}-\d{2}-\d{2}/, replace: '[REDACTED]' }
|
|
92
|
+
]
|
|
93
|
+
})]
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
| Option | Type | Default | Description |
|
|
98
|
+
|--------|------|---------|-------------|
|
|
99
|
+
| `replacements` | `Array<{ find: string\|RegExp, replace: string }>` | `[]` | List of find/replace pairs |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
### `color-tint`
|
|
104
|
+
|
|
105
|
+
Tints the entire capture to a specified color using a `mix-blend-mode` overlay.
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
import { colorTint } from '@zumer/snapdom-plugins/color-tint';
|
|
109
|
+
|
|
110
|
+
snapdom(el, { plugins: [colorTint({ color: 'royalblue', opacity: 0.4 })] });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Option | Type | Default | Description |
|
|
114
|
+
|--------|------|---------|-------------|
|
|
115
|
+
| `color` | `string` | `'red'` | Any CSS color value |
|
|
116
|
+
| `opacity` | `number` | `1` | Overlay opacity (0–1) |
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
### `ascii-export`
|
|
121
|
+
|
|
122
|
+
Adds a `toAscii()` export method that converts captures to ASCII art.
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
import { asciiExport } from '@zumer/snapdom-plugins/ascii-export';
|
|
126
|
+
|
|
127
|
+
const result = await snapdom(el, { plugins: [asciiExport({ width: 100 })] });
|
|
128
|
+
const art = await result.toAscii();
|
|
129
|
+
console.log(art);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
| Option | Type | Default | Description |
|
|
133
|
+
|--------|------|---------|-------------|
|
|
134
|
+
| `width` | `number` | `80` | Character width of output |
|
|
135
|
+
| `charset` | `string` | `' .:-=+*#%@'` | Characters from lightest to darkest |
|
|
136
|
+
| `invert` | `boolean` | `false` | Invert luminance mapping |
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
### `pdf-image`
|
|
141
|
+
|
|
142
|
+
Exports the capture as a PNG embedded in a downloadable PDF (A4). Adds a `toPdfImage()` method.
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import { pdfImage } from '@zumer/snapdom-plugins/pdf-image';
|
|
146
|
+
|
|
147
|
+
const result = await snapdom(el, { plugins: [pdfImage({ orientation: 'landscape' })] });
|
|
148
|
+
await result.toPdfImage(); // triggers download
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
| Option | Type | Default | Description |
|
|
152
|
+
|--------|------|---------|-------------|
|
|
153
|
+
| `orientation` | `string` | `'portrait'` | `'portrait'` \| `'landscape'` |
|
|
154
|
+
| `quality` | `number` | `0.92` | JPEG quality (0–1) |
|
|
155
|
+
| `filename` | `string` | `'capture.pdf'` | Download filename |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### `html-in-canvas`
|
|
160
|
+
|
|
161
|
+
Uses the experimental [WICG `drawElementImage`](https://github.com/WICG/canvas-place-element) API for direct DOM-to-canvas rendering where supported. Falls back gracefully.
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
import { htmlInCanvas } from '@zumer/snapdom-plugins/html-in-canvas';
|
|
165
|
+
|
|
166
|
+
snapdom(el, { plugins: [htmlInCanvas()] });
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
> This plugin uses an experimental browser API and may not work in all environments.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Plugin registration
|
|
174
|
+
|
|
175
|
+
**Global** (applies to all captures):
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
import { snapdom } from '@zumer/snapdom';
|
|
179
|
+
import { filter } from '@zumer/snapdom-plugins/filter';
|
|
180
|
+
|
|
181
|
+
snapdom.plugins(filter({ preset: 'sepia' }));
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Per-capture** (overrides global for that call):
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
const result = await snapdom(element, {
|
|
188
|
+
plugins: [filter({ preset: 'dramatic' })]
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Per-capture plugins run before global ones. Duplicate plugin names are skipped automatically.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Build your own plugin
|
|
197
|
+
|
|
198
|
+
Use the template to scaffold a new plugin in seconds:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
npx degit zumerlab/snapdom/packages/plugin-template my-plugin
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
See [PLUGIN_SPEC.md](../../PLUGIN_SPEC.md) for the full hook specification and [CONTRIBUTING_PLUGINS.md](../../CONTRIBUTING_PLUGINS.md) to get your plugin listed on the community page.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
package/index.js
CHANGED
|
@@ -11,6 +11,6 @@ export { asciiExport } from './ascii-export.js';
|
|
|
11
11
|
export { replaceText } from './replace-text.js';
|
|
12
12
|
export { filter } from './filter.js';
|
|
13
13
|
export { colorTint } from './color-tint.js';
|
|
14
|
-
export { pictureResolver } from './picture-resolver.js';
|
|
15
14
|
export { pdfImage } from './pdf-image.js';
|
|
15
|
+
export { promptExport } from './prompt-export.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": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Official plugins for SnapDOM",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,18 +10,32 @@
|
|
|
10
10
|
"./replace-text": "./replace-text.js",
|
|
11
11
|
"./filter": "./filter.js",
|
|
12
12
|
"./color-tint": "./color-tint.js",
|
|
13
|
-
"./picture-resolver": "./picture-resolver.js",
|
|
14
13
|
"./html-in-canvas": "./html-in-canvas.js",
|
|
15
|
-
"./pdf-image": "./pdf-image.js"
|
|
14
|
+
"./pdf-image": "./pdf-image.js",
|
|
15
|
+
"./prompt-export": "./prompt-export.js"
|
|
16
16
|
},
|
|
17
|
+
"files": [
|
|
18
|
+
"*.js",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
17
21
|
"keywords": [
|
|
18
22
|
"snapdom",
|
|
19
23
|
"snapdom-plugin",
|
|
20
24
|
"dom-capture",
|
|
21
25
|
"plugins"
|
|
22
26
|
],
|
|
23
|
-
"
|
|
24
|
-
|
|
27
|
+
"author": "Juan Martin Muda",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/zumerlab/snapdom.git",
|
|
32
|
+
"directory": "packages/plugins"
|
|
25
33
|
},
|
|
26
|
-
"
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/zumerlab/snapdom/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://zumerlab.github.io/snapdom/",
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@zumer/snapdom": ">=2.7.0"
|
|
40
|
+
}
|
|
27
41
|
}
|
package/prompt-export.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promptExport – Official SnapDOM Plugin
|
|
3
|
+
* Produces an LLM-ready package: annotated screenshot + structured element map + prompt text.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import { promptExport } from '@zumer/snapdom-plugins/prompt-export';
|
|
7
|
+
* const result = await snapdom(el, { plugins: [promptExport()] });
|
|
8
|
+
* const { image, elements, dimensions, prompt } = await result.toPrompt();
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {boolean} [options.annotate=true] - Overlay numbered badges on interactive elements
|
|
12
|
+
* @param {string} [options.imageFormat='png'] - Output image format ('png'|'jpg'|'webp')
|
|
13
|
+
* @param {number} [options.imageQuality=0.8] - Quality for lossy formats (0..1)
|
|
14
|
+
* @param {number} [options.maxImageWidth=1024] - Max width in px (downscales if larger)
|
|
15
|
+
* @param {string} [options.interactiveSelector] - Custom CSS selector for interactive elements
|
|
16
|
+
* @param {string} [options.semanticSelector] - Custom CSS selector for semantic elements
|
|
17
|
+
* @param {Object} [options.labelStyle={}] - Override styles for annotation badges
|
|
18
|
+
* @returns {Object} SnapDOM plugin
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const DEFAULT_INTERACTIVE =
|
|
22
|
+
'a[href], button, input, select, textarea, ' +
|
|
23
|
+
'[role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], ' +
|
|
24
|
+
'[tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]';
|
|
25
|
+
|
|
26
|
+
const DEFAULT_SEMANTIC =
|
|
27
|
+
'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' +
|
|
28
|
+
'header, footer, label, td, th, figcaption, blockquote, legend';
|
|
29
|
+
|
|
30
|
+
export function promptExport(options = {}) {
|
|
31
|
+
const {
|
|
32
|
+
annotate = true,
|
|
33
|
+
imageFormat = 'png',
|
|
34
|
+
imageQuality = 0.8,
|
|
35
|
+
maxImageWidth = 1024,
|
|
36
|
+
interactiveSelector = DEFAULT_INTERACTIVE,
|
|
37
|
+
semanticSelector = DEFAULT_SEMANTIC,
|
|
38
|
+
labelStyle = {},
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name: 'prompt-export',
|
|
43
|
+
|
|
44
|
+
afterClone(ctx) {
|
|
45
|
+
const meta = extractMetadata(ctx.element, interactiveSelector, semanticSelector);
|
|
46
|
+
ctx.__promptMetadata = meta;
|
|
47
|
+
|
|
48
|
+
if (annotate) {
|
|
49
|
+
addAnnotations(ctx.clone, meta.elements, labelStyle);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
defineExports() {
|
|
54
|
+
return {
|
|
55
|
+
prompt: async (ctx, opts = {}) => {
|
|
56
|
+
const meta = ctx.__promptMetadata;
|
|
57
|
+
if (!meta || !meta.elements.length) {
|
|
58
|
+
return {
|
|
59
|
+
image: ctx.export.url,
|
|
60
|
+
elements: [],
|
|
61
|
+
dimensions: { width: 0, height: 0 },
|
|
62
|
+
prompt: '',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const format = opts.imageFormat || imageFormat;
|
|
67
|
+
const quality = opts.imageQuality || imageQuality;
|
|
68
|
+
const maxWidth = opts.maxImageWidth || maxImageWidth;
|
|
69
|
+
|
|
70
|
+
const img = new Image();
|
|
71
|
+
img.src = ctx.export.url;
|
|
72
|
+
await new Promise((res, rej) => {
|
|
73
|
+
img.onload = res;
|
|
74
|
+
img.onerror = rej;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const ratio =
|
|
78
|
+
img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
|
|
79
|
+
const w = Math.round(img.naturalWidth * ratio);
|
|
80
|
+
const h = Math.round(img.naturalHeight * ratio);
|
|
81
|
+
|
|
82
|
+
const canvas = document.createElement('canvas');
|
|
83
|
+
canvas.width = w;
|
|
84
|
+
canvas.height = h;
|
|
85
|
+
const c2d = canvas.getContext('2d');
|
|
86
|
+
c2d.drawImage(img, 0, 0, w, h);
|
|
87
|
+
|
|
88
|
+
const mime =
|
|
89
|
+
format === 'jpg' || format === 'jpeg'
|
|
90
|
+
? 'image/jpeg'
|
|
91
|
+
: format === 'webp'
|
|
92
|
+
? 'image/webp'
|
|
93
|
+
: 'image/png';
|
|
94
|
+
const dataURL = canvas.toDataURL(mime, quality);
|
|
95
|
+
|
|
96
|
+
const sx = w / (meta.dimensions.width || 1);
|
|
97
|
+
const sy = h / (meta.dimensions.height || 1);
|
|
98
|
+
|
|
99
|
+
const elements = meta.elements.map((el) => ({
|
|
100
|
+
...el,
|
|
101
|
+
bbox: {
|
|
102
|
+
x: Math.round(el.bbox.x * sx),
|
|
103
|
+
y: Math.round(el.bbox.y * sy),
|
|
104
|
+
width: Math.round(el.bbox.width * sx),
|
|
105
|
+
height: Math.round(el.bbox.height * sy),
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
image: dataURL,
|
|
111
|
+
elements,
|
|
112
|
+
dimensions: { width: w, height: h },
|
|
113
|
+
prompt: formatPromptText(elements, { width: w, height: h }),
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ── Metadata extraction ──────────────────────── */
|
|
122
|
+
|
|
123
|
+
function extractMetadata(element, interactiveSelector, semanticSelector) {
|
|
124
|
+
const rootRect = element.getBoundingClientRect();
|
|
125
|
+
const elements = [];
|
|
126
|
+
let id = 0;
|
|
127
|
+
|
|
128
|
+
const tracked = new Set();
|
|
129
|
+
|
|
130
|
+
for (const el of element.querySelectorAll(interactiveSelector)) {
|
|
131
|
+
const entry = buildEntry(el, rootRect, id, 'interactive');
|
|
132
|
+
if (entry) {
|
|
133
|
+
elements.push(entry);
|
|
134
|
+
tracked.add(el);
|
|
135
|
+
id++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const el of element.querySelectorAll(semanticSelector)) {
|
|
140
|
+
if (tracked.has(el)) continue;
|
|
141
|
+
const entry = buildEntry(el, rootRect, id, 'semantic');
|
|
142
|
+
if (entry) {
|
|
143
|
+
elements.push(entry);
|
|
144
|
+
id++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
elements,
|
|
150
|
+
dimensions: { width: rootRect.width, height: rootRect.height },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
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
|
+
function buildEntry(el, rootRect, id, type) {
|
|
160
|
+
const rect = el.getBoundingClientRect();
|
|
161
|
+
const bbox = {
|
|
162
|
+
x: Math.round(rect.left - rootRect.left),
|
|
163
|
+
y: Math.round(rect.top - rootRect.top),
|
|
164
|
+
width: Math.round(rect.width),
|
|
165
|
+
height: Math.round(rect.height),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (bbox.width <= 0 && bbox.height <= 0) return null;
|
|
169
|
+
|
|
170
|
+
const tag = el.tagName.toLowerCase();
|
|
171
|
+
const text = (el.textContent || '').trim().slice(0, 200);
|
|
172
|
+
|
|
173
|
+
const attributes = {};
|
|
174
|
+
for (const attr of COLLECTED_ATTRS) {
|
|
175
|
+
const val = el.getAttribute(attr);
|
|
176
|
+
if (val != null) attributes[attr] = val;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { id, tag, type, text, bbox, attributes };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── Visual annotations ───────────────────────── */
|
|
183
|
+
|
|
184
|
+
function addAnnotations(clone, elements, customStyle) {
|
|
185
|
+
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
186
|
+
if (!interactive.length) return;
|
|
187
|
+
|
|
188
|
+
const overlay = document.createElement('div');
|
|
189
|
+
overlay.setAttribute('data-snap-prompt-overlay', 'true');
|
|
190
|
+
Object.assign(overlay.style, {
|
|
191
|
+
position: 'absolute',
|
|
192
|
+
top: '0',
|
|
193
|
+
left: '0',
|
|
194
|
+
width: '100%',
|
|
195
|
+
height: '100%',
|
|
196
|
+
pointerEvents: 'none',
|
|
197
|
+
zIndex: '2147483647',
|
|
198
|
+
overflow: 'visible',
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
for (const el of interactive) {
|
|
202
|
+
const badge = document.createElement('span');
|
|
203
|
+
badge.textContent = String(el.id);
|
|
204
|
+
badge.setAttribute('data-snap-prompt-label', String(el.id));
|
|
205
|
+
Object.assign(badge.style, {
|
|
206
|
+
position: 'absolute',
|
|
207
|
+
left: `${el.bbox.x}px`,
|
|
208
|
+
top: `${el.bbox.y}px`,
|
|
209
|
+
transform: 'translate(-50%, -50%)',
|
|
210
|
+
minWidth: '18px',
|
|
211
|
+
height: '18px',
|
|
212
|
+
lineHeight: '18px',
|
|
213
|
+
fontSize: '11px',
|
|
214
|
+
fontWeight: '700',
|
|
215
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
216
|
+
color: '#fff',
|
|
217
|
+
backgroundColor: 'rgba(220, 38, 38, 0.92)',
|
|
218
|
+
borderRadius: '9px',
|
|
219
|
+
textAlign: 'center',
|
|
220
|
+
padding: '0 4px',
|
|
221
|
+
boxSizing: 'border-box',
|
|
222
|
+
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
|
223
|
+
...customStyle,
|
|
224
|
+
});
|
|
225
|
+
overlay.appendChild(badge);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
clone.style.position = 'relative';
|
|
229
|
+
clone.appendChild(overlay);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ── Prompt text formatter ────────────────────── */
|
|
233
|
+
|
|
234
|
+
function formatPromptText(elements, dimensions) {
|
|
235
|
+
const lines = [
|
|
236
|
+
`Screenshot of a web page (${dimensions.width}\u00d7${dimensions.height}px).`,
|
|
237
|
+
'',
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
241
|
+
const semantic = elements.filter((e) => e.type === 'semantic');
|
|
242
|
+
|
|
243
|
+
if (interactive.length) {
|
|
244
|
+
lines.push('Interactive elements:');
|
|
245
|
+
for (const el of interactive) {
|
|
246
|
+
const attrParts = Object.entries(el.attributes).map(
|
|
247
|
+
([k, v]) => `${k}="${v}"`
|
|
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})`;
|
|
251
|
+
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
252
|
+
lines.push(` [${el.id}] <${el.tag}>${text} ${pos}${attrs}`);
|
|
253
|
+
}
|
|
254
|
+
lines.push('');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (semantic.length) {
|
|
258
|
+
lines.push('Semantic structure:');
|
|
259
|
+
for (const el of semantic) {
|
|
260
|
+
const text = el.text ? ` "${truncate(el.text, 80)}"` : '';
|
|
261
|
+
const attrParts = [];
|
|
262
|
+
if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
|
|
263
|
+
if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
|
|
264
|
+
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
265
|
+
lines.push(` [${el.id}] <${el.tag}>${text}${attrs}`);
|
|
266
|
+
}
|
|
267
|
+
lines.push('');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return lines.join('\n');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function truncate(str, max) {
|
|
274
|
+
if (str.length <= max) return str;
|
|
275
|
+
return str.slice(0, max - 1) + '\u2026';
|
|
276
|
+
}
|
package/picture-resolver.js
DELETED
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pictureResolver - Official SnapDOM Plugin
|
|
3
|
-
*
|
|
4
|
-
* Resolves lazy-loaded <picture> elements that use base64/low-quality placeholders
|
|
5
|
-
* in <img src> while the real image lives in <source srcset>.
|
|
6
|
-
*
|
|
7
|
-
* Common pattern in news sites (La Nación, Clarín, NYT, Medium, etc.):
|
|
8
|
-
* <picture>
|
|
9
|
-
* <source srcset="https://cdn.example.com/real-image.jpg" media="...">
|
|
10
|
-
* <img src="data:image/jpeg;base64,/9j/4AAQ..." alt="..."> ← tiny placeholder
|
|
11
|
-
* </picture>
|
|
12
|
-
*
|
|
13
|
-
* The browser renders the real image visually, but DOM cloning captures the
|
|
14
|
-
* placeholder because cloneNode copies the HTML attribute, not the rendered source.
|
|
15
|
-
*
|
|
16
|
-
* This plugin:
|
|
17
|
-
* 1. Detects <picture> elements with base64/blob placeholder <img src>
|
|
18
|
-
* 2. Resolves the real URL from <source srcset> or img.currentSrc
|
|
19
|
-
* 3. Fetches the real image using the browser's context (correct Referer/cookies)
|
|
20
|
-
* 4. Replaces img src with the fetched data URL (attribute + property)
|
|
21
|
-
* 5. Removes <source> elements to prevent re-resolution during clone
|
|
22
|
-
* 6. Restores original DOM after cloning (zero side effects)
|
|
23
|
-
*
|
|
24
|
-
* Also handles common lazy-loading patterns:
|
|
25
|
-
* - data-src / data-srcset attributes (lazysizes, vanilla-lazyload, etc.)
|
|
26
|
-
* - loading="lazy" images that haven't entered viewport yet
|
|
27
|
-
*
|
|
28
|
-
* @param {Object} [options]
|
|
29
|
-
* @param {number} [options.timeout=5000] Max ms per image fetch
|
|
30
|
-
* @param {number} [options.concurrency=4] Max parallel fetches
|
|
31
|
-
* @param {boolean} [options.resolveLazySrc=true] Also resolve data-src/data-srcset patterns
|
|
32
|
-
* @param {boolean} [options.silent=false] Suppress console warnings
|
|
33
|
-
* @param {string} [options.useProxy=''] CORS proxy for cross-origin images
|
|
34
|
-
* @returns {import('../../src/core/plugins.js').Plugin}
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* // Per-capture usage
|
|
38
|
-
* const result = await snapdom(element, {
|
|
39
|
-
* plugins: [pictureResolver()]
|
|
40
|
-
* });
|
|
41
|
-
*
|
|
42
|
-
* @example
|
|
43
|
-
* // Global registration
|
|
44
|
-
* snapdom.plugins(pictureResolver({ timeout: 3000 }));
|
|
45
|
-
*
|
|
46
|
-
* @example
|
|
47
|
-
* // With proxy for cross-origin images
|
|
48
|
-
* const result = await snapdom(element, {
|
|
49
|
-
* plugins: [pictureResolver({ useProxy: 'https://corsproxy.io/?' })]
|
|
50
|
-
* });
|
|
51
|
-
*/
|
|
52
|
-
export function pictureResolver(options = {}) {
|
|
53
|
-
const {
|
|
54
|
-
timeout = 5000,
|
|
55
|
-
concurrency = 4,
|
|
56
|
-
resolveLazySrc = true,
|
|
57
|
-
silent = false,
|
|
58
|
-
useProxy = '',
|
|
59
|
-
} = options
|
|
60
|
-
|
|
61
|
-
/** @type {Array<() => void>} */
|
|
62
|
-
let undoStack = []
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Check if a src string is a placeholder (base64, blob, empty, tiny SVG placeholder, etc.)
|
|
66
|
-
* @param {string} src
|
|
67
|
-
* @returns {boolean}
|
|
68
|
-
*/
|
|
69
|
-
function isPlaceholder(src) {
|
|
70
|
-
if (!src) return true
|
|
71
|
-
if (src.startsWith('data:')) return true
|
|
72
|
-
if (src.startsWith('blob:')) return true
|
|
73
|
-
// Common 1x1 transparent pixel patterns
|
|
74
|
-
if (/^data:image\/(gif|png|svg)/.test(src) && src.length < 200) return true
|
|
75
|
-
return false
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Find the best real URL for an image inside a <picture>.
|
|
80
|
-
* Priority: img.currentSrc > <source srcset> matching viewport > first <source srcset>
|
|
81
|
-
* @param {HTMLImageElement} img
|
|
82
|
-
* @param {HTMLPictureElement} picture
|
|
83
|
-
* @returns {string|null}
|
|
84
|
-
*/
|
|
85
|
-
function findRealUrl(img, picture) {
|
|
86
|
-
// 1) currentSrc is the browser's resolved choice (best match for viewport)
|
|
87
|
-
const current = img.currentSrc || ''
|
|
88
|
-
if (current && !isPlaceholder(current)) return current
|
|
89
|
-
|
|
90
|
-
// 2) Walk <source> elements; prefer one whose media query matches
|
|
91
|
-
const sources = picture.querySelectorAll('source[srcset]')
|
|
92
|
-
let fallback = null
|
|
93
|
-
for (const source of sources) {
|
|
94
|
-
const srcset = source.getAttribute('srcset')
|
|
95
|
-
if (!srcset || isPlaceholder(srcset)) continue
|
|
96
|
-
|
|
97
|
-
const media = source.getAttribute('media')
|
|
98
|
-
if (media) {
|
|
99
|
-
try {
|
|
100
|
-
if (window.matchMedia(media).matches) {
|
|
101
|
-
// Best candidate: matches current viewport
|
|
102
|
-
return srcset.split(',')[0].trim().split(/\s+/)[0]
|
|
103
|
-
}
|
|
104
|
-
} catch { /* invalid media query, skip */ }
|
|
105
|
-
}
|
|
106
|
-
// Keep first valid source as fallback
|
|
107
|
-
if (!fallback) fallback = srcset.split(',')[0].trim().split(/\s+/)[0]
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return fallback
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Find lazy-load URLs from data-* attributes.
|
|
115
|
-
* Supports: data-src, data-srcset, data-lazy-src, data-original
|
|
116
|
-
* @param {HTMLImageElement} img
|
|
117
|
-
* @returns {string|null}
|
|
118
|
-
*/
|
|
119
|
-
function findLazySrc(img) {
|
|
120
|
-
const candidates = [
|
|
121
|
-
img.getAttribute('data-src'),
|
|
122
|
-
img.getAttribute('data-lazy-src'),
|
|
123
|
-
img.getAttribute('data-original'),
|
|
124
|
-
img.getAttribute('data-hi-res-src'),
|
|
125
|
-
]
|
|
126
|
-
for (const c of candidates) {
|
|
127
|
-
if (c && !isPlaceholder(c)) return c
|
|
128
|
-
}
|
|
129
|
-
// data-srcset: take first candidate
|
|
130
|
-
const dataSrcset = img.getAttribute('data-srcset') || img.getAttribute('data-lazy-srcset')
|
|
131
|
-
if (dataSrcset) {
|
|
132
|
-
const first = dataSrcset.split(',')[0].trim().split(/\s+/)[0]
|
|
133
|
-
if (first && !isPlaceholder(first)) return first
|
|
134
|
-
}
|
|
135
|
-
return null
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Fetch an image URL using the browser's context (sends correct Referer/cookies).
|
|
140
|
-
* Falls back to proxy if direct fetch fails.
|
|
141
|
-
* @param {string} url
|
|
142
|
-
* @returns {Promise<string|null>} data URL or null on failure
|
|
143
|
-
*/
|
|
144
|
-
async function fetchAsDataUrl(url) {
|
|
145
|
-
const controller = new AbortController()
|
|
146
|
-
const timer = setTimeout(() => controller.abort(), timeout)
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
// Try direct fetch first (browser sends Referer/cookies)
|
|
150
|
-
let resp = await fetch(url, {
|
|
151
|
-
credentials: 'include',
|
|
152
|
-
signal: controller.signal,
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
// If blocked, try proxy
|
|
156
|
-
if (!resp.ok && useProxy) {
|
|
157
|
-
const proxyUrl = useProxy.includes('{url}')
|
|
158
|
-
? useProxy.replace('{url}', encodeURIComponent(url))
|
|
159
|
-
: useProxy.endsWith('?')
|
|
160
|
-
? `${useProxy}${encodeURIComponent(url)}`
|
|
161
|
-
: `${useProxy}${useProxy.includes('?') ? '&' : '?'}url=${encodeURIComponent(url)}`
|
|
162
|
-
resp = await fetch(proxyUrl, { signal: controller.signal })
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (!resp.ok) return null
|
|
166
|
-
|
|
167
|
-
const blob = await resp.blob()
|
|
168
|
-
return await new Promise((resolve, reject) => {
|
|
169
|
-
const fr = new FileReader()
|
|
170
|
-
fr.onload = () => resolve(/** @type {string} */ (fr.result))
|
|
171
|
-
fr.onerror = reject
|
|
172
|
-
fr.readAsDataURL(blob)
|
|
173
|
-
})
|
|
174
|
-
} catch {
|
|
175
|
-
return null
|
|
176
|
-
} finally {
|
|
177
|
-
clearTimeout(timer)
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Process a batch of tasks with limited concurrency.
|
|
183
|
-
* @param {Array<() => Promise<void>>} tasks
|
|
184
|
-
*/
|
|
185
|
-
async function runBatched(tasks) {
|
|
186
|
-
for (let i = 0; i < tasks.length; i += concurrency) {
|
|
187
|
-
const batch = tasks.slice(i, i + concurrency)
|
|
188
|
-
await Promise.allSettled(batch.map(fn => fn()))
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return {
|
|
193
|
-
name: 'picture-resolver',
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Before cloning: resolve placeholders to real images in the live DOM.
|
|
197
|
-
* Stores undo functions to restore original state after clone.
|
|
198
|
-
*/
|
|
199
|
-
async beforeClone(ctx) {
|
|
200
|
-
const root = ctx.element
|
|
201
|
-
if (!root || !(root instanceof Element)) return
|
|
202
|
-
|
|
203
|
-
undoStack = []
|
|
204
|
-
const tasks = []
|
|
205
|
-
|
|
206
|
-
// ── 1) <picture> with placeholder <img src> ──
|
|
207
|
-
const pictures = root.querySelectorAll('picture')
|
|
208
|
-
for (const picture of pictures) {
|
|
209
|
-
const img = picture.querySelector('img')
|
|
210
|
-
if (!img) continue
|
|
211
|
-
|
|
212
|
-
const originalSrc = img.getAttribute('src') || ''
|
|
213
|
-
if (!isPlaceholder(originalSrc)) continue
|
|
214
|
-
|
|
215
|
-
const realUrl = findRealUrl(img, picture)
|
|
216
|
-
if (!realUrl) continue
|
|
217
|
-
|
|
218
|
-
tasks.push(async () => {
|
|
219
|
-
const dataUrl = await fetchAsDataUrl(realUrl)
|
|
220
|
-
if (!dataUrl) {
|
|
221
|
-
if (!silent) console.warn(`[snapdom:picture-resolver] Failed to fetch: ${realUrl.slice(0, 60)}`)
|
|
222
|
-
return
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Save original state for undo
|
|
226
|
-
const origAttr = img.getAttribute('src')
|
|
227
|
-
const origSrcset = img.getAttribute('srcset')
|
|
228
|
-
const origSizes = img.getAttribute('sizes')
|
|
229
|
-
const removedSources = []
|
|
230
|
-
|
|
231
|
-
// Swap to real image
|
|
232
|
-
img.src = dataUrl
|
|
233
|
-
img.setAttribute('src', dataUrl)
|
|
234
|
-
img.removeAttribute('srcset')
|
|
235
|
-
img.removeAttribute('sizes')
|
|
236
|
-
|
|
237
|
-
// Remove <source> elements to prevent clone re-resolution
|
|
238
|
-
const sources = picture.querySelectorAll('source')
|
|
239
|
-
for (const s of sources) {
|
|
240
|
-
removedSources.push({ el: s, parent: s.parentElement, next: s.nextSibling })
|
|
241
|
-
s.remove()
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Register undo
|
|
245
|
-
undoStack.push(() => {
|
|
246
|
-
if (origAttr !== null) img.setAttribute('src', origAttr)
|
|
247
|
-
else img.removeAttribute('src')
|
|
248
|
-
if (origSrcset !== null) img.setAttribute('srcset', origSrcset)
|
|
249
|
-
if (origSizes !== null) img.setAttribute('sizes', origSizes)
|
|
250
|
-
for (const { el, parent, next } of removedSources) {
|
|
251
|
-
if (parent) parent.insertBefore(el, next)
|
|
252
|
-
}
|
|
253
|
-
})
|
|
254
|
-
})
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ── 2) Lazy-loaded <img> with data-src / data-srcset ──
|
|
258
|
-
if (resolveLazySrc) {
|
|
259
|
-
const imgs = root.querySelectorAll('img')
|
|
260
|
-
for (const img of imgs) {
|
|
261
|
-
// Skip images already handled by <picture> pass
|
|
262
|
-
if (img.closest('picture') && isPlaceholder(img.getAttribute('src') || '')) continue
|
|
263
|
-
|
|
264
|
-
const currentSrc = img.getAttribute('src') || ''
|
|
265
|
-
const lazySrc = findLazySrc(img)
|
|
266
|
-
|
|
267
|
-
// Case A: has data-src but current src is placeholder
|
|
268
|
-
if (lazySrc && isPlaceholder(currentSrc)) {
|
|
269
|
-
tasks.push(async () => {
|
|
270
|
-
const dataUrl = await fetchAsDataUrl(lazySrc)
|
|
271
|
-
if (!dataUrl) return
|
|
272
|
-
|
|
273
|
-
const origSrc = img.getAttribute('src')
|
|
274
|
-
img.src = dataUrl
|
|
275
|
-
img.setAttribute('src', dataUrl)
|
|
276
|
-
img.removeAttribute('srcset')
|
|
277
|
-
img.removeAttribute('sizes')
|
|
278
|
-
|
|
279
|
-
undoStack.push(() => {
|
|
280
|
-
if (origSrc !== null) img.setAttribute('src', origSrc)
|
|
281
|
-
else img.removeAttribute('src')
|
|
282
|
-
})
|
|
283
|
-
})
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Run all fetches with concurrency limit
|
|
289
|
-
if (tasks.length > 0) {
|
|
290
|
-
await runBatched(tasks)
|
|
291
|
-
}
|
|
292
|
-
},
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* After cloning: restore original DOM (undo all mutations).
|
|
296
|
-
*/
|
|
297
|
-
async afterClone(ctx) {
|
|
298
|
-
for (const undo of undoStack) {
|
|
299
|
-
try { undo() } catch { /* non-blocking */ }
|
|
300
|
-
}
|
|
301
|
-
undoStack = []
|
|
302
|
-
},
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
export default pictureResolver
|