Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
42ebf5d
Add Mermaid Local Editor (Custom Template)
Dvurechensky Apr 15, 2026
3979ce1
chore: re-run CI
Dvurechensky Apr 15, 2026
e2af1d5
fix: prevent XSS by removing innerHTML usage in local editor
Dvurechensky Apr 15, 2026
e44784e
fix: SVG output from mermaid is sanitized (scripts, event handlers re…
Dvurechensky Apr 15, 2026
37a0492
fix: SVG output move only safe nodes
Dvurechensky Apr 15, 2026
f20787c
fix: // CodeQL: sanitized SVG insertion (no scripts, no event handlers)
Dvurechensky Apr 15, 2026
d19a4f2
fix: // CodeQL: is reinterpreted as HTML without escaping meta-charac…
Dvurechensky Apr 15, 2026
3caf11e
fix: // CodeQL: is reinterpreted as HTML without escaping meta-charac…
Dvurechensky Apr 15, 2026
df46b3e
fix: // CodeQL: SVG is parsed and sanitized before insertion (scripts…
Dvurechensky Apr 15, 2026
1fad0f1
fix: // CodeQL: SAFE: SVG is parsed and sanitized before insertion (s…
Dvurechensky Apr 15, 2026
d1d8639
fix: // CodeQL: is reinterpreted as HTML without escaping meta-charac…
Dvurechensky Apr 15, 2026
a406aa1
fix: architecture-diagram-should-render-a-deterministic-layout-for-a-…
Dvurechensky Apr 15, 2026
c9c029b
chore: re-run CI
Dvurechensky Apr 15, 2026
733704f
fix: architecture-diagram-should-render-a-deterministic-layout-for-a-…
Dvurechensky Apr 15, 2026
f2a43fc
fix: architecture-diagram-should-render-a-deterministic-layout-for-a-…
Dvurechensky Apr 15, 2026
92da5d9
fix: architecture-diagram-should-render-a-deterministic-layout-for-a-…
Dvurechensky Apr 15, 2026
011270f
fix: architecture-diagram-should-render-a-deterministic-layout-for-a-…
Dvurechensky Apr 15, 2026
1d1e420
feat: add local editor package and address review feedback
Dvurechensky Apr 17, 2026
00f083e
fix: apply autofix and sync lockfile
Dvurechensky Apr 17, 2026
52dd166
chore: retrigger CI after workspace fix
Dvurechensky Apr 17, 2026
4f041d0
fix: sync lock with package.json
Dvurechensky Apr 17, 2026
5884852
fix: declare browser globals for renderer
Dvurechensky Apr 21, 2026
d80a5e2
fix: resolve CI lint issues
Dvurechensky Apr 21, 2026
65fe19c
Merge branch 'develop' into develop
Dvurechensky Apr 21, 2026
33c36bd
fix: misleading variable name ui.js
Dvurechensky Apr 21, 2026
de6dce3
Merge branch 'develop' of https://github.com/Dvurechensky-Tools/merma…
Dvurechensky Apr 21, 2026
c8b892f
Merge branch 'develop' into develop
Dvurechensky Apr 30, 2026
27cf1a9
Merge branch 'develop' into develop
Dvurechensky May 5, 2026
9f7ea0f
Merge branch 'develop' into develop
Dvurechensky May 5, 2026
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/crazy-jobs-see.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mermaid-js/mermaid-local-editor': patch
---

feat: add local editor package for Mermaid diagrams
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@
"git graph"
],
"scripts": {
"clean": "rimraf packages/mermaid/dist",
"copy:dompurify": "cpy node_modules/.pnpm/dompurify@*/node_modules/dompurify/dist/purify.min.js packages/mermaid/dist/mermaid-local-editor/vendor/ --flat",
"copy:mermaid": "cpy packages/mermaid/dist/mermaid.min.js packages/mermaid/dist/mermaid-local-editor/vendor/ --flat",
"copy:editor": "cpy \"packages/mermaid-local-editor/static/**/*\" packages/mermaid/dist/mermaid-local-editor",
"serve:dist": "sirv packages/mermaid/dist/mermaid-local-editor --port 8081 --dev --no-clear",
"build": "pnpm build:esbuild && pnpm build:types",
"build:esbuild": "pnpm run -r clean && tsx .esbuild/build.ts",
"build:mermaid": "pnpm build:esbuild --mermaid",
"build:mermaid:full": "pnpm clean && pnpm build:mermaid && pnpm copy:editor && pnpm copy:mermaid && pnpm copy:dompurify && pnpm serve:dist",
"build:viz": "pnpm build:esbuild --visualize",
"build:types": "pnpm --filter mermaid types:build-config && tsx .build/types.ts",
"build:types:watch": "tsc -p ./packages/mermaid/tsconfig.json --emitDeclarationOnly --watch",
Expand Down Expand Up @@ -133,6 +139,7 @@
"prettier-plugin-jsdoc": "^1.3.3",
"rimraf": "^6.0.1",
"rollup-plugin-visualizer": "^6.0.5",
"sirv-cli": "^3.0.1",
"start-server-and-test": "^2.1.3",
"tslib": "^2.8.1",
"tsx": "^4.20.6",
Expand Down
74 changes: 74 additions & 0 deletions packages/mermaid-local-editor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Mermaid Local Editor

Standalone local editor for Mermaid diagrams.
Runs entirely from `dist/` with no external dependencies.

---

## Usage

```sh
pnpm build:mermaid:full
```

---

## Build Pipeline

The `build:mermaid:full` command performs the following steps:

1. **Clean**

Removes the existing build output:

```sh
pnpm clean
```

2. **Build Mermaid**

Compiles Mermaid using the repository build pipeline:

```sh
pnpm build:mermaid
```

3. **Copy Editor**

Copies the local editor sources into the distribution directory:

```sh
pnpm copy:editor
```

4. **Bundle Dependencies**

Copies required runtime dependencies into the editor bundle:

```sh
pnpm copy:mermaid
pnpm copy:dompurify
```

5. **Serve**

Starts a local static server:

```sh
pnpm serve:dist
```

---

## Output

After build, the editor is available at [`packages/mermaid/dist/mermaid-local-editor/`](../mermaid/dist/mermaid-local-editor):

---

## Notes

- No external CDN dependencies are used
- DOMPurify is bundled locally
- The editor is fully offline-capable
- Designed to run directly from the `dist/` directory
6 changes: 6 additions & 0 deletions packages/mermaid-local-editor/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "@mermaid-js/mermaid-local-editor",
"private": true,
"version": "0.0.0",
"type": "module"
}
80 changes: 80 additions & 0 deletions packages/mermaid-local-editor/static/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { initMermaid, state, IS_E2E } from './js/config.js';
import { createStorage } from './js/storage.js';
import { renderDiagram } from './js/renderer.js';
import { setupUI, refreshList } from './js/ui.js';
import { createNavigation } from './js/navigation.js';

