fix(editor): preserve markdown on clipboard copy

This commit is contained in:
Neil
2026-09-18 03:27:42 -07:00
parent f7b3a4a3fb
commit 89ee2e7363
4 changed files with 57 additions and 2 deletions
@@ -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()
}
})
})
@@ -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) : ''
}
@@ -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
@@ -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
},