Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cool-elephants-wave.md
Original file line number Diff line number Diff line change
@@ -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`.
127 changes: 127 additions & 0 deletions packages/extensions/__tests__/placeholder.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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: '<ul><li><p></p></li></ul>',
})

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: '<ul><li><p></p></li></ul>',
})

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: '<ul data-type="taskList"><li data-type="taskItem"><p></p></li></ul>',
})

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: '<ul><li><p></p></li></ul>',
})

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)
})
})
18 changes: 18 additions & 0 deletions packages/extensions/src/placeholder/placeholder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}

/**
Expand All @@ -106,6 +117,7 @@ export const Placeholder = Extension.create<PlaceholderOptions>({
showOnlyWhenEditable: true,
showOnlyCurrent: true,
includeChildren: false,
excludedNodeTypes: [],
}
},

Expand Down Expand Up @@ -133,6 +145,12 @@ export const Placeholder = Extension.create<PlaceholderOptions>({
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]

Expand Down