| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| useEffect(() => { |
| setIframeHeight(storedHeight); |
| }, [storedHeight]); |
|
|
| |
| 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), []); |
|
|
| |
| |
| 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; |
| } |
|
|