Add the Mermaid reference plugin (#78)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m55s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
CI / Import/export fidelity gate (push) Successful in 45s
CI / Auth e2e pack (push) Successful in 4m50s
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m55s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
CI / Import/export fidelity gate (push) Successful in 45s
CI / Auth e2e pack (push) Successful in 4m50s
The end-to-end proof of the code-block path: packages/plugins/mermaid
bundles the mermaid library (esbuild, ~3.4 MB unpacked — well under the
20 MiB install gate) so diagrams render entirely inside the sandbox; the
frame CSP forbids any network request (pinned by the e2e's off-origin
request assertion).
- Block data is `{ source, svg }`: the source text is the document of
record, `svg` the last successfully rendered snapshot — persisted
together on every good preview, so office/PDF exports can show the
diagram without executing anything (#79).
- Edit mode: source textarea with a debounced live preview and inline
error display; a failing source still persists (typed text never lost),
paired with the last good snapshot.
- Render mode: renders the stored source; if that stops rendering, it
falls back to the stored snapshot with a "stale" note — a bad edit
never breaks render mode.
- mermaid leaves its scratch element (and, on parse errors, an error SVG)
on document.body — the render helper removes both, so the surface only
shows what the plugin inserts.
- e2e mermaid.spec.ts: flowchart renders + survives reload with zero
off-origin requests, inline syntax errors with intact render mode, and
a collaborator sees the diagram appear live. Wired into CI.
- seed.ts now heals a missing owner-admin grant on existing personal
ponds: a dev database shared with the test suites can lose it to a
cleanup, and the seed's contract is "idempotent", not "first run only".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
0003063c39
commit
ef1c31dd2c
@ -378,6 +378,17 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/page-tools.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/page-tools.spec.ts
|
||||||
|
|
||||||
|
# Owner + admin contexts in the collab case → reset first.
|
||||||
|
- name: Reset login rate limit before mermaid pack
|
||||||
|
run: |
|
||||||
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||||
|
|
||||||
|
- name: Run mermaid pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/mermaid.spec.ts
|
||||||
|
|
||||||
- name: Dump server logs on failure
|
- name: Dump server logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
||||||
|
|||||||
@ -160,17 +160,22 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<string> {
|
|||||||
where: { ownerId: user.id, type: 'PERSONAL' },
|
where: { ownerId: user.id, type: 'PERSONAL' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!existing) {
|
const pondId =
|
||||||
const pond = await prisma.pond.create({
|
existing?.id ??
|
||||||
|
(
|
||||||
|
await prisma.pond.create({
|
||||||
data: {
|
data: {
|
||||||
slug: slugify(fixture.displayName) || fixture.username,
|
slug: slugify(fixture.displayName) || fixture.username,
|
||||||
name: fixture.displayName,
|
name: fixture.displayName,
|
||||||
type: 'PERSONAL',
|
type: 'PERSONAL',
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
await ensureOwnerAdminGrant(pond.id, user.id);
|
).id;
|
||||||
}
|
// Also for a pre-existing pond: a dev database shared with test suites can
|
||||||
|
// lose the owner grant to a cleanup — re-seeding must heal it (the seed's
|
||||||
|
// documented contract is "idempotent", not "first run only").
|
||||||
|
await ensureOwnerAdminGrant(pondId, user.id);
|
||||||
// The instance default for additional_ponds is 0 (ADR 0011) — give
|
// The instance default for additional_ponds is 0 (ADR 0011) — give
|
||||||
// the fixtures headroom so pond flows are exercisable in dev/e2e.
|
// the fixtures headroom so pond flows are exercisable in dev/e2e.
|
||||||
await prisma.quotaOverride.upsert({
|
await prisma.quotaOverride.upsert({
|
||||||
|
|||||||
174
apps/web/e2e/mermaid.spec.ts
Normal file
174
apps/web/e2e/mermaid.spec.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import type { BrowserContext, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mermaid reference plugin end to end (issue #78): the code-block proof.
|
||||||
|
* Installs the real package (built into packages/plugins/mermaid/dist) and
|
||||||
|
* drives the acceptance criteria — a flowchart renders and survives reload
|
||||||
|
* and collaboration, syntax errors stay inline without breaking render mode,
|
||||||
|
* and rendering triggers no request leaving the origin (the bundled library
|
||||||
|
* is the only executable code; the frame CSP forbids the rest).
|
||||||
|
*/
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
|
||||||
|
function mermaidZip(): Buffer {
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
return readFileSync(join(here, '../../../packages/plugins/mermaid/dist/mermaid-1.0.0.zip'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installAsRequired(admin: BrowserContext): Promise<void> {
|
||||||
|
await admin.request.patch('/api/v1/admin/plugins/mermaid/mode', { data: { mode: 'disabled' } });
|
||||||
|
await admin.request.delete('/api/v1/admin/plugins/mermaid');
|
||||||
|
const installed = await admin.request.post('/api/v1/admin/plugins', {
|
||||||
|
multipart: {
|
||||||
|
file: { name: 'mermaid.zip', mimeType: 'application/zip', buffer: mermaidZip() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(installed.status(), await installed.text()).toBe(201);
|
||||||
|
const mode = await admin.request.patch('/api/v1/admin/plugins/mermaid/mode', {
|
||||||
|
data: { mode: 'required' },
|
||||||
|
});
|
||||||
|
expect(mode.status(), await mode.text()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function personalPond(context: BrowserContext): Promise<{ id: string; slug: string }> {
|
||||||
|
const ponds = await context.request.get('/api/v1/ponds');
|
||||||
|
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
||||||
|
return { id: pond.id, slug: pond.slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditor(context: BrowserContext, pondSlug: string, slug: string): Promise<Page> {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pondSlug}/${slug}`);
|
||||||
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||||
|
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
|
||||||
|
await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', {
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameBody(page: Page) {
|
||||||
|
return page.frameLocator('.plugin-block iframe').locator('body');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertDiagram(page: Page): Promise<void> {
|
||||||
|
await page.locator('.editor-toolbar__block-select').selectOption('mermaid/diagram');
|
||||||
|
await expect(page.locator('.plugin-block .plugin-block__surface')).toHaveAttribute(
|
||||||
|
'data-state',
|
||||||
|
'ready',
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a flowchart renders, survives reload, and makes no off-origin request', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||||
|
await installAsRequired(admin);
|
||||||
|
const pond = await personalPond(admin);
|
||||||
|
const created = await (
|
||||||
|
await admin.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
||||||
|
data: { title: `E2E Mermaid ${Date.now()}` },
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const page = await openEditor(admin, pond.slug, created.slug);
|
||||||
|
const origin = new URL(BASE_URL).origin;
|
||||||
|
const offOrigin: string[] = [];
|
||||||
|
page.on('request', (request) => {
|
||||||
|
const url = request.url();
|
||||||
|
if (!url.startsWith(origin) && !url.startsWith('data:') && !url.startsWith('blob:')) {
|
||||||
|
offOrigin.push(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await insertDiagram(page);
|
||||||
|
await expect(frameBody(page).locator('p')).toBeVisible({ timeout: 10000 }); // empty hint
|
||||||
|
|
||||||
|
// Enter edit mode, type a flowchart, watch the live preview render.
|
||||||
|
await page.locator('.plugin-block__bar button').click();
|
||||||
|
const textarea = frameBody(page).locator('textarea');
|
||||||
|
await textarea.fill('graph TD; Start-->End;');
|
||||||
|
await expect(frameBody(page).locator('.dt-mermaid__preview svg')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Back to render mode: the diagram SVG is the surface.
|
||||||
|
await page.locator('.plugin-block__bar button').click();
|
||||||
|
await expect(frameBody(page).locator('svg')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Persisted through the document: a fresh load renders the diagram again.
|
||||||
|
await page.reload();
|
||||||
|
await expect(frameBody(page).locator('svg')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
expect(offOrigin, `off-origin requests: ${offOrigin.join(', ')}`).toEqual([]);
|
||||||
|
await admin.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syntax errors show inline in edit mode and never break render mode', async ({ browser }) => {
|
||||||
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||||
|
await installAsRequired(admin);
|
||||||
|
const pond = await personalPond(admin);
|
||||||
|
const created = await (
|
||||||
|
await admin.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
||||||
|
data: { title: `E2E Mermaid Err ${Date.now()}` },
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const page = await openEditor(admin, pond.slug, created.slug);
|
||||||
|
await insertDiagram(page);
|
||||||
|
await page.locator('.plugin-block__bar button').click();
|
||||||
|
|
||||||
|
// A valid diagram first, so a good snapshot exists.
|
||||||
|
const textarea = frameBody(page).locator('textarea');
|
||||||
|
await textarea.fill('graph TD; A-->B;');
|
||||||
|
await expect(frameBody(page).locator('.dt-mermaid__preview svg')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Break the source: the error shows inline, the last preview stays.
|
||||||
|
await textarea.fill('this is not mermaid at all');
|
||||||
|
await expect(frameBody(page).locator('.dt-mermaid__error')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(frameBody(page).locator('.dt-mermaid__preview svg')).toBeVisible();
|
||||||
|
|
||||||
|
// Render mode falls back to the stored snapshot instead of breaking.
|
||||||
|
await page.locator('.plugin-block__bar button').click();
|
||||||
|
await expect(frameBody(page).locator('svg')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await admin.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a collaborator sees the diagram appear live', async ({ browser }) => {
|
||||||
|
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||||
|
await installAsRequired(admin);
|
||||||
|
const pond = await personalPond(owner);
|
||||||
|
const created = await (
|
||||||
|
await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
||||||
|
data: { title: `E2E Mermaid Collab ${Date.now()}` },
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const pageA = await openEditor(owner, pond.slug, created.slug);
|
||||||
|
const pageB = await openEditor(admin, pond.slug, created.slug);
|
||||||
|
|
||||||
|
await insertDiagram(pageA);
|
||||||
|
await pageA.locator('.plugin-block__bar button').click();
|
||||||
|
await frameBody(pageA).locator('textarea').fill('graph TD; Live-->Sync;');
|
||||||
|
await expect(frameBody(pageA).locator('.dt-mermaid__preview svg')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The block data replicates through Yjs; the collaborator's frame renders.
|
||||||
|
await expect(frameBody(pageB).locator('svg')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await owner.close();
|
||||||
|
await admin.close();
|
||||||
|
});
|
||||||
34
packages/plugins/mermaid/build.mjs
Normal file
34
packages/plugins/mermaid/build.mjs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// Builds the installable plugin (plugin-architecture.md §Package format):
|
||||||
|
// bundles src/plugin.ts (SDK + i18n inlined — the sandbox CSP forbids runtime
|
||||||
|
// fetches) into plugin.js as a single ES module, then packs the ZIP for the
|
||||||
|
// admin upload / dropzone watcher.
|
||||||
|
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { zipSync } from 'fflate';
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
||||||
|
|
||||||
|
mkdirSync(join(root, 'dist'), { recursive: true });
|
||||||
|
await build({
|
||||||
|
entryPoints: [join(root, 'src/plugin.ts')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
outfile: join(root, 'dist/plugin.js'),
|
||||||
|
minify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
'manifest.json': readFileSync(join(root, 'manifest.json')),
|
||||||
|
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
|
||||||
|
};
|
||||||
|
for (const name of readdirSync(join(root, 'i18n'))) {
|
||||||
|
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
|
||||||
|
writeFileSync(target, zipSync(files));
|
||||||
|
console.log(`wrote ${target}`);
|
||||||
6
packages/plugins/mermaid/i18n/de.json
Normal file
6
packages/plugins/mermaid/i18n/de.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"placeholder": "Mermaid-Quelltext, z. B.: graph TD; A-->B;",
|
||||||
|
"empty": "Noch kein Diagramm — Quelltext im Bearbeiten-Modus eingeben.",
|
||||||
|
"error": "Mermaid-Fehler:",
|
||||||
|
"stale": "Zeigt den letzten gültigen Stand; der aktuelle Quelltext hat Fehler."
|
||||||
|
}
|
||||||
6
packages/plugins/mermaid/i18n/en.json
Normal file
6
packages/plugins/mermaid/i18n/en.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"placeholder": "Mermaid source, e.g.: graph TD; A-->B;",
|
||||||
|
"empty": "No diagram yet — enter source in edit mode.",
|
||||||
|
"error": "Mermaid error:",
|
||||||
|
"stale": "Showing the last valid state; the current source has errors."
|
||||||
|
}
|
||||||
18
packages/plugins/mermaid/manifest.json
Normal file
18
packages/plugins/mermaid/manifest.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "mermaid",
|
||||||
|
"name": "Mermaid Diagrams",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "1",
|
||||||
|
"kind": "code",
|
||||||
|
"extensionPoints": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "diagram",
|
||||||
|
"title": { "de": "Mermaid-Diagramm", "en": "Mermaid diagram" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"permissions": ["blockData", "ui"],
|
||||||
|
"fallback": { "type": "text", "value": "[Mermaid diagram]" },
|
||||||
|
"license": "MIT",
|
||||||
|
"i18n": { "de": "i18n/de.json", "en": "i18n/en.json" }
|
||||||
|
}
|
||||||
21
packages/plugins/mermaid/package.json
Normal file
21
packages/plugins/mermaid/package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@dorfteich/plugin-mermaid",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Reference block plugin: Mermaid diagrams edited and rendered inside the sandbox (issue #78)",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dorfteich/plugin-sdk": "workspace:*",
|
||||||
|
"@types/node": "^26.1.0",
|
||||||
|
"esbuild": "^0.24.0",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"mermaid": "^11.4.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
157
packages/plugins/mermaid/src/plugin.ts
Normal file
157
packages/plugins/mermaid/src/plugin.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
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<string, Record<string, string>> = { 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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
11
packages/plugins/mermaid/tsconfig.json
Normal file
11
packages/plugins/mermaid/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src", "*.ts"]
|
||||||
|
}
|
||||||
812
pnpm-lock.yaml
generated
812
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user