@tiptap/extensions 3.23.5 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ import { PluginKey } from '@tiptap/pm/state'
2
+
3
+ import type { ViewportState } from './types.js'
4
+
5
+ /** The default data attribute label */
6
+ export const DEFAULT_DATA_ATTRIBUTE = 'placeholder'
7
+
8
+ /** The plugin key used to store and read the placeholder viewport state */
9
+ export const PLUGIN_KEY = new PluginKey<ViewportState>('tiptap__placeholder')
10
+
11
+ /**
12
+ * Extra pixels added above and below the visible viewport when computing the
13
+ * range of nodes to decorate. Decorating slightly beyond the fold means a
14
+ * node already has its placeholder before it scrolls into view, which hides
15
+ * the latency introduced by the throttled viewport recompute.
16
+ */
17
+ export const VIEWPORT_OVERSCAN_PX = 200
@@ -1 +1,4 @@
1
+ export { DEFAULT_DATA_ATTRIBUTE, PLUGIN_KEY } from './constants.js'
1
2
  export * from './placeholder.js'
3
+ export * from './types.js'
4
+ export { preparePlaceholderAttribute } from './utils/preparePlaceholderAttribute.js'
@@ -1,93 +1,8 @@
1
- import type { Editor } from '@tiptap/core'
2
- import { Extension, isNodeEmpty } from '@tiptap/core'
3
- import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
4
- import { Plugin, PluginKey } from '@tiptap/pm/state'
5
- import { Decoration, DecorationSet } from '@tiptap/pm/view'
1
+ import { Extension } from '@tiptap/core'
6
2
 
7
- /**
8
- * The default data attribute label
9
- */
10
- const DEFAULT_DATA_ATTRIBUTE = 'placeholder'
11
-
12
- /**
13
- * Prepares the placeholder attribute by ensuring it is properly formatted.
14
- * @param attr - The placeholder attribute string.
15
- * @returns The prepared placeholder attribute string.
16
- */
17
- export function preparePlaceholderAttribute(attr: string): string {
18
- return (
19
- attr
20
- // replace whitespace with dashes
21
- .replace(/\s+/g, '-')
22
- // replace non-alphanumeric characters
23
- // or special chars like $, %, &, etc.
24
- // but not dashes
25
- .replace(/[^a-zA-Z0-9-]/g, '')
26
- // and replace any numeric character at the start
27
- .replace(/^[0-9-]+/, '')
28
- // and finally replace any stray, leading dashes
29
- .replace(/^-+/, '')
30
- .toLowerCase()
31
- )
32
- }
33
-
34
- export interface PlaceholderOptions {
35
- /**
36
- * **The class name for the empty editor**
37
- * @default 'is-editor-empty'
38
- */
39
- emptyEditorClass: string
40
-
41
- /**
42
- * **The class name for empty nodes**
43
- * @default 'is-empty'
44
- */
45
- emptyNodeClass: string
46
-
47
- /**
48
- * **The data-attribute used for the placeholder label**
49
- * Will be prepended with `data-` and converted to kebab-case and cleaned of special characters.
50
- * @default 'placeholder'
51
- */
52
- dataAttribute: string
53
-
54
- /**
55
- * **The placeholder content**
56
- *
57
- * You can use a function to return a dynamic placeholder or a string.
58
- * @default 'Write something …'
59
- */
60
- placeholder:
61
- | ((PlaceholderProps: { editor: Editor; node: ProsemirrorNode; pos: number; hasAnchor: boolean }) => string)
62
- | string
63
-
64
- /**
65
- * **Checks if the placeholder should be only shown when the editor is editable.**
66
- *
67
- * If true, the placeholder will only be shown when the editor is editable.
68
- * If false, the placeholder will always be shown.
69
- * @default true
70
- */
71
- showOnlyWhenEditable: boolean
72
-
73
- /**
74
- * **Checks if the placeholder should be only shown when the current node is empty.**
75
- *
76
- * If true, the placeholder will only be shown when the current node is empty.
77
- * If false, the placeholder will be shown when any node is empty.
78
- * @default true
79
- */
80
- showOnlyCurrent: boolean
81
-
82
- /**
83
- * **Controls if the placeholder should be shown for all descendents.**
84
- *
85
- * If true, the placeholder will be shown for all descendents.
86
- * If false, the placeholder will only be shown for the current node.
87
- * @default false
88
- */
89
- includeChildren: boolean
90
- }
3
+ import { DEFAULT_DATA_ATTRIBUTE } from './constants.js'
4
+ import { createPlaceholderPlugin } from './plugins/PlaceholderPlugin.js'
5
+ import type { PlaceholderOptions } from './types.js'
91
6
 
