diff --git a/src/plugins/markdown/command/__tests__/command.test.ts b/src/plugins/markdown/command/__tests__/command.test.ts index bfda4de5..41c6013b 100644 --- a/src/plugins/markdown/command/__tests__/command.test.ts +++ b/src/plugins/markdown/command/__tests__/command.test.ts @@ -12,14 +12,18 @@ import { MentionPlugin, TablePlugin, } from '@lobehub/editor'; -import { REDO_COMMAND, UNDO_COMMAND } from 'lexical'; -import { describe, expect, it } from 'vitest'; +import { COPY_COMMAND, REDO_COMMAND, UNDO_COMMAND } from 'lexical'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import Editor from '@/editor-kernel'; import { MarkdownPlugin } from '../../plugin'; import { GET_MARKDOWN_SELECTION_COMMAND, INSERT_MARKDOWN_COMMAND } from '../index'; +afterEach(() => { + vi.unstubAllGlobals(); +}); + const json = { root: { children: [ @@ -496,6 +500,174 @@ const json = { }; describe('Markdown Commands', () => { + describe('COPY_COMMAND', () => { + const dispatchCopyCommand = (editor: ReturnType) => { + const clipboard = new Map(); + const clipboardData = { + clearData: (type?: string) => { + if (type) { + clipboard.delete(type); + } else { + clipboard.clear(); + } + }, + getData: (type: string) => clipboard.get(type) || '', + setData: (type: string, value: string) => { + clipboard.set(type, value); + }, + get types() { + return Array.from(clipboard.keys()); + }, + } as unknown as DataTransfer; + class MockClipboardEvent extends Event { + clipboardData = clipboardData; + } + vi.stubGlobal('ClipboardEvent', MockClipboardEvent); + + const event = new MockClipboardEvent('copy') as ClipboardEvent; + + editor.dispatchCommand(COPY_COMMAND, event); + + return clipboard; + }; + + it('should write a partial paragraph selection without formatter-generated trailing line break', async () => { + const editor = Editor.createEditor().registerPlugins([CommonPlugin, MarkdownPlugin]); + editor.initNodeEditor(); + + editor.setDocument( + 'json', + { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + id: '2', + mode: 'normal', + style: '', + text: 'before js代码 after', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + id: '1', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }, + ], + direction: 'ltr', + format: '', + id: 'root', + indent: 0, + type: 'root', + version: 1, + }, + }, + { keepId: true }, + ); + + await editor.setSelection({ + endNodeId: '2', + endOffset: 11, + startNodeId: '2', + startOffset: 7, + type: 'range', + }); + + const clipboard = dispatchCopyCommand(editor); + + expect(clipboard.get('text/plain')).toBe('js代码'); + expect(clipboard.get('text/markdown')).toBe('js代码'); + }); + + it('should preserve paragraph breaks when copying across paragraphs', async () => { + const editor = Editor.createEditor().registerPlugins([CommonPlugin, MarkdownPlugin]); + editor.initNodeEditor(); + + editor.setDocument( + 'json', + { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + id: '2', + mode: 'normal', + style: '', + text: 'before 第一段', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + id: '1', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + id: '4', + mode: 'normal', + style: '', + text: '第二段 after', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + id: '3', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }, + ], + direction: 'ltr', + format: '', + id: 'root', + indent: 0, + type: 'root', + version: 1, + }, + }, + { keepId: true }, + ); + + await editor.setSelection({ + endNodeId: '4', + endOffset: 3, + startNodeId: '2', + startOffset: 7, + type: 'range', + }); + + const clipboard = dispatchCopyCommand(editor); + + expect(clipboard.get('text/plain')).toBe('第一段\n\n第二段'); + expect(clipboard.get('text/markdown')).toBe('第一段\n\n第二段'); + }); + }); + describe('INSERT_MARKDOWN_COMMAND', () => { it('should keep markdown auto-convert redoable after undo', async () => { const editor = Editor.createEditor().registerPlugins([CommonPlugin, MarkdownPlugin]); diff --git a/src/plugins/markdown/plugin/__tests__/paste.test.ts b/src/plugins/markdown/plugin/__tests__/paste.test.ts index c9a3f500..1d8d3089 100644 --- a/src/plugins/markdown/plugin/__tests__/paste.test.ts +++ b/src/plugins/markdown/plugin/__tests__/paste.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { MarkdownPlugin } from '../index'; +import { MarkdownPlugin, normalizeMarkdownForClipboard } from '../index'; const createKernelMock = () => ({ @@ -120,3 +120,14 @@ describe('Markdown paste auto conversion', () => { expect(result).toBe(false); }); }); + +describe('Markdown copy normalization', () => { + it('should remove the formatter-generated trailing line break from copied plain text', () => { + expect(normalizeMarkdownForClipboard('js代码\n')).toBe('js代码'); + expect(normalizeMarkdownForClipboard('js代码\r\n')).toBe('js代码'); + }); + + it('should preserve non-line-break trailing whitespace while copying', () => { + expect(normalizeMarkdownForClipboard('js代码 \n')).toBe('js代码 '); + }); +}); diff --git a/src/plugins/markdown/plugin/index.ts b/src/plugins/markdown/plugin/index.ts index fc3d470b..710a5e81 100644 --- a/src/plugins/markdown/plugin/index.ts +++ b/src/plugins/markdown/plugin/index.ts @@ -45,6 +45,8 @@ interface MarkdownDetectionResult { const RICH_HTML_SELECTOR = 'strong,em,b,i,h1,h2,h3,h4,h5,h6,ul,ol,table,img,blockquote,pre>code,a[href]'; +export const normalizeMarkdownForClipboard = (markdown: string) => markdown.replace(/\r?\n$/, ''); + const MARKDOWN_DETECTION_RULES = [ { name: 'headers', score: 5, test: (text) => /^#{1,6}\s+\S/m.test(text) }, { name: 'code-fence-start', score: 5, test: (text) => /^```[\w-]*$/m.test(text) }, @@ -324,8 +326,10 @@ export const MarkdownPlugin: IEditorPluginConstructor = c if (!selection || selection.isCollapsed()) return false; // Get the selection as markdown - const markdown = this.markdownDataSource.write(editor, { selection: true }); - if (!markdown) return false; + const rawMarkdown = this.markdownDataSource.write(editor, { selection: true }); + if (!rawMarkdown) return false; + + const markdown = normalizeMarkdownForClipboard(rawMarkdown); event.preventDefault();