@@ -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