|
| 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 | +} |
0 commit comments