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('![Hello](test)') + 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( '![Hello](test)\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 ![Hello](test) 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( - '

  • Hello
', + '

paragraph1

  1. 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

  1. first

  2. 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( + '
  • Hello
', [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( + '
  • Hello
  • World
', + [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

headercell
contentcell
', + [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

headercell
contentcell
', + [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