diff --git a/packages/views/editor/extensions/markdown-paste.test.ts b/packages/views/editor/extensions/markdown-paste.test.ts
index 886d71c64..85e6c81b8 100644
--- a/packages/views/editor/extensions/markdown-paste.test.ts
+++ b/packages/views/editor/extensions/markdown-paste.test.ts
@@ -43,6 +43,18 @@ function paste(editor: Editor, text: string, html?: string): boolean {
);
}
+function pasteThroughEditorDom(editor: Editor, text: string, html: string): void {
+ const event = new Event("paste", { bubbles: false, cancelable: true });
+ Object.defineProperty(event, "clipboardData", {
+ value: {
+ files: [],
+ getData: (type: string) =>
+ type === "text/plain" ? text : type === "text/html" ? html : "",
+ },
+ });
+ editor.view.dom.dispatchEvent(event);
+}
+
interface JsonNode {
type: string;
text?: string;
@@ -187,6 +199,92 @@ describe("markdownPaste — code block context", () => {
expect(parseSpy).not.toHaveBeenCalled();
});
+ it("keeps ordered numbering when rich HTML fragments every item into its own list", () => {
+ editor = makeEditor({
+ type: "doc",
+ content: [{ type: "paragraph" }],
+ });
+
+ pasteThroughEditorDom(
+ editor,
+ "1. First\n2. Second\n3. Third",
+ "
- First
" +
+ "- Second
" +
+ "- Third
",
+ );
+
+ const json = editor.getJSON() as JsonNode;
+ const orderedLists = (json.content ?? []).filter(
+ (node) => node.type === "orderedList",
+ );
+ expect(orderedLists).toHaveLength(1);
+ expect(orderedLists[0]?.content?.map(nodeText)).toEqual([
+ "First",
+ "Second",
+ "Third",
+ ]);
+ expect(editor.getMarkdown()).toContain(
+ "1. **First**\n2. Second\n3. Third",
+ );
+ });
+
+ it("repairs fragmented ordered lists nested inside another list item", () => {
+ editor = makeEditor({
+ type: "doc",
+ content: [{ type: "paragraph" }],
+ });
+
+ pasteThroughEditorDom(
+ editor,
+ "Plan\n1. First\n2. Second",
+ "- Plan" +
+ "
- First
" +
+ "- Second
" +
+ "
",
+ );
+
+ const orderedList = findFirst(editor.getJSON() as JsonNode, "orderedList");
+ expect(orderedList?.content?.map(nodeText)).toEqual(["First", "Second"]);
+ });
+
+ it("keeps ordered lists separate when real content divides them", () => {
+ editor = makeEditor({
+ type: "doc",
+ content: [{ type: "paragraph" }],
+ });
+
+ pasteThroughEditorDom(
+ editor,
+ "1. First\nSeparate section\n1. Restarted",
+ "- First
" +
+ "Separate section
" +
+ "- Restarted
",
+ );
+
+ const json = editor.getJSON() as JsonNode;
+ expect(
+ (json.content ?? []).filter((node) => node.type === "orderedList"),
+ ).toHaveLength(2);
+ });
+
+ it("leaves ProseMirror-owned clipboard slices on the native path", () => {
+ editor = makeEditor({
+ type: "doc",
+ content: [{ type: "paragraph" }],
+ });
+
+ const handled = paste(
+ editor,
+ "1. First\n1. Restarted",
+ '' +
+ "
- First
" +
+ "
- Restarted
" +
+ "
",
+ );
+
+ expect(handled).toBe(false);
+ });
+
it("does not paste rich HTML natively when its text would drop raw tag-like lines", () => {
editor = makeEditor({
type: "doc",
diff --git a/packages/views/editor/extensions/markdown-paste.ts b/packages/views/editor/extensions/markdown-paste.ts
index bdcdee5c3..2b188a770 100644
--- a/packages/views/editor/extensions/markdown-paste.ts
+++ b/packages/views/editor/extensions/markdown-paste.ts
@@ -27,7 +27,11 @@
*/
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
-import { Slice } from "@tiptap/pm/model";
+import {
+ Fragment,
+ Slice,
+ type Node as ProseMirrorNode,
+} from "@tiptap/pm/model";
const LARGE_PASTE_TEXT_THRESHOLD = 50_000;
const SEMANTIC_RICH_HTML_SELECTOR = [
@@ -347,6 +351,62 @@ function classifyPaste({
return "markdown";
}
+function canJoinOrderedLists(
+ left: ProseMirrorNode,
+ right: ProseMirrorNode,
+): boolean {
+ const leftType = left.attrs.type ?? "1";
+ const rightType = right.attrs.type ?? "1";
+ if (
+ left.type.name !== "orderedList" ||
+ right.type !== left.type ||
+ leftType !== rightType
+ ) {
+ return false;
+ }
+
+ const parsedLeftStart = Number(left.attrs.start);
+ const parsedRightStart = Number(right.attrs.start);
+ const leftStart = Number.isFinite(parsedLeftStart) ? parsedLeftStart : 1;
+ const rightStart = Number.isFinite(parsedRightStart) ? parsedRightStart : 1;
+ const expectedContinuation = leftStart + left.childCount;
+
+ // Some rich-text sources put every visual item in its own without a
+ // start value, so the parsed slice becomes adjacent one-item lists that all
+ // restart at 1. An explicit continuation value is the same structure with a
+ // better HTML hint. Both forms should become one list in our document model.
+ return rightStart === 1 || rightStart === expectedContinuation;
+}
+
+function repairOrderedListsInFragment(fragment: Fragment): Fragment {
+ const repaired: ProseMirrorNode[] = [];
+
+ fragment.forEach((node) => {
+ const content = node.isLeaf
+ ? node.content
+ : repairOrderedListsInFragment(node.content);
+ const repairedNode = content.eq(node.content) ? node : node.copy(content);
+ const previous = repaired.at(-1);
+
+ if (previous && canJoinOrderedLists(previous, repairedNode)) {
+ repaired[repaired.length - 1] = previous.copy(
+ previous.content.append(repairedNode.content),
+ );
+ return;
+ }
+
+ repaired.push(repairedNode);
+ });
+
+ return Fragment.fromArray(repaired);
+}
+
+function repairFragmentedOrderedLists(slice: Slice): Slice {
+ const content = repairOrderedListsInFragment(slice.content);
+ if (content.eq(slice.content)) return slice;
+ return new Slice(content, slice.openStart, slice.openEnd);
+}
+
export function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
@@ -356,7 +416,7 @@ export function createMarkdownPasteExtension() {
new Plugin({
key: new PluginKey("markdownPaste"),
props: {
- handlePaste(view, event) {
+ handlePaste(view, event, slice) {
if (!editor.markdown) return false;
const clipboard = event.clipboardData;
if (!clipboard) return false;
@@ -371,7 +431,24 @@ export function createMarkdownPasteExtension() {
isInsideCodeBlock: $from.parent.type.name === "codeBlock",
});
- if (mode === "native") return false;
+ if (mode === "native") {
+ // ProseMirror-owned clipboard HTML already represents our exact
+ // document structure. Only repair external rich HTML, where
+ // adjacent fragments commonly stand for one visual list.
+ if (html && !html.includes("data-pm-slice")) {
+ const repaired = repairFragmentedOrderedLists(slice);
+ if (repaired !== slice) {
+ const tr = view.state.tr
+ .replaceSelection(repaired)
+ .scrollIntoView()
+ .setMeta("paste", true)
+ .setMeta("uiEvent", "paste");
+ view.dispatch(tr);
+ return true;
+ }
+ }
+ return false;
+ }
if (mode === "literal") {
view.dispatch(view.state.tr.insertText(text));
@@ -397,8 +474,8 @@ export function createMarkdownPasteExtension() {
return true;
}
- const slice = Slice.maxOpen(node.content);
- const tr = view.state.tr.replaceSelection(slice);
+ const parsedSlice = Slice.maxOpen(node.content);
+ const tr = view.state.tr.replaceSelection(parsedSlice);
view.dispatch(tr);
return true;
},