Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions __tests__/snapdom.api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,5 +130,63 @@ expect(webpImg.src.startsWith('data:image/webp')).toBe(true);
document.body.removeChild(el);
});

test('snapdom should support exclude option to filter out elements by CSS selectors', async () => {
const el = document.createElement('div');
el.innerHTML = `
<h1>Title</h1>
<div class="exclude-me">Should be excluded</div>
<div data-private="true">Private data</div>
<p>This should remain</p>
`;
document.body.appendChild(el);

const result = await snapdom(el, { exclude: ['.exclude-me', '[data-private]'] });

const svg = result.toRaw();
expect(svg).not.toContain('Should be excluded');
expect(svg).not.toContain('Private data');
expect(svg).toContain('Title');
expect(svg).toContain('This should remain');
});

test('snapdom should support filter option to exclude elements with custom logic', async () => {
const el = document.createElement('div');
el.innerHTML = `
<div class="level-1">Level 1
<div class="level-2">Level 2
<div class="level-3">Level 3</div>
</div>
</div>
`;
document.body.appendChild(el);
const result = await snapdom(target, {
filter: (element) => !element.classList.contains('level-3')
});

const svg = result.toRaw();
expect(svg).toContain('Level 1');
expect(svg).toContain('Level 2');
expect(svg).not.toContain('Level 3');
});

test('snapdom should support combining exclude and filter options', async () => {
const el = document.createElement('div');
el.innerHTML = `
<div class="exclude-by-selector">Exclude by selector</div>
<div class="exclude-by-filter">Exclude by filter</div>
<div class="keep-me">Keep this content</div>
`;
document.body.appendChild(el);

const result = await snapdom(el, {
exclude: ['.exclude-by-selector'],
filter: (element) => !element.classList.contains('exclude-by-filter')
});

const svg = result.toRaw();
expect(svg).not.toContain('Exclude by selector');
expect(svg).not.toContain('Exclude by filter');
expect(svg).toContain('Keep this content');
});

});
6 changes: 5 additions & 1 deletion src/core/capture.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { baseCSSCache } from '../core/cache.js'
* @param {boolean} [options.embedFonts=false] - Whether to embed custom fonts
* @param {boolean} [options.fast=true] - Whether to skip idle delay for faster results
* @param {number} [options.scale=1] - Output scale multiplier
* @param {string[]} [options.exclude] - CSS selectors for elements to exclude
* @param {Function} [options.filter] - Custom filter function
* @returns {Promise<string>} Promise that resolves to an SVG data URL
*/