initMermaid();

const srcPanel = document.getElementById('srcPanel');
const preview = document.getElementById('preview');
const diagramsSelect = document.getElementById('diagrams');
const nameInput = document.getElementById('name');
const storage = createStorage();
const navigation = createNavigation({
state,
preview,
srcPanel,
applyTransform,
});

function render() {
void renderDiagram({
srcValue: srcPanel.value,
preview,
state,
IS_E2E,
applyTransform,
rebuildNavNodes: navigation.rebuildNavNodes,
});
}

function load(name) {
storage.setCurrent(name);
const d = storage.diagrams[name];

srcPanel.value = d.src;

// restore the view
state.scale = d.view?.scale ?? 1;
state.panX = d.view?.panX ?? 0;
state.panY = d.view?.panY ?? 0;

refreshList({ diagramsSelect, nameInput, storage });
render();
requestAnimationFrame(applyTransform);
}

function applyTransform() {
if (!state.iframeRef) {
return;
}

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

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

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

storage.updateCurrent({
view: { scale: state.scale, panX: state.panX, panY: state.panY },
});
}

setupUI({
src: srcPanel,
diagramsSelect,
nameInput,
storage,
state,
render,
load,
applyTransform,
});

navigation.setupKeyboardNav();
load(storage.current);
31 changes: 31 additions & 0 deletions packages/mermaid-local-editor/static/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Mermaid Local Editor</title>

