From 7333584eb2df0737dabb975766810a356fa703c4 Mon Sep 17 00:00:00 2001
From: Jonas
Date: Mon, 27 Oct 2025 14:36:11 +0100
Subject: [PATCH] fix(Markdown): copy full block node if it has more than one
child
* Single list item is selected: copy only it's content
* Multiple list items are selected: copy list with markdown formatting
* Single table cell is selected: copy only it's conten
* Full table is selected: copy table with markdown formatting
Fixes: #7826
Signed-off-by: Jonas
---
src/extensions/Markdown.js | 9 ++-
src/tests/extensions/Markdown.spec.js | 103 ++++++++++++++++++++++----
2 files changed, 97 insertions(+), 15 deletions(-)
diff --git a/src/extensions/Markdown.js b/src/extensions/Markdown.js
index 17e241a1dc3..6dc07e25f0a 100644
--- a/src/extensions/Markdown.js
+++ b/src/extensions/Markdown.js
@@ -94,13 +94,20 @@ const Markdown = Extension.create({
},
clipboardTextSerializer: (slice) => {
const traverseNodes = (slice) => {
- if (slice.content.childCount > 1) {
+ if (
+ slice.content.childCount > 1
+ || slice.content.firstChild?.childCount > 1
+ ) {
+ // Selected several nodes or several children of one block node
return clipboardSerializer(
this.editor.schema,
).serialize(slice.content)
} else if (slice.isLeaf) {
return slice.textContent
} else {
+ // Only one block node selected, copy it's child content
+ // Required to not copy wrapping block node when selecting e.g. one table
+ // cell, one list item or the content of block quotes/callouts.
return traverseNodes(slice.content.firstChild)
}
}
diff --git a/src/tests/extensions/Markdown.spec.js b/src/tests/extensions/Markdown.spec.js
index 3be467bc373..98dbc845f28 100644
--- a/src/tests/extensions/Markdown.spec.js
+++ b/src/tests/extensions/Markdown.spec.js
@@ -7,14 +7,17 @@ import { getExtensionField } from '@tiptap/core'
import { Blockquote } from '@tiptap/extension-blockquote'
import { CodeBlock } from '@tiptap/extension-code-block'
import TiptapImage from '@tiptap/extension-image'
+import { ListItem } from '@tiptap/extension-list-item'
+import { Markdown } from '../../extensions/index.js'
+import { createMarkdownSerializer } from '../../extensions/Markdown.js'
+import { Italic, Link, Strong, Underline } from '../../marks/index.js'
+import Image from '../../nodes/Image.js'
+import OrderedList from '../../nodes/OrderedList.js'
+import Table from '../../nodes/Table.js'
+import TaskItem from '../../nodes/TaskItem.js'
+import TaskList from '../../nodes/TaskList.js'
import createCustomEditor from '../testHelpers/createCustomEditor.ts'
-import { Markdown } from './../../extensions/index.js'
-import { createMarkdownSerializer } from './../../extensions/Markdown.js'
-import { Italic, Link, Strong, Underline } from './../../marks/index.js'
-import Image from './../../nodes/Image.js'
import ImageInline from './../../nodes/ImageInline.js'
-import TaskItem from './../../nodes/TaskItem.js'
-import TaskList from './../../nodes/TaskList.js'
describe('Markdown extension unit', () => {
it('has a config', () => {
@@ -33,6 +36,7 @@ describe('Markdown extension unit', () => {
expect(underline).toEqual(Underline.config.toMarkdown)
const listItem = serializer.serializer.nodes.listItem
expect(typeof listItem).toBe('function')
+ editor.destroy()
})
})
@@ -44,6 +48,7 @@ describe('Markdown extension integrated in the editor', () => {
])
const serializer = createMarkdownSerializer(editor.schema)
expect(serializer.serialize(editor.state.doc)).toBe('__Test__')
+ editor.destroy()
})
it('serializes nodes according to their spec', () => {
@@ -53,6 +58,7 @@ describe('Markdown extension integrated in the editor', () => {
)
const serializer = createMarkdownSerializer(editor.schema)
expect(serializer.serialize(editor.state.doc)).toBe('\n- [ ] Hello')
+ editor.destroy()
})
it('serializes images with the default prosemirror way', () => {
@@ -62,6 +68,7 @@ describe('Markdown extension integrated in the editor', () => {
])
const serializer = createMarkdownSerializer(editor.schema)
expect(serializer.serialize(editor.state.doc)).toBe('')
+ editor.destroy()
})
it('serializes block images with the default prosemirror way', () => {
@@ -73,6 +80,7 @@ describe('Markdown extension integrated in the editor', () => {
expect(serializer.serialize(editor.state.doc)).toBe(
'\n\nhello',
)
+ editor.destroy()
})
it('serializes inline images with the default prosemirror way', () => {
@@ -84,33 +92,67 @@ describe('Markdown extension integrated in the editor', () => {
expect(serializer.serialize(editor.state.doc)).toBe(
'inline image  inside text',
)
+ editor.destroy()
})
- it('copies task lists to plaintext like markdown', () => {
+ it('copies markdown syntax for task list if selected together with a paragraph', () => {
const editor = createCustomEditor(
'
',
[Markdown, TaskList, TaskItem],
)
const text = copyEditorContent(editor)
expect(text).toBe('\n- [ ] Hello')
+ editor.destroy()
})
- it('copies code block content to plaintext according to their spec', () => {
+ it('copies just the content of a block node', () => {
const editor = createCustomEditor('Hello
', [
Markdown,
CodeBlock,
])
const text = copyEditorContent(editor)
expect(text).toBe('Hello')
+ editor.destroy()
})
- it('copies nested task list nodes to markdown like syntax', () => {
+ it('copies just the content of a single list item', () => {
const editor = createCustomEditor(
- '
',
+ 'paragraph1
first
paragraph2
',
+ [Markdown, ListItem, OrderedList],
+ )
+ const text = copyEditorContent(editor, editor.schema.nodes.orderedList)
+ expect(text).toBe('first')
+ editor.destroy()
+ })
+
+ it('copies markdown syntax for multiple list items', () => {
+ const editor = createCustomEditor(
+ 'paragraph1
first
second
paragraph2
',
+ [Markdown, ListItem, OrderedList],
+ )
+ const text = copyEditorContent(editor, editor.schema.nodes.orderedList)
+ expect(text).toBe('1. first\n2. second')
+ editor.destroy()
+ })
+
+ it('copies just the content of a single nested task list item', () => {
+ const editor = createCustomEditor(
+ '',
[Markdown, Blockquote, TaskList, TaskItem],
)
const text = copyEditorContent(editor)
- expect(text).toBe('\n- [ ] Hello')
+ expect(text).toBe('Hello')
+ editor.destroy()
+ })
+
+ it('copies markdown syntax for multiple nested task list items', () => {
+ const editor = createCustomEditor(
+ '',
+ [Markdown, Blockquote, TaskList, TaskItem],
+ )
+ const text = copyEditorContent(editor)
+ expect(text).toBe('- [ ] Hello\n- [ ] World')
+ editor.destroy()
})
it('copies address from blockquote to markdown', () => {
@@ -120,12 +162,34 @@ describe('Markdown extension integrated in the editor', () => {
)
const text = copyEditorContent(editor)
expect(text).toBe('Hermannsreute 44A')
+ editor.destroy()
})
- it('copy version number without escape character', () => {
+ it('copies version number without escape character', () => {
const editor = createCustomEditor('Hello
28.0.4
', [Markdown])
const text = copyEditorContent(editor)
expect(text).toBe('Hello\n\n28.0.4')
+ editor.destroy()
+ })
+
+ it('copies just content for table cell', () => {
+ const editor = createCustomEditor(
+ 'paragraph
',
+ [Markdown, Table],
+ )
+ const text = copyEditorContent(editor, editor.schema.nodes.tableCell)
+ expect(text).toBe('contentcell')
+ editor.destroy()
+ })
+
+ it('copies markdown syntax for full table', () => {
+ const editor = createCustomEditor(
+ 'paragraph
',
+ [Markdown, Table],
+ )
+ const text = copyEditorContent(editor, editor.schema.nodes.table)
+ expect(text).toBe('| headercell |\n|-------------|\n| contentcell |\n')
+ editor.destroy()
})
it('strips bold, italic, and other marks from paragraph', () => {
@@ -135,6 +199,7 @@ describe('Markdown extension integrated in the editor', () => {
)
const text = copyEditorContent(editor)
expect(text).toBe('Hello\n\nlonely world')
+ editor.destroy()
})
it('strips href and link formatting from email address', () => {
@@ -144,11 +209,21 @@ describe('Markdown extension integrated in the editor', () => {
)
const text = copyEditorContent(editor)
expect(text).toBe('Hello\n\nexample@example.com')
+ editor.destroy()
})
})
-const copyEditorContent = (editor) => {
- editor.commands.selectAll()
+const copyEditorContent = (editor, nodeType = null) => {
+ if (nodeType) {
+ editor.state.doc.descendants((node, pos) => {
+ if (node.type === nodeType) {
+ editor.commands.setNodeSelection(pos)
+ }
+ })
+ } else {
+ editor.commands.selectAll()
+ }
+
const slice = editor.state.selection.content()
const { text } = editor.view.serializeForClipboard(slice)
return text