snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extensions
[![Version](https://img.shields.io/npm/v/@tiptap/extensions.svg?label=version)](https://www.npmjs.com/package/@tiptap/extensions)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extensions.svg)](https://www.npmjs.com/package/@tiptap/extensions)
[![License](https://img.shields.io/npm/l/@tiptap/extensions.svg)](https://www.npmjs.com/package/@tiptap/extensions)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,116 @@
{
"name": "@tiptap/extensions",
"version": "3.30.3",
"description": "various extensions for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./character-count": {
"types": {
"import": "./dist/character-count/index.d.ts",
"require": "./dist/character-count/index.d.cts"
},
"import": "./dist/character-count/index.js",
"require": "./dist/character-count/index.cjs"
},
"./drop-cursor": {
"types": {
"import": "./dist/drop-cursor/index.d.ts",
"require": "./dist/drop-cursor/index.d.cts"
},
"import": "./dist/drop-cursor/index.js",
"require": "./dist/drop-cursor/index.cjs"
},
"./focus": {
"types": {
"import": "./dist/focus/index.d.ts",
"require": "./dist/focus/index.d.cts"
},
"import": "./dist/focus/index.js",
"require": "./dist/focus/index.cjs"
},
"./gap-cursor": {
"types": {
"import": "./dist/gap-cursor/index.d.ts",
"require": "./dist/gap-cursor/index.d.cts"
},
"import": "./dist/gap-cursor/index.js",
"require": "./dist/gap-cursor/index.cjs"
},
"./undo-redo": {
"types": {
"import": "./dist/undo-redo/index.d.ts",
"require": "./dist/undo-redo/index.d.cts"
},
"import": "./dist/undo-redo/index.js",
"require": "./dist/undo-redo/index.cjs"
},
"./placeholder": {
"types": {
"import": "./dist/placeholder/index.d.ts",
"require": "./dist/placeholder/index.d.cts"
},
"import": "./dist/placeholder/index.js",
"require": "./dist/placeholder/index.cjs"
},
"./selection": {
"types": {
"import": "./dist/selection/index.d.ts",
"require": "./dist/selection/index.d.cts"
},
"import": "./dist/selection/index.js",
"require": "./dist/selection/index.cjs"
},
"./trailing-node": {
"types": {
"import": "./dist/trailing-node/index.d.ts",
"require": "./dist/trailing-node/index.d.cts"
},
"import": "./dist/trailing-node/index.js",
"require": "./dist/trailing-node/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/pm": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1,203 @@
import { Extension } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export interface CharacterCountOptions {
/**
* The maximum number of characters that should be allowed. Defaults to `0`.
* @default null
* @example 180
*/
limit: number | null | undefined
/**
* The mode by which the size is calculated. If set to `textSize`, the textContent of the document is used.
* If set to `nodeSize`, the nodeSize of the document is used.
* @default 'textSize'
* @example 'textSize'
*/
mode: 'textSize' | 'nodeSize'
/**
* Sets whether the content will be automatically trimmed when programatically setting content over the limit.
* If set to false, the user will be able to trim the text manually.
* @default true
* @example false
*/
autoTrim?: boolean
/**
* The text counter function to use. Defaults to a simple character count.
* @default (text) => text.length
* @example (text) => [...new Intl.Segmenter().segment(text)].length
*/
textCounter: (text: string) => number
/**
* The word counter function to use. Defaults to a simple word count.
* @default (text) => text.split(' ').filter(word => word !== '').length
* @example (text) => text.split(/\s+/).filter(word => word !== '').length
*/
wordCounter: (text: string) => number
}
export interface CharacterCountStorage {
/**
* Get the number of characters for the current document.
* @param options The options for the character count. (optional)
* @param options.node The node to get the characters from. Defaults to the current document.
* @param options.mode The mode by which the size is calculated. If set to `textSize`, the textContent of the document is used.
*/
characters: (options?: { node?: PMNode; mode?: 'textSize' | 'nodeSize' }) => number
/**
* Get the number of words for the current document.
* @param options The options for the character count. (optional)
* @param options.node The node to get the words from. Defaults to the current document.
*/
words: (options?: { node?: PMNode }) => number
}
declare module '@tiptap/core' {
interface Storage {
characterCount: CharacterCountStorage
}
}
/**
* This extension allows you to count the characters and words of your document.
* @see https://tiptap.dev/api/extensions/character-count
*/
export const CharacterCount = Extension.create<CharacterCountOptions, CharacterCountStorage>({
name: 'characterCount',
addOptions() {
return {
limit: null,
autoTrim: true,
mode: 'textSize',
textCounter: text => text.length,
wordCounter: text => text.split(' ').filter(word => word !== '').length,
}
},
addStorage() {
return {
characters: () => 0,
words: () => 0,
}
},
onBeforeCreate() {
this.storage.characters = options => {
const node = options?.node || this.editor.state.doc
const mode = options?.mode || this.options.mode
if (mode === 'textSize') {
const text = node.textBetween(0, node.content.size, undefined, ' ')
return this.options.textCounter(text)
}
return node.nodeSize
}
this.storage.words = options => {
const node = options?.node || this.editor.state.doc
const text = node.textBetween(0, node.content.size, ' ', ' ')
return this.options.wordCounter(text)
}
},
addProseMirrorPlugins() {
let initialEvaluationDone = false
return [
new Plugin({
key: new PluginKey('characterCount'),
appendTransaction: (transactions, oldState, newState) => {
if (initialEvaluationDone) {
return
}
const limit = this.options.limit
const autoTrim = this.options.autoTrim
if (limit === null || limit === undefined || limit === 0 || autoTrim === false) {
initialEvaluationDone = true
return
}
const initialContentSize = this.storage.characters({ node: newState.doc })
if (initialContentSize > limit) {
const over = initialContentSize - limit
const from = 0
const to = over
console.warn(
`[CharacterCount] Initial content exceeded limit of ${limit} characters. Content was automatically trimmed.`,
)
const tr = newState.tr.deleteRange(from, to)
initialEvaluationDone = true
return tr
}
initialEvaluationDone = true
},
filterTransaction: (transaction, state) => {
const limit = this.options.limit
// Nothing has changed or no limit is defined. Ignore it.
if (!transaction.docChanged || limit === 0 || limit === null || limit === undefined) {
return true
}
const oldSize = this.storage.characters({ node: state.doc })
const newSize = this.storage.characters({ node: transaction.doc })
// Everything is in the limit. Good.
if (newSize <= limit) {
return true
}
// The limit has already been exceeded but will be reduced.
if (oldSize > limit && newSize > limit && newSize <= oldSize) {
return true
}
// The limit has already been exceeded and will be increased further.
if (oldSize > limit && newSize > limit && newSize > oldSize) {
return false
}
const isPaste = transaction.getMeta('paste')
// Block all exceeding transactions that were not pasted.
if (!isPaste) {
return false
}
// For pasted content, we try to remove the exceeding content.
const pos = transaction.selection.$head.pos
const over = newSize - limit
const from = pos - over
const to = pos
// It’s probably a bad idea to mutate transactions within `filterTransaction`
// but for now this is working fine.
transaction.deleteRange(from, to)
// In some situations, the limit will continue to be exceeded after trimming.
// This happens e.g. when truncating within a complex node (e.g. table)
// and ProseMirror has to close this node again.
// If this is the case, we prevent the transaction completely.
const updatedSize = this.storage.characters({ node: transaction.doc })
if (updatedSize > limit) {
return false
}
return true
},
}),
]
},
})

View File

@@ -0,0 +1 @@
export * from './character-count.js'

View File

@@ -0,0 +1,47 @@
import { Extension } from '@tiptap/core'
import { dropCursor } from '@tiptap/pm/dropcursor'
export interface DropcursorOptions {
/**
* The color of the drop cursor. Use `false` to apply no color and rely only on class.
* @default 'currentColor'
* @example 'red'
*/
color?: string | false
/**
* The width of the drop cursor
* @default 1
* @example 2
*/
width: number | undefined
/**
* The class of the drop cursor
* @default undefined
* @example 'drop-cursor'
*/
class: string | undefined
}
/**
* This extension allows you to add a drop cursor to your editor.
* A drop cursor is a line that appears when you drag and drop content
* in-between nodes.
* @see https://tiptap.dev/api/extensions/dropcursor
*/
export const Dropcursor = Extension.create<DropcursorOptions>({
name: 'dropCursor',
addOptions() {
return {
color: 'currentColor',
width: 1,
class: undefined,
}
},
addProseMirrorPlugins() {
return [dropCursor(this.options)]
},
})

View File

@@ -0,0 +1 @@
export * from './drop-cursor.js'

View File

@@ -0,0 +1,110 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export interface FocusOptions {
/**
* The class name that should be added to the focused node.
* @default 'has-focus'
* @example 'is-focused'
*/
className: string
/**
* The mode by which the focused node is determined.
* - All: All nodes are marked as focused.
* - Deepest: Only the deepest node is marked as focused.
* - Shallowest: Only the shallowest node is marked as focused.
*
* @default 'all'
* @example 'deepest'
* @example 'shallowest'
*/
mode: 'all' | 'deepest' | 'shallowest'
}
/**
* This extension allows you to add a class to the focused node.
* @see https://www.tiptap.dev/api/extensions/focus
*/
export const Focus = Extension.create<FocusOptions>({
name: 'focus',
addOptions() {
return {
className: 'has-focus',
mode: 'all',
}
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('focus'),
props: {
decorations: ({ doc, selection }) => {
const { isEditable, isFocused } = this.editor
const { anchor } = selection
const decorations: Decoration[] = []
if (!isEditable || !isFocused) {
return DecorationSet.create(doc, [])
}
// Maximum Levels
let maxLevels = 0
if (this.options.mode === 'deepest') {
doc.descendants((node, pos) => {
if (node.isText) {
return
}
const isCurrent = anchor >= pos && anchor <= pos + node.nodeSize - 1
if (!isCurrent) {
return false
}
maxLevels += 1
})
}
// Loop through current
let currentLevel = 0
doc.descendants((node, pos) => {
if (node.isText) {
return false
}
const isCurrent = anchor >= pos && anchor <= pos + node.nodeSize - 1
if (!isCurrent) {
return false
}
currentLevel += 1
const outOfScope =
(this.options.mode === 'deepest' && maxLevels - currentLevel > 0) ||
(this.options.mode === 'shallowest' && currentLevel > 1)
if (outOfScope) {
return this.options.mode === 'deepest'
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: this.options.className,
}),
)
})
return DecorationSet.create(doc, decorations)
},
},
}),
]
},
})

View File

@@ -0,0 +1 @@
export * from './focus.js'

View File

@@ -0,0 +1,47 @@
import type { ParentConfig } from '@tiptap/core'
import { callOrReturn, Extension, getExtensionField } from '@tiptap/core'
import { gapCursor } from '@tiptap/pm/gapcursor'
declare module '@tiptap/core' {
interface NodeConfig<Options, Storage> {
/**
* A function to determine whether the gap cursor is allowed at the current position. Must return `true` or `false`.
* @default null
*/
allowGapCursor?:
| boolean
| null
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>['allowGapCursor']
}) => boolean | null)
}
}
/**
* This extension allows you to add a gap cursor to your editor.
* A gap cursor is a cursor that appears when you click on a place
* where no content is present, for example inbetween nodes.
* @see https://tiptap.dev/api/extensions/gapcursor
*/
export const Gapcursor = Extension.create({
name: 'gapCursor',
addProseMirrorPlugins() {
return [gapCursor()]
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
}
return {
allowGapCursor: callOrReturn(getExtensionField(extension, 'allowGapCursor', context)) ?? null,
}
},
})

