Rebase viewport-relative positioning when grafting iframe subtrees
A grafted frame's position:fixed children re-resolved against the MAIN page viewport and escaped the frame box's overflow clip (fixed ignores overflow ancestors) - a full-viewport backdrop inside an embedded iframe could paint across the entire page. Grafted subtrees now demote fixed->absolute and sticky->relative, and a static graft host is promoted to relative so demoted absolutes anchor and clip inside the frame box. 472 tests pass (4 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d56fbbe45e
commit
22d0bf19e9
@@ -171,6 +171,21 @@ export function graftFrameIntoSnapshot(
|
|||||||
x: Math.round((n.bbox.x + frame.contentX) * 100) / 100,
|
x: Math.round((n.bbox.x + frame.contentX) * 100) / 100,
|
||||||
y: Math.round((n.bbox.y + frame.contentY) * 100) / 100,
|
y: Math.round((n.bbox.y + frame.contentY) * 100) / 100,
|
||||||
};
|
};
|
||||||
|
// Rebase viewport-relative positioning into the frame's box. Inside a real iframe,
|
||||||
|
// `position: fixed` pins to the FRAME's viewport (its content box) and `sticky` scrolls
|
||||||
|
// against the FRAME's scroll container. Grafted in as plain <div> children, those
|
||||||
|
// containing blocks no longer exist, so `fixed` would resolve against the MAIN page
|
||||||
|
// viewport — escaping the iframe's clip box (fixed ignores `overflow:hidden` ancestors)
|
||||||
|
// and painting frame chrome (e.g. a `fixed inset-0 z-[-1]` dark backdrop) across the
|
||||||
|
// whole clone. Demote to box-relative positioning: `fixed`→`absolute` (contained and
|
||||||
|
// clipped by the host div, whose bboxes are already rebased to page coords) and
|
||||||
|
// `sticky`→`relative` (stays in flow instead of sticking to the page scroll). The host
|
||||||
|
// is made a containing block below so these absolutes anchor to the frame box.
|
||||||
|
if (n.computed) {
|
||||||
|
const pos = n.computed.position;
|
||||||
|
if (pos === "fixed") n.computed = { ...n.computed, position: "absolute" };
|
||||||
|
else if (pos === "sticky") n.computed = { ...n.computed, position: "relative" };
|
||||||
|
}
|
||||||
const a = n.attrs ?? {};
|
const a = n.attrs ?? {};
|
||||||
delete a["data-cid-cap"]; // capture-ids belong to the main document only
|
delete a["data-cid-cap"]; // capture-ids belong to the main document only
|
||||||
if (a.id) a.id = prefix + a.id;
|
if (a.id) a.id = prefix + a.id;
|
||||||
@@ -190,8 +205,13 @@ export function graftFrameIntoSnapshot(
|
|||||||
|
|
||||||
host.children = [wrapper];
|
host.children = [wrapper];
|
||||||
// A real frame viewport clips its document; the replacement <div> must too, at every
|
// A real frame viewport clips its document; the replacement <div> must too, at every
|
||||||
// captured viewport (each per-viewport snapshot is grafted independently).
|
// captured viewport (each per-viewport snapshot is grafted independently). It must also
|
||||||
|
// be a containing block: `visit` demoted the frame's `position: fixed` chrome (which was
|
||||||
|
// pinned to the frame viewport) to `absolute`, and those absolutes must anchor to — and
|
||||||
|
// be clipped by — this box, not some positioned ancestor further up the page. A `static`
|
||||||
|
// host wouldn't establish that containing block, so promote it to `relative`.
|
||||||
host.computed = { ...host.computed, overflow: "hidden", overflowX: "hidden", overflowY: "hidden" };
|
host.computed = { ...host.computed, overflow: "hidden", overflowX: "hidden", overflowY: "hidden" };
|
||||||
|
if ((host.computed.position || "static") === "static") host.computed.position = "relative";
|
||||||
// An iframe is a REPLACED element: `display:inline` (the default) still honors its
|
// An iframe is a REPLACED element: `display:inline` (the default) still honors its
|
||||||
// width/height. The <div> that replaces it at generation is not — inline would collapse
|
// width/height. The <div> that replaces it at generation is not — inline would collapse
|
||||||
// the box — so translate to the behavior-equivalent inline-block.
|
// the box — so translate to the behavior-equivalent inline-block.
|
||||||
|
|||||||
@@ -163,6 +163,54 @@ describe("graftFrameIntoSnapshot", () => {
|
|||||||
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
|
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
|
||||||
assert.ok(host.keyframes.includes("@keyframes spin { to { transform: rotate(360deg); } }"));
|
assert.ok(host.keyframes.includes("@keyframes spin { to { transform: rotate(360deg); } }"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A frame's `position: fixed` chrome pins to the FRAME viewport, not the page's. Grafted
|
||||||
|
// as plain DOM, `fixed` would resolve against the main page viewport and escape the
|
||||||
|
// iframe's clip box — the observed regression: a gallery embed's
|
||||||
|
// `fixed inset-0 z-[-1]` dark backdrop painted the whole clone dark. It must be demoted
|
||||||
|
// to `absolute` so the host box contains and clips it.
|
||||||
|
it("demotes frame `position: fixed` to `absolute` so it stays inside the iframe box", () => {
|
||||||
|
const backdrop = raw("div", { class: "backdrop" }, [], {
|
||||||
|
computed: { display: "block", position: "fixed" },
|
||||||
|
bbox: { x: 0, y: 0, width: 320, height: 160 },
|
||||||
|
});
|
||||||
|
const frame = pageSnap(raw("body", {}, [backdrop]), "https://frame.test/embed");
|
||||||
|
const host = mkHost();
|
||||||
|
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
|
||||||
|
const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode;
|
||||||
|
const grafted = wrapper.children[0] as RawNode;
|
||||||
|
assert.equal(grafted.computed.position, "absolute");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demotes frame `position: sticky` to `relative` (no sticking to the page scroll)", () => {
|
||||||
|
const bar = raw("div", { class: "sticky-bar" }, [], {
|
||||||
|
computed: { display: "block", position: "sticky" },
|
||||||
|
bbox: { x: 0, y: 0, width: 320, height: 40 },
|
||||||
|
});
|
||||||
|
const frame = pageSnap(raw("body", {}, [bar]), "https://frame.test/embed");
|
||||||
|
const host = mkHost();
|
||||||
|
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
|
||||||
|
const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode;
|
||||||
|
const grafted = wrapper.children[0] as RawNode;
|
||||||
|
assert.equal(grafted.computed.position, "relative");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes a `static` host to `relative` so demoted absolutes anchor to the frame box", () => {
|
||||||
|
const host = mkHost(); // iframe host computed.position defaults to "static"
|
||||||
|
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||||
|
assert.equal(findFrameNode(host.root, 0)!.computed.position, "relative");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an already-positioned host's position untouched", () => {
|
||||||
|
const host = pageSnap(raw("body", {}, [
|
||||||
|
raw("iframe", { "data-ditto-frame": "0", src: "https://frame.test/embed" }, [], {
|
||||||
|
computed: { display: "block", position: "absolute" },
|
||||||
|
bbox: { x: 40, y: 30, width: 320, height: 160 },
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||||
|
assert.equal(findFrameNode(host.root, 0)!.computed.position, "absolute");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Cross-origin integration: capture → snapshot graft → IR → generated tag ----
|
// ---- Cross-origin integration: capture → snapshot graft → IR → generated tag ----
|
||||||
|
|||||||
Reference in New Issue
Block a user