fix: render mermaid diagrams with lightbox
This commit is contained in:
@@ -273,6 +273,7 @@
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mermaid-diagram {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
color: var(--colors-text);
|
||||
@@ -287,6 +288,20 @@
|
||||
background: var(--surface-editor);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mermaid-diagram__expand-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
background: var(--surface-editor);
|
||||
box-shadow: 0 6px 16px rgb(15 23 42 / 0.14);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mermaid-diagram:is(:hover, :focus-within) .mermaid-diagram__expand-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mermaid-diagram__viewport:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
@@ -300,6 +315,42 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.editor__blocknote-container .mermaid-diagram__expand-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-diagram__dialog {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: calc(100vw - 32px);
|
||||
height: calc(100dvh - 32px);
|
||||
max-height: calc(100dvh - 32px);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mermaid-diagram__dialog-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mermaid-diagram__dialog-viewport:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.mermaid-diagram__dialog-viewport svg {
|
||||
display: block;
|
||||
max-width: none;
|
||||
min-width: min-content;
|
||||
height: auto;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mermaid-diagram--error {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--destructive);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MermaidDiagram } from './MermaidDiagram'
|
||||
|
||||
@@ -31,6 +31,23 @@ describe('MermaidDiagram', () => {
|
||||
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
expect(mermaidMock.render).toHaveBeenCalledWith(expect.stringMatching(/^tolaria-mermaid-/), 'flowchart LR\nA --> B')
|
||||
expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({ theme: 'default' }))
|
||||
})
|
||||
|
||||
it('opens the rendered SVG in a lightbox', async () => {
|
||||
render(
|
||||
<MermaidDiagram
|
||||
diagram={'flowchart LR\nA --> B'}
|
||||
source={'```mermaid\nflowchart LR\nA --> B\n```'}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Mermaid diagram' }))
|
||||
expect(screen.getByTestId('mermaid-diagram-dialog-viewport').querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the original source when Mermaid cannot render', async () => {
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
type MermaidApi = typeof import('mermaid')['default']
|
||||
|
||||
@@ -7,6 +16,13 @@ interface MermaidDiagramProps {
|
||||
source: string
|
||||
}
|
||||
|
||||
interface MermaidSvgViewportProps {
|
||||
ariaLabel: string
|
||||
className: string
|
||||
svg: string
|
||||
testId: string
|
||||
}
|
||||
|
||||
interface RenderState {
|
||||
diagram: string
|
||||
svg: string
|
||||
@@ -27,15 +43,8 @@ function initializeMermaid(mermaid: MermaidApi) {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: 'base',
|
||||
theme: 'default',
|
||||
themeVariables: {
|
||||
background: '#ffffff',
|
||||
primaryColor: '#f8fafc',
|
||||
primaryTextColor: '#111827',
|
||||
primaryBorderColor: '#94a3b8',
|
||||
lineColor: '#64748b',
|
||||
secondaryColor: '#f1f5f9',
|
||||
tertiaryColor: '#e0f2fe',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
},
|
||||
})
|
||||
@@ -60,6 +69,50 @@ async function renderMermaidDiagram({
|
||||
return nextRender
|
||||
}
|
||||
|
||||
function MermaidSvgViewport({ ariaLabel, className, svg, testId }: MermaidSvgViewportProps) {
|
||||
return (
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
data-testid={testId}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MermaidLightbox({ svg }: { svg: string }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
aria-label="Open Mermaid diagram"
|
||||
className="mermaid-diagram__expand-button"
|
||||
size="icon-sm"
|
||||
title="Open diagram"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Maximize2 aria-hidden="true" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="mermaid-diagram__dialog" showCloseButton>
|
||||
<DialogTitle className="sr-only">Mermaid diagram</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Expanded view of the rendered Mermaid diagram.
|
||||
</DialogDescription>
|
||||
<MermaidSvgViewport
|
||||
ariaLabel="Expanded Mermaid diagram"
|
||||
className="mermaid-diagram__dialog-viewport"
|
||||
svg={svg}
|
||||
testId="mermaid-diagram-dialog-viewport"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
|
||||
const reactId = useId()
|
||||
const renderId = useMemo(() => renderIdFromReactId(reactId), [reactId])
|
||||
@@ -92,13 +145,12 @@ export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
|
||||
|
||||
return (
|
||||
<figure className="mermaid-diagram" data-testid="mermaid-diagram">
|
||||
<div
|
||||
aria-label="Mermaid diagram"
|
||||
<MermaidLightbox svg={currentState.svg} />
|
||||
<MermaidSvgViewport
|
||||
ariaLabel="Mermaid diagram"
|
||||
className="mermaid-diagram__viewport"
|
||||
data-testid="mermaid-diagram-viewport"
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
dangerouslySetInnerHTML={{ __html: currentState.svg }}
|
||||
svg={currentState.svg}
|
||||
testId="mermaid-diagram-viewport"
|
||||
/>
|
||||
</figure>
|
||||
)
|
||||
|
||||
@@ -19,6 +19,55 @@ const SECOND_DIAGRAM = [
|
||||
' Alice->>Bob: Hello',
|
||||
'```',
|
||||
].join('\n')
|
||||
const SYSTEM_OVERVIEW_DIAGRAM = [
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
' subgraph TW["Tauri v2 Window"]',
|
||||
' subgraph FE["React Frontend"]',
|
||||
' App["App.tsx (orchestrator)"]',
|
||||
' WS["WelcomeScreen\\n(onboarding)"]',
|
||||
' SB["Sidebar\\n(navigation + filters + types)"]',
|
||||
' NL["NoteList / PulseView\\n(filtered list / activity)"]',
|
||||
' ED["Editor\\n(BlockNote + diff + raw)"]',
|
||||
' IN["Inspector\\n(metadata + relationships)"]',
|
||||
' AIP["AiPanel\\n(selected CLI agent + tools)"]',
|
||||
' SP["SearchPanel\\n(keyword search)"]',
|
||||
' ST["StatusBar\\n(vault picker + sync + version)"]',
|
||||
' CP["CommandPalette\\n(Cmd+K launcher)"]',
|
||||
'',
|
||||
' App --> WS & SB & NL & ED & SP & ST & CP',
|
||||
' ED --> IN & AIP',
|
||||
' end',
|
||||
'',
|
||||
' subgraph RB["Rust Backend"]',
|
||||
' LIB["lib.rs → Tauri commands"]',
|
||||
' VAULT["vault/"]',
|
||||
' FM["frontmatter/"]',
|
||||
' GIT["git/\\n(commit, sync, clone)"]',
|
||||
' SETTINGS["settings.rs"]',
|
||||
' SEARCH["search.rs"]',
|
||||
' CLI["ai_agents.rs\\n+ claude_cli.rs"]',
|
||||
' end',
|
||||
'',
|
||||
' subgraph EXT["External Services"]',
|
||||
' CCLI["Claude / Codex / OpenCode / Pi CLI\\n(agent subprocesses)"]',
|
||||
' MCP["MCP Server\\n(ws://9710, 9711)"]',
|
||||
' GCLI["git CLI\\n(system executable)"]',
|
||||
' REMOTE["Git remotes\\n(GitHub/GitLab/Gitea/etc.)"]',
|
||||
' end',
|
||||
'',
|
||||
' FE -->|"Tauri IPC"| RB',
|
||||
' CLI -->|"spawn subprocess"| CCLI',
|
||||
' LIB -->|"register / monitor"| MCP',
|
||||
' GIT -->|"clone / fetch / push / pull"| GCLI',
|
||||
' GCLI -->|"network auth via user config"| REMOTE',
|
||||
' end',
|
||||
'',
|
||||
' style FE fill:#e8f4fd,stroke:#2196f3,color:#000',
|
||||
' style RB fill:#fff8e1,stroke:#ff9800,color:#000',
|
||||
' style EXT fill:#f3e5f5,stroke:#9c27b0,color:#000',
|
||||
'```',
|
||||
].join('\n')
|
||||
const INVALID_DIAGRAM = [
|
||||
'```mermaid',
|
||||
'not a diagram',
|
||||
@@ -100,6 +149,19 @@ async function expectRenderedDiagramCount(page: Page, count: number): Promise<vo
|
||||
await expect(page.locator('[data-testid="mermaid-diagram-viewport"] svg')).toHaveCount(count, { timeout: 15_000 })
|
||||
}
|
||||
|
||||
async function countLargeBlackFilledShapes(page: Page, diagramIndex: number): Promise<number> {
|
||||
return page.locator('[data-testid="mermaid-diagram-viewport"] svg').nth(diagramIndex).evaluate((svg) => {
|
||||
const shapes = Array.from(svg.querySelectorAll<SVGGraphicsElement>('rect,path,polygon'))
|
||||
|
||||
return shapes.filter((shape) => {
|
||||
const box = shape.getBoundingClientRect()
|
||||
return getComputedStyle(shape).fill === 'rgb(0, 0, 0)'
|
||||
&& box.width > 12
|
||||
&& box.height > 8
|
||||
}).length
|
||||
})
|
||||
}
|
||||
|
||||
function readNoteBFile(): string {
|
||||
return fs.readFileSync(path.join(tempVaultDir, 'note', 'note-b.md'), 'utf8')
|
||||
}
|
||||
@@ -116,27 +178,36 @@ ${FIRST_DIAGRAM}
|
||||
${INVALID_DIAGRAM}
|
||||
|
||||
${SECOND_DIAGRAM}
|
||||
|
||||
${SYSTEM_OVERVIEW_DIAGRAM}
|
||||
`
|
||||
|
||||
await setRawEditorContent(page, nextContent)
|
||||
await expect.poll(readNoteBFile).toContain(FIRST_DIAGRAM)
|
||||
|
||||
await toggleRawMode(page, '.bn-editor')
|
||||
await expectRenderedDiagramCount(page, 2)
|
||||
await expectRenderedDiagramCount(page, 3)
|
||||
await expect.poll(() => countLargeBlackFilledShapes(page, 2)).toBe(0)
|
||||
await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toHaveCount(1)
|
||||
await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toContainText('not a diagram')
|
||||
|
||||
await page.locator('[data-testid="mermaid-diagram"]').nth(2).hover()
|
||||
await page.getByRole('button', { name: 'Open Mermaid diagram' }).nth(2).click()
|
||||
await expect(page.locator('[data-testid="mermaid-diagram-dialog-viewport"] svg')).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await toggleRawMode(page, '.cm-content')
|
||||
const rawAfterRichMode = await getRawEditorContent(page)
|
||||
expect(rawAfterRichMode).toContain(FIRST_DIAGRAM)
|
||||
expect(rawAfterRichMode).toContain(INVALID_DIAGRAM)
|
||||
expect(rawAfterRichMode).toContain(SECOND_DIAGRAM)
|
||||
expect(rawAfterRichMode).toContain(SYSTEM_OVERVIEW_DIAGRAM)
|
||||
|
||||
await setRawEditorContent(page, rawAfterRichMode.replace(FIRST_DIAGRAM, UPDATED_FIRST_DIAGRAM))
|
||||
await expect.poll(readNoteBFile).toContain(UPDATED_FIRST_DIAGRAM)
|
||||
|
||||
await toggleRawMode(page, '.bn-editor')
|
||||
await expectRenderedDiagramCount(page, 2)
|
||||
await expectRenderedDiagramCount(page, 3)
|
||||
await expect(page.locator('[data-testid="mermaid-diagram-viewport"]').first()).toContainText('Published')
|
||||
|
||||
await openNote(page, 'Note C')
|
||||
@@ -147,4 +218,5 @@ ${SECOND_DIAGRAM}
|
||||
expect(reopenedRaw).toContain(UPDATED_FIRST_DIAGRAM)
|
||||
expect(reopenedRaw).toContain(INVALID_DIAGRAM)
|
||||
expect(reopenedRaw).toContain(SECOND_DIAGRAM)
|
||||
expect(reopenedRaw).toContain(SYSTEM_OVERVIEW_DIAGRAM)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user