View File

@@ -0,0 +1 @@
export * from './gap-cursor.js'

View File

@@ -0,0 +1,8 @@
export * from './character-count/index.js'
export * from './drop-cursor/index.js'
export * from './focus/index.js'
export * from './gap-cursor/index.js'
export * from './placeholder/index.js'
export * from './selection/index.js'
export * from './trailing-node/index.js'
export * from './undo-redo/index.js'

View File

@@ -0,0 +1,8 @@
import { PluginKey } from '@tiptap/pm/state'
import type { DecorationSet } from '@tiptap/pm/view'
/** The default data attribute label */
export const DEFAULT_DATA_ATTRIBUTE = 'placeholder'
/** The plugin key used to store and read the placeholder decoration set */
export const PLUGIN_KEY = new PluginKey<DecorationSet>('tiptap__placeholder')

View File

@@ -0,0 +1,4 @@
export { DEFAULT_DATA_ATTRIBUTE, PLUGIN_KEY } from './constants.js'
export * from './placeholder.js'
export * from './types.js'
export { preparePlaceholderAttribute } from './utils/preparePlaceholderAttribute.js'

View File

@@ -0,0 +1,30 @@
import { Extension } from '@tiptap/core'
import { DEFAULT_DATA_ATTRIBUTE } from './constants.js'
import { createPlaceholderPlugin } from './plugins/PlaceholderPlugin.js'
import type { PlaceholderOptions } from './types.js'
/**
* 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<PlaceholderOptions>({
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() {
return [createPlaceholderPlugin({ editor: this.editor, options: this.options })]
},
})

View File

@@ -0,0 +1,49 @@
import type { Editor } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
import { DecorationSet } from '@tiptap/pm/view'
import { DEFAULT_DATA_ATTRIBUTE, PLUGIN_KEY } from '../constants.js'
import type { PlaceholderOptions } from '../types.js'
import { buildPlaceholderDecorations } from '../utils/buildPlaceholderDecorations.js'
import { createPlaceholderStateField } from '../utils/placeholderStateField.js'
import { preparePlaceholderAttribute } from '../utils/preparePlaceholderAttribute.js'
export type CreatePluginOptions = {
editor: Editor
options: PlaceholderOptions
}
/**
* Creates the ProseMirror plugin that renders placeholder decorations.
* @param options.editor - The editor instance.
* @param options.options - The resolved placeholder options.
* @returns The configured placeholder plugin.
*/
export function createPlaceholderPlugin({ editor, options }: CreatePluginOptions) {
const dataAttribute = options.dataAttribute
? `data-${preparePlaceholderAttribute(options.dataAttribute)}`
: `data-${DEFAULT_DATA_ATTRIBUTE}`
const useResolvedPath = options.showOnlyCurrent && !options.includeChildren
return new Plugin({
key: PLUGIN_KEY,
...(useResolvedPath
? {}
: {
state: createPlaceholderStateField({ editor, options, dataAttribute }),
}),
props: {
decorations: useResolvedPath
? ({ doc, selection }) =>
buildPlaceholderDecorations({ editor, options, dataAttribute, doc, selection })
: state => {
if (options.showOnlyWhenEditable && !editor.isEditable) {
return DecorationSet.empty
}
return PLUGIN_KEY.getState(state) ?? DecorationSet.empty
},
},
})
}

