@zumer/snapdom-plugins 1.0.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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * asciiExport - Official SnapDOM Plugin
3
+ * Adds a toAscii() export method that converts captures to ASCII art.
4
+ *
5
+ * @param {Object} [options]
6
+ * @param {number} [options.width=80] - Character width of output
7
+ * @param {string} [options.charset=' .:-=+*#%@'] - Characters from lightest to darkest
8
+ * @param {boolean} [options.invert=false] - Invert luminance mapping
9
+ * @returns {Object} SnapDOM plugin
10
+ */
11
+ export function asciiExport(options = {}) {
12
+ const {
13
+ width: charWidth = 80,
14
+ charset = ' .:-=+*#%@',
15
+ invert = false,
16
+ } = options;
17
+
18
+ return {
19
+ name: 'ascii-export',
20
+
21
+ defineExports() {
22
+ return {
23
+ ascii: async (ctx, opts = {}) => {
24
+ const cols = opts.width || charWidth;
25
+ const chars = opts.charset || charset;
26
+ const inv = opts.invert ?? invert;
27
+
28
+ const img = new Image();
29
+ img.src = ctx.export.url;
30
+ await new Promise((res, rej) => {
31
+ img.onload = res;
32
+ img.onerror = rej;
33
+ });
34
+
35
+ const aspect = img.height / img.width;
36
+ const rows = Math.round(cols * aspect * 0.5);
37
+ const canvas = document.createElement('canvas');
38
+ canvas.width = cols;
39
+ canvas.height = rows;
40
+ const c = canvas.getContext('2d');
41
+ c.drawImage(img, 0, 0, cols, rows);
42
+ const data = c.getImageData(0, 0, cols, rows).data;
43
+
44
+ let ascii = '';
45
+ for (let y = 0; y < rows; y++) {
46
+ for (let x = 0; x < cols; x++) {
47
+ const i = (y * cols + x) * 4;
48
+ let lum = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) / 255;
49
+ if (inv) lum = 1 - lum;
50
+ const ci = Math.min(Math.floor(lum * chars.length), chars.length - 1);
51
+ ascii += chars[ci];
52
+ }
53
+ ascii += '\n';
54
+ }
55
+ return ascii;
56
+ }
57
+ };
58
+ }
59
+ };
60
+ }
package/color-tint.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * colorTint - Official SnapDOM Plugin
3
+ * Tints the entire capture to a specified color using an overlay
4
+ * with mix-blend-mode, following the same pattern as the overlay plugin.
5
+ *
6
+ * @param {Object} [options]
7
+ * @param {string} [options.color='red'] - CSS color value
8
+ * @param {number} [options.opacity=1] - Overlay opacity (0-1)
9
+ * @returns {Object} SnapDOM plugin
10
+ */
11
+ export function colorTint(options = {}) {
12
+ const {
13
+ color = 'red',
14
+ opacity = 1,
15
+ } = options;
16
+
17
+ return {
18
+ name: 'color-tint',
19
+
20
+ async afterClone(context) {
21
+ const root = context.clone;
22
+ if (!root || !(root instanceof HTMLElement)) return;
23
+ root.style.position = 'relative';
24
+ root.style.overflow = 'hidden';
25
+ const overlay = document.createElement('div');
26
+ overlay.style.position = 'absolute';
27
+ overlay.style.inset = '0';
28
+ overlay.style.backgroundColor = color;
29
+ overlay.style.opacity = String(opacity);
30
+ overlay.style.mixBlendMode = 'color';
31
+ overlay.style.pointerEvents = 'none';
32
+ overlay.style.zIndex = '999999';
33
+ root.appendChild(overlay);
34
+ }
35
+ };
36
+ }
package/filter.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * filter - Official SnapDOM Plugin
3
+ * Applies CSS filter effects to the captured clone.
4
+ *
5
+ * @param {Object} [options]
6
+ * @param {string} [options.filter=''] - CSS filter string, e.g. 'grayscale(1) blur(2px)'
7
+ * @param {string} [options.preset] - 'grayscale' | 'sepia' | 'blur' | 'invert' | 'vintage' | 'dramatic'
8
+ * @returns {Object} SnapDOM plugin
9
+ */
10
+ export function filter(options = {}) {
11
+ const presets = {
12
+ grayscale: 'grayscale(1)',
13
+ sepia: 'sepia(1)',
14
+ blur: 'blur(2px)',
15
+ invert: 'invert(1)',
16
+ vintage: 'sepia(0.4) contrast(1.1) brightness(0.9) saturate(0.8)',
17
+ dramatic: 'contrast(1.4) brightness(0.85) saturate(1.3)',
18
+ };
19
+
20
+ const {
21
+ filter: filterValue = '',
22
+ preset,
23
+ } = options;
24
+
25
+ const cssFilter = preset ? (presets[preset] || '') : filterValue;
26
+
27
+ return {
28
+ name: 'filter',
29
+
30
+ afterClone(ctx) {
31
+ if (!cssFilter) return;
32
+ ctx.clone.style.filter = cssFilter;
33
+ }
34
+ };
35
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Experimental plugin for WICG html-in-canvas (issue #172).
3
+ * Uses drawElementImage() to render the snapdom clone directly into canvas,
4
+ * bypassing SVG/foreignObject.
5
+ *
6
+ * Requires: Chrome with chrome://flags/#canvas-draw-element enabled.
7
+ * @see https://github.com/WICG/html-in-canvas
8
+ */
9
+
10
+ const PLUGIN_NAME = 'html-in-canvas'
11
+
12
+ function isDrawElementImageAvailable() {
13
+ try {
14
+ const c = document.createElement('canvas')
15
+ const ctx = c.getContext('2d')
16
+ return ctx && typeof ctx.drawElementImage === 'function'
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+
22
+ /**
23
+ * @returns {import('../../src/core/plugins.js').Plugin}
24
+ */
25
+ export function htmlInCanvasPlugin() {
26
+ const available = isDrawElementImageAvailable()
27
+ if (!available) {
28
+ console.warn('[snapdom] html-in-canvas plugin: drawElementImage not available. Enable chrome://flags/#canvas-draw-element')
29
+ }
30
+
31
+ return {
32
+ name: PLUGIN_NAME,
33
+
34
+ beforeRender(state) {
35
+ if (!available) return
36
+ if (!state.clone || !state.element) return
37
+ state.options.__htmlInCanvas = {
38
+ clone: state.clone,
39
+ baseCSS: state.baseCSS || '',
40
+ fontsCSS: state.fontsCSS || '',
41
+ classCSS: state.classCSS || '',
42
+ element: state.element,
43
+ w0: null,
44
+ h0: null
45
+ }
46
+ },
47
+
48
+ afterRender(state) {
49
+ if (!available) return
50
+ const meta = state.options?.meta
51
+ const stored = state.options?.__htmlInCanvas
52
+ if (meta && stored) {
53
+ stored.w0 = meta.w0
54
+ stored.h0 = meta.h0
55
+ }
56
+ },
57
+
58
+ async defineExports(ctx) {
59
+ if (!available) return {}
60
+ const stored = ctx.__htmlInCanvas
61
+ if (!stored) return {}
62
+
63
+ return {
64
+ htmlInCanvas: async (opts = {}) => {
65
+ const { clone, baseCSS, fontsCSS, classCSS, element } = stored
66
+ const w0 = stored.w0 ?? element?.offsetWidth
67
+ const h0 = stored.h0 ?? element?.offsetHeight
68
+ const scale = opts.scale ?? ctx.scale ?? 1
69
+ const dpr = opts.dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1)
70
+
71
+ const rect = element?.getBoundingClientRect?.()
72
+ const width = w0 ?? rect?.width ?? 100
73
+ const height = h0 ?? rect?.height ?? 100
74
+ const outW = Math.round(width * scale * dpr)
75
+ const outH = Math.round(height * scale * dpr)
76
+
77
+ const canvas = document.createElement('canvas')
78
+ canvas.width = outW
79
+ canvas.height = outH
80
+ canvas.setAttribute('layoutsubtree', '')
81
+
82
+ const wrapper = document.createElement('div')
83
+ wrapper.style.cssText = `width:${width}px;height:${height}px;overflow:visible;box-sizing:border-box;`
84
+ wrapper.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
85
+
86
+ const styleTag = document.createElement('style')
87
+ styleTag.textContent = `${baseCSS}${fontsCSS}svg{overflow:visible;}${classCSS}`
88
+ wrapper.appendChild(styleTag)
89
+
90
+ const cloneCopy = clone.cloneNode(true)
91
+ wrapper.appendChild(cloneCopy)
92
+
93
+ canvas.appendChild(wrapper)
94
+
95
+ const container = document.createElement('div')
96
+ container.id = 'snapdom-html-in-canvas-temp'
97
+ container.style.cssText = 'position:fixed;left:-9999px;top:0;visibility:hidden;'
98
+ container.appendChild(canvas)
99
+ document.body.appendChild(container)
100
+
101
+ try {
102
+ await new Promise(r => requestAnimationFrame(r))
103
+ const ctx2d = canvas.getContext('2d')
104
+ if (!ctx2d || typeof ctx2d.drawElementImage !== 'function') {
105
+ throw new Error('drawElementImage not available')
106
+ }
107
+ ctx2d.save()
108
+ ctx2d.scale(dpr * scale, dpr * scale)
109
+ ctx2d.drawElementImage(wrapper, 0, 0, width, height)
110
+ ctx2d.restore()
111
+ return canvas
112
+ } finally {
113
+ try {
114
+ document.body.removeChild(container)
115
+ } catch {}
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ export default htmlInCanvasPlugin
package/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @zumer/snapdom-plugins
3
+ * Official plugins for SnapDOM.
4
+ *
5
+ * Individual: import { filter } from '@zumer/snapdom-plugins/filter';
6
+ * All: import { filter, asciiExport } from '@zumer/snapdom-plugins';
7
+ */
8
+
9
+ export { timestampOverlay } from './timestamp-overlay.js';
10
+ export { asciiExport } from './ascii-export.js';
11
+ export { replaceText } from './replace-text.js';
12
+ export { filter } from './filter.js';
13
+ export { colorTint } from './color-tint.js';
14
+ export { pictureResolver } from './picture-resolver.js';
15
+ export { pdfImage } from './pdf-image.js';
16
+ // export { htmlInCanvas } from './html-in-canvas.js';
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@zumer/snapdom-plugins",
3
+ "version": "1.0.0",
4
+ "description": "Official plugins for SnapDOM",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./timestamp-overlay": "./timestamp-overlay.js",
9
+ "./ascii-export": "./ascii-export.js",
10
+ "./replace-text": "./replace-text.js",
11
+ "./filter": "./filter.js",
12
+ "./color-tint": "./color-tint.js",
13
+ "./picture-resolver": "./picture-resolver.js",
14
+ "./html-in-canvas": "./html-in-canvas.js",
15
+ "./pdf-image": "./pdf-image.js"
16
+ },
17
+ "keywords": [
18
+ "snapdom",
19
+ "snapdom-plugin",
20
+ "dom-capture",
21
+ "plugins"
22
+ ],
23
+ "peerDependencies": {
24
+ "@zumer/snapdom": ">=0.9.0"
25
+ },
26
+ "license": "MIT"
27
+ }
package/pdf-image.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * pdfImage - Official SnapDOM Plugin
3
+ * Exports the capture as a PNG embedded in a downloadable PDF.
4
+ *
5
+ * @param {Object} [options]
6
+ * @param {string} [options.orientation='portrait'] - 'portrait' | 'landscape'
7
+ * @param {number} [options.quality=0.92] - JPEG quality (0-1)
8
+ * @param {string} [options.filename='capture.pdf'] - Download filename
9
+ * @returns {Object} SnapDOM plugin
10
+ */
11
+ export function pdfImage(options = {}) {
12
+ const {
13
+ orientation = 'portrait',
14
+ quality = 0.92,
15
+ filename = 'capture.pdf',
16
+ } = options;
17
+
18
+ return {
19
+ name: 'pdf-image',
20
+
21
+ defineExports() {
22
+ return {
23
+ pdfImage: async (ctx) => {
24
+ const img = new Image();
25
+ img.src = ctx.export.url;
26
+ await new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
27
+
28
+ const isLandscape = orientation === 'landscape';
29
+ const pageW = isLandscape ? 841.89 : 595.28; // A4 in points
30
+ const pageH = isLandscape ? 595.28 : 841.89;
31
+ const margin = 40;
32
+ const maxW = pageW - margin * 2;
33
+ const maxH = pageH - margin * 2;
34
+ const scale = Math.min(maxW / img.width, maxH / img.height, 1);
35
+ const w = img.width * scale;
36
+ const h = img.height * scale;
37
+ const x = (pageW - w) / 2;
38
+ const y = (pageH - h) / 2;
39
+
40
+ // Get image as JPEG data
41
+ const canvas = document.createElement('canvas');
42
+ canvas.width = img.width;
43
+ canvas.height = img.height;
44
+ canvas.getContext('2d').drawImage(img, 0, 0);
45
+ const jpegDataUrl = canvas.toDataURL('image/jpeg', quality);
46
+ const jpegBase64 = jpegDataUrl.split(',')[1];
47
+ const jpegBytes = atob(jpegBase64);
48
+ const jpegLen = jpegBytes.length;
49
+
50
+ const enc = new TextEncoder();
51
+ const realOffsets = [];
52
+
53
+ // Obj 1-3
54
+ let textPart = '%PDF-1.4\n';
55
+ realOffsets.push(textPart.length);
56
+ textPart += '1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n';
57
+ realOffsets.push(textPart.length);
58
+ textPart += '2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n';
59
+ realOffsets.push(textPart.length);
60
+ textPart += `3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents 5 0 R /Resources << /XObject << /Img 4 0 R >> >> >>\nendobj\n`;
61
+
62
+ // Obj 4 — image with binary JPEG stream
63
+ realOffsets.push(textPart.length);
64
+ const imgHeader = `4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${img.width} /Height ${img.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpegLen} >>\nstream\n`;
65
+
66
+ const imgHeaderBytes = enc.encode(imgHeader);
67
+ const preImgBytes = enc.encode(textPart);
68
+
69
+ const jpegBuf = new Uint8Array(jpegLen);
70
+ for (let i = 0; i < jpegLen; i++) jpegBuf[i] = jpegBytes.charCodeAt(i);
71
+
72
+ const afterImg = '\nendstream\nendobj\n';
73
+ const afterImgBytes = enc.encode(afterImg);
74
+
75
+ // Obj 5 — content stream
76
+ const contentStr = `q ${w.toFixed(2)} 0 0 ${h.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)} cm /Img Do Q`;
77
+ const obj5 = `5 0 obj\n<< /Length ${contentStr.length} >>\nstream\n${contentStr}\nendstream\nendobj\n`;
78
+ const obj5Bytes = enc.encode(obj5);
79
+
80
+ realOffsets.push(preImgBytes.length + imgHeaderBytes.length + jpegLen + afterImgBytes.length);
81
+
82
+ // xref table
83
+ const xrefOffset = preImgBytes.length + imgHeaderBytes.length + jpegLen + afterImgBytes.length + obj5Bytes.length;
84
+ let xref = 'xref\n0 6\n0000000000 65535 f \n';
85
+ for (let i = 0; i < 5; i++) {
86
+ xref += `${String(realOffsets[i]).padStart(10, '0')} 00000 n \n`;
87
+ }
88
+ xref += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
89
+ const xrefBytes = enc.encode(xref);
90
+
91
+ // Combine all parts
92
+ const totalLen = preImgBytes.length + imgHeaderBytes.length + jpegLen + afterImgBytes.length + obj5Bytes.length + xrefBytes.length;
93
+ const pdfBuf = new Uint8Array(totalLen);
94
+ let pos = 0;
95
+ pdfBuf.set(preImgBytes, pos); pos += preImgBytes.length;
96
+ pdfBuf.set(imgHeaderBytes, pos); pos += imgHeaderBytes.length;
97
+ pdfBuf.set(jpegBuf, pos); pos += jpegLen;
98
+ pdfBuf.set(afterImgBytes, pos); pos += afterImgBytes.length;
99
+ pdfBuf.set(obj5Bytes, pos); pos += obj5Bytes.length;
100
+ pdfBuf.set(xrefBytes, pos);
101
+
102
+ const blob = new Blob([pdfBuf], { type: 'application/pdf' });
103
+ const url = URL.createObjectURL(blob);
104
+ const a = document.createElement('a');
105
+ a.href = url;
106
+ a.download = filename;
107
+ a.click();
108
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
109
+ return url;
110
+ }
111
+ };
112
+ }
113
+ };
114
+ }
@@ -0,0 +1,306 @@
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
@@ -0,0 +1,43 @@
1
+ /**
2
+ * replaceText - Official SnapDOM Plugin
3
+ * Find-and-replace text in the captured clone.
4
+ *
5
+ * @param {Object} [options]
6
+ * @param {Array<{find: string|RegExp, replace: string}>} [options.replacements=[]]
7
+ * @returns {Object} SnapDOM plugin
8
+ */
9
+ export function replaceText(options = {}) {
10
+ const {
11
+ replacements = [],
12
+ } = options;
13
+
14
+ function walkTextNodes(node, fn) {
15
+ if (node.nodeType === Node.TEXT_NODE) {
16
+ fn(node);
17
+ } else {
18
+ for (const child of node.childNodes) {
19
+ walkTextNodes(child, fn);
20
+ }
21
+ }
22
+ }
23
+
24
+ return {
25
+ name: 'replace-text',
26
+
27
+ afterClone(ctx) {
28
+ if (!replacements.length) return;
29
+
30
+ walkTextNodes(ctx.clone, (textNode) => {
31
+ let text = textNode.textContent;
32
+ for (const { find, replace } of replacements) {
33
+ if (find instanceof RegExp) {
34
+ text = text.replace(find, replace);
35
+ } else {
36
+ text = text.split(find).join(replace);
37
+ }
38
+ }
39
+ textNode.textContent = text;
40
+ });
41
+ }
42
+ };
43
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * timestampOverlay - Official SnapDOM Plugin
3
+ * Adds a translucent timestamp label to the captured clone.
4
+ *
5
+ * @param {Object} [options]
6
+ * @param {string} [options.format='datetime'] - 'datetime' | 'date' | 'time' | 'iso' | custom function
7
+ * @param {string} [options.position='bottom-right'] - 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
8
+ * @param {string} [options.background='rgba(0,0,0,0.6)']
9
+ * @param {string} [options.color='#fff']
10
+ * @param {number} [options.fontSize=11]
11
+ * @returns {Object} SnapDOM plugin
12
+ */
13
+ export function timestampOverlay(options = {}) {
14
+ const {
15
+ format = 'datetime',
16
+ position = 'bottom-right',
17
+ background = 'rgba(0,0,0,0.6)',
18
+ color = '#fff',
19
+ fontSize = 11,
20
+ } = options;
21
+
22
+ function getTimestamp() {
23
+ const now = new Date();
24
+ if (typeof format === 'function') return format(now);
25
+ switch (format) {
26
+ case 'date': return now.toLocaleDateString();
27
+ case 'time': return now.toLocaleTimeString();
28
+ case 'iso': return now.toISOString();
29
+ case 'datetime':
30
+ default: return now.toLocaleString();
31
+ }
32
+ }
33
+
34
+ const posMap = {
35
+ 'top-left': 'top:6px;left:6px',
36
+ 'top-right': 'top:6px;right:6px',
37
+ 'bottom-left': 'bottom:6px;left:6px',
38
+ 'bottom-right': 'bottom:6px;right:6px',
39
+ };
40
+
41
+ return {
42
+ name: 'timestamp-overlay',
43
+
44
+ afterClone(ctx) {
45
+ const label = document.createElement('div');
46
+ label.textContent = getTimestamp();
47
+ label.style.cssText = `
48
+ position:absolute;
49
+ ${posMap[position] || posMap['bottom-right']};
50
+ background:${background};
51
+ color:${color};
52
+ font-size:${fontSize}px;
53
+ padding:2px 6px;
54
+ border-radius:3px;
55
+ font-family:monospace;
56
+ pointer-events:none;
57
+ z-index:999999;
58
+ line-height:1.4;
59
+ `;
60
+ ctx.clone.style.position = 'relative';
61
+ ctx.clone.appendChild(label);
62
+ }
63
+ };
64
+ }