From ced89a42bd2f0eefde7112a0999c6030ee6af866 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 28 Apr 2026 10:30:59 +0200 Subject: [PATCH] fix: render mermaid diagrams with lightbox --- src/components/EditorTheme.css | 51 +++++++++++++++ src/components/MermaidDiagram.test.tsx | 19 +++++- src/components/MermaidDiagram.tsx | 80 +++++++++++++++++++---- tests/smoke/mermaid-diagram-notes.spec.ts | 76 ++++++++++++++++++++- 4 files changed, 209 insertions(+), 17 deletions(-) diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index 6cc18033..19e6418b 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -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); diff --git a/src/components/MermaidDiagram.test.tsx b/src/components/MermaidDiagram.test.tsx index 7cb5b7b1..cb861de0 100644 --- a/src/components/MermaidDiagram.test.tsx +++ b/src/components/MermaidDiagram.test.tsx @@ -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( + 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 () => { diff --git a/src/components/MermaidDiagram.tsx b/src/components/MermaidDiagram.tsx index e2cd0804..6ca39809 100644 --- a/src/components/MermaidDiagram.tsx +++ b/src/components/MermaidDiagram.tsx @@ -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 ( +
+ ) +} + +function MermaidLightbox({ svg }: { svg: string }) { + return ( + + + + + + Mermaid diagram + + Expanded view of the rendered Mermaid diagram. + + + + + ) +} + 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 (
-
+
) diff --git a/tests/smoke/mermaid-diagram-notes.spec.ts b/tests/smoke/mermaid-diagram-notes.spec.ts index 97068278..c254cf6f 100644 --- a/tests/smoke/mermaid-diagram-notes.spec.ts +++ b/tests/smoke/mermaid-diagram-notes.spec.ts @@ -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 { + return page.locator('[data-testid="mermaid-diagram-viewport"] svg').nth(diagramIndex).evaluate((svg) => { + const shapes = Array.from(svg.querySelectorAll('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) })