View File

@@ -0,0 +1,74 @@
import type { Editor } from '@tiptap/core'
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
export interface PlaceholderOptions {
/**
* **The class name for the empty editor**
* @default 'is-editor-empty'
*/
emptyEditorClass: string
/**
* **The class name for empty nodes**
*
* You can use a function to return a dynamic class name or a string.
* @default 'is-empty'
*/
emptyNodeClass:
| ((EmptyNodeClassProps: {
editor: Editor
node: ProsemirrorNode
pos: number
hasAnchor: boolean
}) => string)
| 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 descendants.**
*
* If true, the placeholder will be shown for all descendants.
* If false, the placeholder will only be shown for the current node.
* @default false
*/
includeChildren: boolean
}

View File

@@ -0,0 +1,164 @@
import type { Editor } from '@tiptap/core'
import { isNodeEmpty } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import type { Selection } from '@tiptap/pm/state'
import type { Decoration } from '@tiptap/pm/view'
import { DecorationSet } from '@tiptap/pm/view'
import type { PlaceholderOptions } from '../types.js'
import { createPlaceholderDecoration } from './createPlaceholderDecoration.js'
function resolveEmptyNodeClass(
emptyNodeClass: PlaceholderOptions['emptyNodeClass'],
props: { editor: Editor; node: PMNode; pos: number; hasAnchor: boolean },
): string {
return typeof emptyNodeClass === 'function' ? emptyNodeClass(props) : emptyNodeClass
}
/**
* Scans a document range for empty textblocks that should receive placeholder
* decorations. Used by the slow path and incremental state updates.
*/
export function scanRangeForDecorations({
editor,
options,
dataAttribute,
doc,
selection,
from,
to,
}: {
editor: Editor
options: PlaceholderOptions
dataAttribute: string
doc: PMNode
selection: Selection
from: number
to: number
}): Decoration[] {
const { anchor } = selection
const decorations: Decoration[] = []
const isEmptyDoc = editor.isEmpty
doc.nodesBetween(from, to, (node, pos) => {
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
const isEmpty = !node.isLeaf && isNodeEmpty(node)
if (!node.type.isTextblock) {
return options.includeChildren
}
if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
decorations.push(
createPlaceholderDecoration({
editor,
isEmptyDoc,
dataAttribute,
hasAnchor,
placeholder: options.placeholder,
classes: {
emptyEditor: options.emptyEditorClass,
emptyNode: resolveEmptyNodeClass(options.emptyNodeClass, {
editor,
node,
pos,
hasAnchor,
}),
},
node,
pos,
}),
)
}
return options.includeChildren
})
return decorations
}
/**
* Builds the placeholder decorations for the current document state.
* @param options.editor - The editor instance.
* @param options.options - The resolved placeholder options.
* @param options.dataAttribute - The prepared `data-*` attribute name.
* @param options.doc - The current document node.
* @param options.selection - The current selection.
* @returns A decoration set, or `null` when no placeholders should be shown.
*/
export function buildPlaceholderDecorations({
editor,
options,
dataAttribute,
doc,
selection,
}: {
editor: Editor
options: PlaceholderOptions
dataAttribute: string
doc: PMNode
selection: Selection
}): DecorationSet | null {
const active = editor.isEditable || !options.showOnlyWhenEditable
if (!active) {
return null
}
const { anchor } = selection
const decorations: Decoration[] = []
const isEmptyDoc = editor.isEmpty
const useResolvedPath = options.showOnlyCurrent && !options.includeChildren
if (useResolvedPath) {
const resolved = doc.resolve(anchor)
// When the selection spans the whole document (e.g. an `AllSelection`
// after Cmd+A), the anchor resolves to the document level (depth 0). In
// that case the relevant textblock is the node directly after the
// position rather than an ancestor. otherwise the placeholder would
// disappear after selecting all and deleting.
const node = resolved.depth > 0 ? resolved.node(1) : resolved.nodeAfter
const nodeStart = resolved.depth > 0 ? resolved.before(1) : anchor
if (node && node.type.isTextblock && isNodeEmpty(node)) {
const hasAnchor = anchor >= nodeStart && anchor <= nodeStart + node.nodeSize
decorations.push(
createPlaceholderDecoration({
editor,
isEmptyDoc,
dataAttribute,
hasAnchor,
placeholder: options.placeholder,
classes: {
emptyEditor: options.emptyEditorClass,
emptyNode: resolveEmptyNodeClass(options.emptyNodeClass, {
editor,
node,
pos: nodeStart,
hasAnchor,
}),
},
node,
pos: nodeStart,
}),
)
}
} else {
decorations.push(
...scanRangeForDecorations({
editor,
options,
dataAttribute,
doc,
selection,
from: 0,
to: doc.content.size,
}),
)
}
return DecorationSet.create(doc, decorations)
}

View File

@@ -0,0 +1,61 @@
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { Decoration } from '@tiptap/pm/view'
import type { PlaceholderOptions } from '../types.js'
/**
* Creates a ProseMirror node decoration that applies a placeholder
* CSS class and data attribute to an empty node.
* @param options.editor - The editor instance
* @param options.pos - The position of the node in the document
* @param options.node - The ProseMirror node
* @param options.isEmptyDoc - Whether the entire document is empty
* @param options.hasAnchor - Whether the selection anchor is within the node
* @param options.dataAttribute - The data attribute name (e.g. `data-placeholder`)
* @param options.classes - CSS classes for empty nodes and the empty editor
* @param options.placeholder - The placeholder text or a function that returns it
* @returns A ProseMirror node decoration with placeholder classes and data attribute
*/
export function createPlaceholderDecoration(options: {
editor: Editor
pos: number
node: PMNode
isEmptyDoc: boolean
hasAnchor: boolean
dataAttribute: string
classes: {
emptyEditor: PlaceholderOptions['emptyEditorClass']
emptyNode: string
}
placeholder: PlaceholderOptions['placeholder']
}) {
const {
editor,
placeholder,
dataAttribute,
pos,
node,
isEmptyDoc,
hasAnchor,
classes: { emptyNode, emptyEditor },
} = options
const classes = [emptyNode]
if (isEmptyDoc) {
classes.push(emptyEditor)
}
return Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(' '),
[dataAttribute]:
typeof placeholder === 'function'
? placeholder({
editor,
node,
pos,
hasAnchor,
})
: placeholder,
})
}

View File

@@ -0,0 +1,3 @@
export * from './buildPlaceholderDecorations.js'
export * from './createPlaceholderDecoration.js'
export * from './preparePlaceholderAttribute.js'

View File

