Features implemented: - Visual node/edge editing with property panels - Drag and drop node positioning - Color customization for nodes and edges - Text editing with real-time updates - Bidirectional Mermaid code synchronization - Subgraph/grouping support for layout control - Export functionality to JSON with position data - Layout lock system (experimental) - Edge label editing and styling - Comprehensive Mermaid syntax parser 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1727 lines
No EOL
76 KiB
PHP
1727 lines
No EOL
76 KiB
PHP
<?php
|
|
require_once 'config.php';
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title><?php echo SITE_TITLE; ?></title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--primary-btn-color: #333;
|
|
--primary-btn-hover-color: #555;
|
|
--background-color: #fff;
|
|
--text-color: #333;
|
|
--border-color: #ccc;
|
|
--header-footer-bg: #333;
|
|
--header-footer-text: #fff;
|
|
--textarea-bg: #fff;
|
|
--textarea-text: #333;
|
|
--output-bg: #fff;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Montserrat', sans-serif;
|
|
line-height: 1.6;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100vh;
|
|
background-color: var(--background-color);
|
|
color: var(--text-color);
|
|
transition: background-color 0.3s ease, color 0.3s ease;
|
|
}
|
|
header {
|
|
background-color: var(--header-footer-bg);
|
|
color: var(--header-footer-text);
|
|
text-align: center;
|
|
padding: 0.5rem;
|
|
}
|
|
main {
|
|
flex-grow: 1;
|
|
padding: 2rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.container {
|
|
display: flex;
|
|
gap: 2rem;
|
|
flex-grow: 1;
|
|
}
|
|
.input-area, .output-area {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
textarea {
|
|
width: 100%;
|
|
height: 300px;
|
|
margin-bottom: 1rem;
|
|
font-family: 'Montserrat', sans-serif;
|
|
background-color: var(--textarea-bg);
|
|
color: var(--textarea-text);
|
|
border: 1px solid var(--border-color);
|
|
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
|
}
|
|
#mermaidOutput {
|
|
border: 1px solid var(--border-color);
|
|
padding: 1rem;
|
|
flex-grow: 1;
|
|
overflow: auto;
|
|
background-color: #fff; /* Always keep diagram background white */
|
|
color: #333; /* Always keep diagram text dark */
|
|
transition: border-color 0.3s ease;
|
|
position: relative;
|
|
}
|
|
|
|
/* Interactive editing styles */
|
|
.mermaid-node {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.mermaid-node:hover {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
.mermaid-node.selected {
|
|
stroke: #ff6b6b !important;
|
|
stroke-width: 3px !important;
|
|
filter: drop-shadow(0 0 5px rgba(255, 107, 107, 0.5));
|
|
}
|
|
|
|
.mermaid-edge {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.mermaid-edge:hover {
|
|
stroke-width: 3px !important;
|
|
}
|
|
|
|
.mermaid-edge.selected {
|
|
stroke: #ff6b6b !important;
|
|
stroke-width: 3px !important;
|
|
}
|
|
|
|
.property-panel {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
width: 250px;
|
|
background: white;
|
|
border: 2px solid #ff6b6b;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
|
display: none;
|
|
z-index: 9999;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.property-panel.visible {
|
|
display: block !important;
|
|
}
|
|
|
|
.property-panel h3 {
|
|
margin: 0 0 10px 0;
|
|
font-size: 14px;
|
|
color: #333;
|
|
}
|
|
|
|
.property-panel label {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
.property-panel input, .property-panel select {
|
|
width: 100%;
|
|
padding: 5px;
|
|
margin-bottom: 10px;
|
|
border: 1px solid #ccc;
|
|
border-radius: 3px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.property-panel button {
|
|
width: 100%;
|
|
padding: 5px;
|
|
margin-top: 5px;
|
|
font-size: 12px;
|
|
}
|
|
.button-container {
|
|
margin-top: 1rem;
|
|
}
|
|
button {
|
|
padding: 0.5rem 1rem;
|
|
background-color: var(--primary-btn-color);
|
|
color: #fff;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-family: 'Montserrat', sans-serif;
|
|
margin-right: 0.5rem;
|
|
margin-bottom: 0.5rem;
|
|
transition: background-color 0.3s ease;
|
|
}
|
|
button:hover {
|
|
background-color: var(--primary-btn-hover-color);
|
|
}
|
|
footer {
|
|
background-color: var(--header-footer-bg);
|
|
color: var(--header-footer-text);
|
|
text-align: center;
|
|
padding: 1rem;
|
|
}
|
|
|
|
/* Dark Mode Toggle Button */
|
|
.dark-mode-toggle {
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
z-index: 1000;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background-color: var(--primary-btn-color);
|
|
border: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
color: #fff;
|
|
}
|
|
|
|
/* Dark Mode Styles */
|
|
.dark-mode {
|
|
--background-color: #1e1e1e;
|
|
--text-color: #f5f5f5;
|
|
--border-color: #444;
|
|
--header-footer-bg: #000;
|
|
--header-footer-text: #f5f5f5;
|
|
--textarea-bg: #333;
|
|
--textarea-text: #f5f5f5;
|
|
--output-bg: #333;
|
|
--primary-btn-color: #555;
|
|
--primary-btn-hover-color: #777;
|
|
}
|
|
|
|
/* Special styling for the output area heading in dark mode */
|
|
.dark-mode .output-area h2 {
|
|
background-color: #333;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
margin-bottom: 12px;
|
|
}
|
|
</style>
|
|
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1><?php echo SITE_TITLE; ?></h1>
|
|
</header>
|
|
|
|
<!-- Dark Mode Toggle Button -->
|
|
<button id="darkModeToggle" class="dark-mode-toggle" title="Toggle Dark Mode">
|
|
<span id="lightModeIcon">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
|
<!-- Sun icon path -->
|
|
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
|
|
</svg>
|
|
</span>
|
|
<span id="darkModeIcon" style="display: none;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
|
<!-- Moon icon path -->
|
|
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
<main>
|
|
<div class="container">
|
|
<div class="input-area">
|
|
<h2>Input Mermaid Diagram Code</h2>
|
|
<textarea id="mermaidInput">graph TD
|
|
A[Christmas] -->|Get money| B(Go shopping)
|
|
B --> C{Let me think}
|
|
C -->|One| D[Android]
|
|
C -->|Two| E[Nuka Cola]
|
|
C -->|Three| F[Cold fusion generator]</textarea>
|
|
<div class="button-container">
|
|
<button id="renderBtn">Render Diagram</button>
|
|
<button id="exportMmdBtn">Download as .mmd</button>
|
|
<button id="importMmdBtn">Import .mmd</button>
|
|
<input type="file" id="fileInput" accept=".mmd" style="display: none;">
|
|
</div>
|
|
</div>
|
|
<div class="output-area">
|
|
<h2>Rendered Diagram</h2>
|
|
<div style="position: relative;">
|
|
<div id="mermaidOutput"></div>
|
|
<div class="property-panel" id="propertyPanel">
|
|
<h3>Element Properties</h3>
|
|
<div id="propertyContent">
|
|
<p>Select an element to edit its properties</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="button-container">
|
|
<button id="exportBtn">Download as PNG</button>
|
|
<button id="toggleEditMode">Enable Edit Mode</button>
|
|
<button id="syncCodeBtn" style="display: none;">Sync Code</button>
|
|
<button id="testPanelBtn" style="display: none;">Test Panel</button>
|
|
<button id="addSubgraphBtn" style="display: none;">Add Group</button>
|
|
<button id="exportJsonBtn" style="display: none;">Export Layout</button>
|
|
<button id="lockLayoutBtn" style="display: none;">Lock Layout</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
<footer>
|
|
<p>© <?php echo date('Y'); ?> <?php echo SITE_TITLE; ?></p>
|
|
</footer>
|
|
<script>
|
|
// Initial configuration will be set based on dark mode preference
|
|
let initialConfig = {
|
|
startOnLoad: false,
|
|
theme: 'base',
|
|
themeVariables: {
|
|
primaryColor: '#ffc406',
|
|
primaryTextColor: '#000000',
|
|
primaryBorderColor: '#000000',
|
|
lineColor: '#000000',
|
|
secondaryColor: '#B8B9B9',
|
|
tertiaryColor: '#B8B9B9'
|
|
},
|
|
flowchart: {
|
|
useMaxWidth: false,
|
|
htmlLabels: true,
|
|
curve: 'basis'
|
|
},
|
|
securityLevel: 'loose',
|
|
fontFamily: 'Montserrat, sans-serif',
|
|
fontSize: 14
|
|
};
|
|
|
|
// Always keep the Mermaid diagram in light mode for consistency
|
|
// Dark mode only applies to the UI, not the diagram itself
|
|
|
|
// Initialize with the appropriate theme
|
|
mermaid.initialize(initialConfig);
|
|
|
|
function renderMermaidDiagram() {
|
|
const input = document.getElementById('mermaidInput').value;
|
|
const output = document.getElementById('mermaidOutput');
|
|
|
|
output.innerHTML = ''; // Clear previous content
|
|
|
|
mermaid.render('mermaid-svg', input).then(result => {
|
|
output.innerHTML = result.svg;
|
|
}).catch(error => {
|
|
output.innerHTML = `<p style="color: red;">Error rendering diagram: ${error.message}</p>`;
|
|
});
|
|
}
|
|
|
|
// Initial render
|
|
document.addEventListener('DOMContentLoaded', renderMermaidDiagram);
|
|
|
|
document.getElementById('renderBtn').addEventListener('click', renderMermaidDiagram);
|
|
|
|
document.getElementById('exportBtn').addEventListener('click', function() {
|
|
const svg = document.querySelector('#mermaidOutput svg');
|
|
if (!svg) {
|
|
alert('No diagram to export. Please render a diagram first.');
|
|
return;
|
|
}
|
|
const svgData = new XMLSerializer().serializeToString(svg);
|
|
|
|
// Create a high-resolution canvas
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const scale = 4; // Increase this for higher resolution
|
|
canvas.width = svg.viewBox.baseVal.width * scale;
|
|
canvas.height = svg.viewBox.baseVal.height * scale;
|
|
|
|
const img = new Image();
|
|
img.onload = function() {
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
const pngFile = canvas.toDataURL('image/png');
|
|
const downloadLink = document.createElement('a');
|
|
downloadLink.download = 'mermaid_diagram_high_res.png';
|
|
downloadLink.href = pngFile;
|
|
downloadLink.click();
|
|
};
|
|
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
|
|
});
|
|
|
|
// Export as .mmd file
|
|
document.getElementById('exportMmdBtn').addEventListener('click', function() {
|
|
const mermaidCode = document.getElementById('mermaidInput').value;
|
|
if (!mermaidCode.trim()) {
|
|
alert('No diagram code to export.');
|
|
return;
|
|
}
|
|
|
|
// Create a blob with the mermaid code
|
|
const blob = new Blob([mermaidCode], {type: 'text/plain'});
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// Create download link
|
|
const downloadLink = document.createElement('a');
|
|
downloadLink.download = 'mermaid_diagram.mmd';
|
|
downloadLink.href = url;
|
|
downloadLink.click();
|
|
|
|
// Clean up
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
// Import .mmd file
|
|
document.getElementById('importMmdBtn').addEventListener('click', function() {
|
|
document.getElementById('fileInput').click();
|
|
});
|
|
|
|
document.getElementById('fileInput').addEventListener('change', function(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
const contents = e.target.result;
|
|
document.getElementById('mermaidInput').value = contents;
|
|
renderMermaidDiagram();
|
|
};
|
|
reader.readAsText(file);
|
|
|
|
// Reset file input
|
|
event.target.value = '';
|
|
});
|
|
|
|
// Dark mode toggle functionality
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Check for saved dark mode preference
|
|
const darkModeEnabled = localStorage.getItem('darkMode') === 'enabled';
|
|
|
|
if (darkModeEnabled) {
|
|
document.body.classList.add('dark-mode');
|
|
document.getElementById('lightModeIcon').style.display = 'none';
|
|
document.getElementById('darkModeIcon').style.display = 'block';
|
|
|
|
// Update Mermaid theme for dark mode
|
|
updateMermaidTheme(true);
|
|
}
|
|
|
|
// Toggle dark mode when button is clicked
|
|
document.getElementById('darkModeToggle').addEventListener('click', function() {
|
|
document.body.classList.toggle('dark-mode');
|
|
const isDarkMode = document.body.classList.contains('dark-mode');
|
|
|
|
// Save preference and toggle icons
|
|
if (isDarkMode) {
|
|
localStorage.setItem('darkMode', 'enabled');
|
|
document.getElementById('lightModeIcon').style.display = 'none';
|
|
document.getElementById('darkModeIcon').style.display = 'block';
|
|
} else {
|
|
localStorage.setItem('darkMode', 'disabled');
|
|
document.getElementById('lightModeIcon').style.display = 'block';
|
|
document.getElementById('darkModeIcon').style.display = 'none';
|
|
}
|
|
|
|
// Update Mermaid theme based on dark mode
|
|
updateMermaidTheme(isDarkMode);
|
|
|
|
// Re-render the diagram with new theme
|
|
renderMermaidDiagram();
|
|
});
|
|
});
|
|
|
|
// Function to update Mermaid theme based on dark mode
|
|
function updateMermaidTheme(isDarkMode) {
|
|
// Always use light theme for diagrams regardless of site theme
|
|
// This ensures exported diagrams look good and consistent
|
|
mermaid.initialize({
|
|
startOnLoad: false,
|
|
theme: 'base',
|
|
themeVariables: {
|
|
primaryColor: '#ffc406',
|
|
primaryTextColor: '#000000',
|
|
primaryBorderColor: '#000000',
|
|
lineColor: '#000000',
|
|
secondaryColor: '#B8B9B9',
|
|
tertiaryColor: '#B8B9B9'
|
|
},
|
|
flowchart: {
|
|
useMaxWidth: false,
|
|
htmlLabels: true,
|
|
curve: 'basis'
|
|
},
|
|
securityLevel: 'loose',
|
|
fontFamily: 'Montserrat, sans-serif',
|
|
fontSize: 14
|
|
});
|
|
}
|
|
|
|
// Mermaid Parser and Code Generator
|
|
class MermaidParser {
|
|
constructor() {
|
|
this.nodes = new Map();
|
|
this.edges = [];
|
|
this.diagramType = 'flowchart';
|
|
this.direction = 'TD';
|
|
}
|
|
|
|
parseCode(mermaidCode) {
|
|
this.nodes.clear();
|
|
this.edges = [];
|
|
|
|
const lines = mermaidCode.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('//'));
|
|
|
|
if (lines.length === 0) return;
|
|
|
|
// Parse first line to get diagram type and direction
|
|
const firstLine = lines[0];
|
|
if (firstLine.includes('graph') || firstLine.includes('flowchart')) {
|
|
this.diagramType = firstLine.includes('flowchart') ? 'flowchart' : 'graph';
|
|
const match = firstLine.match(/(TD|TB|BT|RL|LR|DT)/);
|
|
this.direction = match ? match[1] : 'TD';
|
|
}
|
|
|
|
// Parse nodes and edges
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
this.parseLine(line);
|
|
}
|
|
|
|
return {
|
|
nodes: this.nodes,
|
|
edges: this.edges,
|
|
diagramType: this.diagramType,
|
|
direction: this.direction
|
|
};
|
|
}
|
|
|
|
parseLine(line) {
|
|
console.log('Parsing line:', line);
|
|
|
|
// Updated pattern to handle complex Mermaid syntax:
|
|
// A[Christmas] -->|Get money| B(Go shopping)
|
|
// Node format: ID + optional [label] or (label) or {label}
|
|
const nodeWithLabel = '([A-Za-z0-9_]+)(?:\\[([^\\]]+)\\]|\\(([^\\)]+)\\)|\\{([^\\}]+)\\})?';
|
|
const connectionTypes = '(-->|---|--\\||-.->|==>\\||[=-]{2,}[>|]?)';
|
|
const edgeLabel = '(?:\\|([^|]+)\\|)?';
|
|
|
|
// Full pattern: NodeA -->|label| NodeB
|
|
const connectionPattern = new RegExp(
|
|
`${nodeWithLabel}\\s*${connectionTypes}${edgeLabel}\\s*${nodeWithLabel}`
|
|
);
|
|
|
|
console.log('Using pattern:', connectionPattern);
|
|
|
|
// Helper function to extract node info from regex groups
|
|
const extractNodeInfo = (nodeId, bracketLabel, parenLabel, braceLabel) => {
|
|
let text = nodeId; // Default to ID
|
|
let shape = 'rect'; // Default shape
|
|
|
|
if (bracketLabel) {
|
|
text = bracketLabel;
|
|
shape = 'rect';
|
|
} else if (parenLabel) {
|
|
text = parenLabel;
|
|
shape = 'round';
|
|
} else if (braceLabel) {
|
|
text = braceLabel;
|
|
shape = 'diamond';
|
|
}
|
|
|
|
return { id: nodeId, text, shape };
|
|
};
|
|
|
|
// Parse connections
|
|
const connectionMatch = line.match(connectionPattern);
|
|
if (connectionMatch) {
|
|
console.log('Connection match groups:', connectionMatch);
|
|
|
|
// Extract groups from the complex regex
|
|
const fromNodeId = connectionMatch[1];
|
|
const fromBracketLabel = connectionMatch[2];
|
|
const fromParenLabel = connectionMatch[3];
|
|
const fromBraceLabel = connectionMatch[4];
|
|
const connectionType = connectionMatch[5];
|
|
const edgeLabel = connectionMatch[6] || '';
|
|
const toNodeId = connectionMatch[7];
|
|
const toBracketLabel = connectionMatch[8];
|
|
const toParenLabel = connectionMatch[9];
|
|
const toBraceLabel = connectionMatch[10];
|
|
|
|
console.log('Parsed connection:', {
|
|
from: fromNodeId,
|
|
to: toNodeId,
|
|
type: connectionType,
|
|
edgeLabel
|
|
});
|
|
|
|
// Extract node information
|
|
const fromNodeInfo = extractNodeInfo(fromNodeId, fromBracketLabel, fromParenLabel, fromBraceLabel);
|
|
const toNodeInfo = extractNodeInfo(toNodeId, toBracketLabel, toParenLabel, toBraceLabel);
|
|
|
|
console.log('From node info:', fromNodeInfo);
|
|
console.log('To node info:', toNodeInfo);
|
|
|
|
// Add/update nodes
|
|
if (!this.nodes.has(fromNodeId)) {
|
|
this.nodes.set(fromNodeId, {
|
|
...fromNodeInfo,
|
|
style: { fill: '#ffc406', stroke: '#000000', color: '#000000' },
|
|
position: { x: 0, y: 0 }
|
|
});
|
|
console.log('Added fromNode:', fromNodeId);
|
|
} else {
|
|
// Update existing node with new text if available
|
|
const existingNode = this.nodes.get(fromNodeId);
|
|
if (fromNodeInfo.text !== fromNodeId) {
|
|
existingNode.text = fromNodeInfo.text;
|
|
existingNode.shape = fromNodeInfo.shape;
|
|
}
|
|
}
|
|
|
|
if (!this.nodes.has(toNodeId)) {
|
|
this.nodes.set(toNodeId, {
|
|
...toNodeInfo,
|
|
style: { fill: '#ffc406', stroke: '#000000', color: '#000000' },
|
|
position: { x: 0, y: 0 }
|
|
});
|
|
console.log('Added toNode:', toNodeId);
|
|
} else {
|
|
// Update existing node with new text if available
|
|
const existingNode = this.nodes.get(toNodeId);
|
|
if (toNodeInfo.text !== toNodeId) {
|
|
existingNode.text = toNodeInfo.text;
|
|
existingNode.shape = toNodeInfo.shape;
|
|
}
|
|
}
|
|
|
|
const edge = {
|
|
from: fromNodeId,
|
|
to: toNodeId,
|
|
type: connectionType,
|
|
label: edgeLabel,
|
|
style: {
|
|
stroke: '#000000',
|
|
strokeWidth: 2,
|
|
strokeDasharray: 'none'
|
|
}
|
|
};
|
|
|
|
this.edges.push(edge);
|
|
console.log('Added edge:', edge);
|
|
} else {
|
|
console.log('No connection pattern matched for line:', line);
|
|
}
|
|
}
|
|
|
|
determineNodeShape(nodeString) {
|
|
if (nodeString.includes('[') && nodeString.includes(']')) return 'rect';
|
|
if (nodeString.includes('(') && nodeString.includes(')')) return 'round';
|
|
if (nodeString.includes('{') && nodeString.includes('}')) return 'diamond';
|
|
if (nodeString.includes('((') && nodeString.includes('))')) return 'circle';
|
|
return 'rect';
|
|
}
|
|
|
|
generateCode(nodes, edges, diagramType = 'flowchart', direction = 'TD', subgraphs = new Map()) {
|
|
let code = `${diagramType} ${direction}\n`;
|
|
|
|
const nodesInSubgraphs = new Set();
|
|
|
|
// Generate subgraphs first
|
|
for (const [subgraphName, subgraph] of subgraphs) {
|
|
code += `\n subgraph ${subgraphName}\n`;
|
|
|
|
// Add nodes that belong to this subgraph
|
|
for (const nodeId of subgraph.nodes) {
|
|
if (nodes.has(nodeId)) {
|
|
const node = nodes.get(nodeId);
|
|
const shapeStart = this.getShapeStart(node.shape);
|
|
const shapeEnd = this.getShapeEnd(node.shape);
|
|
code += ` ${nodeId}${shapeStart}${node.text}${shapeEnd}\n`;
|
|
nodesInSubgraphs.add(nodeId);
|
|
}
|
|
}
|
|
|
|
code += ` end\n`;
|
|
}
|
|
|
|
// Generate remaining nodes (not in subgraphs)
|
|
for (const [nodeId, node] of nodes) {
|
|
if (!nodesInSubgraphs.has(nodeId)) {
|
|
const shapeStart = this.getShapeStart(node.shape);
|
|
const shapeEnd = this.getShapeEnd(node.shape);
|
|
code += ` ${nodeId}${shapeStart}${node.text}${shapeEnd}\n`;
|
|
}
|
|
}
|
|
|
|
// Generate edges
|
|
if (edges.length > 0) {
|
|
code += '\n %% Connections\n';
|
|
edges.forEach(edge => {
|
|
const label = edge.label ? `|${edge.label}|` : '';
|
|
code += ` ${edge.from} ${edge.type}${label} ${edge.to}\n`;
|
|
});
|
|
}
|
|
|
|
// Generate styling section if nodes have custom colors
|
|
let hasCustomStyles = false;
|
|
let styleCode = '';
|
|
|
|
for (const [nodeId, node] of nodes) {
|
|
if (node.style.fill !== '#ffc406' || node.style.color !== '#000000') {
|
|
if (!hasCustomStyles) {
|
|
styleCode += '\n %% Styling\n';
|
|
hasCustomStyles = true;
|
|
}
|
|
styleCode += ` style ${nodeId} fill:${node.style.fill},color:${node.style.color}\n`;
|
|
}
|
|
}
|
|
|
|
// Add edge styling if any edges have custom styles
|
|
edges.forEach((edge, index) => {
|
|
if (edge.style.stroke !== '#000000' || edge.style.strokeWidth !== 2) {
|
|
if (!hasCustomStyles) {
|
|
styleCode += '\n %% Styling\n';
|
|
hasCustomStyles = true;
|
|
}
|
|
styleCode += ` linkStyle ${index} stroke:${edge.style.stroke},stroke-width:${edge.style.strokeWidth}\n`;
|
|
}
|
|
});
|
|
|
|
code += styleCode;
|
|
return code;
|
|
}
|
|
|
|
getShapeStart(shape) {
|
|
switch (shape) {
|
|
case 'round': return '(';
|
|
case 'diamond': return '{';
|
|
case 'circle': return '((';
|
|
default: return '[';
|
|
}
|
|
}
|
|
|
|
getShapeEnd(shape) {
|
|
switch (shape) {
|
|
case 'round': return ')';
|
|
case 'diamond': return '}';
|
|
case 'circle': return '))';
|
|
default: return ']';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Interactive Editing System
|
|
class MermaidEditor {
|
|
constructor() {
|
|
this.isEditMode = false;
|
|
this.selectedElement = null;
|
|
this.diagramData = { nodes: new Map(), edges: [] };
|
|
this.isDragging = false;
|
|
this.dragOffset = { x: 0, y: 0 };
|
|
this.parser = new MermaidParser();
|
|
this.nodePositions = new Map();
|
|
this.subgraphs = new Map(); // Store subgraph information
|
|
this.isLayoutLocked = false;
|
|
this.savedPositions = new Map(); // Store manually adjusted positions
|
|
}
|
|
|
|
toggleEditMode() {
|
|
this.isEditMode = !this.isEditMode;
|
|
const toggleBtn = document.getElementById('toggleEditMode');
|
|
const syncBtn = document.getElementById('syncCodeBtn');
|
|
|
|
if (this.isEditMode) {
|
|
toggleBtn.textContent = 'Disable Edit Mode';
|
|
toggleBtn.style.backgroundColor = '#ff6b6b';
|
|
syncBtn.style.display = 'inline-block';
|
|
document.getElementById('testPanelBtn').style.display = 'inline-block';
|
|
document.getElementById('addSubgraphBtn').style.display = 'inline-block';
|
|
document.getElementById('exportJsonBtn').style.display = 'inline-block';
|
|
document.getElementById('lockLayoutBtn').style.display = 'inline-block';
|
|
this.parseCurrentCode();
|
|
this.enableInteractivity();
|
|
} else {
|
|
toggleBtn.textContent = 'Enable Edit Mode';
|
|
toggleBtn.style.backgroundColor = '';
|
|
syncBtn.style.display = 'none';
|
|
document.getElementById('testPanelBtn').style.display = 'none';
|
|
document.getElementById('addSubgraphBtn').style.display = 'none';
|
|
document.getElementById('exportJsonBtn').style.display = 'none';
|
|
document.getElementById('lockLayoutBtn').style.display = 'none';
|
|
this.disableInteractivity();
|
|
this.hidePropertyPanel();
|
|
}
|
|
}
|
|
|
|
parseCurrentCode() {
|
|
const mermaidCode = document.getElementById('mermaidInput').value;
|
|
console.log('Parsing current code:', mermaidCode);
|
|
|
|
const parsedData = this.parser.parseCode(mermaidCode);
|
|
console.log('Parsed data:', parsedData);
|
|
|
|
if (parsedData) {
|
|
this.diagramData = parsedData;
|
|
console.log('Set diagram data:', this.diagramData);
|
|
this.syncVisualPositions();
|
|
} else {
|
|
console.log('Failed to parse Mermaid code');
|
|
}
|
|
}
|
|
|
|
syncVisualPositions() {
|
|
// Capture current visual positions of nodes
|
|
const svg = document.querySelector('#mermaidOutput svg');
|
|
if (!svg) return;
|
|
|
|
const nodes = svg.querySelectorAll('.node');
|
|
console.log('Syncing positions for', nodes.length, 'nodes');
|
|
|
|
nodes.forEach(nodeElement => {
|
|
const nodeId = this.extractNodeId(nodeElement);
|
|
if (nodeId && this.diagramData.nodes.has(nodeId)) {
|
|
const transform = nodeElement.getAttribute('transform');
|
|
if (transform) {
|
|
const match = transform.match(/translate\(([-\d.]+),\s*([-\d.]+)\)/);
|
|
if (match) {
|
|
const node = this.diagramData.nodes.get(nodeId);
|
|
node.position = {
|
|
x: parseFloat(match[1]),
|
|
y: parseFloat(match[2])
|
|
};
|
|
console.log(`Synced ${nodeId} position to (${node.position.x}, ${node.position.y})`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log('Final node positions:',
|
|
Array.from(this.diagramData.nodes.entries()).map(([id, node]) =>
|
|
({ id, position: node.position }))
|
|
);
|
|
}
|
|
|
|
extractNodeId(nodeElement) {
|
|
// Debug the node element structure
|
|
console.log('Extracting ID from node:', nodeElement);
|
|
console.log('Node classes:', nodeElement.classList);
|
|
console.log('Node id attribute:', nodeElement.getAttribute('id'));
|
|
|
|
// Try multiple methods to extract node ID
|
|
const id = nodeElement.getAttribute('id');
|
|
if (id) {
|
|
// Clean up Mermaid-generated IDs
|
|
const patterns = [
|
|
/flowchart-([A-Za-z0-9_]+)-\d+/, // flowchart-A-123
|
|
/([A-Za-z0-9_]+)-\d+$/, // A-123
|
|
/flowchart-([A-Za-z0-9_]+)$/, // flowchart-A
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
const match = id.match(pattern);
|
|
if (match) {
|
|
console.log('Extracted node ID:', match[1]);
|
|
return match[1];
|
|
}
|
|
}
|
|
|
|
// Fallback: use the ID as-is or extract last part
|
|
const fallback = id.split('-').pop();
|
|
console.log('Fallback node ID:', fallback);
|
|
return fallback;
|
|
}
|
|
|
|
// Try to find ID from class names
|
|
const classList = Array.from(nodeElement.classList);
|
|
for (const className of classList) {
|
|
if (className.startsWith('node-')) {
|
|
const nodeId = className.replace('node-', '');
|
|
console.log('Found node ID from class:', nodeId);
|
|
return nodeId;
|
|
}
|
|
}
|
|
|
|
console.log('Could not extract node ID');
|
|
return null;
|
|
}
|
|
|
|
enableInteractivity() {
|
|
const svg = document.querySelector('#mermaidOutput svg');
|
|
if (!svg) return;
|
|
|
|
// Add event listeners to all nodes and edges
|
|
this.addEventListenersToElements(svg);
|
|
}
|
|
|
|
disableInteractivity() {
|
|
const svg = document.querySelector('#mermaidOutput svg');
|
|
if (!svg) return;
|
|
|
|
// Remove event listeners and classes
|
|
svg.querySelectorAll('.mermaid-node, .mermaid-edge').forEach(el => {
|
|
el.classList.remove('mermaid-node', 'mermaid-edge', 'selected');
|
|
});
|
|
|
|
this.selectedElement = null;
|
|
}
|
|
|
|
addEventListenersToElements(svg) {
|
|
console.log('Adding event listeners to SVG:', svg);
|
|
|
|
// Debug: Log the SVG structure
|
|
console.log('SVG innerHTML preview:', svg.innerHTML.substring(0, 500));
|
|
|
|
// Find all flowchart nodes and edges
|
|
const nodes = svg.querySelectorAll('.node');
|
|
|
|
// Try different edge selectors (more comprehensive)
|
|
let edges = svg.querySelectorAll('.edgePath');
|
|
if (edges.length === 0) edges = svg.querySelectorAll('.edge');
|
|
if (edges.length === 0) edges = svg.querySelectorAll('.flowchart-link');
|
|
if (edges.length === 0) edges = svg.querySelectorAll('path[marker-end]');
|
|
|
|
// If still no edges, try to find any clickable path elements
|
|
if (edges.length === 0) {
|
|
const allPaths = svg.querySelectorAll('path');
|
|
edges = Array.from(allPaths).filter(path => {
|
|
const d = path.getAttribute('d');
|
|
return d && d.includes('L') && !path.classList.contains('arrowMarkerPath');
|
|
});
|
|
}
|
|
|
|
console.log('Found nodes:', nodes.length);
|
|
console.log('Found edges:', edges.length);
|
|
|
|
// Debug: Show all path elements to find edge selector
|
|
const allPaths = svg.querySelectorAll('path');
|
|
console.log('All path elements:', allPaths.length);
|
|
if (allPaths.length > 0) {
|
|
console.log('First path element:', allPaths[0]);
|
|
console.log('First path classes:', allPaths[0].classList);
|
|
}
|
|
|
|
// Debug first node structure
|
|
if (nodes.length > 0) {
|
|
console.log('First node structure:', nodes[0]);
|
|
console.log('First node HTML:', nodes[0].outerHTML);
|
|
}
|
|
|
|
nodes.forEach(node => {
|
|
node.classList.add('mermaid-node');
|
|
node.addEventListener('click', (e) => this.selectElement(e, 'node'));
|
|
node.addEventListener('mousedown', (e) => this.startDrag(e, node));
|
|
});
|
|
|
|
edges.forEach(edge => {
|
|
edge.classList.add('mermaid-edge');
|
|
edge.addEventListener('click', (e) => this.selectElement(e, 'edge'));
|
|
});
|
|
|
|
// Add global mouse events for dragging
|
|
svg.addEventListener('mousemove', (e) => this.drag(e));
|
|
svg.addEventListener('mouseup', () => this.endDrag());
|
|
}
|
|
|
|
selectElement(event, type) {
|
|
if (!this.isEditMode) {
|
|
console.log('Not in edit mode, ignoring click');
|
|
return;
|
|
}
|
|
|
|
event.stopPropagation();
|
|
console.log('Element selected:', type, event.currentTarget);
|
|
|
|
// Remove previous selection
|
|
if (this.selectedElement) {
|
|
this.selectedElement.classList.remove('selected');
|
|
}
|
|
|
|
// Select new element
|
|
this.selectedElement = event.currentTarget;
|
|
this.selectedElement.classList.add('selected');
|
|
|
|
console.log('Element now selected, showing property panel');
|
|
|
|
// Show property panel
|
|
this.showPropertyPanel(type, this.selectedElement);
|
|
}
|
|
|
|
showPropertyPanel(type, element) {
|
|
const panel = document.getElementById('propertyPanel');
|
|
const content = document.getElementById('propertyContent');
|
|
|
|
console.log('Showing property panel for:', type, element);
|
|
|
|
if (type === 'node') {
|
|
// Look for text in foreignObject (Mermaid v10+ structure)
|
|
const foreignObjects = element.querySelectorAll('foreignObject');
|
|
const textElements = element.querySelectorAll('text');
|
|
const nodeLabelElements = element.querySelectorAll('.nodeLabel');
|
|
|
|
console.log('Found foreignObject elements:', foreignObjects);
|
|
console.log('Found text elements:', textElements);
|
|
console.log('Found nodeLabel elements:', nodeLabelElements);
|
|
|
|
let currentText = '';
|
|
|
|
// Try different ways to extract text
|
|
if (nodeLabelElements.length > 0) {
|
|
// Method 1: Extract from .nodeLabel elements
|
|
nodeLabelElements.forEach(labelEl => {
|
|
const text = labelEl.textContent.trim();
|
|
if (text) {
|
|
currentText += (currentText ? ' ' : '') + text;
|
|
}
|
|
});
|
|
} else if (foreignObjects.length > 0) {
|
|
// Method 2: Extract from foreignObject
|
|
foreignObjects.forEach(fo => {
|
|
const text = fo.textContent.trim();
|
|
if (text) {
|
|
currentText += (currentText ? ' ' : '') + text;
|
|
}
|
|
});
|
|
} else if (textElements.length > 0) {
|
|
// Method 3: Traditional SVG text elements
|
|
textElements.forEach(textEl => {
|
|
const text = textEl.textContent.trim();
|
|
if (text && !text.match(/^flowchart-/)) {
|
|
currentText += (currentText ? ' ' : '') + text;
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('Extracted text:', currentText);
|
|
|
|
const nodeId = this.extractNodeId(element);
|
|
const shapeElement = element.querySelector('rect, circle, polygon, path');
|
|
|
|
// Get current colors from styles or data model
|
|
let currentFill = '#ffc406';
|
|
let currentTextColor = '#000000';
|
|
|
|
if (nodeId && this.diagramData.nodes.has(nodeId)) {
|
|
const node = this.diagramData.nodes.get(nodeId);
|
|
currentFill = node.style.fill || currentFill;
|
|
currentTextColor = node.style.color || currentTextColor;
|
|
// Use text from data model if available
|
|
if (node.text && !currentText) {
|
|
currentText = node.text;
|
|
}
|
|
}
|
|
|
|
content.innerHTML = `
|
|
<label>Node ID:</label>
|
|
<input type="text" id="nodeId" value="${nodeId || 'unknown'}" readonly>
|
|
|
|
<label>Node Text:</label>
|
|
<input type="text" id="nodeText" value="${currentText}">
|
|
|
|
<label>Node Color:</label>
|
|
<input type="color" id="nodeColor" value="${currentFill}">
|
|
|
|
<label>Text Color:</label>
|
|
<input type="color" id="textColor" value="${currentTextColor}">
|
|
|
|
<button onclick="mermaidEditor.updateNodeProperties()">Update Node</button>
|
|
<button onclick="mermaidEditor.deleteElement()">Delete Node</button>
|
|
<hr>
|
|
<label>Grouping:</label>
|
|
<input type="text" id="subgraphName" placeholder="Group name (optional)">
|
|
<button onclick="mermaidEditor.addToSubgraph('${nodeId}')">Add to Group</button>
|
|
`;
|
|
} else if (type === 'edge') {
|
|
const pathElement = element.querySelector('path');
|
|
let currentStroke = '#000000';
|
|
let currentWidth = 2;
|
|
let currentStyle = 'solid';
|
|
let currentLabel = '';
|
|
|
|
// Try to find edge in data model to get current label
|
|
const edgeIndex = Array.from(element.parentElement.children).indexOf(element);
|
|
if (this.diagramData.edges[edgeIndex]) {
|
|
currentLabel = this.diagramData.edges[edgeIndex].label || '';
|
|
}
|
|
|
|
if (pathElement) {
|
|
currentStroke = pathElement.style.stroke || currentStroke;
|
|
currentWidth = parseInt(pathElement.style.strokeWidth) || currentWidth;
|
|
const dashArray = pathElement.style.strokeDasharray;
|
|
if (dashArray === '5,5') currentStyle = 'dashed';
|
|
else if (dashArray === '2,2') currentStyle = 'dotted';
|
|
}
|
|
|
|
content.innerHTML = `
|
|
<label>Edge Label:</label>
|
|
<input type="text" id="edgeLabel" value="${currentLabel}">
|
|
|
|
<label>Edge Style:</label>
|
|
<select id="edgeStyle">
|
|
<option value="solid" ${currentStyle === 'solid' ? 'selected' : ''}>Solid</option>
|
|
<option value="dashed" ${currentStyle === 'dashed' ? 'selected' : ''}>Dashed</option>
|
|
<option value="dotted" ${currentStyle === 'dotted' ? 'selected' : ''}>Dotted</option>
|
|
</select>
|
|
|
|
<label>Edge Color:</label>
|
|
<input type="color" id="edgeColor" value="${currentStroke}">
|
|
|
|
<label>Edge Width:</label>
|
|
<input type="range" id="edgeWidth" min="1" max="8" value="${currentWidth}">
|
|
<span id="widthValue">${currentWidth}px</span>
|
|
|
|
<button onclick="mermaidEditor.updateEdgeProperties()">Update Edge</button>
|
|
<button onclick="mermaidEditor.deleteElement()">Delete Edge</button>
|
|
`;
|
|
|
|
// Add live width display
|
|
setTimeout(() => {
|
|
const widthSlider = document.getElementById('edgeWidth');
|
|
const widthDisplay = document.getElementById('widthValue');
|
|
if (widthSlider && widthDisplay) {
|
|
widthSlider.addEventListener('input', () => {
|
|
widthDisplay.textContent = widthSlider.value + 'px';
|
|
});
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
panel.classList.add('visible');
|
|
panel.style.display = 'block';
|
|
console.log('Property panel should be visible now');
|
|
}
|
|
|
|
hidePropertyPanel() {
|
|
const panel = document.getElementById('propertyPanel');
|
|
panel.classList.remove('visible');
|
|
panel.style.display = 'none';
|
|
}
|
|
|
|
startDrag(event, element) {
|
|
if (!this.isEditMode) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.isDragging = true;
|
|
this.selectedElement = element;
|
|
|
|
// Get current transform values
|
|
const transform = element.getAttribute('transform') || 'translate(0, 0)';
|
|
const match = transform.match(/translate\(([-\d.]+),\s*([-\d.]+)\)/);
|
|
const currentX = match ? parseFloat(match[1]) : 0;
|
|
const currentY = match ? parseFloat(match[2]) : 0;
|
|
|
|
const svgRect = document.querySelector('#mermaidOutput svg').getBoundingClientRect();
|
|
|
|
// Calculate offset from current transform position
|
|
this.dragOffset.x = event.clientX - svgRect.left - currentX;
|
|
this.dragOffset.y = event.clientY - svgRect.top - currentY;
|
|
|
|
element.style.cursor = 'grabbing';
|
|
console.log('Started dragging node:', this.extractNodeId(element));
|
|
}
|
|
|
|
drag(event) {
|
|
if (!this.isDragging || !this.selectedElement) return;
|
|
|
|
event.preventDefault();
|
|
|
|
const svg = document.querySelector('#mermaidOutput svg');
|
|
const svgRect = svg.getBoundingClientRect();
|
|
|
|
// Calculate new position
|
|
const newX = event.clientX - svgRect.left - this.dragOffset.x;
|
|
const newY = event.clientY - svgRect.top - this.dragOffset.y;
|
|
|
|
// Apply transform
|
|
this.selectedElement.style.transform = `translate(${newX}px, ${newY}px)`;
|
|
|
|
// Update position in data model
|
|
const nodeId = this.extractNodeId(this.selectedElement);
|
|
if (nodeId && this.diagramData.nodes.has(nodeId)) {
|
|
const node = this.diagramData.nodes.get(nodeId);
|
|
node.position = { x: newX, y: newY };
|
|
}
|
|
}
|
|
|
|
endDrag() {
|
|
if (this.isDragging) {
|
|
this.isDragging = false;
|
|
if (this.selectedElement) {
|
|
this.selectedElement.style.cursor = 'pointer';
|
|
|
|
// Trigger code sync when dragging ends
|
|
const nodeId = this.extractNodeId(this.selectedElement);
|
|
if (nodeId) {
|
|
console.log(`Node ${nodeId} moved to new position`);
|
|
// Note: We don't update code for position changes since
|
|
// Mermaid handles layout automatically
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updateNodeProperties() {
|
|
if (!this.selectedElement) return;
|
|
|
|
const nodeText = document.getElementById('nodeText').value;
|
|
const nodeColor = document.getElementById('nodeColor').value;
|
|
const textColor = document.getElementById('textColor').value;
|
|
const nodeId = this.extractNodeId(this.selectedElement);
|
|
|
|
console.log('Updating node properties:', { nodeId, nodeText, nodeColor, textColor });
|
|
|
|
// Update text in foreignObject (Mermaid v10+ structure)
|
|
const foreignObjects = this.selectedElement.querySelectorAll('foreignObject');
|
|
const textElements = this.selectedElement.querySelectorAll('text');
|
|
const nodeLabelElements = this.selectedElement.querySelectorAll('.nodeLabel');
|
|
const paragraphElements = this.selectedElement.querySelectorAll('p');
|
|
|
|
console.log('Found for text update - foreignObjects:', foreignObjects.length, 'textElements:', textElements.length, 'nodeLabels:', nodeLabelElements.length, 'paragraphs:', paragraphElements.length);
|
|
|
|
let textUpdated = false;
|
|
|
|
// Method 1: Update paragraph elements (most likely in Mermaid v10+)
|
|
if (paragraphElements.length > 0) {
|
|
paragraphElements.forEach(pEl => {
|
|
pEl.textContent = nodeText;
|
|
pEl.style.color = textColor;
|
|
});
|
|
textUpdated = true;
|
|
console.log('Updated paragraph elements');
|
|
}
|
|
|
|
// Method 2: Update .nodeLabel elements
|
|
if (nodeLabelElements.length > 0 && !textUpdated) {
|
|
nodeLabelElements.forEach(labelEl => {
|
|
labelEl.textContent = nodeText;
|
|
labelEl.style.color = textColor;
|
|
});
|
|
textUpdated = true;
|
|
console.log('Updated nodeLabel elements');
|
|
}
|
|
|
|
// Method 3: Update traditional SVG text elements
|
|
if (textElements.length > 0 && !textUpdated) {
|
|
textElements.forEach(textEl => {
|
|
textEl.textContent = nodeText;
|
|
textEl.style.fill = textColor;
|
|
});
|
|
textUpdated = true;
|
|
console.log('Updated text elements');
|
|
}
|
|
|
|
if (!textUpdated) {
|
|
console.log('No text elements found to update');
|
|
}
|
|
|
|
// Update node background/shape color
|
|
const shapeElements = this.selectedElement.querySelectorAll('rect, circle, polygon, path');
|
|
console.log('Updating shape elements:', shapeElements);
|
|
|
|
shapeElements.forEach(shapeEl => {
|
|
shapeEl.style.fill = nodeColor;
|
|
shapeEl.style.stroke = nodeColor; // Also update border color
|
|
});
|
|
|
|
// Update data model
|
|
if (nodeId && this.diagramData.nodes.has(nodeId)) {
|
|
const node = this.diagramData.nodes.get(nodeId);
|
|
node.text = nodeText;
|
|
node.style.fill = nodeColor;
|
|
node.style.color = textColor;
|
|
console.log('Updated data model for node:', nodeId, node);
|
|
}
|
|
|
|
// Update the Mermaid code
|
|
this.updateCodeFromData();
|
|
}
|
|
|
|
updateEdgeProperties() {
|
|
if (!this.selectedElement) return;
|
|
|
|
const edgeLabel = document.getElementById('edgeLabel').value;
|
|
const edgeColor = document.getElementById('edgeColor').value;
|
|
const edgeWidth = document.getElementById('edgeWidth').value;
|
|
const edgeStyle = document.getElementById('edgeStyle').value;
|
|
|
|
console.log('Updating edge properties:', { edgeLabel, edgeColor, edgeWidth, edgeStyle });
|
|
|
|
// Update visual appearance
|
|
const pathElement = this.selectedElement.querySelector('path');
|
|
if (pathElement) {
|
|
pathElement.style.stroke = edgeColor;
|
|
pathElement.style.strokeWidth = edgeWidth + 'px';
|
|
|
|
switch(edgeStyle) {
|
|
case 'dashed':
|
|
pathElement.style.strokeDasharray = '5,5';
|
|
break;
|
|
case 'dotted':
|
|
pathElement.style.strokeDasharray = '2,2';
|
|
break;
|
|
default:
|
|
pathElement.style.strokeDasharray = 'none';
|
|
}
|
|
}
|
|
|
|
// Update data model - find edge by position in DOM
|
|
const edgeIndex = Array.from(this.selectedElement.parentElement.children).indexOf(this.selectedElement);
|
|
const pathElements = document.querySelectorAll('#mermaidOutput svg path[marker-end]');
|
|
const actualEdgeIndex = Array.from(pathElements).indexOf(pathElement);
|
|
|
|
console.log('Edge indices - DOM:', edgeIndex, 'Path:', actualEdgeIndex);
|
|
console.log('Available edges:', this.diagramData.edges);
|
|
|
|
if (actualEdgeIndex >= 0 && actualEdgeIndex < this.diagramData.edges.length) {
|
|
const edge = this.diagramData.edges[actualEdgeIndex];
|
|
console.log('Updating edge:', edge);
|
|
|
|
edge.label = edgeLabel;
|
|
edge.style.stroke = edgeColor;
|
|
edge.style.strokeWidth = parseInt(edgeWidth);
|
|
edge.style.strokeDasharray = edgeStyle === 'solid' ? 'none' :
|
|
edgeStyle === 'dashed' ? '5,5' : '2,2';
|
|
|
|
console.log('Updated edge:', edge);
|
|
}
|
|
|
|
// Update the Mermaid code
|
|
this.updateCodeFromData();
|
|
}
|
|
|
|
deleteElement() {
|
|
if (!this.selectedElement) return;
|
|
|
|
const nodeId = this.extractNodeId(this.selectedElement);
|
|
|
|
// Remove from data model
|
|
if (nodeId) {
|
|
// Remove node
|
|
this.diagramData.nodes.delete(nodeId);
|
|
|
|
// Remove edges connected to this node
|
|
this.diagramData.edges = this.diagramData.edges.filter(edge =>
|
|
edge.from !== nodeId && edge.to !== nodeId
|
|
);
|
|
} else {
|
|
// Handle edge deletion
|
|
const edgeId = this.extractEdgeId(this.selectedElement);
|
|
this.diagramData.edges = this.diagramData.edges.filter(edge =>
|
|
edge.id !== edgeId
|
|
);
|
|
}
|
|
|
|
// Remove from DOM
|
|
this.selectedElement.remove();
|
|
this.selectedElement = null;
|
|
this.hidePropertyPanel();
|
|
|
|
// Update the Mermaid code
|
|
this.updateCodeFromData();
|
|
}
|
|
|
|
extractEdgeId(edgeElement) {
|
|
// Try to extract edge identifier from classes or attributes
|
|
const classes = edgeElement.className.baseVal || edgeElement.className;
|
|
const match = classes.match(/edge-(\w+)-(\w+)/);
|
|
return match ? `${match[1]}-${match[2]}` : null;
|
|
}
|
|
|
|
updateCodeFromData() {
|
|
if (!this.diagramData || !this.diagramData.nodes) return;
|
|
|
|
console.log('Updating code from data model:');
|
|
console.log('Nodes:', this.diagramData.nodes);
|
|
console.log('Edges:', this.diagramData.edges);
|
|
console.log('Diagram type:', this.diagramData.diagramType);
|
|
console.log('Direction:', this.diagramData.direction);
|
|
|
|
// Generate new Mermaid code from current data model
|
|
const newCode = this.parser.generateCode(
|
|
this.diagramData.nodes,
|
|
this.diagramData.edges,
|
|
this.diagramData.diagramType,
|
|
this.diagramData.direction,
|
|
this.subgraphs
|
|
);
|
|
|
|
console.log('Generated new code:');
|
|
console.log(newCode);
|
|
|
|
// Update textarea
|
|
const textarea = document.getElementById('mermaidInput');
|
|
const oldCode = textarea.value;
|
|
textarea.value = newCode;
|
|
|
|
console.log('Old code:');
|
|
console.log(oldCode);
|
|
console.log('New code:');
|
|
console.log(newCode);
|
|
|
|
console.log('Code synchronized with visual changes');
|
|
}
|
|
|
|
drag(event) {
|
|
if (!this.isDragging || !this.selectedElement) return;
|
|
|
|
event.preventDefault();
|
|
|
|
const svg = document.querySelector('#mermaidOutput svg');
|
|
const svgRect = svg.getBoundingClientRect();
|
|
|
|
// Calculate new position relative to SVG
|
|
const newX = event.clientX - svgRect.left - this.dragOffset.x;
|
|
const newY = event.clientY - svgRect.top - this.dragOffset.y;
|
|
|
|
// Update the transform attribute directly
|
|
this.selectedElement.setAttribute('transform', `translate(${newX}, ${newY})`);
|
|
|
|
// Update connected edges
|
|
const nodeId = this.extractNodeId(this.selectedElement);
|
|
if (nodeId) {
|
|
this.updateConnectedEdges(nodeId, newX, newY);
|
|
|
|
// Update position in data model
|
|
if (this.diagramData.nodes.has(nodeId)) {
|
|
const node = this.diagramData.nodes.get(nodeId);
|
|
node.position = { x: newX, y: newY };
|
|
}
|
|
}
|
|
}
|
|
|
|
updateConnectedEdges(nodeId, nodeX, nodeY) {
|
|
// Find all edges connected to this node
|
|
const connectedEdges = this.diagramData.edges.filter(edge =>
|
|
edge.from === nodeId || edge.to === nodeId
|
|
);
|
|
|
|
console.log(`Updating ${connectedEdges.length} edges connected to node ${nodeId}`);
|
|
|
|
connectedEdges.forEach((edge, index) => {
|
|
// Find the corresponding path element in the SVG
|
|
const edgeIndex = this.diagramData.edges.indexOf(edge);
|
|
const pathElements = document.querySelectorAll('#mermaidOutput svg path[marker-end]');
|
|
|
|
if (edgeIndex < pathElements.length) {
|
|
const pathElement = pathElements[edgeIndex];
|
|
this.updateEdgePath(pathElement, edge, nodeId, nodeX, nodeY);
|
|
}
|
|
});
|
|
}
|
|
|
|
updateEdgePath(pathElement, edge, movedNodeId, nodeX, nodeY) {
|
|
// Get positions of both nodes
|
|
const fromNode = this.diagramData.nodes.get(edge.from);
|
|
const toNode = this.diagramData.nodes.get(edge.to);
|
|
|
|
if (!fromNode || !toNode) return;
|
|
|
|
// Use updated position for the moved node, stored position for the other
|
|
const fromPos = edge.from === movedNodeId ?
|
|
{ x: nodeX, y: nodeY } : fromNode.position || { x: 0, y: 0 };
|
|
const toPos = edge.to === movedNodeId ?
|
|
{ x: nodeX, y: nodeY } : toNode.position || { x: 0, y: 0 };
|
|
|
|
// If we don't have the other node's position, try to get it from the DOM
|
|
if (edge.from !== movedNodeId && (!fromNode.position || (fromNode.position.x === 0 && fromNode.position.y === 0))) {
|
|
const fromElement = document.getElementById(`flowchart-${edge.from}-${document.querySelectorAll(`[id^="flowchart-${edge.from}-"]`).length - 1}`);
|
|
if (fromElement) {
|
|
const transform = fromElement.getAttribute('transform');
|
|
const match = transform && transform.match(/translate\(([-\d.]+),\s*([-\d.]+)\)/);
|
|
if (match) {
|
|
fromPos.x = parseFloat(match[1]);
|
|
fromPos.y = parseFloat(match[2]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (edge.to !== movedNodeId && (!toNode.position || (toNode.position.x === 0 && toNode.position.y === 0))) {
|
|
const toElement = document.getElementById(`flowchart-${edge.to}-${document.querySelectorAll(`[id^="flowchart-${edge.to}-"]`).length - 1}`);
|
|
if (toElement) {
|
|
const transform = toElement.getAttribute('transform');
|
|
const match = transform && transform.match(/translate\(([-\d.]+),\s*([-\d.]+)\)/);
|
|
if (match) {
|
|
toPos.x = parseFloat(match[1]);
|
|
toPos.y = parseFloat(match[2]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create a simple straight line path (Mermaid uses more complex curves, but this is a start)
|
|
const pathData = `M ${fromPos.x} ${fromPos.y} L ${toPos.x} ${toPos.y}`;
|
|
pathElement.setAttribute('d', pathData);
|
|
|
|
console.log(`Updated path for edge ${edge.from} -> ${edge.to}:`, pathData);
|
|
}
|
|
|
|
addToSubgraph(nodeId) {
|
|
const subgraphNameInput = document.getElementById('subgraphName');
|
|
let subgraphName = subgraphNameInput ? subgraphNameInput.value.trim() : '';
|
|
|
|
if (!subgraphName) {
|
|
subgraphName = `group_${this.subgraphs.size + 1}`;
|
|
}
|
|
|
|
console.log(`Adding node ${nodeId} to subgraph ${subgraphName}`);
|
|
|
|
// Create or update subgraph
|
|
if (!this.subgraphs.has(subgraphName)) {
|
|
this.subgraphs.set(subgraphName, {
|
|
name: subgraphName,
|
|
nodes: new Set(),
|
|
direction: 'TD'
|
|
});
|
|
}
|
|
|
|
this.subgraphs.get(subgraphName).nodes.add(nodeId);
|
|
|
|
// Update the Mermaid code with subgraphs
|
|
this.updateCodeFromData();
|
|
|
|
alert(`Added node ${nodeId} to group "${subgraphName}"`);
|
|
}
|
|
|
|
// B) Export functionality for position-aware formats
|
|
exportLayoutData() {
|
|
const layoutData = {
|
|
nodes: [],
|
|
edges: [],
|
|
subgraphs: [],
|
|
metadata: {
|
|
diagramType: this.diagramData.diagramType,
|
|
direction: this.diagramData.direction,
|
|
exportedAt: new Date().toISOString()
|
|
}
|
|
};
|
|
|
|
// Export nodes with positions
|
|
for (const [nodeId, node] of this.diagramData.nodes) {
|
|
layoutData.nodes.push({
|
|
id: nodeId,
|
|
text: node.text,
|
|
shape: node.shape,
|
|
position: node.position || { x: 0, y: 0 },
|
|
style: node.style
|
|
});
|
|
}
|
|
|
|
// Export edges
|
|
layoutData.edges = this.diagramData.edges.map(edge => ({
|
|
from: edge.from,
|
|
to: edge.to,
|
|
label: edge.label,
|
|
type: edge.type,
|
|
style: edge.style
|
|
}));
|
|
|
|
// Export subgraphs
|
|
for (const [subgraphName, subgraph] of this.subgraphs) {
|
|
layoutData.subgraphs.push({
|
|
name: subgraphName,
|
|
nodes: Array.from(subgraph.nodes),
|
|
direction: subgraph.direction
|
|
});
|
|
}
|
|
|
|
return layoutData;
|
|
}
|
|
|
|
downloadLayoutData() {
|
|
const layoutData = this.exportLayoutData();
|
|
const blob = new Blob([JSON.stringify(layoutData, null, 2)],
|
|
{ type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const downloadLink = document.createElement('a');
|
|
downloadLink.download = 'mermaid_layout_export.json';
|
|
downloadLink.href = url;
|
|
downloadLink.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
console.log('Layout data exported:', layoutData);
|
|
}
|
|
|
|
toggleLayoutLock() {
|
|
this.isLayoutLocked = !this.isLayoutLocked;
|
|
const lockBtn = document.getElementById('lockLayoutBtn');
|
|
|
|
if (this.isLayoutLocked) {
|
|
lockBtn.textContent = 'Unlock Layout';
|
|
lockBtn.style.backgroundColor = '#ff6b6b';
|
|
|
|
// Save current positions
|
|
this.saveCurrentPositions();
|
|
alert('Layout locked! Manual positions will be preserved when syncing/rendering.');
|
|
} else {
|
|
lockBtn.textContent = 'Lock Layout';
|
|
lockBtn.style.backgroundColor = '';
|
|
alert('Layout unlocked. Mermaid will use automatic positioning.');
|
|
}
|
|
}
|
|
|
|
saveCurrentPositions() {
|
|
const svg = document.querySelector('#mermaidOutput svg');
|
|
if (!svg) return;
|
|
|
|
const nodes = svg.querySelectorAll('.node');
|
|
console.log('Saving positions for', nodes.length, 'nodes');
|
|
|
|
nodes.forEach(nodeElement => {
|
|
const nodeId = this.extractNodeId(nodeElement);
|
|
if (nodeId) {
|
|
const transform = nodeElement.getAttribute('transform');
|
|
if (transform) {
|
|
const match = transform.match(/translate\(([-\d.]+),\s*([-\d.]+)\)/);
|
|
if (match) {
|
|
const position = {
|
|
x: parseFloat(match[1]),
|
|
y: parseFloat(match[2])
|
|
};
|
|
this.savedPositions.set(nodeId, position);
|
|
console.log(`Saved position for ${nodeId}:`, position);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log('All saved positions:', Array.from(this.savedPositions.entries()));
|
|
}
|
|
|
|
restoreSavedPositions() {
|
|
if (!this.isLayoutLocked || this.savedPositions.size === 0) {
|
|
console.log('Not restoring positions - layout not locked or no saved positions');
|
|
return;
|
|
}
|
|
|
|
console.log('Restoring saved positions...');
|
|
|
|
const svg = document.querySelector('#mermaidOutput svg');
|
|
if (!svg) return;
|
|
|
|
const nodes = svg.querySelectorAll('.node');
|
|
let restoredCount = 0;
|
|
|
|
nodes.forEach(nodeElement => {
|
|
const nodeId = this.extractNodeId(nodeElement);
|
|
if (nodeId && this.savedPositions.has(nodeId)) {
|
|
const savedPos = this.savedPositions.get(nodeId);
|
|
nodeElement.setAttribute('transform', `translate(${savedPos.x}, ${savedPos.y})`);
|
|
|
|
// Also update connected edges
|
|
this.updateConnectedEdges(nodeId, savedPos.x, savedPos.y);
|
|
|
|
restoredCount++;
|
|
console.log(`Restored position for ${nodeId} to (${savedPos.x}, ${savedPos.y})`);
|
|
}
|
|
});
|
|
|
|
console.log(`Restored positions for ${restoredCount} nodes`);
|
|
}
|
|
}
|
|
|
|
// Initialize the editor
|
|
const mermaidEditor = new MermaidEditor();
|
|
|
|
// Initialize everything when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
console.log('Page loaded, checking for property panel...');
|
|
const panel = document.getElementById('propertyPanel');
|
|
const content = document.getElementById('propertyContent');
|
|
|
|
console.log('Property panel element:', panel);
|
|
console.log('Property content element:', content);
|
|
|
|
if (panel) {
|
|
console.log('Panel found! Ready for use.');
|
|
// Reset to original content
|
|
const content = document.getElementById('propertyContent');
|
|
if (content) {
|
|
content.innerHTML = '<p>Select an element to edit its properties</p>';
|
|
}
|
|
} else {
|
|
console.log('ERROR: Property panel not found!');
|
|
}
|
|
// Add event listener for edit mode toggle
|
|
document.getElementById('toggleEditMode').addEventListener('click', () => {
|
|
mermaidEditor.toggleEditMode();
|
|
});
|
|
|
|
// Add event listener for sync code button
|
|
document.getElementById('syncCodeBtn').addEventListener('click', () => {
|
|
mermaidEditor.updateCodeFromData();
|
|
alert('Code synchronized with visual changes!');
|
|
});
|
|
|
|
// Add test button for debugging property panel
|
|
document.getElementById('testPanelBtn').addEventListener('click', () => {
|
|
console.log('Test button clicked');
|
|
const panel = document.getElementById('propertyPanel');
|
|
const content = document.getElementById('propertyContent');
|
|
|
|
console.log('Panel found:', panel);
|
|
console.log('Content found:', content);
|
|
|
|
if (panel && content) {
|
|
content.innerHTML = `
|
|
<label>Test Panel:</label>
|
|
<input type="text" value="Panel is working!">
|
|
<button onclick="alert('Panel button works!')">Test Button</button>
|
|
`;
|
|
|
|
panel.classList.add('visible');
|
|
panel.style.display = 'block';
|
|
console.log('Test panel should be visible now');
|
|
} else {
|
|
console.error('Panel or content element not found!');
|
|
alert('Panel elements not found - check console');
|
|
}
|
|
});
|
|
|
|
// Add subgraph functionality
|
|
document.getElementById('addSubgraphBtn').addEventListener('click', () => {
|
|
const groupName = prompt('Enter group name:');
|
|
if (groupName) {
|
|
alert(`Group "${groupName}" created. Select nodes and use "Add to Group" to add them to this group.`);
|
|
}
|
|
});
|
|
|
|
// Add export functionality
|
|
document.getElementById('exportJsonBtn').addEventListener('click', () => {
|
|
mermaidEditor.downloadLayoutData();
|
|
});
|
|
|
|
// Add layout lock functionality
|
|
document.getElementById('lockLayoutBtn').addEventListener('click', () => {
|
|
mermaidEditor.toggleLayoutLock();
|
|
});
|
|
});
|
|
|
|
// Override the render function to enable interactivity after rendering
|
|
const originalRender = renderMermaidDiagram;
|
|
renderMermaidDiagram = function() {
|
|
originalRender();
|
|
|
|
// Re-enable interactivity if edit mode is on
|
|
setTimeout(() => {
|
|
if (mermaidEditor.isEditMode) {
|
|
mermaidEditor.enableInteractivity();
|
|
|
|
// Restore saved positions if layout is locked
|
|
if (mermaidEditor.isLayoutLocked) {
|
|
mermaidEditor.restoreSavedPositions();
|
|
}
|
|
}
|
|
}, 100);
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|