Skip to content

Commit 03b4b59

Browse files
Merge pull request #26 from ThisIs-Developer/copilot/add-copy-export-zoom-mermaid-diagrams
feat: Add copy/export & zoom toolbar for rendered Mermaid diagrams
2 parents cc0c33f + 4de2424 commit 03b4b59

4 files changed

Lines changed: 502 additions & 2 deletions

File tree

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Markdown Viewer is a professional, full-featured Markdown editor and preview app
2121
- **Live preview** - Instantly see changes as you type
2222
- **Syntax highlighting** - Beautiful code highlighting for multiple programming languages
2323
- **LaTeX math support** - Render mathematical equations using LaTeX syntax
24-
- **Mermaid diagrams** - Create diagrams and flowcharts within your Markdown
24+
- **Mermaid diagrams** - Create diagrams and flowcharts within your Markdown; hover over any diagram to reveal a toolbar for zooming, downloading (PNG/SVG), and copying to clipboard
2525
- **Dark mode toggle** - Switch between light and dark themes for comfortable viewing
2626
- **Export options** - Download your content as Markdown, HTML, or PDF
2727
- **Import Markdown files** - Drag & drop or select files to open
@@ -56,6 +56,24 @@ Markdown Viewer is a professional, full-featured Markdown editor and preview app
5656
5. **Toggle Dark Mode** - Click the moon icon to switch between light and dark themes
5757
6. **Toggle Sync Scrolling** - Enable/disable synchronized scrolling between panels
5858

59+
### Mermaid Diagram Toolbar
60+
61+
When a Mermaid diagram is rendered, hover over it to reveal a small toolbar with the following actions:
62+
63+
| Button | Action |
64+
|--------|--------|
65+
| ⛶ (arrows) | Open diagram in a zoom/pan modal |
66+
| PNG | Download the diagram as a PNG image |
67+
| 📋 (clipboard) | Copy the diagram image to the clipboard |
68+
| SVG | Download the diagram as an SVG file |
69+
70+
Inside the **zoom modal** you can:
71+
- **Zoom in / out** using the buttons or the mouse wheel
72+
- **Pan** by clicking and dragging the diagram
73+
- **Reset** zoom and position with the Reset button
74+
- **Download PNG or SVG** directly from the modal
75+
- **Close** with the × button or by pressing `Escape`
76+
5977
### Supported Markdown Features
6078

