Skip to content

Commit 54c50fb

Browse files
authored
feat: Improve node cloning using @zumer/snapdom deepClone (#792)
1 parent 2e817bb commit 54c50fb

File tree

3 files changed

+215
-33
lines changed

3 files changed

+215
-33
lines changed

src/snapdom/clone.js

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// https://github.com/zumerlab/snapdom
2+
//
3+
// MIT License
4+
//
5+
// Copyright (c) 2025 ZumerLab
6+
//
7+
// Permission is hereby granted, free of charge, to any person obtaining a copy
8+
// of this software and associated documentation files (the "Software"), to deal
9+
// in the Software without restriction, including without limitation the rights
10+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
// copies of the Software, and to permit persons to whom the Software is
12+
// furnished to do so, subject to the following conditions:
13+
//
14+
// The above copyright notice and this permission notice shall be included in all
15+
// copies or substantial portions of the Software.
16+
//
17+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
// SOFTWARE.
24+
25+
/**
26+
* Deep cloning utilities for DOM elements, including styles and shadow DOM.
27+
* @module clone
28+
*/
29+
30+
31+
/**
32+
* Freeze the responsive selection of an <img> that has srcset/sizes.
33+
* Copies a concrete URL into `src` and removes `srcset`/`sizes` so the clone
34+
* doesn't need layout to resolve a candidate.
35+
* Works with <picture> because currentSrc reflects the chosen source.
36+
* @param {HTMLImageElement} original - Image in the live DOM.
37+
* @param {HTMLImageElement} cloned - Just-created cloned <img>.
38+
*/
39+
function freezeImgSrcset(original, cloned) {
40+
try {
41+
const chosen = original.currentSrc || original.src || '';
42+
if (!chosen) return;
43+
cloned.setAttribute('src', chosen);
44+
cloned.removeAttribute('srcset');
45+
cloned.removeAttribute('sizes');
46+
// Hint deterministic decode/load for capture
47+
cloned.loading = 'eager';
48+
cloned.decoding = 'sync';
49+
} catch {
50+
// no-op
51+
}
52+
}
53+
54+
55+
/**
56+
* Creates a deep clone of a DOM node, including styles, shadow DOM, and special handling for excluded/placeholder/canvas nodes.
57+
*
58+
* @param {Node} node - Node to clone
59+
* @returns {Node|null} Cloned node with styles and shadow DOM content, or null for empty text nodes or filtered elements
60+
*/
61+
62+
63+
export function deepCloneBasic(node) {
64+
if (!node) throw new Error('Invalid node');
65+
66+
// Local set to avoid duplicates in slot processing
67+
const clonedAssignedNodes = new Set();
68+
let pendingSelectValue = null; // Track select value for later fix
69+
70+
// 1. Text nodes
71+
if (node.nodeType === Node.TEXT_NODE) {
72+
return node.cloneNode(true);
73+
}
74+
75+
// 2. Non-element nodes (comments, etc.)
76+
if (node.nodeType !== Node.ELEMENT_NODE) {
77+
return node.cloneNode(true);
78+
}
79+
80+
// 6. Special case: iframe → fallback pattern
81+
if (node.tagName === "IFRAME") {
82+
const fallback = document.createElement("div");
83+
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;`;
84+
return fallback;
85+
}
86+
87+
// 8. Canvas → convert to image
88+
if (node.tagName === "CANVAS") {
89+
const dataURL = node.toDataURL();
90+
const img = document.createElement("img");
91+
img.src = dataURL;
92+
img.width = node.width;
93+
img.height = node.height;
94+
return img;
95+
}
96+
97+
// 9. Base clone (without children)
98+
let clone;
99+
try {
100+
clone = node.cloneNode(false);
101+
102+
if (node.tagName === 'IMG') {
103+
freezeImgSrcset(node, clone);
104+
}
105+
} catch (err) {
106+
console.error("[Snapdom] Failed to clone node:", node, err);
107+
throw err;
108+
}
109+
110+
// Special handling: textarea (keep size and value)
111+
if (node instanceof HTMLTextAreaElement) {
112+
clone.textContent = node.value;
113+
clone.value = node.value;
114+
const rect = node.getBoundingClientRect();
115+
clone.style.boxSizing = 'border-box';
116+
clone.style.width = `${rect.width}px`;
117+
clone.style.height = `${rect.height}px`;
118+
return clone;
119+
}
120+
121+
// Special handling: input
122+
if (node instanceof HTMLInputElement) {
123+
if (node.hasAttribute("value")) {
124+
clone.value = node.value;
125+
clone.setAttribute("value", node.value);
126+
}
127+
if (node.checked !== void 0) {
128+
clone.checked = node.checked;
129+
if (node.checked) clone.setAttribute("checked", "");
130+
if (node.indeterminate) clone.indeterminate = node.indeterminate;
131+
}
132+
// return clone;
133+
}
134+
135+
// Special handling: select → postpone value adjustment
136+
if (node instanceof HTMLSelectElement) {
137+
pendingSelectValue = node.value;
138+
}
139+
140+
// 12. ShadowRoot logic
141+
if (node.shadowRoot) {
142+
const hasSlot = Array.from(node.shadowRoot.querySelectorAll("slot")).length > 0;
143+
144+
if (hasSlot) {
145+
} else {
146+
// ShadowRoot without slots: clone full content
147+
const shadowFrag = document.createDocumentFragment();
148+
for (const child of node.shadowRoot.childNodes) {
149+
if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "STYLE") {
150+
continue;
151+
}
152+
const clonedChild = deepCloneBasic(child);
153+
if (clonedChild) shadowFrag.appendChild(clonedChild);
154+
}
155+
clone.appendChild(shadowFrag);
156+
}
157+
}
158+
159+
// 13. Slot outside ShadowRoot
160+
if (node.tagName === "SLOT") {
161+
const assigned = node.assignedNodes?.({ flatten: true }) || [];
162+
const nodesToClone = assigned.length > 0 ? assigned : Array.from(node.childNodes);
163+
const fragment = document.createDocumentFragment();
164+
165+
for (const child of nodesToClone) {
166+
const clonedChild = deepCloneBasic(child);
167+
if (clonedChild) fragment.appendChild(clonedChild);
168+
}
169+
return fragment;
170+
}
171+
172+
// 14. Clone children (light DOM), skipping duplicates
173+
for (const child of node.childNodes) {
174+
if (clonedAssignedNodes.has(child)) continue;
175+
176+
const clonedChild = deepCloneBasic(child);
177+
if (clonedChild) clone.appendChild(clonedChild);
178+
}
179+
180+
// Adjust select value after children are cloned
181+
if (pendingSelectValue !== null && clone instanceof HTMLSelectElement) {
182+
clone.value = pendingSelectValue;
183+
for (const opt of clone.options) {
184+
if (opt.value === pendingSelectValue) {
185+
opt.setAttribute("selected", "");
186+
} else {
187+
opt.removeAttribute("selected");
188+
}
189+
}
190+
}
191+
192+
// Fix scrolling (taken from prepareClone).
193+
const scrollX = node.scrollLeft;
194+
const scrollY = node.scrollTop;
195+
const hasScroll = scrollX || scrollY;
196+
if (hasScroll && clone instanceof HTMLElement) {
197+
clone.style.overflow = "hidden";
198+
clone.style.scrollbarWidth = "none";
199+
clone.style.msOverflowStyle = "none";
200+
const inner = document.createElement("div");
201+
inner.style.transform = `translate(${-scrollX}px, ${-scrollY}px)`;
202+
inner.style.willChange = "transform";
203+
inner.style.display = "inline-block";
204+
inner.style.width = "100%";
205+
while (clone.firstChild) {
206+
inner.appendChild(clone.firstChild);
207+
}
208+
clone.appendChild(inner);
209+
}
210+
211+
return clone;
212+
}

src/utils.js

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,37 +28,6 @@ export const createElement = function createElement(tagName, opt) {
2828
return el;
2929
};
3030

31-
// Deep-clone a node and preserve contents/properties.
32-
export const cloneNode = function cloneNode(node, javascriptEnabled) {
33-
// Recursively clone the node.
34-
var clone = node.nodeType === 3 ? document.createTextNode(node.nodeValue) : node.cloneNode(false);
35-
for (var child = node.firstChild; child; child = child.nextSibling) {
36-
if (javascriptEnabled === true || child.nodeType !== 1 || child.nodeName !== 'SCRIPT') {
37-
clone.appendChild(cloneNode(child, javascriptEnabled));
38-
}
39-
}
40-
41-
if (node.nodeType === 1) {
42-
// Preserve contents/properties of special nodes.
43-
if (node.nodeName === 'CANVAS') {
44-
clone.width = node.width;
45-
clone.height = node.height;
46-
clone.getContext('2d').drawImage(node, 0, 0);
47-
} else if (node.nodeName === 'TEXTAREA' || node.nodeName === 'SELECT') {
48-
clone.value = node.value;
49-
}
50-
51-
// Preserve the node's scroll position when it loads.
52-
clone.addEventListener('load', function() {
53-
clone.scrollTop = node.scrollTop;
54-
clone.scrollLeft = node.scrollLeft;
55-
}, true);
56-
}
57-
58-
// Return the cloned node.
59-
return clone;
60-
}
61-
6231
// Convert units from px using the conversion value 'k' from jsPDF.
6332
export const unitConvert = function unitConvert(obj, k) {
6433
if (objType(obj) === 'number') {

src/worker.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { jsPDF } from 'jspdf';
22
import html2canvas from 'html2canvas';
3-
import { objType, createElement, cloneNode, toPx } from './utils.js';
3+
import { deepCloneBasic } from './snapdom/clone.js';
4+
import { objType, createElement, toPx } from './utils.js';
45

56
/* ----- CONSTRUCTOR ----- */
67

@@ -116,7 +117,7 @@ Worker.prototype.toContainer = function toContainer() {
116117
overlayCSS.opacity = 0;
117118

118119
// Create and attach the elements.
119-
var source = cloneNode(this.prop.src, this.opt.html2canvas.javascriptEnabled);
120+
var source = deepCloneBasic(this.prop.src);
120121
this.prop.overlay = createElement('div', { className: 'html2pdf__overlay', style: overlayCSS });
121122
this.prop.container = createElement('div', { className: 'html2pdf__container', style: containerCSS });
122123
this.prop.container.appendChild(source);

0 commit comments

Comments
 (0)