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]