@tiptap/extensions 3.23.6 → 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.
@@ -1,42 +1,8 @@
1
- import { Extension, isNodeEmpty } from '@tiptap/core'
2
- import { Plugin, PluginKey } from '@tiptap/pm/state'
3
- import type { Decoration } from '@tiptap/pm/view'
4
- import { DecorationSet } from '@tiptap/pm/view'
1
+ import { Extension } from '@tiptap/core'
5
2
 
3
+ import { DEFAULT_DATA_ATTRIBUTE } from './constants.js'
4
+ import { createPlaceholderPlugin } from './plugins/PlaceholderPlugin.js'
6
5
  import type { PlaceholderOptions } from './types.js'
7
- import { createPlaceholderDecoration } from './utils/createPlaceholderDecoration.js'
8
- import { findScrollParent } from './utils/findScrollParent.js'
9
- import { getViewportBoundaryPositions } from './utils/getViewportBoundaryPositions.js'
10
- import { throttle } from './utils/throttle.js'
11
-
12
- /**
13
- * The default data attribute label
14
- */
15
- const DEFAULT_DATA_ATTRIBUTE = 'placeholder'
16
-
17
- /**
18
- * Prepares the placeholder attribute by ensuring it is properly formatted.
19
- * @param attr - The placeholder attribute string.
20
- * @returns The prepared placeholder attribute string.
21
- */
22
- export function preparePlaceholderAttribute(attr: string): string {
23
- return (
24
- attr
25
- // replace whitespace with dashes
26
- .replace(/\s+/g, '-')
27
- // replace non-alphanumeric characters
28
- // or special chars like $, %, &, etc.
29
- // but not dashes
30
- .replace(/[^a-zA-Z0-9-]/g, '')
31
- // and replace any numeric character at the start
32
- .replace(/^[0-9-]+/, '')
33
- // and finally replace any stray, leading dashes
34
- .replace(/^-+/, '')
35
- .toLowerCase()
36
- )
37
- }
38
-
39
- export const PLUGIN_KEY = new PluginKey('tiptap__placeholder')
40
6
 
