Fix Mermaid diagram paste (#10714)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-04-05 14:37:31 +07:00
committed by GitHub
parent ea254970c0
commit ebbad2b557
3 changed files with 337 additions and 10 deletions
@@ -44,6 +44,7 @@ const mermaidMetaTxField = 'mermaid-meta-tx'
interface TxMetaContainer {
nodePatch?: NodePatchSpec
nodePatches?: NodePatchSpec[]
renderResult?: MermaidRenderResult
updateDecorations?: boolean
}
@@ -122,7 +123,7 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
addProseMirrorPlugins () {
const parent = (this.parent?.() ?? []).filter((p) => p.props.handlePaste === undefined)
return [...parent, MermaidDecorator(this.options)]
return [...parent, MermaidCodeBlockNormalizer(), MermaidDecorator(this.options)]
},
addNodeView () {
@@ -358,6 +359,75 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
}
})
/**
* Normalizes pasted/imported content so that Mermaid blocks render.
*
* There are multiple ways Mermaid content can enter the editor (markdown paste, html paste,
* programmatic inserts). Some of those paths produce a regular `codeBlock` with
* `attrs.language === 'mermaid'` instead of a dedicated `mermaid` node.
*
* Our rendering pipeline only targets `mermaid` nodes, so we convert such `codeBlock`s
* into `mermaid` nodes on any doc-changing transaction.
*/
function MermaidCodeBlockNormalizer (): Plugin {
return new Plugin({
key: new PluginKey('mermaid-codeblock-normalizer'),
appendTransaction (transactions, oldState, newState) {
if (!transactions.some((tr) => tr.docChanged)) return
const { schema } = newState
const mermaidType = schema.nodes[MermaidExtension.name]
const codeBlockType = schema.nodes.codeBlock
if (mermaidType == null || codeBlockType == null) return
const targets: Array<{ pos: number, node: ProseMirrorNode }> = []
newState.doc.descendants((node, pos) => {
if (node.type !== codeBlockType) return
if ((node.attrs as any)?.language !== 'mermaid') return
targets.push({ pos, node })
})
if (targets.length === 0) return
// Replace from end to start to keep positions stable.
const tr = newState.tr
const selectionPos = newState.selection.from
const nodePatches: NodePatchSpec[] = []
let shouldMoveSelection = false
let selectionTargetPos = 0
for (let i = targets.length - 1; i >= 0; i--) {
const { pos, node } = targets[i]
const attrs = { ...(node.attrs ?? {}), language: 'mermaid' }
tr.replaceRangeWith(pos, pos + node.nodeSize, mermaidType.create(attrs, node.content, node.marks))
// If the user selection is inside the normalized block, keep it editable (unfolded)
// and keep the cursor inside the new node.
if (selectionPos >= pos && selectionPos <= pos + node.nodeSize) {
nodePatches.push({ pos, folded: false, selected: false })
shouldMoveSelection = true
selectionTargetPos = pos
}
}
if (nodePatches.length > 0) {
setTxMeta(tr, { nodePatches })
}
if (shouldMoveSelection) {
// Place the cursor into the code content of the mermaid node.
// Node content starts at `pos + 1`.
const nextSelection =
TextSelection.findFrom(tr.doc.resolve(selectionTargetPos + 1), 1) ??
TextSelection.create(tr.doc, selectionTargetPos + 1)
tr.setSelection(nextSelection)
}
return tr
}
})
}
interface MermaidPluginState {
decorationSet: DecorationSet
decorationCache: Map<number | string, NodeDecorationState>
@@ -554,6 +624,11 @@ function buildState (
const lastDecorationSet = tr !== undefined ? prev.decorationSet.map(tr.mapping, tr.doc) : prev.decorationSet
const nodeStatePatch = getTxMeta(tr)?.nodePatch
const nodeStatePatches = getTxMeta(tr)?.nodePatches
const nodeStatePatchByPos =
nodeStatePatches !== undefined && nodeStatePatches.length > 0
? new Map<number, NodePatchSpec>(nodeStatePatches.map((p) => [p.pos, p]))
: undefined
let mIndex = 0
doc.descendants((node, pos, parent, index) => {
@@ -584,9 +659,12 @@ function buildState (
textContent: node.textContent
}
if (nodeStatePatch !== undefined && pos === nodeStatePatch.pos) {
newState.folded = nodeStatePatch.folded
newState.selected = nodeStatePatch.selected
const patch =
nodeStatePatchByPos?.get(pos) ??
(nodeStatePatch !== undefined && pos === nodeStatePatch.pos ? nodeStatePatch : undefined)
if (patch !== undefined) {
newState.folded = patch.folded
newState.selected = patch.selected
}
if (yid !== undefined) decorationCache.set(yid, newState)
@@ -0,0 +1,245 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Schema } from '@tiptap/pm/model'
import { EditorState, NodeSelection, TextSelection } from '@tiptap/pm/state'
import { PasteTextAsMarkdownPlugin } from '../smartPaste'
jest.mock('@hcengineering/text', () => ({
__esModule: true,
MarkupNodeType: {
doc: 'doc',
text: 'text',
paragraph: 'paragraph',
heading: 'heading',
code_block: 'codeBlock',
bullet_list: 'bulletList',
list_item: 'listItem',
table: 'table',
todoList: 'todoList',
ordered_list: 'orderedList',
reference: 'reference',
image: 'image',
mermaid: 'mermaid'
},
MarkupMarkType: {
bold: 'bold',
em: 'em',
code: 'code',
link: 'link'
}
}))
jest.mock('@hcengineering/text-markdown', () => ({
__esModule: true,
markdownToMarkup: (markdown: string) => ({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: markdown.trim().length > 0 ? markdown.trim() : 'title' }]
}
]
})
}))
jest.mock('../../codeSnippets/codeblock', () => ({
__esModule: true,
CodeBlockHighlighExtension: { name: 'codeBlock' }
}))
function makeSchema (): Schema {
return new Schema({
nodes: {
doc: { content: 'block+' },
text: { group: 'inline' },
heading: {
group: 'block',
content: 'inline*',
attrs: { level: { default: 1 } },
toDOM: (node) => ['h' + node.attrs.level, 0],
parseDOM: [
{ tag: 'h1', attrs: { level: 1 } },
{ tag: 'h2', attrs: { level: 2 } },
{ tag: 'h3', attrs: { level: 3 } }
]
},
paragraph: {
group: 'block',
content: 'inline*',
toDOM: () => ['p', 0],
parseDOM: [{ tag: 'p' }]
},
codeBlock: {
group: 'block',
content: 'text*',
marks: '',
attrs: { language: { default: null } },
toDOM: () => ['pre', ['code', 0]],
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }]
},
mermaid: {
group: 'block',
content: 'text*',
marks: '',
attrs: { language: { default: 'mermaid' } },
toDOM: () => ['div', { class: 'mermaid-diagram' }, ['code', 0]],
parseDOM: [{ tag: 'div.mermaid-diagram', preserveWhitespace: 'full' }]
}
},
marks: {}
})
}
function makeClipboardData (data: { plain?: string, markdown?: string, types?: string[] }): any {
const plain = data.plain ?? ''
const markdown = data.markdown ?? ''
const types = data.types ?? ['text/plain']
return {
types,
getData: (t: string) => {
if (t === 'text/plain') return plain
if (t === 'text/markdown') return markdown
return ''
}
} as any
}
describe('SmartPaste handlePaste ignore contexts', () => {
it('ignores smart paste when selection is inside a codeBlock', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [
schema.node('codeBlock', { language: 'mermaid' }, schema.text('graph TD\nA-->B'))
])
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, 2)
})
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch: jest.fn() },
{ clipboardData: makeClipboardData({ plain: '# title' }) },
null
)
expect(handled).toBe(false)
})
it('ignores smart paste when selection is inside a mermaid block', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('mermaid', undefined, schema.text('graph TD\nA-->B'))])
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, 2)
})
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch: jest.fn() },
{ clipboardData: makeClipboardData({ plain: '# title' }) },
null
)
expect(handled).toBe(false)
})
it('ignores smart paste when NodeSelection is a codeBlock', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('codeBlock', undefined, schema.text('x'))])
const state = EditorState.create({
schema,
doc,
selection: NodeSelection.create(doc, 0)
})
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch: jest.fn() },
{ clipboardData: makeClipboardData({ plain: '# title' }) },
null
)
expect(handled).toBe(false)
})
it('ignores smart paste when NodeSelection is a mermaid node', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('mermaid', undefined, schema.text('x'))])
const state = EditorState.create({
schema,
doc,
selection: NodeSelection.create(doc, 0)
})
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch: jest.fn() },
{ clipboardData: makeClipboardData({ plain: '# title' }) },
null
)
expect(handled).toBe(false)
})
})
describe('SmartPaste handlePaste transform scenarios', () => {
it('transforms plain text paste into markdown output in normal text selection', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('paragraph', undefined, schema.text('hello'))])
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, 2)
})
const dispatch = jest.fn()
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch },
{ clipboardData: makeClipboardData({ plain: '# Title', types: ['text/plain'] }) },
null
)
expect(handled).toBe(true)
expect(dispatch).toHaveBeenCalledTimes(1)
})
it('transforms when explicit markdown is present even with rich clipboard types', () => {
const schema = makeSchema()
const doc = schema.node('doc', undefined, [schema.node('paragraph', undefined, schema.text('hello'))])
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, 2)
})
const dispatch = jest.fn()
const plugin = PasteTextAsMarkdownPlugin()
const handled = (plugin.props as any).handlePaste(
{ state, dispatch },
{
clipboardData: makeClipboardData({
plain: 'fallback',
markdown: '## Heading',
types: ['text/html', 'text/markdown']
})
},
null
)
expect(handled).toBe(true)
expect(dispatch).toHaveBeenCalledTimes(1)
})
})
@@ -17,7 +17,7 @@ import { MarkupMarkType, MarkupNodeType, type MarkupNode } from '@hcengineering/
import { markdownToMarkup } from '@hcengineering/text-markdown'
import { Extension } from '@tiptap/core'
import { Node, type Schema } from '@tiptap/pm/model'
import { Plugin } from '@tiptap/pm/state'
import { NodeSelection, Plugin } from '@tiptap/pm/state'
import { CodeBlockHighlighExtension } from '../codeSnippets/codeblock'
import { hasTableMetadataMarker } from './tableMetadata'
@@ -29,7 +29,7 @@ export const SmartPasteExtension = Extension.create({
}
})
function PasteTextAsMarkdownPlugin (): Plugin {
export function PasteTextAsMarkdownPlugin (): Plugin {
return new Plugin({
props: {
handlePaste (view, event, slice) {
@@ -39,12 +39,16 @@ function PasteTextAsMarkdownPlugin (): Plugin {
const pastedText = clipboardData.getData('text/plain')
const pastedMarkdown = clipboardData.getData('text/markdown')
// check if we are in code block
const { $from } = view.state.selection
// Ignore smart paste inside code blocks / mermaid blocks (keep default paste behavior).
const selection = view.state.selection
const ignoredNodeTypes = new Set<string>([CodeBlockHighlighExtension.name, 'mermaid'])
if (selection instanceof NodeSelection && ignoredNodeTypes.has(selection.node.type.name)) {
return false
}
const { $from } = selection
for (let d = $from.depth; d > 0; d--) {
const node = $from.node(d)
if (node.type.name === CodeBlockHighlighExtension.name) {
// paste as plain text in code blocks
if (ignoredNodeTypes.has(node.type.name)) {
return false
}
}