Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/local-editor-png-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mermaid-js/mermaid-local-editor': patch
---

feat: add PNG export, cursor-anchored zoom, AbortController-based listener cleanup, and switch to default theme so user-chosen themes are respected
4 changes: 2 additions & 2 deletions packages/mermaid-local-editor/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ function applyTransform() {
return;
}

state.panY = Math.max(-20000, Math.min(20000, state.panY));
state.panX = Math.max(-20000, Math.min(20000, state.panX));
state.panY = Math.max(-50000, Math.min(50000, state.panY));
state.panX = Math.max(-50000, Math.min(50000, state.panX));

svg.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`;

Expand Down
1 change: 1 addition & 0 deletions packages/mermaid-local-editor/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<button id="del">🗑 Delete</button>
<button id="resetView">↺ Reset View</button>
<button id="exportSvg">⬇ Export SVG</button>
<button id="exportPng">⬇ Export PNG</button>
</div>

<div id="main">
Expand Down
3 changes: 2 additions & 1 deletion packages/mermaid-local-editor/static/js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const IS_E2E = navigator.webdriver || location.search.includes('graph=');
export function initMermaid() {
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
theme: 'default',
securityLevel: 'strict',
deterministicIds: true,
fontFamily: 'Arial',
Expand All @@ -21,4 +21,5 @@ export let state = {
panX: 0,
panY: 0,
iframeRef: null,
abortController: null,
};
159 changes: 87 additions & 72 deletions packages/mermaid-local-editor/static/js/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,37 @@ export async function renderDiagram({
try {
const { svg } = await mermaid.render(IS_E2E ? 'm1' : 'm' + Date.now(), srcValue);

// Tear down previous render and all its event listeners
preview.replaceChildren();
state.iframeRef = null;
if (state.abortController) {
state.abortController.abort();
}
state.abortController = new AbortController();
const { signal } = state.abortController;

if (IS_E2E) {
const cleanSvg = DOMPurify.sanitize(svg, {
ADD_TAGS: ['foreignObject'],
ADD_ATTR: ['xmlns'],
});

const doc = new DOMParser().parseFromString(cleanSvg, 'image/svg+xml');
preview.replaceChildren(doc.documentElement);
return;
}

// iframe renders the SVG but never receives pointer events
const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-same-origin';
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
iframe.style.cssText = 'width:100%;height:100%;border:none;pointer-events:none;display:block;';

// Overlay sits on top and captures all mouse/wheel events in parent coordinate space
const overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;inset:0;z-index:10;cursor:default;';

preview.style.position = 'relative';
preview.appendChild(iframe);
preview.appendChild(overlay);
state.iframeRef = iframe;

const cleanSvg = DOMPurify.sanitize(svg, {
Expand All @@ -40,9 +51,7 @@ export async function renderDiagram({

const parsed = new DOMParser().parseFromString(cleanSvg, 'image/svg+xml');
const svgEl = parsed.documentElement;

svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');

svgEl.querySelectorAll('*').forEach((el) => {
[...el.attributes].forEach((attr) => {
if (attr.name.startsWith('on')) {
Expand All @@ -56,102 +65,108 @@ export async function renderDiagram({
doc.close();
doc.body.appendChild(doc.importNode(svgEl, true));

setTimeout(() => {
rebuildNavNodes();
}, 0);
setTimeout(() => rebuildNavNodes(), 0);

requestAnimationFrame(() => {
const svgEl = iframe.contentDocument?.querySelector('svg');
if (!svgEl) {
return;
const s = iframe.contentDocument?.querySelector('svg');
if (s) {
s.style.transformOrigin = '0 0';
s.style.display = 'block';
}

svgEl.style.transformOrigin = '0 0';
svgEl.style.display = 'block';
});

const style = doc.createElement('style');
style.textContent = `
text { fill: #e6e6e6 !important; }
.node rect, .node polygon, .node path {
transition: fill 120ms ease, filter 120ms ease;
}

.node:hover rect,
.node:hover polygon,
.node:hover path {
fill: rgba(0, 170, 255, 0.25);
filter: drop-shadow(0 0 11px rgba(0, 170, 255, 0.6));
filter: drop-shadow(0 0 8px rgba(0, 120, 220, 0.55));
}

g.node.selected-node rect,
g.node.selected-node polygon,
g.node.selected-node path {
fill: rgba(0, 170, 255, 0.35) !important;
stroke: #00aaff !important;
stroke-width: 2px !important;
filter: drop-shadow(0 0 16px rgba(0, 170, 255, 1)) !important;
}

g.node.selected-node text {
fill: #ffffff !important;
font-weight: bold !important;
stroke: #0078dc !important;
stroke-width: 2.5px !important;
filter: drop-shadow(0 0 12px rgba(0, 120, 220, 0.8)) !important;
}

body {
margin: 0;
overflow: hidden;
background: #ffffff;
}
`;
doc.head.appendChild(style);

iframe.addEventListener('load', () => {
window.focus();
});

doc.onwheel = null;
doc.onmousedown = null;
doc.onmouseup = null;
doc.onmousemove = null;

let isPanningLocal = false;
let startXLocal = 0;
let startYLocal = 0;

doc.onwheel = (e) => {
e.preventDefault();
state.scale += e.deltaY * -0.0015;
state.scale = Math.min(Math.max(0.2, state.scale), 4);
applyTransform();
};

doc.onmousedown = (e) => {
isPanningLocal = true;
startXLocal = e.clientX - state.panX;
startYLocal = e.clientY - state.panY;
doc.body.style.cursor = 'grabbing';
};

doc.onmouseup = () => {
isPanningLocal = false;
doc.body.style.cursor = 'default';
};

doc.onmousemove = (e) => {
if (!isPanningLocal) {
return;
}
state.panX = e.clientX - startXLocal;
state.panY = e.clientY - startYLocal;
applyTransform();
};
// ── Zoom (cursor-anchored, multiplicative) ────────────────────────────────
overlay.addEventListener(
'wheel',
(e) => {
e.preventDefault();
const rect = overlay.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;

const oldScale = state.scale;
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
state.scale = Math.min(Math.max(0.05, oldScale * factor), 20);

const ratio = state.scale / oldScale;
state.panX = cx - ratio * (cx - state.panX);
state.panY = cy - ratio * (cy - state.panY);

applyTransform();
},
{ passive: false, signal }
);

// ── Panning ───────────────────────────────────────────────────────────────
let isPanning = false;
let startX = 0;
let startY = 0;

overlay.addEventListener(
'mousedown',
(e) => {
isPanning = true;
startX = e.clientX - state.panX;
startY = e.clientY - state.panY;
overlay.style.cursor = 'grabbing';
},
{ signal }
);

document.addEventListener(
'mouseup',
() => {
if (!isPanning) {
return;
}
isPanning = false;
overlay.style.cursor = 'default';
},
{ signal }
);

document.addEventListener(
'mousemove',
(e) => {
if (!isPanning) {
return;
}
state.panX = e.clientX - startX;
state.panY = e.clientY - startY;
applyTransform();
},
{ signal }
);
} catch (e) {
preview.replaceChildren();

const pre = document.createElement('pre');
pre.style.color = '#ff6b6b';
pre.textContent = e.message;

preview.appendChild(pre);
}
}
53 changes: 53 additions & 0 deletions packages/mermaid-local-editor/static/js/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,59 @@ export function setupUI({
applyTransform(); // this will save the reset to storage.diagrams[storage.current].view
};

document.getElementById('exportPng').onclick = () => {
if (!state.iframeRef) {
return;
}

const svg = state.iframeRef.contentDocument?.querySelector('svg');
if (!svg) {
return;
}

const svgClone = svg.cloneNode(true);

// Strip CSS transform — export the full diagram at native size, not what's on screen
svgClone.style.transform = '';
svgClone.style.transformOrigin = '';

// Prefer viewBox dimensions; fall back to SVG width/height attributes
const vb = svg.viewBox.baseVal;
const width = vb.width || parseFloat(svg.getAttribute('width')) || 800;
const height = vb.height || parseFloat(svg.getAttribute('height')) || 600;

svgClone.setAttribute('width', width);
svgClone.setAttribute('height', height);
if (!vb.width) {
svgClone.setAttribute('viewBox', `0 0 ${width} ${height}`);
}

const svgData = new XMLSerializer().serializeToString(svgClone);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);

const img = new Image();
img.onload = () => {
const scale = 2; // 2× for retina sharpness
const canvas = document.createElement('canvas');
canvas.width = width * scale;
canvas.height = height * scale;

const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);

const a = document.createElement('a');
a.download = `${storage.current}.png`;
a.href = canvas.toDataURL('image/png');
a.click();
};
img.src = url;
};

document.getElementById('exportSvg').onclick = () => {
if (!state.iframeRef) {
return;
Expand Down
Loading