@@ -0,0 +1,204 @@
import type { Editor } from '@tiptap/core'
import { getChangedRanges } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import type { EditorState, StateField, Transaction } from '@tiptap/pm/state'
import type { Selection } from '@tiptap/pm/state'
import { DecorationSet } from '@tiptap/pm/view'
import type { PlaceholderOptions } from '../types.js'
import {
buildPlaceholderDecorations,
scanRangeForDecorations,
} from './buildPlaceholderDecorations.js'
import {
getTopLevelBlocksInRange,
mergeRanges,
resolveTopLevelRange,
toContentRelativeRange,
} from './resolveTopLevelRange.js'
/** Options passed to {@link createPlaceholderStateField}. */
export type CreatePlaceholderStateFieldOptions = {
editor: Editor
options: PlaceholderOptions
dataAttribute: string
}
/**
* Expands a single changed range to the top-level blocks it touches.
* Also resolves blocks at range boundaries so split/merge edits update
* adjacent empty nodes (e.g. a new paragraph after Enter).
*/
function collectBlocksForChange(
doc: PMNode,
change: { from: number; to: number },
): Array<{ from: number; to: number }> {
const ranges = getTopLevelBlocksInRange(doc, change.from, change.to)
ranges.push(toContentRelativeRange(doc, resolveTopLevelRange(doc, change.from)))
if (change.to > change.from) {
ranges.push(
toContentRelativeRange(
doc,
resolveTopLevelRange(doc, Math.min(change.to, doc.content.size + 1) - 1),
),
)
} else if (change.from < doc.content.size + 1) {
ranges.push(
toContentRelativeRange(
doc,
resolveTopLevelRange(doc, Math.min(change.from + 1, doc.content.size)),
),
)
}
return ranges
}
/**
* Collects content-relative top-level block ranges that need placeholder
* decorations recomputed after a transaction.
*/
function collectRescanRanges(
tr: Transaction,
oldState: EditorState,
newState: EditorState,
): Array<{ from: number; to: number }> {
const ranges: Array<{ from: number; to: number }> = []
if (tr.docChanged) {
const changes = getChangedRanges(tr)
for (const change of changes) {
ranges.push(...collectBlocksForChange(newState.doc, change.newRange))
}
}
if (tr.selectionSet) {
ranges.push(
toContentRelativeRange(
newState.doc,
resolveTopLevelRange(newState.doc, tr.mapping.map(oldState.selection.anchor)),
),
)
ranges.push(
toContentRelativeRange(
newState.doc,
resolveTopLevelRange(newState.doc, newState.selection.anchor),
),
)
}
return mergeRanges(ranges)
}
/** Clamps a content-relative range to `[0, doc.content.size]`. */
function clampRange(from: number, to: number, doc: PMNode): { from: number; to: number } {
const clampedFrom = Math.max(0, Math.min(from, doc.content.size))
const clampedTo = Math.max(clampedFrom, Math.min(to, doc.content.size))
return { from: clampedFrom, to: clampedTo }
}
/**
* Removes and rebuilds placeholder decorations within the given ranges.
* Only drops decorations fully contained in a range so mapped decorations
* on neighbouring blocks (e.g. at a block boundary) are kept intact.
*/
function updateDecorationsInRanges({
decorations,
ranges,
editor,
options,
dataAttribute,
doc,
selection,
}: {
decorations: DecorationSet
ranges: Array<{ from: number; to: number }>
editor: Editor
options: PlaceholderOptions
dataAttribute: string
doc: PMNode
selection: Selection
}): DecorationSet {
let next = decorations
for (const range of ranges) {
const { from, to } = clampRange(range.from, range.to, doc)
const existing = next
.find(from, to)
.filter(decoration => decoration.from >= from && decoration.to <= to)
if (existing.length) {
next = next.remove(existing)
}
const newDecos = scanRangeForDecorations({
editor,
options,
dataAttribute,
doc,
selection,
from,
to,
})
if (newDecos.length) {
next = next.add(doc, newDecos)
}
}
return next
}
/**
* Creates the incremental `StateField<DecorationSet>` used by the slow path
* (`showOnlyCurrent: false` or `includeChildren: true`).
*
* Decorations are mapped through each transaction and only recomputed for
* top-level blocks touched by document or selection changes.
* @param options.editor - The editor instance.
* @param options.options - The resolved placeholder options.
* @param options.dataAttribute - The prepared `data-*` attribute name.
* @returns A ProseMirror state field storing the placeholder decoration set.
*/
export function createPlaceholderStateField({
editor,
options,
dataAttribute,
}: CreatePlaceholderStateFieldOptions): StateField<DecorationSet> {
return {
init(_config, state: EditorState) {
const decorations = buildPlaceholderDecorations({
editor,
options,
dataAttribute,
doc: state.doc,
selection: state.selection,
})
return decorations ?? DecorationSet.empty
},
apply(tr: Transaction, prev: DecorationSet, oldState: EditorState, newState: EditorState) {
if (!tr.docChanged && !tr.selectionSet) {
return prev
}
const mapped = prev.map(tr.mapping, tr.doc)
const ranges = collectRescanRanges(tr, oldState, newState)
return updateDecorationsInRanges({
decorations: mapped,
ranges,
editor,
options,
dataAttribute,
doc: newState.doc,
selection: newState.selection,
})
},
}
}

View File

@@ -0,0 +1,21 @@
/**
* 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()
)
}

View File

@@ -0,0 +1,78 @@
import { Editor, getChangedRanges } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { getTopLevelBlocksInRange, toContentRelativeRange } from './resolveTopLevelRange.js'
function createHeadlessEditor(content: string) {
return new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content,
})
}
describe('placeholder utility: getTopLevelBlocksInRange', () => {
it('returns content-relative ranges aligned with nodesBetween positions', () => {
const editor = createHeadlessEditor('<p></p><p></p><p></p>')
const doc = editor.state.doc
const nodesBetweenPositions: number[] = []
doc.nodesBetween(0, doc.content.size, (node, pos) => {
if (node.type.name === 'paragraph') {
nodesBetweenPositions.push(pos)
}
})
const blocks = getTopLevelBlocksInRange(doc, 1, 2)
expect(blocks).toEqual([{ from: 0, to: 2 }])
expect(blocks[0]?.from).toBe(nodesBetweenPositions[0])
editor.destroy()
})
it('aligns resolveTopLevelRange + toContentRelativeRange with getTopLevelBlocksInRange', () => {
const editor = createHeadlessEditor('<p></p><p></p><p></p>')
const doc = editor.state.doc
doc.forEach((node, offset) => {
const contentRange = { from: offset, to: offset + node.nodeSize }
const absoluteRange = { from: offset + 1, to: offset + node.nodeSize + 1 }
expect(toContentRelativeRange(doc, absoluteRange)).toEqual(contentRange)
expect(getTopLevelBlocksInRange(doc, absoluteRange.from, absoluteRange.to)).toEqual([
contentRange,
])
})
editor.destroy()
})
it('collects only the touched top-level block for a single-paragraph edit', () => {
const editor = createHeadlessEditor('<p></p><p></p><p></p>')
const changes: Array<{ from: number; to: number }> = []
editor.on('transaction', ({ transaction }) => {
if (transaction.docChanged) {
for (const change of getChangedRanges(transaction)) {
changes.push(change.newRange)
}
}
})
editor.commands.insertContent('Hello')
const doc = editor.state.doc
const blocks = changes.flatMap(change => getTopLevelBlocksInRange(doc, change.from, change.to))
expect(blocks).toEqual([{ from: 0, to: 7 }])
editor.destroy()
})
})

View File

@@ -0,0 +1,93 @@
import type { Node as PMNode } from '@tiptap/pm/model'
/**
* Resolves a document position to the `[from, to)` range of its containing
* top-level block node in absolute document positions.
*/
export function resolveTopLevelRange(doc: PMNode, pos: number): { from: number; to: number } {
const resolved = doc.resolve(pos)
if (resolved.depth === 0) {
const node = resolved.nodeAfter ?? resolved.nodeBefore
if (!node) {
return { from: pos, to: pos }
}
const nodePos = resolved.nodeAfter ? pos : pos - node.nodeSize
return { from: nodePos, to: nodePos + node.nodeSize }
}
const topLevelPos = resolved.before(1)
const node = resolved.node(1)
return { from: topLevelPos, to: topLevelPos + node.nodeSize }
}
/**
* Converts an absolute document range to content-relative positions used by
* `Node#nodesBetween` and `Node#forEach` offsets.
*/
export function toContentRelativeRange(
doc: PMNode,
range: { from: number; to: number },
): { from: number; to: number } {
return {
from: Math.max(0, range.from - 1),
to: Math.min(doc.content.size, range.to - 1),
}
}
/**
* Returns the top-level block ranges that intersect a document change range.
* Input `from`/`to` are absolute positions (e.g. from `getChangedRanges`).
* Returned ranges are content-relative, matching `Node#forEach` offsets.
*/
export function getTopLevelBlocksInRange(
doc: PMNode,
from: number,
to: number,
): Array<{ from: number; to: number }> {
const ranges: Array<{ from: number; to: number }> = []
doc.forEach((node, offset) => {
const nodeStart = offset
const nodeEnd = nodeStart + node.nodeSize
const absNodeStart = nodeStart + 1
const absNodeEnd = nodeEnd + 1
if (absNodeStart < to && absNodeEnd > from) {
ranges.push({ from: nodeStart, to: nodeEnd })
}
})
return ranges
}
/**
* Sorts ranges by start position and merges overlapping or adjacent ranges.
*/
export function mergeRanges(
ranges: Array<{ from: number; to: number }>,
): Array<{ from: number; to: number }> {
if (ranges.length === 0) {
return []
}
const sorted = [...ranges].sort((a, b) => a.from - b.from)
const merged: Array<{ from: number; to: number }> = [{ ...sorted[0] }]
for (let i = 1; i < sorted.length; i += 1) {
const last = merged[merged.length - 1]
const current = sorted[i]
if (current.from <= last.to) {
last.to = Math.max(last.to, current.to)
} else {
merged.push({ ...current })
}
}
return merged
}

