fix(editor): preserve ordered list numbering on rich paste (#5538)

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Jiayuan Zhang
2026-07-17 11:11:17 +08:00
committed by GitHub
parent a18cc65b35
commit d833ef520d
2 changed files with 180 additions and 5 deletions

View File

@@ -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",
"<ol><li><strong>First</strong></li></ol>" +
"<ol><li>Second</li></ol>" +
"<ol><li>Third</li></ol>",
);
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",
"<ul><li>Plan" +
"<ol><li>First</li></ol>" +
"<ol><li>Second</li></ol>" +
"</li></ul>",
);
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",
"<ol><li>First</li></ol>" +
"<p>Separate section</p>" +
"<ol><li>Restarted</li></ol>",
);
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",
'<div data-pm-slice="0 0 []">' +
"<ol><li>First</li></ol>" +
"<ol><li>Restarted</li></ol>" +
"</div>",
);
expect(handled).toBe(false);
});
it("does not paste rich HTML natively when its text would drop raw tag-like lines", () => {
editor = makeEditor({
type: "doc",

View File

@@ -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 <ol> 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 <ol> 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;
},