@zumer/snapdom-plugins 1.0.1 → 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 +0 -21
- package/index.js +1 -1
- package/package.json +3 -3
- package/prompt-export.js +276 -0
- package/picture-resolver.js +0 -306
package/README.md
CHANGED
|
@@ -117,27 +117,6 @@ snapdom(el, { plugins: [colorTint({ color: 'royalblue', opacity: 0.4 })] });
|
|
|
117
117
|
|
|
118
118
|
---
|
|
119
119
|
|
|
120
|
-
### `picture-resolver`
|
|
121
|
-
|
|
122
|
-
Resolves lazy-loaded `<picture>` placeholders and `data-src` patterns before capture.
|
|
123
|
-
Detects base64/blob stubs, fetches the real image, and restores the DOM after cloning (zero side effects).
|
|
124
|
-
|
|
125
|
-
```js
|
|
126
|
-
import { pictureResolver } from '@zumer/snapdom-plugins/picture-resolver';
|
|
127
|
-
|
|
128
|
-
snapdom(el, { plugins: [pictureResolver({ timeout: 3000 })] });
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
| Option | Type | Default | Description |
|
|
132
|
-
|--------|------|---------|-------------|
|
|
133
|
-
| `timeout` | `number` | `5000` | Max ms per image fetch |
|
|
134
|
-
| `concurrency` | `number` | `4` | Max parallel fetches |
|
|
135
|
-
| `resolveLazySrc` | `boolean` | `true` | Also resolve `data-src` / `data-srcset` patterns |
|
|
136
|
-
| `useProxy` | `string` | `''` | CORS proxy URL for cross-origin images |
|
|
137
|
-
| `silent` | `boolean` | `false` | Suppress console warnings |
|
|
138
|
-
|
|
139
|
-
---
|
|
140
|
-
|
|
141
120
|
### `ascii-export`
|
|
142
121
|
|
|
143
122
|
Adds a `toAscii()` export method that converts captures to ASCII art.
|
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,9 +10,9 @@
|
|
|
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
17
|
"files": [
|
|
18
18
|
"*.js",
|
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
|