Spaces:
Runtime error
Runtime error
| 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"; | |