diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.test.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.test.ts new file mode 100644 index 00000000000..e71cea2aaa9 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.test.ts @@ -0,0 +1,33 @@ +import { Editor } from '@tiptap/core' +import { describe, expect, it } from 'vitest' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { serializeRichMarkdownSliceAsMarkdown } from './rich-markdown-clipboard-serialization' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +describe('rich Markdown clipboard serialization', () => { + it('serializes a selected slice as Markdown source', () => { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor( + '# Heading\n\nA **bold** paragraph with a [link](https://example.com).\n', + codec + ), + contentType: 'markdown' + }) + try { + const slice = editor.state.doc.slice(0, editor.state.doc.content.size) + const markdown = serializeRichMarkdownSliceAsMarkdown(slice, (content) => + editor.markdown!.serialize(content) + ) + expect(markdown).toContain('# Heading') + expect(markdown).toContain('**bold**') + expect(markdown).toContain('[link](https://example.com)') + expect(markdown).not.toContain('\n\n\n') + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts index ae3cd7badca..ed4360b2cb1 100644 --- a/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts +++ b/src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts @@ -1,6 +1,9 @@ import { DOMSerializer, type Slice } from '@tiptap/pm/model' +import type { JSONContent } from '@tiptap/core' import type { EditorView } from '@tiptap/pm/view' +export type MarkdownSliceSerializer = (content: JSONContent | JSONContent[]) => string + export function serializeRichMarkdownSliceForClipboard( view: EditorView, slice: Slice @@ -15,3 +18,11 @@ export function serializeRichMarkdownSliceForClipboard( container.appendChild(fragment) return { html: container.innerHTML } } + +export function serializeRichMarkdownSliceAsMarkdown( + slice: Slice, + serialize: MarkdownSliceSerializer +): string { + const content = slice.content.toJSON() + return Array.isArray(content) ? serialize(content) : '' +} diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-write.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-write.ts index 3d146c614ec..4ab570b22f2 100644 --- a/src/renderer/src/components/editor/rich-markdown-clipboard-write.ts +++ b/src/renderer/src/components/editor/rich-markdown-clipboard-write.ts @@ -21,14 +21,18 @@ export function writeRichMarkdownSliceToClipboard( return false } const serialized = serializeRichMarkdownSliceForClipboard(view, slice) + const markdownText = + (typeof view.someProp === 'function' + ? view.someProp('clipboardTextSerializer', (serializer) => serializer(slice, view)) + : undefined) || visibleText clipboardData.setData('text/html', serialized.html) - clipboardData.setData('text/plain', visibleText) + clipboardData.setData('text/plain', markdownText) // Why: if the clipboard rejected the write we must not delete, and we must // surface the same cut-limit feedback so the no-op is not silent. if ( typeof clipboardData.getData === 'function' && (clipboardData.getData('text/html') !== serialized.html || - clipboardData.getData('text/plain') !== visibleText) + clipboardData.getData('text/plain') !== markdownText) ) { showRichMarkdownSourceOwningCutLimitError() return false diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 7fd8385eeb8..41cdd9aa19f 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -36,6 +36,7 @@ import { getRichMarkdownSelectionLinkBubble, openSelectedHtmlSuperscriptLink } from './rich-markdown-selected-link-actions' +import { serializeRichMarkdownSliceAsMarkdown } from './rich-markdown-clipboard-serialization' export type EditorConfigParams = { codec: RichMarkdownEditorCodec @@ -138,6 +139,12 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE class: 'rich-markdown-editor', spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled) }, + clipboardTextSerializer: (slice) => { + const markdown = editorRef.current?.markdown + return markdown + ? serializeRichMarkdownSliceAsMarkdown(slice, (content) => markdown.serialize(content)) + : '' + }, handleDOMEvents: { cut: handleRichMarkdownCut },