@zumer/snapdom-plugins
Advanced tools
+276
| /** | ||
| * promptExport – Official SnapDOM Plugin | ||
| * Produces an LLM-ready package: annotated screenshot + structured element map + prompt text. | ||
| * | ||
| * Usage: | ||
| * import { promptExport } from '@zumer/snapdom-plugins/prompt-export'; | ||
| * const result = await snapdom(el, { plugins: [promptExport()] }); | ||
| * const { image, elements, dimensions, prompt } = await result.toPrompt(); | ||
| * | ||
| * @param {Object} [options] | ||
| * @param {boolean} [options.annotate=true] - Overlay numbered badges on interactive elements | ||
| * @param {string} [options.imageFormat='png'] - Output image format ('png'|'jpg'|'webp') | ||
| * @param {number} [options.imageQuality=0.8] - Quality for lossy formats (0..1) | ||
| * @param {number} [options.maxImageWidth=1024] - Max width in px (downscales if larger) | ||
| * @param {string} [options.interactiveSelector] - Custom CSS selector for interactive elements | ||
| * @param {string} [options.semanticSelector] - Custom CSS selector for semantic elements | ||
| * @param {Object} [options.labelStyle={}] - Override styles for annotation badges | ||
| * @returns {Object} SnapDOM plugin | ||
| */ | ||
| const DEFAULT_INTERACTIVE = | ||
| 'a[href], button, input, select, textarea, ' + | ||
| '[role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], ' + | ||
| '[tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]'; | ||
| const DEFAULT_SEMANTIC = | ||
| 'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' + | ||
| 'header, footer, label, td, th, figcaption, blockquote, legend'; | ||
| export function promptExport(options = {}) { | ||
| const { | ||
| annotate = true, | ||
| imageFormat = 'png', | ||
| imageQuality = 0.8, | ||
| maxImageWidth = 1024, | ||
| interactiveSelector = DEFAULT_INTERACTIVE, | ||
| semanticSelector = DEFAULT_SEMANTIC, | ||
| labelStyle = {}, | ||
| } = options; | ||
| return { | ||
| name: 'prompt-export', | ||
| afterClone(ctx) { | ||
| const meta = extractMetadata(ctx.element, interactiveSelector, semanticSelector); | ||
| ctx.__promptMetadata = meta; | ||
| if (annotate) { | ||
| addAnnotations(ctx.clone, meta.elements, labelStyle); | ||
| } | ||
| }, | ||
| defineExports() { | ||
| return { | ||
| prompt: async (ctx, opts = {}) => { | ||
| const meta = ctx.__promptMetadata; | ||
| if (!meta || !meta.elements.length) { | ||
| return { | ||
| image: ctx.export.url, | ||
| elements: [], | ||
| dimensions: { width: 0, height: 0 }, | ||
| prompt: '', | ||
| }; | ||
| } | ||
| const format = opts.imageFormat || imageFormat; | ||
| const quality = opts.imageQuality || imageQuality; | ||
| const maxWidth = opts.maxImageWidth || maxImageWidth; | ||
| const img = new Image(); | ||
| img.src = ctx.export.url; | ||
| await new Promise((res, rej) => { | ||
| img.onload = res; | ||
| img.onerror = rej; | ||
| }); | ||
| const ratio = | ||
| img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1; | ||
| const w = Math.round(img.naturalWidth * ratio); | ||
| const h = Math.round(img.naturalHeight * ratio); | ||
| const canvas = document.createElement('canvas'); | ||
| canvas.width = w; | ||
| canvas.height = h; | ||
| const c2d = canvas.getContext('2d'); | ||
| c2d.drawImage(img, 0, 0, w, h); | ||
| const mime = | ||
| format === 'jpg' || format === 'jpeg' | ||
| ? 'image/jpeg' | ||
| : format === 'webp' | ||
| ? 'image/webp' | ||
| : 'image/png'; | ||
| const dataURL = canvas.toDataURL(mime, quality); | ||
| const sx = w / (meta.dimensions.width || 1); | ||
| const sy = h / (meta.dimensions.height || 1); | ||
| const elements = meta.elements.map((el) => ({ | ||
| ...el, | ||
| bbox: { | ||
| x: Math.round(el.bbox.x * sx), | ||
| y: Math.round(el.bbox.y * sy), | ||
| width: Math.round(el.bbox.width * sx), | ||
| height: Math.round(el.bbox.height * sy), | ||
| }, | ||
| })); | ||
| return { | ||
| image: dataURL, | ||
| elements, | ||
| dimensions: { width: w, height: h }, | ||
| prompt: formatPromptText(elements, { width: w, height: h }), | ||
| }; | ||
| }, | ||
| }; | ||
| }, | ||
| }; | ||
| } | ||
| /* ── Metadata extraction ──────────────────────── */ | ||
| function extractMetadata(element, interactiveSelector, semanticSelector) { | ||
| const rootRect = element.getBoundingClientRect(); | ||
| const elements = []; | ||
| let id = 0; | ||
| const tracked = new Set(); | ||
| for (const el of element.querySelectorAll(interactiveSelector)) { | ||
| const entry = buildEntry(el, rootRect, id, 'interactive'); | ||
| if (entry) { | ||
| elements.push(entry); | ||
| tracked.add(el); | ||
| id++; | ||
| } | ||
| } | ||
| for (const el of element.querySelectorAll(semanticSelector)) { | ||
| if (tracked.has(el)) continue; | ||
| const entry = buildEntry(el, rootRect, id, 'semantic'); | ||
| if (entry) { | ||
| elements.push(entry); | ||
| id++; | ||
| } | ||
| } | ||
| return { | ||
| elements, | ||
| dimensions: { width: rootRect.width, height: rootRect.height }, | ||
| }; | ||
| } | ||
| const COLLECTED_ATTRS = [ | ||
| 'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled', | ||
| 'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled', | ||
| ]; | ||
| function buildEntry(el, rootRect, id, type) { | ||
| const rect = el.getBoundingClientRect(); | ||
| const bbox = { | ||
| x: Math.round(rect.left - rootRect.left), | ||
| y: Math.round(rect.top - rootRect.top), | ||
| width: Math.round(rect.width), | ||
| height: Math.round(rect.height), | ||
| }; | ||
| if (bbox.width <= 0 && bbox.height <= 0) return null; | ||
| const tag = el.tagName.toLowerCase(); | ||
| const text = (el.textContent || '').trim().slice(0, 200); | ||
| const attributes = {}; | ||
| for (const attr of COLLECTED_ATTRS) { | ||
| const val = el.getAttribute(attr); | ||
| if (val != null) attributes[attr] = val; | ||
| } | ||
| return { id, tag, type, text, bbox, attributes }; | ||
| } | ||
| /* ── Visual annotations ───────────────────────── */ | ||
| function addAnnotations(clone, elements, customStyle) { | ||
| const interactive = elements.filter((e) => e.type === 'interactive'); | ||
| if (!interactive.length) return; | ||
| const overlay = document.createElement('div'); | ||
| overlay.setAttribute('data-snap-prompt-overlay', 'true'); | ||
| Object.assign(overlay.style, { | ||
| position: 'absolute', | ||
| top: '0', | ||
| left: '0', | ||
| width: '100%', | ||
| height: '100%', | ||
| pointerEvents: 'none', | ||
| zIndex: '2147483647', | ||
| overflow: 'visible', | ||
| }); | ||
| for (const el of interactive) { | ||
| const badge = document.createElement('span'); | ||
| badge.textContent = String(el.id); | ||
| badge.setAttribute('data-snap-prompt-label', String(el.id)); | ||
| Object.assign(badge.style, { | ||
| position: 'absolute', | ||
| left: `${el.bbox.x}px`, | ||
| top: `${el.bbox.y}px`, | ||
| transform: 'translate(-50%, -50%)', | ||
| minWidth: '18px', | ||
| height: '18px', | ||
| lineHeight: '18px', | ||
| fontSize: '11px', | ||
| fontWeight: '700', | ||
| fontFamily: 'system-ui, -apple-system, sans-serif', | ||
| color: '#fff', | ||
| backgroundColor: 'rgba(220, 38, 38, 0.92)', | ||
| borderRadius: '9px', | ||
| textAlign: 'center', | ||
| padding: '0 4px', | ||
| boxSizing: 'border-box', | ||
| boxShadow: '0 1px 3px rgba(0,0,0,0.3)', | ||
| ...customStyle, | ||
| }); | ||
| overlay.appendChild(badge); | ||
| } | ||
| clone.style.position = 'relative'; | ||
| clone.appendChild(overlay); | ||
| } | ||
| /* ── Prompt text formatter ────────────────────── */ | ||
| function formatPromptText(elements, dimensions) { | ||
| const lines = [ | ||
| `Screenshot of a web page (${dimensions.width}\u00d7${dimensions.height}px).`, | ||
| '', | ||
| ]; | ||
| const interactive = elements.filter((e) => e.type === 'interactive'); | ||
| const semantic = elements.filter((e) => e.type === 'semantic'); | ||
| if (interactive.length) { | ||
| lines.push('Interactive elements:'); | ||
| for (const el of interactive) { | ||
| const attrParts = Object.entries(el.attributes).map( | ||
| ([k, v]) => `${k}="${v}"` | ||
| ); | ||
| const text = el.text ? ` "${truncate(el.text, 60)}"` : ''; | ||
| const pos = `(${el.bbox.x},${el.bbox.y} ${el.bbox.width}\u00d7${el.bbox.height})`; | ||
| const attrs = attrParts.length ? ' ' + attrParts.join(' ') : ''; | ||
| lines.push(` [${el.id}] <${el.tag}>${text} ${pos}${attrs}`); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| if (semantic.length) { | ||
| lines.push('Semantic structure:'); | ||
| for (const el of semantic) { | ||
| const text = el.text ? ` "${truncate(el.text, 80)}"` : ''; | ||
| const attrParts = []; | ||
| if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`); | ||
| if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`); | ||
| const attrs = attrParts.length ? ' ' + attrParts.join(' ') : ''; | ||
| lines.push(` [${el.id}] <${el.tag}>${text}${attrs}`); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function truncate(str, max) { | ||
| if (str.length <= max) return str; | ||
| return str.slice(0, max - 1) + '\u2026'; | ||
| } |
+1
-1
@@ -14,4 +14,4 @@ /** | ||
| export { colorTint } from './color-tint.js'; | ||
| export { pictureResolver } from './picture-resolver.js'; | ||
| export { pdfImage } from './pdf-image.js'; | ||
| export { promptExport } from './prompt-export.js'; | ||
| // export { htmlInCanvas } from './html-in-canvas.js'; |
+3
-3
| { | ||
| "name": "@zumer/snapdom-plugins", | ||
| "version": "1.0.1", | ||
| "version": "1.0.2", | ||
| "description": "Official plugins for SnapDOM", | ||
@@ -13,5 +13,5 @@ "type": "module", | ||
| "./color-tint": "./color-tint.js", | ||
| "./picture-resolver": "./picture-resolver.js", | ||
| "./html-in-canvas": "./html-in-canvas.js", | ||
| "./pdf-image": "./pdf-image.js" | ||
| "./pdf-image": "./pdf-image.js", | ||
| "./prompt-export": "./prompt-export.js" | ||
| }, | ||
@@ -18,0 +18,0 @@ "files": [ |
+0
-21
@@ -120,23 +120,2 @@ # @zumer/snapdom-plugins | ||
| ### `picture-resolver` | ||
| Resolves lazy-loaded `<picture>` placeholders and `data-src` patterns before capture. | ||
| Detects base64/blob stubs, fetches the real image, and restores the DOM after cloning (zero side effects). | ||
| ```js | ||
| import { pictureResolver } from '@zumer/snapdom-plugins/picture-resolver'; | ||
| snapdom(el, { plugins: [pictureResolver({ timeout: 3000 })] }); | ||
| ``` | ||
| | Option | Type | Default | Description | | ||
| |--------|------|---------|-------------| | ||
| | `timeout` | `number` | `5000` | Max ms per image fetch | | ||
| | `concurrency` | `number` | `4` | Max parallel fetches | | ||
| | `resolveLazySrc` | `boolean` | `true` | Also resolve `data-src` / `data-srcset` patterns | | ||
| | `useProxy` | `string` | `''` | CORS proxy URL for cross-origin images | | ||
| | `silent` | `boolean` | `false` | Suppress console warnings | | ||
| --- | ||
| ### `ascii-export` | ||
@@ -143,0 +122,0 @@ |
| /** | ||
| * pictureResolver - Official SnapDOM Plugin | ||
| * | ||
| * Resolves lazy-loaded <picture> elements that use base64/low-quality placeholders | ||
| * in <img src> while the real image lives in <source srcset>. | ||
| * | ||
| * Common pattern in news sites (La Nación, Clarín, NYT, Medium, etc.): | ||
| * <picture> | ||
| * <source srcset="https://cdn.example.com/real-image.jpg" media="..."> | ||
| * <img src="data:image/jpeg;base64,/9j/4AAQ..." alt="..."> ← tiny placeholder | ||
| * </picture> | ||
| * | ||
| * The browser renders the real image visually, but DOM cloning captures the | ||
| * placeholder because cloneNode copies the HTML attribute, not the rendered source. | ||
| * | ||
| * This plugin: | ||
| * 1. Detects <picture> elements with base64/blob placeholder <img src> | ||
| * 2. Resolves the real URL from <source srcset> or img.currentSrc | ||
| * 3. Fetches the real image using the browser's context (correct Referer/cookies) | ||
| * 4. Replaces img src with the fetched data URL (attribute + property) | ||
| * 5. Removes <source> elements to prevent re-resolution during clone | ||
| * 6. Restores original DOM after cloning (zero side effects) | ||
| * | ||
| * Also handles common lazy-loading patterns: | ||
| * - data-src / data-srcset attributes (lazysizes, vanilla-lazyload, etc.) | ||
| * - loading="lazy" images that haven't entered viewport yet | ||
| * | ||
| * @param {Object} [options] | ||
| * @param {number} [options.timeout=5000] Max ms per image fetch | ||
| * @param {number} [options.concurrency=4] Max parallel fetches | ||
| * @param {boolean} [options.resolveLazySrc=true] Also resolve data-src/data-srcset patterns | ||
| * @param {boolean} [options.silent=false] Suppress console warnings | ||
| * @param {string} [options.useProxy=''] CORS proxy for cross-origin images | ||
| * @returns {import('../../src/core/plugins.js').Plugin} | ||
| * | ||
| * @example | ||
| * // Per-capture usage | ||
| * const result = await snapdom(element, { | ||
| * plugins: [pictureResolver()] | ||
| * }); | ||
| * | ||
| * @example | ||
| * // Global registration | ||
| * snapdom.plugins(pictureResolver({ timeout: 3000 })); | ||
| * | ||
| * @example | ||
| * // With proxy for cross-origin images | ||
| * const result = await snapdom(element, { | ||
| * plugins: [pictureResolver({ useProxy: 'https://corsproxy.io/?' })] | ||
| * }); | ||
| */ | ||
| export function pictureResolver(options = {}) { | ||
| const { | ||
| timeout = 5000, | ||
| concurrency = 4, | ||
| resolveLazySrc = true, | ||
| silent = false, | ||
| useProxy = '', | ||
| } = options | ||
| /** @type {Array<() => void>} */ | ||
| let undoStack = [] | ||
| /** | ||
| * Check if a src string is a placeholder (base64, blob, empty, tiny SVG placeholder, etc.) | ||
| * @param {string} src | ||
| * @returns {boolean} | ||
| */ | ||
| function isPlaceholder(src) { | ||
| if (!src) return true | ||
| if (src.startsWith('data:')) return true | ||
| if (src.startsWith('blob:')) return true | ||
| // Common 1x1 transparent pixel patterns | ||
| if (/^data:image\/(gif|png|svg)/.test(src) && src.length < 200) return true | ||
| return false | ||
| } | ||
| /** | ||
| * Find the best real URL for an image inside a <picture>. | ||
| * Priority: img.currentSrc > <source srcset> matching viewport > first <source srcset> | ||
| * @param {HTMLImageElement} img | ||
| * @param {HTMLPictureElement} picture | ||
| * @returns {string|null} | ||
| */ | ||
| function findRealUrl(img, picture) { | ||
| // 1) currentSrc is the browser's resolved choice (best match for viewport) | ||
| const current = img.currentSrc || '' | ||
| if (current && !isPlaceholder(current)) return current | ||
| // 2) Walk <source> elements; prefer one whose media query matches | ||
| const sources = picture.querySelectorAll('source[srcset]') | ||
| let fallback = null | ||
| for (const source of sources) { | ||
| const srcset = source.getAttribute('srcset') | ||
| if (!srcset || isPlaceholder(srcset)) continue | ||
| const media = source.getAttribute('media') | ||
| if (media) { | ||
| try { | ||
| if (window.matchMedia(media).matches) { | ||
| // Best candidate: matches current viewport | ||
| return srcset.split(',')[0].trim().split(/\s+/)[0] | ||
| } | ||
| } catch { /* invalid media query, skip */ } | ||
| } | ||
| // Keep first valid source as fallback | ||
| if (!fallback) fallback = srcset.split(',')[0].trim().split(/\s+/)[0] | ||
| } | ||
| return fallback | ||
| } | ||
| /** | ||
| * Find lazy-load URLs from data-* attributes. | ||
| * Supports: data-src, data-srcset, data-lazy-src, data-original | ||
| * @param {HTMLImageElement} img | ||
| * @returns {string|null} | ||
| */ | ||
| function findLazySrc(img) { | ||
| const candidates = [ | ||
| img.getAttribute('data-src'), | ||
| img.getAttribute('data-lazy-src'), | ||
| img.getAttribute('data-original'), | ||
| img.getAttribute('data-hi-res-src'), | ||
| ] | ||
| for (const c of candidates) { | ||
| if (c && !isPlaceholder(c)) return c | ||
| } | ||
| // data-srcset: take first candidate | ||
| const dataSrcset = img.getAttribute('data-srcset') || img.getAttribute('data-lazy-srcset') | ||
| if (dataSrcset) { | ||
| const first = dataSrcset.split(',')[0].trim().split(/\s+/)[0] | ||
| if (first && !isPlaceholder(first)) return first | ||
| } | ||
| return null | ||
| } | ||
| /** | ||
| * Fetch an image URL using the browser's context (sends correct Referer/cookies). | ||
| * Falls back to proxy if direct fetch fails. | ||
| * @param {string} url | ||
| * @returns {Promise<string|null>} data URL or null on failure | ||
| */ | ||
| async function fetchAsDataUrl(url) { | ||
| const controller = new AbortController() | ||
| const timer = setTimeout(() => controller.abort(), timeout) | ||
| try { | ||
| // Try direct fetch first (browser sends Referer/cookies) | ||
| let resp = await fetch(url, { | ||
| credentials: 'include', | ||
| signal: controller.signal, | ||
| }) | ||
| // If blocked, try proxy | ||
| if (!resp.ok && useProxy) { | ||
| const proxyUrl = useProxy.includes('{url}') | ||
| ? useProxy.replace('{url}', encodeURIComponent(url)) | ||
| : useProxy.endsWith('?') | ||
| ? `${useProxy}${encodeURIComponent(url)}` | ||
| : `${useProxy}${useProxy.includes('?') ? '&' : '?'}url=${encodeURIComponent(url)}` | ||
| resp = await fetch(proxyUrl, { signal: controller.signal }) | ||
| } | ||
| if (!resp.ok) return null | ||
| const blob = await resp.blob() | ||
| return await new Promise((resolve, reject) => { | ||
| const fr = new FileReader() | ||
| fr.onload = () => resolve(/** @type {string} */ (fr.result)) | ||
| fr.onerror = reject | ||
| fr.readAsDataURL(blob) | ||
| }) | ||
| } catch { | ||
| return null | ||
| } finally { | ||
| clearTimeout(timer) | ||
| } | ||
| } | ||
| /** | ||
| * Process a batch of tasks with limited concurrency. | ||
| * @param {Array<() => Promise<void>>} tasks | ||
| */ | ||
| async function runBatched(tasks) { | ||
| for (let i = 0; i < tasks.length; i += concurrency) { | ||
| const batch = tasks.slice(i, i + concurrency) | ||
| await Promise.allSettled(batch.map(fn => fn())) | ||
| } | ||
| } | ||
| return { | ||
| name: 'picture-resolver', | ||
| /** | ||
| * Before cloning: resolve placeholders to real images in the live DOM. | ||
| * Stores undo functions to restore original state after clone. | ||
| */ | ||
| async beforeClone(ctx) { | ||
| const root = ctx.element | ||
| if (!root || !(root instanceof Element)) return | ||
| undoStack = [] | ||
| const tasks = [] | ||
| // ── 1) <picture> with placeholder <img src> ── | ||
| const pictures = root.querySelectorAll('picture') | ||
| for (const picture of pictures) { | ||
| const img = picture.querySelector('img') | ||
| if (!img) continue | ||
| const originalSrc = img.getAttribute('src') || '' | ||
| if (!isPlaceholder(originalSrc)) continue | ||
| const realUrl = findRealUrl(img, picture) | ||
| if (!realUrl) continue | ||
| tasks.push(async () => { | ||
| const dataUrl = await fetchAsDataUrl(realUrl) | ||
| if (!dataUrl) { | ||
| if (!silent) console.warn(`[snapdom:picture-resolver] Failed to fetch: ${realUrl.slice(0, 60)}`) | ||
| return | ||
| } | ||
| // Save original state for undo | ||
| const origAttr = img.getAttribute('src') | ||
| const origSrcset = img.getAttribute('srcset') | ||
| const origSizes = img.getAttribute('sizes') | ||
| const removedSources = [] | ||
| // Swap to real image | ||
| img.src = dataUrl | ||
| img.setAttribute('src', dataUrl) | ||
| img.removeAttribute('srcset') | ||
| img.removeAttribute('sizes') | ||
| // Remove <source> elements to prevent clone re-resolution | ||
| const sources = picture.querySelectorAll('source') | ||
| for (const s of sources) { | ||
| removedSources.push({ el: s, parent: s.parentElement, next: s.nextSibling }) | ||
| s.remove() | ||
| } | ||
| // Register undo | ||
| undoStack.push(() => { | ||
| if (origAttr !== null) img.setAttribute('src', origAttr) | ||
| else img.removeAttribute('src') | ||
| if (origSrcset !== null) img.setAttribute('srcset', origSrcset) | ||
| if (origSizes !== null) img.setAttribute('sizes', origSizes) | ||
| for (const { el, parent, next } of removedSources) { | ||
| if (parent) parent.insertBefore(el, next) | ||
| } | ||
| }) | ||
| }) | ||
| } | ||
| // ── 2) Lazy-loaded <img> with data-src / data-srcset ── | ||
| if (resolveLazySrc) { | ||
| const imgs = root.querySelectorAll('img') | ||
| for (const img of imgs) { | ||
| // Skip images already handled by <picture> pass | ||
| if (img.closest('picture') && isPlaceholder(img.getAttribute('src') || '')) continue | ||
| const currentSrc = img.getAttribute('src') || '' | ||
| const lazySrc = findLazySrc(img) | ||
| // Case A: has data-src but current src is placeholder | ||
| if (lazySrc && isPlaceholder(currentSrc)) { | ||
| tasks.push(async () => { | ||
| const dataUrl = await fetchAsDataUrl(lazySrc) | ||
| if (!dataUrl) return | ||
| const origSrc = img.getAttribute('src') | ||
| img.src = dataUrl | ||
| img.setAttribute('src', dataUrl) | ||
| img.removeAttribute('srcset') | ||
| img.removeAttribute('sizes') | ||
| undoStack.push(() => { | ||
| if (origSrc !== null) img.setAttribute('src', origSrc) | ||
| else img.removeAttribute('src') | ||
| }) | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| // Run all fetches with concurrency limit | ||
| if (tasks.length > 0) { | ||
| await runBatched(tasks) | ||
| } | ||
| }, | ||
| /** | ||
| * After cloning: restore original DOM (undo all mutations). | ||
| */ | ||
| async afterClone(ctx) { | ||
| for (const undo of undoStack) { | ||
| try { undo() } catch { /* non-blocking */ } | ||
| } | ||
| undoStack = [] | ||
| }, | ||
| } | ||
| } | ||
| export default pictureResolver |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
0
-100%32098
-6.67%676
-5.19%211
-9.05%