View File

@@ -0,0 +1 @@
export * from './selection.js'

View File

@@ -0,0 +1,97 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { Selection } from './selection.js'
describe('extension-selection', () => {
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
vi.restoreAllMocks()
})
it('clears the native selection on blur', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Selection],
content: '<p>Hello world</p>',
})
editor.commands.setTextSelection({ from: 1, to: 6 })
const button = document.createElement('button')
document.body.append(button)
editor.view.dom.blur()
button.focus()
const domSelection = window.getSelection()
expect(domSelection?.isCollapsed ?? true).toBe(true)
button.remove()
})
it('adds selection decorations when blurred with a non-empty selection', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Selection],
content: '<p>Hello world</p>',
})
editor.commands.setTextSelection({ from: 1, to: 6 })
const button = document.createElement('button')
document.body.append(button)
editor.view.dom.blur()
button.focus()
expect(editor.view.dom.querySelector('.selection')).not.toBeNull()
button.remove()
})
it('does not clear or restore the DOM selection when the editor is not editable', async () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Selection],
content: '<p>Hello world</p>',
editable: false,
})
const removeAllRanges = vi.fn()
const focusSpy = vi.spyOn(editor.view, 'focus')
vi.spyOn(window, 'getSelection').mockReturnValue({
removeAllRanges,
} as unknown as Selection)
editor.commands.setTextSelection({ from: 1, to: 6 })
const button = document.createElement('button')
document.body.append(button)
editor.view.dom.blur()
button.focus()
expect(removeAllRanges).not.toHaveBeenCalled()
editor.view.dom.focus()
await new Promise<void>(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
expect(focusSpy).not.toHaveBeenCalled()
button.remove()
})
})

View File

@@ -0,0 +1,106 @@
import { Extension, isNodeSelection, type Editor } from '@tiptap/core'
import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export type SelectionOptions = {
/**
* The class name that should be added to the selected text.
* @default 'selection'
* @example 'is-selected'
*/
className: string
}
/**
* Whether the native browser selection should be cleared on blur and restored on focus.
* Only applies to non-empty text selections in an editable editor.
*/
function shouldSyncDomSelection(state: EditorState, editor: Editor): boolean {
return !state.selection.empty && !isNodeSelection(state.selection) && editor.isEditable
}
/**
* Whether the selection decoration should be rendered to keep the selection
* visible while the editor is blurred (and not dragging).
*/
function shouldPreserveSelection(state: EditorState, editor: Editor): boolean {
return shouldSyncDomSelection(state, editor) && !editor.isFocused && !editor.view.dragging
}
function clearDomSelection() {
window.getSelection()?.removeAllRanges()
}
/**
* Sync the native selection from the editor state.
* @see https://prosemirror.net/docs/ref/#view.EditorView.focus
*/
function restoreDomSelection(view: EditorView) {
view.focus()
}
/**
* This extension allows you to add a class to the selected text when the editor is blurred.
* It clears the native browser selection on blur (so `::selection` styles do not overlap the
* decoration) and restores it when the editor is focused again.
* @see https://www.tiptap.dev/api/extensions/selection
*/
export const Selection = Extension.create<SelectionOptions>({
name: 'selection',
addOptions() {
return {
className: 'selection',
}
},
addProseMirrorPlugins() {
const { editor, options } = this
return [
new Plugin({
key: new PluginKey('selection'),
props: {
decorations(state) {
if (!shouldPreserveSelection(state, editor)) {
return null
}
return DecorationSet.create(state.doc, [
Decoration.inline(state.selection.from, state.selection.to, {
class: options.className,
}),
])
},
handleDOMEvents: {
blur(view) {
if (!shouldSyncDomSelection(view.state, editor)) {
return false
}
clearDomSelection()
return false
},
focus(view) {
if (!shouldSyncDomSelection(view.state, editor)) {
return false
}
requestAnimationFrame(() => {
if (!editor.isDestroyed && view.hasFocus()) {
restoreDomSelection(view)
}
})
return false
},
},
},
}),
]
},
})
export default Selection

View File

@@ -0,0 +1 @@
export * from './trailing-node.js'

View File

@@ -0,0 +1,90 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Heading from '@tiptap/extension-heading'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { skipTrailingNodeMeta, TrailingNode } from './trailing-node.js'
const headingDocument = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Title' }],
},
],
}
const headingWithTrailingParagraphDocument = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Title' }],
},
{
type: 'paragraph',
},
],
}
describe('extension-trailing-node', () => {
let editor: Editor | null = null
const createEditor = () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Heading, TrailingNode],
content: '<h1>Title</h1><p>End</p>',
})
}
const deleteTrailingParagraph = (meta?: { key: string; value: unknown }) => {
const headingSize = editor!.state.doc.firstChild!.nodeSize
const transaction = editor!.state.tr.delete(headingSize, editor!.state.doc.content.size)
if (meta) {
transaction.setMeta(meta.key, meta.value)
}
editor!.view.dispatch(transaction)
}
afterEach(() => {
if (editor) {
editor.destroy()
editor = null
}
})
it('adds a trailing paragraph when the document ends with a heading', () => {
createEditor()
deleteTrailingParagraph()
expect(editor!.getJSON()).toEqual(headingWithTrailingParagraphDocument)
})
it('skips trailing node insertion for unique id transactions', () => {
createEditor()
deleteTrailingParagraph({ key: '__uniqueIDTransaction', value: true })
expect(editor!.getJSON()).toEqual(headingDocument)
})
it('only skips trailing node insertion for the current transaction cycle', () => {
createEditor()
deleteTrailingParagraph({ key: skipTrailingNodeMeta, value: true })
expect(editor!.getJSON()).toEqual(headingDocument)
editor!.commands.setTextSelection(1)
expect(editor!.getJSON()).toEqual(headingWithTrailingParagraphDocument)
})
})

