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 } from "react";
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import mermaid from "mermaid";
let currentMermaidTheme: string | null = null;
function getMermaidTheme(): "dark" | "neutral" {
return document.documentElement.getAttribute("data-theme") === "dark"
? "dark"
: "neutral";
}
function ensureMermaidInit(forceReinit = false) {
const desired = getMermaidTheme();
if (currentMermaidTheme === desired && !forceReinit) return;
mermaid.initialize({
startOnLoad: false,
theme: desired,
securityLevel: "loose",
fontFamily: "var(--default-font-family)",
});
currentMermaidTheme = desired;
}
export function MermaidNodeView({ node, updateAttributes }: NodeViewProps) {
const code = (node.attrs.code as string) || "";
const [editing, setEditing] = useState(!code);
const [draft, setDraft] = useState(code);
const [svgHtml, setSvgHtml] = useState("");
const [error, setError] = useState("");
const renderIdRef = useRef(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!editing) setDraft(code);
}, [code, editing]);
useEffect(() => {
if (editing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.selectionStart = textareaRef.current.value.length;
}
}, [editing]);
const renderDiagram = useCallback(async (src: string) => {
if (!src.trim()) {
setSvgHtml("");
setError("");
return;
}
ensureMermaidInit();
const id = `mermaid-${++renderIdRef.current}`;
try {
const { svg } = await mermaid.render(id, src);
setSvgHtml(svg);
setError("");
} catch (err: any) {
setError(err?.message || "Invalid diagram");
const stale = document.getElementById(id);
stale?.remove();
}
}, []);
useEffect(() => {
renderDiagram(code);
}, [code, renderDiagram]);
// Update shadow DOM only when svgHtml changes (not on every render)
useEffect(() => {
const el = previewRef.current;
if (!el || !svgHtml) return;
const shadow = el.shadowRoot ?? el.attachShadow({ mode: "open" });
shadow.innerHTML = `<div style="display:flex;justify-content:center">${svgHtml}</div>`;
}, [svgHtml]);
useEffect(() => {
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName === "data-theme") {
ensureMermaidInit(true);
renderDiagram(code);
break;
}
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return () => observer.disconnect();
}, [code, renderDiagram]);
const commit = useCallback(() => {
setEditing(false);
if (draft !== code) {
updateAttributes({ code: draft });
}
}, [draft, code, updateAttributes]);
return (
<NodeViewWrapper data-component="mermaid">
<div
contentEditable={false}
style={{
border: "1px solid var(--border-color)",
borderRadius: 8,
overflow: "hidden",
margin: "0.75em 0",
userSelect: "none",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 12px",
borderBottom: "1px solid var(--border-color)",
background: "var(--surface-bg)",
}}
>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--muted-color)" }}>
◈ Mermaid
</span>
<button
onClick={() => {
if (editing) commit();
else setEditing(true);
}}
style={{
background: "none",
border: "1px solid var(--border-color)",
borderRadius: 4,
padding: "2px 10px",
fontSize: 11,
color: "var(--muted-color)",
cursor: "pointer",
}}
>
{editing ? "Done" : "Edit"}
</button>
</div>
{/* Code editor (shown when editing) */}
{editing && (
<div style={{ borderBottom: "1px solid var(--border-color)" }}>
<textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
setDraft(code);
setEditing(false);
}
if (e.key === "Tab") {
e.preventDefault();
const ta = e.currentTarget;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const val = ta.value;
const updated = val.substring(0, start) + " " + val.substring(end);
setDraft(updated);
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + 2;
});
}
}}
spellCheck={false}
style={{
width: "100%",
minHeight: 120,
padding: "12px 16px",
border: "none",
outline: "none",
resize: "vertical",
fontFamily: "var(--font-mono)",
fontSize: 13,
lineHeight: 1.5,
background: "var(--code-bg)",
color: "var(--text-color)",
boxSizing: "border-box",
}}
/>
</div>
)}
{/* Preview */}
{error ? (
<div style={{ padding: "16px", color: "var(--danger-color, #dc2626)", fontSize: 12 }}>
{error}
</div>
) : svgHtml ? (
<div
style={{ padding: "16px", display: "flex", justifyContent: "center" }}
ref={previewRef}
/>
) : (
<div style={{ padding: "24px", textAlign: "center", color: "var(--muted-color)", fontSize: 13 }}>
Click "Edit" to write a Mermaid diagram
</div>
)}
</div>
</NodeViewWrapper>
);
}
MermaidNodeView.displayName = "MermaidView";