6179
- Headings (# H1, ## H2, etc.)

index.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,36 @@ <h5>Menu</h5>
223223
</div>
224224
</div>
225225

226+
<!-- Mermaid Zoom Modal -->
227+
<div id="mermaid-zoom-modal" role="dialog" aria-modal="true" aria-label="Diagram zoom view">
228+
<div class="mermaid-modal-content">
229+
<div class="mermaid-modal-header">
230+
<span>Diagram</span>
231+
<button class="mermaid-modal-close" id="mermaid-modal-close" title="Close" aria-label="Close diagram modal">
232+
<i class="bi bi-x-lg"></i>
233+
</button>
234+
</div>
235+
<div class="mermaid-modal-diagram" id="mermaid-modal-diagram"></div>
236+
<div class="mermaid-modal-controls">
237+
<button class="mermaid-toolbar-btn" id="mermaid-modal-zoom-in" title="Zoom in">
238+
<i class="bi bi-zoom-in"></i> Zoom In
239+
</button>
240+
<button class="mermaid-toolbar-btn" id="mermaid-modal-zoom-out" title="Zoom out">
241+
<i class="bi bi-zoom-out"></i> Zoom Out
242+
</button>
243+
<button class="mermaid-toolbar-btn" id="mermaid-modal-zoom-reset" title="Reset zoom">
244+
<i class="bi bi-arrows-angle-contract"></i> Reset
245+
</button>
246+
<button class="mermaid-toolbar-btn" id="mermaid-modal-download-png" title="Download PNG">
247+
<i class="bi bi-file-image"></i> PNG
248+
</button>
249+
<button class="mermaid-toolbar-btn" id="mermaid-modal-download-svg" title="Download SVG">
250+
<i class="bi bi-filetype-svg"></i> SVG
251+
</button>
252+
</div>
253+
</div>
254+
</div>
255+
226256
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js"></script>
227257
<script src="script.js"></script>
228258
</body>

script.js

Lines changed: 312 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,15 @@ This is a fully client-side application. Your content never leaves your browser
316316
initMermaid();
317317

318318
try {
319-
mermaid.init(undefined, markdownPreview.querySelectorAll('.mermaid'));
319+
const mermaidNodes = markdownPreview.querySelectorAll('.mermaid');
320+
if (mermaidNodes.length > 0) {
321+
Promise.resolve(mermaid.init(undefined, mermaidNodes))
322+
.then(() => addMermaidToolbars())
323+
.catch((e) => {
324+
console.warn("Mermaid rendering failed:", e);
325+
addMermaidToolbars();
326+
});
327+
}
320328
} catch (e) {
321329
console.warn("Mermaid rendering failed:", e);
322330
}
@@ -1590,5 +1598,308 @@ This is a fully client-side application. Your content never leaves your browser
15901598
toggleSyncScrolling();
15911599
}
15921600
}
1601+
// Close Mermaid zoom modal with Escape
1602+
if (e.key === "Escape") {
1603+
closeMermaidModal();
1604+
}
15931605
});
1606+
1607+
// ========================================
1608+
// MERMAID DIAGRAM TOOLBAR
1609+
// ========================================
1610+
1611+
/**
1612+
* Serialises an SVG element to a data URL suitable for use as an image source.
1613+
* Inline styles and dimensions are preserved so the PNG matches the rendered diagram.
1614+
*/
1615+
function svgToDataUrl(svgEl) {
1616+
const clone = svgEl.cloneNode(true);
1617+
// Ensure explicit width/height so the canvas has the right dimensions
1618+
const bbox = svgEl.getBoundingClientRect();
1619+
if (!clone.getAttribute('width')) clone.setAttribute('width', Math.round(bbox.width));
1620+
if (!clone.getAttribute('height')) clone.setAttribute('height', Math.round(bbox.height));
1621+
const serialized = new XMLSerializer().serializeToString(clone);
1622+
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(serialized);
1623+
}
1624+
1625+
/**
1626+
* Renders an SVG element onto a canvas and resolves with the canvas.
1627+
*/
1628+
function svgToCanvas(svgEl) {
1629+
return new Promise((resolve, reject) => {
1630+
const bbox = svgEl.getBoundingClientRect();
1631+
const scale = window.devicePixelRatio || 1;
1632+
const width = Math.max(Math.round(bbox.width), 1);
1633+
const height = Math.max(Math.round(bbox.height), 1);
1634+
1635+
const canvas = document.createElement('canvas');
1636+
canvas.width = width * scale;
1637+
canvas.height = height * scale;
1638+
const ctx = canvas.getContext('2d');
1639+
ctx.scale(scale, scale);
1640+
1641+
// Fill background matching current theme using the CSS variable value
1642+
const bgColor = getComputedStyle(document.documentElement)
1643+
.getPropertyValue('--bg-color').trim() || '#ffffff';
1644+
ctx.fillStyle = bgColor;
1645+
ctx.fillRect(0, 0, width, height);
1646+
1647+
const img = new Image();
1648+
img.onload = () => { ctx.drawImage(img, 0, 0, width, height); resolve(canvas); };
1649+
img.onerror = reject;
1650+
img.src = svgToDataUrl(svgEl);
1651+
});
1652+
}
1653+
1654+
/** Downloads the diagram in the given container as a PNG file. */
1655+
async function downloadMermaidPng(container, btn) {
1656+
const svgEl = container.querySelector('svg');
1657+
if (!svgEl) return;
1658+
const original = btn.innerHTML;
1659+
btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
1660+
try {
1661+
const canvas = await svgToCanvas(svgEl);
1662+
canvas.toBlob(blob => {
1663+
const url = URL.createObjectURL(blob);
1664+
const a = document.createElement('a');
1665+
a.href = url;
1666+
a.download = `diagram-${Date.now()}.png`;
1667+
a.click();
1668+
URL.revokeObjectURL(url);
1669+
btn.innerHTML = '<i class="bi bi-check-lg"></i>';
1670+
setTimeout(() => { btn.innerHTML = original; }, 1500);
1671+
}, 'image/png');
1672+
} catch (e) {
1673+
console.error('Mermaid PNG export failed:', e);
1674+
btn.innerHTML = original;
1675+
}
1676+
}
1677+
1678+
/** Copies the diagram in the given container as a PNG image to the clipboard. */
1679+
async function copyMermaidImage(container, btn) {
1680+
const svgEl = container.querySelector('svg');
1681+
if (!svgEl) return;
1682+
const original = btn.innerHTML;
1683+
btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
1684+
try {
1685+
const canvas = await svgToCanvas(svgEl);
1686+
canvas.toBlob(async blob => {
1687+
try {
1688+
await navigator.clipboard.write([
1689+
new ClipboardItem({ 'image/png': blob })
1690+
]);
1691+
btn.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
1692+
} catch (clipErr) {
1693+
console.error('Clipboard write failed:', clipErr);
1694+
btn.innerHTML = '<i class="bi bi-x-lg"></i>';
1695+
}
1696+
setTimeout(() => { btn.innerHTML = original; }, 1800);
1697+
}, 'image/png');
1698+
} catch (e) {
1699+
console.error('Mermaid copy failed:', e);
1700+
btn.innerHTML = original;
1701+
}
1702+
}
1703+
1704+
/** Downloads the SVG source of a diagram. */
1705+
function downloadMermaidSvg(container, btn) {
1706+
const svgEl = container.querySelector('svg');
1707+
if (!svgEl) return;
1708+
const clone = svgEl.cloneNode(true);
1709+
const serialized = new XMLSerializer().serializeToString(clone);
1710+
const blob = new Blob([serialized], { type: 'image/svg+xml' });
1711+
const url = URL.createObjectURL(blob);
1712+
const a = document.createElement('a');
1713+
a.href = url;
1714+
a.download = `diagram-${Date.now()}.svg`;
1715+
a.click();
1716+
URL.revokeObjectURL(url);
1717+
const original = btn.innerHTML;
1718+
btn.innerHTML = '<i class="bi bi-check-lg"></i>';
1719+
setTimeout(() => { btn.innerHTML = original; }, 1500);
1720+
}
1721+
1722+
// ---- Zoom modal state ----
1723+
let modalZoomScale = 1;
1724+
let modalPanX = 0;
1725+
let modalPanY = 0;
1726+
let modalIsDragging = false;
1727+
let modalDragStart = { x: 0, y: 0 };
1728+
let modalCurrentSvgEl = null;
1729+
1730+
const mermaidZoomModal = document.getElementById('mermaid-zoom-modal');
1731+
const mermaidModalDiagram = document.getElementById('mermaid-modal-diagram');
1732+
1733+
function applyModalTransform() {
1734+
if (modalCurrentSvgEl) {
1735+
modalCurrentSvgEl.style.transform =
1736+
`translate(${modalPanX}px, ${modalPanY}px) scale(${modalZoomScale})`;
1737+
}
1738+
}
1739+
1740+
function closeMermaidModal() {
1741+
if (!mermaidZoomModal.classList.contains('active')) return;
1742+
mermaidZoomModal.classList.remove('active');
1743+
mermaidModalDiagram.innerHTML = '';
1744+
modalCurrentSvgEl = null;
1745+
modalZoomScale = 1;
1746+
modalPanX = 0;
1747+
modalPanY = 0;
1748+
}
1749+
1750+
/** Opens the zoom modal with the SVG from the given container. */
1751+
function openMermaidZoomModal(container) {
1752+
const svgEl = container.querySelector('svg');
1753+
if (!svgEl) return;
1754+
1755+
mermaidModalDiagram.innerHTML = '';
1756+
modalZoomScale = 1;
1757+
modalPanX = 0;
1758+
modalPanY = 0;
1759+
1760+
const svgClone = svgEl.cloneNode(true);
1761+
// Remove fixed dimensions so it sizes naturally inside the modal
1762+
svgClone.removeAttribute('width');
1763+
svgClone.removeAttribute('height');
1764+
svgClone.style.width = 'auto';
1765+
svgClone.style.height = 'auto';
1766+
svgClone.style.maxWidth = '80vw';
1767+
svgClone.style.maxHeight = '60vh';
1768+
svgClone.style.transformOrigin = 'center';
1769+
mermaidModalDiagram.appendChild(svgClone);
1770+
modalCurrentSvgEl = svgClone;
1771+
1772+
mermaidZoomModal.classList.add('active');
1773+
}
1774+
1775+
// Modal close button
1776+
document.getElementById('mermaid-modal-close').addEventListener('click', closeMermaidModal);
1777+
// Click backdrop to close
1778+
mermaidZoomModal.addEventListener('click', function(e) {
1779+
if (e.target === mermaidZoomModal) closeMermaidModal();
1780+
});
1781+
1782+
// Zoom controls
1783+
document.getElementById('mermaid-modal-zoom-in').addEventListener('click', () => {
1784+
modalZoomScale = Math.min(modalZoomScale + 0.25, 10);
1785+
applyModalTransform();
1786+
});
1787+
document.getElementById('mermaid-modal-zoom-out').addEventListener('click', () => {
1788+
modalZoomScale = Math.max(modalZoomScale - 0.25, 0.1);
1789+
applyModalTransform();
1790+
});
1791+
document.getElementById('mermaid-modal-zoom-reset').addEventListener('click', () => {
1792+
modalZoomScale = 1; modalPanX = 0; modalPanY = 0;
1793+
applyModalTransform();
1794+
});
1795+
1796+
// Mouse-wheel zoom inside modal
1797+
mermaidModalDiagram.addEventListener('wheel', function(e) {
1798+
e.preventDefault();
1799+
const delta = e.deltaY < 0 ? 0.15 : -0.15;
1800+
modalZoomScale = Math.min(Math.max(modalZoomScale + delta, 0.1), 10);
1801+
applyModalTransform();
1802+
}, { passive: false });
1803+
1804+
// Drag to pan inside modal
1805+
mermaidModalDiagram.addEventListener('mousedown', function(e) {
1806+
modalIsDragging = true;
1807+
modalDragStart = { x: e.clientX - modalPanX, y: e.clientY - modalPanY };
1808+
mermaidModalDiagram.classList.add('dragging');
1809+
});
1810+
document.addEventListener('mousemove', function(e) {
1811+
if (!modalIsDragging) return;
1812+
modalPanX = e.clientX - modalDragStart.x;
1813+
modalPanY = e.clientY - modalDragStart.y;
1814+
applyModalTransform();
1815+
});
1816+
document.addEventListener('mouseup', function() {
1817+
if (modalIsDragging) {
1818+
modalIsDragging = false;
1819+
mermaidModalDiagram.classList.remove('dragging');
1820+
}
1821+
});
1822+
1823+
// Modal download buttons (operate on the currently displayed SVG)
1824+
document.getElementById('mermaid-modal-download-png').addEventListener('click', async function() {
1825+
if (!modalCurrentSvgEl) return;
1826+
const btn = this;
1827+
const original = btn.innerHTML;
1828+
btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
1829+
try {
1830+
// Use the original SVG (with dimensions) for proper PNG rendering
1831+
const canvas = await svgToCanvas(modalCurrentSvgEl);
1832+
canvas.toBlob(blob => {
1833+
const url = URL.createObjectURL(blob);
1834+
const a = document.createElement('a');
1835+
a.href = url; a.download = `diagram-${Date.now()}.png`; a.click();
1836+
URL.revokeObjectURL(url);
1837+
btn.innerHTML = '<i class="bi bi-check-lg"></i>';
1838+
setTimeout(() => { btn.innerHTML = original; }, 1500);
1839+
}, 'image/png');
1840+
} catch (e) {
1841+
console.error('Modal PNG export failed:', e);
1842+
btn.innerHTML = original;
1843+
}
1844+
});
1845+
1846+
document.getElementById('mermaid-modal-download-svg').addEventListener('click', function() {
1847+
if (!modalCurrentSvgEl) return;
1848+
const serialized = new XMLSerializer().serializeToString(modalCurrentSvgEl);
1849+
const blob = new Blob([serialized], { type: 'image/svg+xml' });
1850+
const url = URL.createObjectURL(blob);
1851+
const a = document.createElement('a');
1852+
a.href = url; a.download = `diagram-${Date.now()}.svg`; a.click();
1853+
URL.revokeObjectURL(url);
1854+
});
1855+
1856+
/**
1857+
* Adds the hover toolbar to every rendered Mermaid container.
1858+
* Safe to call multiple times – existing toolbars are not duplicated.
1859+
*/
1860+
function addMermaidToolbars() {
1861+
markdownPreview.querySelectorAll('.mermaid-container').forEach(container => {
1862+
if (container.querySelector('.mermaid-toolbar')) return; // already added
1863+
const svgEl = container.querySelector('svg');
1864+
if (!svgEl) return; // diagram not yet rendered
1865+
1866+
const toolbar = document.createElement('div');
1867+
toolbar.className = 'mermaid-toolbar';
1868+
toolbar.setAttribute('aria-label', 'Diagram actions');
1869+
1870+
const btnZoom = document.createElement('button');
1871+
btnZoom.className = 'mermaid-toolbar-btn';
1872+
btnZoom.title = 'Zoom diagram';
1873+
btnZoom.setAttribute('aria-label', 'Zoom diagram');
1874+
btnZoom.innerHTML = '<i class="bi bi-arrows-fullscreen"></i>';
1875+
btnZoom.addEventListener('click', () => openMermaidZoomModal(container));
1876+
1877+
const btnPng = document.createElement('button');
1878+
btnPng.className = 'mermaid-toolbar-btn';
1879+
btnPng.title = 'Download PNG';
1880+
btnPng.setAttribute('aria-label', 'Download PNG');
1881+
btnPng.innerHTML = '<i class="bi bi-file-image"></i> PNG';
1882+
btnPng.addEventListener('click', () => downloadMermaidPng(container, btnPng));
1883+
1884+
const btnCopy = document.createElement('button');
1885+
btnCopy.className = 'mermaid-toolbar-btn';
1886+
btnCopy.title = 'Copy image to clipboard';
1887+
btnCopy.setAttribute('aria-label', 'Copy image to clipboard');
1888+
btnCopy.innerHTML = '<i class="bi bi-clipboard-image"></i>';
1889+
btnCopy.addEventListener('click', () => copyMermaidImage(container, btnCopy));
1890+
1891+
const btnSvg = document.createElement('button');
1892+
btnSvg.className = 'mermaid-toolbar-btn';
1893+
btnSvg.title = 'Download SVG';
1894+
btnSvg.setAttribute('aria-label', 'Download SVG');
1895+
btnSvg.innerHTML = '<i class="bi bi-filetype-svg"></i> SVG';
1896+
btnSvg.addEventListener('click', () => downloadMermaidSvg(container, btnSvg));
1897+
1898+
toolbar.appendChild(btnZoom);
1899+
toolbar.appendChild(btnPng);
1900+
toolbar.appendChild(btnCopy);
1901+
toolbar.appendChild(btnSvg);
1902+
container.appendChild(toolbar);
1903+
});
1904+
}
15941905
});

0 commit comments

Comments
 (0)