Skip to content

Instantly share code, notes, and snippets.

@habdelra
Created May 20, 2025 14:49
Show Gist options
  • Select an option

  • Save habdelra/f5718c1a1614f8b2ec32387325a958e7 to your computer and use it in GitHub Desktop.

Select an option

Save habdelra/f5718c1a1614f8b2ec32387325a958e7 to your computer and use it in GitHub Desktop.
Idiomatic Boxel Mermaid Card
import {
CardDef,
field,
contains,
Component,
} from 'https://cardstack.com/base/card-api';
import TextAreaField from 'https://cardstack.com/base/text-area';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
import { Button } from '@cardstack/boxel-ui/components';
import { task, restartableTask } from 'ember-concurrency';
import Modifier from 'ember-modifier';
function mermaid() {
return (globalThis as any).mermaid;
}
const defaultDiagramText = 'graph TD;\nA-->B';
class Isolated extends Component<typeof MermaidTest> {
@tracked errorMessage = '';
@tracked zoomLevel = 2; // Initial zoom level
private el: HTMLElement | undefined;
private loadMermaid = task(async () => {
if (mermaid()) {
return;
}
const script = document.createElement('script');
script.src =
'https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js';
script.async = true;
// Wait for script to load
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
mermaid().initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose',
fontSize: '16px',
fontFamily: 'Poppins, sans-serif',
primaryColor: '#fff4dd',
primaryTextColor: '#333333',
background: '#ffffff',
lineColor: '#333333',
mainBkg: '#f9f9f9',
noteBkgColor: '#fff5ad',
noteTextColor: '#333333',
// Additional explicit styling to ensure consistency
htmlLabels: true, // Enable HTML in labels for consistent rendering
flowchart: {
htmlLabels: true,
curve: 'linear',
},
er: {
useMaxWidth: false,
},
sequence: {
useMaxWidth: false,
showSequenceNumbers: false,
},
gantt: {
useMaxWidth: false,
},
class: {
useMaxWidth: false,
},
});
});
private onMermaidElement = (el: HTMLElement) => {
this.el = el;
this.runMermaid.perform(this.args.model.diagramText);
};
private runMermaid = restartableTask(
async (diagramText: string | undefined = defaultDiagramText) => {
this.errorMessage = '';
try {
await this.loadMermaid.perform();
if (!mermaid() || !this.el) {
return;
}
const tempId = `diagram-${Date.now()}`;
this.el.innerHTML = `<div id="${tempId}" class="mermaid">${diagramText}</div>`;
await mermaid().run();
} catch (e: any) {
this.errorMessage = `Error: ${e.message}`;
}
// Apply initial zoom after diagram is rendered
this.updateZoom();
},
);
private zoomIn = () => {
this.zoomLevel += 0.5;
this.updateZoom();
};
private zoomOut = () => {
if (this.zoomLevel > 0.5) {
this.zoomLevel -= 0.5;
this.updateZoom();
}
};
private updateZoom = () => {
if (this.el) {
const svgElement = this.el.querySelector('svg');
if (svgElement) {
svgElement.style.transform = `scale(${this.zoomLevel})`;
}
}
};
<template>
<div class='mermaid-card'>
{{#if this.errorMessage}}
<div class='error-message'>{{this.errorMessage}}</div>
{{/if}}
<div class='zoom-controls'>
<Button
@kind='secondary'
@size='small'
{{on 'click' this.zoomOut}}
>-</Button>
<span class='zoom-level'>{{this.zoomLevel}}x</span>
<Button
@kind='secondary'
@size='small'
{{on 'click' this.zoomIn}}
>+</Button>
</div>
<div class='scroll-container'>
<div class='diagram-container'>
<div {{MermaidDiagram onElement=this.onMermaidElement}}></div>
</div>
</div>
</div>
<style scoped>
.mermaid-card {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.zoom-controls {
position: absolute;
top: 10px;
right: 10px;
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.8);
padding: 4px 8px;
border-radius: 4px;
z-index: 10;
}
.zoom-level {
font-family: Poppins, sans-serif;
font-size: 14px;
min-width: 40px;
text-align: center;
}
.scroll-container {
width: 100%;
height: 100%;
overflow: auto;
position: relative;
}
.diagram-container {
padding: 500px;
min-width: max-content;
min-height: max-content;
display: inline-block;
}
#mermaid-output {
position: relative;
}
#mermaid-output svg {
display: block;
width: auto !important;
height: auto !important;
transform-origin: center;
max-height: none !important;
max-width: none !important;
}
/* Additional styling to shield the diagram from outside CSS */
.diagram-container .mermaid {
font-family: Poppins, sans-serif !important;
font-size: 16px !important;
}
/* Ensure text elements have consistent sizing */
.diagram-container .mermaid text {
font-family: Poppins, sans-serif !important;
font-size: 16px !important;
fill: #333 !important;
}
/* Style class diagram specific elements */
.diagram-container .mermaid .classText .title {
font-family: Poppins, sans-serif !important;
font-weight: bold !important;
font-size: 16px !important;
}
.diagram-container .mermaid .classText .label {
font-family: Poppins, sans-serif !important;
font-size: 14px !important;
}
.error-message {
color: #d32f2f;
margin-bottom: 10px;
padding: 10px;
background-color: #ffebee;
border-radius: 4px;
}
</style>
</template>
}
interface Signature {
Args: {
Named: {
onElement: (element: HTMLElement) => void;
};
};
}
class MermaidDiagram extends Modifier<Signature> {
modify(
element: HTMLElement,
_positional: [],
{ onElement }: Signature['Args']['Named'],
) {
onElement(element);
}
}
export class MermaidTest extends CardDef {
static displayName = 'Mermaid Diagram';
static prefersWideFormat = true;
@field diagramText = contains(TextAreaField);
static isolated = Isolated;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment