import type { Editor } from '@tiptap/core' import { Extension, isNodeEmpty } from '@tiptap/core' import type { Node as ProsemirrorNode } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' /** * The default data attribute label */ const DEFAULT_DATA_ATTRIBUTE = 'placeholder' /** * Prepares the placeholder attribute by ensuring it is properly formatted. * @param attr - The placeholder attribute string. * @returns The prepared placeholder attribute string. */ export function preparePlaceholderAttribute(attr: string): string { return ( attr // replace whitespace with dashes .replace(/\s+/g, '-') // replace non-alphanumeric characters // or special chars like $, %, &, etc. // but not dashes .replace(/[^a-zA-Z0-9-]/g, '') // and replace any numeric character at the start .replace(/^[0-9-]+/, '') // and finally replace any stray, leading dashes .replace(/^-+/, '') .toLowerCase() ) } export interface PlaceholderOptions { /** * **The class name for the empty editor** * @default 'is-editor-empty' */ emptyEditorClass: string /** * **The class name for empty nodes** * @default 'is-empty' */ emptyNodeClass: string /** * **The data-attribute used for the placeholder label** * Will be prepended with `data-` and converted to kebab-case and cleaned of special characters. * @default 'placeholder' */ dataAttribute: string /** * **The placeholder content** * * 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 /** * **Checks if the placeholder should be only shown when the editor is editable.** * * If true, the placeholder will only be shown when the editor is editable. * If false, the placeholder will always be shown. * @default true */ showOnlyWhenEditable: boolean /** * **Checks if the placeholder should be only shown when the current node is empty.** * * If true, the placeholder will only be shown when the current node is empty. * If false, the placeholder will be shown when any node is empty. * @default true */ showOnlyCurrent: boolean /** * **Controls if the placeholder should be shown for all descendents.** * * If true, the placeholder will be shown for all descendents. * If false, the placeholder will only be shown for the current node. * @default false */ includeChildren: boolean } /** * 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. * @see https://www.tiptap.dev/api/extensions/placeholder */ export const Placeholder = Extension.create({ name: 'placeholder', addOptions() { return { emptyEditorClass: 'is-editor-empty', emptyNodeClass: 'is-empty', dataAttribute: DEFAULT_DATA_ATTRIBUTE, placeholder: 'Write something …', showOnlyWhenEditable: true, showOnlyCurrent: true, includeChildren: false, } }, addProseMirrorPlugins() { const dataAttribute = this.options.dataAttribute ? `data-${preparePlaceholderAttribute(this.options.dataAttribute)}` : `data-${DEFAULT_DATA_ATTRIBUTE}` return [ new Plugin({ key: new PluginKey('placeholder'), props: { 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 }) return DecorationSet.create(doc, decorations) }, }, }), ] }, })