-
-
Notifications
You must be signed in to change notification settings - Fork 745
fix(core): preserve list item type when pasting into empty list items #2722
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
73e7a02
fix(core): preserve list item type when pasting into empty inline-con…
nperez0111 49fe92c
fix(core): type orphan list item set as Set<Element> for tsc strict c…
nperez0111 38743b4
fix(core): address review feedback on paste/parse fixes
nperez0111 e624218
fix(core): preserve list item type when pasting into empty inline-con…
nperez0111 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| import { TextSelection } from "@tiptap/pm/state"; | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { BlockNoteEditor } from "./BlockNoteEditor.js"; | ||
|
|
||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
|
|
||
| function mountEditor(editor: BlockNoteEditor<any, any, any>) { | ||
| editor.mount(document.createElement("div")); | ||
| } | ||
|
|
||
| function selectStartOfFirstBlock(editor: BlockNoteEditor) { | ||
| editor.transact((tr) => { | ||
| let pos: number | undefined; | ||
| tr.doc.descendants((node, nodePos) => { | ||
| if (node.type.spec.group === "blockContent") { | ||
| pos = nodePos + 1; | ||
| return false; | ||
| } | ||
| return pos === undefined; | ||
| }); | ||
| tr.setSelection(TextSelection.create(tr.doc, pos!)); | ||
| }); | ||
| } | ||
|
|
||
| describe("paste into empty inline-content block", () => { | ||
| it.each(["bulletListItem", "numberedListItem", "checkListItem"] as const)( | ||
| "pastes a paragraph into an empty %s without replacing it", | ||
| (type) => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type, content: "" }], | ||
| }); | ||
| mountEditor(editor); | ||
| selectStartOfFirstBlock(editor); | ||
|
|
||
| editor.pasteHTML(`<p>Pasted</p>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe(type); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "Pasted", styles: {} }, | ||
| ]); | ||
| }, | ||
| ); | ||
|
|
||
| it("inserts paragraph content into an empty list item without dropping marks", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "" }], | ||
| }); | ||
| mountEditor(editor); | ||
| selectStartOfFirstBlock(editor); | ||
|
|
||
| editor.pasteHTML(`<p>Hello <strong>world</strong></p>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("bulletListItem"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "Hello ", styles: {} }, | ||
| { type: "text", text: "world", styles: { bold: true } }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("merges leading paragraph into empty list item and inserts rest as siblings", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "" }], | ||
| }); | ||
| mountEditor(editor); | ||
| selectStartOfFirstBlock(editor); | ||
|
|
||
| editor.pasteHTML(`<p>First</p><p>Second</p>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("bulletListItem"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "First", styles: {} }, | ||
| ]); | ||
| expect(blocks[1].type).toBe("paragraph"); | ||
| expect(blocks[1].content).toEqual([ | ||
| { type: "text", text: "Second", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("replaces an empty list item with a heading when pasting a heading", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "" }], | ||
| }); | ||
| mountEditor(editor); | ||
| selectStartOfFirstBlock(editor); | ||
|
|
||
| editor.pasteHTML(`<h1>Heading</h1>`); | ||
|
|
||
| // The empty list item is replaced by the heading rather than absorbing | ||
| // its inline content. Headings carry semantic meaning the user explicitly | ||
| // chose, so we keep them as-is. | ||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("heading"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "Heading", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("keeps the list item but discards the heading wrapper when a paragraph follows the heading", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "" }], | ||
| }); | ||
| mountEditor(editor); | ||
| selectStartOfFirstBlock(editor); | ||
|
|
||
| // Heading first means the leading block is not a paragraph, so the | ||
| // unwrap/retype rule doesn't apply: the empty list item gets replaced | ||
| // by the pasted heading and the trailing paragraph follows as a sibling. | ||
| editor.pasteHTML(`<h1>Heading</h1><p>Body</p>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("heading"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "Heading", styles: {} }, | ||
| ]); | ||
| expect(blocks[1].type).toBe("paragraph"); | ||
| expect(blocks[1].content).toEqual([ | ||
| { type: "text", text: "Body", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("still replaces the empty list item when pasting another list item", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "" }], | ||
| }); | ||
| mountEditor(editor); | ||
| selectStartOfFirstBlock(editor); | ||
|
|
||
| editor.pasteHTML(`<ul><li>Pasted item</li></ul>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("bulletListItem"); | ||
| // The empty list item should be replaced (not have inline content | ||
| // appended in-place), which matches the existing behavior. | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "Pasted item", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("pastes a paragraph into a non-empty list item without replacing it", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "abc" }], | ||
| }); | ||
| mountEditor(editor); | ||
| editor.setTextCursorPosition(editor.document[0].id, "end"); | ||
|
|
||
| editor.pasteHTML(`<p>hello</p>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("bulletListItem"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "abchello", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("pastes bare <li>a</li><li>b</li> into an empty list item as two list items", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "" }], | ||
| }); | ||
| mountEditor(editor); | ||
| selectStartOfFirstBlock(editor); | ||
|
|
||
| editor.pasteHTML(`<li>a</li><li>b</li>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("bulletListItem"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "a", styles: {} }, | ||
| ]); | ||
| expect(blocks[1].type).toBe("bulletListItem"); | ||
| expect(blocks[1].content).toEqual([ | ||
| { type: "text", text: "b", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("pastes bare <li>a</li><li>b</li> into a non-empty list item, splicing the first into the cursor", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "X" }], | ||
| }); | ||
| mountEditor(editor); | ||
| editor.setTextCursorPosition(editor.document[0].id, "end"); | ||
|
|
||
| editor.pasteHTML(`<li>a</li><li>b</li>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("bulletListItem"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "Xa", styles: {} }, | ||
| ]); | ||
| expect(blocks[1].type).toBe("bulletListItem"); | ||
| expect(blocks[1].content).toEqual([ | ||
| { type: "text", text: "b", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("pastes two list items into an empty list item as two siblings", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "" }], | ||
| }); | ||
| mountEditor(editor); | ||
| selectStartOfFirstBlock(editor); | ||
|
|
||
| editor.pasteHTML(`<ul><li>a</li><li>b</li></ul>`); | ||
|
|
||
| // The empty list item is replaced by the two pasted list items. | ||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("bulletListItem"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "a", styles: {} }, | ||
| ]); | ||
| expect(blocks[1].type).toBe("bulletListItem"); | ||
| expect(blocks[1].content).toEqual([ | ||
| { type: "text", text: "b", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("pastes two list items into a non-empty list item, splicing the first into the cursor", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "X" }], | ||
| }); | ||
| mountEditor(editor); | ||
| editor.setTextCursorPosition(editor.document[0].id, "end"); | ||
|
|
||
| editor.pasteHTML(`<ul><li>a</li><li>b</li></ul>`); | ||
|
|
||
| // The first list item's inline content is spliced at the cursor (the | ||
| // existing list item becomes "Xa") and the second list item becomes a | ||
| // new sibling. | ||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("bulletListItem"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "Xa", styles: {} }, | ||
| ]); | ||
| expect(blocks[1].type).toBe("bulletListItem"); | ||
| expect(blocks[1].content).toEqual([ | ||
| { type: "text", text: "b", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("preserves nested list structure when pasting a nested list into an empty list item", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "bulletListItem", content: "" }], | ||
| }); | ||
| mountEditor(editor); | ||
| selectStartOfFirstBlock(editor); | ||
|
|
||
| // Pasting a list with nested children: the leading block is a list item, | ||
| // so the unwrap/retype rule does not apply and the existing slice-level | ||
| // list-nesting fix in `transformPasted` keeps the nested structure | ||
| // intact. | ||
| editor.pasteHTML(`<ul><li>Outer<ul><li>Inner</li></ul></li></ul>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("bulletListItem"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "Outer", styles: {} }, | ||
| ]); | ||
| expect(blocks[0].children).toHaveLength(1); | ||
| expect(blocks[0].children[0].type).toBe("bulletListItem"); | ||
| expect(blocks[0].children[0].content).toEqual([ | ||
| { type: "text", text: "Inner", styles: {} }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("preserves the existing paragraph behavior when pasting into a non-empty paragraph", () => { | ||
| const editor = BlockNoteEditor.create({ | ||
| initialContent: [{ type: "paragraph", content: "Existing " }], | ||
| }); | ||
| mountEditor(editor); | ||
| editor.setTextCursorPosition(editor.document[0].id, "end"); | ||
|
|
||
| editor.pasteHTML(`<p>added</p>`); | ||
|
|
||
| const blocks = editor.document; | ||
| expect(blocks[0].type).toBe("paragraph"); | ||
| expect(blocks[0].content).toEqual([ | ||
| { type: "text", text: "Existing added", styles: {} }, | ||
| ]); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.