From 6f295425287d798726f23ac18e43ec38ba20f892 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 17:12:29 +0200 Subject: [PATCH] fix: render mermaid diagrams from notes --- .../arrowLigaturesExtension.test.ts | 20 +++ src/components/arrowLigaturesExtension.ts | 134 ++++++++++++------ src/components/editorSchema.mermaid.test.ts | 25 ++++ src/components/editorSchema.tsx | 32 ++++- .../useCodeMirror.arrowLigatures.test.ts | 22 ++- src/hooks/useCodeMirror.ts | 45 ++++++ src/utils/mermaidMarkdown.test.ts | 51 +++++++ src/utils/mermaidMarkdown.ts | 70 ++++++++- tests/smoke/mermaid-diagram-notes.spec.ts | 111 ++++++++++++++- 9 files changed, 448 insertions(+), 62 deletions(-) create mode 100644 src/components/editorSchema.mermaid.test.ts diff --git a/src/components/arrowLigaturesExtension.test.ts b/src/components/arrowLigaturesExtension.test.ts index 76118f2b..3e971195 100644 --- a/src/components/arrowLigaturesExtension.test.ts +++ b/src/components/arrowLigaturesExtension.test.ts @@ -4,6 +4,7 @@ import { createArrowLigaturesExtension } from './arrowLigaturesExtension' function createFixture() { let beforeInputListener: ((event: InputEvent) => void) | null = null const transaction = { insertText: vi.fn(() => transaction) } + const paragraphNode = { type: { name: 'paragraph', spec: {} } } const view = { dispatch: vi.fn(), state: { @@ -13,6 +14,10 @@ function createFixture() { selection: { from: 2, to: 2, + $from: { + depth: 0, + node: vi.fn(() => paragraphNode), + }, }, tr: transaction, }, @@ -119,6 +124,21 @@ describe('createArrowLigaturesExtension', () => { expect(fixture.view.dispatch).not.toHaveBeenCalled() }) + it('does not replace arrows while typing inside code blocks', () => { + const fixture = createFixture() + fixture.mount() + fixture.view.state.doc.textBetween.mockReturnValue('-') + fixture.view.state.selection.$from.node.mockReturnValue({ + type: { name: 'codeBlock', spec: { code: true } }, + }) + + const event = fixture.fireInput() + + expect(event.preventDefault).not.toHaveBeenCalled() + expect(fixture.transaction.insertText).not.toHaveBeenCalled() + expect(fixture.view.dispatch).not.toHaveBeenCalled() + }) + it('ignores composing input so IME text is not rewritten', () => { const fixture = createFixture() fixture.mount() diff --git a/src/components/arrowLigaturesExtension.ts b/src/components/arrowLigaturesExtension.ts index 64ce7b7d..21b9ca75 100644 --- a/src/components/arrowLigaturesExtension.ts +++ b/src/components/arrowLigaturesExtension.ts @@ -3,62 +3,104 @@ import { resolveArrowLigatureInput } from '../utils/arrowLigatures' const PREFIX_CONTEXT_LENGTH = 2 +interface CodeContextSelection { + $from?: { + depth: number + node: (depth?: number) => { + type?: { + name?: string + spec?: { code?: boolean } + } + } + } +} + function isInsertedCharacter(event: InputEvent): event is InputEvent & { data: string } { return event.inputType === 'insertText' && typeof event.data === 'string' } +function isCodeContext(selection: CodeContextSelection): boolean { + const position = selection.$from + if (!position) return false + + for (let depth = position.depth; depth >= 0; depth--) { + const type = position.node(depth).type + if (type?.spec?.code || type?.name === 'codeBlock') return true + } + + return false +} + +function hasWritableCursor(selection: { from: number; to: number }): boolean { + return selection.from === selection.to +} + +function isComposingInput({ + event, + view, +}: { + event: InputEvent + view: { composing?: boolean } +}): boolean { + return event.isComposing || Boolean(view.composing) +} + export const createArrowLigaturesExtension = createExtension(({ editor }) => { let literalAsciiCursor: number | null = null + const handleBeforeInput = (event: InputEvent) => { + if (!isInsertedCharacter(event)) { + return + } + + const view = editor._tiptapEditor?.view ?? editor.prosemirrorView + if (!view) { + return + } + if (isComposingInput({ event, view })) { + return + } + + const { from } = view.state.selection + if (!hasWritableCursor(view.state.selection)) { + return + } + if (isCodeContext(view.state.selection)) { + literalAsciiCursor = null + return + } + + const beforeText = view.state.doc.textBetween( + Math.max(0, from - PREFIX_CONTEXT_LENGTH), + from, + '', + '', + ) + const resolution = resolveArrowLigatureInput({ + beforeText, + cursor: from, + inputText: event.data, + literalAsciiCursor, + }) + literalAsciiCursor = resolution.nextLiteralAsciiCursor + + if (!resolution.change) { + return + } + + event.preventDefault() + view.dispatch( + view.state.tr.insertText( + resolution.change.insert, + resolution.change.from, + resolution.change.to, + ), + ) + } + return { key: 'arrowLigatures', mount: ({ dom, signal }) => { - const handleBeforeInput = (event: InputEvent) => { - if (!isInsertedCharacter(event)) { - return - } - - const view = editor._tiptapEditor?.view ?? editor.prosemirrorView - if (!view) { - return - } - if (event.isComposing || view.composing) { - return - } - - const { from, to } = view.state.selection - if (from !== to) { - return - } - - const beforeText = view.state.doc.textBetween( - Math.max(0, from - PREFIX_CONTEXT_LENGTH), - from, - '', - '', - ) - const resolution = resolveArrowLigatureInput({ - beforeText, - cursor: from, - inputText: event.data, - literalAsciiCursor, - }) - literalAsciiCursor = resolution.nextLiteralAsciiCursor - - if (!resolution.change) { - return - } - - event.preventDefault() - view.dispatch( - view.state.tr.insertText( - resolution.change.insert, - resolution.change.from, - resolution.change.to, - ), - ) - } - dom.addEventListener('beforeinput', handleBeforeInput as EventListener, { capture: true, signal, diff --git a/src/components/editorSchema.mermaid.test.ts b/src/components/editorSchema.mermaid.test.ts new file mode 100644 index 00000000..cc039358 --- /dev/null +++ b/src/components/editorSchema.mermaid.test.ts @@ -0,0 +1,25 @@ +import { BlockNoteEditor } from '@blocknote/core' +import { describe, expect, it } from 'vitest' +import { MERMAID_BLOCK_TYPE } from '../utils/mermaidMarkdown' +import { schema } from './editorSchema' + +describe('editor schema Mermaid parsing', () => { + it('parses fenced Mermaid Markdown as a rendered Mermaid block', async () => { + const editor = BlockNoteEditor.create({ schema }) + + const blocks = await editor.tryParseMarkdownToBlocks([ + '```mermaid', + 'graph TD', + 'A --> B', + '```', + ].join('\n')) + + expect(blocks[0]).toMatchObject({ + type: MERMAID_BLOCK_TYPE, + props: { + diagram: 'graph TD\nA --> B\n', + source: '```mermaid\ngraph TD\nA --> B\n```', + }, + }) + }) +}) diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index d0131fb6..1b357f65 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -5,7 +5,7 @@ import { createReactBlockSpec, createReactInlineContentSpec } from '@blocknote/r import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors' import { resolveEntry } from '../utils/wikilink' import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/mathMarkdown' -import { MERMAID_BLOCK_TYPE } from '../utils/mermaidMarkdown' +import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown' import type { VaultEntry } from '../types' import { NoteTitleIcon } from './NoteTitleIcon' import { MermaidDiagram } from './MermaidDiagram' @@ -106,6 +106,32 @@ const MathBlock = createReactBlockSpec( }, ) +function readCodeElementLanguage(code: Element): string | null { + const language = code.getAttribute('data-language') + ?? Array.from(code.classList) + .find(className => className.startsWith('language-')) + ?.replace(/^language-/u, '') + if (!language) return null + + return language.trim().split(/\s+/u)[0]?.toLowerCase() ?? null +} + +function readMermaidPreElement(element: HTMLElement): { source: string; diagram: string } | undefined { + if (element.tagName !== 'PRE') return undefined + if (element.childElementCount !== 1 || element.firstElementChild?.tagName !== 'CODE') return undefined + + const code = element.firstElementChild + if (readCodeElementLanguage(code) !== 'mermaid') return undefined + + const diagram = code.textContent?.endsWith('\n') + ? code.textContent + : `${code.textContent ?? ''}\n` + return { + diagram, + source: mermaidFenceSource({ diagram }), + } +} + const MermaidBlock = createReactBlockSpec( { type: MERMAID_BLOCK_TYPE, @@ -116,6 +142,8 @@ const MermaidBlock = createReactBlockSpec( content: 'none', }, { + runsBefore: ['codeBlock'], + parse: readMermaidPreElement, render: (props) => ( - useCodeMirror(ref, '', noopCallbacks), + useCodeMirror(ref, content, noopCallbacks), ) return result.current.current! } @@ -90,4 +90,22 @@ describe('useCodeMirror arrow ligatures', () => { }) expect(view.state.doc.toString()).toBe('→') }) + + it('keeps Mermaid arrows literal while typing inside fenced code', () => { + const content = [ + '```mermaid', + 'flowchart TD', + 'A --', + '```', + ].join('\n') + const cursor = content.indexOf('A --') + 'A --'.length + const view = createView(container, content) + + act(() => { + view.dispatch({ selection: { anchor: cursor } }) + }) + typeSequence(view, ['>']) + + expect(view.state.doc.toString()).toContain('A -->') + }) }) diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index 99c79af0..273ececb 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -18,6 +18,11 @@ const RAW_EDITOR_COLORS = { gutterBorder: 'var(--border-subtle)', gutterText: 'var(--text-muted)', } +interface MarkdownFence { + character: '`' | '~' + length: number +} + export interface CodeMirrorCallbacks { onDocChange: (doc: string) => void onCursorActivity: (view: EditorView) => void @@ -25,6 +30,41 @@ export interface CodeMirrorCallbacks { onEscape: () => boolean } +function readMarkdownFence(line: string): MarkdownFence | null { + const match = /^( {0,3})(`{3,}|~{3,})/.exec(line) + if (!match) return null + + const fence = match[2] + return { + character: fence[0] as MarkdownFence['character'], + length: fence.length, + } +} + +function isClosingMarkdownFence(line: string, opening: MarkdownFence): boolean { + const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line) + if (!match) return false + + const fence = match[2] + return fence[0] === opening.character && fence.length >= opening.length +} + +function isInsideMarkdownFence(markdownBeforeCursor: string): boolean { + const lines = markdownBeforeCursor.split(/\r?\n/) + let opening: MarkdownFence | null = null + + for (const line of lines) { + if (opening) { + if (isClosingMarkdownFence(line, opening)) opening = null + continue + } + + opening = readMarkdownFence(line) + } + + return opening !== null +} + function buildBaseTheme() { return EditorView.theme({ '&': { @@ -83,6 +123,11 @@ function buildArrowLigaturesExtension() { let literalAsciiCursor: number | null = null return EditorView.inputHandler.of((view, from, _to, text) => { + if (isInsideMarkdownFence(view.state.doc.sliceString(0, from))) { + literalAsciiCursor = null + return false + } + const beforeText = view.state.doc.sliceString(Math.max(0, from - 2), from) const resolution = resolveArrowLigatureInput({ beforeText, diff --git a/src/utils/mermaidMarkdown.test.ts b/src/utils/mermaidMarkdown.test.ts index 81b88d08..a90a2ef0 100644 --- a/src/utils/mermaidMarkdown.test.ts +++ b/src/utils/mermaidMarkdown.test.ts @@ -56,6 +56,57 @@ describe('mermaid markdown round-trip', () => { ].join('\n\n')) }) + it('injects parsed Mermaid code blocks into dedicated diagram blocks', () => { + const [block] = injectMermaidInBlocks([{ + type: 'codeBlock', + props: { language: 'mermaid' }, + content: [{ type: 'text', text: 'flowchart LR\n A --> B', styles: {} }], + children: [], + }]) as Array<{ + type: string + props: { source: string; diagram: string } + }> + + expect(block.type).toBe(MERMAID_BLOCK_TYPE) + expect(block.props.source).toBe('```mermaid\nflowchart LR\n A --> B\n```') + expect(block.props.diagram).toBe('flowchart LR\n A --> B\n') + }) + + it('injects Mermaid-looking text code blocks when the parser drops the language', () => { + const [block] = injectMermaidInBlocks([{ + type: 'codeBlock', + props: { language: 'text' }, + content: [{ + type: 'text', + text: [ + "%%{init: {'theme':'base'}}%%", + 'flowchart TD', + ' A --> B', + ].join('\n'), + styles: {}, + }], + children: [], + }]) as Array<{ + type: string + props: { source: string; diagram: string } + }> + + expect(block.type).toBe(MERMAID_BLOCK_TYPE) + expect(block.props.source).toContain('```mermaid\n') + expect(block.props.diagram).toContain('flowchart TD\n A --> B\n') + }) + + it('keeps ordinary text code blocks unchanged', () => { + const [block] = injectMermaidInBlocks([{ + type: 'codeBlock', + props: { language: 'text' }, + content: [{ type: 'text', text: 'const chart = "flowchart TD"', styles: {} }], + children: [], + }]) as Array<{ type: string }> + + expect(block.type).toBe('codeBlock') + }) + it('leaves non-Mermaid and unclosed fences as normal Markdown', () => { const markdown = [ '```ts', diff --git a/src/utils/mermaidMarkdown.ts b/src/utils/mermaidMarkdown.ts index 4e13d810..3c904cff 100644 --- a/src/utils/mermaidMarkdown.ts +++ b/src/utils/mermaidMarkdown.ts @@ -63,6 +63,10 @@ interface DiagramSource { diagram: string } +interface CodeBlockSource { + block: BlockLike +} + function lineEnding({ line }: MarkdownLine): string { if (line.endsWith('\r\n')) return '\r\n' return line.endsWith('\n') ? '\n' : '' @@ -185,10 +189,69 @@ function buildMermaidBlock({ block, payload }: { block: BlockLike; payload: Merm } } +export function mermaidFenceSource({ diagram }: DiagramSource): string { + const body = diagram.endsWith('\n') ? diagram : `${diagram}\n` + return `\`\`\`mermaid\n${body}\`\`\`` +} + +function readCodeBlockLanguage({ block }: CodeBlockSource): string | null { + const language = block.props?.language + if (typeof language !== 'string') return null + + return language.trim().split(/\s+/)[0]?.toLowerCase() ?? null +} + +function readInlineText(content: InlineItem[] | undefined): string | null { + if (!Array.isArray(content)) return null + return content.map((item) => ( + item.type === 'text' && typeof item.text === 'string' ? item.text : '' + )).join('') +} + +function looksLikeMermaidDiagram(diagram: string): boolean { + const firstStatement = diagram + .split(/\r?\n/) + .map(line => line.trim()) + .find(line => line.length > 0 && !line.startsWith('%%')) + + return typeof firstStatement === 'string' + && /^(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie|mindmap|timeline|quadrantChart|requirementDiagram|gitGraph|C4Context|C4Container|C4Component|C4Dynamic|sankey-beta|xychart-beta)\b/.test(firstStatement) +} + +function shouldInjectCodeBlockAsMermaid({ + diagram, + language, +}: { + diagram: string + language: string | null +}): boolean { + if (language === 'mermaid') return true + if (language !== null && language !== 'text' && language !== 'plain' && language !== 'plaintext') return false + + return looksLikeMermaidDiagram(diagram) +} + +function readMermaidCodeBlock({ block }: CodeBlockSource): MermaidPayload | null { + if (block.type !== 'codeBlock') return null + + const diagram = readInlineText(block.content) + if (diagram === null) return null + if (!shouldInjectCodeBlockAsMermaid({ diagram, language: readCodeBlockLanguage({ block }) })) return null + + const normalizedDiagram = diagram.endsWith('\n') ? diagram : `${diagram}\n` + return { + diagram: normalizedDiagram, + source: mermaidFenceSource({ diagram: normalizedDiagram }), + } +} + function injectMermaidInBlock(block: BlockLike): BlockLike { const payload = readMermaidPayload(block.content) if (payload) return buildMermaidBlock({ block, payload }) + const codeBlockPayload = readMermaidCodeBlock({ block }) + if (codeBlockPayload) return buildMermaidBlock({ block, payload: codeBlockPayload }) + const children = Array.isArray(block.children) ? block.children.map(injectMermaidInBlock) : block.children return { ...block, children } } @@ -199,16 +262,11 @@ function isMermaidBlock(block: BlockLike): boolean { && typeof block.props?.diagram === 'string' } -function fallbackMermaidSource({ diagram }: DiagramSource): string { - const body = diagram.endsWith('\n') ? diagram : `${diagram}\n` - return `\`\`\`mermaid\n${body}\`\`\`` -} - function mermaidMarkdown(block: BlockLike): string { const source = block.props?.source if (source) return source - return fallbackMermaidSource({ diagram: block.props?.diagram ?? '' }) + return mermaidFenceSource({ diagram: block.props?.diagram ?? '' }) } export function injectMermaidInBlocks(blocks: unknown[]): unknown[] { diff --git a/tests/smoke/mermaid-diagram-notes.spec.ts b/tests/smoke/mermaid-diagram-notes.spec.ts index c254cf6f..3a42eab4 100644 --- a/tests/smoke/mermaid-diagram-notes.spec.ts +++ b/tests/smoke/mermaid-diagram-notes.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' import fs from 'fs' import path from 'path' import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' @@ -19,6 +19,21 @@ const SECOND_DIAGRAM = [ ' Alice->>Bob: Hello', '```', ].join('\n') +const REPORTED_THEME_DIAGRAM = [ + '```mermaid', + "%%{init: {'theme':'base','themeVariables':{'primaryColor':'#dbe7ff','primaryTextColor':'#0b1220','primaryBorderColor':'#1f3a8a','lineColor':'#1f3a8a','secondaryColor':'#fff4d6','tertiaryColor':'#ffe0e0','fontSize':'14px'}}}%%", + 'flowchart TD', + ' A(["Employee clocks in"]) --> B{"Linked to a planned shift?"}', + '', + ' B -- "Yes" --> C["Other path
for scheduled shift"]', + ' C --> D["Contract path
for scheduled shift"]', + '', + ' B -- "No (unscheduled)" --> E["other path
for unscheduled clocking"]', + '', + ' classDef path fill:#fff4d6,stroke:#7a5b00,stroke-width:2px,color:#3a2c00;', + ' class C,D,E,F path;', + '```', +].join('\n') const SYSTEM_OVERVIEW_DIAGRAM = [ '```mermaid', 'flowchart TD', @@ -77,6 +92,15 @@ const INVALID_DIAGRAM = [ test.beforeEach(async ({ page }, testInfo) => { testInfo.setTimeout(90_000) tempVaultDir = createFixtureVaultCopy() + fs.writeFileSync( + path.join(tempVaultDir, 'note', 'mermaid-reported.md'), + [ + '# Mermaid Reported', + '', + REPORTED_THEME_DIAGRAM, + '', + ].join('\n'), + ) await openFixtureVault(page, tempVaultDir) }) @@ -162,10 +186,72 @@ async function countLargeBlackFilledShapes(page: Page, diagramIndex: number): Pr }) } +function mermaidSvg(page: Page, diagramIndex: number): Locator { + return page.locator('[data-testid="mermaid-diagram-viewport"] svg').nth(diagramIndex) +} + +function mermaidNode(page: Page, diagramIndex: number, text: string): Locator { + return mermaidSvg(page, diagramIndex).locator('.node').filter({ hasText: text }).first() +} + +async function computedCss(locator: Locator, property: 'color' | 'fill' | 'stroke'): Promise { + return locator.evaluate((element, styleProperty) => ( + getComputedStyle(element).getPropertyValue(styleProperty) + ), property) +} + +async function labelFitsNode(node: Locator): Promise { + return node.evaluate((element) => { + const shape = element.querySelector('rect, polygon, circle, ellipse, path')! + const label = element.querySelector('.nodeLabel')! + const shapeBox = shape.getBoundingClientRect() + const labelBox = label.getBoundingClientRect() + return [ + labelBox.width <= shapeBox.width + 2, + labelBox.height <= shapeBox.height + 2, + ].every(Boolean) + }) +} + +async function readReportedDiagramMetrics(page: Page, diagramIndex: number) { + const svg = mermaidSvg(page, diagramIndex) + const scheduledNode = mermaidNode(page, diagramIndex, 'Other path') + const scheduledShape = scheduledNode.locator('rect, polygon, circle, ellipse, path').first() + const scheduledLabel = scheduledNode.locator('.nodeLabel').first() + + return { + connectorStroke: await computedCss(svg.locator('.flowchart-link').first(), 'stroke'), + markerFill: await computedCss(svg.locator('marker path').first(), 'fill'), + scheduledFill: await computedCss(scheduledShape, 'fill'), + scheduledStroke: await computedCss(scheduledShape, 'stroke'), + scheduledTextColor: await computedCss(scheduledLabel, 'color'), + labelsFit: [ + await labelFitsNode(scheduledNode), + await labelFitsNode(mermaidNode(page, diagramIndex, 'Contract path')), + await labelFitsNode(mermaidNode(page, diagramIndex, 'unscheduled clocking')), + ].every(Boolean), + text: await svg.evaluate((element) => element.textContent ?? ''), + } +} + function readNoteBFile(): string { return fs.readFileSync(path.join(tempVaultDir, 'note', 'note-b.md'), 'utf8') } +test('Mermaid diagrams render when opening saved notes directly', async ({ page }) => { + await openNote(page, 'Mermaid Reported') + await expectRenderedDiagramCount(page, 1) + await expect.poll(() => readReportedDiagramMetrics(page, 0)).toMatchObject({ + connectorStroke: 'rgb(31, 58, 138)', + markerFill: 'rgb(31, 58, 138)', + scheduledFill: 'rgb(255, 244, 214)', + scheduledStroke: 'rgb(122, 91, 0)', + scheduledTextColor: 'rgb(58, 44, 0)', + labelsFit: true, + text: expect.stringContaining('Linked to a planned shift?'), + }) +}) + test('Mermaid diagrams render, fall back, and round-trip through raw mode', async ({ page }) => { await openNote(page, 'Note B') await toggleRawMode(page, '.cm-content') @@ -175,6 +261,8 @@ test('Mermaid diagrams render, fall back, and round-trip through raw mode', asyn ${FIRST_DIAGRAM} +${REPORTED_THEME_DIAGRAM} + ${INVALID_DIAGRAM} ${SECOND_DIAGRAM} @@ -186,19 +274,29 @@ ${SYSTEM_OVERVIEW_DIAGRAM} await expect.poll(readNoteBFile).toContain(FIRST_DIAGRAM) await toggleRawMode(page, '.bn-editor') - await expectRenderedDiagramCount(page, 3) - await expect.poll(() => countLargeBlackFilledShapes(page, 2)).toBe(0) + await expectRenderedDiagramCount(page, 4) + await expect.poll(() => countLargeBlackFilledShapes(page, 3)).toBe(0) + await expect.poll(() => readReportedDiagramMetrics(page, 1)).toMatchObject({ + connectorStroke: 'rgb(31, 58, 138)', + markerFill: 'rgb(31, 58, 138)', + scheduledFill: 'rgb(255, 244, 214)', + scheduledStroke: 'rgb(122, 91, 0)', + scheduledTextColor: 'rgb(58, 44, 0)', + labelsFit: true, + text: expect.stringContaining('Linked to a planned shift?'), + }) 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 page.locator('[data-testid="mermaid-diagram"]').nth(3).hover() + await page.getByRole('button', { name: 'Open Mermaid diagram' }).nth(3).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(REPORTED_THEME_DIAGRAM) expect(rawAfterRichMode).toContain(INVALID_DIAGRAM) expect(rawAfterRichMode).toContain(SECOND_DIAGRAM) expect(rawAfterRichMode).toContain(SYSTEM_OVERVIEW_DIAGRAM) @@ -207,7 +305,7 @@ ${SYSTEM_OVERVIEW_DIAGRAM} await expect.poll(readNoteBFile).toContain(UPDATED_FIRST_DIAGRAM) await toggleRawMode(page, '.bn-editor') - await expectRenderedDiagramCount(page, 3) + await expectRenderedDiagramCount(page, 4) await expect(page.locator('[data-testid="mermaid-diagram-viewport"]').first()).toContainText('Published') await openNote(page, 'Note C') @@ -216,6 +314,7 @@ ${SYSTEM_OVERVIEW_DIAGRAM} const reopenedRaw = await getRawEditorContent(page) expect(reopenedRaw).toContain(UPDATED_FIRST_DIAGRAM) + expect(reopenedRaw).toContain(REPORTED_THEME_DIAGRAM) expect(reopenedRaw).toContain(INVALID_DIAGRAM) expect(reopenedRaw).toContain(SECOND_DIAGRAM) expect(reopenedRaw).toContain(SYSTEM_OVERVIEW_DIAGRAM)