92
7
  /**
93
8
  * This extension allows you to add a placeholder to your editor.
@@ -110,63 +25,6 @@ export const Placeholder = Extension.create<PlaceholderOptions>({
110
25
  },
111
26
 
112
27
  addProseMirrorPlugins() {
113
- const dataAttribute = this.options.dataAttribute
114
- ? `data-${preparePlaceholderAttribute(this.options.dataAttribute)}`
115
- : `data-${DEFAULT_DATA_ATTRIBUTE}`
116
-
117
- return [
118
- new Plugin({
119
- key: new PluginKey('placeholder'),
120
- props: {
121
- decorations: ({ doc, selection }) => {
122
- const active = this.editor.isEditable || !this.options.showOnlyWhenEditable
123
- const { anchor } = selection
124
- const decorations: Decoration[] = []
125
-
126
- if (!active) {
127
- return null
128
- }
129
-
130
- const isEmptyDoc = this.editor.isEmpty
131
-
132
- doc.descendants((node, pos) => {
133
- const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
134
- const isEmpty = !node.isLeaf && isNodeEmpty(node)
135
-
136
- if (!node.type.isTextblock) {
137
- return this.options.includeChildren
138
- }
139
-
140
- if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) {
141
- const classes = [this.options.emptyNodeClass]
142
-
143
- if (isEmptyDoc) {
144
- classes.push(this.options.emptyEditorClass)
145
- }
146
-
147
- const decoration = Decoration.node(pos, pos + node.nodeSize, {
148
- class: classes.join(' '),
149
- [dataAttribute]:
150
- typeof this.options.placeholder === 'function'
151
- ? this.options.placeholder({
152
- editor: this.editor,
153
- node,
154
- pos,
155
- hasAnchor,
156
- })
157
- : this.options.placeholder,
158
- })
159
-
160
- decorations.push(decoration)
161
- }
162
-
163
- return this.options.includeChildren
164
- })
165
-
166
- return DecorationSet.create(doc, decorations)
167
- },
168
- },
169
- }),
170
- ]
28
+ return [createPlaceholderPlugin({ editor: this.editor, options: this.options })]
171
29
  },
172
30
  })
@@ -0,0 +1,35 @@
1
+ import type { Editor } from '@tiptap/core'
2
+ import { Plugin } from '@tiptap/pm/state'
3
+
4
+ import { DEFAULT_DATA_ATTRIBUTE, PLUGIN_KEY } from '../constants.js'
5
+ import type { PlaceholderOptions } from '../types.js'
6
+ import { buildPlaceholderDecorations } from '../utils/buildPlaceholderDecorations.js'
7
+ import { preparePlaceholderAttribute } from '../utils/preparePlaceholderAttribute.js'
8
+ import { createViewportPluginView, viewportPluginState } from '../utils/viewportTracking.js'
9
+
10
+ export type CreatePluginOptions = {
11
+ editor: Editor
12
+ options: PlaceholderOptions
13
+ }
14
+
15
+ /**
16
+ * Creates the ProseMirror plugin that renders placeholder decorations.
17
+ * @param options.editor - The editor instance.
18
+ * @param options.options - The resolved placeholder options.
19
+ * @returns The configured placeholder plugin.
20
+ */
21
+ export function createPlaceholderPlugin({ editor, options }: CreatePluginOptions) {
22
+ const dataAttribute = options.dataAttribute
23
+ ? `data-${preparePlaceholderAttribute(options.dataAttribute)}`
24
+ : `data-${DEFAULT_DATA_ATTRIBUTE}`
25
+
26
+ return new Plugin({
27
+ key: PLUGIN_KEY,
28
+ state: viewportPluginState,
29
+ view: createViewportPluginView,
30
+ props: {
31
+ decorations: ({ doc, selection }) =>
32
+ buildPlaceholderDecorations({ editor, options, dataAttribute, doc, selection }),
33
+ },
34
+ })
35
+ }
@@ -0,0 +1,75 @@
1
+ import type { Editor } from '@tiptap/core'
2
+ import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
3
+
4
+ /**
5
+ * The viewport positions tracked by the placeholder plugin.
6
+ * `null` means "no viewport info yet" — the decoration callback falls back to
7
+ * a full document scan until the scroll handler fires.
8
+ */
9
+ export interface ViewportState {
10
+ topPos: number | null
11
+ bottomPos: number | null
12
+ }
13
+
14
+ export interface PlaceholderOptions {
15
+ /**
16
+ * **The class name for the empty editor**
17
+ * @default 'is-editor-empty'
18
+ */
19
+ emptyEditorClass: string
20
+
21
+ /**
22
+ * **The class name for empty nodes**
23
+ * @default 'is-empty'
24
+ */
25
+ emptyNodeClass: string
26
+
27
+ /**
28
+ * **The data-attribute used for the placeholder label**
29
+ * Will be prepended with `data-` and converted to kebab-case and cleaned of special characters.
30
+ * @default 'placeholder'
31
+ */
32
+ dataAttribute: string
33
+
34
+ /**
35
+ * **The placeholder content**
36
+ *
37
+ * You can use a function to return a dynamic placeholder or a string.
38
+ * @default 'Write something …'
39
+ */
40
+ placeholder:
41
+ | ((PlaceholderProps: {
42
+ editor: Editor
43
+ node: ProsemirrorNode
44
+ pos: number
45
+ hasAnchor: boolean
46
+ }) => string)
47
+ | string
48
+
49
+ /**
50
+ * **Checks if the placeholder should be only shown when the editor is editable.**
51
+ *
52
+ * If true, the placeholder will only be shown when the editor is editable.
53
+ * If false, the placeholder will always be shown.
54
+ * @default true
55
+ */
56
+ showOnlyWhenEditable: boolean
57
+
58
+ /**
59
+ * **Checks if the placeholder should be only shown when the current node is empty.**
60
+ *
61
+ * If true, the placeholder will only be shown when the current node is empty.
62
+ * If false, the placeholder will be shown when any node is empty.
63
+ * @default true
64
+ */
65
+ showOnlyCurrent: boolean
66
+
67
+ /**
68
+ * **Controls if the placeholder should be shown for all descendants.**
69
+ *
70
+ * If true, the placeholder will be shown for all descendants.
71
+ * If false, the placeholder will only be shown for the current node.
72
+ * @default false
73
+ */
74
+ includeChildren: boolean
75
+ }
@@ -0,0 +1,111 @@
1
+ import type { Editor } from '@tiptap/core'
2
+ import { isNodeEmpty } from '@tiptap/core'
3
+ import type { Node } from '@tiptap/pm/model'
4
+ import type { Selection } from '@tiptap/pm/state'
5
+ import type { Decoration } from '@tiptap/pm/view'
6
+ import { DecorationSet } from '@tiptap/pm/view'
7
+
8
+ import { PLUGIN_KEY } from '../constants.js'
9
+ import type { PlaceholderOptions } from '../types.js'
10
+ import { createPlaceholderDecoration } from './createPlaceholderDecoration.js'
11
+
12
+ /**
13
+ * Builds the placeholder decorations for the current document state.
14
+ * @param options.editor - The editor instance.
15
+ * @param options.options - The resolved placeholder options.
16
+ * @param options.dataAttribute - The prepared `data-*` attribute name.
17
+ * @param options.doc - The current document node.
18
+ * @param options.selection - The current selection.
19
+ * @returns A decoration set, or `null` when no placeholders should be shown.
20
+ */
21
+ export function buildPlaceholderDecorations({
22
+ editor,
23
+ options,
24
+ dataAttribute,
25
+ doc,
26
+ selection,
27
+ }: {
28
+ editor: Editor
29
+ options: PlaceholderOptions
30
+ dataAttribute: string
31
+ doc: Node
32
+ selection: Selection
33
+ }): DecorationSet | null {
34
+ const active = editor.isEditable || !options.showOnlyWhenEditable
35
+
36
+ if (!active) {
37
+ return null
38
+ }
39
+
40
+ const { anchor } = selection
41
+ const decorations: Decoration[] = []
42
+ const isEmptyDoc = editor.isEmpty
43
+
44
+ const classes = {
45
+ emptyEditor: options.emptyEditorClass,
46
+ emptyNode: options.emptyNodeClass,
47
+ }
48
+
49
+ const useResolvedPath = options.showOnlyCurrent && !options.includeChildren
50
+
51
+ if (useResolvedPath) {
52
+ const resolved = doc.resolve(anchor)
53
+
54
+ // When the selection spans the whole document (e.g. an `AllSelection`
55
+ // after Cmd+A), the anchor resolves to the document level (depth 0). In
56
+ // that case the relevant textblock is the node directly after the
57
+ // position rather than an ancestor. otherwise the placeholder would
58
+ // disappear after selecting all and deleting.
59
+ const node = resolved.depth > 0 ? resolved.node(1) : resolved.nodeAfter
60
+ const nodeStart = resolved.depth > 0 ? resolved.before(1) : anchor
61
+
62
+ if (node && node.type.isTextblock && isNodeEmpty(node)) {
63
+ const hasAnchor = anchor >= nodeStart && anchor <= nodeStart + node.nodeSize
64
+
65
+ decorations.push(
66
+ createPlaceholderDecoration({
67
+ editor,
68
+ isEmptyDoc,
69
+ dataAttribute,
70
+ hasAnchor,
71
+ placeholder: options.placeholder,
72
+ classes,
73
+ node,
74
+ pos: nodeStart,
75
+ }),
76
+ )
77
+ }
78
+ } else {
79
+ const pluginState = PLUGIN_KEY.getState(editor.state)
80
+ const from = pluginState?.topPos ?? 0
81
+ const to = pluginState?.bottomPos ?? doc.content.size
82
+
83
+ doc.nodesBetween(from, to, (node, pos) => {
84
+ const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
85
+ const isEmpty = !node.isLeaf && isNodeEmpty(node)
86
+
87
+ if (!node.type.isTextblock) {
88
+ return options.includeChildren
89
+ }
90
+
91
+ if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
92
+ decorations.push(
93
+ createPlaceholderDecoration({
94
+ editor,
95
+ isEmptyDoc,
96
+ dataAttribute,
97
+ hasAnchor,
98
+ placeholder: options.placeholder,
99
+ classes,
100
+ node,
101
+ pos,
102
+ }),
103
+ )
104
+ }
105
+
106
+ return options.includeChildren
107
+ })
108
+ }
109
+
110
+ return DecorationSet.create(doc, decorations)
111
+ }
@@ -0,0 +1,61 @@
1
+ import type { Editor } from '@tiptap/core'
2
+ import type { Node } from '@tiptap/pm/model'
3
+ import { Decoration } from '@tiptap/pm/view'
4
+
5
+ import type { PlaceholderOptions } from '../types.js'
6
+
7
+ /**
8
+ * Creates a ProseMirror node decoration that applies a placeholder
9
+ * CSS class and data attribute to an empty node.
10
+ * @param options.editor - The editor instance
11
+ * @param options.pos - The position of the node in the document
12
+ * @param options.node - The ProseMirror node
13
+ * @param options.isEmptyDoc - Whether the entire document is empty
14
+ * @param options.hasAnchor - Whether the selection anchor is within the node
15
+ * @param options.dataAttribute - The data attribute name (e.g. `data-placeholder`)
16
+ * @param options.classes - CSS classes for empty nodes and the empty editor
17
+ * @param options.placeholder - The placeholder text or a function that returns it
18
+ * @returns A ProseMirror node decoration with placeholder classes and data attribute
19
+ */
20
+ export function createPlaceholderDecoration(options: {
21
+ editor: Editor
22
+ pos: number
23
+ node: Node
24
+ isEmptyDoc: boolean
25
+ hasAnchor: boolean
26
+ dataAttribute: string
27
+ classes: {
28
+ emptyEditor: PlaceholderOptions['emptyEditorClass']
29
+ emptyNode: PlaceholderOptions['emptyNodeClass']
30
+ }
31
+ placeholder: PlaceholderOptions['placeholder']
32
+ }) {
33
+ const {
34
+ editor,
35
+ placeholder,
36
+ dataAttribute,
37
+ pos,
38
+ node,
39
+ isEmptyDoc,
40
+ hasAnchor,
41
+ classes: { emptyNode, emptyEditor },
42
+ } = options
43
+ const classes = [emptyNode]
44
+
45
+ if (isEmptyDoc) {
46
+ classes.push(emptyEditor)
47
+ }
48
+
49
+ return Decoration.node(pos, pos + node.nodeSize, {
50
+ class: classes.join(' '),
51
+ [dataAttribute]:
52
+ typeof placeholder === 'function'
53
+ ? placeholder({
54
+ editor,
55
+ node,
56
+ pos,
57
+ hasAnchor,
58
+ })
59
+ : placeholder,
60
+ })
61
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Checks if an element is scrollable by testing its overflow properties.
3
+ * Elements with `overflow: hidden` or `overflow: clip` are intentionally
4
+ * excluded — they clip content but don't emit scroll events.
5
+ */
6
+ function isScrollable(el: HTMLElement): boolean {
7
+ const style = getComputedStyle(el)
8
+ const overflow = `${style.overflow} ${style.overflowY} ${style.overflowX}`
9
+
10
+ return /auto|scroll|overlay/.test(overflow)
11
+ }
12
+
13
+ export function findScrollParent(element: HTMLElement): HTMLElement | Window {
14
+ let el: HTMLElement | null = element
15
+
16
+ while (el) {
17
+ if (isScrollable(el)) {
18
+ return el
19
+ }
20
+
21
+ // Check if we hit a Shadow DOM boundary. If so, jump to the shadow host
22
+ // and continue traversing the light DOM.
23
+ const parent = el.parentElement
24
+ if (!parent) {
25
+ const root = el.getRootNode()
26
+ if (root instanceof ShadowRoot) {
27
+ el = root.host as HTMLElement
28
+ continue
29
+ }
30
+
31
+ return window
32
+ }
33
+
34
+ el = parent
35
+ }
36
+
37
+ return window
38
+ }
@@ -0,0 +1,49 @@
1
+ import type { Node } from '@tiptap/pm/model'
2
+ import type { EditorView } from '@tiptap/pm/view'
3
+
4
+ import { VIEWPORT_OVERSCAN_PX } from '../constants.js'
5
+
6
+ function getContainerRect(container: HTMLElement | Window): { top: number; bottom: number } {
7
+ if (container === window) {
8
+ return { top: 0, bottom: window.innerHeight }
9
+ }
10
+
11
+ return (container as HTMLElement).getBoundingClientRect()
12
+ }
13
+
14
+ export function getViewportBoundaryPositions({
15
+ doc,
16
+ view,
17
+ scrollContainer,
18
+ }: {
19
+ doc: Node
20
+ view: EditorView
21
+ scrollContainer?: HTMLElement | Window
22
+ }) {
23
+ const editorRect = view.dom.getBoundingClientRect()
24
+ const containerRect = scrollContainer
25
+ ? getContainerRect(scrollContainer)
26
+ : { top: 0, bottom: window.innerHeight }
27
+
28
+ const visibleTop = Math.max(editorRect.top, containerRect.top) - VIEWPORT_OVERSCAN_PX
29
+ const visibleBottom = Math.min(editorRect.bottom, containerRect.bottom) + VIEWPORT_OVERSCAN_PX
30
+
31
+ if (visibleTop >= visibleBottom) {
32
+ // Editor is not visible — fall back to full document range
33
+ return { top: 0, bottom: doc.content.size }
34
+ }
35
+
36
+ // Pick the x-coordinate based on text direction. In LTR the content
37
+ // starts at the left edge; in RTL it starts at the right edge.
38
+ // Clamp to ensure the coordinate stays inside the editor bounds.
39
+ const isRTL = getComputedStyle(view.dom).direction === 'rtl'
40
+ const x = isRTL ? Math.max(editorRect.right - 2, editorRect.left + 2) : editorRect.left + 2
41
+
42
+ const topPos = view.posAtCoords({ left: x, top: visibleTop + 2 })
43
+ const bottomPos = view.posAtCoords({ left: x, top: visibleBottom - 2 })
44
+
45
+ return {
46
+ top: topPos ? topPos.pos : 0,
47
+ bottom: bottomPos ? bottomPos.pos : doc.content.size,
48
+ }
49
+ }
@@ -0,0 +1,6 @@
1
+ export * from './buildPlaceholderDecorations.js'
2
+ export * from './createPlaceholderDecoration.js'
3
+ export * from './findScrollParent.js'
4
+ export * from './getViewportBoundaryPositions.js'
5
+ export * from './preparePlaceholderAttribute.js'
6
+ export * from './viewportTracking.js'
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Prepares the placeholder attribute by ensuring it is properly formatted.
3
+ * @param attr - The placeholder attribute string.
4
+ * @returns The prepared placeholder attribute string.
5
+ */
6
+ export function preparePlaceholderAttribute(attr: string): string {
7
+ return (
8
+ attr
9
+ // replace whitespace with dashes
10
+ .replace(/\s+/g, '-')
11
+ // replace non-alphanumeric characters
12
+ // or special chars like $, %, &, etc.
13
+ // but not dashes
14
+ .replace(/[^a-zA-Z0-9-]/g, '')
15
+ // and replace any numeric character at the start
16
+ .replace(/^[0-9-]+/, '')
17
+ // and finally replace any stray, leading dashes
18
+ .replace(/^-+/, '')
19
+ .toLowerCase()
20
+ )
21
+ }
@@ -0,0 +1,28 @@
1
+ export function throttle<T extends (...args: any[]) => void>(
2
+ fn: T,
3
+ delay: number,
4
+ ): { call: T; cancel: () => void } {
5
+ let timer: ReturnType<typeof setTimeout> | null = null
6
+
7
+ const call = ((...args: any[]) => {
8
+ if (timer) {
9
+ return
10
+ }
11
+
12
+ // Leading-edge: fire immediately, then prevent subsequent calls
13
+ // until the timer fires and resets.
14
+ fn(...args)
15
+ timer = setTimeout(() => {
16
+ timer = null
17
+ }, delay)
18
+ }) as T
19
+
20
+ const cancel = () => {
21
+ if (timer) {
22
+ clearTimeout(timer)
23
+ timer = null
24
+ }
25
+ }
26
+
27
+ return { call, cancel }
28
+ }