Skip to content

Commit d41504b

Browse files
fix: resolve CSS transform double-scale bug (issue #321)
1 parent bcb719a commit d41504b

3 files changed

Lines changed: 132 additions & 25 deletions

File tree

__tests__/core.clone.more.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,61 @@ describe('deepClone – targeted branches for coverage gaps', () => {
243243
expect(clone.dataset.snapdomHeight).toBe('22')
244244
})
245245

246+
/**
247+
* Covers: CSS transform double-scale bug
248+
* IMG should NOT double-scale when ancestor has transform:scale()
249+
* offsetWidth/offsetHeight returns pre-transform dimensions regardless of nesting depth
250+
*/
251+
it('IMG preserves dimensions when parent has transform:scale()', async () => {
252+
const container = document.createElement('div')
253+
container.style.cssText = 'transform: scale(1.5); width: 200px; height: 200px;'
254+
255+
const img = document.createElement('img')
256+
img.width = 100
257+
img.height = 100
258+
img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='
259+
260+
container.appendChild(img)
261+
document.body.appendChild(container)
262+
263+
const containerClone = await deepClone(container, session, {})
264+
const imgClone = containerClone.querySelector('img')
265+
266+
// Container should preserve its pre-transform dimensions (200x200)
267+
expect(containerClone.style.width).toBe('200px')
268+
expect(containerClone.style.height).toBe('200px')
269+
270+
// Image should have original 100x100, NOT 150x150 (scaled by parent)
271+
expect(imgClone.dataset.snapdomWidth).toBe('100')
272+
expect(imgClone.dataset.snapdomHeight).toBe('100')
273+
274+
container.remove()
275+
})
276+
277+
/**
278+
* Covers: CANVAS element with parent scale
279+
*/
280+
it('CANVAS handles parent transform:scale()', async () => {
281+
const container = document.createElement('div')
282+
container.style.transform = 'scale(1.5)'
283+
284+
const canvas = document.createElement('canvas')
285+
canvas.width = 100
286+
canvas.height = 100
287+
288+
container.appendChild(canvas)
289+
document.body.appendChild(container)
290+
291+
const clone = await deepClone(canvas, session, {})
292+
293+
// Clone should be IMG with original dimensions
294+
expect(clone.tagName).toBe('IMG')
295+
expect(clone.width).toBe(100)
296+
expect(clone.height).toBe(100)
297+
298+
container.remove()
299+
})
300+
246301
/**
247302
* Covers: textarea pendingTextAreaValue → final textContent assignment path.
248303
*/

src/core/clone.js

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
collectCustomPropsFromCSS,
1717
buildSeedCustomPropsRule,
1818
markSlottedSubtree,
19-
rasterizeIframe
19+
rasterizeIframe,
20+
getUnscaledDimensions
2021
} from '../utils/clone.helpers.js'
2122

