Skip to content
Open
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
148 changes: 108 additions & 40 deletions packages/extensions/src/placeholder/placeholder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Editor } from '@tiptap/core'
import { Extension, isNodeEmpty } from '@tiptap/core'
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'

Expand Down Expand Up @@ -57,9 +57,7 @@ export interface PlaceholderOptions {
* You can use a function to return a dynamic placeholder or a string.
* @default 'Write something …'
*/
placeholder:
| ((PlaceholderProps: { editor: Editor; node: ProsemirrorNode; pos: number; hasAnchor: boolean }) => string)
| string
placeholder: ((props: { editor: Editor; node: ProseMirrorNode; pos: number; hasAnchor: boolean }) => string) | string

/**
* **Checks if the placeholder should be only shown when the editor is editable.**
Expand Down Expand Up @@ -89,6 +87,108 @@ export interface PlaceholderOptions {
includeChildren: boolean
}

function nodeContainsAnchor(anchor: number, pos: number, node: ProseMirrorNode): boolean {
return anchor >= pos && anchor <= pos + node.nodeSize
}

function createPlaceholderDecoration(
editor: Editor,
options: Pick<PlaceholderOptions, 'emptyEditorClass' | 'emptyNodeClass' | 'placeholder'>,
node: ProseMirrorNode,
pos: number,
hasAnchor: boolean,
dataAttributeKey: string,
): Decoration {
const classes = [options.emptyNodeClass]
const isEmptyDoc = editor.isEmpty

if (isEmptyDoc) {
classes.push(options.emptyEditorClass)
}

return Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(' '),
[dataAttributeKey]:
typeof options.placeholder === 'function'
? options.placeholder({
editor,
node,
pos,
hasAnchor,
})
: options.placeholder,
})
}

/** Full-document scan — only used when `showOnlyCurrent` is false (decorate every empty textblock). */
function collectPlaceholdersFullScan(
doc: ProseMirrorNode,
anchor: number,
editor: Editor,
options: PlaceholderOptions,
dataAttributeKey: string,
): Decoration[] {
const decorations: Decoration[] = []

doc.descendants((node, pos) => {
const hasAnchor = nodeContainsAnchor(anchor, pos, node)
const isEmpty = !node.isLeaf && isNodeEmpty(node)

if (!node.type.isTextblock) {
return options.includeChildren
}

if (!isEmpty) {
return options.includeChildren
}

decorations.push(createPlaceholderDecoration(editor, options, node, pos, hasAnchor, dataAttributeKey))

return options.includeChildren
})

return decorations
}

/**
* When `showOnlyCurrent` is true: same decorations as a full scan would produce,
* without walking the whole document.
*/
function collectPlaceholdersShowOnlyCurrent(
doc: ProseMirrorNode,
anchor: number,
editor: Editor,
options: PlaceholderOptions,
dataAttributeKey: string,
): Decoration[] {
const $anchor = doc.resolve(anchor)

if ($anchor.depth < 1) {
return []
}

const decorations: Decoration[] = []
const maxDepth = options.includeChildren ? $anchor.depth : 1

for (let d = 1; d <= maxDepth; d += 1) {
const node = $anchor.node(d)
const pos = $anchor.before(d)

if (!node.type.isTextblock) {
continue
}

const hasAnchor = nodeContainsAnchor(anchor, pos, node)
const isEmpty = !node.isLeaf && isNodeEmpty(node)

if (hasAnchor && isEmpty) {
decorations.push(createPlaceholderDecoration(editor, options, node, pos, hasAnchor, dataAttributeKey))

@arnaugomez arnaugomez Apr 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should you return early here? If I understand it correctly, the function only returns one decoration for the current node.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn’t return early inside the loop.

The loop is over depths (1 … maxDepth). In principle more than one empty textblock on the resolved path could match (unusual schemas / nested textblocks). Stopping after the first push could drop a valid decoration, so we finish the loop.

On the API side: we collect a Decoration[] (length 0 or more) and pass it to DecorationSet.create(doc, decorations), which is what ProseMirror expects — an array of Decorations; an empty array is valid when there’s nothing to show.

Separately, the props.decorations hook may return null when the plugin is inactive (!active); that’s valid for “no decorations” and is separate from the Decoration[] passed into DecorationSet.create.

References: DecorationSet, EditorProps.decorations.

}
}

return decorations
}

/**
* This extension allows you to add a placeholder to your editor.
* A placeholder is a text that appears when the editor or a node is empty.
Expand Down Expand Up @@ -121,47 +221,15 @@ export const Placeholder = Extension.create<PlaceholderOptions>({
decorations: ({ doc, selection }) => {
const active = this.editor.isEditable || !this.options.showOnlyWhenEditable
const { anchor } = selection
const decorations: Decoration[] = []

if (!active) {
return null
}

const isEmptyDoc = this.editor.isEmpty

doc.descendants((node, pos) => {
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
const isEmpty = !node.isLeaf && isNodeEmpty(node)

if (!node.type.isTextblock) {
return this.options.includeChildren
}

if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) {
const classes = [this.options.emptyNodeClass]

if (isEmptyDoc) {
classes.push(this.options.emptyEditorClass)
}

const decoration = Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(' '),
[dataAttribute]:
typeof this.options.placeholder === 'function'
? this.options.placeholder({
editor: this.editor,
node,
pos,
hasAnchor,
})
: this.options.placeholder,
})

decorations.push(decoration)
}

return this.options.includeChildren
})
const opts = this.options
const decorations = opts.showOnlyCurrent
? collectPlaceholdersShowOnlyCurrent(doc, anchor, this.editor, opts, dataAttribute)
: collectPlaceholdersFullScan(doc, anchor, this.editor, opts, dataAttribute)

return DecorationSet.create(doc, decorations)
},
Expand Down