Files
edgeever/apps/web/src/components/MermaidCodeBlock.tsx
T

205 lines
7.3 KiB
TypeScript

import { useEffect, useState } from "react";
import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { useTheme } from "./ThemeProvider";
import { copyTextToClipboard } from "@/lib/clipboard";
type MermaidModule = typeof import("mermaid")["default"];
type BeautifulMermaidModule = typeof import("beautiful-mermaid");
let mermaidModulePromise: Promise<MermaidModule> | null = null;
let mermaidRenderSequence = 0;
let beautifulMermaidModulePromise: Promise<BeautifulMermaidModule> | null = null;
const loadMermaid = () => {
if (!mermaidModulePromise) {
mermaidModulePromise = import("mermaid").then(({ default: mermaid }) => {
return mermaid;
});
}
return mermaidModulePromise;
};
const loadBeautifulMermaid = () => {
if (!beautifulMermaidModulePromise) {
beautifulMermaidModulePromise = import("beautiful-mermaid");
}
return beautifulMermaidModulePromise;
};
export const MermaidCodeBlock = ({ editor, node }: NodeViewProps) => {
const { t } = useTranslation();
const { mermaidTheme } = useTheme();
const language = typeof node.attrs.language === "string" ? node.attrs.language.toLowerCase() : "plaintext";
const source = node.textContent.trim();
const isMermaid = language === "mermaid";
const [svg, setSvg] = useState("");
const [sourceVisible, setSourceVisible] = useState(false);
const [renderState, setRenderState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle");
useEffect(() => {
if (copyState === "idle") return;
const timer = window.setTimeout(() => setCopyState("idle"), 1800);
return () => window.clearTimeout(timer);
}, [copyState]);
const handleCopy = async () => {
const copied = await copyTextToClipboard(node.textContent);
setCopyState(copied ? "copied" : "error");
};
useEffect(() => {
if (!isMermaid || !source) {
setSvg("");
setRenderState("idle");
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setRenderState("loading");
void loadBeautifulMermaid()
.then(({ renderMermaidSVG, THEMES }) => {
try {
return renderMermaidSVG(source, {
...THEMES[mermaidTheme],
transparent: true,
font: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
padding: 24,
});
} catch {
return null;
}
})
.then((beautifulSvg) => {
if (beautifulSvg) return beautifulSvg;
return loadMermaid().then(async (mermaid) => {
const palette = (await loadBeautifulMermaid()).THEMES[mermaidTheme];
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: "base",
themeVariables: {
background: palette.bg,
primaryColor: palette.surface ?? palette.bg,
primaryTextColor: palette.fg,
primaryBorderColor: palette.border ?? palette.fg,
lineColor: palette.line ?? palette.fg,
textColor: palette.fg,
mainBkg: palette.bg,
nodeBorder: palette.border ?? palette.fg,
edgeLabelBackground: palette.bg,
actorBkg: palette.surface ?? palette.bg,
actorBorder: palette.border ?? palette.fg,
actorTextColor: palette.fg,
signalColor: palette.line ?? palette.fg,
signalTextColor: palette.fg,
},
});
const valid = await mermaid.parse(source, { suppressErrors: true });
if (!valid) {
throw new Error("Invalid Mermaid diagram");
}
mermaidRenderSequence += 1;
const { svg: fallbackSvg } = await mermaid.render(`edgeever-mermaid-${mermaidRenderSequence}`, source);
return fallbackSvg;
});
})
.then((nextSvg) => {
if (!cancelled) {
setSvg(nextSvg);
setRenderState("ready");
}
})
.catch(() => {
if (!cancelled) {
setSvg("");
setRenderState("error");
}
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [isMermaid, mermaidTheme, source]);
return (
<NodeViewWrapper
className={isMermaid
? `edgeever-mermaid-code-block${sourceVisible ? " is-source-visible" : ""}`
: "edgeever-code-block"}
data-language={language}
>
<button
type="button"
className="edgeever-code-copy-button"
contentEditable={false}
aria-label={t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
title={t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleCopy();
}}
onMouseDown={(event) => event.preventDefault()}
>
{copyState === "copied"
? t("editorToolbar.codeCopied")
: copyState === "error"
? t("editorToolbar.codeCopyFailed")
: t("editorToolbar.copyCode")}
</button>
{isMermaid && (
<div
className="edgeever-mermaid-preview"
contentEditable={false}
onClick={() => editor.isEditable && setSourceVisible((visible) => !visible)}
onKeyDown={(event) => {
if (editor.isEditable && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setSourceVisible((visible) => !visible);
}
}}
aria-label={editor.isEditable
? `${t("editorToolbar.mermaidPreview")} · ${t("editorToolbar.mermaidSource")}`
: undefined}
role={editor.isEditable ? "button" : undefined}
tabIndex={editor.isEditable ? 0 : undefined}
>
{!source && <p className="edgeever-mermaid-message">{t("editorToolbar.mermaidEmpty")}</p>}
{source && renderState === "loading" && !svg && (
<p className="edgeever-mermaid-message">{t("editorToolbar.mermaidRendering")}</p>
)}
{renderState === "error" && (
<p className="edgeever-mermaid-error" role="alert">
{t("editorToolbar.mermaidInvalid")}
</p>
)}
{svg && (
<div
className="edgeever-mermaid-svg"
role="img"
aria-label={t("editorToolbar.mermaidPreview")}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)}
</div>
)}
<NodeViewContent
className={isMermaid ? "edgeever-code-source edgeever-mermaid-source" : "edgeever-code-source"}
role="textbox"
aria-label={isMermaid ? t("editorToolbar.mermaidSource") : undefined}
aria-multiline="true"
aria-readonly={!editor.isEditable}
/>
</NodeViewWrapper>
);
};