From 3b3c4cf7bdbbfb56b99a7ea9b2f45799ecc0a87e Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Wed, 1 Jul 2026 15:45:51 +0700 Subject: [PATCH] feat(editor): parse markdown on paste so paste matches load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @tiptap/markdown only parses markdown for initial content / insertContent, never for the clipboard — so pasting block markdown (headings, lists, tables, quotes, fences) landed as literal text while only StarterKit's inline mark paste-rules fired, and `_`/`__` formatted on paste even though load keeps them literal. Add a MarkdownPaste extension that routes plain-text pastes through the same faithful markdown layer as load, so the two are consistent. Rich sources keep ProseMirror's own fidelity: an in-editor copy (data-pm-slice) and web/Office HTML (block tags) fall through to the default path, and pastes inside code stay literal. Co-Authored-By: Claude Opus 4.8 --- .../shared/markdown-editor/editor-paste.ts | 43 +++++++++++++++++++ .../markdown-editor-extensions.ts | 2 + 2 files changed, 45 insertions(+) create mode 100644 frontend/src/components/shared/markdown-editor/editor-paste.ts diff --git a/frontend/src/components/shared/markdown-editor/editor-paste.ts b/frontend/src/components/shared/markdown-editor/editor-paste.ts new file mode 100644 index 00000000..3844d17a --- /dev/null +++ b/frontend/src/components/shared/markdown-editor/editor-paste.ts @@ -0,0 +1,43 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +// @tiptap/markdown parses markdown for load/insertContent but never for the clipboard, so a paste of block +// markdown (# ## - 1. | >) would land as literal text (only StarterKit's inline mark paste-rules fire). +// Route plain-text pastes through the same markdown layer as load; defer to ProseMirror's own path for rich +// sources — an in-editor copy (carries `data-pm-slice`) or web/Office HTML (block tags) — so their fidelity +// survives, and keep pastes inside code literal. +const RICH_HTML_BLOCK = /<(?:h[1-6]|ul|ol|li|table|thead|tbody|tr|td|th|blockquote|pre|img|hr)\b/i; + +export const MarkdownPaste = Extension.create({ + addProseMirrorPlugins() { + const { editor } = this; + + return [ + new Plugin({ + key: new PluginKey('markdownPaste'), + props: { + handlePaste(view, event) { + const text = event.clipboardData?.getData('text/plain') ?? ''; + + if (!text.trim()) { + return false; + } + + const html = event.clipboardData?.getData('text/html') ?? ''; + + if (html.includes('data-pm-slice') || RICH_HTML_BLOCK.test(html)) { + return false; + } + + if (view.state.selection.$from.parent.type.spec.code || editor.isActive('code')) { + return false; + } + + return editor.commands.insertContent(text, { contentType: 'markdown' }); + }, + }, + }), + ]; + }, + name: 'markdownPaste', +}); diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts index 36a291b4..0cf17ddf 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts @@ -5,6 +5,7 @@ import { Placeholder } from '@tiptap/extensions'; import StarterKit from '@tiptap/starter-kit'; import { createMarkdownLayer, MarkdownTable } from './editor-markdown'; +import { MarkdownPaste } from './editor-paste'; import { TagHighlight } from './editor-tag-highlight'; import { VariableHighlight } from './editor-variable-highlight'; @@ -25,4 +26,5 @@ export const createMarkdownExtensions = (placeholder?: string) => [ TagHighlight, Placeholder.configure({ emptyEditorClass: 'is-editor-empty', placeholder }), ...createMarkdownLayer(), + MarkdownPaste, ];