@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 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.1",
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",
@@ -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
+ }
@@ -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