tfrere HF Staff Cursor commited on
Commit
0c69852
·
1 Parent(s): bf08f85

feat(editor): Iframe embed component for remote URLs

Browse files

Adds a new atomic component `Iframe` (slash menu icon 🔗) that embeds an
arbitrary remote URL via `<iframe src=...>` instead of inlining HTML
from the embed store. Use case: integrating widget Spaces (e.g. carbon-
tokenization-widgets) without having to download and adapt their HTML
to the strict create-html-embed conventions.

- Shared component def (atomic, fields: src, title, desc, height, wide)
- Custom NodeView with live iframe preview, Settings + Reload buttons,
auto-resize when the remote page emits postMessage({type:"embedResize"})
- Publisher transformer emits <figure class="html-embed"> reusing the
existing iframe/figcaption markup so styling and figure numbering
carry over for free
- llms.txt renderer surfaces the URL as a Markdown link
- 3 publisher tests covering happy path, empty-src drop, and wide flag

Co-authored-by: Cursor <cursoragent@cursor.com>

backend/src/publisher/markdown-renderer.ts CHANGED
@@ -279,6 +279,16 @@ function renderBlock(node: JSONNode, ctx: RenderCtx): string {
279
  return `*[Interactive visualization: ${label}]*`;
280
  }
281
 
 
 
 
 
 
 
 
 
 
 
282
  case "hfUser": {
283
  const username = String(node.attrs?.username || "").trim();
284
  if (!username) return "";
 
279
  return `*[Interactive visualization: ${label}]*`;
280
  }
281
 
282
+ case "iframe": {
283
+ const src = String(node.attrs?.src || "").trim();
284
+ const title = String(node.attrs?.title || "").trim();
285
+ const desc = String(node.attrs?.desc || "").trim();
286
+ if (!src) return "";
287
+ const label = title || desc || src;
288
+ // Surface the URL so LLM agents and crawlers can follow it.
289
+ return `*[Embedded page: [${label}](${src})]*`;
290
+ }
291
+
292
  case "hfUser": {
293
  const username = String(node.attrs?.username || "").trim();
294
  if (!username) return "";
backend/src/publisher/transformers/iframe-embed.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Transformer } from "./types.js";
2
+
3
+ /**
4
+ * Transforms `<div data-component="iframe" src="..." title="..." desc="..." height="..." wide="true">`
5
+ * into:
6
+ * <figure class="html-embed" data-iframe-src="...">
7
+ * <div class="html-embed-container">
8
+ * <iframe src="..." title="..." ... />
9
+ * </div>
10
+ * <figcaption class="html-embed__desc">...</figcaption>
11
+ * </figure>
12
+ *
13
+ * Reuses the same outer markup as `html-embed.ts` so the existing publisher
14
+ * CSS (`figure.html-embed`, `.html-embed-container`, `.html-embed__desc`)
15
+ * styles the figure, numbers it, and lays it out responsively.
16
+ *
17
+ * Differences with htmlEmbed:
18
+ * - `src=` is a remote URL, not a Y.Map key, so we never inline `srcdoc`.
19
+ * - No sandbox attribute: arbitrary third-party pages often need cookies,
20
+ * popups, or storage. Authors who need to lock things down can use
21
+ * RawHtml with a hand-rolled iframe.
22
+ * - `wide` toggles the `html-embed--wide` modifier (full-bleed layout).
23
+ */
24
+ const DEFAULT_IFRAME_HEIGHT = 600;
25
+ const SAFETY_MIN_HEIGHT = 80;
26
+
27
+ export const iframeEmbedTransformer: Transformer = {
28
+ name: "iframe",
29
+ apply(document) {
30
+ for (const div of [...document.querySelectorAll('div[data-component="iframe"]')]) {
31
+ const src = (div.getAttribute("src") || div.getAttribute("data-src") || "").trim();
32
+ const title = div.getAttribute("title") || div.getAttribute("data-title") || "";
33
+ const desc = div.getAttribute("desc") || div.getAttribute("data-desc") || "";
34
+ const rawHeight = div.getAttribute("height") || div.getAttribute("data-height");
35
+ const wide =
36
+ div.getAttribute("wide") === "true" ||
37
+ div.getAttribute("data-wide") === "true";
38
+
39
+ const initialHeight = Math.max(
40
+ parseInt(rawHeight || "", 10) || DEFAULT_IFRAME_HEIGHT,
41
+ SAFETY_MIN_HEIGHT,
42
+ );
43
+
44
+ // Drop iframes without a src - publishing a broken empty frame is worse
45
+ // than silently omitting the placeholder.
46
+ if (!src) {
47
+ div.remove();
48
+ continue;
49
+ }
50
+
51
+ const figure = document.createElement("figure");
52
+ figure.className = wide ? "html-embed html-embed--wide" : "html-embed";
53
+ figure.setAttribute("data-iframe-src", src);
54
+
55
+ const container = document.createElement("div");
56
+ container.className = "html-embed-container";
57
+
58
+ const iframe = document.createElement("iframe");
59
+ iframe.setAttribute("src", src);
60
+ iframe.setAttribute(
61
+ "style",
62
+ `width:100%;border:none;display:block;height:${initialHeight}px;min-height:${SAFETY_MIN_HEIGHT}px;background:transparent;`,
63
+ );
64
+ iframe.setAttribute("loading", "lazy");
65
+ iframe.setAttribute("referrerpolicy", "no-referrer-when-downgrade");
66
+ iframe.setAttribute(
67
+ "allow",
68
+ "accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture",
69
+ );
70
+ if (title) iframe.setAttribute("title", title);
71
+
72
+ container.appendChild(iframe);
73
+ figure.appendChild(container);
74
+
75
+ if (title || desc) {
76
+ const caption = document.createElement("figcaption");
77
+ caption.className = "html-embed__desc";
78
+ if (title && desc) {
79
+ const strong = document.createElement("strong");
80
+ strong.textContent = title;
81
+ caption.appendChild(strong);
82
+ caption.appendChild(document.createTextNode(" " + desc));
83
+ } else {
84
+ caption.textContent = title || desc;
85
+ }
86
+ figure.appendChild(caption);
87
+ }
88
+
89
+ div.replaceWith(figure);
90
+ }
91
+ },
92
+ };
backend/src/publisher/transformers/index.ts CHANGED
@@ -14,9 +14,11 @@
14
  * the mermaid source.
