Spaces:
Sleeping
Sleeping
| 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 ( | |
| <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> | |
| ); | |
| } | |
| /** 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<HTMLIFrameElement>(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<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: 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 ( | |
| <NodeViewWrapper data-component="htmlEmbed"> | |
| <div contentEditable={false} className="embed-view"> | |
| {/* Header */} | |
| <div className="embed-header"> | |
| <div className="embed-header-left"> | |
| <span className="embed-header-icon">📊</span> | |
| <span className="embed-header-label"> | |
| {title || src || "HTML Embed"} | |
| </span> | |
| {src && title && ( | |
| <span className="embed-header-src">{src}</span> | |
| )} | |
| </div> | |
| <div className="embed-header-actions"> | |
| <button | |
| className="embed-btn" | |
| onClick={() => setShowSettings(!showSettings)} | |
| title="Settings" | |
| > | |
| {showSettings ? "Close" : "Settings"} | |
| </button> | |
| {hasContent && ( | |
| <button | |
| className="embed-btn embed-btn-primary" | |
| onClick={() => { | |
| window.dispatchEvent( | |
| new CustomEvent("open-embed-studio", { detail: { src } }), | |
| ); | |
| }} | |
| title="Open Embed Studio" | |
| > | |
| Edit | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Settings panel */} | |
| {showSettings && ( | |
| <div className="embed-settings"> | |
| {editableFields.map((f) => ( | |
| <FieldRow | |
| key={f.name} | |
| field={f} | |
| value={node.attrs[f.name]} | |
| onChange={(v) => handleFieldChange(f.name, v)} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| {/* Preview */} | |
| {hasContent ? ( | |
| <div className="embed-preview"> | |
| <iframe | |
| ref={iframeRef} | |
| srcDoc={srcdoc} | |
| title={title || src || "Chart preview"} | |
| sandbox="allow-scripts allow-same-origin" | |
| className="embed-iframe" | |
| style={{ | |
| height: iframeHeight, | |
| minHeight: Math.min(storedHeight, 120), | |
| }} | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="embed-empty"> | |
| {src ? ( | |
| <> | |
| <span className="embed-empty-icon">📊</span> | |
| <span> | |
| No content for <code>{src}</code> | |
| </span> | |
| <button | |
| className="embed-btn embed-btn-primary" | |
| onClick={() => { | |
| window.dispatchEvent( | |
| new CustomEvent("open-embed-studio", { detail: { src } }), | |
| ); | |
| }} | |
| > | |
| Create Chart | |
| </button> | |
| </> | |
| ) : ( | |
| <> | |
| <span className="embed-empty-icon">📊</span> | |
| <span>Set a source filename in settings to link an embed</span> | |
| <button | |
| className="embed-btn" | |
| onClick={() => setShowSettings(true)} | |
| > | |
| Open Settings | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </NodeViewWrapper> | |
| ); | |
| } | |
| HtmlEmbedNodeView.displayName = "HtmlEmbedView"; | |
| return HtmlEmbedNodeView; | |
| } | |