mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user