Created
May 20, 2025 14:49
-
-
Save habdelra/f5718c1a1614f8b2ec32387325a958e7 to your computer and use it in GitHub Desktop.
Idiomatic Boxel Mermaid Card
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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