Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 174 additions & 2 deletions src/plugins/markdown/command/__tests__/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -496,6 +500,174 @@ const json = {
};

describe('Markdown Commands', () => {
describe('COPY_COMMAND', () => {
const dispatchCopyCommand = (editor: ReturnType<typeof Editor.createEditor>) => {
const clipboard = new Map<string, string>();
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]);
Expand Down
13 changes: 12 additions & 1 deletion src/plugins/markdown/plugin/__tests__/paste.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';

import { MarkdownPlugin } from '../index';
import { MarkdownPlugin, normalizeMarkdownForClipboard } from '../index';

const createKernelMock = () =>
({
Expand Down Expand Up @@ -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代码 ');
});
});
8 changes: 6 additions & 2 deletions src/plugins/markdown/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -324,8 +326,10 @@ export const MarkdownPlugin: IEditorPluginConstructor<MarkdownPluginOptions> = 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();

Expand Down
Loading