15
  * 5. HighlightCode — runs Shiki over every remaining `<pre><code>`.
16
  * 6. HtmlEmbed — independent; converts placeholder divs into iframes.
17
- * 7. HfUser — independent; converts atomic placeholder divs into
 
 
18
  * Hugging Face user profile cards.
19
- * 8. Footnote — runs last so collected texts include those inside every
20
  * other transformed block (tables, callouts, accordions...).
21
  */
22
  import type { Transformer } from "./types.js";
@@ -26,6 +28,7 @@ import { bibliographyTransformer } from "./bibliography.js";
26
  import { mermaidTransformer } from "./mermaid.js";
27
  import { highlightCodeTransformer } from "./highlight-code.js";
28
  import { htmlEmbedTransformer } from "./html-embed.js";
 
29
  import { hfUserTransformer } from "./hf-user.js";
30
  import { footnoteTransformer } from "./footnote.js";
31
 
@@ -36,6 +39,7 @@ export const transformers: Transformer[] = [
36
  mermaidTransformer,
37
  highlightCodeTransformer,
38
  htmlEmbedTransformer,
 
39
  hfUserTransformer,
40
  footnoteTransformer,
41
  ];
 
14
  * the mermaid source.
15
  * 5. HighlightCode — runs Shiki over every remaining `<pre><code>`.
16
  * 6. HtmlEmbed — independent; converts placeholder divs into iframes.
17
+ * 7. IframeEmbed — independent; converts placeholder divs pointing at a
18
+ * remote URL into a standard `<figure><iframe src=...>`.
19
+ * 8. HfUser — independent; converts atomic placeholder divs into
20
  * Hugging Face user profile cards.
