|
5 | 5 |
|
6 | 6 | import { getStyle, inlineSingleBackgroundEntry, splitBackgroundImage } from '../utils' |
7 | 7 | import { cache } from '../core/cache.js' |
| 8 | +import { needsBackgroundInline } from './styles.js' |
8 | 9 |
|
9 | | -/** |
10 | | - * Recursively inlines background-related images and masks from the source element to its clone. |
11 | | - * |
12 | | - * This function walks through the source DOM tree and its clone, copying inline styles for |
13 | | - * background images, masks, and border images to ensure the clone retains all visual image |
14 | | - * resources inline (e.g., data URLs), avoiding external dependencies. |
15 | | - * |
16 | | - * It also preserves the `background-color` property if it is not transparent. |
17 | | - * |
18 | | - * Special handling is done for `border-image` related properties: the |
19 | | - * `border-image-slice`, `border-image-width`, `border-image-outset`, and `border-image-repeat` |
20 | | - * are only copied if `border-image` or `border-image-source` are present and active. |
21 | | - * |
22 | | - * @param {HTMLElement} source The original source element from which styles are read. |
23 | | - * @param {HTMLElement} clone The cloned element to which inline styles are applied. |
24 | | - * @param {Object} [options={}] Optional parameters passed to image inlining functions. |
25 | | - * @returns {Promise<void>} Resolves when all inlining operations (including async image fetches) complete. |
26 | | - */ |
27 | | -/** |
28 | | - * Inlines URL-bearing properties (background/mask/border-image) |
29 | | - * and also preserves mask positioning longhands (position/size/repeat). |
30 | | - * This fixes cases like `mask: url(...) center/60% 60% no-repeat`. |
31 | | - */ |
32 | | -export async function inlineBackgroundImages(source, clone, styleCache, options = {}) { |
33 | | - const queue = [[source, clone]] |
| 10 | +/** Props that can contain url(...) and may need inlining */ |
| 11 | +const URL_PROPS = [ |
| 12 | + 'background-image', |
34 | 13 |
|
35 | | - /** Props that can contain url(...) and may need inlining */ |
36 | | - const URL_PROPS = [ |
37 | | - 'background-image', |
| 14 | + // Mask shorthands & images (both standard and WebKit) |
| 15 | + 'mask', |
| 16 | + 'mask-image', |
| 17 | + '-webkit-mask', |
| 18 | + '-webkit-mask-image', |
38 | 19 |
|
39 | | - // Mask shorthands & images (both standard and WebKit) |
40 | | - 'mask', |
41 | | - 'mask-image', |
42 | | - '-webkit-mask', |
43 | | - '-webkit-mask-image', |
| 20 | + // Mask sources (rare, but keep) |
| 21 | + 'mask-source', |
| 22 | + 'mask-box-image-source', |
| 23 | + 'mask-border-source', |
| 24 | + '-webkit-mask-box-image-source', |
44 | 25 |
|
45 | | - // Mask sources (rare, but keep) |
46 | | - 'mask-source', |
47 | | - 'mask-box-image-source', |
48 | | - 'mask-border-source', |
49 | | - '-webkit-mask-box-image-source', |
| 26 | + // Border image |
| 27 | + 'border-image', |
| 28 | + 'border-image-source', |
| 29 | +] |
50 | 30 |
|
51 | | - // Border image |
52 | | - 'border-image', |
53 | | - 'border-image-source', |
54 | | - ] |
| 31 | +/** Mask longhands to preserve spatial layout (copy as-is). |
| 32 | + * Must run AFTER the `mask` shorthand in URL_PROPS — setting the shorthand |
| 33 | + * resets every longhand to its initial value (#402: lost mask-mode/composite). */ |
| 34 | +const MASK_LAYOUT_PROPS = [ |
| 35 | + 'mask-position', |
| 36 | + 'mask-size', |
| 37 | + 'mask-repeat', |
| 38 | + 'mask-mode', |
| 39 | + 'mask-composite', |
| 40 | + // WebKit variants |
| 41 | + '-webkit-mask-position', |
| 42 | + '-webkit-mask-size', |
| 43 | + '-webkit-mask-repeat', |
| 44 | + '-webkit-mask-composite', |
| 45 | + // Extra (optional but helpful across engines) |
| 46 | + 'mask-origin', |
| 47 | + 'mask-clip', |
| 48 | + '-webkit-mask-origin', |
| 49 | + '-webkit-mask-clip', |
| 50 | + // Some engines expose X/Y position separately: |
| 51 | + '-webkit-mask-position-x', |
| 52 | + '-webkit-mask-position-y', |
| 53 | +] |
| 54 | +const BG_LAYOUT_PROPS = [ |
| 55 | + 'background-position', 'background-position-x', 'background-position-y', |
| 56 | + 'background-size', 'background-repeat', |
| 57 | + 'background-origin', 'background-clip', |
| 58 | + 'background-attachment', 'background-blend-mode' |
| 59 | +] |
| 60 | +/** Border-image aux longhands (copy only when active) */ |
| 61 | +const BORDER_AUX_PROPS = [ |
| 62 | + 'border-image-slice', |
| 63 | + 'border-image-width', |
| 64 | + 'border-image-outset', |
| 65 | + 'border-image-repeat', |
| 66 | +] |
55 | 67 |
|
56 | | - /** Mask longhands to preserve spatial layout (copy as-is). |
57 | | - * Must run AFTER the `mask` shorthand in URL_PROPS — setting the shorthand |
58 | | - * resets every longhand to its initial value (#402: lost mask-mode/composite). */ |
59 | | - const MASK_LAYOUT_PROPS = [ |
60 | | - 'mask-position', |
61 | | - 'mask-size', |
62 | | - 'mask-repeat', |
63 | | - 'mask-mode', |
64 | | - 'mask-composite', |
65 | | - // WebKit variants |
66 | | - '-webkit-mask-position', |
67 | | - '-webkit-mask-size', |
68 | | - '-webkit-mask-repeat', |
69 | | - '-webkit-mask-composite', |
70 | | - // Extra (optional but helpful across engines) |
71 | | - 'mask-origin', |
72 | | - 'mask-clip', |
73 | | - '-webkit-mask-origin', |
74 | | - '-webkit-mask-clip', |
75 | | - // Some engines expose X/Y position separately: |
76 | | - '-webkit-mask-position-x', |
77 | | - '-webkit-mask-position-y', |
78 | | - ] |
79 | | - const BG_LAYOUT_PROPS = [ |
80 | | - 'background-position', 'background-position-x', 'background-position-y', |
81 | | - 'background-size', 'background-repeat', |
82 | | - 'background-origin', 'background-clip', |
83 | | - 'background-attachment', 'background-blend-mode' |
84 | | - ] |
85 | | - /** Border-image aux longhands (copy only when active) */ |
86 | | - const BORDER_AUX_PROPS = [ |
87 | | - 'border-image-slice', |
88 | | - 'border-image-width', |
89 | | - 'border-image-outset', |
90 | | - 'border-image-repeat', |
91 | | - ] |
92 | | - |
93 | | - while (queue.length) { |
94 | | - const [srcNode, cloneNode] = queue.shift() |
| 68 | +/** |
| 69 | + * Inline URL-bearing properties (background/mask/border-image) from one source element onto its |
| 70 | + * clone, plus the layout longhands that keep them positioned (mask position/size, bg layout for |
| 71 | + * background-clip:text, border-image auxiliaries). |
| 72 | + * @param {Element} srcNode |
| 73 | + * @param {HTMLElement} cloneNode |
| 74 | + * @param {WeakMap} styleCache |
| 75 | + * @param {Object} options |
| 76 | + */ |
| 77 | +async function inlineBackgroundForNode(srcNode, cloneNode, styleCache, options) { |
| 78 | + const style = styleCache.get(srcNode) || getStyle(srcNode) |
| 79 | + if (!styleCache.has(srcNode)) styleCache.set(srcNode, style) |
95 | 80 |
|
96 | | - if (!cloneNode) continue |
| 81 | + // Border-image present? |
| 82 | + const bi = style.getPropertyValue('border-image') |
| 83 | + const bis = style.getPropertyValue('border-image-source') |
| 84 | + const hasBorderImage = (bi && bi !== 'none') || (bis && bis !== 'none') |
97 | 85 |
|
98 | | - // Style cache |
99 | | - const style = styleCache.get(srcNode) || getStyle(srcNode) |
100 | | - if (!styleCache.has(srcNode)) styleCache.set(srcNode, style) |
101 | | - // Border-image present? |
102 | | - const hasBorderImage = (() => { |
103 | | - const bi = style.getPropertyValue('border-image') |
104 | | - const bis = style.getPropertyValue('border-image-source') |
105 | | - return (bi && bi !== 'none') || (bis && bis !== 'none') |
106 | | - })() |
107 | | - // Background layout longhands (position/size/repeat/origin/clip/...) are inert without a |
108 | | - // background, yet are never empty, so copying them onto every node bloated the markup and |
109 | | - // rasterization cost. Copy only when a background actually exists. background-color is |
110 | | - // included so the background-clip:text trick (color clipped to text) still works. |
111 | | - const bgImage = style.getPropertyValue('background-image') |
112 | | - const bgColor = style.getPropertyValue('background-color') |
113 | | - const hasBg = |
114 | | - (bgImage && bgImage !== 'none') || |
115 | | - (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') || |
116 | | - /url\s*\(|gradient\s*\(/i.test(style.getPropertyValue('background') || '') |
117 | | - if (hasBg) { |
118 | | - for (const prop of BG_LAYOUT_PROPS) { |
119 | | - const v = style.getPropertyValue(prop) |
120 | | - if (!v) continue |
121 | | - cloneNode.style.setProperty(prop, v) |
122 | | - } |
| 86 | + // Background layout longhands (position/size/repeat/origin/clip/...) are inert without a |
| 87 | + // background, yet are never empty, so copying them onto every node bloated the markup and |
| 88 | + // rasterization cost. Copy only when a background actually exists. background-color is |
| 89 | + // included so the background-clip:text trick (color clipped to text) still works. |
| 90 | + const bgImage = style.getPropertyValue('background-image') |
| 91 | + const bgColor = style.getPropertyValue('background-color') |
| 92 | + const hasBg = |
| 93 | + (bgImage && bgImage !== 'none') || |
| 94 | + (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') || |
| 95 | + /url\s*\(|gradient\s*\(/i.test(style.getPropertyValue('background') || '') |
| 96 | + if (hasBg) { |
| 97 | + for (const prop of BG_LAYOUT_PROPS) { |
| 98 | + const v = style.getPropertyValue(prop) |
| 99 | + if (!v) continue |
| 100 | + cloneNode.style.setProperty(prop, v) |
123 | 101 | } |
124 | | - // 1) Inline URL-bearing properties |
125 | | - for (const prop of URL_PROPS) { |
126 | | - let val = style.getPropertyValue(prop) |
127 | | - // Fallback: when background-image is none/empty, parse url() from background shorthand (#343) |
128 | | - if ((prop === 'background-image') && (!val || val === 'none')) { |
129 | | - const bgShorthand = style.getPropertyValue('background') |
130 | | - if (bgShorthand && /url\s*\(/.test(bgShorthand)) { |
131 | | - // Use filter+join to preserve all url() layers, not just the first (#NEW-5) |
132 | | - val = splitBackgroundImage(bgShorthand).filter(p => /url\s*\(/.test(p)).join(', ') || val |
133 | | - } |
| 102 | + } |
| 103 | + // 1) Inline URL-bearing properties |
| 104 | + for (const prop of URL_PROPS) { |
| 105 | + let val = style.getPropertyValue(prop) |
| 106 | + // Fallback: when background-image is none/empty, parse url() from background shorthand (#343) |
| 107 | + if ((prop === 'background-image') && (!val || val === 'none')) { |
| 108 | + const bgShorthand = style.getPropertyValue('background') |
| 109 | + if (bgShorthand && /url\s*\(/.test(bgShorthand)) { |
| 110 | + // Use filter+join to preserve all url() layers, not just the first (#NEW-5) |
| 111 | + val = splitBackgroundImage(bgShorthand).filter(p => /url\s*\(/.test(p)).join(', ') || val |
134 | 112 | } |
135 | | - if (!val || val === 'none') continue |
| 113 | + } |
| 114 | + if (!val || val === 'none') continue |
136 | 115 |
|
137 | | - // Split multiple layers (comma-separated) |
138 | | - const splits = splitBackgroundImage(val) |
| 116 | + // Split multiple layers (comma-separated) |
| 117 | + const splits = splitBackgroundImage(val) |
139 | 118 |
|
140 | | - const inlined = await Promise.all( |
141 | | - splits.map(entry => inlineSingleBackgroundEntry(entry, options)) |
142 | | - ) |
| 119 | + const inlined = await Promise.all( |
| 120 | + splits.map(entry => inlineSingleBackgroundEntry(entry, options)) |
| 121 | + ) |
143 | 122 |
|
144 | | - if (inlined.some(p => p && p !== 'none' && !/^url\(undefined/.test(p))) { |
145 | | - cloneNode.style.setProperty(prop, inlined.join(', ')) |
146 | | - } |
| 123 | + if (inlined.some(p => p && p !== 'none' && !/^url\(undefined/.test(p))) { |
| 124 | + cloneNode.style.setProperty(prop, inlined.join(', ')) |
147 | 125 | } |
148 | | - // 2) Copy mask layout longhands (position / size / repeat, etc.) |
149 | | - for (const prop of MASK_LAYOUT_PROPS) { |
| 126 | + } |
| 127 | + // 2) Copy mask layout longhands (position / size / repeat, etc.) |
| 128 | + for (const prop of MASK_LAYOUT_PROPS) { |
| 129 | + const val = style.getPropertyValue(prop) |
| 130 | + // Skip empty/initial defaults to avoid bloating |
| 131 | + if (!val || val === 'initial') continue |
| 132 | + cloneNode.style.setProperty(prop, val) |
| 133 | + } |
| 134 | + // 3) Copy border-image auxiliaries only if border-image is active |
| 135 | + if (hasBorderImage) { |
| 136 | + for (const prop of BORDER_AUX_PROPS) { |
150 | 137 | const val = style.getPropertyValue(prop) |
151 | | - // Skip empty/initial defaults to avoid bloating |
152 | 138 | if (!val || val === 'initial') continue |
153 | 139 | cloneNode.style.setProperty(prop, val) |
154 | 140 | } |
155 | | - // 3) Copy border-image auxiliaries only if border-image is active |
156 | | - if (hasBorderImage) { |
157 | | - for (const prop of BORDER_AUX_PROPS) { |
158 | | - const val = style.getPropertyValue(prop) |
159 | | - if (!val || val === 'initial') continue |
160 | | - cloneNode.style.setProperty(prop, val) |
161 | | - } |
162 | | - } |
163 | | - // 4) Recurse — use nodeMap (clone→source) for child alignment instead of |
164 | | - // index-based pairing, which breaks when clone-only elements (e.g. |
165 | | - // svg.inline-defs-container) shift indices (#439). |
166 | | - if (srcNode.shadowRoot) { |
167 | | - const sChildren = Array.from(srcNode.shadowRoot.children).filter(el => el.tagName !== 'STYLE') |
168 | | - const cChildren = Array.from(cloneNode.children) |
169 | | - .filter(el => !el.dataset?.snapdomPseudo && !(el.tagName === 'STYLE' && el.dataset?.sd)) |
170 | | - for (let i = 0; i < Math.min(sChildren.length, cChildren.length); i++) { |
171 | | - queue.push([sChildren[i], cChildren[i]]) |
172 | | - } |
173 | | - } else { |
174 | | - const nodeMap = cache.session.nodeMap |
175 | | - for (const cChild of cloneNode.children) { |
176 | | - const srcChild = nodeMap.get(cChild) |
177 | | - if (!srcChild) continue |
178 | | - queue.push([srcChild, cChild]) |
179 | | - } |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +/** |
| 145 | + * Inlines background-related images and masks from the source tree onto the clone. |
| 146 | + * |
| 147 | + * The worklist is built from the session clone→source nodeMap and filtered by the |
| 148 | + * needsBackgroundInline flag computed during the style snapshot, so the pass no longer |
| 149 | + * re-reads ~40 computed properties on every node — only flagged nodes do real work. |
| 150 | + * Walking the clone tree (descending through clone-only wrappers like the scroll-translate |
| 151 | + * wrapper) also reaches subtrees the old source/clone parallel walk skipped. |
| 152 | + * |
| 153 | + * @param {HTMLElement} source The original source element. |
| 154 | + * @param {HTMLElement} clone The cloned element receiving inline styles. |
| 155 | + * @param {WeakMap} styleCache |
| 156 | + * @param {Object} [options={}] |
| 157 | + * @returns {Promise<void>} |
| 158 | + */ |
| 159 | +export async function inlineBackgroundImages(source, clone, styleCache, options = {}) { |
| 160 | + if (!clone) return |
| 161 | + const nodeMap = cache.session.nodeMap |
| 162 | + |
| 163 | + const jobs = [] |
| 164 | + if (source && needsBackgroundInline(source)) jobs.push([source, clone]) |
| 165 | + const stack = [clone] |
| 166 | + while (stack.length) { |
| 167 | + const cn = stack.pop() |
| 168 | + if (!cn.children) continue |
| 169 | + for (const child of cn.children) { |
| 170 | + if (child.tagName === 'STYLE') continue |
| 171 | + const src = nodeMap.get(child) |
| 172 | + if (src && needsBackgroundInline(src)) jobs.push([src, child]) |
| 173 | + stack.push(child) |
180 | 174 | } |
181 | 175 | } |
| 176 | + |
| 177 | + // Batch of 6 mirrors inlineImages: bounded fetch concurrency, snapFetch dedupes inflight. |
| 178 | + const BATCH = 6 |
| 179 | + for (let i = 0; i < jobs.length; i += BATCH) { |
| 180 | + await Promise.allSettled( |
| 181 | + jobs.slice(i, i + BATCH).map(([s, c]) => inlineBackgroundForNode(s, c, styleCache, options)) |
| 182 | + ) |
| 183 | + } |
182 | 184 | } |
0 commit comments