diff --git a/packages/core/src/blocks/ListItem/BulletListItem/block.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts
index 4cd64f5058..0a40bdc1ce 100644
--- a/packages/core/src/blocks/ListItem/BulletListItem/block.ts
+++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts
@@ -37,17 +37,20 @@ export const createBulletListItemBlockSpec = createBlockSpec(
const parent = element.parentElement;
- if (parent === null) {
- return undefined;
- }
-
if (
+ parent === null ||
parent.tagName === "UL" ||
(parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
) {
return parseDefaultProps(element);
}
+ // Orphan `
` (no / ancestor) — match as bulletListItem so
+ // pasting bare `- ` HTML doesn't fall back to a paragraph.
+ if (!element.closest("ul, ol")) {
+ return parseDefaultProps(element);
+ }
+
return undefined;
},
// As `li` elements can contain multiple paragraphs, we need to merge their contents
diff --git a/packages/core/src/editor/transformPasted.test.ts b/packages/core/src/editor/transformPasted.test.ts
new file mode 100644
index 0000000000..9fbb32e9a4
--- /dev/null
+++ b/packages/core/src/editor/transformPasted.test.ts
@@ -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) {
+ 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(`
Pasted
`);
+
+ 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(`Hello world
`);
+
+ 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(`First
Second
`);
+
+ 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(`Heading
`);
+
+ // 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(`Heading
Body
`);
+
+ 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(``);
+
+ 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(`hello
`);
+
+ const blocks = editor.document;
+ expect(blocks[0].type).toBe("bulletListItem");
+ expect(blocks[0].content).toEqual([
+ { type: "text", text: "abchello", styles: {} },
+ ]);
+ });
+
+ it("pastes bare - a
- b
into an empty list item as two list items", () => {
+ const editor = BlockNoteEditor.create({
+ initialContent: [{ type: "bulletListItem", content: "" }],
+ });
+ mountEditor(editor);
+ selectStartOfFirstBlock(editor);
+
+ editor.pasteHTML(`- a
- b
`);
+
+ 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 - a
- b
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(`- a
- b
`);
+
+ 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(``);
+
+ // 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(``);
+
+ // 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(``);
+
+ 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(`added
`);
+
+ const blocks = editor.document;
+ expect(blocks[0].type).toBe("paragraph");
+ expect(blocks[0].content).toEqual([
+ { type: "text", text: "Existing added", styles: {} },
+ ]);
+ });
+});
diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts
index 2985ad33dc..4f0515df95 100644
--- a/packages/core/src/editor/transformPasted.ts
+++ b/packages/core/src/editor/transformPasted.ts
@@ -113,6 +113,11 @@ export function transformPasted(slice: Slice, view: EditorView) {
let f = Fragment.from(slice.content);
f = wrapTableRows(f, view.state.schema);
+ const retyped = retypeLeadingParagraphForEmptyTarget(f, view, slice);
+ if (retyped) {
+ return retyped;
+ }
+
if (isInTableCell(view)) {
let hasTableContent = false;
f.descendants((node) => {
@@ -173,6 +178,78 @@ export function transformPasted(slice: Slice, view: EditorView) {
return new Slice(f, slice.openStart, slice.openEnd);
}
+/**
+ * Pasting plain text into an empty inline-content block (e.g. an empty
+ * bullet list item) would normally replace that block with a paragraph:
+ * BlockNote's serializer always wraps content in
+ * `blockGroup > blockContainer > `, producing a closed slice that
+ * ProseMirror inserts as a new block rather than splicing inline.
+ *
+ * To preserve the empty block's type, retype the leading paragraph in the
+ * slice to match the target block. Subsequent blocks in the slice are left
+ * alone and end up as siblings.
+ *
+ * Scoped to: empty, non-paragraph, inline-content target + paragraph leading
+ * the slice. A non-empty target already gives ProseMirror a valid inline
+ * insertion point so it splices correctly on its own; non-paragraph leading
+ * blocks (heading, list item, …) carry semantic meaning the user picked, so
+ * we keep the existing replace behavior.
+ */
+function retypeLeadingParagraphForEmptyTarget(
+ fragment: Fragment,
+ view: EditorView,
+ slice: Slice,
+): Slice | null {
+ if (isInTableCell(view)) {
+ return null;
+ }
+
+ // `transformPasted` is also called for drop events, where the slice will be
+ // inserted at the drop position rather than the current selection. In that
+ // case the selection-derived target is wrong, so bail out and let the
+ // default behavior handle drops.
+ if (view.dragging) {
+ return null;
+ }
+
+ const blockInfo = getBlockInfoFromSelection(view.state);
+ const target = blockInfo.isBlockContainer
+ ? blockInfo.blockContent.node
+ : null;
+ if (
+ !target ||
+ target.type.name === "paragraph" ||
+ target.type.spec.content !== "inline*" ||
+ target.childCount > 0
+ ) {
+ return null;
+ }
+
+ const blockGroup = fragment.firstChild;
+ const blockContainer = blockGroup?.firstChild;
+ const leading = blockContainer?.firstChild;
+ if (
+ blockGroup?.type.name !== "blockGroup" ||
+ blockContainer?.type.name !== "blockContainer" ||
+ leading?.type.name !== "paragraph"
+ ) {
+ return null;
+ }
+
+ const retyped = target.type.create(target.attrs, leading.content);
+ const newBlockContainer = blockContainer.copy(
+ blockContainer.content.replaceChild(0, retyped),
+ );
+ const newBlockGroup = blockGroup.copy(
+ blockGroup.content.replaceChild(0, newBlockContainer),
+ );
+ return new Slice(
+ fragment.replaceChild(0, newBlockGroup),
+ slice.openStart,
+ slice.openEnd,
+ );
+}
+
/**
* Used in `transformPasted` to check if the fix there should be applied, i.e.
* if the pasted fragment should be wrapped in a `blockContainer` node. This
diff --git a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithMultipleCheckboxesInTableCell.json b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithMultipleCheckboxesInTableCell.json
index adfce7adf4..e2cf22f640 100644
--- a/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithMultipleCheckboxesInTableCell.json
+++ b/tests/src/unit/core/clipboard/paste/__snapshots__/text/html/pasteHTMLWithMultipleCheckboxesInTableCell.json
@@ -16,8 +16,8 @@
{
"styles": {},
"text": "Cell 1ABC
- Unit tests covering the new feature have been added.
- All existing tests pass.",
+Unit tests covering the new feature have been added.
+All existing tests pass.",
"type": "text",
},
],