| 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"; |
|
|
| |
| |
| |
| |
| 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> |
| ); |
| } |
|
|
| |
| 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); |
|
|
| |
| useEffect(() => { |
| if (!embedStore || !src) return; |
| setHtml(embedStore.get(src)); |
| return embedStore.observeKey(src, setHtml); |
| }, [embedStore, src]); |
|
|
| |
| |
| |
| const srcdoc = useMemo(() => { |
| if (!html) return ""; |
| return buildDoc(html, { isDark, primaryColor }); |
| |
| }, [html]); |
|
|
| |
| 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 { |
| |
| } |
| }; |
| send(); |
| const onLoad = () => send(); |
| frame.addEventListener("load", onLoad); |
| return () => frame.removeEventListener("load", onLoad); |
| }, [isDark, primaryColor, html]); |
|
|
| |
| |
| |
| |
| 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]); |
|
|
| |
| |
| useEffect(() => { |
| lastPersistedRef.current = storedHeight; |
| }, [storedHeight]); |
|
|
| const handleFieldChange = useCallback( |
| (fieldName: string, value: unknown) => { |
| updateAttributes({ [fieldName]: value }); |
| }, |
| [updateAttributes], |
| ); |
|
|
| const hasContent = !!html; |
|
|
| |
| 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; |
| } |
|
|