tfrere's picture
tfrere HF Staff
feat(editor): Iframe embed component for remote URLs
0c69852
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import type { ComponentDef, ComponentField } from "../components/registry";
const DEFAULT_IFRAME_HEIGHT = 600;
const SAFETY_MIN_HEIGHT = 80;
function FieldRow({
field,
value,
onChange,
}: {
field: ComponentField;
value: unknown;
onChange: (val: unknown) => void;
}) {
if (field.type === "boolean") {
return (
<label className="embed-field-row embed-field-checkbox">
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
/>
{field.label}
</label>
);
}
return (
<div className="embed-field-row">
<span className="embed-field-label">{field.label}</span>
<input
type="text"
value={String(value ?? "")}
placeholder={field.placeholder || field.label}
onChange={(e) => onChange(e.target.value)}
className="embed-field-input"
/>
</div>
);
}
function parseStoredHeight(raw: unknown): number {
if (typeof raw === "number" && raw > 0) return Math.round(raw);
const n = parseInt(String(raw ?? ""), 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_IFRAME_HEIGHT;
}
/**
* NodeView for `<div data-component="iframe">`.
*
* Renders a live preview of the remote URL inside an `<iframe src="...">`.
* Auto-resizes when the embedded page sends `postMessage({ type: "embedResize", height })`
* (same protocol as `HtmlEmbedView` so our own widgets work out of the box),
* otherwise falls back to the manual `height` attribute.
*
* No content is stored in `Y.Map("embeds")` because the HTML lives at the
* remote URL; only node attributes (src, title, desc, height, wide) travel
* through the document.
*/
export function makeIframeEmbedView(def: ComponentDef) {
function IframeEmbedNodeView({ node, updateAttributes }: NodeViewProps) {
const src = String(node.attrs.src || "").trim();
const title = String(node.attrs.title || "");
const storedHeight = parseStoredHeight(node.attrs.height);
const [iframeHeight, setIframeHeight] = useState(storedHeight);
const [showSettings, setShowSettings] = useState(!src);
const [reloadToken, setReloadToken] = useState(0);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Keep iframe height in sync with the stored attribute when it changes
// from elsewhere (undo/redo, settings panel edit).
useEffect(() => {
setIframeHeight(storedHeight);
}, [storedHeight]);
// Listen for height reports from same-origin/cooperating iframes.
const lastPersistedRef = useRef<number>(storedHeight);
const persistTimerRef = useRef(0);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data?.type !== "embedResize") return;
const frame = iframeRef.current;
if (!frame || e.source !== frame.contentWindow) return;
const h = Math.max(0, Math.ceil(e.data.height));
if (!h) return;
setIframeHeight((prev) => (prev === h ? prev : h));
if (h !== lastPersistedRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = window.setTimeout(() => {
if (h !== lastPersistedRef.current) {
lastPersistedRef.current = h;
updateAttributes({ height: String(h) });
}
}, 800);
}
};
window.addEventListener("message", handler);
return () => {
window.removeEventListener("message", handler);
clearTimeout(persistTimerRef.current);
};
}, [updateAttributes]);
const handleFieldChange = useCallback(
(fieldName: string, value: unknown) => {
updateAttributes({ [fieldName]: value });
},
[updateAttributes],
);
const reload = useCallback(() => setReloadToken((n) => n + 1), []);
// `key` on the iframe forces a remount when src changes or on reload,
// which works around cross-origin pages that don't expose the History API.
const iframeKey = useMemo(() => `${src}#${reloadToken}`, [src, reloadToken]);
const hasSrc = !!src;
return (
<NodeViewWrapper data-component="iframe">
<div contentEditable={false} className="embed-view">
{/* Header */}
<div className="embed-header">
<div className="embed-header-left">
<span className="embed-header-icon">{def.icon}</span>
<span className="embed-header-label">
{title || src || "Iframe"}
</span>
{src && title && (
<span className="embed-header-src">{src}</span>
)}
</div>
<div className="embed-header-actions">
{hasSrc && (
<button
className="embed-btn"
onClick={reload}
title="Reload iframe"
aria-label="Reload iframe"
>
Reload
</button>
)}
<button
className="embed-btn"
onClick={() => setShowSettings(!showSettings)}
title="Settings"
>
{showSettings ? "Close" : "Settings"}
</button>
</div>
</div>
{/* Settings panel */}
{showSettings && (
<div className="embed-settings">
{def.fields.map((f) => (
<FieldRow
key={f.name}
field={f}
value={node.attrs[f.name]}
onChange={(v) => handleFieldChange(f.name, v)}
/>
))}
</div>
)}
{/* Preview */}
{hasSrc ? (
<div className="embed-preview">
<iframe
key={iframeKey}
ref={iframeRef}
src={src}
title={title || src}
className="embed-iframe"
referrerPolicy="no-referrer-when-downgrade"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture"
style={{
height: iframeHeight,
minHeight: Math.min(storedHeight, SAFETY_MIN_HEIGHT),
}}
/>
</div>
) : (
<div className="embed-empty">
<span className="embed-empty-icon">{def.icon}</span>
<span>Enter a URL in settings to embed a page</span>
<button
className="embed-btn embed-btn-primary"
onClick={() => setShowSettings(true)}
>
Open Settings
</button>
</div>
)}
</div>
</NodeViewWrapper>
);
}
IframeEmbedNodeView.displayName = "IframeEmbedView";
return IframeEmbedNodeView;
}