Spaces:
Sleeping
Sleeping
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Mermaid Live Renderer</title> | |
<style> | |
body { font-family: sans-serif; margin: 1em; background-color: #f4f4f4; color: #333; } | |
.container { max-width: 1200px; margin: auto; background: #fff; padding: 1em; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); display: flex; gap: 2em;} | |
.input-area { flex: 1; } | |
.preview-area { flex: 1; border-left: 1px solid #eee; padding-left: 2em; } | |
/* Mobile responsive styles */ | |
@media (max-width: 768px) { | |
body { margin: 0.5em; } | |
.container { | |
flex-direction: column; | |
padding: 0.5em; | |
gap: 1em; | |
} | |
.input-area { | |
width: 100%; | |
} | |
.preview-area { | |
width: 100%; | |
border-left: none; | |
padding-left: 0; | |
border-top: 1px solid #eee; | |
padding-top: 1em; | |
} | |
h1 { font-size: 1.5em; } | |
textarea { | |
width: 95%; | |
min-height: 200px; | |
} | |
.options { | |
flex-direction: column; | |
align-items: stretch; | |
} | |
.options > div { | |
width: 100%; | |
} | |
select { | |
width: 100%; | |
margin-top: 0.5em; | |
margin-right: 0; | |
} | |
.preview-controls { | |
display: flex; | |
flex-wrap: wrap; | |
justify-content: center; | |
} | |
.preview-controls button { | |
padding: 0.5em; | |
font-size: 1rem; | |
min-width: 40px; | |
margin: 0.2em; | |
} | |
#preview-box { | |
min-height: 200px; | |
} | |
} | |
@media (max-width: 480px) { | |
.container { | |
padding: 0.3em; | |
} | |
textarea { | |
width: 93%; | |
min-height: 150px; | |
} | |
.preview-controls { | |
display: flex; | |
flex-wrap: wrap; | |
justify-content: center; | |
} | |
.preview-controls button { | |
flex: 1 1 30%; | |
margin: 0.2em; | |
} | |
} | |
h1 { color: #555; text-align: center; margin-bottom: 1em; width: 100%;} | |
label { display: block; margin-top: 1em; font-weight: bold; } | |
textarea { width: 95%; min-height: 300px; margin-top: 0.5em; padding: 10px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 1rem; } | |
select, button { padding: 0.8em 1.2em; margin-top: 0.5em; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; cursor: pointer; margin-right: 0.5em; } | |
button.primary { background-color: #5cb85c; color: white; border-color: #4cae4c; font-weight: bold; } | |
button.primary:hover { background-color: #4cae4c; } | |
button.secondary { background-color: #5bc0de; color: white; border-color: #46b8da; } | |
button.secondary:hover { background-color: #31b0d5; } | |
.options { display: flex; gap: 1em; align-items: center; margin-top: 1em; flex-wrap: wrap;} | |
.flash { padding: 1em; margin-bottom: 1em; border-radius: 5px; border: 1px solid transparent; } | |
.flash.error { background-color: #f8d7da; color: #721c24; border-color: #f5c6cb; } | |
.flash.warning { background-color: #fff3cd; color: #856404; border-color: #ffeeba; } | |
.flash.info { background-color: #d1ecf1; color: #0c5460; border-color: #bee5eb; } | |
#preview-box { margin-top: 1em; padding: 1em; border: 1px solid #ccc; border-radius: 4px; min-height: 300px; text-align: center; background-color: #fdfdfd; overflow: auto; display: flex; justify-content: center; align-items: center;} | |
#preview-box img, #preview-box svg { display: block; /* Prevent extra space below */ } | |
#preview-content-wrapper { /* Inner wrapper for transformations */ | |
width: 100%; | |
height: 100%; | |
transition: transform 0.2s ease-out; /* Smooth transitions */ | |
transform-origin: center center; /* Zoom from center */ | |
cursor: grab; /* Indicate pannable */ | |
} | |
#preview-content-wrapper:active { | |
cursor: grabbing; | |
} | |
#preview-status { margin-top: 0.5em; font-style: italic; color: #777; min-height: 1.2em;} | |
.preview-controls { text-align: center; margin-bottom: 0.5em; } | |
.preview-controls button { padding: 0.3em 0.6em; font-size: 0.9rem; min-width: 30px; } | |
</style> | |
</head> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="input-area"> | |
<h1>Mermaid Input</h1> | |
<p style="font-size: 0.9em; color: #666; margin-top: -0.5em; margin-bottom: 1.5em;">Enter Mermaid code below. The preview on the right updates automatically.</p> | |
<!-- Flash messages --> | |
{% with messages = get_flashed_messages(with_categories=true) %} | |
{% if messages %} | |
{% for category, message in messages %} | |
<div class="flash {{ category }}">{{ message }}</div> | |
{% endfor %} | |
{% endif %} | |
{% endwith %} | |
<!-- Main form for final download --> | |
<form id="download-form" action="{{ url_for('render_mermaid') }}" method="post"> | |
<label for="mermaid_code">Mermaid Code:</label> | |
<textarea id="mermaid_code" name="mermaid_code" required placeholder="e.g., graph TD; A-->B;">{{ default_code }}</textarea> | |
<div class="options"> | |
<div> | |
<label for="output_format">Format:</label> | |
<select id="output_format" name="output_format"> | |
<option value="png">PNG</option> | |
<option value="svg" selected>SVG</option> <!-- Default to SVG for better preview --> | |
<option value="pdf">PDF</option> | |
</select> | |
</div> | |
<div> | |
<label for="theme">Theme:</label> | |
<select id="theme" name="theme"> | |
<option value="default" selected>Default</option> | |
<option value="forest">Forest</option> | |
<option value="dark">Dark</option> | |
<option value="neutral">Neutral</option> | |
</select> | |
</div> | |
</div> | |
<br><br> | |
<!-- Download Button (submits the form) --> | |
<button type="submit" class="primary">Download Diagram</button> | |
<!-- Preview button removed, preview updates automatically --> | |
</form> | |
</div> | |
<div class="preview-area"> | |
<h1>Live Preview</h1> | |
<p style="font-size: 0.9em; color: #666; margin-top: -0.5em; margin-bottom: 0.5em;">Use controls to zoom/pan, or click & drag the preview.</p> | |
<div id="preview-status">Enter code to see preview.</div> | |
<div class="preview-controls"> | |
<button id="zoom-in">+</button> | |
<button id="zoom-out">-</button> | |
<button id="pan-up">↑</button> | |
<button id="pan-down">↓</button> | |
<button id="pan-left">←</button> | |
<button id="pan-right">→</button> | |
<button id="reset-view">Reset</button> | |
</div> | |
<div id="preview-box"> | |
<div id="preview-content-wrapper"> | |
<!-- Preview will be loaded here by JavaScript --> | |
<p style="color: #aaa;">Preview Area</p> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script> | |
// Debounce function | |
function debounce(func, wait) { | |
let timeout; | |
return function executedFunction(...args) { | |
const later = () => { | |
clearTimeout(timeout); | |
func(...args); | |
}; | |
clearTimeout(timeout); | |
timeout = setTimeout(later, wait); | |
}; | |
} | |
const previewBox = document.getElementById('preview-box'); | |
const previewContentWrapper = document.getElementById('preview-content-wrapper'); | |
const previewStatus = document.getElementById('preview-status'); | |
const mermaidCodeInput = document.getElementById('mermaid_code'); | |
const outputFormatSelect = document.getElementById('output_format'); // Still needed for download format | |
const themeSelect = document.getElementById('theme'); | |
// Function to fetch and update preview | |
const updatePreview = async () => { | |
const mermaidCode = mermaidCodeInput.value; | |
// Preview will always use SVG for best results and simplicity, download format is separate | |
const previewFormat = 'svg'; | |
const theme = themeSelect.value; | |
if (!mermaidCode.trim()) { | |
// Don't show error for empty input, just clear preview | |
previewStatus.textContent = 'Enter code to see preview.'; | |
previewStatus.style.color = '#777'; | |
previewContentWrapper.innerHTML = '<p style="color: #aaa;">Preview Area</p>'; | |
resetTransform(); // Reset view when input is empty | |
return; | |
} | |
previewStatus.textContent = 'Generating preview...'; | |
previewStatus.style.color = '#777'; | |
previewContentWrapper.innerHTML = '<p style="color: #aaa;">Loading...</p>'; // Show loading indicator in the wrapper | |
try { | |
const response = await fetch("{{ url_for('preview_mermaid') }}", { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
mermaid_code: mermaidCode, | |
output_format: previewFormat, // Always request SVG for preview | |
theme: theme, | |
}), | |
}); | |
if (!response.ok) { | |
const errorData = await response.json(); | |
throw new Error(errorData.error || `HTTP error! status: ${response.status}`); | |
} | |
const result = await response.json(); | |
resetTransform(); // Reset view before loading new content | |
previewContentWrapper.innerHTML = ''; // Clear previous preview/loading message | |
if (result.format === 'png') { | |
const img = document.createElement('img'); | |
img.src = `data:image/png;base64,${result.data}`; | |
img.alt = 'Mermaid Diagram Preview'; | |
// Prevent dragging the image itself, we'll handle panning | |
img.style.pointerEvents = 'none'; | |
previewContentWrapper.appendChild(img); | |
} else if (result.format === 'svg') { | |
// Directly insert SVG markup into the wrapper | |
previewContentWrapper.innerHTML = result.data; | |
const svgElement = previewContentWrapper.querySelector('svg'); | |
if (svgElement) { | |
// Make SVG take up space correctly and prevent internal pointer events interfering | |
svgElement.style.maxWidth = '100%'; | |
svgElement.style.height = 'auto'; | |
svgElement.style.display = 'block'; | |
svgElement.style.pointerEvents = 'none'; | |
} | |
} | |
previewStatus.textContent = `Preview updated (SVG).`; // Preview is always SVG now | |
previewStatus.style.color = 'green'; | |
} catch (error) { | |
console.error('Error fetching preview:', error); | |
previewStatus.textContent = `Error: ${error.message}`; | |
previewStatus.style.color = 'red'; | |
previewContentWrapper.innerHTML = '<p style="color: red;">Failed to load preview.</p>'; | |
} | |
}; | |
// --- Zoom and Pan Logic --- | |
let scale = 1; | |
let translateX = 0; | |
let translateY = 0; | |
const zoomStep = 0.1; | |
const panStep = 30; // pixels | |
function applyTransform() { | |
previewContentWrapper.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`; | |
} | |
function resetTransform() { | |
scale = 1; | |
translateX = 0; | |
translateY = 0; | |
applyTransform(); | |
} | |
// Button Event Listeners | |
document.getElementById('zoom-in').addEventListener('click', () => { | |
scale += zoomStep; | |
applyTransform(); | |
}); | |
document.getElementById('zoom-out').addEventListener('click', () => { | |
scale = Math.max(0.1, scale - zoomStep); // Prevent zooming out too much | |
applyTransform(); | |
}); | |
document.getElementById('pan-up').addEventListener('click', () => { | |
translateY -= panStep; | |
applyTransform(); | |
}); | |
document.getElementById('pan-down').addEventListener('click', () => { | |
translateY += panStep; | |
applyTransform(); | |
}); | |
document.getElementById('pan-left').addEventListener('click', () => { | |
translateX -= panStep; | |
applyTransform(); | |
}); | |
document.getElementById('pan-right').addEventListener('click', () => { | |
translateX += panStep; | |
applyTransform(); | |
}); | |
document.getElementById('reset-view').addEventListener('click', resetTransform); | |
// --- Drag to Pan Logic --- | |
let isDragging = false; | |
let startX, startY; | |
let initialTranslateX, initialTranslateY; | |
previewBox.addEventListener('mousedown', (e) => { | |
// Only start drag if clicking directly on the preview box (not buttons etc.) | |
if (e.target === previewBox || e.target === previewContentWrapper) { | |
isDragging = true; | |
startX = e.clientX; | |
startY = e.clientY; | |
initialTranslateX = translateX; | |
initialTranslateY = translateY; | |
previewContentWrapper.style.transition = 'none'; // Disable transition during drag | |
previewContentWrapper.style.cursor = 'grabbing'; // Change cursor | |
previewBox.style.userSelect = 'none'; // Prevent text selection during drag | |
} | |
}); | |
document.addEventListener('mousemove', (e) => { | |
if (!isDragging) return; | |
const currentX = e.clientX; | |
const currentY = e.clientY; | |
translateX = initialTranslateX + (currentX - startX); | |
translateY = initialTranslateY + (currentY - startY); | |
applyTransform(); | |
}); | |
document.addEventListener('mouseup', () => { | |
if (isDragging) { | |
isDragging = false; | |
previewContentWrapper.style.transition = 'transform 0.2s ease-out'; // Re-enable transition | |
previewContentWrapper.style.cursor = 'grab'; // Restore cursor | |
previewBox.style.userSelect = ''; // Re-enable text selection | |
} | |
}); | |
// Prevent dragging state from sticking if mouse leaves the window | |
document.addEventListener('mouseleave', () => { | |
if (isDragging) { | |
isDragging = false; | |
previewContentWrapper.style.transition = 'transform 0.2s ease-out'; | |
previewContentWrapper.style.cursor = 'grab'; | |
previewBox.style.userSelect = ''; | |
} | |
}); | |
// Debounced version of the updatePreview function | |
const debouncedUpdatePreview = debounce(updatePreview, 750); // 750ms delay | |
// Event listener for textarea input | |
mermaidCodeInput.addEventListener('input', debouncedUpdatePreview); | |
// Also update preview if theme changes | |
themeSelect.addEventListener('change', debouncedUpdatePreview); | |
// Initial preview load on page ready | |
document.addEventListener('DOMContentLoaded', () => { | |
if (mermaidCodeInput.value.trim()) { | |
previewStatus.textContent = 'Rendering initial example...'; | |
updatePreview(); // Render the default code on load | |
} else { | |
previewStatus.textContent = 'Enter code to see preview.'; | |
} | |
}); | |
</script> | |
</body> | |
</html> | |