diff --git a/.changeset/cool-elephants-wave.md b/.changeset/cool-elephants-wave.md
new file mode 100644
index 0000000000..f39a34e7f7
--- /dev/null
+++ b/.changeset/cool-elephants-wave.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/extensions': patch
+---
+
+Add `excludedNodeTypes` option to Placeholder extension to prevent duplicate placeholders on wrapper nodes like lists when using `includeChildren: true`.
diff --git a/packages/extensions/__tests__/placeholder.spec.ts b/packages/extensions/__tests__/placeholder.spec.ts
index 78d3d8174c..9e23e6f8f8 100644
--- a/packages/extensions/__tests__/placeholder.spec.ts
+++ b/packages/extensions/__tests__/placeholder.spec.ts
@@ -1,6 +1,10 @@
import { Editor } from '@tiptap/core'
+import BulletList from '@tiptap/extension-bullet-list'
import Document from '@tiptap/extension-document'
+import ListItem from '@tiptap/extension-list-item'
import Paragraph from '@tiptap/extension-paragraph'
+import TaskItem from '@tiptap/extension-task-item'
+import TaskList from '@tiptap/extension-task-list'
import Text from '@tiptap/extension-text'
import { type PlaceholderOptions, Placeholder, preparePlaceholderAttribute } from '@tiptap/extensions'
import { afterEach, describe, expect, it } from 'vitest'
@@ -89,3 +93,126 @@ describe('extension-placeholder', () => {
editor!.destroy()
})
})
+describe('extension-placeholder with excludedNodeTypes', () => {
+ let editor: Editor | null = null
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy()
+ }
+ })
+
+ it('should not show placeholder on excluded node types', () => {
+ editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ BulletList,
+ ListItem,
+ Placeholder.configure({
+ placeholder: 'Type something...',
+ includeChildren: true,
+ excludedNodeTypes: ['bulletList', 'listItem'],
+ }),
+ ],
+ content: '
',
+ })
+
+ const bulletList = editor!.view.dom.querySelector('ul') as HTMLElement
+ const listItem = editor!.view.dom.querySelector('li') as HTMLElement
+ const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
+
+ // bulletList should NOT have placeholder (excluded)
+ expect(bulletList.hasAttribute('data-placeholder')).toBe(false)
+ // listItem should NOT have placeholder (excluded)
+ expect(listItem.hasAttribute('data-placeholder')).toBe(false)
+ // paragraph should have placeholder (empty)
+ expect(paragraph.hasAttribute('data-placeholder')).toBe(true)
+ })
+
+ it('should show placeholder on empty nodes when excludedNodeTypes is empty', () => {
+ editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ BulletList,
+ ListItem,
+ Placeholder.configure({
+ placeholder: 'Type something...',
+ includeChildren: true,
+ excludedNodeTypes: [],
+ }),
+ ],
+ content: '',
+ })
+
+ const bulletList = editor!.view.dom.querySelector('ul') as HTMLElement
+ const listItem = editor!.view.dom.querySelector('li') as HTMLElement
+ const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
+
+ // All nodes with empty content will have placeholder when not excluded
+ // bulletList contains an empty listItem, so it is also considered empty
+ expect(bulletList.hasAttribute('data-placeholder')).toBe(true)
+ // listItem has an empty paragraph, so isNodeEmpty returns true
+ expect(listItem.hasAttribute('data-placeholder')).toBe(true)
+ // paragraph is empty
+ expect(paragraph.hasAttribute('data-placeholder')).toBe(true)
+ })
+
+ it('should support excluding multiple node types', () => {
+ editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ TaskList,
+ TaskItem,
+ Placeholder.configure({
+ placeholder: 'Type something...',
+ includeChildren: true,
+ excludedNodeTypes: ['taskList', 'taskItem', 'orderedList', 'bulletList'],
+ }),
+ ],
+ content: '',
+ })
+
+ const taskList = editor!.view.dom.querySelector('ul') as HTMLElement
+ const taskItem = editor!.view.dom.querySelector('li') as HTMLElement
+ const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
+
+ // taskList should NOT have placeholder (excluded)
+ expect(taskList.hasAttribute('data-placeholder')).toBe(false)
+ // taskItem should NOT have placeholder (excluded)
+ expect(taskItem.hasAttribute('data-placeholder')).toBe(false)
+ // paragraph should have placeholder (empty)
+ expect(paragraph.hasAttribute('data-placeholder')).toBe(true)
+ })
+
+ it('should not traverse children when includeChildren is false and node is excluded', () => {
+ editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ BulletList,
+ ListItem,
+ Placeholder.configure({
+ placeholder: 'Type something...',
+ includeChildren: false,
+ excludedNodeTypes: ['bulletList'],
+ }),
+ ],
+ content: '',
+ })
+
+ const bulletList = editor!.view.dom.querySelector('ul') as HTMLElement
+ const listItem = editor!.view.dom.querySelector('li') as HTMLElement
+
+ // bulletList should NOT have placeholder (excluded)
+ expect(bulletList.hasAttribute('data-placeholder')).toBe(false)
+ // listItem should NOT have placeholder (includeChildren is false, so children are not traversed)
+ expect(listItem.hasAttribute('data-placeholder')).toBe(false)
+ })
+})
diff --git a/packages/extensions/src/placeholder/placeholder.ts b/packages/extensions/src/placeholder/placeholder.ts
index 7620f043de..ae55a10127 100644
--- a/packages/extensions/src/placeholder/placeholder.ts
+++ b/packages/extensions/src/placeholder/placeholder.ts
@@ -87,6 +87,17 @@ export interface PlaceholderOptions {
* @default false
*/
includeChildren: boolean
+
+ /**
+ * **Node types that should not show placeholders.**
+ *
+ * This is useful for excluding wrapper nodes like lists that should not
+ * display placeholders themselves, but their child nodes should.
+ *
+ * Common examples: 'bulletList', 'orderedList', 'taskList'
+ * @default []
+ */
+ excludedNodeTypes: string[]
}
/**
@@ -106,6 +117,7 @@ export const Placeholder = Extension.create({
showOnlyWhenEditable: true,
showOnlyCurrent: true,
includeChildren: false,
+ excludedNodeTypes: [],
}
},
@@ -133,6 +145,12 @@ export const Placeholder = Extension.create({
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
const isEmpty = !node.isLeaf && isNodeEmpty(node)
+ // Skip placeholder for excluded node types
+ // This prevents duplicate placeholders in wrapper nodes like lists
+ if (this.options.excludedNodeTypes.includes(node.type.name)) {
+ return this.options.includeChildren
+ }
+
if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) {
const classes = [this.options.emptyNodeClass]