import React, { 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"; import { buildDoc, DEFAULT_EMBED_HEIGHT } from "./build-doc"; import type { EmbedStore } from "./embed-store"; import { useTheme } from "../../hooks/useTheme"; /** * Resolve embed store from the editor's storage. * The store is injected by Editor.tsx into editor.storage.htmlEmbed.embedStore. */ function useEmbedStore(editor: NodeViewProps["editor"]): EmbedStore | null { return (editor.storage.htmlEmbed as any)?.embedStore ?? null; } function FieldRow({ field, value, onChange, }: { field: ComponentField; value: unknown; onChange: (val: unknown) => void; }) { if (field.type === "boolean") { return ( ); } return (
{field.label} onChange(e.target.value)} className="embed-field-input" />
); } /** Safely parse the stored height attribute (legacy: string; new: number). */ 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_EMBED_HEIGHT; } export function makeHtmlEmbedView(def: ComponentDef) { function HtmlEmbedNodeView({ node, updateAttributes, editor }: NodeViewProps) { const src = (node.attrs.src as string) || ""; const title = (node.attrs.title as string) || ""; const storedHeight = parseStoredHeight(node.attrs.height); const { isDark, primaryColor } = useTheme(); const embedStore = useEmbedStore(editor); const [html, setHtml] = useState(""); const [iframeHeight, setIframeHeight] = useState(storedHeight); const [showSettings, setShowSettings] = useState(false); const iframeRef = useRef(null); // Sync from embed store useEffect(() => { if (!embedStore || !src) return; setHtml(embedStore.get(src)); return embedStore.observeKey(src, setHtml); }, [embedStore, src]); // Build full document for srcdoc. // NOTE: only depends on `html` – theme changes are pushed via postMessage // below so we don't reload the iframe (which would lose chart state). const srcdoc = useMemo(() => { if (!html) return ""; return buildDoc(html, { isDark, primaryColor }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [html]); // Hot-swap theme inside the iframe without reload. useEffect(() => { const frame = iframeRef.current; if (!frame || !html) return; const win = frame.contentWindow; if (!win) return; const send = () => { try { win.postMessage( { type: "setTheme", theme: isDark ? "dark" : "light", primaryColor, }, "*", ); } catch { /* cross-origin / not yet loaded – will retry on load */ } }; send(); const onLoad = () => send(); frame.addEventListener("load", onLoad); return () => frame.removeEventListener("load", onLoad); }, [isDark, primaryColor, html]); // Listen for height reports from the iframe. // - Apply immediately to the visible iframe (React state) for smooth resize. // - Debounce the PM transaction (node attribute) so document history isn't // polluted with noisy updates during animations. const lastPersistedRef = useRef(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: h }); } }, 800); } }; window.addEventListener("message", handler); return () => { window.removeEventListener("message", handler); clearTimeout(persistTimerRef.current); }; }, [updateAttributes]); // Reset persisted tracker when the underlying node attribute changes from // the outside (undo/redo, collab remote update). useEffect(() => { lastPersistedRef.current = storedHeight; }, [storedHeight]); const handleFieldChange = useCallback( (fieldName: string, value: unknown) => { updateAttributes({ [fieldName]: value }); }, [updateAttributes], ); const hasContent = !!html; // Metadata fields (exclude height - managed automatically from iframe) const editableFields = def.fields.filter((f) => f.name !== "height"); return (
{/* Header */}
📊 {title || src || "HTML Embed"} {src && title && ( {src} )}
{hasContent && ( )}
{/* Settings panel */} {showSettings && (
{editableFields.map((f) => ( handleFieldChange(f.name, v)} /> ))}
)} {/* Preview */} {hasContent ? (