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(null); const previewRef = useRef(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 = `
${svgHtml}
`; }, [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 (
{/* Header */}
◈ Mermaid
{/* Code editor (shown when editing) */} {editing && (