Skip to content

Commit 07bb819

Browse files
feat: use plaintext for mermaid parsing when clipboard has html+text (tldraw#8344)
Fixes an issue where mermaid code from VS Code, textedit, and live mermaid editor (among others) wasn't rendering as shapes because the stripped HTML removed the linebreaks so it was parsing as invalid mermaid. This has been kept inside the mermaid handler to make sure it shouldn't impact any other copy/paste functionality, but still worth testing pasting of: 1) Pasting in formatted text (eg from google docs) 2) Copying non-mermaid code from VS Code 3) Copying in a webpage link and making sure that turns into an embed I've tested all of those cases locally and they work fine --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d5ec5a4 commit 07bb819

3 files changed

Lines changed: 73 additions & 7 deletions

File tree

apps/dotcom/client/src/components/SneakyMermaidHandler/SneakyMermaidHandler.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect } from 'react'
22
import { defaultHandleExternalTextContent, useEditor, useToasts } from 'tldraw'
33
import { defineMessages, useMsg } from '../../tla/utils/i18n'
4-
import { simpleMermaidStringTest } from './simpleMermaidStringTest'
4+
import { simpleMermaidStringTest, stripMarkdownMermaidFence } from './simpleMermaidStringTest'
55

66
const messages = defineMessages({
77
unsupportedTitle: { defaultMessage: 'Unsupported Mermaid diagram' },
@@ -16,10 +16,22 @@ export function SneakyMermaidHandler() {
1616

1717
useEffect(() => {
1818
editor.registerExternalContentHandler('text', async (content) => {
19-
if (!simpleMermaidStringTest(content.text)) {
19+
// when pasting html, the derived text is stripped and loses line breaks
20+
// which make evaluating mermaid diagrams impossible. We look into the
21+
// sources and see if there's some plain text alongside the html and if
22+
// there are, we can use this to evaluate mermaid diagrams.
23+
24+
const plainTextSource = content.sources?.find(
25+
(s) => s.type === 'text' && s.subtype === 'text'
26+
)
27+
const plainText = plainTextSource?.data ?? content.text
28+
const textToTest = simpleMermaidStringTest(plainText) ? plainText : content.text
29+
30+
if (!simpleMermaidStringTest(textToTest)) {
2031
await defaultHandleExternalTextContent(editor, content)
2132
return
2233
}
34+
const mermaidText = stripMarkdownMermaidFence(textToTest)
2335
const { createMermaidDiagram } = await import('@tldraw/mermaid')
2436
const shapesBefore = new Set(editor.getCurrentPageShapeIds())
2537

@@ -48,7 +60,7 @@ export function SneakyMermaidHandler() {
4860
})
4961
}
5062

51-
await createMermaidDiagram(editor, content.text, { onUnsupportedDiagram })
63+
await createMermaidDiagram(editor, mermaidText, { onUnsupportedDiagram })
5264
selectNewShapes()
5365
} catch (e) {
5466
console.error(e)

apps/dotcom/client/src/components/SneakyMermaidHandler/simpleMermaidStringTest.test.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { simpleMermaidStringTest } from './simpleMermaidStringTest'
1+
import { simpleMermaidStringTest, stripMarkdownMermaidFence } from './simpleMermaidStringTest'
22

33
describe('simpleMermaidStringTest', () => {
44
describe('bare keywords', () => {
@@ -75,6 +75,28 @@ describe('simpleMermaidStringTest', () => {
7575
})
7676
})
7777

78+
describe('markdown code fences', () => {
79+
it('detects mermaid inside ```mermaid fence', () => {
80+
const text = '```mermaid\nflowchart TD\n A --> B\n```'
81+
expect(simpleMermaidStringTest(text)).toBe(true)
82+
})
83+
84+
it('detects mermaid inside fence with extra backticks', () => {
85+
const text = '````mermaid\nsequenceDiagram\n Alice->>Bob: Hi\n````'
86+
expect(simpleMermaidStringTest(text)).toBe(true)
87+
})
88+
89+
it('detects mermaid inside fence with leading whitespace', () => {
90+
const text = ' ```mermaid\ngantt\n title Plan\n ```'
91+
expect(simpleMermaidStringTest(text)).toBe(true)
92+
})
93+
94+
it('strips fence and preserves inner boilerplate', () => {
95+
const text = '```mermaid\n%%{init: {"theme":"dark"}}%%\nflowchart LR\n A --> B\n```'
96+
expect(simpleMermaidStringTest(text)).toBe(true)
97+
})
98+
})
99+
78100
describe('negative cases', () => {
79101
it('rejects plain English text', () => {
80102
expect(simpleMermaidStringTest('Hello world')).toBe(false)
@@ -101,9 +123,31 @@ describe('simpleMermaidStringTest', () => {
101123
expect(simpleMermaidStringTest('<div class="flowchart">content</div>')).toBe(false)
102124
})
103125

104-
it('rejects a markdown code block wrapping mermaid', () => {
105-
const text = '```mermaid\nflowchart TD\n A --> B\n```'
126+
it('rejects a non-mermaid markdown code block', () => {
127+
const text = '```javascript\nconst x = 1\n```'
106128
expect(simpleMermaidStringTest(text)).toBe(false)
107129
})
108130
})
109131
})
132+
133+
describe('stripMarkdownMermaidFence', () => {
134+
it('extracts content from a mermaid fence', () => {
135+
const text = '```mermaid\nflowchart TD\n A --> B\n```'
136+
expect(stripMarkdownMermaidFence(text)).toBe('flowchart TD\n A --> B')
137+
})
138+
139+
it('returns non-fenced text unchanged', () => {
140+
const text = 'flowchart TD\n A --> B'
141+
expect(stripMarkdownMermaidFence(text)).toBe(text)
142+
})
143+
144+
it('returns non-mermaid fenced text unchanged', () => {
145+
const text = '```javascript\nconst x = 1\n```'
146+
expect(stripMarkdownMermaidFence(text)).toBe(text)
147+
})
148+
149+
it('handles extra backticks', () => {
150+
const text = '````mermaid\ngantt\n title Plan\n````'
151+
expect(stripMarkdownMermaidFence(text)).toBe('gantt\n title Plan')
152+
})
153+
})

apps/dotcom/client/src/components/SneakyMermaidHandler/simpleMermaidStringTest.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ const FRONTMATTER_REGEX = /^-{3}\s*[\n\r]([\s\S]*?)[\n\r]-{3}\s*[\n\r]+/
1515
const DIAGRAM_KEYWORD_REGEX =
1616
/^\s*(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|sankey|xychart|block|quadrantChart|requirement|C4Context|C4Container|C4Component|C4Dynamic|C4Deployment|packet|kanban|architecture|treemap|radar|info)/
1717

18+
/**
19+
* Captures the inner content of a mermaid code blog marked by ```mermaid
20+
*/
21+
const MARKDOWN_MERMAID_FENCE_REGEX = /^\s*```+\s*mermaid\s*\n([\s\S]*?)\n\s*```+\s*$/
22+
1823
/**
1924
* Strip mermaid boilerplate (frontmatter, directives, comments) so only the
2025
* diagram body remains. The two global regexes are created as fresh literals
@@ -27,6 +32,11 @@ function stripMermaidBoilerplate(text: string): string {
2732
.replace(/\s*%%.*\n/gm, '\n')
2833
}
2934

35+
export function stripMarkdownMermaidFence(text: string): string {
36+
const match = text.match(MARKDOWN_MERMAID_FENCE_REGEX)
37+
return match ? match[1] : text
38+
}
39+
3040
export function simpleMermaidStringTest(text: string): boolean {
31-
return DIAGRAM_KEYWORD_REGEX.test(stripMermaidBoilerplate(text))
41+
return DIAGRAM_KEYWORD_REGEX.test(stripMermaidBoilerplate(stripMarkdownMermaidFence(text)))
3242
}

0 commit comments

Comments
 (0)