41
7
  /**
42
8
  * This extension allows you to add a placeholder to your editor.
@@ -59,168 +25,6 @@ export const Placeholder = Extension.create<PlaceholderOptions>({
59
25
  },
60
26
 
61
27
  addProseMirrorPlugins() {
62
- const dataAttribute = this.options.dataAttribute
63
- ? `data-${preparePlaceholderAttribute(this.options.dataAttribute)}`
64
- : `data-${DEFAULT_DATA_ATTRIBUTE}`
65
-
66
- return [
67
- new Plugin({
68
- state: {
69
- init() {
70
- return {
71
- // null means "no viewport info yet" — decoration callback falls
72
- // back to full document scan until the scroll handler fires.
73
- topPos: null as number | null,
74
- bottomPos: null as number | null,
75
- }
76
- },
77
- apply(tr, prev) {
78
- const meta = tr.getMeta(PLUGIN_KEY) as { positions?: { top: number; bottom: number } } | undefined
79
-
80
- if (meta?.positions) {
81
- return {
82
- topPos: meta.positions.top,
83
- bottomPos: meta.positions.bottom,
84
- }
85
- }
86
-
87
- if (!tr.docChanged) {
88
- return prev
89
- }
90
-
91
- // Preserve last known viewport positions across transactions.
92
- // Without this, every keystroke resets back to a full document
93
- // scan, defeating the viewport optimisation.
94
- // Only map when we have actual positions — null means "no viewport
95
- // info yet" and should stay null to fall back to full doc scan.
96
- return {
97
- topPos: prev.topPos !== null ? tr.mapping.map(prev.topPos) : null,
98
- bottomPos: prev.bottomPos !== null ? tr.mapping.map(prev.bottomPos) : null,
99
- }
100
- },
101
- },
102
- key: PLUGIN_KEY,
103
- view(view) {
104
- const scrollContainer = findScrollParent(view.dom)
105
-
106
- const computeAndDispatch = () => {
107
- const positions = getViewportBoundaryPositions({
108
- view,
109
- doc: view.state.doc,
110
- scrollContainer,
111
- })
112
-
113
- const prev = PLUGIN_KEY.getState(view.state)
114
- if (prev.topPos === positions.top && prev.bottomPos === positions.bottom) {
115
- return
116
- }
117
-
118
- const tr = view.state.tr
119
- .setMeta(PLUGIN_KEY, { positions })
120
- // Flag this transaction so the update() method can detect
121
- // it and avoid re-entrant computation.
122
- .setMeta('tiptap__viewportUpdate', true)
123
- view.dispatch(tr)
124
- }
125
-
126
- const { call: throttledUpdate, cancel: cancelThrottle } = throttle(computeAndDispatch, 250)
127
- const scrollParent = scrollContainer
128
-
129
- scrollParent.addEventListener('scroll', throttledUpdate, { passive: true })
130
-
131
- // Fire once to populate initial viewport (bypass throttle)
132
- computeAndDispatch()
133
-
134
- return {
135
- update(_, prevState) {
136
- // Skip re-entry: the dispatch inside computeAndDispatch would
137
- // trigger this update again, but the doc didn't change so the
138
- // size guard catches that. The meta flag is an extra safeguard.
139
- if (view.state.doc.content.size !== prevState.doc.content.size) {
140
- computeAndDispatch()
141
- }
142
- },
143
- destroy: () => {
144
- cancelThrottle()
145
- scrollParent.removeEventListener('scroll', throttledUpdate)
146
- },
147
- }
148
- },
149
- props: {
150
- decorations: ({ doc, selection }) => {
151
- const active = this.editor.isEditable || !this.options.showOnlyWhenEditable
152
-
153
- if (!active) {
154
- return null
155
- }
156
-
157
- const { anchor } = selection
158
- const decorations: Decoration[] = []
159
- const isEmptyDoc = this.editor.isEmpty
160
-
161
- const useResolvedPath = this.options.showOnlyCurrent && !this.options.includeChildren
162
-
163
- if (useResolvedPath) {
164
- const resolved = doc.resolve(anchor)
165
-
166
- if (resolved.depth > 0) {
167
- const node = resolved.node(1)
168
- const nodeStart = resolved.before(1)
169
-
170
- if (node.type.isTextblock && isNodeEmpty(node)) {
171
- const hasAnchor = anchor >= nodeStart && anchor <= nodeStart + node.nodeSize
172
- const decoration = createPlaceholderDecoration({
173
- node,
174
- dataAttribute,
175
- hasAnchor,
176
- placeholder: this.options.placeholder,
177
- classes: {
178
- emptyEditor: this.options.emptyEditorClass,
179
- emptyNode: this.options.emptyNodeClass,
180
- },
181
- editor: this.editor,
182
- isEmptyDoc,
183
- pos: resolved.before(1),
184
- })
185
-
186
- decorations.push(decoration)
187
- }
188
- }
189
- } else {
190
- const pluginState = PLUGIN_KEY.getState(this.editor.state)
191
- const from = pluginState.topPos ?? 0
192
- const to = pluginState.bottomPos ?? doc.content.size
193
-
194
- doc.nodesBetween(from, to, (node, pos) => {
195
- const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
196
- const isEmpty = !node.isLeaf && isNodeEmpty(node)
197
-
198
- if (!node.type.isTextblock) {
199
- return this.options.includeChildren
200
- }
201
-
202
- if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) {
203
- const decoration = createPlaceholderDecoration({
204
- classes: { emptyEditor: this.options.emptyEditorClass, emptyNode: this.options.emptyNodeClass },
205
- editor: this.editor,
206
- isEmptyDoc,
207
- dataAttribute,
208
- hasAnchor,
209
- placeholder: this.options.placeholder,
210
- node,
211
- pos,
212
- })
213
- decorations.push(decoration)
214
- }
215
-
216
- return this.options.includeChildren
217
- })
218
- }
219
-
220
- return DecorationSet.create(doc, decorations)
221
- },
222
- },
223
- }),
224
- ]
28
+ return [createPlaceholderPlugin({ editor: this.editor, options: this.options })]
225
29
  },
226
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
+ }
@@ -1,6 +1,16 @@
1
1
  import type { Editor } from '@tiptap/core'
2
2
  import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
3
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
+
4
14
  export interface PlaceholderOptions {
5
15
  /**
6
16
  * **The class name for the empty editor**
@@ -28,7 +38,12 @@ export interface PlaceholderOptions {
28
38
  * @default 'Write something …'
29
39
  */
