Skip to content

Commit f50720f

Browse files
committed
Add same-origin iframe support .See #222
1 parent adb6455 commit f50720f

2 files changed

Lines changed: 167 additions & 12 deletions

File tree

src/api/snapdom.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export async function snapdom(element, userOptions) {
3333

3434
if (context.iconFonts && context.iconFonts.length > 0) extendIconFonts(context.iconFonts);
3535

36+
if (!context.snap) {
37+
/** Facade used by core (e.g., deepClone for same-origin iframes). */
38+
context.snap = {
39+
/** Delegates to this module’s PNG path (tu build devuelve <img>). */
40+
toPng: (el, opts) => snapdom.toPng(el, opts),
41+
/** Útil si en algún flujo necesitás vector o <img> directo. */
42+
toImg: (el, opts) => snapdom.toImg(el, opts),
43+
};
44+
}
45+
3646
return snapdom.capture(element, context, INTERNAL_TOKEN);
3747
}
3848

src/core/clone.js

Lines changed: 157 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,131 @@ function markSlottedSubtree(root) {
250250
root.querySelectorAll('*').forEach(el => el.setAttribute('data-sd-slotted', ''));
251251
}
252252
}
253+
254+
255+
/**
256+
* Wait for an accessible same-origin Document for a given <iframe>.
257+
* @param {HTMLIFrameElement} iframe
258+
* @param {number} [attempts=3]
259+
* @returns {Promise<Document|null>}
260+
*/
261+
async function getAccessibleIframeDocument(iframe, attempts = 3) {
262+
const probe = () => {
263+
try { return iframe.contentDocument || iframe.contentWindow?.document || null; } catch { return null; }
264+
};
265+
let doc = probe();
266+
let i = 0;
267+
while (i < attempts && (!doc || (!doc.body && !doc.documentElement))) {
268+
await new Promise(r => setTimeout(r, 0));
269+
doc = probe();
270+
i++;
271+
}
272+
return doc && (doc.body || doc.documentElement) ? doc : null;
273+
}
274+
275+
/**
276+
* Compute the content-box size of an element (client rect minus borders).
277+
* @param {Element} el
278+
* @returns {{contentWidth:number, contentHeight:number, rect:DOMRect}}
279+
*/
280+
function measureContentBox(el) {
281+
const rect = el.getBoundingClientRect();
282+
let bl=0, br=0, bt=0, bb=0;
283+
try {
284+
const cs = getComputedStyle(el);
285+
bl = parseFloat(cs.borderLeftWidth) || 0;
286+
br = parseFloat(cs.borderRightWidth) || 0;
287+
bt = parseFloat(cs.borderTopWidth) || 0;
288+
bb = parseFloat(cs.borderBottomWidth)|| 0;
289+
} catch {}
290+
const contentWidth = Math.max(0, Math.round(rect.width - (bl + br)));
291+
const contentHeight = Math.max(0, Math.round(rect.height - (bt + bb)));
292+
return { contentWidth, contentHeight, rect };
293+
}
294+
295+
/**
296+
* Temporarily pin the iframe's internal viewport to (w, h) CSS px.
297+
* Injects a <style> into the iframe doc and returns a cleanup function.
298+
* @param {Document} doc
299+
* @param {number} w
300+
* @param {number} h
301+
* @returns {() => void}
302+
*/
303+
function pinIframeViewport(doc, w, h) {
304+
const style = doc.createElement('style');
305+
style.setAttribute('data-sd-iframe-pin','');
306+
style.textContent = `
307+
html, body {
308+
margin: 0 !important;
309+
padding: 0 !important;
310+
width: ${w}px !important;
311+
height: ${h}px !important;
312+
min-width: ${w}px !important;
313+
min-height: ${h}px !important;
314+
box-sizing: border-box !important;
315+
overflow: hidden !important;
316+
background-clip: border-box !important;
317+
}
318+
`;
319+
(doc.head || doc.documentElement).appendChild(style);
320+
return () => { try { style.remove(); } catch {} };
321+
}
322+
323+
/**
324+
* Rasterize a same-origin iframe exactly at its content-box size, as the user requested:
325+
* - Capture iframe.contentDocument.documentElement
326+
* - Force a bitmap (toPng) sized to the iframe viewport (not the content height)
327+
* - Wrap with a styled container that mimics the <iframe> box (borders, radius, etc.)
328+
*
329+
* @param {HTMLIFrameElement} iframe
330+
* @param {object} sessionCache
331+
* @param {object} options
332+
* @returns {Promise<HTMLElement>}
333+
*/
334+
async function rasterizeIframe(iframe, sessionCache, options) {
335+
const doc = await getAccessibleIframeDocument(iframe, 3);
336+
if (!doc) throw new Error('iframe document not accessible/ready');
337+
338+
const { contentWidth, contentHeight, rect } = measureContentBox(iframe);
339+
340+
// Prefer snapdom from the iframe realm; fallback to host's window.snapdom
341+
const snap = options?.snap
342+
if (!snap || typeof snap.toPng !== 'function') {
343+
throw new Error('snapdom.toPng not available in iframe or window');
344+
}
345+
346+
// Avoid double scaling; parent capture decides final scale
347+
const nested = { ...options, scale: 1 };
348+
349+
// Pin viewport so body background fills exactly content box (fixes 400x110 → 400x150)
350+
const unpin = pinIframeViewport(doc, contentWidth, contentHeight);
351+
let imgEl;
352+
try {
353+
imgEl = await snap.toPng(doc.documentElement, nested);
354+
} finally {
355+
unpin();
356+
}
357+
358+
// Build <img> (bitmap) sized to content box
359+
360+
imgEl.style.display = 'block';
361+
imgEl.style.width = `${contentWidth}px`;
362+
imgEl.style.height = `${contentHeight}px`;
363+
364+
// Wrapper that preserves the iframe box (border, radius...) and clips
365+
const wrapper = document.createElement('div');
366+
sessionCache.nodeMap.set(wrapper, iframe);
367+
inlineAllStyles(iframe, wrapper, sessionCache, options);
368+
wrapper.style.overflow = 'hidden';
369+
wrapper.style.display = 'block';
370+
if (!wrapper.style.width) wrapper.style.width = `${Math.round(rect.width)}px`;
371+
if (!wrapper.style.height) wrapper.style.height = `${Math.round(rect.height)}px`;
372+
373+
wrapper.appendChild(imgEl);
374+
return wrapper;
375+
}
376+
377+
253378