21
+ * 9. Footnote — runs last so collected texts include those inside every
22
  * other transformed block (tables, callouts, accordions...).
23
  */
24
  import type { Transformer } from "./types.js";
 
28
  import { mermaidTransformer } from "./mermaid.js";
29
  import { highlightCodeTransformer } from "./highlight-code.js";
30
  import { htmlEmbedTransformer } from "./html-embed.js";
31
+ import { iframeEmbedTransformer } from "./iframe-embed.js";
32
  import { hfUserTransformer } from "./hf-user.js";
33
  import { footnoteTransformer } from "./footnote.js";
34
 
 
39
  mermaidTransformer,
40
  highlightCodeTransformer,
41
  htmlEmbedTransformer,
42
+ iframeEmbedTransformer,
43
  hfUserTransformer,
44
  footnoteTransformer,
45
  ];
backend/src/shared/component-defs.ts CHANGED
@@ -78,6 +78,17 @@ export const SHARED_COMPONENT_DEFS: SharedComponentDef[] = [
78
  { name: "url", type: "string", default: "" },
79
  ],
80
  },
 
 
 
 
 
 
 
 
 
 
 
81
  {
82
  name: "rawHtml",
83
  kind: "atomic",
 
78
  { name: "url", type: "string", default: "" },
79
  ],
80
  },
