From d833ef520d092877ccf9d10ff23fd129039d519a Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 17 Jul 2026 11:11:17 +0800 Subject: [PATCH] fix(editor): preserve ordered list numbering on rich paste (#5538) Co-authored-by: Lambda Co-authored-by: multica-agent --- .../editor/extensions/markdown-paste.test.ts | 98 +++++++++++++++++++ .../views/editor/extensions/markdown-paste.ts | 87 +++++++++++++++- 2 files changed, 180 insertions(+), 5 deletions(-) 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", + "
  1. First
" + + "
  1. Second
" + + "
  1. 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" + + "
    1. First
    " + + "
    1. 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", + "
  1. First
" + + "

Separate section

" + + "
  1. 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", + '
' + + "
  1. First
" + + "
  1. 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; },