diff --git a/src/components/DiagramEditor.tsx b/src/components/DiagramEditor.tsx index 1e48a95..e2d8210 100644 --- a/src/components/DiagramEditor.tsx +++ b/src/components/DiagramEditor.tsx @@ -36,7 +36,7 @@ import { useDiagramStore } from '@/lib/store'; import { Checkpoint, Diagram, Tag } from '@/lib/types'; import { getLiveSyncClientId, useLiveSync } from '@/lib/useLiveSync'; -import { copyToClipboard, formatDate, cn } from '@/lib/utils'; +import { copyToClipboard, formatDate, cn, getDiagramType } from '@/lib/utils'; import { Info, Moon, @@ -93,6 +93,15 @@ const COLOR_PRESETS = [ { name: 'Slate', value: '#94a3b8' }, ] as const; +const SUPPORTED_COLOR_DIAGRAMS = [ + 'flowchart', + 'graph', + 'stateDiagram-v2', + 'classDiagram', + 'stateDiagram', + 'erDiagram' +]; + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); type NodeOption = { id: string; label?: string }; @@ -158,18 +167,53 @@ const extractMermaidNodes = (src: string): NodeOption[] => { return Array.from(nodes.values()).sort((a, b) => a.id.localeCompare(b.id)); }; -const getNodeIdFromLine = (line: string): string | null => { - const styleMatch = line.match(/^\s*style\s+([A-Za-z0-9_:-]+)/); +const getNodeIdFromLine = (line: string, type: string): string | null => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('%%')) return null; + + // 1. Common style directive + const styleMatch = trimmed.match(/^\s*style\s+([A-Za-z0-9_:-]+)/); if (styleMatch) return styleMatch[1]; - // Ignore edge-only lines to avoid showing palette on connectors - if (/\s*[A-Za-z0-9_:-]+\s*[-.]*[-=]*>\s*[A-Za-z0-9_:-]+/.test(line)) return null; - if (/\s*[A-Za-z0-9_:-]+\s*---\s*[A-Za-z0-9_:-]+/.test(line)) return null; + // 2. Connector filtering + const isEdge = + /\s*[A-Za-z0-9_:-]+\s*[-.]*[-=]*>\s*[A-Za-z0-9_:-]+/.test(line) || + /\s*[A-Za-z0-9_:-]+\s*---\s*[A-Za-z0-9_:-]+/.test(line); - const nodeMatch = line.match(/(^|\s)([A-Za-z0-9_:-]+)\s*(\[|\(|\{|\"|:::|>|\\{\\{)/); - if (nodeMatch) return nodeMatch[2]; + if (isEdge && type !== 'erDiagram') return null; - return null; + // 3. Diagram-specific logic + switch (type) { + case 'flowchart': + case 'graph': { + const match = trimmed.match(/^([A-Za-z0-9_][A-Za-z0-9_:-]*)\s*(?:\[|\(|\{|\"|:::|>|\\{\\{)/); + return match ? match[1] : null; + } + case 'sequenceDiagram': { + const match = trimmed.match(/^\s*(?:participant|actor)\s+([A-Za-z0-9_][A-Za-z0-9_:-]*)/i); + return match ? match[1] : null; + } + case 'erDiagram': { + const relMatch = trimmed.match(/^([A-Za-z0-9_]+)\s+[|{}-]{2,}/); + if (relMatch) return relMatch[1]; + const defMatch = trimmed.match(/^([A-Za-z0-9_]+)\s*\{/); + if (defMatch) return defMatch[1]; + return null; + } + case 'stateDiagram': + case 'stateDiagram-v2': { + const match = trimmed.match(/^\s*state\s+([A-Za-z0-9_][A-Za-z0-9_:-]*)/i); + return match ? match[1] : null; + } + case 'classDiagram': { + const match = trimmed.match(/^\s*class\s+([A-Za-z0-9_][A-Za-z0-9_:-]*)/i); + return match ? match[1] : null; + } + default: { + const match = trimmed.match(/^([A-Za-z0-9_][A-Za-z0-9_:-]*)\s*(\[|\(|\{|\"|:::|>|\\{\\{)/); + return match ? match[1] : null; + } + } }; const findNodeDefinitionRange = (content: string, nodeId: string): TextRange | null => { @@ -199,9 +243,7 @@ const getNodeFillColor = (content: string, nodeId: string): string | null => { const upsertNodeStyleLine = (content: string, nodeId: string, color: string | null): string => { const lines = content.split('\n'); const styleRegex = new RegExp(`^\\s*style\\s+${escapeRegExp(nodeId)}\\s+([^\\n]+)$`); - const nodeRegex = new RegExp(`(^|\\s)${escapeRegExp(nodeId)}\\s*(\\[|\\(|\\{|\"|:::|>|\\{\\{)`); const styleIndex = lines.findIndex((line) => styleRegex.test(line)); - const nodeIndex = lines.findIndex((line) => nodeRegex.test(line)); if (color === null) { if (styleIndex !== -1) { @@ -237,9 +279,7 @@ const upsertNodeStyleLine = (content: string, nodeId: string, color: string | nu const indent = lines[styleIndex].match(/^\s*/)?.[0] ?? ''; lines[styleIndex] = `${indent}style ${nodeId} ${buildProps(props)}`; } else { - const insertAt = nodeIndex !== -1 ? nodeIndex + 1 : lines.length; - const indent = nodeIndex !== -1 ? (lines[nodeIndex].match(/^\s*/)?.[0] ?? '') : ''; - lines.splice(insertAt, 0, `${indent}style ${nodeId} ${buildProps()}`); + lines.splice(lines.length, 0, ` style ${nodeId} ${buildProps()}`); } return lines.join('\n'); @@ -255,6 +295,7 @@ const nextIsoAfter = (currentIso: string): string => { export function DiagramEditor({ initialDiagram }: DiagramEditorProps) { const router = useRouter(); const [diagram, setDiagram] = useState(initialDiagram); + const [diagramType, setDiagramType] = useState(getDiagramType(initialDiagram.content)); const [tags, setTags] = useState(initialDiagram.tags || []); const [mounted, setMounted] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); @@ -404,6 +445,8 @@ export function DiagramEditor({ initialDiagram }: DiagramEditorProps) { }, [updateSettings]); useEffect(() => { + setDiagramType(getDiagramType(diagram.content)); + if (!selectedNodeId) { setSelectionRange(null); return; @@ -467,7 +510,7 @@ export function DiagramEditor({ initialDiagram }: DiagramEditorProps) { const handleCursorLineChange = useCallback( (line: string) => { - const nodeId = getNodeIdFromLine(line); + const nodeId = getNodeIdFromLine(line, diagramType); if (!nodeId) { setSelectedNode(null); setSelectionRange(null); @@ -479,7 +522,7 @@ export function DiagramEditor({ initialDiagram }: DiagramEditorProps) { const range = findNodeDefinitionRange(diagram.content, nodeId); setSelectionRange(range); }, - [diagram.content, nodeOptions, selectedNodeId] + [diagram.content, diagramType, nodeOptions, selectedNodeId] ); const handleNodeColorChange = useCallback( @@ -1062,7 +1105,7 @@ export function DiagramEditor({ initialDiagram }: DiagramEditorProps) { - {selectedNode && ( + {selectedNode && SUPPORTED_COLOR_DIAGRAMS.includes(diagramType) && (
diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 93d7641..5b24009 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -166,3 +166,21 @@ export async function copyToClipboard(text: string): Promise { export function sanitizeFilename(name: string): string { return name.replace(/[^a-z0-9]/gi, '_').toLowerCase(); } + +/** + * Get diagram type + */ +export function getDiagramType(text: string): string { + const lines = text.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('%%')) continue; + + const match = trimmed.match(/^(\S+)/); + if (match) return match[1]; + } + + return ''; +}