File size: 7,083 Bytes
0c69852 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | 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;
}
|