| 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]); |
|
|
| |
| 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"; |
|
|