import mermaid from 'mermaid'; import { createPlugin, windowTransport, type RenderContext } from '@dorfteich/plugin-sdk'; import de from '../i18n/de.json'; import en from '../i18n/en.json'; /** * Mermaid reference plugin (issue #78) — the end-to-end proof of the code * block path: the mermaid library is bundled into plugin.js and runs entirely * inside the sandbox (the frame CSP forbids any network request). * * Block data: `{ source, svg }` — the source text is the document of record; * `svg` is the last successfully rendered snapshot, persisted alongside so * office/PDF exports can show the diagram without executing anything (#79). * * Render mode shows the diagram (or, if the stored source no longer renders, * the stored snapshot with a "stale" note — a bad edit never breaks render * mode). Edit mode is a source textarea with a live, debounced preview and * inline error display; every successful preview persists source + snapshot * through `blockData.setData`. */ const STRINGS: Record> = { de, en }; const PREVIEW_DEBOUNCE_MS = 400; function labelFor(locale: string, key: string): string { const base = locale.split('-')[0] ?? locale; return STRINGS[base]?.[key] ?? STRINGS.en?.[key] ?? key; } interface DiagramData { source?: string; svg?: string; } mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' }); let renderSeq = 0; /** Renders mermaid source to SVG markup; throws on syntax errors. */ async function toSvg(source: string): Promise { renderSeq += 1; const id = `dt-mermaid-${renderSeq}`; try { const { svg } = await mermaid.render(id, source); return svg; } finally { // mermaid renders into a scratch element on `document.body` and, on a // parse error, leaves its own error SVG behind — remove both so the // surface only ever shows what this plugin inserts itself. document.getElementById(id)?.remove(); document.getElementById(`d${id}`)?.remove(); } } function dataOf(context: RenderContext): DiagramData { return context.data && typeof context.data === 'object' ? (context.data as DiagramData) : {}; } const { host } = createPlugin({ transport: windowTransport({ target: { postMessage: (message) => window.parent.postMessage(message, '*') }, source: window, }), onRender: (context) => renderMode(context), onEdit: (context) => editMode(context), }); function resize(): void { void host.ui.resize(Math.max(64, document.body.scrollHeight + 16)); } async function renderMode(context: RenderContext): Promise { const data = dataOf(context); document.body.textContent = ''; document.body.className = 'dt-mermaid dt-mermaid--render'; const source = (data.source ?? '').trim(); if (source === '') { const hint = document.createElement('p'); hint.textContent = labelFor(context.locale, 'empty'); document.body.appendChild(hint); resize(); return; } try { document.body.innerHTML = await toSvg(source); } catch { // The stored source no longer renders (e.g. edited elsewhere with an // error saved mid-typing): fall back to the last good snapshot. if (data.svg) { document.body.innerHTML = data.svg; const note = document.createElement('p'); note.textContent = labelFor(context.locale, 'stale'); document.body.appendChild(note); } else { document.body.textContent = labelFor(context.locale, 'error'); } } resize(); } function editMode(context: RenderContext): void { const data = dataOf(context); document.body.textContent = ''; document.body.className = 'dt-mermaid dt-mermaid--edit'; const textarea = document.createElement('textarea'); textarea.placeholder = labelFor(context.locale, 'placeholder'); textarea.value = data.source ?? ''; textarea.rows = 6; textarea.style.width = '100%'; textarea.style.boxSizing = 'border-box'; textarea.style.fontFamily = 'monospace'; const error = document.createElement('p'); error.className = 'dt-mermaid__error'; error.style.color = '#b91c1c'; error.style.whiteSpace = 'pre-wrap'; error.hidden = true; const preview = document.createElement('div'); preview.className = 'dt-mermaid__preview'; if (data.svg) preview.innerHTML = data.svg; document.body.append(textarea, error, preview); resize(); let debounce: number | undefined; let lastGoodSvg = data.svg ?? ''; const refresh = async (): Promise => { const source = textarea.value; try { const svg = await toSvg(source); lastGoodSvg = svg; preview.innerHTML = svg; error.hidden = true; // Persist source + snapshot together — the snapshot is what office/PDF // exports show (#79), so it must always match a source that rendered. void host.blockData.setData({ source, svg }); } catch (cause) { // Inline error, previous preview stays; the source is still persisted // so a collaborator/reload never loses typed text. error.textContent = `${labelFor(context.locale, 'error')} ${String( (cause as Error)?.message ?? cause, )}`; error.hidden = false; void host.blockData.setData({ source, svg: lastGoodSvg }); } resize(); }; textarea.addEventListener('input', () => { window.clearTimeout(debounce); debounce = window.setTimeout(() => void refresh(), PREVIEW_DEBOUNCE_MS); }); if (textarea.value.trim() !== '' && !data.svg) void refresh(); }