View File

@@ -0,0 +1,107 @@
import { Extension } from '@tiptap/core'
import type { Node as PMNode, NodeType } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export const skipTrailingNodeMeta = 'skipTrailingNode'
function nodeEqualsType({
types,
node,
}: {
types: NodeType | NodeType[]
node: PMNode | null | undefined
}) {
return (node && Array.isArray(types) && types.includes(node.type)) || node?.type === types
}
/**
* Extension based on:
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
*/
export interface TrailingNodeOptions {
/**
* The node type that should be inserted at the end of the document.
* @note the node will always be added to the `notAfter` lists to
* prevent an infinite loop.
* @default undefined
*/
node?: string
/**
* The node types after which the trailing node should not be inserted.
* @default ['paragraph']
*/
notAfter?: string | string[]
}
/**
* This extension allows you to add an extra node at the end of the document.
* @see https://www.tiptap.dev/api/extensions/trailing-node
*/
export const TrailingNode = Extension.create<TrailingNodeOptions>({
name: 'trailingNode',
addOptions() {
return {
node: undefined,
notAfter: [],
}
},
addProseMirrorPlugins() {
const plugin = new PluginKey(this.name)
const defaultNode =
this.options.node ||
this.editor.schema.topNodeType.contentMatch.defaultType?.name ||
'paragraph'
const disabledNodes = Object.entries(this.editor.schema.nodes)
.map(([, value]) => value)
.filter(node => (this.options.notAfter || []).concat(defaultNode).includes(node.name))
return [
new Plugin({
key: plugin,
appendTransaction: (transactions, __, state) => {
const { doc, tr, schema } = state
const shouldInsertNodeAtEnd = plugin.getState(state)
const endPosition = doc.content.size
const type = schema.nodes[defaultNode]
if (transactions.some(transaction => transaction.getMeta(skipTrailingNodeMeta))) {
return
}
if (!shouldInsertNodeAtEnd) {
return
}
return tr.insert(endPosition, type.create())
},
state: {
init: (_, state) => {
const lastNode = state.tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
},
apply: (tr, value) => {
if (!tr.docChanged) {
return value
}
// Ignore transactions from UniqueID extension to prevent infinite loops
// when UniqueID adds IDs to newly inserted trailing nodes
if (tr.getMeta('__uniqueIDTransaction')) {
return value
}
const lastNode = tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
},
},
}),
]
},
})

View File

@@ -0,0 +1 @@
export * from './undo-redo.js'

View File

@@ -0,0 +1,86 @@
import { Extension } from '@tiptap/core'
import { history, redo, undo } from '@tiptap/pm/history'
export interface UndoRedoOptions {
/**
* The amount of history events that are collected before the oldest events are discarded.
* @default 100
* @example 50
*/
depth: number
/**
* The delay (in milliseconds) between changes after which a new group should be started.
* @default 500
* @example 1000
*/
newGroupDelay: number
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
undoRedo: {
/**
* Undo recent changes
* @example editor.commands.undo()
*/
undo: () => ReturnType
/**
* Reapply reverted changes
* @example editor.commands.redo()
*/
redo: () => ReturnType
}
}
}
/**
* This extension allows you to undo and redo recent changes.
* @see https://www.tiptap.dev/api/extensions/undo-redo
*
* **Important**: If the `@tiptap/extension-collaboration` package is used, make sure to remove
* the `undo-redo` extension, as it is not compatible with the `collaboration` extension.
*
* `@tiptap/extension-collaboration` uses its own history implementation.
*/
export const UndoRedo = Extension.create<UndoRedoOptions>({
name: 'undoRedo',
addOptions() {
return {
depth: 100,
newGroupDelay: 500,
}
},
addCommands() {
return {
undo:
() =>
({ state, dispatch }) => {
return undo(state, dispatch)
},
redo:
() =>
({ state, dispatch }) => {
return redo(state, dispatch)
},
}
},
addProseMirrorPlugins() {
return [history(this.options)]
},
addKeyboardShortcuts() {
return {
'Mod-z': () => this.editor.commands.undo(),
'Shift-Mod-z': () => this.editor.commands.redo(),
'Mod-y': () => this.editor.commands.redo(),
// Russian keyboard layouts
'Mod-я': () => this.editor.commands.undo(),
'Shift-Mod-я': () => this.editor.commands.redo(),
}
},
})

View File

