diff --git a/packages/extensions/src/placeholder/placeholder.ts b/packages/extensions/src/placeholder/placeholder.ts index 3c8c483e88..06e870ba2e 100644 --- a/packages/extensions/src/placeholder/placeholder.ts +++ b/packages/extensions/src/placeholder/placeholder.ts @@ -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' @@ -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.** @@ -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, + 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)) + } + } + + 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. @@ -121,47 +221,15 @@ export const Placeholder = Extension.create({ 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) },