81
+ {
82
+ name: "iframe",
83
+ kind: "atomic",
84
+ fields: [
85
+ { name: "src", type: "string", default: "" },
86
+ { name: "title", type: "string", default: "" },
87
+ { name: "desc", type: "string", default: "" },
88
+ { name: "height", type: "string", default: "600" },
89
+ { name: "wide", type: "boolean", default: false },
90
+ ],
91
+ },
92
  {
93
  name: "rawHtml",
94
  kind: "atomic",
backend/tests/publisher.test.ts CHANGED
@@ -171,6 +171,62 @@ describe("1.3 Post-processing", () => {
171
  expect(html).toContain("chart.html");
172
  });
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  it("1.3.3 transforms mermaid to pre block", async () => {
175
  const docJson = {
176
  type: "doc",
 
171
  expect(html).toContain("chart.html");
172
  });
173
 
174
+ it("1.3.2b transforms iframe component to figure + iframe with src", async () => {
175
+ const docJson = {
176
+ type: "doc",
177
+ content: [
178
+ {
179
+ type: "iframe",
180
+ attrs: {
181
+ src: "https://my-space.hf.space/",
182
+ title: "Tokenization demo",
183
+ desc: "Interactive widget",
184
+ height: "500",
185
+ wide: false,
186
+ },
187
+ },
188
+ ],
189
+ };
190
+ const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS);
191
+ expect(html).toMatch(/<figure[^>]*\bclass="html-embed"/);
192
+ expect(html).toContain('data-iframe-src="https://my-space.hf.space/"');
193
+ expect(html).toContain('src="https://my-space.hf.space/"');
194
+ expect(html).toContain('height:500px');
195
+ expect(html).toContain('title="Tokenization demo"');
196
+ expect(html).toContain("Interactive widget");
197
+ // Placeholder div must be gone
198
+ expect(html).not.toContain('data-component="iframe"');
199
+ });
200
+
201
+ it("1.3.2c drops iframe component without src", async () => {
202
+ const docJson = {
203
+ type: "doc",
204
+ content: [
205
+ {
206
+ type: "iframe",
207
+ attrs: { src: "", title: "Empty", desc: "", height: "600", wide: false },
208
+ },
209
+ ],
210
+ };
211
+ const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS);
212
+ expect(html).not.toContain('data-component="iframe"');
213
+ expect(html).not.toContain('<iframe');
214
+ });
215
+
216
+ it("1.3.2d iframe with wide flag gets the wide modifier class", async () => {
217
+ const docJson = {
218
+ type: "doc",
219
+ content: [
220
+ {
221
+ type: "iframe",
222
+ attrs: { src: "https://example.com", title: "", desc: "", height: "600", wide: true },
223
+ },
224
+ ],
225
+ };
226
+ const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS);
227
+ expect(html).toMatch(/<figure[^>]*\bclass="html-embed html-embed--wide"/);
228
+ });
229
+
230
  it("1.3.3 transforms mermaid to pre block", async () => {
231
  const docJson = {
232
  type: "doc",
frontend/src/editor/components/factory.ts CHANGED
@@ -14,6 +14,7 @@ import { makeAtomicView } from "./AtomicView";
14
  import { MermaidNodeView } from "./MermaidView";
15
  import { HfUserNodeView } from "./HfUserView";
16
  import { makeHtmlEmbedView } from "../embeds/HtmlEmbedView";
 
17
 
18
  function buildAttrSchema(fields: ComponentDef["fields"]) {
19
  const attrs: Record<string, { default: unknown }> = {};
@@ -81,6 +82,7 @@ export function createComponentExtension(def: ComponentDef) {
81
  if (def.name === "mermaid") View = MermaidNodeView;
82
  else if (def.name === "hfUser") View = HfUserNodeView;
83
  else if (def.name === "htmlEmbed") View = makeHtmlEmbedView(def);
 
84
  else View = makeAtomicView(def);
85
  return ReactNodeViewRenderer(View);
86
  },
 
14
  import { MermaidNodeView } from "./MermaidView";
15
  import { HfUserNodeView } from "./HfUserView";
16
  import { makeHtmlEmbedView } from "../embeds/HtmlEmbedView";
17
+ import { makeIframeEmbedView } from "../embeds/IframeEmbedView";
18
 
19
  function buildAttrSchema(fields: ComponentDef["fields"]) {
20
  const attrs: Record<string, { default: unknown }> = {};
 
82
  if (def.name === "mermaid") View = MermaidNodeView;
83
  else if (def.name === "hfUser") View = HfUserNodeView;
84
  else if (def.name === "htmlEmbed") View = makeHtmlEmbedView(def);
85
+ else if (def.name === "iframe") View = makeIframeEmbedView(def);
86
  else View = makeAtomicView(def);
87
  return ReactNodeViewRenderer(View);
88
  },
frontend/src/editor/components/registry.ts CHANGED
@@ -85,6 +85,16 @@ const UI_META: Record<string, UIMeta> = {
85
  tag: "HfUser", icon: "👤", label: "HF User card", description: "Hugging Face user profile card",
86
  fieldMeta: { username: { label: "Username", placeholder: "username" }, name: { label: "Display name", placeholder: "Full Name" }, url: { label: "URL", placeholder: "https://huggingface.co/username" } },
87
  },
 
 
 
 
 
 
 
 
 
 
88
  rawHtml: {
89
  tag: "RawHtml", icon: "</>", label: "Raw HTML", description: "Inject raw HTML content",
90
  fieldMeta: { html: { label: "HTML", placeholder: "<div>…</div>" } },
 
85
  tag: "HfUser", icon: "👤", label: "HF User card", description: "Hugging Face user profile card",
86
  fieldMeta: { username: { label: "Username", placeholder: "username" }, name: { label: "Display name", placeholder: "Full Name" }, url: { label: "URL", placeholder: "https://huggingface.co/username" } },
87
  },
88
+ iframe: {
89
+ tag: "Iframe", icon: "🔗", label: "Iframe", description: "Embed a remote URL (Space, widget, demo…)",
90
+ fieldMeta: {
91
+ src: { label: "URL", placeholder: "https://my-space.hf.space/" },
92
+ title: { label: "Title", placeholder: "Embed title…" },
93
+ desc: { label: "Description", placeholder: "Caption shown below…" },
94
+ height: { label: "Height (px)", placeholder: "600" },
95
+ wide: { label: "Wide" },
96
+ },
97
+ },
98
  rawHtml: {
99
  tag: "RawHtml", icon: "</>", label: "Raw HTML", description: "Inject raw HTML content",
100
  fieldMeta: { html: { label: "HTML", placeholder: "<div>…</div>" } },
frontend/src/editor/embeds/IframeEmbedView.tsx ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
+ import { NodeViewWrapper } from "@tiptap/react";
3
+ import type { NodeViewProps } from "@tiptap/react";
4
+ import type { ComponentDef, ComponentField } from "../components/registry";
5
+
6
+ const DEFAULT_IFRAME_HEIGHT = 600;
7
+ const SAFETY_MIN_HEIGHT = 80;
8
+
9
+ function FieldRow({
10
+ field,
11
+ value,
12
+ onChange,
13
+ }: {
14
+ field: ComponentField;
15
+ value: unknown;
16
+ onChange: (val: unknown) => void;
17
+ }) {
18
+ if (field.type === "boolean") {
19
+ return (
20
+ <label className="embed-field-row embed-field-checkbox">
21
+ <input
22
+ type="checkbox"
23
+ checked={!!value}
24
+ onChange={(e) => onChange(e.target.checked)}
25
+ />
26
+ {field.label}
27
+ </label>
28
+ );
29
+ }
30
+ return (
31
+ <div className="embed-field-row">
32
+ <span className="embed-field-label">{field.label}</span>
33
+ <input
34
+ type="text"
35
+ value={String(value ?? "")}
36
+ placeholder={field.placeholder || field.label}
37
+ onChange={(e) => onChange(e.target.value)}
38
+ className="embed-field-input"
39
+ />
40
+ </div>
41
+ );
42
+ }
43
+
44
+ function parseStoredHeight(raw: unknown): number {
45
+ if (typeof raw === "number" && raw > 0) return Math.round(raw);
46
+ const n = parseInt(String(raw ?? ""), 10);
47
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_IFRAME_HEIGHT;
48
+ }
49
+
50
+ /**
51
+ * NodeView for `<div data-component="iframe">`.
52
+ *
53
+ * Renders a live preview of the remote URL inside an `<iframe src="...">`.
54
+ * Auto-resizes when the embedded page sends `postMessage({ type: "embedResize", height })`
55
+ * (same protocol as `HtmlEmbedView` so our own widgets work out of the box),
56
+ * otherwise falls back to the manual `height` attribute.
57
+ *
58
+ * No content is stored in `Y.Map("embeds")` because the HTML lives at the
59
+ * remote URL; only node attributes (src, title, desc, height, wide) travel
60
+ * through the document.
61
+ */
62
+ export function makeIframeEmbedView(def: ComponentDef) {
63
+ function IframeEmbedNodeView({ node, updateAttributes }: NodeViewProps) {
64
+ const src = String(node.attrs.src || "").trim();
65
+ const title = String(node.attrs.title || "");
66
+ const storedHeight = parseStoredHeight(node.attrs.height);
67
+
68
+ const [iframeHeight, setIframeHeight] = useState(storedHeight);
69
+ const [showSettings, setShowSettings] = useState(!src);
70
+ const [reloadToken, setReloadToken] = useState(0);
71
+ const iframeRef = useRef<HTMLIFrameElement>(null);
72
+
73
+ // Keep iframe height in sync with the stored attribute when it changes
74
+ // from elsewhere (undo/redo, settings panel edit).
75
+ useEffect(() => {
76
+ setIframeHeight(storedHeight);
77
+ }, [storedHeight]);
78
+
79
+ // Listen for height reports from same-origin/cooperating iframes.
80
+ const lastPersistedRef = useRef<number>(storedHeight);
81
+ const persistTimerRef = useRef(0);
82
+ useEffect(() => {
83
+ const handler = (e: MessageEvent) => {
84
+ if (e.data?.type !== "embedResize") return;
85
+ const frame = iframeRef.current;
86
+ if (!frame || e.source !== frame.contentWindow) return;
87
+ const h = Math.max(0, Math.ceil(e.data.height));
88
+ if (!h) return;
89
+ setIframeHeight((prev) => (prev === h ? prev : h));
90
+ if (h !== lastPersistedRef.current) {
91
+ clearTimeout(persistTimerRef.current);
92
+ persistTimerRef.current = window.setTimeout(() => {
93
+ if (h !== lastPersistedRef.current) {
94
+ lastPersistedRef.current = h;
95
+ updateAttributes({ height: String(h) });
96
+ }
97
+ }, 800);
98
+ }
99
+ };
100
+ window.addEventListener("message", handler);
101
+ return () => {
102
+ window.removeEventListener("message", handler);
103
+ clearTimeout(persistTimerRef.current);
104
+ };
105
+ }, [updateAttributes]);
106
+
107
+ const handleFieldChange = useCallback(
108
+ (fieldName: string, value: unknown) => {
109
+ updateAttributes({ [fieldName]: value });
110
+ },
111
+ [updateAttributes],
112
+ );
113
+
114
+ const reload = useCallback(() => setReloadToken((n) => n + 1), []);
115
+
116
+ // `key` on the iframe forces a remount when src changes or on reload,
117
+ // which works around cross-origin pages that don't expose the History API.
118
+ const iframeKey = useMemo(() => `${src}#${reloadToken}`, [src, reloadToken]);
119
+
120
+ const hasSrc = !!src;
121
+
122
+ return (
123
+ <NodeViewWrapper data-component="iframe">
124
+ <div contentEditable={false} className="embed-view">
125
+ {/* Header */}
126
+ <div className="embed-header">
127
+ <div className="embed-header-left">
128
+ <span className="embed-header-icon">{def.icon}</span>
129
+ <span className="embed-header-label">
130
+ {title || src || "Iframe"}
131
+ </span>
132
+ {src && title && (
133
+ <span className="embed-header-src">{src}</span>
134
+ )}
135
+ </div>
136
+ <div className="embed-header-actions">
137
+ {hasSrc && (
138
+ <button
139
+ className="embed-btn"
140
+ onClick={reload}
141
+ title="Reload iframe"
142
+ aria-label="Reload iframe"
143
+ >
144
+ Reload
145
+ </button>
146
+ )}
147
+ <button
148
+ className="embed-btn"
149
+ onClick={() => setShowSettings(!showSettings)}
150
+ title="Settings"
151
+ >
152
+ {showSettings ? "Close" : "Settings"}
153
+ </button>
154
+ </div>
155
+ </div>
156
+
157
+ {/* Settings panel */}
158
+ {showSettings && (
159
+ <div className="embed-settings">
160
+ {def.fields.map((f) => (
161
+ <FieldRow
162
+ key={f.name}
163
+ field={f}
164
+ value={node.attrs[f.name]}
165
+ onChange={(v) => handleFieldChange(f.name, v)}
166
+ />
167
+ ))}
168
+ </div>
169
+ )}
170
+
171
+ {/* Preview */}
172
+ {hasSrc ? (
173
+ <div className="embed-preview">
174
+ <iframe
175
+ key={iframeKey}
176
+ ref={iframeRef}
177
+ src={src}
178
+ title={title || src}
179
+ className="embed-iframe"
180
+ referrerPolicy="no-referrer-when-downgrade"
181
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture"
182
+ style={{
183
+ height: iframeHeight,
184
+ minHeight: Math.min(storedHeight, SAFETY_MIN_HEIGHT),
185
+ }}
186
+ />
187
+ </div>
188
+ ) : (
189
+ <div className="embed-empty">
190
+ <span className="embed-empty-icon">{def.icon}</span>
191
+ <span>Enter a URL in settings to embed a page</span>
192
+ <button
193
+ className="embed-btn embed-btn-primary"
194
+ onClick={() => setShowSettings(true)}
195
+ >
196
+ Open Settings
197
+ </button>
198
+ </div>
199
+ )}
200
+ </div>
201
+ </NodeViewWrapper>
202
+ );
203
+ }
204
+
205
+ IframeEmbedNodeView.displayName = "IframeEmbedView";
206
+ return IframeEmbedNodeView;
207
+ }