From 6209600037104a82f83aff705d6d2293424b35e8 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sat, 14 Mar 2026 10:10:25 +0100 Subject: [PATCH] init - PoC works --- Dockerfile | 8 ++ README.md | 16 ++++ app.js | 187 +++++++++++++++++++++++++++++++++++++++ index.html | 205 +++++++++++++++++++++++++++++++++++++++++++ nginx-container.conf | 67 ++++++++++++++ 5 files changed, 483 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.js create mode 100644 index.html create mode 100644 nginx-container.conf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce59c0f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +# nginxinc/nginx-unprivileged runs as uid 101 on port 8080 — no root needed +FROM docker.io/nginxinc/nginx-unprivileged:alpine + +# Copy app files +COPY --chown=101:101 index.html app.js /usr/share/nginx/html/ +COPY --chown=101:101 nginx-container.conf /etc/nginx/conf.d/default.conf + +EXPOSE 8080 diff --git a/README.md b/README.md new file mode 100644 index 0000000..76ebeec --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Another Mermaid Thing + +Early stage, but should be working. + +## Features + +- local only +- stores to localstorage +- preview in iFrame for some form of sandbox + + +## Setup + +- please use reverse proxy with strict CSP options if hosted online + +Download and run. Container follows. diff --git a/app.js b/app.js new file mode 100644 index 0000000..c4798c6 --- /dev/null +++ b/app.js @@ -0,0 +1,187 @@ +mermaid.initialize({ startOnLoad: false, theme: 'dark' }); + +const editor = document.getElementById('editor'); +const status = document.getElementById('status'); +const previewArea = document.getElementById('previewArea'); +const panLayer = document.getElementById('pan-layer'); +const svgFrame = document.getElementById('svg-frame'); +const overlay = document.getElementById('overlay'); +const placeholder = document.getElementById('placeholder'); + +const STORAGE_KEY = 'mermaid-editor-content'; + +const DEFAULT = `flowchart TD + A[Start] --> B{Decision} + B -->|Yes| C[Do the thing] + B -->|No| D[Skip it] + C --> E[End] + D --> E`; + +editor.value = localStorage.getItem(STORAGE_KEY) ?? DEFAULT; + +editor.addEventListener('input', () => { + localStorage.setItem(STORAGE_KEY, editor.value); +}); + +// ── Render ─────────────────────────────────────────────────────────────────── + +let lastSvg = null; + +async function render() { + const code = editor.value.trim(); + if (!code) return; + + try { + const id = 'mermaid-' + Date.now(); + const { svg } = await mermaid.render(id, code); + lastSvg = svg; + + const { w, h } = parseSvgSize(svg); + svgFrame.width = w; + svgFrame.height = h; + + // Write SVG into sandboxed iframe — scripts inside cannot reach the parent page + svgFrame.srcdoc = ` + + ${svg}`; + + svgFrame.style.display = 'block'; + overlay.style.display = 'none'; + + setStatus('OK', '#4ade80'); + requestAnimationFrame(resetView); + } catch (err) { + showError(err.message || String(err)); + } +} + +function parseSvgSize(svgString) { + const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml'); + const svg = doc.querySelector('svg'); + let w = parseFloat(svg?.getAttribute('width')); + let h = parseFloat(svg?.getAttribute('height')); + if (!w || !h) { + const vb = svg?.getAttribute('viewBox')?.trim().split(/[\s,]+/); + if (vb?.length === 4) { w = parseFloat(vb[2]); h = parseFloat(vb[3]); } + } + return { w: w || 800, h: h || 600 }; +} + +function showError(msg) { + const errEl = document.createElement('div'); + errEl.className = 'error'; + errEl.textContent = msg; + overlay.innerHTML = ''; + overlay.appendChild(errEl); + overlay.style.display = 'flex'; + setStatus('Error', '#f87171'); + setTimeout(() => errEl.classList.add('fade-out'), 4000); + setTimeout(() => { + if (overlay.contains(errEl)) { + overlay.innerHTML = ''; + overlay.appendChild(placeholder); + overlay.style.display = 'flex'; + } + }, 4600); +} + +// ── Toolbar actions ────────────────────────────────────────────────────────── + +function clearEditor() { + editor.value = ''; + localStorage.removeItem(STORAGE_KEY); + lastSvg = null; + svgFrame.srcdoc = ''; + svgFrame.style.display = 'none'; + overlay.innerHTML = ''; + overlay.appendChild(placeholder); + overlay.style.display = 'flex'; +} + +function downloadSvg() { + if (!lastSvg) return; + const blob = new Blob([lastSvg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'diagram.svg'; a.click(); + URL.revokeObjectURL(url); +} + +async function copySvg() { + if (!lastSvg) return; + await navigator.clipboard.writeText(lastSvg); + setStatus('Copied!', '#60a5fa'); +} + +function setStatus(msg, color) { + status.textContent = msg; + status.style.color = color; + setTimeout(() => { status.textContent = ''; }, 2000); +} + +// ── Pan & zoom ─────────────────────────────────────────────────────────────── + +let vt = { x: 0, y: 0, scale: 1 }; +let drag = null; + +function applyTransform() { + panLayer.style.transform = `translate(${vt.x}px, ${vt.y}px) scale(${vt.scale})`; +} + +function resetView() { + const area = previewArea.getBoundingClientRect(); + vt = { + x: (area.width - svgFrame.offsetWidth) / 2, + y: (area.height - svgFrame.offsetHeight) / 2, + scale: 1, + }; + applyTransform(); +} + +previewArea.addEventListener('wheel', e => { + e.preventDefault(); + const rect = previewArea.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const delta = e.deltaY < 0 ? 1.1 : 0.9; + vt.x = mx - (mx - vt.x) * delta; + vt.y = my - (my - vt.y) * delta; + vt.scale = Math.min(10, Math.max(0.1, vt.scale * delta)); + applyTransform(); +}, { passive: false }); + +previewArea.addEventListener('mousedown', e => { + drag = { startX: e.clientX - vt.x, startY: e.clientY - vt.y }; + previewArea.classList.add('dragging'); +}); + +window.addEventListener('mousemove', e => { + if (!drag) return; + vt.x = e.clientX - drag.startX; + vt.y = e.clientY - drag.startY; + applyTransform(); +}); + +window.addEventListener('mouseup', () => { + drag = null; + previewArea.classList.remove('dragging'); +}); + +// ── Keyboard shortcuts ─────────────────────────────────────────────────────── + +editor.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + render(); + } + if (e.key === 'Tab') { + e.preventDefault(); + const s = editor.selectionStart; + editor.value = editor.value.slice(0, s) + ' ' + editor.value.slice(editor.selectionEnd); + editor.selectionStart = editor.selectionEnd = s + 2; + } +}); + +// ── Init ───────────────────────────────────────────────────────────────────── + +render(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..79c32a3 --- /dev/null +++ b/index.html @@ -0,0 +1,205 @@ + + + + + + Simple Mermaid Editor + + + + + +
+

