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 }}
+ />
+ );
}
diff --git a/src/components/mermaid/MermaidCanvas.jsx b/src/components/mermaid/MermaidCanvas.jsx
new file mode 100644
index 00000000..4eb31450
--- /dev/null
+++ b/src/components/mermaid/MermaidCanvas.jsx
@@ -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,
+} from "lucide-react";
+
+export function MermaidCanvas({
+ nodes,
+ edges,
+ onNodesChange,
+ onEdgesChange,
+ onConnect,
+ onNodeClick,
+ onEdgeClick,
+ onPaneClick,
+ onAddNode,
+}) {
+ const nodeTypes = useMemo(
+ () => ({
+ mermaidNode: MermaidNode,
+ }),
+ []
+ );
+
+ return (
+
+ {/* Excalidraw-Style Floating Canvas Shape Bar */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/mermaid/MermaidNode.jsx b/src/components/mermaid/MermaidNode.jsx
new file mode 100644
index 00000000..e313b28f
--- /dev/null
+++ b/src/components/mermaid/MermaidNode.jsx
@@ -0,0 +1,195 @@
+import { useState, useCallback, memo } from "react";
+import { Handle, Position } from "@xyflow/react";
+import { Edit2, Check, X, Trash2 } from "lucide-react";
+
+export const MermaidNode = memo(function MermaidNode({ id, data, selected }) {
+ const {
+ label = "",
+ shape = "rectangle",
+ fillColor,
+ strokeColor,
+ textColor,
+ onChangeLabel,
+ onDeleteNode,
+ } = data || {};
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [editText, setEditText] = useState(label);
+
+ const handleDoubleClick = (e) => {
+ e.stopPropagation();
+ setEditText(label);
+ setIsEditing(true);
+ };
+
+ const handleSave = useCallback(
+ (e) => {
+ e?.stopPropagation();
+ onChangeLabel?.(id, editText);
+ setIsEditing(false);
+ },
+ [id, editText, onChangeLabel]
+ );
+
+ const handleCancel = useCallback(
+ (e) => {
+ e?.stopPropagation();
+ setEditText(label);
+ setIsEditing(false);
+ },
+ [label]
+ );
+
+ const handleKeyDown = (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSave(e);
+ } else if (e.key === "Escape") {
+ handleCancel(e);
+ }
+ };
+
+ // Shape geometry calculations
+ const isDiamond = shape === "diamond";
+ const isCircle = shape === "circle";
+ const isStadium = shape === "stadium";
+ const isRounded = shape === "rounded";
+ const isSubroutine = shape === "subroutine";
+
+ const getBorderRadius = () => {
+ if (isCircle) return "50%";
+ if (isStadium) return "9999px";
+ if (isRounded) return "16px";
+ if (isSubroutine) return "4px";
+ if (isDiamond) return "4px";
+ return "6px";
+ };
+
+ const nodeStyle = {
+ padding: isCircle ? "16px" : isDiamond ? "20px 24px" : isStadium ? "10px 24px" : isSubroutine ? "10px 22px" : "10px 18px",
+ borderRadius: getBorderRadius(),
+ background: fillColor || "var(--surface-elevated, #ffffff)",
+ border: selected
+ ? "2px solid var(--accent-solid, #3b82f6)"
+ : strokeColor
+ ? `1.5px solid ${strokeColor}`
+ : "1px solid var(--border-soft, #cbd5e1)",
+ color: textColor || "var(--text-strong, #0f172a)",
+ minWidth: isCircle ? "90px" : isDiamond ? "110px" : "120px",
+ minHeight: isCircle ? "90px" : isDiamond ? "110px" : "44px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ textAlign: "center",
+ position: "relative",
+ boxShadow: selected
+ ? "0 0 0 3px color-mix(in srgb, var(--accent-solid, #3b82f6) 30%, transparent), 0 6px 16px rgba(0, 0, 0, 0.12)"
+ : "0 2px 8px rgba(0, 0, 0, 0.08)",
+ fontSize: "0.85rem",
+ fontWeight: 500,
+ letterSpacing: "-0.01em",
+ transition: "all 0.15s cubic-bezier(0.16, 1, 0.3, 1)",
+ transform: isDiamond ? "rotate(45deg)" : "none",
+ };
+
+ const contentStyle = {
+ transform: isDiamond ? "rotate(-45deg)" : "none",
+ width: "100%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ };
+
+ const handleStyle = {
+ background: strokeColor || "var(--accent-solid, #3b82f6)",
+ width: 8,
+ height: 8,
+ border: "1.5px solid #ffffff",
+ };
+
+ return (
+
+ {/* Subroutine Side Borders */}
+ {isSubroutine && (
+ <>
+
+
+ >
+ )}
+
+ {/* Connection Handles */}
+
+
+
+
+
+ {/* Card Content */}
+
+ {isEditing ? (
+
+ setEditText(e.target.value)}
+ onKeyDown={handleKeyDown}
+ autoFocus
+ className="inline-node-input"
+ style={{
+ background: "var(--surface-bg, #ffffff)",
+ color: "var(--text-strong, #0f172a)",
+ border: "1px solid var(--accent-solid, #3b82f6)",
+ borderRadius: 4,
+ padding: "2px 6px",
+ fontSize: "0.82rem",
+ width: "90px",
+ textAlign: "center",
+ }}
+ />
+
+
+
+ ) : (
+
+ {label}
+
+
+ )}
+
+
+ {/* Floating Action Button on Selection */}
+ {selected && !isEditing && (
+
+ )}
+
+ );
+});
diff --git a/src/components/mermaid/MermaidVisualEditorModal.jsx b/src/components/mermaid/MermaidVisualEditorModal.jsx
new file mode 100644
index 00000000..a81de294
--- /dev/null
+++ b/src/components/mermaid/MermaidVisualEditorModal.jsx
@@ -0,0 +1,944 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+import {
+ useNodesState,
+ useEdgesState,
+ addEdge,
+} from "@xyflow/react";
+import dagre from "dagre";
+import {
+ Save,
+ X,
+ RotateCcw,
+ Code2,
+ Eye,
+ Workflow,
+ Sliders,
+ Palette,
+ Trash2,
+ Undo,
+ Redo,
+ Copy,
+ Check,
+ Info,
+ ZoomIn,
+ ZoomOut,
+ Maximize2,
+} from "lucide-react";
+import { MermaidCanvas } from "./MermaidCanvas";
+import { parseMermaidToFlow, generateMermaidFromFlow, COLOR_PRESETS } from "./mermaidParser";
+import { MermaidBlock } from "../MermaidBlock";
+import OverlayDialog from "../OverlayDialog";
+import AppButton from "../AppButton";
+import AppSelect from "../AppSelect";
+import "../../styles/mermaidEditor.css";
+
+function FullpageMermaidPreview({ code }) {
+ const [zoomScale, setZoomScale] = useState(1);
+ const [panPos, setPanPos] = useState({ x: 0, y: 0 });
+ const [isDragging, setIsDragging] = useState(false);
+ const dragStartRef = useRef({ x: 0, y: 0 });
+
+ const cleanCode = code?.trim() || "";
+
+ const handleMouseDown = (e) => {
+ setIsDragging(true);
+ dragStartRef.current = { x: e.clientX - panPos.x, y: e.clientY - panPos.y };
+ };
+
+ const handleMouseMove = (e) => {
+ if (!isDragging) return;
+ setPanPos({
+ x: e.clientX - dragStartRef.current.x,
+ y: e.clientY - dragStartRef.current.y,
+ });
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ };
+
+ const handleWheel = (e) => {
+ e.preventDefault();
+ const zoomFactor = e.deltaY < 0 ? 0.1 : -0.1;
+ setZoomScale((prev) => Math.min(Math.max(prev + zoomFactor, 0.4), 3));
+ };
+
+ const handleReset = () => {
+ setZoomScale(1);
+ setPanPos({ x: 0, y: 0 });
+ };
+
+ if (!cleanCode) {
+ return (
+
+ No diagram code to preview. Switch to Visual Canvas or Mermaid Code tab to build your diagram.
+
+ );
+ }
+
+ return (
+
+ {/* Zoom Toolbar */}
+
+
+
+ {Math.round(zoomScale * 100)}%
+
+
+
+
+
+ {/* Full Viewport Renderer */}
+
+
+ );
+}
+
+const DIAGRAM_TEMPLATES = {
+ flowchart: `flowchart TD\n A["Start Task"] -->|Next| B("In Progress")\n B --> C{"Is Done?"}\n C -->|Yes| D(("Complete"))`,
+ sequence: `sequenceDiagram\n autonumber\n actor User\n participant App as App Frontend\n participant API as Backend Service\n User->>App: Click Submit\n App->>API: POST /data\n API-->>App: 200 OK\n App-->>User: Show Success Banner`,
+ class: `classDiagram\n class User {\n +String id\n +String name\n +login()\n }\n class Document {\n +String title\n +save()\n }\n User "1" --> "*" Document : owns`,
+ state: `stateDiagram-v2\n [*] --> Draft\n Draft --> Reviewing: Submit\n Reviewing --> Approved: Accept\n Reviewing --> Draft: Request Changes\n Approved --> [*]`,
+};
+
+export function MermaidVisualEditorModal({
+ initialCode = "",
+ isOpen = false,
+ onClose,
+ onSave,
+}) {
+ const [activeTab, setActiveTab] = useState("visual"); // "visual" | "code" | "preview"
+ const [diagramType, setDiagramType] = useState("flowchart"); // "flowchart" | "sequence" | "class" | "state"
+ const [direction, setDirection] = useState("TD");
+ const [mermaidCode, setMermaidCode] = useState("");
+ const [codeError, setCodeError] = useState("");
+ const [copied, setCopied] = useState(false);
+ const [selectedElement, setSelectedElement] = useState(null); // { type: "node" | "edge", id: string }
+
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+
+ // Undo / Redo History
+ const [history, setHistory] = useState([]);
+ const [historyIndex, setHistoryIndex] = useState(-1);
+ const isInternalUpdateRef = useRef(false);
+
+ const saveButtonRef = useRef(null);
+ const edgesRef = useRef(edges);
+ edgesRef.current = edges;
+
+ const directionRef = useRef(direction);
+ directionRef.current = direction;
+
+ const nodesRef = useRef(nodes);
+ nodesRef.current = nodes;
+
+ // Push state to history
+ const pushHistory = useCallback((currentNodes, currentEdges, currentDir) => {
+ if (isInternalUpdateRef.current) return;
+ const snapshot = {
+ nodes: JSON.parse(JSON.stringify(currentNodes)),
+ edges: JSON.parse(JSON.stringify(currentEdges)),
+ direction: currentDir,
+ };
+ setHistory((prev) => {
+ const newHistory = prev.slice(0, historyIndex + 1);
+ return [...newHistory, snapshot];
+ });
+ setHistoryIndex((prev) => prev + 1);
+ }, [historyIndex]);
+
+ // Sync canvas -> code
+ const syncCodeFromFlow = useCallback(
+ (currentNodes, currentEdges, currentDir, recordHistory = true) => {
+ if (diagramType !== "flowchart") return;
+ const code = generateMermaidFromFlow(currentNodes, currentEdges, currentDir);
+ setMermaidCode(code);
+ setCodeError("");
+ if (recordHistory) {
+ pushHistory(currentNodes, currentEdges, currentDir);
+ }
+ },
+ [diagramType, pushHistory]
+ );
+
+ // Handle label change on custom node
+ const handleNodeLabelChange = useCallback(
+ (nodeId, newLabel) => {
+ setNodes((nds) => {
+ const updated = nds.map((n) =>
+ n.id === nodeId
+ ? { ...n, data: { ...n.data, label: newLabel } }
+ : n
+ );
+ syncCodeFromFlow(updated, edgesRef.current, directionRef.current);
+ return updated;
+ });
+ },
+ [setNodes, syncCodeFromFlow]
+ );
+
+ // Handle shape change on custom node
+ const handleNodeShapeChange = useCallback(
+ (nodeId, newShape) => {
+ setNodes((nds) => {
+ const updated = nds.map((n) =>
+ n.id === nodeId
+ ? { ...n, data: { ...n.data, shape: newShape } }
+ : n
+ );
+ syncCodeFromFlow(updated, edgesRef.current, directionRef.current);
+ return updated;
+ });
+ },
+ [setNodes, syncCodeFromFlow]
+ );
+
+ // Handle color change on custom node
+ const handleNodeColorChange = useCallback(
+ (nodeId, { preset, fill, stroke, text }) => {
+ setNodes((nds) => {
+ const updated = nds.map((n) =>
+ n.id === nodeId
+ ? {
+ ...n,
+ data: {
+ ...n.data,
+ colorPreset: preset,
+ fillColor: fill,
+ strokeColor: stroke,
+ textColor: text,
+ },
+ }
+ : n
+ );
+ syncCodeFromFlow(updated, edgesRef.current, directionRef.current);
+ return updated;
+ });
+ },
+ [setNodes, syncCodeFromFlow]
+ );
+
+ // Handle delete node
+ const handleNodeDelete = useCallback(
+ (nodeId) => {
+ setNodes((nds) => {
+ const updated = nds.filter((n) => n.id !== nodeId);
+ setEdges((eds) => {
+ const updatedEdges = eds.filter(
+ (e) => e.source !== nodeId && e.target !== nodeId
+ );
+ syncCodeFromFlow(updated, updatedEdges, directionRef.current);
+ return updatedEdges;
+ });
+ return updated;
+ });
+ setSelectedElement(null);
+ },
+ [setEdges, setNodes, syncCodeFromFlow]
+ );
+
+ // Attach interactive callbacks to node data
+ const attachNodeCallbacks = useCallback(
+ (nodesList) => {
+ return nodesList.map((n) => ({
+ ...n,
+ data: {
+ ...n.data,
+ onChangeLabel: handleNodeLabelChange,
+ onChangeShape: handleNodeShapeChange,
+ onChangeColor: handleNodeColorChange,
+ onDeleteNode: handleNodeDelete,
+ },
+ }));
+ },
+ [handleNodeLabelChange, handleNodeShapeChange, handleNodeColorChange, handleNodeDelete]
+ );
+
+ // Track modal open state
+ const wasOpenRef = useRef(false);
+ useEffect(() => {
+ if (isOpen && !wasOpenRef.current) {
+ wasOpenRef.current = true;
+ const rawCode = initialCode.trim() || DIAGRAM_TEMPLATES.flowchart;
+ setMermaidCode(rawCode);
+
+ if (rawCode.startsWith("sequenceDiagram")) {
+ setDiagramType("sequence");
+ setActiveTab("code");
+ setCodeError("");
+ } else if (rawCode.startsWith("classDiagram")) {
+ setDiagramType("class");
+ setActiveTab("code");
+ setCodeError("");
+ } else if (rawCode.startsWith("stateDiagram")) {
+ setDiagramType("state");
+ setActiveTab("code");
+ setCodeError("");
+ } else {
+ setDiagramType("flowchart");
+ setActiveTab("visual");
+ try {
+ const parsed = parseMermaidToFlow(rawCode);
+ setDirection(parsed.direction || "TD");
+ const initialNodes = attachNodeCallbacks(parsed.nodes);
+ setNodes(initialNodes);
+ setEdges(parsed.edges);
+ setCodeError("");
+
+ setHistory([{ nodes: JSON.parse(JSON.stringify(initialNodes)), edges: JSON.parse(JSON.stringify(parsed.edges)), direction: parsed.direction || "TD" }]);
+ setHistoryIndex(0);
+ } catch (err) {
+ setCodeError(err.message || "Failed to parse initial Mermaid code.");
+ }
+ }
+ } else if (!isOpen) {
+ wasOpenRef.current = false;
+ setSelectedElement(null);
+ }
+ }, [isOpen, initialCode, attachNodeCallbacks, setEdges, setNodes]);
+
+ // Handle switching diagram type template cleanly
+ const handleDiagramTypeChange = (newType) => {
+ setDiagramType(newType);
+ const templateCode = DIAGRAM_TEMPLATES[newType] || DIAGRAM_TEMPLATES.flowchart;
+ setMermaidCode(templateCode);
+ setCodeError("");
+
+ if (newType !== "flowchart") {
+ setActiveTab("code");
+ } else {
+ setActiveTab("visual");
+ try {
+ const parsed = parseMermaidToFlow(templateCode);
+ setDirection(parsed.direction || "TD");
+ const initialNodes = attachNodeCallbacks(parsed.nodes);
+ setNodes(initialNodes);
+ setEdges(parsed.edges);
+ } catch (err) {
+ console.warn("Failed to parse flowchart template:", err);
+ }
+ }
+ };
+
+ // Sync code -> canvas on code edit
+ const handleCodeChange = (newCode) => {
+ setMermaidCode(newCode);
+ const trimmed = newCode.trim();
+
+ if (trimmed.startsWith("sequenceDiagram")) {
+ setDiagramType("sequence");
+ setCodeError("");
+ return;
+ }
+ if (trimmed.startsWith("classDiagram")) {
+ setDiagramType("class");
+ setCodeError("");
+ return;
+ }
+ if (trimmed.startsWith("stateDiagram")) {
+ setDiagramType("state");
+ setCodeError("");
+ return;
+ }
+
+ setDiagramType("flowchart");
+ try {
+ const parsed = parseMermaidToFlow(newCode);
+ setDirection(parsed.direction || "TD");
+ const updatedNodes = attachNodeCallbacks(parsed.nodes);
+ setNodes(updatedNodes);
+ setEdges(parsed.edges);
+ setCodeError("");
+ pushHistory(updatedNodes, parsed.edges, parsed.direction || "TD");
+ } catch (err) {
+ setCodeError(err.message || "Invalid Mermaid syntax.");
+ }
+ };
+
+ // Real-time direction change (TD, LR, RL, BT) with instant Dagre re-layout
+ const handleDirectionChange = (newDir) => {
+ setDirection(newDir);
+
+ const g = new dagre.graphlib.Graph();
+ g.setGraph({ rankdir: newDir, nodesep: 60, ranksep: 80 });
+ g.setDefaultEdgeLabel(() => ({}));
+
+ nodes.forEach((n) => g.setNode(n.id, { width: 150, height: 50 }));
+ edges.forEach((e) => g.setEdge(e.source, e.target));
+
+ dagre.layout(g);
+
+ const updatedNodes = nodes.map((node) => {
+ const pos = g.node(node.id);
+ return {
+ ...node,
+ position: {
+ x: (pos?.x || 100) - 75,
+ y: (pos?.y || 100) - 25,
+ },
+ };
+ });
+
+ setNodes(updatedNodes);
+ syncCodeFromFlow(updatedNodes, edges, newDir);
+ };
+
+ // Auto layout using Dagre
+ const handleAutoLayout = () => {
+ handleDirectionChange(direction);
+ };
+
+ // Undo / Redo handlers
+ const handleUndo = () => {
+ if (historyIndex > 0) {
+ isInternalUpdateRef.current = true;
+ const targetIndex = historyIndex - 1;
+ const snapshot = history[targetIndex];
+ setDirection(snapshot.direction);
+ const updatedNodes = attachNodeCallbacks(snapshot.nodes);
+ setNodes(updatedNodes);
+ setEdges(snapshot.edges);
+ setMermaidCode(generateMermaidFromFlow(updatedNodes, snapshot.edges, snapshot.direction));
+ setHistoryIndex(targetIndex);
+ setTimeout(() => {
+ isInternalUpdateRef.current = false;
+ }, 50);
+ }
+ };
+
+ const handleRedo = () => {
+ if (historyIndex < history.length - 1) {
+ isInternalUpdateRef.current = true;
+ const targetIndex = historyIndex + 1;
+ const snapshot = history[targetIndex];
+ setDirection(snapshot.direction);
+ const updatedNodes = attachNodeCallbacks(snapshot.nodes);
+ setNodes(updatedNodes);
+ setEdges(snapshot.edges);
+ setMermaidCode(generateMermaidFromFlow(updatedNodes, snapshot.edges, snapshot.direction));
+ setHistoryIndex(targetIndex);
+ setTimeout(() => {
+ isInternalUpdateRef.current = false;
+ }, 50);
+ }
+ };
+
+ // Connect edges handler
+ const onConnect = useCallback(
+ (params) => {
+ setEdges((eds) => {
+ const updated = addEdge(
+ {
+ ...params,
+ animated: false,
+ style: { strokeWidth: 2, stroke: "var(--accent-solid, #3b82f6)" },
+ data: { lineStyle: "solid" },
+ },
+ eds
+ );
+ syncCodeFromFlow(nodesRef.current, updated, directionRef.current);
+ return updated;
+ });
+ },
+ [syncCodeFromFlow, setEdges]
+ );
+
+ // Selection handlers
+ const handleNodeClick = (_, node) => {
+ setSelectedElement({ type: "node", id: node.id });
+ };
+
+ const handleEdgeClick = (_, edge) => {
+ setSelectedElement({ type: "edge", id: edge.id });
+ };
+
+ const handlePaneClick = () => {
+ setSelectedElement(null);
+ };
+
+ // Add new node with specified shape preset
+ const handleAddNodePreset = (shapePreset = "rectangle", defaultLabel = "New Step") => {
+ const newId = `node-${Date.now().toString().slice(-4)}`;
+ const newNode = {
+ id: newId,
+ type: "mermaidNode",
+ data: {
+ label: defaultLabel,
+ shape: shapePreset,
+ onChangeLabel: handleNodeLabelChange,
+ onChangeShape: handleNodeShapeChange,
+ onChangeColor: handleNodeColorChange,
+ onDeleteNode: handleNodeDelete,
+ },
+ position: { x: 150 + Math.random() * 80, y: 150 + Math.random() * 80 },
+ };
+
+ setNodes((nds) => {
+ const updated = [...nds, newNode];
+ syncCodeFromFlow(updated, edgesRef.current, directionRef.current);
+ return updated;
+ });
+ setSelectedElement({ type: "node", id: newId });
+ };
+
+ // Edge property updates
+ const handleEdgeLabelChange = (edgeId, newLabel) => {
+ setEdges((eds) => {
+ const updated = eds.map((e) =>
+ e.id === edgeId ? { ...e, label: newLabel } : e
+ );
+ syncCodeFromFlow(nodesRef.current, updated, directionRef.current);
+ return updated;
+ });
+ };
+
+ const handleEdgeLineStyleChange = (edgeId, lineStyle) => {
+ setEdges((eds) => {
+ const updated = eds.map((e) => {
+ if (e.id !== edgeId) return e;
+ const animated = lineStyle === "dashed";
+ const strokeWidth = lineStyle === "thick" ? 4 : 2;
+ const strokeDasharray = lineStyle === "dashed" ? "5,5" : undefined;
+ return {
+ ...e,
+ animated,
+ style: { ...e.style, strokeWidth, strokeDasharray },
+ data: { ...e.data, lineStyle },
+ };
+ });
+ syncCodeFromFlow(nodesRef.current, updated, directionRef.current);
+ return updated;
+ });
+ };
+
+ const handleEdgeDelete = (edgeId) => {
+ setEdges((eds) => {
+ const updated = eds.filter((e) => e.id !== edgeId);
+ syncCodeFromFlow(nodesRef.current, updated, directionRef.current);
+ return updated;
+ });
+ setSelectedElement(null);
+ };
+
+ // Copy code handler
+ const handleCopyCode = async () => {
+ try {
+ const code = diagramType === "flowchart" ? generateMermaidFromFlow(nodes, edges, direction) : mermaidCode;
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy code:", err);
+ }
+ };
+
+ // Save handler
+ const handleSave = () => {
+ const finalCode = diagramType === "flowchart" ? generateMermaidFromFlow(nodes, edges, direction) : mermaidCode;
+ onSave?.(finalCode);
+ onClose?.();
+ };
+
+ const activeSelectedNode = selectedElement?.type === "node" ? nodes.find((n) => n.id === selectedElement.id) : null;
+ const activeSelectedEdge = selectedElement?.type === "edge" ? edges.find((e) => e.id === selectedElement.id) : null;
+
+ if (!isOpen) return null;
+
+ const currentActiveCode = diagramType === "flowchart" ? generateMermaidFromFlow(nodes, edges, direction) : mermaidCode;
+
+ return (
+
+ {/* Standard App Modal Header */}
+
+
+
+
Mermaid Editor
+
+
+ {/* Clean 3 Main View Tabs */}
+
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+ {copied ? : }
+ {copied ? "Copied" : "Copy Code"}
+
+
+
+ Save Diagram
+
+
+
+ Close
+
+
+
+
+ {/* Toolbar for Visual Editor View */}
+ {activeTab === "visual" && diagramType === "flowchart" && (
+
+
+
+
Type:
+
handleDiagramTypeChange(e.target.value)}
+ style={{ height: 26, fontSize: "0.78rem", padding: "0 8px" }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Layout:
+ {["TD", "LR", "RL", "BT"].map((dir) => (
+
+ ))}
+
+
+
+ )}
+
+ {/* Modal Body / Dedicated Full-Height Views */}
+
+ {/* Tab 1: Visual Canvas */}
+ {activeTab === "visual" && (
+
+
+
+ {/* Node Inspector Drawer */}
+ {activeSelectedNode && (
+
+
+
+ Node Inspector
+
+
+
+
+
+ handleNodeLabelChange(activeSelectedNode.id, e.target.value)}
+ className="inspector-input"
+ />
+
+
+
+
+
+ {[
+ { id: "rectangle", label: "Rectangle [ ]" },
+ { id: "stadium", label: "Stadium ([ ])" },
+ { id: "diamond", label: "Diamond { }" },
+ { id: "circle", label: "Circle (( ))" },
+ { id: "rounded", label: "Rounded ( )" },
+ { id: "subroutine", label: "Subroutine [[ ]]" },
+ ].map((s) => (
+
+ ))}
+
+
+
+
+
+
+ {COLOR_PRESETS.map((preset) => (
+
+
+
+
+
+
+
+ )}
+
+ {/* Edge Inspector Drawer */}
+ {activeSelectedEdge && (
+
+
+
+ Connector Inspector
+
+
+
+
+
+ handleEdgeLabelChange(activeSelectedEdge.id, e.target.value)}
+ placeholder="e.g. Yes / No"
+ className="inspector-input"
+ />
+
+
+
+
+
+ {[
+ { id: "solid", label: "Solid (-->)" },
+ { id: "dashed", label: "Dashed (-.->)" },
+ { id: "thick", label: "Thick (==>)" },
+ ].map((ls) => (
+
+ ))}
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+ {/* Tab 2: Mermaid Code */}
+ {activeTab === "code" && (
+
+
+
+
Diagram Type:
+
handleDiagramTypeChange(e.target.value)}
+ style={{ height: 24, fontSize: "0.76rem" }}
+ >
+
+
+
+
+
+
+ {codeError &&
{codeError}}
+
+
+ {diagramType !== "flowchart" && (
+
+
+ Editing {diagramType} syntax. Switch to Live Preview tab to see rendered diagram.
+
+ )}
+
+
+ )}
+
+ {/* Tab 3: Fullpage Live Preview with Zoom */}
+ {activeTab === "preview" && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/mermaid/mermaidParser.js b/src/components/mermaid/mermaidParser.js
new file mode 100644
index 00000000..8de6896b
--- /dev/null
+++ b/src/components/mermaid/mermaidParser.js
@@ -0,0 +1,319 @@
+import dagre from "dagre";
+
+/**
+ * Supported node shapes:
+ * - rectangle: [text]
+ * - rounded: (text)
+ * - diamond: {text}
+ * - circle: ((text))
+ * - stadium: ([text])
+ * - subroutine: [[text]]
+ */
+
+const SHAPE_REGEXES = [
+ { type: "stadium", regex: /^([a-zA-Z0-9_-]+)\s*\(\[(.+?)\]\)$/ },
+ { type: "subroutine", regex: /^([a-zA-Z0-9_-]+)\s*\[\[(.+?)\]\]$/ },
+ { type: "circle", regex: /^([a-zA-Z0-9_-]+)\s*\(\((.+?)\)\)$/ },
+ { type: "rounded", regex: /^([a-zA-Z0-9_-]+)\s*\((.+?)\)$/ },
+ { type: "diamond", regex: /^([a-zA-Z0-9_-]+)\s*\{(.+?)\}$/ },
+ { type: "rectangle", regex: /^([a-zA-Z0-9_-]+)\s*\[(.+?)\]$/ },
+];
+
+/**
+ * Color preset map for style serialization
+ */
+export const COLOR_PRESETS = [
+ { id: "default", label: "Default", fill: undefined, stroke: undefined, text: undefined },
+ { id: "blue", label: "Ocean Blue", fill: "#1e3a8a", stroke: "#3b82f6", text: "#ffffff" },
+ { id: "green", label: "Emerald Green", fill: "#064e3b", stroke: "#10b981", text: "#ffffff" },
+ { id: "amber", label: "Amber Gold", fill: "#78350f", stroke: "#f59e0b", text: "#ffffff" },
+ { id: "rose", label: "Rose Red", fill: "#881337", stroke: "#f43f5e", text: "#ffffff" },
+ { id: "purple", label: "Royal Purple", fill: "#581c87", stroke: "#a855f7", text: "#ffffff" },
+ { id: "cyan", label: "Cyan Breeze", fill: "#164e63", stroke: "#06b6d4", text: "#ffffff" },
+ { id: "slate", label: "Dark Slate", fill: "#1e293b", stroke: "#64748b", text: "#ffffff" },
+];
+
+/**
+ * Parses Mermaid flowchart string into React Flow nodes and edges.
+ * Uses dagre to compute initial node layout coordinates.
+ */
+export function parseMermaidToFlow(code = "") {
+ const lines = code
+ .split("\n")
+ .map((l) => l.trim())
+ .filter((l) => l.length > 0 && !l.startsWith("```"));
+
+ let direction = "TD";
+ if (lines.length > 0) {
+ const headerMatch = lines[0].match(/^(?:flowchart|graph)\s+(TD|LR|RL|BT)/i);
+ if (headerMatch) {
+ direction = headerMatch[1].toUpperCase();
+ }
+ }
+
+ const nodesMap = new Map();
+ const nodeStylesMap = new Map();
+ const edges = [];
+ let edgeIdCounter = 1;
+
+ const RESERVED_KEYWORDS = new Set(["end", "subgraph", "flowchart", "graph", "style", "classdef", "class", "click", "direction", "linkstyle"]);
+
+ function ensureNode(token) {
+ if (!token) return null;
+ let raw = token.trim();
+ if (!raw) return null;
+
+ // Check if token matches node declaration with shape: e.g. A[Label]
+ for (const { type, regex } of SHAPE_REGEXES) {
+ const match = raw.match(regex);
+ if (match) {
+ const id = match[1];
+ if (RESERVED_KEYWORDS.has(id.toLowerCase())) return null;
+ const label = match[2].replace(/^["']|["']$/g, "").trim();
+ nodesMap.set(id, { id, label, shape: type });
+ return id;
+ }
+ }
+
+ // Bare node ID (e.g. "A")
+ const idMatch = raw.match(/^([a-zA-Z0-9_-]+)$/);
+ if (idMatch) {
+ const id = idMatch[1];
+ if (RESERVED_KEYWORDS.has(id.toLowerCase())) return null;
+ if (!nodesMap.has(id)) {
+ nodesMap.set(id, { id, label: id, shape: "rectangle" });
+ }
+ return id;
+ }
+
+ return null;
+ }
+
+ // Edge regex matching arrows with optional labels, e.g. -->|label| or ==> or -.-
+ const EDGE_REGEX = /^(.+?)\s*(==>(?:\|[^|]+\|)?|-->(?:\|[^|]+\|)?|---(?:\|[^|]+\|)?|-\.->(?:\|[^|]+\|)?)\s*(.+)$/;
+
+ // Parse lines for nodes, connections, and styles
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const lower = line.toLowerCase();
+
+ // Skip header line, comments, subgraphs, end declarations, classDef, style directives
+ if (
+ (i === 0 && (lower.startsWith("flowchart") || lower.startsWith("graph"))) ||
+ lower.startsWith("%%") ||
+ lower.startsWith("subgraph") ||
+ lower === "end" ||
+ lower.startsWith("end ") ||
+ lower.startsWith("classdef ") ||
+ lower.startsWith("direction ") ||
+ lower.startsWith("linkstyle ") ||
+ lower.startsWith("click ")
+ ) {
+ continue;
+ }
+
+ // Parse style directives, e.g. style A fill:#1e3a8a,stroke:#3b82f6,color:#ffffff
+ if (line.toLowerCase().startsWith("style ")) {
+ const styleMatch = line.match(/^style\s+([a-zA-Z0-9_-]+)\s+(.+)$/i);
+ if (styleMatch) {
+ const nodeId = styleMatch[1];
+ const styleRules = styleMatch[2];
+ const fillMatch = styleRules.match(/fill:([^,;\s]+)/i);
+ const strokeMatch = styleRules.match(/stroke:([^,;\s]+)/i);
+ const textMatch = styleRules.match(/color:([^,;\s]+)/i);
+
+ const preset = COLOR_PRESETS.find(
+ (p) => p.fill === fillMatch?.[1] && p.stroke === strokeMatch?.[1]
+ );
+
+ nodeStylesMap.set(nodeId, {
+ preset: preset?.id || "custom",
+ fill: fillMatch?.[1] || undefined,
+ stroke: strokeMatch?.[1] || undefined,
+ text: textMatch?.[1] || undefined,
+ });
+ }
+ continue;
+ }
+
+ const edgeMatch = line.match(EDGE_REGEX);
+
+ if (edgeMatch) {
+ const leftPart = edgeMatch[1].trim();
+ const connSymbol = edgeMatch[2].trim();
+ const rightPart = edgeMatch[3].trim();
+
+ const sourceId = ensureNode(leftPart);
+ const targetId = ensureNode(rightPart);
+
+ let label = "";
+ let animated = false;
+ let lineStyle = "solid"; // "solid" | "dashed" | "thick"
+ let style = {};
+
+ if (connSymbol.includes("|")) {
+ const labelPart = connSymbol.split("|")[1] || "";
+ label = labelPart.replace(/^["']|["']$/g, "").trim();
+ }
+
+ if (connSymbol.startsWith("-.-")) {
+ animated = true;
+ lineStyle = "dashed";
+ style = { strokeDasharray: "5,5", strokeWidth: 2 };
+ } else if (connSymbol.startsWith("==")) {
+ lineStyle = "thick";
+ style = { strokeWidth: 4 };
+ } else {
+ style = { strokeWidth: 2 };
+ }
+
+ if (sourceId && targetId) {
+ edges.push({
+ id: `e-${sourceId}-${targetId}-${edgeIdCounter++}`,
+ source: sourceId,
+ target: targetId,
+ label: label.trim(),
+ animated,
+ style,
+ data: { lineStyle },
+ });
+ }
+ } else {
+ // Line might just be a standalone node definition: e.g. A[Hello World]
+ ensureNode(line);
+ }
+ }
+
+ // Fallback if graph is empty
+ if (nodesMap.size === 0) {
+ nodesMap.set("node-1", { id: "node-1", label: "Start", shape: "stadium" });
+ nodesMap.set("node-2", { id: "node-2", label: "Process", shape: "rectangle" });
+ nodesMap.set("node-3", { id: "node-3", label: "Decision?", shape: "diamond" });
+ nodesMap.set("node-4", { id: "node-4", label: "End", shape: "circle" });
+
+ edges.push(
+ { id: "e-node-1-node-2", source: "node-1", target: "node-2", label: "Next", data: { lineStyle: "solid" } },
+ { id: "e-node-2-node-3", source: "node-2", target: "node-3", label: "Check", data: { lineStyle: "solid" } },
+ { id: "e-node-3-node-4", source: "node-3", target: "node-4", label: "Yes", data: { lineStyle: "thick" } }
+ );
+ }
+
+ // Apply Dagre layout for x,y positions
+ const g = new dagre.graphlib.Graph();
+ g.setGraph({ rankdir: direction, nodesep: 60, ranksep: 80 });
+ g.setDefaultEdgeLabel(() => ({}));
+
+ const nodeWidth = 150;
+ const nodeHeight = 50;
+
+ nodesMap.forEach((node) => {
+ g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
+ });
+
+ edges.forEach((edge) => {
+ g.setEdge(edge.source, edge.target);
+ });
+
+ dagre.layout(g);
+
+ const nodes = Array.from(nodesMap.values()).map((node) => {
+ const nodeWithPos = g.node(node.id);
+ const customStyle = nodeStylesMap.get(node.id) || {};
+ return {
+ id: node.id,
+ type: "mermaidNode",
+ data: {
+ label: node.label,
+ shape: node.shape || "rectangle",
+ colorPreset: customStyle.preset || "default",
+ fillColor: customStyle.fill,
+ strokeColor: customStyle.stroke,
+ textColor: customStyle.text,
+ },
+ position: {
+ x: (nodeWithPos?.x || 100) - nodeWidth / 2,
+ y: (nodeWithPos?.y || 100) - nodeHeight / 2,
+ },
+ };
+ });
+
+ return { nodes, edges, direction };
+}
+
+/**
+ * Converts React Flow nodes and edges state back to Mermaid flowchart code.
+ */
+export function generateMermaidFromFlow(nodes = [], edges = [], direction = "TD") {
+ const lines = [`flowchart ${direction}`];
+ const styleDirectives = [];
+
+ // Map nodes to mermaid node declarations
+ nodes.forEach((node) => {
+ const id = node.id;
+ const label = node.data?.label || id;
+ const shape = node.data?.shape || "rectangle";
+
+ let nodeDecl = "";
+ switch (shape) {
+ case "stadium":
+ nodeDecl = `${id}(["${label}"])`;
+ break;
+ case "subroutine":
+ nodeDecl = `${id}[["${label}"]]`;
+ break;
+ case "rounded":
+ nodeDecl = `${id}("${label}")`;
+ break;
+ case "diamond":
+ nodeDecl = `${id}{"${label}"}`;
+ break;
+ case "circle":
+ nodeDecl = `${id}(("${label}"))`;
+ break;
+ case "rectangle":
+ default:
+ nodeDecl = `${id}["${label}"]`;
+ break;
+ }
+ lines.push(` ${nodeDecl}`);
+
+ // Generate style directive if node has custom colors (only for valid non-var colors)
+ const fill = node.data?.fillColor && !node.data.fillColor.startsWith("var(") ? node.data.fillColor : null;
+ const stroke = node.data?.strokeColor && !node.data.strokeColor.startsWith("var(") ? node.data.strokeColor : null;
+ const text = node.data?.textColor && !node.data.textColor.startsWith("var(") ? node.data.textColor : null;
+
+ if (fill || stroke || text) {
+ const parts = [];
+ if (fill) parts.push(`fill:${fill}`);
+ if (stroke) parts.push(`stroke:${stroke}`);
+ if (text) parts.push(`color:${text}`);
+ styleDirectives.push(` style ${id} ${parts.join(",")}`);
+ }
+ });
+
+ // Map edges to mermaid connection lines
+ edges.forEach((edge) => {
+ const source = edge.source;
+ const target = edge.target;
+ const label = edge.label ? `|${edge.label}|` : "";
+ const lineStyle = edge.data?.lineStyle || (edge.animated ? "dashed" : "solid");
+
+ let arrow = "-->";
+ if (lineStyle === "dashed" || edge.animated) {
+ arrow = "-.->";
+ } else if (lineStyle === "thick") {
+ arrow = "==>";
+ }
+
+ if (source && target) {
+ lines.push(` ${source} ${arrow}${label} ${target}`);
+ }
+ });
+
+ // Append style directives at the end
+ if (styleDirectives.length > 0) {
+ lines.push(...styleDirectives);
+ }
+
+ return lines.join("\n");
+}
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 336856f7..89fe9fa4 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -318,7 +318,7 @@
position: relative;
border-radius: var(--radius-md, 6px);
border: 1px solid transparent;
- padding: 4px;
+ padding: 0;
cursor: pointer;
overflow-x: auto;
transition: border-color var(--motion-standard, 0.15s ease),
@@ -326,6 +326,7 @@
background var(--motion-standard, 0.15s ease);
}
+
.preview .markdown-table-wrapper:hover {
border-color: color-mix(in srgb, var(--accent-solid, #3b82f6) 65%, var(--border-soft, #cbd5e1));
background: color-mix(in srgb, var(--accent-solid, #3b82f6) 3%, transparent);
@@ -624,11 +625,29 @@
overflow: auto;
margin: 16px 0;
padding: 16px;
- border: 1px solid #d9dedb;
+ border: 1px solid var(--border-soft, #d9dedb);
border-radius: 6px;
- background: #faf9f6;
+ background: var(--bg-card, #faf9f6);
+ cursor: pointer;
+ transition: border-color var(--motion-standard, 0.15s ease),
+ box-shadow var(--motion-standard, 0.15s ease),
+ background var(--motion-standard, 0.15s ease);
+}
+
+.mermaid-render:hover {
+ border-color: color-mix(in srgb, var(--accent-solid, #3b82f6) 65%, var(--border-soft, #cbd5e1));
+ background: color-mix(in srgb, var(--accent-solid, #3b82f6) 3%, transparent);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-solid, #3b82f6) 20%, transparent),
+ 0 4px 12px rgba(0, 0, 0, 0.06);
+}
+
+.mermaid-render:focus-visible {
+ outline: none;
+ border-color: var(--accent-solid, #3b82f6);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-solid, #3b82f6) 35%, transparent);
}
+
.diagram-error,
.error-banner {
border: 1px solid #9b2f2f;
diff --git a/src/styles/mermaidEditor.css b/src/styles/mermaidEditor.css
new file mode 100644
index 00000000..f66f1e55
--- /dev/null
+++ b/src/styles/mermaidEditor.css
@@ -0,0 +1,488 @@
+/* Mermaid Visual Editor Modal Styles - App Theme & Excalidraw Sizing Integration */
+
+.mermaid-modal-backdrop,
+.excalidraw-modal-overlay {
+ position: fixed;
+ top: 32px;
+ bottom: 28px;
+ left: 0;
+ right: 0;
+ background: var(--surface-overlay, rgba(0, 0, 0, 0.6));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2500;
+ padding: 6px;
+}
+
+.mermaid-modal-container,
+.excalidraw-modal-container {
+ width: calc(100vw - 12px);
+ height: calc(100vh - 60px - 12px);
+ max-width: none;
+ max-height: none;
+ background: var(--surface-bg, #ffffff);
+ border: 1px solid var(--border-soft, #e2e8f0);
+ border-radius: var(--radius-md, 6px);
+ box-shadow: var(--shadow-overlay, 0 20px 40px rgba(0, 0, 0, 0.2));
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ color: var(--text-strong, #0f172a);
+}
+
+/* Header & View Mode Tabs */
+.mermaid-modal-header,
+.excalidraw-modal-header {
+ height: 40px;
+ min-height: 40px;
+ padding: 4px 12px;
+ background: color-mix(in srgb, var(--surface-bg, #fff) 94%, var(--surface-elevated, #f8fafc));
+ border-bottom: 1px solid var(--border-soft, #e2e8f0);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.modal-title-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.modal-title-group h2 {
+ margin: 0;
+ color: var(--text-strong, #0f172a);
+ font-size: 0.92rem;
+ font-weight: 600;
+}
+
+.modal-title-icon {
+ color: var(--accent-solid, #3b82f6);
+}
+
+.mermaid-view-tabs {
+ display: flex;
+ align-items: center;
+ background: var(--surface-subtle, #f1f5f9);
+ padding: 2px;
+ border-radius: var(--radius-sm, 4px);
+ border: 1px solid var(--border-default, #cbd5e1);
+ gap: 2px;
+}
+
+.tab-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: transparent;
+ border: none;
+ color: var(--text-muted, #64748b);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--motion-fast, 0.15s ease);
+}
+
+.tab-btn:hover {
+ color: var(--text-strong, #0f172a);
+ background: color-mix(in srgb, var(--surface-bg) 80%, transparent);
+}
+
+.tab-btn.active {
+ background: var(--accent-solid, #3b82f6);
+ color: #ffffff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+}
+
+/* Toolbar */
+.mermaid-editor-toolbar {
+ height: 42px;
+ padding: 0 12px;
+ background: var(--surface-elevated, #f8fafc);
+ border-bottom: 1px solid var(--border-soft, #e2e8f0);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.toolbar-left,
+.toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.preset-insert-group {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.toolbar-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--surface-bg, #ffffff);
+ border: 1px solid var(--border-default, #cbd5e1);
+ color: var(--text-strong, #0f172a);
+ padding: 4px 10px;
+ border-radius: var(--radius-sm, 4px);
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--motion-fast, 0.15s ease);
+}
+
+.toolbar-btn:hover {
+ background: var(--surface-subtle, #f1f5f9);
+ border-color: color-mix(in srgb, var(--accent-solid, #3b82f6) 50%, var(--border-default, #cbd5e1));
+}
+
+.toolbar-btn.preset-btn {
+ background: var(--surface-subtle, #f8fafc);
+ border-color: var(--border-soft, #e2e8f0);
+ color: var(--text-muted, #64748b);
+}
+
+.toolbar-btn.preset-btn:hover {
+ color: var(--text-strong, #0f172a);
+ border-color: var(--accent-solid, #3b82f6);
+ background: color-mix(in srgb, var(--accent-solid, #3b82f6) 8%, var(--surface-bg, #fff));
+}
+
+.toolbar-btn.primary {
+ background: var(--accent-solid, #3b82f6);
+ color: #ffffff;
+ border-color: transparent;
+}
+
+.toolbar-btn.primary:hover {
+ opacity: 0.9;
+}
+
+.direction-selector {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.78rem;
+ color: var(--text-muted, #64748b);
+ margin-left: 6px;
+}
+
+.dir-btn {
+ background: var(--surface-bg, #ffffff);
+ border: 1px solid var(--border-default, #cbd5e1);
+ color: var(--text-muted, #64748b);
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-size: 0.76rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--motion-fast, 0.15s ease);
+}
+
+.dir-btn.active {
+ background: var(--accent-solid, #3b82f6);
+ color: #ffffff;
+ border-color: transparent;
+}
+
+/* Modal Body */
+.mermaid-modal-body {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+ background: var(--surface-bg, #ffffff);
+}
+
+.mermaid-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.tab-split .canvas-panel {
+ border-right: 1px solid var(--border-soft, #e2e8f0);
+ flex: 1.2;
+}
+
+.tab-split .code-panel {
+ flex: 0.8;
+}
+
+.tab-visual .canvas-panel {
+ flex: 1;
+}
+
+.tab-code .code-panel {
+ flex: 1;
+}
+
+/* Code Panel */
+.code-editor-header {
+ height: 36px;
+ padding: 0 12px;
+ background: var(--surface-elevated, #f8fafc);
+ border-bottom: 1px solid var(--border-soft, #e2e8f0);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-muted, #64748b);
+}
+
+.code-error-badge {
+ background: color-mix(in srgb, var(--danger-color, #ef4444) 12%, transparent);
+ border: 1px solid var(--danger-color, #ef4444);
+ color: var(--danger-color, #ef4444);
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.74rem;
+}
+
+.mermaid-code-input {
+ flex: 1;
+ width: 100%;
+ background: var(--surface-subtle, #f8fafc);
+ color: var(--text-strong, #0f172a);
+ font-family: var(--font-mono, monospace);
+ font-size: 0.88rem;
+ line-height: 1.5;
+ padding: 14px;
+ border: none;
+ outline: none;
+ resize: none;
+}
+
+.code-preview-footer {
+ height: 160px;
+ border-top: 1px solid var(--border-soft, #e2e8f0);
+ background: var(--surface-elevated, #f8fafc);
+ display: flex;
+ flex-direction: column;
+ padding: 8px 12px;
+ overflow: hidden;
+}
+
+.fullpage-preview-container,
+.fullpage-preview-container * {
+ cursor: pointer !important;
+}
+
+.fullpage-preview-container.is-dragging,
+.fullpage-preview-container.is-dragging * {
+ cursor: grabbing !important;
+ cursor: -webkit-grabbing !important;
+}
+
+.fullpage-svg-viewport svg {
+ max-width: 100%;
+ max-height: 100%;
+ height: auto;
+ width: auto;
+}
+
+.fullpage-svg-viewport .mermaid-render {
+ border: none !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ cursor: default !important;
+}
+
+.fullpage-preview-error {
+ padding: 16px;
+ color: var(--danger-color, #ef4444);
+ font-size: 0.88rem;
+ background: color-mix(in srgb, var(--danger-color, #ef4444) 8%, transparent);
+ border: 1px solid var(--danger-color, #ef4444);
+ border-radius: 6px;
+ margin: 20px;
+}
+
+.toolbar-divider {
+ width: 1px;
+ height: 18px;
+ background: var(--border-soft, #e2e8f0);
+ margin: 0 4px;
+}
+
+
+.preview-label {
+ font-size: 0.74rem;
+ font-weight: 600;
+ color: var(--text-muted, #64748b);
+ margin-bottom: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.live-mermaid-preview {
+ flex: 1;
+ overflow: auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--surface-bg, #ffffff);
+ border-radius: 6px;
+ padding: 8px;
+ border: 1px solid var(--border-soft, #e2e8f0);
+}
+
+/* Property Inspector Drawer */
+.mermify-inspector-drawer {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ width: 260px;
+ background: var(--surface-elevated, #ffffff);
+ border: 1px solid var(--border-soft, #cbd5e1);
+ border-radius: var(--radius-md, 6px);
+ box-shadow: var(--shadow-overlay, 0 12px 28px rgba(0, 0, 0, 0.15));
+ padding: 14px;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ color: var(--text-strong, #0f172a);
+}
+
+.drawer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.85rem;
+ font-weight: 600;
+ border-bottom: 1px solid var(--border-soft, #e2e8f0);
+ padding-bottom: 8px;
+ color: var(--accent-solid, #3b82f6);
+}
+
+.drawer-close-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-muted, #64748b);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+}
+
+.drawer-close-btn:hover {
+ background: var(--surface-subtle, #f1f5f9);
+ color: var(--text-strong, #0f172a);
+}
+
+.drawer-section {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.drawer-section label {
+ font-size: 0.74rem;
+ font-weight: 600;
+ color: var(--text-muted, #64748b);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.inspector-input {
+ background: var(--surface-bg, #ffffff);
+ border: 1px solid var(--border-default, #cbd5e1);
+ color: var(--text-strong, #0f172a);
+ padding: 6px 10px;
+ border-radius: 4px;
+ font-size: 0.82rem;
+ outline: none;
+}
+
+.inspector-input:focus {
+ border-color: var(--accent-solid, #3b82f6);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-solid, #3b82f6) 20%, transparent);
+}
+
+.shape-picker-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 4px;
+}
+
+.shape-btn {
+ background: var(--surface-bg, #ffffff);
+ border: 1px solid var(--border-default, #cbd5e1);
+ color: var(--text-muted, #64748b);
+ padding: 6px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-align: center;
+ transition: all var(--motion-fast, 0.15s ease);
+}
+
+.shape-btn:hover {
+ background: var(--surface-subtle, #f1f5f9);
+ color: var(--text-strong, #0f172a);
+}
+
+.shape-btn.active {
+ background: var(--accent-solid, #3b82f6);
+ color: #ffffff;
+ border-color: transparent;
+}
+
+.color-preset-grid {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ gap: 4px;
+}
+
+.color-preset-btn {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid transparent;
+ transition: transform 0.12s ease;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
+}
+
+.color-preset-btn:hover {
+ transform: scale(1.15);
+}
+
+.drawer-footer {
+ margin-top: 4px;
+ padding-top: 8px;
+ border-top: 1px solid var(--border-soft, #e2e8f0);
+}
+
+.drawer-delete-btn {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ background: color-mix(in srgb, var(--danger-color, #ef4444) 10%, transparent);
+ border: 1px solid color-mix(in srgb, var(--danger-color, #ef4444) 30%, transparent);
+ color: var(--danger-color, #ef4444);
+ padding: 6px;
+ border-radius: 4px;
+ font-size: 0.78rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--motion-fast, 0.15s ease);
+}
+
+.drawer-delete-btn:hover {
+ background: var(--danger-color, #ef4444);
+ color: #ffffff;
+}
diff --git a/src/tests/components/mermaidParser.test.js b/src/tests/components/mermaidParser.test.js
new file mode 100644
index 00000000..0d6046bd
--- /dev/null
+++ b/src/tests/components/mermaidParser.test.js
@@ -0,0 +1,78 @@
+import { describe, it, expect } from "vitest";
+import { parseMermaidToFlow, generateMermaidFromFlow } from "../../components/mermaid/mermaidParser";
+
+describe("mermaidParser", () => {
+ it("should parse flowchart code into React Flow nodes and edges", () => {
+ const code = `flowchart TD
+ A["Start Task"] -->|Next| B("In Progress")
+ B --> C{"Is Done?"}
+ C -->|Yes| D(("Complete"))`;
+
+ const { nodes, edges, direction } = parseMermaidToFlow(code);
+
+ expect(direction).toBe("TD");
+ expect(nodes.length).toBe(4);
+ expect(edges.length).toBe(3);
+
+ const nodeA = nodes.find((n) => n.id === "A");
+ expect(nodeA).toBeDefined();
+ expect(nodeA.data.label).toBe("Start Task");
+ expect(nodeA.data.shape).toBe("rectangle");
+
+ const nodeB = nodes.find((n) => n.id === "B");
+ expect(nodeB.data.shape).toBe("rounded");
+
+ const nodeC = nodes.find((n) => n.id === "C");
+ expect(nodeC.data.shape).toBe("diamond");
+
+ const nodeD = nodes.find((n) => n.id === "D");
+ expect(nodeD.data.shape).toBe("circle");
+
+ expect(edges[0].source).toBe("A");
+ expect(edges[0].target).toBe("B");
+ expect(edges[0].label).toBe("Next");
+ });
+
+ it("should parse extended shapes and line styles", () => {
+ const code = `flowchart LR
+ S(["Start Event"]) ==>|Thick| P[["Subroutine Process"]]
+ P -.->|Dashed| E(("End"))
+ style S fill:#1e3a8a,stroke:#3b82f6,color:#ffffff`;
+
+ const { nodes, edges } = parseMermaidToFlow(code);
+
+ const nodeS = nodes.find((n) => n.id === "S");
+ expect(nodeS.data.shape).toBe("stadium");
+ expect(nodeS.data.fillColor).toBe("#1e3a8a");
+
+ const nodeP = nodes.find((n) => n.id === "P");
+ expect(nodeP.data.shape).toBe("subroutine");
+
+ expect(edges[0].data.lineStyle).toBe("thick");
+ expect(edges[1].data.lineStyle).toBe("dashed");
+ });
+
+ it("should generate valid Mermaid code with style directives and line types", () => {
+ const nodes = [
+ { id: "A", data: { label: "Start", shape: "stadium", fillColor: "#1e3a8a", strokeColor: "#3b82f6" } },
+ { id: "B", data: { label: "Decision", shape: "diamond" } },
+ ];
+ const edges = [
+ { id: "e1", source: "A", target: "B", label: "Proceed", data: { lineStyle: "thick" } },
+ ];
+
+ const mermaidCode = generateMermaidFromFlow(nodes, edges, "LR");
+
+ expect(mermaidCode).toContain("flowchart LR");
+ expect(mermaidCode).toContain('A(["Start"])');
+ expect(mermaidCode).toContain('B{"Decision"}');
+ expect(mermaidCode).toContain("A ==>|Proceed| B");
+ expect(mermaidCode).toContain("style A fill:#1e3a8a,stroke:#3b82f6");
+ });
+
+ it("should fallback gracefully for empty inputs", () => {
+ const { nodes, edges } = parseMermaidToFlow("");
+ expect(nodes.length).toBeGreaterThan(0);
+ expect(edges.length).toBeGreaterThan(0);
+ });
+});