Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion src/components/MarkdownPreview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { DrawioBlock } from "./DrawioBlock";
import { ImageCropModal } from "./ImageCropModal";
import CodeBlockModal from "./CodeBlockModal";
import { MarkdownTableEditor } from "./MarkdownTableEditor";
import { MermaidVisualEditorModal } from "./mermaid/MermaidVisualEditorModal";

function replaceAllLiteral(source, needle, replacement) {
if (!needle || needle === replacement) return source;
Expand Down Expand Up @@ -448,6 +449,7 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({
const [cropSaving, setCropSaving] = useState(false);
const [replaceState, setReplaceState] = useState({ busy: false, assetPath: "" });
const [codeEditState, setCodeEditState] = useState({ open: false, language: "", code: "", sourceLine: null });
const [mermaidEditState, setMermaidEditState] = useState({ open: false, initialCode: "", originalBlockCode: "" });
const [tableEditState, setTableEditState] = useState({ open: false, initialMarkdown: "", sourceLine: null, lineCount: 0 });
const [diagramEditState, setDiagramEditState] = useState({
open: false,
Expand Down Expand Up @@ -1885,7 +1887,20 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({
>
{parts.map((part, index) =>
part.type === "mermaid" ? (
<MermaidBlock code={part.value} index={index} key={`${part.type}-${index}`} />
<MermaidBlock
code={part.value}
index={index}
key={`${part.type}-${index}`}
onEdit={(codeToEdit) => {
if (!readOnly) {
setMermaidEditState({
open: true,
initialCode: codeToEdit,
originalBlockCode: part.value,
});
}
}}
/>
) : part.type === "excalidraw" ? (
readOnly ? (
<div key={`${part.type}-${index}`} className="excalidraw-block">
Expand Down Expand Up @@ -2109,6 +2124,29 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({
onCancel={() => setTableEditState({ open: false, initialMarkdown: "", sourceLine: null, lineCount: 0 })}
/>
)}
{mermaidEditState.open && (
<MermaidVisualEditorModal
isOpen={mermaidEditState.open}
initialCode={mermaidEditState.initialCode}
onClose={() => setMermaidEditState({ open: false, initialCode: "", originalBlockCode: "" })}
onSave={(newCode) => {
if (onContentChange) {
const oldBlock = `\`\`\`mermaid\n${mermaidEditState.originalBlockCode}\n\`\`\``;
const newBlock = `\`\`\`mermaid\n${newCode}\n\`\`\``;
if (content && content.includes(oldBlock)) {
onContentChange(content.replace(oldBlock, newBlock));
} else if (content && content.includes(mermaidEditState.originalBlockCode)) {
onContentChange(content.replace(mermaidEditState.originalBlockCode, newCode));
} else {
onContentChange(`${content}\n\n${newBlock}`);
}
onNotify?.("Mermaid diagram saved.", "success");
}
setMermaidEditState({ open: false, initialCode: "", originalBlockCode: "" });
}}
/>
)}
</>
);
});

106 changes: 73 additions & 33 deletions src/components/MermaidBlock.jsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,98 @@
import { useEffect, useState } from "react";

let mermaidInitialized = false;

export function MermaidBlock({ code, index }) {
export function MermaidBlock({ code, onEdit }) {
const [svg, setSvg] = useState("");
const [error, setError] = useState("");

useEffect(() => {
let cancelled = false;
const id = `mermaid-${index}-${Math.random().toString(36).slice(2)}`;
let isCancelled = false;
const cleanCode = (code || "").trim();

if (!cleanCode) {
setSvg("");
setError("");
return;
}

const renderId = `m${Math.random().toString(36).substring(2, 9)}${Date.now()}`;

async function renderMermaid() {
async function doRender() {
try {
const mermaidModule = await import("mermaid");
const mermaid = mermaidModule?.default;
if (!mermaidInitialized) {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
themeVariables: {
primaryColor: "#f4f1ea",
primaryBorderColor: "#2f5d62",
primaryTextColor: "#172326",
lineColor: "#506b70",
secondaryColor: "#dce8e3",
tertiaryColor: "#ffffff",
},
});
mermaidInitialized = true;
}
const result = await mermaid.render(id, code);
if (!cancelled) {
setSvg(result.svg);
const mermaid = mermaidModule?.default || mermaidModule;

mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
theme: "default",
fontFamily: "Inter, sans-serif",
});

const res = await mermaid.render(renderId, cleanCode);
const svgContent = typeof res === "string" ? res : res?.svg || "";

if (!isCancelled) {
setSvg(svgContent);
setError("");
}
} catch (err) {
if (!cancelled) {
if (!isCancelled) {
console.error("Mermaid Render Error:", err);
setSvg("");
setError(err?.message || "Unable to render Mermaid diagram.");
setError(err?.message || "Failed to render Mermaid diagram.");
}
} finally {
const tempEl = document.getElementById(renderId);
if (tempEl) tempEl.remove();
const tempElD = document.getElementById(`d${renderId}`);
if (tempElD) tempElD.remove();
}
}

renderMermaid();
const animFrame = requestAnimationFrame(() => {
void doRender();
});

return () => {
cancelled = true;
isCancelled = true;
cancelAnimationFrame(animFrame);
};
}, [code, index]);
}, [code]);

if (error) {
return <pre className="diagram-error">{error}</pre>;
return (
<div
className="diagram-error"
style={{
padding: "12px 16px",
color: "var(--danger-color, #ef4444)",
background: "color-mix(in srgb, var(--danger-color, #ef4444) 8%, transparent)",
border: "1px solid var(--danger-color, #ef4444)",
borderRadius: 6,
fontSize: "0.82rem",
margin: "8px 0",
}}
>
<strong>Mermaid Render Error:</strong>
<pre style={{ margin: "6px 0 0 0", whiteSpace: "pre-wrap", fontFamily: "monospace", fontSize: "0.78rem" }}>{error}</pre>
</div>
);
}

return <div className="mermaid-render" dangerouslySetInnerHTML={{ __html: svg }} />;
return (
<div
className="mermaid-render"
onClick={() => onEdit?.(code)}
style={{
cursor: onEdit ? "pointer" : "default",
minHeight: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
}}
title={onEdit ? "Click to edit Mermaid diagram visually" : ""}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
194 changes: 194 additions & 0 deletions src/components/mermaid/MermaidCanvas.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import React, { useMemo } from "react";
import {
ReactFlow,
Controls,
Background,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { MermaidNode } from "./MermaidNode";
import {
Square,
Circle,
Diamond,
Plus,
Maximize2,

Check warning on line 14 in src/components/mermaid/MermaidCanvas.jsx

View workflow job for this annotation

GitHub Actions / build-and-test

'Maximize2' is defined but never used. Allowed unused vars must match /^_/u
} from "lucide-react";

export function MermaidCanvas({
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect,
onNodeClick,
onEdgeClick,
onPaneClick,
onAddNode,
}) {
const nodeTypes = useMemo(
() => ({
mermaidNode: MermaidNode,
}),
[]
);

return (
<div className="mermaid-canvas-wrapper" style={{ width: "100%", height: "100%", position: "relative", background: "var(--surface-bg, #ffffff)" }}>
{/* Excalidraw-Style Floating Canvas Shape Bar */}
<div
className="excalidraw-floating-shapebar"
style={{
position: "absolute",
top: 12,
left: "50%",
transform: "translateX(-50%)",
zIndex: 10,
background: "var(--surface-elevated, #ffffff)",
border: "1px solid var(--border-soft, #cbd5e1)",
borderRadius: "var(--radius-md, 8px)",
boxShadow: "var(--shadow-overlay, 0 8px 24px rgba(0, 0, 0, 0.12))",
padding: "4px 8px",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<button
className="canvas-shape-btn"
onClick={() => onAddNode?.("rectangle", "Process Step")}
title="Add Rectangle Node"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 8px",
border: "1px solid var(--border-default, #cbd5e1)",
borderRadius: 4,
background: "var(--surface-bg, #fff)",
fontSize: "0.78rem",
color: "var(--text-strong, #0f172a)",
cursor: "pointer",
}}
>
<Square size={13} /> Process
</button>
<button
className="canvas-shape-btn"
onClick={() => onAddNode?.("diamond", "Decision?")}
title="Add Decision Node"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 8px",
border: "1px solid var(--border-default, #cbd5e1)",
borderRadius: 4,
background: "var(--surface-bg, #fff)",
fontSize: "0.78rem",
color: "var(--text-strong, #0f172a)",
cursor: "pointer",
}}
>
<Diamond size={13} /> Decision
</button>
<button
className="canvas-shape-btn"
onClick={() => onAddNode?.("stadium", "Start / End")}
title="Add Start/End Capsule Node"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 8px",
border: "1px solid var(--border-default, #cbd5e1)",
borderRadius: 4,
background: "var(--surface-bg, #fff)",
fontSize: "0.78rem",
color: "var(--text-strong, #0f172a)",
cursor: "pointer",
}}
>
<Circle size={13} /> Start / End
</button>
<button
className="canvas-shape-btn"
onClick={() => onAddNode?.("circle", "State")}
title="Add Circle State Node"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 8px",
border: "1px solid var(--border-default, #cbd5e1)",
borderRadius: 4,
background: "var(--surface-bg, #fff)",
fontSize: "0.78rem",
color: "var(--text-strong, #0f172a)",
cursor: "pointer",
}}
>
<Circle size={13} /> Circle
</button>
<button
className="canvas-shape-btn primary"
onClick={() => onAddNode?.("rectangle", "New Node")}
title="Add Quick Node"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 10px",
border: "none",
borderRadius: 4,
background: "var(--accent-solid, #3b82f6)",
color: "#ffffff",
fontSize: "0.78rem",
fontWeight: 600,
cursor: "pointer",
}}
>
<Plus size={14} /> Quick Add
</button>
</div>

<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
snapToGrid
snapGrid={[15, 15]}
defaultEdgeOptions={{
animated: false,
style: { strokeWidth: 2, stroke: "var(--accent-solid, #3b82f6)" },
}}
>
<Background color="var(--border-subtle, #cbd5e1)" gap={20} />
<Controls
showZoom
showFitView
showInteractive={false}
aria-label="Mermaid diagram zoom and fit controls"
style={{
borderRadius: 6,
boxShadow: "var(--shadow-overlay, 0 8px 24px rgba(0,0,0,0.12))",
border: "1px solid var(--border-soft, #e2e8f0)",
overflow: "hidden",
button: {
background: "var(--surface-elevated, #ffffff)",
color: "var(--text-strong, #0f172a)",
border: "none",
borderBottom: "1px solid var(--border-soft, #e2e8f0)",
},
}}
/>
</ReactFlow>
</div>
);
}
Loading
Loading