Spaces:
Sleeping
Sleeping
| 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; | |
| } | |