feat(editor): Iframe embed component for remote URLs
Browse filesAdds 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 +10 -0
- backend/src/publisher/transformers/iframe-embed.ts +92 -0
- backend/src/publisher/transformers/index.ts +6 -2
- backend/src/shared/component-defs.ts +11 -0
- backend/tests/publisher.test.ts +56 -0
- frontend/src/editor/components/factory.ts +2 -0
- frontend/src/editor/components/registry.ts +10 -0
- frontend/src/editor/embeds/IframeEmbedView.tsx +207 -0
|
@@ -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 "";
|
|
@@ -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 |
+
};
|
|
@@ -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.
|
|
|
|
|
|
|
| 18 |
* Hugging Face user profile cards.
|
| 19 |
-
*
|
| 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 |
];
|
|
@@ -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",
|
|
@@ -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",
|
|
@@ -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 |
},
|
|
@@ -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>" } },
|
|
@@ -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 |
+
}
|