@@ -0,0 +1,696 @@
import { Editor } from '@tiptap/core'
import BulletList from '@tiptap/extension-bullet-list'
import Document from '@tiptap/extension-document'
import { ListItem, TaskItem, TaskList } from '@tiptap/extension-list'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import {
type PlaceholderOptions,
Placeholder,
preparePlaceholderAttribute,
} from '@tiptap/extensions'
import { Node as PMNode } from '@tiptap/pm/model'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
/** Lets ProseMirror DOMObserver timers settle before teardown. */
function flushView(): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, 30)
})
}
async function destroyMountedEditor(editor: Editor | null): Promise<void> {
if (!editor) {
return
}
await flushView()
editor.destroy()
}
describe('extension-placeholder', () => {
let editor: Editor | null = null
const createEditor = (placeholderOptions: Partial<PlaceholderOptions>) => {
if (editor) {
editor.destroy()
editor = null
}
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder.configure(placeholderOptions)],
content: '<p></p>',
})
}
afterEach(async () => {
await destroyMountedEditor(editor)
editor = null
})
it('uses the default data-placeholder attribute when not passing any dataAttribute option', () => {
createEditor({
placeholder: 'Type something...',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.getAttribute('data-placeholder')).toBe('Type something...')
editor!.destroy()
})
it('falls back to the default when passing in an empty string', () => {
createEditor({
placeholder: 'Type something...',
dataAttribute: '',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.getAttribute('data-placeholder')).toBe('Type something...')
editor!.destroy()
})
it('uses a custom data-placeholder attribute when passing a dataAttribute option', () => {
createEditor({
placeholder: 'Type something...',
dataAttribute: 'my-placeholder',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.getAttribute('data-my-placeholder')).toBe('Type something...')
editor!.destroy()
})
it('auto-replaces spaces with dashes to keep a valid html element', () => {
createEditor({
placeholder: 'Type something...',
dataAttribute: 'my placeholder',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.getAttribute('data-my-placeholder')).toBe('Type something...')
editor!.destroy()
})
it('auto-repairs an invalid attribute string', () => {
const brokenAttr = '5 My br0ken $tring'
createEditor({
placeholder: 'Type something...',
dataAttribute: brokenAttr,
})
const attributeName = preparePlaceholderAttribute(brokenAttr)
expect(attributeName).toBe('my-br0ken-tring')
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.getAttribute('data-my-br0ken-tring')).toBe('Type something...')
editor!.destroy()
})
})
describe('extension-placeholder with includeChildren and wrapper nodes', () => {
let editor: Editor | null = null
afterEach(async () => {
await destroyMountedEditor(editor)
editor = null
})
it('should not show placeholder on non-textblock wrapper nodes (bulletList, listItem)', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
BulletList,
ListItem,
Placeholder.configure({
placeholder: 'Type something...',
includeChildren: true,
}),
],
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 is not a textblock, so it should NOT have a placeholder
expect(bulletList.hasAttribute('data-placeholder')).toBe(false)
// listItem is not a textblock, so it should NOT have a placeholder
expect(listItem.hasAttribute('data-placeholder')).toBe(false)
// paragraph is a textblock, so it should have a placeholder
expect(paragraph.hasAttribute('data-placeholder')).toBe(true)
})
it('should not show placeholder on non-textblock nodes for taskList structures', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
TaskList,
TaskItem,
Placeholder.configure({
placeholder: 'Type something...',
includeChildren: true,
}),
],
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 is not a textblock, so it should NOT have a placeholder
expect(taskList.hasAttribute('data-placeholder')).toBe(false)
// taskItem is not a textblock, so it should NOT have a placeholder
expect(taskItem.hasAttribute('data-placeholder')).toBe(false)
// paragraph is a textblock, so it should have a placeholder
expect(paragraph.hasAttribute('data-placeholder')).toBe(true)
})
it('should not show placeholder on non-textblock nodes and not traverse their children when includeChildren is false', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
BulletList,
ListItem,
Placeholder.configure({
placeholder: 'Type something...',
includeChildren: false,
}),
],
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 is not a textblock, so it should NOT have a placeholder
// (with the old implementation, bulletList was considered empty and would receive a placeholder)
expect(bulletList.hasAttribute('data-placeholder')).toBe(false)
// listItem and paragraph are not traversed because includeChildren is false
expect(listItem.hasAttribute('data-placeholder')).toBe(false)
expect(paragraph.hasAttribute('data-placeholder')).toBe(false)
})
})
describe('extension-placeholder: fast path (default config)', () => {
let editor: Editor | null = null
afterEach(async () => {
await destroyMountedEditor(editor)
editor = null
})
it('shows placeholder in the current empty paragraph via the fast path', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: 'Type something...',
showOnlyCurrent: true,
includeChildren: false,
}),
],
content: '<p></p>',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.getAttribute('data-placeholder')).toBe('Type something...')
expect(paragraph.classList.contains('is-empty')).toBe(true)
})
it('hides placeholder when the cursor is in a non-empty paragraph', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: 'Type something...',
showOnlyCurrent: true,
includeChildren: false,
}),
],
content: '<p>Hello</p>',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.hasAttribute('data-placeholder')).toBe(false)
expect(paragraph.classList.contains('is-empty')).toBe(false)
})
it('shows placeholder only for the current empty node when multiple paragraphs exist', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: 'Write here...',
showOnlyCurrent: true,
includeChildren: false,
}),
],
content: '<p></p><p></p>',
})
// Cursor is in the first paragraph by default (start of doc)
const paragraphs = editor!.view.dom.querySelectorAll('p')
// Only the first paragraph (where the cursor is) should have a placeholder
expect(paragraphs[0].getAttribute('data-placeholder')).toBe('Write here...')
// The second paragraph should not have a placeholder since showOnlyCurrent is true
expect(paragraphs[1].hasAttribute('data-placeholder')).toBe(false)
})
it('removes placeholder when user types into the empty paragraph', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: 'Type something...',
showOnlyCurrent: true,
includeChildren: false,
}),
],
content: '<p></p>',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.getAttribute('data-placeholder')).toBe('Type something...')
// Insert text into the paragraph
editor!.commands.insertContent('Hello')
expect(paragraph.hasAttribute('data-placeholder')).toBe(false)
expect(paragraph.classList.contains('is-empty')).toBe(false)
})
it('shows the placeholder again after selecting all and deleting the content', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: 'Type something...',
showOnlyCurrent: true,
includeChildren: false,
}),
],
content: '<p>Hello world</p>',
})
let paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.hasAttribute('data-placeholder')).toBe(false)
// Cmd+A selects the whole document (AllSelection), then delete removes
// all content, leaving a single empty paragraph.
editor!.commands.selectAll()
editor!.commands.deleteSelection()
paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(editor!.isEmpty).toBe(true)
expect(paragraph.getAttribute('data-placeholder')).toBe('Type something...')
expect(paragraph.classList.contains('is-empty')).toBe(true)
})
})
describe('extension-placeholder: slow path (showOnlyCurrent: false)', () => {
let editor: Editor | null = null
afterEach(async () => {
await destroyMountedEditor(editor)
editor = null
})
it('shows placeholder on all empty textblocks when showOnlyCurrent is false', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: 'Fill me in...',
showOnlyCurrent: false,
includeChildren: false,
}),
],
content: '<p></p><p>Content</p><p></p>',
})
const paragraphs = editor!.view.dom.querySelectorAll('p')
// First paragraph (empty) should have placeholder
expect(paragraphs[0].getAttribute('data-placeholder')).toBe('Fill me in...')
// Second paragraph (has content) should not
expect(paragraphs[1].hasAttribute('data-placeholder')).toBe(false)
// Third paragraph (empty) should have placeholder
expect(paragraphs[2].getAttribute('data-placeholder')).toBe('Fill me in...')
})
})
describe('extension-placeholder — empty editor class', () => {
let editor: Editor | null = null
afterEach(async () => {
await destroyMountedEditor(editor)
editor = null
})
it('adds is-editor-empty class when the entire document is empty', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder],
content: '<p></p>',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.classList.contains('is-editor-empty')).toBe(true)
})
it('removes is-editor-empty class when content is added', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder],
content: '<p></p>',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.classList.contains('is-editor-empty')).toBe(true)
editor!.commands.insertContent('Hello')
expect(paragraph.classList.contains('is-editor-empty')).toBe(false)
})
it('uses custom emptyEditorClass option', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
emptyEditorClass: 'my-empty-editor',
emptyNodeClass: 'my-empty-node',
}),
],
content: '<p></p>',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.classList.contains('my-empty-editor')).toBe(true)
expect(paragraph.classList.contains('my-empty-node')).toBe(true)
})
it('uses emptyNodeClass function to return dynamic class names', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
emptyNodeClass: ({ node }) => {
return node.type.name === 'paragraph' ? 'paragraph-empty' : 'other-empty'
},
}),
],
content: '<p></p>',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.classList.contains('paragraph-empty')).toBe(true)
expect(paragraph.classList.contains('other-empty')).toBe(false)
})
})
describe('extension-placeholder: incremental updates (slow path)', () => {
let editor: Editor | null = null
const slowPathConfig = {
placeholder: 'Fill me in...',
showOnlyCurrent: false,
includeChildren: true,
showOnlyWhenEditable: false,
emptyEditorClass: null as unknown as string,
emptyNodeClass: null as unknown as string,
dataAttribute: 'placeholder-text',
}
afterEach(async () => {
await destroyMountedEditor(editor)
editor = null
})
it('shows placeholder on all empty textblocks initially', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder.configure(slowPathConfig)],
content: '<p></p><p></p><p></p>',
})
const paragraphs = editor!.view.dom.querySelectorAll('p')
expect(paragraphs[0].getAttribute('data-placeholder-text')).toBe('Fill me in...')
expect(paragraphs[1].getAttribute('data-placeholder-text')).toBe('Fill me in...')
expect(paragraphs[2].getAttribute('data-placeholder-text')).toBe('Fill me in...')
})
it('updates function-based placeholder text when selection moves between empty blocks', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
...slowPathConfig,
placeholder: ({ hasAnchor }) => (hasAnchor ? 'Focused' : 'Empty'),
}),
],
content: '<p></p><p></p>',
})
const paragraphs = editor!.view.dom.querySelectorAll('p')
expect(paragraphs[0].getAttribute('data-placeholder-text')).toBe('Focused')
expect(paragraphs[1].getAttribute('data-placeholder-text')).toBe('Empty')
editor!.commands.setTextSelection(4)
expect(paragraphs[0].getAttribute('data-placeholder-text')).toBe('Empty')
expect(paragraphs[1].getAttribute('data-placeholder-text')).toBe('Focused')
})
it('removes placeholder only for the edited node when typing into one of several empty paragraphs', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder.configure(slowPathConfig)],
content: '<p></p><p></p><p></p>',
})
const paragraphs = editor!.view.dom.querySelectorAll('p')
editor!.commands.insertContent('Hello')
expect(paragraphs[0].hasAttribute('data-placeholder-text')).toBe(false)
expect(paragraphs[1].getAttribute('data-placeholder-text')).toBe('Fill me in...')
expect(paragraphs[2].getAttribute('data-placeholder-text')).toBe('Fill me in...')
})
it('preserves decorations on untouched nodes during remote-style edits', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder.configure(slowPathConfig)],
content: '<p></p><p></p><p></p><p></p><p></p>',
})
const paragraphs = editor!.view.dom.querySelectorAll('p')
editor!.commands.insertContentAt(0, 'x')
expect(paragraphs[1].getAttribute('data-placeholder-text')).toBe('Fill me in...')
expect(paragraphs[2].getAttribute('data-placeholder-text')).toBe('Fill me in...')
expect(paragraphs[3].getAttribute('data-placeholder-text')).toBe('Fill me in...')
expect(paragraphs[4].getAttribute('data-placeholder-text')).toBe('Fill me in...')
})
it('adds placeholder when a node becomes empty after deleteSelection', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder.configure(slowPathConfig)],
content: '<p>Hello</p>',
})
const paragraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(paragraph.hasAttribute('data-placeholder-text')).toBe(false)
editor!.commands.selectAll()
editor!.commands.deleteSelection()
expect(paragraph.getAttribute('data-placeholder-text')).toBe('Fill me in...')
})
it('correctly updates when a top-level node is split (Enter)', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder.configure(slowPathConfig)],
content: '<p>Hello</p>',
})
editor!.commands.setTextSelection(6)
editor!.commands.splitBlock()
const paragraphs = editor!.view.dom.querySelectorAll('p')
expect(paragraphs[0].hasAttribute('data-placeholder-text')).toBe(false)
expect(paragraphs[1].getAttribute('data-placeholder-text')).toBe('Fill me in...')
})
it('correctly updates when two top-level nodes are merged (Backspace)', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder.configure(slowPathConfig)],
content: '<p>Hello</p><p></p>',
})
const paragraphs = editor!.view.dom.querySelectorAll('p')
expect(paragraphs[0].hasAttribute('data-placeholder-text')).toBe(false)
expect(paragraphs[1].getAttribute('data-placeholder-text')).toBe('Fill me in...')
editor!.commands.setTextSelection(7)
editor!.commands.joinBackward()
const mergedParagraph = editor!.view.dom.querySelector('p') as HTMLElement
expect(mergedParagraph.hasAttribute('data-placeholder-text')).toBe(false)
})
it('does not traverse the full document on incremental update', () => {
const nodesBetweenSpy = vi.spyOn(PMNode.prototype, 'nodesBetween')
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder.configure(slowPathConfig)],
content: '<p></p>'.repeat(10),
})
const docSize = editor!.state.doc.content.size
nodesBetweenSpy.mockClear()
editor!.commands.insertContent('Hello')
expect(nodesBetweenSpy).toHaveBeenCalled()
expect(nodesBetweenSpy.mock.calls.some(([, from, to]) => from === 0 && to === docSize)).toBe(
false,
)
nodesBetweenSpy.mockRestore()
})
it('handles rapid remote edits without losing decorations on untouched nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Placeholder.configure(slowPathConfig)],
content: '<p></p><p></p><p></p><p></p><p></p>',
})
const paragraphs = editor!.view.dom.querySelectorAll('p')
for (let i = 0; i < 5; i += 1) {
editor!.commands.insertContentAt(0, 'x')
}
expect(paragraphs[1].getAttribute('data-placeholder-text')).toBe('Fill me in...')
expect(paragraphs[2].getAttribute('data-placeholder-text')).toBe('Fill me in...')
expect(paragraphs[3].getAttribute('data-placeholder-text')).toBe('Fill me in...')
expect(paragraphs[4].getAttribute('data-placeholder-text')).toBe('Fill me in...')
})
})
describe('extension-placeholder: editable state', () => {
let editor: Editor | null = null
afterEach(async () => {
await destroyMountedEditor(editor)
editor = null
})
it('returns empty decoration set when editor is not editable and showOnlyWhenEditable is true', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: 'Type something...',
showOnlyCurrent: false,
showOnlyWhenEditable: true,
}),
],
content: '<p></p><p></p>',
})
const paragraphs = editor!.view.dom.querySelectorAll('p')
expect(paragraphs[0].hasAttribute('data-placeholder')).toBe(true)
editor!.setEditable(false)
expect(paragraphs[0].hasAttribute('data-placeholder')).toBe(false)
expect(paragraphs[1].hasAttribute('data-placeholder')).toBe(false)
})
it('restores decorations when editor becomes editable again', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: 'Type something...',
showOnlyCurrent: false,
showOnlyWhenEditable: true,
}),
],
content: '<p></p><p></p>',
})
editor!.setEditable(false)
editor!.setEditable(true)
const paragraphs = editor!.view.dom.querySelectorAll('p')
expect(paragraphs[0].getAttribute('data-placeholder')).toBe('Type something...')
expect(paragraphs[1].getAttribute('data-placeholder')).toBe('Type something...')
})
})
describe('extension-placeholder: showOnlyCurrent with includeChildren', () => {
let editor: Editor | null = null
afterEach(async () => {
await destroyMountedEditor(editor)
editor = null
})
it('moves placeholder when cursor moves between empty textblocks', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: 'Write here...',
showOnlyCurrent: true,
includeChildren: true,
}),
],
content: '<p></p><p></p>',
})
const paragraphs = editor!.view.dom.querySelectorAll('p')
expect(paragraphs[0].getAttribute('data-placeholder')).toBe('Write here...')
expect(paragraphs[1].hasAttribute('data-placeholder')).toBe(false)
editor!.commands.setTextSelection(4)
expect(paragraphs[0].hasAttribute('data-placeholder')).toBe(false)
expect(paragraphs[1].getAttribute('data-placeholder')).toBe('Write here...')
})
})

View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite-plus'
import { tsupCompatibleExtensions } from '../../pack.config.mjs'
const entries = [
'src/character-count/index.ts',
'src/drop-cursor/index.ts',
'src/focus/index.ts',
'src/gap-cursor/index.ts',
'src/undo-redo/index.ts',
'src/placeholder/index.ts',
'src/selection/index.ts',
'src/trailing-node/index.ts',
'src/index.ts',
]
export default defineConfig({
pack: entries.map(entry => ({
entry: [entry],
tsconfig: '../../tsconfig.build.json',
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
dts: { sourcemap: true },
sourcemap: true,
target: 'es2019',
format: ['esm', 'cjs'],
deps: { neverBundle: [/^[^./]/] },
outExtensions: tsupCompatibleExtensions,
})),
})