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: "" }); + }} + /> + )} ); }); + diff --git a/src/components/MermaidBlock.jsx b/src/components/MermaidBlock.jsx index 48e02de8..4f3f9237 100644 --- a/src/components/MermaidBlock.jsx +++ b/src/components/MermaidBlock.jsx @@ -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
{error}
; + return ( +
+ Mermaid Render Error: +
{error}
+
+ ); } - return
; + return ( +
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"} + + + + + +
+
+ + {/* 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. +
+ )} + +