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

281 lines
11 KiB
TypeScript

import { useEffect, useState } from "react";
import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { Check, CircleAlert, Code2, Copy, Maximize2 } from "lucide-react";
import { MERMAID_THEME_PALETTES, useMermaidTheme } from "./ThemeProvider";
import { MermaidViewer } from "./MermaidViewer";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { copyTextToClipboard } from "@/lib/clipboard";
import { renderMermaidWithFallback } from "@/lib/mermaid-renderer";
import { getOfficialMermaidThemeVariables } from "@/lib/mermaid-theme";
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 } = useMermaidTheme();
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 [viewerOpen, setViewerOpen] = 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");
const palette = MERMAID_THEME_PALETTES[mermaidTheme];
const renderBeautiful = () => loadBeautifulMermaid()
.then(({ renderMermaidSVG, THEMES }) => renderMermaidSVG(source, {
...THEMES[mermaidTheme],
...palette,
transparent: true,
font: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
padding: 24,
}));
const renderOfficial = () => loadMermaid().then(async (mermaid) => {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: "base",
themeVariables: getOfficialMermaidThemeVariables(palette),
});
const valid = await mermaid.parse(source, { suppressErrors: true });
if (!valid) throw new Error("Invalid Mermaid diagram");
mermaidRenderSequence += 1;
const { svg: renderedSvg } = await mermaid.render(`edgeever-mermaid-${mermaidRenderSequence}`, source);
return renderedSvg;
});
const renderPromise = renderMermaidWithFallback({
renderBeautiful,
renderOfficial,
});
void renderPromise
.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}
>
{isMermaid ? (
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<div className="edgeever-mermaid-toolbar" contentEditable={false}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-mermaid-tool-button"
data-state={copyState}
aria-label={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"
? <Check aria-hidden="true" />
: copyState === "error"
? <CircleAlert aria-hidden="true" />
: <Copy aria-hidden="true" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
</TooltipContent>
</Tooltip>
{editor.isEditable && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-mermaid-tool-button"
aria-label={t(sourceVisible ? "editorToolbar.mermaidHideSource" : "editorToolbar.mermaidShowSource")}
aria-pressed={sourceVisible}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setSourceVisible((visible) => !visible);
}}
onMouseDown={(event) => event.preventDefault()}
>
<Code2 aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t(sourceVisible ? "editorToolbar.mermaidHideSource" : "editorToolbar.mermaidShowSource")}
</TooltipContent>
</Tooltip>
)}
{svg && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-mermaid-tool-button"
aria-label={t("editorToolbar.mermaidOpenViewer")}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setViewerOpen(true);
}}
onMouseDown={(event) => event.preventDefault()}
>
<Maximize2 aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("editorToolbar.mermaidOpenViewer")}</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
) : (
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-code-copy-button"
contentEditable={false}
aria-label={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>
</TooltipTrigger>
<TooltipContent side="bottom">
{t(copyState === "copied" ? "editorToolbar.codeCopied" : copyState === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isMermaid && (
<div
className="edgeever-mermaid-preview"
contentEditable={false}
aria-label={t("editorToolbar.mermaidPreview")}
style={{ backgroundColor: MERMAID_THEME_PALETTES[mermaidTheme].bg }}
>
{!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"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-label={isMermaid ? t("editorToolbar.mermaidSource") : undefined}
aria-multiline="true"
aria-readonly={!editor.isEditable}
/>
{isMermaid && svg && (
<MermaidViewer
closeLabel={t("editorToolbar.mermaidCloseViewer")}
fallbackBackgroundColor={MERMAID_THEME_PALETTES[mermaidTheme].bg}
open={viewerOpen}
resetZoomLabel={t("editorToolbar.mermaidResetZoom")}
svg={svg}
viewerLabel={t("editorToolbar.mermaidViewer")}
zoomInLabel={t("editorToolbar.mermaidZoomIn")}
zoomOutLabel={t("editorToolbar.mermaidZoomOut")}
onClose={() => setViewerOpen(false)}
/>
)}
</NodeViewWrapper>
);
};