Simple Mermaid Editor

+ live preview +
+ +
+
+
Diagram Source
+ +
+ + + + + +
+
+ +
+
+ Preview + +
+
+
+ +
+
+
+ ← Write a diagram and click Render +

or press Ctrl+Enter

+
+
+
+
+
+ + + + diff --git a/nginx-container.conf b/nginx-container.conf new file mode 100644 index 0000000..da5f501 --- /dev/null +++ b/nginx-container.conf @@ -0,0 +1,67 @@ +# Runs inside the container — TLS and HSTS are handled by the reverse proxy. + +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Disable nginx version in error pages and headers + server_tokens off; + + # ── Security headers ───────────────────────────────────────────────────── + add_header Content-Security-Policy " + default-src 'none'; + script-src 'self' https://cdn.jsdelivr.net; + style-src 'unsafe-inline'; + img-src 'self' data: blob:; + frame-src 'self'; + child-src 'self'; + connect-src 'none'; + font-src 'none'; + object-src 'none'; + base-uri 'self'; + form-action 'none'; + frame-ancestors 'none'; + " always; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; + + # ── Static files ───────────────────────────────────────────────────────── + location / { + try_files $uri $uri/ =404; + } + + location ~* \.(js|css)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + + # nginx overrides parent add_header inside location blocks, so repeat them + add_header Content-Security-Policy " + default-src 'none'; + script-src 'self' https://cdn.jsdelivr.net; + style-src 'unsafe-inline'; + img-src 'self' data: blob:; + frame-src 'self'; + child-src 'self'; + connect-src 'none'; + font-src 'none'; + object-src 'none'; + base-uri 'self'; + form-action 'none'; + frame-ancestors 'none'; + " always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + } + + # Block hidden files (.git, .env, etc.) + location ~ /\. { + deny all; + } +}