Skip to content

Commit d86d625

Browse files
authored
fix(flow-php/web): render each mermaid diagram into its own wrapper (#2580)
fix(flow-php/web): render each mermaid diagram into its own wrappe - own diagram ids instead of mermaid.run() timestamps - create panzoom and listeners once, tear down on disconnect - self-host mermaid, load it lazily off the CDN, initialize once per theme change - hide the wrapper until its diagram is drawn - cover MermaidCodeRenderer markup with unit tests
1 parent b126d85 commit d86d625

8 files changed

Lines changed: 2795 additions & 54 deletions

File tree

Lines changed: 51 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,70 @@
11
import { Controller } from "@hotwired/stimulus";
22
import Panzoom from '@panzoom/panzoom';
3+
import { mermaid } from '../services/mermaid.js';
4+
5+
let sequence = 0;
36

47
export default class extends Controller {
58
static targets = ['svg', 'zoomIn', 'zoomOut'];
9+
static values = { src: String };
610

7-
#originalSource = null;
8-
#onThemeChanged = null;
11+
#id = `mermaid-diagram-${sequence++}`;
12+
#source = null;
913
#panzoom = null;
14+
#onZoomIn = null;
15+
#onZoomOut = null;
16+
#onWheel = null;
17+
#onThemeChanged = null;
1018

1119
connect() {
12-
this.#originalSource = this.svgTarget.textContent;
13-
this.#render();
20+
this.#source = this.svgTarget.textContent;
1421

15-
this.#onThemeChanged = this.#handleThemeChange.bind(this);
22+
this.#onZoomIn = (event) => {
23+
event.preventDefault();
24+
this.#panzoom?.zoomIn();
25+
};
26+
this.#onZoomOut = (event) => {
27+
event.preventDefault();
28+
this.#panzoom?.zoomOut();
29+
};
30+
this.#onWheel = (event) => this.#panzoom?.zoomWithWheel(event);
31+
this.#onThemeChanged = () => this.#render();
32+
33+
this.zoomInTarget.addEventListener('click', this.#onZoomIn);
34+
this.zoomOutTarget.addEventListener('click', this.#onZoomOut);
35+
this.element.addEventListener('wheel', this.#onWheel);
1636
document.addEventListener('theme:changed', this.#onThemeChanged);
37+
38+
this.#render();
1739
}
1840

1941
disconnect() {
20-
if (this.#onThemeChanged) {
21-
document.removeEventListener('theme:changed', this.#onThemeChanged);
22-
}
23-
}
42+
this.zoomInTarget.removeEventListener('click', this.#onZoomIn);
43+
this.zoomOutTarget.removeEventListener('click', this.#onZoomOut);
44+
this.element.removeEventListener('wheel', this.#onWheel);
45+
document.removeEventListener('theme:changed', this.#onThemeChanged);
2446

25-
#handleThemeChange(event) {
26-
const resolved = event.detail?.resolved || 'light';
27-
mermaid.initialize({
28-
startOnLoad: false,
29-
theme: resolved === 'dark' ? 'dark' : 'default',
30-
securityLevel: 'loose',
31-
flowchart: { useMaxWidth: true, htmlLabels: true },
32-
});
33-
34-
this.svgTarget.removeAttribute('data-processed');
35-
this.svgTarget.innerHTML = '';
36-
this.svgTarget.textContent = this.#originalSource;
37-
this.#render();
47+
this.#panzoom?.destroy();
48+
this.#panzoom = null;
3849
}
3950

40-
#render() {
41-
mermaid.run({
42-
nodes: [this.svgTarget],
43-
postRenderCallback: () => {
44-
this.#panzoom = Panzoom(this.svgTarget, {});
45-
this.#panzoom.pan(0, 0);
46-
this.element.addEventListener('wheel', this.#panzoom.zoomWithWheel);
47-
48-
this.zoomInTarget.addEventListener('click', (event) => {
49-
event.preventDefault();
50-
this.#panzoom.zoomIn();
51-
});
52-
53-
this.zoomOutTarget.addEventListener('click', (event) => {
54-
event.preventDefault();
55-
this.#panzoom.zoomOut();
56-
});
57-
},
58-
});
51+
async #render() {
52+
this.#panzoom?.destroy();
53+
this.#panzoom = null;
54+
55+
try {
56+
const { svg, bindFunctions } = await (await mermaid(this.srcValue)).render(this.#id, this.#source);
57+
58+
this.svgTarget.innerHTML = svg;
59+
bindFunctions?.(this.svgTarget);
60+
61+
this.#panzoom = Panzoom(this.svgTarget, {});
62+
this.#panzoom.pan(0, 0);
63+
} catch (error) {
64+
this.svgTarget.textContent = this.#source;
65+
console.error(`Failed to render mermaid diagram "${this.#id}"`, error);
66+
} finally {
67+
this.element.dataset.mermaidState = 'ready';
68+
}
5969
}
6070
}

web/landing/assets/mermaid/mermaid.min.js

Lines changed: 2607 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
let loadPromise = null;
2+
let appliedTheme = null;
3+
4+
function currentTheme() {
5+
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default';
6+
}
7+
8+
function loadScript(src) {
9+
return new Promise((resolve, reject) => {
10+
const script = document.createElement('script');
11+
12+
script.src = src;
13+
script.addEventListener('load', () => resolve(window.mermaid));
14+
script.addEventListener('error', () => reject(new Error(`Failed to load mermaid from "${src}"`)));
15+
16+
document.head.appendChild(script);
17+
});
18+
}
19+
20+
export async function mermaid(src) {
21+
loadPromise ??= loadScript(src);
22+
23+
const instance = await loadPromise;
24+
const theme = currentTheme();
25+
26+
if (theme !== appliedTheme) {
27+
instance.initialize({
28+
startOnLoad: false,
29+
theme,
30+
securityLevel: 'loose',
31+
flowchart: { useMaxWidth: true, htmlLabels: true },
32+
});
33+
34+
appliedTheme = theme;
35+
}
36+
37+
return instance;
38+
}

web/landing/assets/styles/app.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,11 @@ code {
398398
@apply inline-block w-[16px] h-[16px];
399399
}
400400

401+
.mermaid-wrapper[data-mermaid-state="pending"] .mermaid,
402+
.mermaid-wrapper[data-mermaid-state="pending"] .navigation {
403+
@apply invisible;
404+
}
405+
401406

402407
.mermaid-wrapper .mermaid {
403408
@apply z-0 !p-0;

web/landing/src/Flow/Website/Service/Markdown/MermaidCodeRenderer.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer)
3030
if ($info === 'mermaid') {
3131
return new HtmlElement(
3232
'div',
33-
['class' => 'mermaid-wrapper', 'data-controller' => 'mermaid'],
33+
[
34+
'class' => 'mermaid-wrapper',
35+
'data-controller' => 'mermaid',
36+
'data-mermaid-state' => 'pending',
37+
'data-mermaid-src-value' => $this->packages->getUrl('mermaid/mermaid.min.js'),
38+
],
3439
$this->renderElements([
3540
new HtmlElement('div', ['class' => 'navigation'], $this->renderElements([
3641
new HTMLElement(

web/landing/templates/base.html.twig

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,18 +70,6 @@
7070
{% endblock %}
7171

7272
{% block javascripts %}
73-
<script src="https://cdn.jsdelivr.net/npm/mermaid@11.6.0/dist/mermaid.min.js"></script>
74-
<script>
75-
mermaid.initialize({
76-
startOnLoad: false,
77-
theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default',
78-
securityLevel: 'loose',
79-
flowchart: {
80-
useMaxWidth: true,
81-
htmlLabels: true
82-
}
83-
});
84-
</script>
8573
<script>
8674
window.flow = {
8775
dsl: {{ dsl() | raw }},
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\Website\Tests\Double;
6+
7+
use League\CommonMark\Renderer\ChildNodeRendererInterface;
8+
9+
final class NullChildNodeRenderer implements ChildNodeRendererInterface
10+
{
11+
public function getBlockSeparator(): string
12+
{
13+
return "\n";
14+
}
15+
16+
public function getInnerSeparator(): string
17+
{
18+
return "\n";
19+
}
20+
21+
public function renderNodes(iterable $nodes): string
22+
{
23+
return '';
24+
}
25+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\Website\Tests\Unit\Service\Markdown;
6+
7+
use Flow\Website\Service\Markdown\MermaidCodeRenderer;
8+
use Flow\Website\Tests\Double\NullChildNodeRenderer;
9+
use InvalidArgumentException;
10+
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
11+
use League\CommonMark\Node\Block\Paragraph;
12+
use PHPUnit\Framework\Attributes\TestWith;
13+
use PHPUnit\Framework\TestCase;
14+
use Symfony\Component\Asset\Packages;
15+
use Symfony\Component\Asset\PathPackage;
16+
use Symfony\Component\Asset\VersionStrategy\EmptyVersionStrategy;
17+
18+
final class MermaidCodeRendererTest extends TestCase
19+
{
20+
public function buildRenderer(): MermaidCodeRenderer
21+
{
22+
return new MermaidCodeRenderer(new Packages(new PathPackage('/', new EmptyVersionStrategy())));
23+
}
24+
25+
public function test_renders_wrapper_for_mermaid_fenced_code(): void
26+
{
27+
$node = new FencedCode(3, '`', 0);
28+
$node->setInfo('mermaid');
29+
$node->setLiteral("flowchart LR\n A[App] --> B[Tracer]\n");
30+
31+
static::assertSame(
32+
'<div class="mermaid-wrapper" data-controller="mermaid" data-mermaid-state="pending"'
33+
. ' data-mermaid-src-value="/mermaid/mermaid.min.js">'
34+
. '<div class="navigation">'
35+
. '<button class="button" data-mermaid-target="zoomIn"><img src="/images/icons/zoom-in.svg" /></button>'
36+
. '<button class="button" data-mermaid-target="zoomOut"><img src="/images/icons/zoom-out.svg" /></button>'
37+
. '</div>'
38+
. "<pre class=\"mermaid\" data-mermaid-target=\"svg\">flowchart LR\n A[App] --> B[Tracer]\n</pre>"
39+
. '</div>',
40+
(string) $this->buildRenderer()->render($node, new NullChildNodeRenderer()),
41+
);
42+
}
43+
44+
#[TestWith(['php'])]
45+
#[TestWith(['json'])]
46+
#[TestWith([''])]
47+
public function test_returns_null_for_non_mermaid_fenced_code(string $info): void
48+
{
49+
$node = new FencedCode(3, '`', 0);
50+
$node->setInfo($info);
51+
$node->setLiteral('echo "not a diagram";');
52+
53+
static::assertNull($this->buildRenderer()->render($node, new NullChildNodeRenderer()));
54+
}
55+
56+
public function test_throws_on_incompatible_node_type(): void
57+
{
58+
$this->expectException(InvalidArgumentException::class);
59+
$this->expectExceptionMessage('Incompatible node type: ' . Paragraph::class);
60+
61+
$this->buildRenderer()->render(new Paragraph(), new NullChildNodeRenderer());
62+
}
63+
}

0 commit comments

Comments
 (0)