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
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:
1380
packages/extensions/CHANGELOG.md
Normal file
1380
packages/extensions/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extensions/README.md
Normal file
18
packages/extensions/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extensions
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extensions)
|
||||
[](https://www.npmjs.com/package/@tiptap/extensions)
|
||||
[](https://www.npmjs.com/package/@tiptap/extensions)
|
||||
[](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).
|
||||
116
packages/extensions/package.json
Normal file
116
packages/extensions/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
203
packages/extensions/src/character-count/character-count.ts
Normal file
203
packages/extensions/src/character-count/character-count.ts
Normal 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
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
1
packages/extensions/src/character-count/index.ts
Normal file
1
packages/extensions/src/character-count/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './character-count.js'
|
||||
47
packages/extensions/src/drop-cursor/drop-cursor.ts
Normal file
47
packages/extensions/src/drop-cursor/drop-cursor.ts
Normal 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)]
|
||||
},
|
||||
})
|
||||
1
packages/extensions/src/drop-cursor/index.ts
Normal file
1
packages/extensions/src/drop-cursor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './drop-cursor.js'
|
||||
110
packages/extensions/src/focus/focus.ts
Normal file
110
packages/extensions/src/focus/focus.ts
Normal 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)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
1
packages/extensions/src/focus/index.ts
Normal file
1
packages/extensions/src/focus/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './focus.js'
|
||||
47
packages/extensions/src/gap-cursor/gap-cursor.ts
Normal file
47
packages/extensions/src/gap-cursor/gap-cursor.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
1
packages/extensions/src/gap-cursor/index.ts
Normal file
1
packages/extensions/src/gap-cursor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './gap-cursor.js'
|
||||
8
packages/extensions/src/index.ts
Normal file
8
packages/extensions/src/index.ts
Normal 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'
|
||||
8
packages/extensions/src/placeholder/constants.ts
Normal file
8
packages/extensions/src/placeholder/constants.ts
Normal 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')
|
||||
4
packages/extensions/src/placeholder/index.ts
Normal file
4
packages/extensions/src/placeholder/index.ts
Normal 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'
|
||||
30
packages/extensions/src/placeholder/placeholder.ts
Normal file
30
packages/extensions/src/placeholder/placeholder.ts
Normal 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 })]
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
74
packages/extensions/src/placeholder/types.ts
Normal file
74
packages/extensions/src/placeholder/types.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
3
packages/extensions/src/placeholder/utils/index.ts
Normal file
3
packages/extensions/src/placeholder/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './buildPlaceholderDecorations.js'
|
||||
export * from './createPlaceholderDecoration.js'
|
||||
export * from './preparePlaceholderAttribute.js'
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
1
packages/extensions/src/selection/index.ts
Normal file
1
packages/extensions/src/selection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './selection.js'
|
||||
97
packages/extensions/src/selection/selection.test.ts
Normal file
97
packages/extensions/src/selection/selection.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
106
packages/extensions/src/selection/selection.ts
Normal file
106
packages/extensions/src/selection/selection.ts
Normal 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
|
||||
1
packages/extensions/src/trailing-node/index.ts
Normal file
1
packages/extensions/src/trailing-node/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './trailing-node.js'
|
||||
90
packages/extensions/src/trailing-node/trailing-node.test.ts
Normal file
90
packages/extensions/src/trailing-node/trailing-node.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
107
packages/extensions/src/trailing-node/trailing-node.ts
Normal file
107
packages/extensions/src/trailing-node/trailing-node.ts
Normal 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 })
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
1
packages/extensions/src/undo-redo/index.ts
Normal file
1
packages/extensions/src/undo-redo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './undo-redo.js'
|
||||
86
packages/extensions/src/undo-redo/undo-redo.ts
Normal file
86
packages/extensions/src/undo-redo/undo-redo.ts
Normal 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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
696
packages/extensions/tests/integration/placeholder.test.ts
Normal file
696
packages/extensions/tests/integration/placeholder.test.ts
Normal 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...')
|
||||
})
|
||||
})
|
||||
28
packages/extensions/vite.config.ts
Normal file
28
packages/extensions/vite.config.ts
Normal 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,
|
||||
})),
|
||||
})
|
||||
Reference in New Issue
Block a user