<link rel="stylesheet" href="./styles.css" />

<script src="./vendor/mermaid.min.js"></script>
<script src="./vendor/purify.min.js"></script>

<script type="module" src="./app.js"></script>
</head>
<body>
<div id="toolbar">
<button id="toggleToolbar" title="Toggle toolbar">☰</button>
<select id="diagrams"></select>
<input id="name" placeholder="Enter diagram name" />
<button id="save">💾 Save</button>
<button id="new">➕ New</button>
<button id="del">🗑 Delete</button>
<button id="resetView">↺ Reset View</button>
<button id="exportSvg">⬇ Export SVG</button>
</div>

<div id="main">
<textarea id="srcPanel"></textarea>
<div id="preview"></div>
</div>
</body>
</html>
24 changes: 24 additions & 0 deletions packages/mermaid-local-editor/static/js/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* global mermaid */

export const IS_E2E = navigator.webdriver || location.search.includes('graph=');

export function initMermaid() {
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
securityLevel: 'strict',
deterministicIds: true,
fontFamily: 'Arial',
htmlLabels: false,
flowchart: {
useMaxWidth: false,
},
});
}

export let state = {
scale: 1,
panX: 0,
panY: 0,
iframeRef: null,
};
96 changes: 96 additions & 0 deletions packages/mermaid-local-editor/static/js/navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
export function createNavigation({ state, preview, srcPanel, applyTransform }) {
let navNodes = [];
let navIndex = 0;

function rebuildNavNodes() {
navNodes = [];
navIndex = 0;

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

navNodes = [...svg.querySelectorAll('g.node')];
navIndex = 0;

if (navNodes.length) {
highlightCurrentNode();
centerCurrentNode();
}
}

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

svg
.querySelectorAll('g.node.selected-node')
.forEach((n) => n.classList.remove('selected-node'));

const node = navNodes[navIndex];
if (node) {
node.classList.add('selected-node');
}
}

function centerCurrentNode() {
const node = navNodes[navIndex];
if (!node) {
return;
}

const nodeRect = node.getBoundingClientRect();
const contRect = preview.getBoundingClientRect();

const nodeCenterY = nodeRect.top + nodeRect.height / 6;
const contCenterY = contRect.top + contRect.height / 6;
const deltaY = contCenterY - nodeCenterY;

// Intentionally biased to upper-left instead of strict center,
// so forward nodes remain visible in LR / TD diagrams.
const nodeCenterX = nodeRect.left + nodeRect.width / 6;
const contCenterX = contRect.left + contRect.width / 6;
const deltaX = contCenterX - nodeCenterX;

state.panX += deltaX;
state.panY += deltaY;

applyTransform();
}

function setupKeyboardNav() {
window.addEventListener('keydown', (e) => {
if (document.activeElement === srcPanel) {
return;
}

if (e.key === 'ArrowDown') {
e.preventDefault();
if (!navNodes.length) {
rebuildNavNodes();
}
navIndex = Math.min(navIndex + 1, navNodes.length - 1);
highlightCurrentNode();
centerCurrentNode();
}

if (e.key === 'ArrowUp') {
e.preventDefault();
if (!navNodes.length) {
rebuildNavNodes();
}
navIndex = Math.max(navIndex - 1, 0);
highlightCurrentNode();
centerCurrentNode();
}
});
}

return {
rebuildNavNodes,
setupKeyboardNav,
};
}
Loading
Loading