2223
// helper implementations moved to ../utils/clone.helpers.js
@@ -101,17 +102,18 @@ export async function deepClone(node, sessionCache, options) {
101102

102103
// Fallback actual (placeholder o spacer)
103104
if (options.placeholders) {
105+
const { width, height } = getUnscaledDimensions(node)
104106
const fallback = document.createElement('div')
105107
fallback.style.cssText =
106-
`width:${node.offsetWidth}px;height:${node.offsetHeight}px;` +
108+
`width:${width}px;height:${height}px;` +
107109
'background-image:repeating-linear-gradient(45deg,#ddd,#ddd 5px,#f9f9f9 5px,#f9f9f9 10px);' +
108110
'display:flex;align-items:center;justify-content:center;font-size:12px;color:#555;border:1px solid #aaa;'
109111
inlineAllStyles(node, fallback, sessionCache, options)
110112
return fallback
111113
} else {
112-
const rect = node.getBoundingClientRect()
114+
const { width, height } = getUnscaledDimensions(node)
113115
const spacer = document.createElement('div')
114-
spacer.style.cssText = `display:inline-block;width:${rect.width}px;height:${rect.height}px;visibility:hidden;`
116+
spacer.style.cssText = `display:inline-block;width:${width}px;height:${height}px;visibility:hidden;`
115117
inlineAllStyles(node, spacer, sessionCache, options)
116118
return spacer
117119
}
@@ -165,12 +167,10 @@ export async function deepClone(node, sessionCache, options) {
165167
img.width = node.width
166168
img.height = node.height
167169

168-
// conservar caja CSS para no romper layout
169-
try {
170-
const cs = getComputedStyle(node)
171-
if (cs.width) img.style.width = cs.width
172-
if (cs.height) img.style.height = cs.height
173-
} catch { }
170+
// conservar caja CSS para no romper layout usando dimensiones pre-transform
171+
const { width, height } = getUnscaledDimensions(node)
172+
if (width > 0) img.style.width = `${width}px`
173+
if (height > 0) img.style.height = `${height}px`
174174

175175
sessionCache.nodeMap.set(img, node)
176176
inlineAllStyles(node, img, sessionCache, options)
@@ -184,22 +184,11 @@ export async function deepClone(node, sessionCache, options) {
184184
sessionCache.nodeMap.set(clone, node)
185185
if (node.tagName === 'IMG') {
186186
freezeImgSrcset(node, clone)
187-
// Record original image dimensions for fallback usage when inlining fails
187+
// Record original image dimensions (pre-transform) for fallback usage when inlining fails
188188
try {
189-
const rect = node.getBoundingClientRect()
190-
let w = Math.round(rect.width || 0)
191-
let h = Math.round(rect.height || 0)
192-
if (!w || !h) {
193-
const computed = window.getComputedStyle(node)
194-
const cssW = parseFloat(computed.width) || 0
195-
const cssH = parseFloat(computed.height) || 0
196-
const attrW = parseInt(node.getAttribute('width') || '', 10) || 0
197-
const attrH = parseInt(node.getAttribute('height') || '', 10) || 0
198-
const propW = node.width || node.naturalWidth || 0
199-
const propH = node.height || node.naturalHeight || 0
200-
w = Math.round(w || cssW || attrW || propW || 0)
201-
h = Math.round(h || cssH || attrH || propH || 0)
202-
}
189+
const { width, height } = getUnscaledDimensions(node)
190+
const w = Math.round(width || 0)
191+
const h = Math.round(height || 0)
203192
if (w) clone.dataset.snapdomWidth = String(w)
204193
if (h) clone.dataset.snapdomHeight = String(h)
205194
} catch { }

src/utils/clone.helpers.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,69 @@ function measureContentBox(el) {
286286
return { contentWidth, contentHeight, rect }
287287
}
288288

289+
/**
290+
* Get the unscaled dimensions of an element (pre-transform layout dimensions).
291+
* This function returns dimensions that do NOT include ancestor CSS transforms,
292+
* avoiding the double-scale bug where getBoundingClientRect() returns already-scaled
293+
* dimensions that then get scaled again by inherited transforms.
294+
*
295+
* Priority fallback chain:
296+
* 1. offsetWidth/offsetHeight (pre-transform layout dimensions)
297+
* 2. getComputedStyle() width/height
298+
* 3. getAttribute() width/height
299+
* 4. Intrinsic dimensions (naturalWidth/naturalHeight for images)
300+
*
301+
* @param {Element} el - The element to measure
302+
* @returns {{width: number, height: number}} Unscaled dimensions in pixels
303+
*/
304+
export function getUnscaledDimensions(el) {
305+
let width = 0
306+
let height = 0
307+
308+
// Priority 1: offsetWidth/offsetHeight (pre-transform layout dimensions)
309+
if (el.offsetWidth > 0) width = el.offsetWidth
310+
if (el.offsetHeight > 0) height = el.offsetHeight
311+
312+
// Priority 2: getComputedStyle() if offset dimensions not available
313+
if (width === 0 || height === 0) {
314+
try {
315+
const cs = getComputedStyle(el)
316+
if (width === 0) {
317+
const w = parseFloat(cs.width)
318+
if (!isNaN(w) && w > 0) width = w
319+
}
320+
if (height === 0) {
321+
const h = parseFloat(cs.height)
322+
if (!isNaN(h) && h > 0) height = h
323+
}
324+
} catch { }
325+
}
326+
327+
// Priority 3: getAttribute() for hardcoded dimensions
328+
if (width === 0 || height === 0) {
329+
try {
330+
if (width === 0) {
331+
const w = parseFloat(el.getAttribute('width'))
332+
if (!isNaN(w) && w > 0) width = w
333+
}
334+
if (height === 0) {
335+
const h = parseFloat(el.getAttribute('height'))
336+
if (!isNaN(h) && h > 0) height = h
337+
}
338+
} catch { }
339+
}
340+
341+
// Priority 4: Intrinsic dimensions (for images)
342+
if ((width === 0 || height === 0) && (el.naturalWidth || el.naturalHeight)) {
343+
try {
344+
if (width === 0 && el.naturalWidth > 0) width = el.naturalWidth
345+
if (height === 0 && el.naturalHeight > 0) height = el.naturalHeight
346+
} catch { }
347+
}
348+
349+
return { width, height }
350+
}
351+
289352
/**
290353
* Temporarily pin the iframe's internal viewport to (w, h) CSS px.
291354
* Injects a <style> into the iframe doc and returns a cleanup function.

0 commit comments

Comments
 (0)