30
40
  placeholder:
31
- | ((PlaceholderProps: { editor: Editor; node: ProsemirrorNode; pos: number; hasAnchor: boolean }) => string)
41
+ | ((PlaceholderProps: {
42
+ editor: Editor
43
+ node: ProsemirrorNode
44
+ pos: number
45
+ hasAnchor: boolean
46
+ }) => string)
32
47
  | string
33
48
 
34
49
  /**
@@ -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
+ }
@@ -1,6 +1,8 @@
1
1
  import type { Node } from '@tiptap/pm/model'
2
2
  import type { EditorView } from '@tiptap/pm/view'
3
3
 
4
+ import { VIEWPORT_OVERSCAN_PX } from '../constants.js'
5
+
4
6
  function getContainerRect(container: HTMLElement | Window): { top: number; bottom: number } {
5
7
  if (container === window) {
6
8
  return { top: 0, bottom: window.innerHeight }
@@ -19,10 +21,12 @@ export function getViewportBoundaryPositions({
19
21
  scrollContainer?: HTMLElement | Window
20
22
  }) {
21
23
  const editorRect = view.dom.getBoundingClientRect()
22
- const containerRect = scrollContainer ? getContainerRect(scrollContainer) : { top: 0, bottom: window.innerHeight }
24
+ const containerRect = scrollContainer
25
+ ? getContainerRect(scrollContainer)
26
+ : { top: 0, bottom: window.innerHeight }
23
27
 
24
- const visibleTop = Math.max(editorRect.top, containerRect.top)
25
- const visibleBottom = Math.min(editorRect.bottom, containerRect.bottom)
28
+ const visibleTop = Math.max(editorRect.top, containerRect.top) - VIEWPORT_OVERSCAN_PX
29
+ const visibleBottom = Math.min(editorRect.bottom, containerRect.bottom) + VIEWPORT_OVERSCAN_PX
26
30
 
27
31
  if (visibleTop >= visibleBottom) {
28
32
  // Editor is not visible — fall back to full document range
@@ -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
+ }
@@ -1,4 +1,7 @@
1
- export function throttle<T extends (...args: any[]) => void>(fn: T, delay: number): { call: T; cancel: () => void } {
1
+ export function throttle<T extends (...args: any[]) => void>(
2
+ fn: T,
3
+ delay: number,
4
+ ): { call: T; cancel: () => void } {
2
5
  let timer: ReturnType<typeof setTimeout> | null = null
3
6
 
4
7
  const call = ((...args: any[]) => {
@@ -0,0 +1,118 @@
1
+ import type { EditorState, PluginView, StateField, Transaction } from '@tiptap/pm/state'
2
+ import type { EditorView } from '@tiptap/pm/view'
3
+
4
+ import { PLUGIN_KEY } from '../constants.js'
5
+ import type { ViewportState } from '../types.js'
6
+ import { findScrollParent } from './findScrollParent.js'
7
+ import { getViewportBoundaryPositions } from './getViewportBoundaryPositions.js'
8
+
9
+ /**
10
+ * The plugin `state` config that tracks the visible viewport boundaries so the
11
+ * decoration callback only scans the nodes currently on screen.
12
+ */
13
+ export const viewportPluginState: StateField<ViewportState> = {
14
+ /**
15
+ * Initialises the viewport state with no known positions.
16
+ * @returns The initial viewport state.
17
+ */
18
+ init(): ViewportState {
19
+ return { topPos: null, bottomPos: null }
20
+ },
21
+
22
+ /**
23
+ * Updates the viewport state from incoming transactions.
24
+ * @param tr - The transaction being applied.
25
+ * @param prev - The previous viewport state.
26
+ * @returns The next viewport state.
27
+ */
28
+ apply(tr: Transaction, prev: ViewportState): ViewportState {
29
+ const meta = tr.getMeta(PLUGIN_KEY) as
30
+ | { positions?: { top: number; bottom: number } }
31
+ | undefined
32
+
33
+ if (meta?.positions) {
34
+ return { topPos: meta.positions.top, bottomPos: meta.positions.bottom }
35
+ }
36
+
37
+ if (!tr.docChanged) {
38
+ return prev
39
+ }
40
+
41
+ // Preserve last known viewport positions across transactions.
42
+ // Without this, every keystroke resets back to a full document
43
+ // scan, defeating the viewport optimisation.
44
+ // Only map when we have actual positions — null means "no viewport
45
+ // info yet" and should stay null to fall back to full doc scan.
46
+ return {
47
+ topPos: prev.topPos !== null ? tr.mapping.map(prev.topPos) : null,
48
+ bottomPos: prev.bottomPos !== null ? tr.mapping.map(prev.bottomPos) : null,
49
+ }
50
+ },
51
+ }
52
+
53
+ /**
54
+ * Creates the plugin `view` that recomputes the visible viewport on scroll and
55
+ * document size changes, dispatching the result into the plugin state.
56
+ * @param view - The editor view the plugin is attached to.
57
+ * @returns A plugin view with `update` and `destroy` handlers.
58
+ */
59
+ export function createViewportPluginView(view: EditorView): PluginView {
60
+ const scrollContainer = findScrollParent(view.dom)
61
+
62
+ const computeAndDispatch = () => {
63
+ const positions = getViewportBoundaryPositions({
64
+ view,
65
+ doc: view.state.doc,
66
+ scrollContainer,
67
+ })
68
+
69
+ const prev = PLUGIN_KEY.getState(view.state)
70
+ if (prev?.topPos === positions.top && prev?.bottomPos === positions.bottom) {
71
+ return
72
+ }
73
+
74
+ const tr = view.state.tr.setMeta(PLUGIN_KEY, { positions })
75
+ view.dispatch(tr)
76
+ }
77
+
78
+ // rAF-based scheduler with interval guard (150ms → ~6–7 Hz) used by
79
+ // scroll events and update(). The overscan margin hides the extra
80
+ // latency. When the guard blocks, the callback reschedules itself so
81
+ // the trailing position is always captured.
82
+ let frame: number | null = null
83
+ let lastCompute = 0
84
+ const MIN_SCROLL_INTERVAL = 150
85
+
86
+ const scheduleFrame = () => {
87
+ if (frame !== null) return
88
+ frame = requestAnimationFrame(() => {
89
+ frame = null
90
+ const now = performance.now()
91
+ if (now - lastCompute >= MIN_SCROLL_INTERVAL) {
92
+ lastCompute = now
93
+ computeAndDispatch()
94
+ } else {
95
+ scheduleFrame()
96
+ }
97
+ })
98
+ }
99
+
100
+ scrollContainer.addEventListener('scroll', scheduleFrame, { passive: true })
101
+
102
+ // Fire once to populate initial viewport
103
+ computeAndDispatch()
104
+
105
+ return {
106
+ update(_view: EditorView, prevState: EditorState) {
107
+ if (view.state.doc.content.size !== prevState.doc.content.size) {
108
+ scheduleFrame()
109
+ }
110
+ },
111
+ destroy: () => {
112
+ if (frame !== null) {
113
+ cancelAnimationFrame(frame)
114
+ }
115
+ scrollContainer.removeEventListener('scroll', scheduleFrame)
116
+ },
117
+ }
118
+ }
@@ -4,7 +4,13 @@ import { Plugin, PluginKey } from '@tiptap/pm/state'
4
4
 
5
5
  export const skipTrailingNodeMeta = 'skipTrailingNode'
6
6
 
7
- function nodeEqualsType({ types, node }: { types: NodeType | NodeType[]; node: Node | null | undefined }) {
7
+ function nodeEqualsType({
8
+ types,
9
+ node,
10
+ }: {
11
+ types: NodeType | NodeType[]
12
+ node: Node | null | undefined
13
+ }) {
8
14
  return (node && Array.isArray(types) && types.includes(node.type)) || node?.type === types
9
15
  }
10
16
 
@@ -46,7 +52,9 @@ export const TrailingNode = Extension.create<TrailingNodeOptions>({
46
52
  addProseMirrorPlugins() {
47
53
  const plugin = new PluginKey(this.name)
48
54
  const defaultNode =
49
- this.options.node || this.editor.schema.topNodeType.contentMatch.defaultType?.name || 'paragraph'
55
+ this.options.node ||
56
+ this.editor.schema.topNodeType.contentMatch.defaultType?.name ||
57
+ 'paragraph'
50
58
 
51
59
  const disabledNodes = Object.entries(this.editor.schema.nodes)
52
60
  .map(([, value]) => value)