tfrere's picture
tfrere HF Staff
feat(frontend): editor refresh (embed studio, comment popover, shiki, top bar, hooks, styles)
76fc93a
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;
}