From a68c59ac37abd243e9562a2c4ae7fd47cbef1526 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Wed, 5 Aug 2026 22:40:49 +0530 Subject: [PATCH 1/3] Added mermaid editor support --- src/components/MarkdownPreview.jsx | 40 +++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/components/MarkdownPreview.jsx b/src/components/MarkdownPreview.jsx index 704774e8..d75b5508 100644 --- a/src/components/MarkdownPreview.jsx +++ b/src/components/MarkdownPreview.jsx @@ -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; @@ -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, @@ -1885,7 +1887,20 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ > {parts.map((part, index) => part.type === "mermaid" ? ( - + { + if (!readOnly) { + setMermaidEditState({ + open: true, + initialCode: codeToEdit, + originalBlockCode: part.value, + }); + } + }} + /> ) : part.type === "excalidraw" ? ( readOnly ? (
@@ -2109,6 +2124,29 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ onCancel={() => setTableEditState({ open: false, initialMarkdown: "", sourceLine: null, lineCount: 0 })} /> )} + {mermaidEditState.open && ( + 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: "" }); + }} + /> + )} ); }); + From 10c7d113f8854b5149122d1ad9710a9f9b0a44f2 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Wed, 5 Aug 2026 22:40:52 +0530 Subject: [PATCH 2/3] Added mermaid editor support --- src/components/MermaidBlock.jsx | 14 +- src/components/mermaid/MermaidCanvas.jsx | 69 ++ src/components/mermaid/MermaidNode.jsx | 278 ++++++++ .../mermaid/MermaidVisualEditorModal.jsx | 596 ++++++++++++++++++ src/components/mermaid/mermaidParser.js | 304 +++++++++ src/styles/editor.css | 25 +- src/styles/mermaidEditor.css | 443 +++++++++++++ src/tests/components/mermaidParser.test.js | 78 +++ 8 files changed, 1802 insertions(+), 5 deletions(-) create mode 100644 src/components/mermaid/MermaidCanvas.jsx create mode 100644 src/components/mermaid/MermaidNode.jsx create mode 100644 src/components/mermaid/MermaidVisualEditorModal.jsx create mode 100644 src/components/mermaid/mermaidParser.js create mode 100644 src/styles/mermaidEditor.css create mode 100644 src/tests/components/mermaidParser.test.js diff --git a/src/components/MermaidBlock.jsx b/src/components/MermaidBlock.jsx index 48e02de8..e859a3bb 100644 --- a/src/components/MermaidBlock.jsx +++ b/src/components/MermaidBlock.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; let mermaidInitialized = false; -export function MermaidBlock({ code, index }) { +export function MermaidBlock({ code, index, onEdit }) { const [svg, setSvg] = useState(""); const [error, setError] = useState(""); @@ -54,5 +54,15 @@ export function MermaidBlock({ code, index }) { return
{error}
; } - return
; + return ( +
onEdit?.(code)} + style={{ + cursor: onEdit ? "pointer" : "default", + }} + 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..46782373 --- /dev/null +++ b/src/components/mermaid/MermaidCanvas.jsx @@ -0,0 +1,69 @@ +import React, { useMemo } from "react"; +import { + ReactFlow, + Controls, + Background, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { MermaidNode } from "./MermaidNode"; + +export function MermaidCanvas({ + nodes, + edges, + onNodesChange, + onEdgesChange, + onConnect, + onNodeClick, + onEdgeClick, + onPaneClick, +}) { + const nodeTypes = useMemo( + () => ({ + mermaidNode: MermaidNode, + }), + [] + ); + + return ( +
+ + + + +
+ ); +} diff --git a/src/components/mermaid/MermaidNode.jsx b/src/components/mermaid/MermaidNode.jsx new file mode 100644 index 00000000..a8c894a4 --- /dev/null +++ b/src/components/mermaid/MermaidNode.jsx @@ -0,0 +1,278 @@ +import React, { useState, useEffect } from "react"; +import { Handle, Position } from "@xyflow/react"; +import { Trash2, Square, Circle, Diamond } from "lucide-react"; +import { COLOR_PRESETS } from "./mermaidParser"; + +export function MermaidNode({ id, data, selected }) { + const [isEditing, setIsEditing] = useState(false); + const [label, setLabel] = useState(data.label || id); + const shape = data.shape || "rectangle"; + const fillColor = data.fillColor; + const strokeColor = data.strokeColor; + const textColor = data.textColor; + + useEffect(() => { + setLabel(data.label || id); + }, [data.label, id]); + + const handleBlur = () => { + setIsEditing(false); + if (data.onChangeLabel) { + data.onChangeLabel(id, label); + } + }; + + const handleKeyDown = (e) => { + if (e.key === "Enter") { + handleBlur(); + } + }; + + const handleShapeChange = (newShape) => { + if (data.onChangeShape) { + data.onChangeShape(id, newShape); + } + }; + + const handleColorPresetChange = (presetId) => { + const preset = COLOR_PRESETS.find((p) => p.id === presetId); + if (data.onChangeColor) { + data.onChangeColor(id, { + preset: presetId, + fill: preset?.id === "default" ? undefined : preset?.fill, + stroke: preset?.id === "default" ? undefined : preset?.stroke, + text: preset?.id === "default" ? undefined : preset?.text, + }); + } + }; + + const handleDelete = (e) => { + e.stopPropagation(); + if (data.onDeleteNode) { + data.onDeleteNode(id); + } + }; + + const getBorderRadius = () => { + switch (shape) { + case "stadium": + return "999px"; + case "rounded": + return "8px"; + case "circle": + return "50%"; + case "diamond": + return "4px"; + case "subroutine": + return "2px"; + case "rectangle": + default: + return "6px"; + } + }; + + const isDiamond = shape === "diamond"; + const isSubroutine = shape === "subroutine"; + + const nodeStyle = { + padding: isDiamond ? "16px 20px" : isSubroutine ? "10px 20px" : "10px 16px", + borderRadius: getBorderRadius(), + background: fillColor || "var(--surface-elevated, #ffffff)", + border: selected + ? "2px solid var(--accent-solid, #3b82f6)" + : strokeColor + ? `1px solid ${strokeColor}` + : "1px solid var(--border-soft, #cbd5e1)", + color: textColor || "var(--text-strong, #0f172a)", + minWidth: "120px", + textAlign: "center", + position: "relative", + transform: isDiamond ? "rotate(45deg)" : "none", + boxShadow: selected + ? "0 0 0 3px color-mix(in srgb, var(--accent-solid, #3b82f6) 30%, transparent), 0 4px 12px rgba(0, 0, 0, 0.15)" + : "0 2px 6px 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)", + }; + + + + return ( +
+ {/* Target handle top */} + + {/* Target handle left */} + + + {/* Subroutine inner border indicators */} + {isSubroutine && ( + <> +
+
+ + )} + + {/* Inner Content */} +
+ {isEditing ? ( + setLabel(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + autoFocus + className="mermaid-node-input" + style={{ + background: "var(--bg-subtle, #1e1e1e)", + color: "var(--text-main, #fff)", + border: "1px solid var(--accent-color, #007acc)", + borderRadius: "4px", + padding: "2px 6px", + fontSize: "0.8rem", + textAlign: "center", + width: "100%", + }} + /> + ) : ( +
setIsEditing(true)} + style={{ cursor: "pointer", userSelect: "none" }} + title="Double-click to edit label" + > + {label} +
+ )} + + {/* Toolbar on Node selection */} + {selected && ( +
+ + + + + + {/* Quick Color Preset Swatches */} +
+ {COLOR_PRESETS.slice(1, 6).map((preset) => ( +
+ + +
+ )} +
+ + {/* Source handle bottom */} + + {/* Source handle right */} + +
+ ); +} diff --git a/src/components/mermaid/MermaidVisualEditorModal.jsx b/src/components/mermaid/MermaidVisualEditorModal.jsx new file mode 100644 index 00000000..3e700428 --- /dev/null +++ b/src/components/mermaid/MermaidVisualEditorModal.jsx @@ -0,0 +1,596 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { + useNodesState, + useEdgesState, + addEdge, +} from "@xyflow/react"; +import dagre from "dagre"; +import { + Save, + X, + Plus, + RotateCcw, + Code2, + Eye, + Columns, + Workflow, + Sliders, + Palette, + Circle, + Diamond, + Trash2, +} 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 "../../styles/mermaidEditor.css"; + +export function MermaidVisualEditorModal({ + initialCode = "", + isOpen = false, + onClose, + onSave, +}) { + const [activeTab, setActiveTab] = useState("split"); // "split" | "visual" | "code" + const [direction, setDirection] = useState("TD"); + const [mermaidCode, setMermaidCode] = useState(""); + const [codeError, setCodeError] = useState(""); + const [selectedElement, setSelectedElement] = useState(null); // { type: "node" | "edge", id: string } + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + 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; + + // Sync canvas -> code + const syncCodeFromFlow = useCallback( + (currentNodes, currentEdges, currentDir) => { + const code = generateMermaidFromFlow(currentNodes, currentEdges, currentDir); + setMermaidCode(code); + setCodeError(""); + }, + [] + ); + + // 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 to run initialization ONLY when modal opens + const wasOpenRef = useRef(false); + useEffect(() => { + if (isOpen && !wasOpenRef.current) { + wasOpenRef.current = true; + const rawCode = initialCode.trim() || `flowchart TD\n A["Start Task"] -->|Next| B("In Progress")\n B --> C{"Is Done?"}\n C -->|Yes| D(("Complete"))`; + setMermaidCode(rawCode); + + try { + const parsed = parseMermaidToFlow(rawCode); + setDirection(parsed.direction || "TD"); + setNodes(attachNodeCallbacks(parsed.nodes)); + setEdges(parsed.edges); + setCodeError(""); + } catch (err) { + setCodeError(err.message || "Failed to parse initial Mermaid code."); + } + } else if (!isOpen) { + wasOpenRef.current = false; + setSelectedElement(null); + } + }, [isOpen, initialCode, attachNodeCallbacks, setEdges, setNodes]); + + // Sync code -> canvas on code edit + const handleCodeChange = (newCode) => { + setMermaidCode(newCode); + try { + const parsed = parseMermaidToFlow(newCode); + setDirection(parsed.direction || "TD"); + setNodes(attachNodeCallbacks(parsed.nodes)); + setEdges(parsed.edges); + setCodeError(""); + } catch (err) { + setCodeError(err.message || "Invalid Mermaid flowchart syntax."); + } + }; + + // 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 }); + }; + + // Change direction (TD, LR, RL, BT) + const handleDirectionChange = (newDir) => { + setDirection(newDir); + syncCodeFromFlow(nodesRef.current, edgesRef.current, newDir); + }; + + // Auto layout using Dagre + const handleAutoLayout = () => { + const g = new dagre.graphlib.Graph(); + g.setGraph({ rankdir: direction, 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); + + setNodes((nds) => + nds.map((node) => { + const pos = g.node(node.id); + return { + ...node, + position: { + x: (pos?.x || 100) - 75, + y: (pos?.y || 100) - 25, + }, + }; + }) + ); + }; + + // 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); + }; + + // Save handler + const handleSave = () => { + const finalCode = generateMermaidFromFlow(nodes, edges, direction); + 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; + + return ( + + {/* Standard App Modal Header (matching Excalidraw) */} +
+
+ +

Mermaid Visual Editor

+
+ + {/* View Mode Tabs */} +
+ + + +
+ + {/* Action buttons (matching Excalidraw) */} +
+ + + + +
+
+ + {/* Mermify Toolbar */} +
+
+
+ + + +
+ + + +
+ Layout: + {["TD", "LR", "RL", "BT"].map((dir) => ( + + ))} +
+
+
+ + {/* Modal Body / Views */} +
+ {(activeTab === "split" || activeTab === "visual") && ( +
+ + + {/* Mermify Property 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) => ( + + ))} +
+
+ +
+ +
+
+ )} +
+ )} + + {(activeTab === "split" || activeTab === "code") && ( +
+
+ Mermaid Code + {codeError && {codeError}} +
+