Skip to content

Commit e3d4341

Browse files
aidenybaiOracle
authored andcommitted
Support Shadow DOM and iframe element grabbing
1 parent dc276b2 commit e3d4341

58 files changed

Lines changed: 2504 additions & 222 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/e2e-app-vite/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"typecheck": "tsc --noEmit"
1111
},
1212
"dependencies": {
13+
"@pierre/diffs": "^1.2.12",
1314
"@tanstack/react-table": "^8.21.3",
1415
"@tanstack/react-virtual": "^3.14.5",
1516
"react": "19.2.6",

apps/e2e-app-vite/src/App.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useState, useRef, useEffect } from "react";
22
import { PerfGrid } from "./perf-grid";
33
import { HeavyPage, parseHeavyView, type HeavyView } from "./perf-heavy/heavy-page";
4+
import { IframeFixture } from "./iframe-fixture";
45
import { OwnerStackCases } from "./owner-stack-cases";
6+
import { PierreDiffFixture, PierreDiffPreview } from "./pierre-diff-fixture";
7+
import { ShadowDomEdgeFixture } from "./shadow-dom-edge-fixture";
58

69
declare global {
710
interface Window {
@@ -802,6 +805,9 @@ const PointerEventsModalSection = () => {
802805
export default function App() {
803806
const perfConfig = usePerfGridConfig();
804807
const heavyView = usePerfHeavyView();
808+
const searchParams = new URLSearchParams(window.location.search);
809+
const isIframePreview = searchParams.has("iframe-preview");
810+
const isIframeDiffPreview = searchParams.has("iframe-diff-preview");
805811

806812
if (perfConfig) {
807813
return <PerfGrid rowCount={perfConfig.rowCount} columnCount={perfConfig.columnCount} />;
@@ -811,6 +817,18 @@ export default function App() {
811817
return <HeavyPage view={heavyView} />;
812818
}
813819

820+
if (isIframeDiffPreview) {
821+
return <PierreDiffPreview />;
822+
}
823+
824+
if (isIframePreview) {
825+
return (
826+
<main className="min-h-screen bg-slate-100 p-8">
827+
<IframeFixture />
828+
</main>
829+
);
830+
}
831+
814832
return (
815833
<div className="min-h-[200vh] p-12 flex flex-col gap-8 pb-32">
816834
<EdgeElements />
@@ -858,6 +876,12 @@ export default function App() {
858876

859877
<PointerEventsModalSection />
860878

879+
<PierreDiffFixture />
880+
881+
<ShadowDomEdgeFixture />
882+
883+
<IframeFixture />
884+
861885
<HiddenToggleSection />
862886

863887
<FiberSwapSection />
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
const IFRAME_SOURCE = `<!doctype html>
2+
<html>
3+
<head>
4+
<style>
5+
:root { color: #172033; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
6+
* { box-sizing: border-box; }
7+
body { margin: 0; background: #f4f6fb; }
8+
nav { display: flex; align-items: center; justify-content: space-between; padding: 18px 28px; background: white; border-bottom: 1px solid #e4e8f0; }
9+
nav strong { letter-spacing: -0.03em; }
10+
nav span { color: #68738a; font-size: 13px; }
11+
main { padding: 48px 28px; }
12+
.eyebrow { color: #5b5bd6; font-size: 12px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
13+
h1 { max-width: 620px; margin: 12px 0; font-size: 38px; line-height: 1.05; letter-spacing: -0.04em; }
14+
.lede { max-width: 560px; color: #5c667b; font-size: 16px; line-height: 1.6; }
15+
.actions { display: flex; gap: 10px; margin: 24px 0 36px; }
16+
button { padding: 11px 16px; border: 0; border-radius: 8px; background: #25263b; color: white; font: inherit; font-weight: 650; }
17+
.secondary { border: 1px solid #d7dce6; background: white; color: #30384a; }
18+
.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
19+
.feature { padding: 18px; border: 1px solid #e1e5ed; border-radius: 12px; background: white; }
20+
.feature b { display: block; margin-bottom: 6px; font-size: 14px; }
21+
.feature p { margin: 0; color: #727c90; font-size: 13px; line-height: 1.5; }
22+
</style>
23+
</head>
24+
<body>
25+
<nav>
26+
<strong>Northstar</strong>
27+
<span>Product preview</span>
28+
</nav>
29+
<main data-testid="iframe-main">
30+
<div class="eyebrow">Workspace intelligence</div>
31+
<h1>Make every project easier to understand.</h1>
32+
<p class="lede">A fictional product page rendered inside the preview canvas. Select any element here without leaving the editor shell.</p>
33+
<div class="actions">
34+
<button data-testid="iframe-target" style="border-radius: 8px 12px / 16px 20px">Start a workspace</button>
35+
<button class="secondary">View a demo</button>
36+
</div>
37+
<div class="features">
38+
<article class="feature"><b>Shared context</b><p>Keep decisions and references next to the work.</p></article>
39+
<article class="feature"><b>Fast handoffs</b><p>Give collaborators a clear place to begin.</p></article>
40+
<article class="feature"><b>Useful history</b><p>See how a project changed without digging.</p></article>
41+
</div>
42+
<iframe-shadow-fixture></iframe-shadow-fixture>
43+
</main>
44+
<script>
45+
customElements.define("iframe-shadow-fixture", class extends HTMLElement {
46+
connectedCallback() {
47+
if (this.shadowRoot) return
48+
this.attachShadow({ mode: "open" }).innerHTML =
49+
'<button data-testid="iframe-shadow-target">Iframe shadow target</button>'
50+
}
51+
})
52+
</script>
53+
</body>
54+
</html>`;
55+
56+
const SANDBOXED_IFRAME_SOURCE = `<!doctype html>
57+
<html>
58+
<body style="margin: 0; padding: 20px">
59+
<button data-testid="sandboxed-iframe-target" style="padding: 12px">
60+
Sandboxed opaque-origin target
61+
</button>
62+
</body>
63+
</html>`;
64+
65+
export const IframeFixture = () => (
66+
<section
67+
className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"
68+
data-testid="iframe-section"
69+
>
70+
<header className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
71+
<div>
72+
<p className="text-sm font-semibold text-slate-900">Canvas</p>
73+
<p className="text-xs text-slate-500">Fictional page builder fixture</p>
74+
</div>
75+
<span className="rounded-full bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">
76+
Preview ready
77+
</span>
78+
</header>
79+
80+
<div className="grid md:grid-cols-4">
81+
<aside className="border-b border-slate-800 bg-slate-950 p-4 text-slate-300 md:border-b-0 md:border-r">
82+
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500">Pages</p>
83+
<button
84+
type="button"
85+
className="mb-5 w-full rounded-md bg-slate-800 px-3 py-2 text-left text-sm font-medium text-white"
86+
>
87+
Landing page
88+
</button>
89+
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
90+
Sections
91+
</p>
92+
<div className="space-y-1 text-sm">
93+
<div className="rounded px-3 py-2 text-slate-200">Navigation</div>
94+
<div className="rounded px-3 py-2 text-slate-200">Hero</div>
95+
<div className="rounded px-3 py-2 text-slate-200">Feature grid</div>
96+
<div className="rounded bg-violet-500/10 px-3 py-2 text-violet-300">Code diff</div>
97+
</div>
98+
</aside>
99+
100+
<div className="bg-slate-100 p-4 md:col-span-3">
101+
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-500">
102+
<span className="h-2 w-2 rounded-full bg-emerald-500" />
103+
preview.local/landing
104+
</div>
105+
<iframe
106+
title="HTML page preview"
107+
data-testid="same-origin-iframe"
108+
srcDoc={IFRAME_SOURCE}
109+
className="h-[32rem] w-full rounded-lg border border-slate-300 bg-white shadow-sm"
110+
/>
111+
<div className="mt-4 overflow-hidden rounded-lg border border-slate-300 bg-slate-950 shadow-sm">
112+
<div className="flex items-center justify-between border-b border-slate-700 px-3 py-2 text-xs">
113+
<span className="font-medium text-slate-200">Pull request diff</span>
114+
<span className="text-slate-500">Same-origin iframe + Shadow DOM</span>
115+
</div>
116+
<iframe
117+
title="Pierre diff iframe preview"
118+
data-testid="iframe-pierre-diff"
119+
src="/?iframe-diff-preview"
120+
className="h-[28rem] w-full bg-slate-950"
121+
/>
122+
</div>
123+
</div>
124+
</div>
125+
126+
<div className="grid gap-4 border-t border-slate-200 p-4 md:grid-cols-2">
127+
<div>
128+
<p className="mb-2 text-xs font-medium text-slate-500">Scaled preview</p>
129+
<iframe
130+
title="Scaled same-origin React Grab fixture"
131+
data-testid="scaled-same-origin-iframe"
132+
srcDoc={IFRAME_SOURCE}
133+
className="h-48 w-full origin-top-left scale-90 rounded border bg-white"
134+
/>
135+
</div>
136+
<div>
137+
<p className="mb-2 text-xs font-medium text-slate-500">Isolated preview</p>
138+
<iframe
139+
title="Sandboxed opaque-origin React Grab fixture"
140+
data-testid="sandboxed-iframe"
141+
srcDoc={SANDBOXED_IFRAME_SOURCE}
142+
sandbox=""
143+
className="h-32 w-full rounded border bg-white"
144+
/>
145+
</div>
146+
</div>
147+
</section>
148+
);

apps/e2e-app-vite/src/main.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import { StrictMode } from "react";
22
import { createRoot } from "react-dom/client";
33
import { init, formatElementInfo } from "react-grab";
4+
import { freeze, unfreeze } from "react-grab/primitives";
45
import "./index.css";
56
import App from "./App.tsx";
67

78
declare global {
89
interface Window {
910
initReactGrab: typeof init;
1011
formatElementInfo: typeof formatElementInfo;
12+
freezeReactGrab: typeof freeze;
13+
unfreezeReactGrab: typeof unfreeze;
1114
}
1215
}
1316

1417
window.initReactGrab = init;
1518
window.formatElementInfo = formatElementInfo;
19+
window.freezeReactGrab = freeze;
20+
window.unfreezeReactGrab = unfreeze;
1621

1722
createRoot(document.getElementById("root")!).render(
1823
<StrictMode>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { MultiFileDiff } from "@pierre/diffs/react";
2+
3+
const OLD_FILE = {
4+
name: "small-input.tsx",
5+
contents: `import React, { ChangeEventHandler, useCallback, useEffect, useState } from 'react'
6+
7+
const SmallInput = () => <input aria-label="Small input" />
8+
9+
export default SmallInput
10+
`,
11+
};
12+
13+
const NEW_FILE = {
14+
name: "small-input.tsx",
15+
contents: `import React, { ChangeEventHandler, KeyboardEventHandler, useState } from 'react'
16+
17+
const SmallInput = () => <input aria-label="Small input" />
18+
19+
export default SmallInput
20+
`,
21+
};
22+
23+
export const PierreDiff = () => (
24+
<MultiFileDiff
25+
oldFile={OLD_FILE}
26+
newFile={NEW_FILE}
27+
options={{ theme: "pierre-dark", diffStyle: "split" }}
28+
disableWorkerPool
29+
/>
30+
);
31+
32+
export const PierreDiffFixture = () => (
33+
<section className="border rounded-lg p-4" data-testid="pierre-diff-section">
34+
<h2 className="text-lg font-bold mb-4">Pierre Shadow DOM Diff</h2>
35+
<PierreDiff />
36+
</section>
37+
);
38+
39+
export const PierreDiffPreview = () => (
40+
<main className="min-h-screen bg-slate-950 p-4 text-white">
41+
<header className="mb-3 flex items-center justify-between">
42+
<div>
43+
<p className="text-sm font-semibold">SmallInput.tsx</p>
44+
<p className="text-xs text-slate-500">Import cleanup</p>
45+
</div>
46+
<span className="rounded-full border border-violet-400/30 bg-violet-400/10 px-2 py-1 text-xs text-violet-300">
47+
Pierre
48+
</span>
49+
</header>
50+
<PierreDiff />
51+
</main>
52+
);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useEffect, useRef } from "react";
2+
3+
const initializedClosedShadowHosts = new WeakSet<HTMLDivElement>();
4+
5+
export const ShadowDomEdgeFixture = () => {
6+
const slottedHostRef = useRef<HTMLDivElement>(null);
7+
const closedHostRef = useRef<HTMLDivElement>(null);
8+
9+
useEffect(() => {
10+
const slottedHostElement = slottedHostRef.current;
11+
if (!slottedHostElement || slottedHostElement.shadowRoot) return;
12+
13+
slottedHostElement.attachShadow({ mode: "open" }).innerHTML = `
14+
<style>:host { display: block; padding: 12px; border: 1px solid #94a3b8; }</style>
15+
<slot name="nested-content"></slot>
16+
`;
17+
const nestedShadowHost = slottedHostElement.querySelector<HTMLElement>(
18+
"[data-testid='slotted-shadow-host']",
19+
);
20+
if (!nestedShadowHost) return;
21+
const nestedShadowButton = document.createElement("button");
22+
nestedShadowButton.textContent = "Nested slotted shadow target";
23+
nestedShadowButton.dataset.testid = "slotted-shadow-target";
24+
nestedShadowHost.attachShadow({ mode: "open" }).append(nestedShadowButton);
25+
}, []);
26+
27+
useEffect(() => {
28+
const closedHostElement = closedHostRef.current;
29+
if (!closedHostElement || initializedClosedShadowHosts.has(closedHostElement)) return;
30+
initializedClosedShadowHosts.add(closedHostElement);
31+
closedHostElement.attachShadow({ mode: "closed" }).innerHTML =
32+
'<button data-testid="closed-shadow-inner">Closed shadow target</button>';
33+
}, []);
34+
35+
return (
36+
<section className="border rounded-lg p-4" data-testid="shadow-edge-section">
37+
<h2 className="text-lg font-bold mb-4">Shadow DOM Edge Cases</h2>
38+
<div ref={slottedHostRef} data-testid="slotted-shadow-outer-host">
39+
<span slot="nested-content" data-testid="slotted-shadow-host">
40+
Slotted fallback
41+
</span>
42+
</div>
43+
<div
44+
ref={closedHostRef}
45+
className="mt-4 p-3 border rounded"
46+
data-testid="closed-shadow-host"
47+
/>
48+
</section>
49+
);
50+
};

packages/react-grab/e2e/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { REACT_GRAB_ATTRIBUTE_NAME as ATTRIBUTE_NAME } from "../src/utils/react-grab-attribute-name.js";
2+
export { Z_INDEX_OVERLAY } from "../src/constants.js";
23

34
export const SHIFT_LABEL_CLICK_ANCHOR_RATIO = 0.25;
45
export const SHIFT_LABEL_SECOND_CLICK_ANCHOR_RATIO = 0.75;
@@ -11,3 +12,10 @@ export const SHIFT_DRAG_PREVIEW_STEP_COUNT = 6;
1112
export const HOST_STYLE_DRAG_DISTANCE_PX = 100;
1213
export const HOST_STYLE_DRAG_STEP_COUNT = 4;
1314
export const SHIFT_PENDING_HOVER_STEP_COUNT = 8;
15+
export const POINTER_SETTLE_DELAY_MS = 32;
16+
export const IFRAME_TEST_POINTER_ID = 1;
17+
export const IFRAME_SCROLL_SETTLE_DELAY_MS = 150;
18+
export const IFRAME_SCROLL_DELTA_Y_PX = 200;
19+
export const SCALED_IFRAME_EXPECTED_BORDER_RADIUS = "7.2px 10.8px / 14.4px 18px";
20+
export const NON_UNIFORM_SCALED_IFRAME_EXPECTED_BORDER_RADIUS = "6px 9px / 8px 10px";
21+
export const SHADOW_HOVER_BACKGROUND_COLOR = "rgb(14, 165, 233)";

0 commit comments

Comments
 (0)