Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
928c122653 feat(editor): preserve Markdown source on copy/cut
ProseMirror's default clipboardTextSerializer uses Slice.textBetween,
which flattens every node to its inner text. Copying `## 你好` from the
editor only put `你好` on the clipboard's text/plain channel, so pasting
into VS Code, terminals, or messaging apps lost all Markdown markers.

Add a markdown-copy extension symmetric to the existing markdown-paste:
on copy/cut/drag, route the selected Slice through editor.markdown.serialize
to write the Markdown source. The text/html channel is left at ProseMirror's
default so pasting back into another ProseMirror editor still preserves
exact node structure via data-pm-slice.

Registered for both editable and readonly modes — users frequently copy
from rendered comments/issue descriptions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:33:13 +08:00
2 changed files with 67 additions and 0 deletions

View File

@@ -39,6 +39,7 @@ import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createMarkdownCopyExtension } from "./markdown-copy";
import { createSubmitExtension } from "./submit-shortcut";
import { createBlurShortcutExtension } from "./blur-shortcut";
import { createFileUploadExtension } from "./file-upload";
@@ -129,6 +130,10 @@ export function createEditorExtensions(
InlineMathExtension,
// 3-space indent so nested ordered lists survive CommonMark in ReadonlyContent.
Markdown.configure({ indentation: { style: "space", size: 3 } }),
// Make Cmd+C / Cmd+X / drag write Markdown source to clipboard text/plain.
// Registered for both editable and readonly so users can copy from rendered
// comments and paste the original Markdown elsewhere.
createMarkdownCopyExtension(),
FileCardExtension,
...(options.disableMentions
? []

View File

@@ -0,0 +1,62 @@
/**
* Markdown copy extension — make the clipboard's text/plain channel carry
* Markdown source instead of plain textContent.
*
* Symmetric to markdown-paste.ts:
* paste: text/plain → editor.markdown.parse → doc
* copy: slice → editor.markdown.serialize → text/plain
*
* Why: ProseMirror's default clipboardTextSerializer calls Slice.textBetween,
* which flattens every node to its inner text. Headings, lists, code blocks,
* mentions, file cards — all lose their Markdown markers. Pasting into VS
* Code, terminals, or messaging apps then sees only naked text.
*
* The text/html channel is left at ProseMirror's default so pasting back
* into another ProseMirror editor still preserves exact node structure via
* data-pm-slice.
*/
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { Slice } from "@tiptap/pm/model";
// Blob URLs (blob:http://…) are process-local; never let them leave the page.
const BLOB_IMAGE_RE = /!\[[^\]]*\]\(blob:[^)]*\)\n?/g;
export function createMarkdownCopyExtension() {
return Extension.create({
name: "markdownCopy",
addProseMirrorPlugins() {
const { editor } = this;
const fallback = (slice: Slice) =>
slice.content.textBetween(0, slice.content.size, "\n\n");
return [
new Plugin({
key: new PluginKey("markdownCopy"),
props: {
clipboardTextSerializer(slice: Slice) {
if (!editor.markdown) return fallback(slice);
try {
// Wrap slice content in a temp doc so the serializer walks
// it like a real document. Inline-only slices auto-wrap
// into doc → paragraph; block slices pass through.
const doc = editor.schema.topNodeType.create(
null,
slice.content,
);
const md = editor.markdown.serialize(doc.toJSON());
return md.replace(BLOB_IMAGE_RE, "").replace(/\n+$/, "");
} catch {
// Special selections (e.g. table cellSelection) may fail
// schema validation when wrapped in a doc node. Fall back
// so copy never breaks.
return fallback(slice);
}
},
},
}),
];
},
});
}