254379
export async function deepClone(node, sessionCache, options) {
255380
if (!node) throw new Error("Invalid node");
@@ -303,20 +428,40 @@ export async function deepClone(node, sessionCache, options) {
303428
console.warn("Error in filter function:", err);
304429
}
305430
}
306-
if (node.tagName === "IFRAME") {
307-
if (options.placeholders) {
308-
const fallback = document.createElement("div");
309-
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;`;
310-
inlineAllStyles(node, fallback, sessionCache, options);
311-
return fallback;
312-
} else {
313-
const rect = node.getBoundingClientRect();
314-
const spacer = document.createElement("div");
315-
spacer.style.cssText = `display:inline-block;width:${rect.width}px;height:${rect.height}px;visibility:hidden;`;
316-
inlineAllStyles(node, spacer, sessionCache, options);
317-
return spacer;
431+
if (node.tagName === "IFRAME") {
432+
let sameOrigin = false;
433+
try { sameOrigin = !!(node.contentDocument || node.contentWindow?.document); } catch { sameOrigin = false; }
434+
435+
if (sameOrigin) {
436+
try {
437+
const wrapper = await rasterizeIframe(node, sessionCache, options);
438+
return wrapper;
439+
} catch (err) {
440+
console.warn('[SnapDOM] iframe rasterization failed, fallback:', err);
441+
// fall through
318442
}
319443
}
444+
445+
// Fallback actual (placeholder o spacer)
446+
if (options.placeholders) {
447+
const fallback = document.createElement("div");
448+
fallback.style.cssText =
449+
`width:${node.offsetWidth}px;height:${node.offsetHeight}px;` +
450+
`background-image:repeating-linear-gradient(45deg,#ddd,#ddd 5px,#f9f9f9 5px,#f9f9f9 10px);` +
451+
`display:flex;align-items:center;justify-content:center;font-size:12px;color:#555;border:1px solid #aaa;`;
452+
inlineAllStyles(node, fallback, sessionCache, options);
453+
return fallback;
454+
} else {
455+
const rect = node.getBoundingClientRect();
456+
const spacer = document.createElement("div");
457+
spacer.style.cssText = `display:inline-block;width:${rect.width}px;height:${rect.height}px;visibility:hidden;`;
458+
inlineAllStyles(node, spacer, sessionCache, options);
459+
return spacer;
460+
}
461+
}
462+
463+
464+
320465
if (node.getAttribute("data-capture") === "placeholder") {
321466
const clone2 = node.cloneNode(false);
322467
sessionCache.nodeMap.set(clone2, node);

0 commit comments

Comments
 (0)