Expand All @@ -31,7 +33,9 @@ export async function captureDOM(element, options = {}) {
let baseCSS = "";
let dataURL;
let svgString;
({ clone, classCSS, styleCache } = await prepareClone(element, compress, embedFonts, useProxy));

({ clone, classCSS, styleCache } = await prepareClone(element, compress, embedFonts, options));

await new Promise((resolve) => {
idle(async () => {
await inlineImages(clone, options);
Expand Down
81 changes: 67 additions & 14 deletions src/core/clone.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,69 @@ import { inlineAllStyles } from '../modules/styles.js';
* @param {WeakMap} styleCache - Cache of computed styles
* @param {WeakMap} nodeMap - Map to track original-to-clone node relationships
* @param {boolean} compress - Whether to compress style keys
* @returns {Node|null} Cloned node with styles and shadow DOM content, or null for empty text nodes
* @param {Object} [options={}] - Capture options including exclude and filter
* @param {Node} [originalRoot] - Original root element being captured
* @returns {Node|null} Cloned node with styles and shadow DOM content, or null for empty text nodes or filtered elements
*/
export function deepClone(node, styleMap, styleCache, nodeMap, compress) {
if (node.nodeType === Node.ELEMENT_NODE && node.getAttribute("data-capture") === "exclude") {
export function deepClone(node, styleMap, styleCache, nodeMap, compress, options = {}, originalRoot) {
// Skip text nodes and non-element nodes
if (node.nodeType === Node.TEXT_NODE) {
if (node.parentElement?.shadowRoot) {
const tag = node.parentElement.tagName.toLowerCase();
if (customElements.get(tag)) return null;
}
return node.cloneNode(true);
}

if (node.nodeType !== Node.ELEMENT_NODE) return node.cloneNode(true);

// Check exclude by data-capture attribute
if (node.getAttribute("data-capture") === "exclude") {
const spacer = document.createElement("div");
const rect = node.getBoundingClientRect();
spacer.style.cssText = `display: inline-block; width: ${rect.width}px; height: ${rect.height}px; visibility: hidden;`;
return spacer;
}

// Check exclude by CSS selector
if (options.exclude && Array.isArray(options.exclude) && options.exclude.length > 0) {
for (const selector of options.exclude) {
try {
if (node.matches && node.matches(selector)) {
const spacer = document.createElement("div");
const rect = node.getBoundingClientRect();
spacer.style.cssText = `display: inline-block; width: ${rect.width}px; height: ${rect.height}px; visibility: hidden;`;
return spacer;
}
} catch (err) {
console.warn(`Invalid selector in exclude option: ${selector}`, err);
}
}
}

// Check custom filter function
if (typeof options.filter === 'function') {
try {
if (!options.filter(node, originalRoot || node)) {
const spacer = document.createElement("div");
const rect = node.getBoundingClientRect();
spacer.style.cssText = `display: inline-block; width: ${rect.width}px; height: ${rect.height}px; visibility: hidden;`;
return spacer;
}
} catch (err) {
console.warn('Error in filter function:', err);
}
}

// Special handling for specific elements
if (node.tagName === "IFRAME") {
const fallback = document.createElement("div");
fallback.textContent = "";
fallback.style.cssText = `width: ${node.offsetWidth}px; height: ${node.offsetHeight}px; background-image: repeating-linear-gradient(45deg, #ddd, #ddd 5px, #f9f9f9 5px, #f9f9f9 10px);display: flex;align-items: center;justify-content: center;font-size: 12px;color: #555; border: 1px solid #aaa;`;
return fallback;
}
if (node.nodeType === Node.ELEMENT_NODE && node.getAttribute("data-capture") === "placeholder") {

if (node.getAttribute("data-capture") === "placeholder") {
const clone2 = node.cloneNode(false);
nodeMap.set(clone2, node);
inlineAllStyles(node, clone2, styleMap, styleCache, compress);
Expand All @@ -38,6 +85,7 @@ export function deepClone(node, styleMap, styleCache, nodeMap, compress) {
clone2.appendChild(placeholder);
return clone2;
}

if (node.tagName === "CANVAS") {
const dataURL = node.toDataURL();
const img = document.createElement("img");
Expand All @@ -49,16 +97,10 @@ export function deepClone(node, styleMap, styleCache, nodeMap, compress) {
img.style.height = node.style.height || `${node.height}px`;
return img;
}
if (node.nodeType === Node.TEXT_NODE) {
if (node.parentElement?.shadowRoot) {
const tag = node.parentElement.tagName.toLowerCase();
if (customElements.get(tag)) return null;
}
return node.cloneNode(true);
}
if (node.nodeType !== Node.ELEMENT_NODE) return node.cloneNode(true);

const clone = node.cloneNode(false);
nodeMap.set(clone, node);

if (node instanceof HTMLInputElement) {
clone.value = node.value;
clone.setAttribute("value", node.value);
Expand All @@ -84,16 +126,27 @@ export function deepClone(node, styleMap, styleCache, nodeMap, compress) {

inlineAllStyles(node, clone, styleMap, styleCache, compress);
const frag = document.createDocumentFragment();

// Pass the original root element to child clones for filter function
const rootElement = originalRoot || node;

node.childNodes.forEach((child) => {
const clonedChild = deepClone(child, styleMap, styleCache, nodeMap, compress);
const clonedChild = deepClone(child, styleMap, styleCache, nodeMap, compress, options, rootElement);
if (clonedChild) frag.appendChild(clonedChild);
});

clone.appendChild(frag);

if (node.shadowRoot) {
const shadowContent = Array.from(node.shadowRoot.children).filter((el) => el.tagName !== "STYLE").map((el) => deepClone(el, styleMap, styleCache, nodeMap)).filter(Boolean);
const shadowContent = Array.from(node.shadowRoot.children)
.filter((el) => el.tagName !== "STYLE")
.map((el) => deepClone(el, styleMap, styleCache, nodeMap, compress, options, rootElement))
.filter(Boolean);

const shadowFrag = document.createDocumentFragment();
shadowContent.forEach((child) => shadowFrag.appendChild(child));
clone.appendChild(shadowFrag);
}

return clone;
}
17 changes: 11 additions & 6 deletions src/core/prepare.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,27 @@ import { inlineExternalDef } from '../modules/svgDefs.js';
*
* @param {Element} element - Element to clone
* @param {boolean} [compress=false] - Whether to compress style keys
* @param {boolean} [embedFonts=false] - Whether to embed custom fonts
* @param {Object} [options={}] - Capture options
* @param {string[]} [options.exclude] - CSS selectors for elements to exclude
* @param {Function} [options.filter] - Custom filter function
* @returns {Promise<Object>} Object containing the clone, generated CSS, and style cache
*/

export async function prepareClone(element, compress = false, embedFonts = false, useProxy = '') {
const styleMap = /* @__PURE__ */ new Map();
const styleCache = /* @__PURE__ */ new WeakMap();
const nodeMap = /* @__PURE__ */ new Map();
export async function prepareClone(element, compress = false, embedFonts = false, options = {}) {
const styleMap = new Map();
const styleCache = new WeakMap();
const nodeMap = new Map();

let clone;
try {
clone = deepClone(element, styleMap, styleCache, nodeMap, compress);
clone = deepClone(element, styleMap, styleCache, nodeMap, compress, options, element);
} catch (e) {
console.warn("deepClone failed:", e);
throw e;
}
try {
await inlinePseudoElements(element, clone, styleMap, styleCache, compress, embedFonts, useProxy);
await inlinePseudoElements(element, clone, styleMap, styleCache, compress, embedFonts, options.useProxy);
} catch (e) {
console.warn("inlinePseudoElements failed:", e);
}
Expand Down
2 changes: 2 additions & 0 deletions types/snapdom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ declare module "@zumer/snapdom" {
dpr?: number;
quality?: number;
crossOrigin?: (url: string) => "anonymous" | "use-credentials";
exclude?: string[];
filter?: (element: Element, originalElement: Element) => boolean;
}

export interface SnapResult {
Expand Down